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>
241 lines
10 KiB
Swift
241 lines
10 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import ObjectiveC
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import SwiftSignalKit
|
|
import TelegramCore
|
|
import TelegramPresentationData
|
|
import AccountContext
|
|
import ChatTextInputPanelNode
|
|
import AttachmentTextInputPanelNode
|
|
import ChatTextInputActionButtonsNode
|
|
import EntityKeyboard
|
|
import GGCore
|
|
import GGHooks
|
|
|
|
/// Input panel (input_bar_editor, typing_animation, SG-F11, auto formatting, character counter).
|
|
/// Everything per layout / keystroke reads one published snapshot; nothing is written per frame
|
|
/// (the geometry editor writes on release, see GGInputBarEditor).
|
|
enum GGInputFeature {
|
|
static func register() {
|
|
GGInputBarHooks.geometry.register({
|
|
if let draft = GGInputBarEditorDraft.current {
|
|
return draft.layoutGeometry
|
|
}
|
|
return InterfaceSnapshots.shared.current.inputBarGeometry.layoutGeometry
|
|
})
|
|
|
|
GGInputPanelHooks.sendButtonSlotWidth.register({ panel, context in
|
|
return GGCharacterCounter.slotWidth(panel: panel, context: context)
|
|
})
|
|
|
|
GGInputPanelHooks.didLayout.register({ layout in
|
|
let snapshot = InterfaceSnapshots.shared.current
|
|
GGCharacterCounter.update(layout, isEnabled: snapshot.characterCounter)
|
|
// The chat panel places the toolbar under its field itself (bottomAccessory below);
|
|
// the attachment caption gets it above the field, in the panel's own coordinates.
|
|
guard let panel = layout.panel as? AttachmentTextInputPanelNode else {
|
|
return
|
|
}
|
|
if snapshot.formattingToolbar || GGFormattingToolbar.hasToolbar(panel.view) {
|
|
let presentationData = layout.context?.sharedContext.currentPresentationData.with { $0 }
|
|
guard let theme = presentationData?.theme else {
|
|
return
|
|
}
|
|
GGFormattingToolbar.update(GGFormattingToolbarRequest(hostView: panel.view, panelFrame: CGRect(origin: CGPoint(), size: CGSize(width: layout.width, height: layout.height)), isFocused: panel.isFocused, theme: theme, transition: layout.transition))
|
|
}
|
|
})
|
|
|
|
// The toolbar over a composer's top edge is outside its bounds: the composers ask for it first.
|
|
GGInputPanelHooks.overlayHitTest.register({ view, point, event in
|
|
return GGFormattingToolbar.hitTest(in: view, point: point, event: event)
|
|
})
|
|
GGInputBarHooks.toolbarHitTest.register({ view, point, event in
|
|
return GGFormattingToolbar.hitTest(in: view, point: point, event: event)
|
|
})
|
|
|
|
GGInputBarHooks.bottomAccessory.register({ request in
|
|
return GGFormattingToolbar.layoutBelow(request)
|
|
})
|
|
|
|
// The composers that are components (stories, gift message) call this point themselves.
|
|
GGInputBarHooks.formattingToolbar.register({ request in
|
|
GGFormattingToolbar.update(request)
|
|
})
|
|
|
|
GGInputPanelHooks.textDidChange.register({ panel, _ in
|
|
GGTypingAnimation.play(panel: panel)
|
|
})
|
|
|
|
// SG-G9.
|
|
GGEmojiKeyboardHooks.style.register({
|
|
return GGEmojiKeyboardStyle(emojiTabFirst: InterfaceSettings.emojiTabFirst.value, standardEmojiFirst: InterfaceSettings.standardEmojiFirst.value)
|
|
})
|
|
|
|
// Auto formatting: the whole text of a sent message / caption in one style. Last in the
|
|
// chain, after W2-3's text stages (which may change the text).
|
|
GGOutgoingHooks.textStages.register({ text, _ in
|
|
guard InterfaceSettings.autoFormatting.value, !text.text.isEmpty else {
|
|
return
|
|
}
|
|
guard let type = GGAutoFormatting.entityType(InterfaceSettings.autoFormattingStyle.value) else {
|
|
return
|
|
}
|
|
let length = (text.text as NSString).length
|
|
if text.entities.contains(where: { $0.range == 0 ..< length && $0.type == type }) {
|
|
return
|
|
}
|
|
text.entities.append(MessageTextEntity(range: 0 ..< length, type: type))
|
|
}, order: 900)
|
|
}
|
|
}
|
|
|
|
extension GGInputBarGeometry {
|
|
var layoutGeometry: GGInputBarLayoutGeometry {
|
|
return GGInputBarLayoutGeometry(
|
|
heightPadding: CGFloat(self.heightPadding),
|
|
leftMargin: CGFloat(self.leftMargin),
|
|
rightMargin: CGFloat(self.rightMargin),
|
|
bottomOffset: CGFloat(self.bottomOffset),
|
|
attachOffset: CGPoint(x: self.attachX, y: self.attachY),
|
|
accessoryOffset: CGPoint(x: self.accessoryX, y: self.accessoryY),
|
|
sendOffset: CGPoint(x: self.sendX, y: self.sendY)
|
|
)
|
|
}
|
|
}
|
|
|
|
enum GGAutoFormatting {
|
|
static let styles = ["bold", "italic", "monospace", "strikethrough", "underline", "spoiler", "quote", "code"]
|
|
|
|
static func entityType(_ style: String) -> MessageTextEntityType? {
|
|
switch style {
|
|
case "bold":
|
|
return .Bold
|
|
case "italic":
|
|
return .Italic
|
|
case "monospace":
|
|
return .Code
|
|
case "strikethrough":
|
|
return .Strikethrough
|
|
case "underline":
|
|
return .Underline
|
|
case "spoiler":
|
|
return .Spoiler
|
|
case "quote":
|
|
return .BlockQuote(isCollapsed: false)
|
|
case "code":
|
|
return .Pre(language: nil)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func title(_ style: String) -> String {
|
|
switch style {
|
|
case "bold":
|
|
return GGStrings.Interface.Input.styleBold
|
|
case "italic":
|
|
return GGStrings.Interface.Input.styleItalic
|
|
case "monospace":
|
|
return GGStrings.Interface.Input.styleMonospace
|
|
case "strikethrough":
|
|
return GGStrings.Interface.Input.styleStrikethrough
|
|
case "underline":
|
|
return GGStrings.Interface.Input.styleUnderline
|
|
case "spoiler":
|
|
return GGStrings.Interface.Input.styleSpoiler
|
|
case "quote":
|
|
return GGStrings.Interface.Input.styleQuote
|
|
default:
|
|
return GGStrings.Interface.Input.styleCode
|
|
}
|
|
}
|
|
}
|
|
|
|
private var ggCharacterCounterKey: UInt8 = 0
|
|
|
|
/// «Счётчик символов»: the length of the text from its first character (owner, build 189). While the
|
|
/// text is one line it stands right before the send button — the panel moves the emoji button left for
|
|
/// it (`GGInputPanelHooks.sendButtonSlotWidth`); past one line it goes back to the field's top-right
|
|
/// corner, above the buttons, or — when Telegram's article-editor button stands there — right before
|
|
/// that button (`GGInputPanelLayout.slot`). The font is Telegram's `Font`, so a custom font applies to it
|
|
/// as to the rest of the panel.
|
|
enum GGCharacterCounter {
|
|
/// Room on each side of the counter beyond what the panel already leaves between the glyphs.
|
|
private static let spacing: CGFloat = 3.0
|
|
|
|
static func font(baseFontSize: CGFloat) -> UIFont {
|
|
return Font.with(size: floor(baseFontSize * 13.0 / 17.0), traits: .monospacedNumbers)
|
|
}
|
|
|
|
/// The room the counter asks for in the text row, pt (0 = none).
|
|
static func slotWidth(count: Int, font: UIFont) -> CGFloat {
|
|
if count == 0 {
|
|
return 0.0
|
|
}
|
|
return ceil(("\(count)" as NSString).size(withAttributes: [.font: font]).width) + GGCharacterCounter.spacing
|
|
}
|
|
|
|
private static func baseFontSize(_ context: AccountContext?) -> CGFloat {
|
|
let presentationData = context?.sharedContext.currentPresentationData.with({ $0 })
|
|
return presentationData.flatMap { chatTextInputBaseFontSize(for: $0.chatFontSize) } ?? 17.0
|
|
}
|
|
|
|
/// GGInputPanelHooks.sendButtonSlotWidth: asked on every text measurement of the chat panel.
|
|
static func slotWidth(panel: ASDisplayNode, context: AccountContext?) -> CGFloat {
|
|
guard InterfaceSnapshots.shared.current.characterCounter, let panel = panel as? ChatTextInputPanelNode else {
|
|
return 0.0
|
|
}
|
|
return GGCharacterCounter.slotWidth(count: panel.inputTextState.inputText.string.count, font: GGCharacterCounter.font(baseFontSize: GGCharacterCounter.baseFontSize(context)))
|
|
}
|
|
|
|
static func update(_ layout: GGInputPanelLayout, isEnabled: Bool) {
|
|
let existing = objc_getAssociatedObject(layout.panel, &ggCharacterCounterKey) as? UILabel
|
|
guard isEnabled, let panel = layout.panel as? ChatTextInputPanelNode, layout.slot != .none else {
|
|
existing?.isHidden = true
|
|
return
|
|
}
|
|
let count = panel.inputTextState.inputText.string.count
|
|
guard count > 0 else {
|
|
existing?.isHidden = true
|
|
return
|
|
}
|
|
let label: UILabel
|
|
if let existing {
|
|
label = existing
|
|
} else {
|
|
label = UILabel()
|
|
label.isHidden = true
|
|
label.isUserInteractionEnabled = false
|
|
panel.view.addSubview(label)
|
|
objc_setAssociatedObject(layout.panel, &ggCharacterCounterKey, label, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
}
|
|
let font = GGCharacterCounter.font(baseFontSize: GGCharacterCounter.baseFontSize(layout.context))
|
|
if label.font != font {
|
|
label.font = font
|
|
}
|
|
if let presentationData = layout.context?.sharedContext.currentPresentationData.with({ $0 }) {
|
|
label.textColor = presentationData.theme.chat.inputPanel.panelControlColor
|
|
}
|
|
label.text = "\(count)"
|
|
let size = label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude))
|
|
let frame: CGRect
|
|
switch layout.slot {
|
|
case .none:
|
|
return
|
|
case let .beforeSendButton(rect), let .beforeCornerButton(rect):
|
|
frame = CGRect(origin: CGPoint(x: floorToScreenPixels(rect.midX - size.width * 0.5), y: floorToScreenPixels(rect.midY - size.height * 0.5)), size: size)
|
|
case let .textCorner(rect):
|
|
frame = CGRect(origin: CGPoint(x: rect.maxX - size.width - 11.0, y: rect.minY + 4.0), size: size)
|
|
}
|
|
if label.isHidden {
|
|
label.isHidden = false
|
|
label.frame = frame
|
|
} else {
|
|
layout.transition.updateFrame(view: label, frame: frame)
|
|
}
|
|
panel.view.bringSubviewToFront(label)
|
|
}
|
|
}
|