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

245 lines
13 KiB
Swift

import Foundation
import UIKit
import Display
import Postbox
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import ContextUI
import AppBundle
import UndoUI
import PeerInfoUI
import TextFormat
import ChatScheduleTimeController
import GGCore
import GGHooks
import GGFeaturesShared
/// SG-G1, «Своё меню сообщения»: Telegram's own items the user turned off are taken out of the
/// menu, and Ghostgram adds «Сохранить в Избранное», «Переслать без автора» and «Ограничить».
/// There is no «Ghostgram» submenu (owner, 2026-10-29). Everything goes through
/// `GGMessageMenuHooks` (one place at the end of the menu builder) instead of ten `if`s inside the
/// 3000-line upstream function. With the master off the menu is exactly Telegram's.
enum GGMessageMenuCustomization {
static func register() {
GGMessageMenuHooks.items.register({ context in
return GGMessageMenuCustomization.extraItems(context)
}, order: 300)
GGMessageMenuHooks.filter.register({ items, context in
return GGMessageMenuCustomization.filter(items, context: context)
}, order: 300)
}
// MARK: Filter
private static func hiddenTexts(_ context: GGMessageMenuContext) -> Set<String> {
let strings = context.presentationData.strings
var result = Set<String>()
if !GGMessagesSettings.menuShowReply.value {
result.insert(strings.Conversation_ContextMenuReply)
}
if !GGMessagesSettings.menuShowPin.value {
result.insert(strings.Conversation_Pin)
result.insert(strings.Conversation_Unpin)
}
if !GGMessagesSettings.menuShowReport.value {
result.insert(strings.Conversation_ContextMenuReport)
}
if !GGMessagesSettings.menuShowSaveMedia.value {
result.insert(strings.Gallery_SaveImage)
result.insert(strings.Gallery_SaveVideo)
}
if !GGMessagesSettings.menuShowReplies.value {
result.insert(strings.Conversation_ContextViewThread)
for message in context.messages {
for attribute in message.attributes {
if let attribute = attribute as? ReplyThreadMessageAttribute, attribute.count > 0 {
result.insert(strings.Conversation_ContextViewReplies(attribute.count))
}
}
}
}
return result
}
/// Takes the turned-off items out; a separator left at the start, at the end or next to another
/// one by that goes too.
static func filter(_ items: [ContextMenuItem], context: GGMessageMenuContext) -> [ContextMenuItem] {
guard GGMessagesSettings.customMessageMenu.value else {
return items
}
let hidden = GGMessageMenuCustomization.hiddenTexts(context)
if hidden.isEmpty {
return items
}
var kept: [ContextMenuItem] = []
var removedAny = false
for item in items {
if case let .action(action) = item, hidden.contains(action.text) {
removedAny = true
continue
}
if case .separator = item {
if kept.isEmpty {
continue
}
if case .separator? = kept.last {
continue
}
}
kept.append(item)
}
if !removedAny {
return items
}
while case .separator? = kept.last {
kept.removeLast()
}
return kept
}
// MARK: Extra items
static func extraItems(_ context: GGMessageMenuContext) -> [GGMessageMenuEntry] {
guard GGMessagesSettings.customMessageMenu.value, !context.messages.isEmpty else {
return []
}
let accountContext = context.context
let messages = context.messages.sorted(by: { $0.id < $1.id })
let isSecret = messages[0].id.peerId.namespace == Namespaces.Peer.SecretChat
let hasLocalOnly = messages.contains(where: { $0.id.namespace != Namespaces.Message.Cloud })
// What the server would refuse to forward is not offered: protected content (unless the
// bypass sends it as a copy — then the chat and the messages report no protection), service
// messages and view-once media.
let isProtected = context.isCopyProtectionEnabled || messages.contains(where: { $0.isCopyProtected() })
let hasUnforwardable = messages.contains(where: { message in
return message.containsSecretMedia || message.media.contains(where: { $0 is TelegramMediaAction })
})
let canForward = !isSecret && !hasLocalOnly && !isProtected && !hasUnforwardable
var result: [GGMessageMenuEntry] = []
if GGMessagesSettings.menuSaveToCloud.value && canForward && messages[0].id.peerId != accountContext.account.peerId {
result.append(GGMessageMenuEntry(position: .bottom, item: .action(ContextMenuActionItem(text: GGStrings.MessageMenu.Menu.saveToCloud, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Fave"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
f(.default)
GGMessageMenuCustomization.send(messages, to: accountContext.account.peerId, threadId: nil, hideNames: false, context: accountContext, toast: GGStrings.MessageMenu.savedToCloud)
}))))
}
if GGMessagesSettings.menuForwardWithoutAuthor.value && canForward {
result.append(GGMessageMenuEntry(position: .bottom, item: .action(ContextMenuActionItem(text: GGStrings.MessageMenu.Menu.forwardWithoutAuthor, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
f(.default)
GGMessageMenuCustomization.pickPeerAndForward(messages, context: accountContext)
}))))
}
if GGMessagesSettings.menuShowRestrict.value, let restrict = GGMessageMenuCustomization.restrictItem(messages: messages, context: accountContext, chatLocation: context.chatLocation) {
result.append(GGMessageMenuEntry(position: .bottom, item: restrict))
}
return result
}
/// Forward (or a copy when the source is protected and the bypass is on) without opening the
/// target; `threadId` — the forum topic chosen in the picker.
static func send(_ messages: [Message], to peerId: PeerId, threadId: Int64?, hideNames: Bool, context: AccountContext, toast: String) {
GGForwardSend.send(account: context.account, messages: messages, to: [GGForwardSend.Target(peerId: peerId, threadId: threadId)], hideNames: hideNames, hideCaptions: false, completion: { result in
GGMessageMenuCustomization.showResult(result, context: context, toast: toast)
})
}
private static func pickPeerAndForward(_ messages: [Message], context: AccountContext) {
guard let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController else {
return
}
// As Telegram's own «Переслать»: «Выбрать» for several chats, a comment, the forward options
// (names start hidden) and the send menu.
let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: [.onlyWriteable, .excludeDisabled, .doNotSearchMessages], hasFilters: true, multipleSelection: true, forwardedMessageIds: messages.map { $0.id }, selectForumThreads: true, ggHidesSenderNames: true))
controller.peerSelected = { [weak controller] peer, threadId in
controller?.dismiss()
GGMessageMenuCustomization.send(messages, to: peer.id, threadId: threadId, hideNames: true, context: context, toast: GGStrings.MessageMenu.forwarded)
}
controller.multiplePeersSelected = { [weak controller] peers, _, text, mode, forwardOptions, _ in
guard let controller, !peers.isEmpty else {
return
}
let send: (Bool, Int32?, Int32?) -> Void = { silent, scheduleTime, repeatPeriod in
controller.dismiss()
GGForwardSend.send(account: context.account, messages: messages, comment: GGMessageMenuCustomization.commentMessages(text), to: peers.map { GGForwardSend.Target(peerId: $0.id, threadId: nil) }, hideNames: forwardOptions?.hideNames ?? true, hideCaptions: forwardOptions?.hideCaptions ?? false, silent: silent, scheduleTime: scheduleTime, repeatPeriod: repeatPeriod, completion: { result in
GGMessageMenuCustomization.showResult(result, context: context, toast: GGStrings.MessageMenu.forwarded)
})
}
switch mode {
case .generic:
send(false, nil, nil)
case .silent:
send(true, nil, nil)
case .whenOnline:
send(false, scheduleWhenOnlineTimestamp, nil)
case .schedule:
let scheduleMode: ChatScheduleTimeScreen.Mode
if peers.count == 1, peers[0].id == context.account.peerId {
scheduleMode = .reminders
} else {
scheduleMode = .scheduledMessages(peerId: peers[0].id, sendWhenOnlineAvailable: false)
}
controller.present(ChatScheduleTimeScreen(context: context, mode: scheduleMode, currentTime: nil, isDark: false, completion: { result in
send(result.silentPosting, result.time, result.repeatPeriod)
}), in: .window(.root))
}
}
navigationController.pushViewController(controller)
}
/// The comment typed in the picker, as Telegram's forward sends it: split by the length limit,
/// with its entities.
private static func commentMessages(_ text: NSAttributedString) -> [EnqueueMessage] {
var result: [EnqueueMessage] = []
for part in breakChatInputText(trimChatInputText(convertMarkdownToAttributes(text))) where part.length != 0 {
var attributes: [MessageAttribute] = []
let entities = generateTextEntities(part.string, enabledTypes: .all, currentEntities: generateChatInputTextEntities(part))
if !entities.isEmpty {
attributes.append(TextEntitiesMessageAttribute(entities: entities))
}
result.append(.message(text: part.string, attributes: attributes, inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []))
}
return result
}
private static func showResult(_ result: GGForwardCopy.Result, context: AccountContext, toast: String) {
let text: String
if !result.messages.isEmpty {
text = toast
} else {
// Nothing could be copied: say why instead of a «sent» that did not happen.
text = result.failedCount > 0 ? GGStrings.Restrictions.Copy.failed(count: result.failedCount) : GGStrings.Restrictions.Copy.unsupported(count: result.skippedCount)
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.mainWindow?.present(UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: text, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in
return false
}), on: .root)
}
/// «Ограничить»: admins who may ban get the member restriction screen for the author.
private static func restrictItem(messages: [Message], context: AccountContext, chatLocation: ChatLocation) -> ContextMenuItem? {
guard messages.count == 1, let message = messages.first, let author = message.author as? TelegramUser, author.id != context.account.peerId else {
return nil
}
guard let channel = message.peers[message.id.peerId] as? TelegramChannel, case .group = channel.info, channel.hasPermission(.banMembers) else {
return nil
}
let channelId = channel.id
return .action(ContextMenuActionItem(text: GGStrings.MessageMenu.Menu.restrict, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
f(.default)
guard let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController else {
return
}
let controller = channelBannedMemberController(context: context, peerId: channelId, memberId: author.id, initialParticipant: nil, updated: { _ in }, upgradedToSupergroup: { _, f in f() })
navigationController.pushViewController(controller)
}))
}
}