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

287 lines
13 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import AccountContext
import TelegramCore
import TelegramPresentationData
import ChatPresentationInterfaceState
import ChatEntityKeyboardInputNode
import EntityKeyboard
import EmojiTextAttachmentView
import TextFormat
import SwiftSignalKit
import ViewControllerComponent
import RichTextEditorUIKit
/// Hosts the Telegram emoji keyboard (`ChatEntityKeyboardInputNode`, emoji-only) as a bottom panel for
/// a `RichTextEditorView`. Unicode emoji insert as text; custom emoji insert as inline custom-emoji
/// rendered live. The editor stays first responder with the system keyboard suppressed (caret visible).
@available(iOS 13.0, *)
final class RichTextEmojiKeyboardController {
private let context: AccountContext
private weak var editor: RichTextEditorView?
private let requestLayout: () -> Void
private let stateContext = ChatEntityKeyboardInputNode.StateContext()
private var interaction: ChatEntityKeyboardInputNode.Interaction?
private var inputNode: ChatEntityKeyboardInputNode?
/// Whether the panel is currently slid into view (drives the slide-in/out animation).
private var panelPresented = false
private let dataPromise = Promise<ChatEntityKeyboardInputNode.InputData>()
private var dataDisposable: Disposable?
private var currentData: ChatEntityKeyboardInputNode.InputData?
/// Files for custom emoji the user has inserted, keyed by fileId — the editor's emoji-view-provider
/// resolves these to live `InlineStickerItemLayer` views. Seeded on open with the files of any emoji the
/// initial document already references (`seedEmojiFiles`), so a custom emoji carried in from the chat
/// composer renders without the user re-picking it, and its file survives back out (`currentEmojiFiles`).
private var emojiFiles: [Int64: TelegramMediaFile] = [:]
/// Seed the file store with custom-emoji files the initial document references (the editor `Document`
/// itself carries only fileIds). Merge rather than replace, so a file the user later picks for the same
/// fileId is not lost. Call before the editor's first layout, so the seeded emoji resolve on first render.
func seedEmojiFiles(_ files: [Int64: TelegramMediaFile]) {
for (fileId, file) in files {
self.emojiFiles[fileId] = file
}
}
/// The full file store (seeded + user-inserted), keyed by fileId — handed back so the caller can re-attach
/// the `TelegramMediaFile` to each custom-emoji run when converting the editor's fileId-only `Document`
/// back to the chat currency.
var currentEmojiFiles: [Int64: TelegramMediaFile] {
return self.emojiFiles
}
private(set) var isEmojiMode = false
init(context: AccountContext, editor: RichTextEditorView, requestLayout: @escaping () -> Void) {
self.context = context
self.editor = editor
self.requestLayout = requestLayout
self.dataPromise.set(ChatEntityKeyboardInputNode.inputData(
context: context,
chatPeerId: nil,
areCustomEmojiEnabled: true,
hasTrending: false,
hasSearch: true,
hasStickers: false,
hasGifs: false,
hideBackground: false,
maskEdge: .none,
sendGif: nil
))
self.dataDisposable = (self.dataPromise.get()
|> deliverOnMainQueue).start(next: { [weak self] data in
guard let self else { return }
self.currentData = data
if self.isEmojiMode {
self.requestLayout()
}
})
self.interaction = ChatEntityKeyboardInputNode.Interaction(
sendSticker: { _, _, _, _, _, _, _, _, _ in return false },
sendEmoji: { _, _, _ in },
sendGif: { _, _, _, _, _ in return false },
sendBotContextResultAsGif: { _, _, _, _, _, _ in return false },
editGif: { _, _ in },
updateChoosingSticker: { _ in },
switchToTextInput: { [weak self] in self?.setEmojiMode(false) },
dismissTextInput: { },
insertText: { [weak self] attributedText in self?.handleInsert(attributedText) },
backwardsDeleteText: { [weak self] in self?.editor?.deleteBackward() },
openStickerEditor: { },
presentController: { _, _ in },
presentGlobalOverlayController: { _, _ in },
getNavigationController: { return nil },
requestLayout: { [weak self] _ in self?.requestLayout() }
)
}
deinit {
self.dataDisposable?.dispose()
}
func toggle() {
self.setEmojiMode(!self.isEmojiMode)
}
func setEmojiMode(_ active: Bool) {
guard active != self.isEmojiMode else { return }
self.isEmojiMode = active
if active {
_ = self.editor?.becomeFirstResponder()
// `EmptyInputView` (ChatEntityKeyboardInputNode) suppresses the system keyboard while the
// canvas stays first responder, so the caret keeps rendering under the emoji panel.
self.editor?.customInputView = EmptyInputView()
} else {
self.editor?.customInputView = nil
}
self.requestLayout()
}
private func handleInsert(_ attributedText: NSAttributedString) {
guard attributedText.length > 0 else { return }
if let emoji = attributedText.attribute(ChatTextInputAttributes.customEmoji, at: 0, effectiveRange: nil) as? ChatTextInputTextCustomEmojiAttribute {
if let file = emoji.file {
self.emojiFiles[emoji.fileId] = file
}
let alt = attributedText.string
self.editor?.insertEmoji(id: String(emoji.fileId), altText: alt.isEmpty ? nil : alt)
} else {
self.editor?.insertText(attributedText.string)
}
}
/// Builds a fresh live custom-emoji view for the editor's emoji-view-provider. Returns nil for ids
/// that are not recorded custom-emoji fileIds (the screen falls back to its demo glyphs).
func customEmojiView(forId id: String, size: CGSize) -> (UIView & RichTextEmojiView)? {
guard let fileId = Int64(id), let file = self.emojiFiles[fileId] else { return nil }
let layer = InlineStickerItemLayer(
context: self.context,
userLocation: .other,
attemptSynchronousLoad: true,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: file),
file: file,
cache: self.context.animationCache,
renderer: self.context.animationRenderer,
placeholderColor: UIColor(white: 0.5, alpha: 0.2),
pointSize: size
)
layer.isVisibleForAnimations = true
return RichTextInlineEmojiView(frame: CGRect(origin: .zero, size: size), contentLayer: layer)
}
/// Builds/positions the emoji panel inside `container`. Returns its height (0 when not in emoji mode).
func updatePanel(container: UIView, availableSize: CGSize, environment: ViewControllerComponentContainer.Environment, transition: ComponentTransition) -> CGFloat {
guard self.isEmojiMode, let currentData = self.currentData else {
// Slide the panel down off-screen, then hide it (kept alive for reuse).
if self.panelPresented, let node = self.inputNode {
self.panelPresented = false
let offscreenFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height), size: CGSize(width: availableSize.width, height: node.view.frame.height))
transition.setFrame(view: node.view, frame: offscreenFrame, completion: { [weak self, weak node] _ in
if self?.panelPresented == false {
node?.view.isHidden = true
}
})
}
return 0.0
}
let node: ChatEntityKeyboardInputNode
let animateIn: Bool
if let existing = self.inputNode {
node = existing
animateIn = !self.panelPresented
} else {
node = ChatEntityKeyboardInputNode(
context: self.context,
currentInputData: currentData,
updatedInputData: self.dataPromise.get(),
defaultToEmojiTab: true,
opaqueTopPanelBackground: false,
useOpaqueTheme: false,
interaction: self.interaction,
chatPeerId: nil,
stateContext: self.stateContext
)
node.clipsToBounds = true
// Render the top panel (emoji-pack bar) INSIDE the node's bounds. Otherwise it defaults to an
// external PagerExternalTopPanelContainer the host must place in its hierarchy — which we don't,
// so the pack bar would be invisible (mirrors ComposePollScreen).
node.externalTopPanelContainerImpl = nil
self.inputNode = node
container.addSubview(node.view)
animateIn = true
}
node.view.isHidden = false
self.panelPresented = true
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let presentationInterfaceState = ChatPresentationInterfaceState(
chatWallpaper: .builtin(WallpaperSettings()),
theme: presentationData.theme,
preferredGlassType: .default,
strings: presentationData.strings,
dateTimeFormat: presentationData.dateTimeFormat,
nameDisplayOrder: presentationData.nameDisplayOrder,
limitsConfiguration: self.context.currentLimitsConfiguration.with { $0 },
fontSize: presentationData.chatFontSize,
bubbleCorners: presentationData.chatBubbleCorners,
accountPeerId: self.context.account.peerId,
mode: .standard(.default),
chatLocation: .peer(id: self.context.account.peerId),
subject: nil,
greetingData: nil,
pendingUnpinnedAllMessages: false,
activeGroupCallInfo: nil,
hasActiveGroupCall: false,
threadData: nil,
isGeneralThreadClosed: nil,
replyMessage: nil,
accountPeerColor: nil,
businessIntro: nil
)
let deviceMetrics = environment.deviceMetrics
let standardInputHeight = deviceMetrics.standardInputHeight(inLandscape: false)
let heightAndOverflow = node.updateLayout(
width: availableSize.width,
leftInset: environment.safeInsets.left,
rightInset: environment.safeInsets.right,
bottomInset: environment.safeInsets.bottom,
standardInputHeight: standardInputHeight,
inputHeight: standardInputHeight,
maximumHeight: availableSize.height,
inputPanelHeight: 0.0,
transition: .immediate,
interfaceState: presentationInterfaceState,
layoutMetrics: environment.metrics,
deviceMetrics: deviceMetrics,
isVisible: true,
isExpanded: false
)
let panelHeight = heightAndOverflow.0
let shownFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - panelHeight), size: CGSize(width: availableSize.width, height: panelHeight))
if animateIn {
// Start off-screen at the bottom (non-animated) so the layout transition slides it up,
// rather than animating from a fresh node's .zero frame (which looked like "from the top").
let offscreenFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height), size: shownFrame.size)
ComponentTransition.immediate.setFrame(view: node.view, frame: offscreenFrame)
}
transition.setFrame(view: node.view, frame: shownFrame)
return panelHeight
}
}
/// A plain host view for one inline custom emoji, backed by an `InlineStickerItemLayer`. Conforms to the
/// editor's `RichTextEmojiView` so the editor can keep `dynamicColor` (the template-emoji tint) synced to
/// the current text color; the layer masks-and-tints itself for a custom template emoji.
private final class RichTextInlineEmojiView: UIView, RichTextEmojiView {
private let contentLayer: InlineStickerItemLayer
var dynamicColor: UIColor? {
get { return self.contentLayer.dynamicColor }
set { self.contentLayer.dynamicColor = newValue }
}
init(frame: CGRect, contentLayer: InlineStickerItemLayer) {
self.contentLayer = contentLayer
super.init(frame: frame)
contentLayer.frame = self.bounds
self.layer.addSublayer(contentLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
self.contentLayer.frame = self.bounds
}
}