Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apple/InlineIOS/Features/Message/UIMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,16 @@ class UIMessageView: UIView {
return
}

if let phoneNumber = attributedText.attribute(.phoneNumber, at: characterIndex, effectiveRange: nil) as? String {
UIPasteboard.general.string = phoneNumber
ToastManager.shared.showToast(
"Copied number",
type: .success,
systemImage: "doc.on.doc"
)
return
}

attributedText.enumerateAttribute(.link, in: NSRange(
location: 0,
length: attributedText.length
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public class AttributedStringHelpers {
public extension NSAttributedString.Key {
static let mentionUserId = NSAttributedString.Key("mentionUserId")
static let emailAddress = NSAttributedString.Key("emailAddress")
static let phoneNumber = NSAttributedString.Key("phoneNumber")
static let inlineCode = NSAttributedString.Key("inlineCode")
static let preCode = NSAttributedString.Key("preCode")
static let italic = NSAttributedString.Key("italic")
Expand Down
4 changes: 4 additions & 0 deletions apple/InlineMac/Views/Message/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,10 @@ class MessageViewAppKit: NSView {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(email, forType: .string)
ToastCenter.shared.showSuccess("Copied email")
} else if let phoneNumber = textStorage.attribute(.phoneNumber, at: characterIndex, effectiveRange: nil) as? String {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(phoneNumber, forType: .string)
ToastCenter.shared.showSuccess("Copied number")
}
}

Expand Down
187 changes: 175 additions & 12 deletions apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,35 @@ public class ProcessEntities {
case .url:
// URL is the text itself
let urlText = (text as NSString).substring(with: range)
var attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: configuration.linkColor,
.underlineStyle: 0,
]
if let url = URL(string: urlText) {
attributes[.link] = url
if phoneNumber(from: urlText) != nil {
var attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: configuration.linkColor,
.underlineStyle: 0,
.phoneNumber: urlText,
]

#if os(macOS)
attributes[.cursor] = NSCursor.pointingHand
#endif

attributedString.addAttributes(attributes, range: range)
} else {
attributes[.link] = urlText
}
var attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: configuration.linkColor,
.underlineStyle: 0,
]
if let url = URL(string: urlText) {
attributes[.link] = url
} else {
attributes[.link] = urlText
}

#if os(macOS)
attributes[.cursor] = NSCursor.pointingHand
#endif
#if os(macOS)
attributes[.cursor] = NSCursor.pointingHand
#endif

attributedString.addAttributes(attributes, range: range)
attributedString.addAttributes(attributes, range: range)
}

case .textURL:
if case let .textURL(textURL) = entity.entity {
Expand All @@ -106,6 +120,19 @@ public class ProcessEntities {
attributes[.cursor] = NSCursor.pointingHand
#endif

attributedString.addAttributes(attributes, range: range)
} else if phoneNumber(from: textURL.url) != nil {
let phoneText = (text as NSString).substring(with: range)
var attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: configuration.linkColor,
.underlineStyle: 0,
.phoneNumber: phoneText,
]

#if os(macOS)
attributes[.cursor] = NSCursor.pointingHand
#endif

attributedString.addAttributes(attributes, range: range)
} else {
var attributes: [NSAttributedString.Key: Any] = [
Expand Down Expand Up @@ -244,6 +271,25 @@ public class ProcessEntities {
}
}

attributedString.enumerateAttribute(
.phoneNumber,
in: fullRange,
options: []
) { value, range, _ in
if let phoneNumber = value as? String,
let normalized = normalizePhoneNumber(phoneNumber)
{
var entity = MessageEntity()
entity.type = .textURL
entity.offset = Int64(range.location)
entity.length = Int64(range.length)
entity.textURL = MessageEntity.MessageEntityTextUrl.with {
$0.url = "tel:\(normalized)"
}
entities.append(entity)
}
}

// Extract link entities (excluding mention links).
attributedString.enumerateAttribute(
.link,
Expand All @@ -262,6 +308,10 @@ public class ProcessEntities {
return
}

if attributesAtLocation[.phoneNumber] != nil {
return
}

let urlString: String? = {
if let url = value as? URL { return url.absoluteString }
if let str = value as? String { return str }
Expand All @@ -279,6 +329,20 @@ public class ProcessEntities {
return
}

if let phoneNumber = phoneNumber(from: urlString),
let normalized = normalizePhoneNumber(phoneNumber)
{
var entity = MessageEntity()
entity.type = .textURL
entity.offset = Int64(range.location)
entity.length = Int64(range.length)
entity.textURL = MessageEntity.MessageEntityTextUrl.with {
$0.url = "tel:\(normalized)"
}
entities.append(entity)
return
}

// Ignore data-detector / non-web link targets (we only support actual URLs as entities).
guard isAllowedExternalLink(urlString) else { return }

Expand Down Expand Up @@ -416,6 +480,7 @@ public class ProcessEntities {
entities = extractItalicFromMarkdown(text: &text, existingEntities: entities)

entities = extractEmailEntities(text: text, existingEntities: entities)
entities = extractPhoneNumberEntities(text: text, existingEntities: entities)

// Sort entities by offset
entities.sort { $0.offset < $1.offset }
Expand Down Expand Up @@ -443,6 +508,61 @@ public class ProcessEntities {
return address?.isEmpty == false ? address : nil
}

private static func phoneNumber(from urlString: String) -> String? {
guard urlString.lowercased().hasPrefix("tel:") else { return nil }
let startIndex = urlString.index(urlString.startIndex, offsetBy: "tel:".count)
let remainder = String(urlString[startIndex...])
let number = remainder.split(separator: "?").first.map(String.init)
return number?.isEmpty == false ? number : nil
}

private static func normalizePhoneNumber(_ phoneNumber: String) -> String? {
let trimmed = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

if trimmed.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
return nil
}

let allowedCharacters = CharacterSet(charactersIn: "+-()0123456789")
if trimmed.rangeOfCharacter(from: allowedCharacters.inverted) != nil {
return nil
}

let hasLeadingPlus = trimmed.first == "+"
if hasLeadingPlus, trimmed.dropFirst().contains("+") {
return nil
}
if !hasLeadingPlus, trimmed.contains("+") {
return nil
}

var parenDepth = 0
for character in trimmed {
if character == "(" {
parenDepth += 1
} else if character == ")" {
parenDepth -= 1
if parenDepth < 0 {
return nil
}
}
}
if parenDepth != 0 {
return nil
}

let digits = trimmed.filter { $0.isNumber }
guard digits.count >= 7, digits.count <= 15 else { return nil }

let hasStrongIndicator = trimmed.contains("+") || trimmed.contains("(") || trimmed.contains(")")
if digits.count < 10 && !hasStrongIndicator {
return nil
}

return (hasLeadingPlus ? "+" : "") + digits
}

// MARK: - Helper Methods

// MARK: - Constants
Expand Down Expand Up @@ -564,6 +684,11 @@ public class ProcessEntities {
return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
}()

private static let phoneNumberRegex: NSRegularExpression = {
let pattern = "(?<!\\w)(\\+?[0-9(][0-9()\\-]{5,}[0-9])(?!\\w)"
return try! NSRegularExpression(pattern: pattern, options: [])
}()

private static func extractEmailEntities(
text: String,
existingEntities: [MessageEntity]
Expand Down Expand Up @@ -595,6 +720,44 @@ public class ProcessEntities {
return entities
}

private static func extractPhoneNumberEntities(
text: String,
existingEntities: [MessageEntity]
) -> [MessageEntity] {
guard !text.isEmpty else { return existingEntities }

var entities = existingEntities
let range = NSRange(location: 0, length: text.utf16.count)
let matches = phoneNumberRegex.matches(in: text, options: [], range: range)
let nsText = text as NSString

for match in matches {
guard match.range.length > 0 else { continue }

if isPositionWithinCodeBlock(position: match.range.location, entities: entities) {
continue
}

if entities.contains(where: { rangesOverlap(lhs: $0, rhs: match.range) }) {
continue
}

let rawPhoneNumber = nsText.substring(with: match.range)
guard let normalized = normalizePhoneNumber(rawPhoneNumber) else { continue }

var entity = MessageEntity()
entity.type = .textURL
entity.offset = Int64(match.range.location)
entity.length = Int64(match.range.length)
entity.textURL = MessageEntity.MessageEntityTextUrl.with {
$0.url = "tel:\(normalized)"
}
entities.append(entity)
}

return entities
}

private static func rangesOverlap(lhs: MessageEntity, rhs: NSRange) -> Bool {
let start = Int(lhs.offset)
let end = start + Int(lhs.length)
Expand Down
Loading
Loading