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

142 lines
6.3 KiB
Swift

import Foundation
import TelegramCore
/// Whether a link's display `text` IS its own `url` — i.e. the link adds nothing a plain URL would not
/// already give, so it should paste as plain text rather than as a text link (`textUrl`) attribute.
///
/// The comparison is not a bare `==` because the producers of a link normalize the URL while leaving the
/// display text as the user wrote it. The tolerated normalizations, each observed from
/// `NSAttributedString(markdown:)` (which applies the GFM autolink extension to bare URLs):
/// - a `mailto:` scheme added to a bare email address,
/// - an `http://` / `https://` scheme added to a bare `www.` host (only when the text carries no scheme
/// of its own — `http://x` labelling `https://x` is a genuinely different destination),
/// - percent-encoding of a non-ASCII path,
/// - a trailing `/` added to a bare host.
///
/// A Telegram deep link (`tg://user?id=…`, `tg://timestamp?t=…` — the mention/date markers) never matches
/// its own display text, so those entities are unaffected.
public func chatInputLinkIsSelfReferential(text: String, url: String) -> Bool {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !url.isEmpty else {
return false
}
var candidates: Set<String> = []
var target = url
if target.lowercased().hasPrefix("mailto:") {
target = String(target.dropFirst("mailto:".count))
}
candidates.insert(target)
if let decoded = target.removingPercentEncoding {
candidates.insert(decoded)
}
var forms: Set<String> = [text, text + "/"]
if !text.contains("://") {
for scheme in ["http://", "https://"] {
forms.insert(scheme + text)
forms.insert(scheme + text + "/")
}
}
return !candidates.isDisjoint(with: forms)
}
/// Clears every `.url` inline entity whose covered text is the URL itself (see
/// `chatInputLinkIsSelfReferential`), leaving the text — and every other inline attribute on the run —
/// untouched. Recurses into nested containers (block quotes, details, table cells, media captions).
///
/// Used on the PASTE path: an imported bare URL must land in the composer as plain text, so it sends as a
/// server-detected `url` entity rather than as a `textUrl` that pins the same destination. A genuine text
/// link (a label that differs from its target) is kept.
public func chatInputContentStrippingSelfReferentialLinks(_ content: ChatInputContent) -> ChatInputContent {
var content = content
content.blocks = content.blocks.map(chatInputBlockStrippingSelfReferentialLinks)
return content
}
private func chatInputBlockStrippingSelfReferentialLinks(_ block: ChatInputBlock) -> ChatInputBlock {
switch block {
case var .paragraph(paragraph):
paragraph.runs = strippingSelfReferentialLinks(paragraph.runs)
return .paragraph(paragraph)
case var .code(code):
code.runs = strippingSelfReferentialLinks(code.runs)
return .code(code)
case var .media(media):
media.caption = strippingSelfReferentialLinks(media.caption)
return .media(media)
case var .table(table):
table.rows = table.rows.map { row in
var row = row
row.cells = row.cells.map { cell in
var cell = cell
cell.runs = strippingSelfReferentialLinks(cell.runs)
return cell
}
return row
}
return .table(table)
case var .pullQuote(pullQuote):
pullQuote.runs = strippingSelfReferentialLinks(pullQuote.runs)
pullQuote.author = strippingSelfReferentialLinks(pullQuote.author)
return .pullQuote(pullQuote)
case var .blockQuote(blockQuote):
blockQuote.content = chatInputContentStrippingSelfReferentialLinks(blockQuote.content)
blockQuote.author = strippingSelfReferentialLinks(blockQuote.author)
return .blockQuote(blockQuote)
case var .details(details):
details.content = chatInputContentStrippingSelfReferentialLinks(details.content)
details.title = strippingSelfReferentialLinks(details.title)
return .details(details)
case var .buttonRow(buttonRow):
buttonRow.buttons = buttonRow.buttons.map { button in
var button = button
button.label = strippingSelfReferentialLinks(button.label)
return button
}
return .buttonRow(buttonRow)
}
}
/// The `NSAttributedString` form of `chatInputContentStrippingSelfReferentialLinks`, for the legacy composer's
/// currency: removes every `ChatTextInputAttributes.textUrl` whose covered text is the URL itself. Applied on
/// the PASTE direction only — the copy direction (`storeAttributedTextInPasteboard`) keeps whatever entities
/// the source message carried.
public func chatInputTextStrippingSelfReferentialLinks(_ string: NSAttributedString) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: string)
let nsString = result.string as NSString
result.enumerateAttribute(ChatTextInputAttributes.textUrl, in: NSRange(location: 0, length: result.length), using: { value, range, _ in
guard let attribute = value as? ChatTextInputTextUrlAttribute else {
return
}
if chatInputLinkIsSelfReferential(text: nsString.substring(with: range), url: attribute.url) {
result.removeAttribute(ChatTextInputAttributes.textUrl, range: range)
}
})
return result
}
/// A link's label can be split over several runs (a formatted span inside it), so the span that must equal
/// the URL is the JOINED text of the adjacent runs sharing that same `.url` entity — not each run alone.
private func strippingSelfReferentialLinks(_ runs: [ChatInputRun]) -> [ChatInputRun] {
var result = runs
var index = 0
while index < result.count {
guard case let .url(url)? = result[index].attributes.entity else {
index += 1
continue
}
var end = index + 1
while end < result.count, result[end].attributes.entity == .url(url) {
end += 1
}
if chatInputLinkIsSelfReferential(text: result[index ..< end].map(\.text).joined(), url: url) {
for i in index ..< end {
result[i].attributes.entity = nil
}
}
index = end
}
return result
}