diff --git a/apple/InlineIOS/Features/Message/UIMessageView.swift b/apple/InlineIOS/Features/Message/UIMessageView.swift index d3a66bf3..82782e5a 100644 --- a/apple/InlineIOS/Features/Message/UIMessageView.swift +++ b/apple/InlineIOS/Features/Message/UIMessageView.swift @@ -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 diff --git a/apple/InlineKit/Sources/InlineKit/RichTextHelpers/AttributedStringHelpers.swift b/apple/InlineKit/Sources/InlineKit/RichTextHelpers/AttributedStringHelpers.swift index cfabe5c8..cd761caf 100644 --- a/apple/InlineKit/Sources/InlineKit/RichTextHelpers/AttributedStringHelpers.swift +++ b/apple/InlineKit/Sources/InlineKit/RichTextHelpers/AttributedStringHelpers.swift @@ -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") diff --git a/apple/InlineMac/Views/Message/MessageView.swift b/apple/InlineMac/Views/Message/MessageView.swift index 726cd06c..32b0c5b8 100644 --- a/apple/InlineMac/Views/Message/MessageView.swift +++ b/apple/InlineMac/Views/Message/MessageView.swift @@ -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") } } diff --git a/apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift b/apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift index 3ce42ea4..1046633b 100644 --- a/apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift +++ b/apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift @@ -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 { @@ -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] = [ @@ -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, @@ -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 } @@ -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 } @@ -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 } @@ -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 @@ -564,6 +684,11 @@ public class ProcessEntities { return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) }() + private static let phoneNumberRegex: NSRegularExpression = { + let pattern = "(? [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) diff --git a/apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift b/apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift index b8199aa8..03ab45b8 100644 --- a/apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift +++ b/apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift @@ -212,6 +212,32 @@ struct ProcessEntitiesTests { #expect(emailAttributes[.link] == nil) } + @Test("Phone number text_url applies phone attributes without link") + func testPhoneNumberTextURL() { + let text = "Call (415)555-1234 for details" + let phoneRange = rangeOfSubstring("(415)555-1234", in: text) + var phoneEntity = MessageEntity() + phoneEntity.type = .textURL + phoneEntity.offset = Int64(phoneRange.location) + phoneEntity.length = Int64(phoneRange.length) + phoneEntity.textURL = MessageEntity.MessageEntityTextUrl.with { + $0.url = "tel:+14155551234" + } + + let entities = createMessageEntities([phoneEntity]) + + let result = ProcessEntities.toAttributedString( + text: text, + entities: entities, + configuration: testConfiguration + ) + + let phoneAttributes = result.attributes(at: phoneRange.location, effectiveRange: nil) + #expect(phoneAttributes[.foregroundColor] as? PlatformColor == testConfiguration.linkColor) + #expect(phoneAttributes[.phoneNumber] as? String == "(415)555-1234") + #expect(phoneAttributes[.link] == nil) + } + @Test("Italic text") func testItalicText() { let text = "This is italic text" @@ -543,6 +569,29 @@ struct ProcessEntitiesTests { #expect(entity.length == Int64(range.length)) } + @Test("Extract phone number from tel link attributes") + func testExtractPhoneNumberFromTelLinkAttributes() { + let text = "call me" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [.font: testConfiguration.font, .foregroundColor: testConfiguration.textColor] + ) + + let range = NSRange(location: 0, length: (text as NSString).length) + attributedString.addAttribute(.link, value: "tel:+14155551234", range: range) + + let result = ProcessEntities.fromAttributedString(attributedString) + + #expect(result.text == text) + #expect(result.entities.entities.count == 1) + + let entity = result.entities.entities[0] + #expect(entity.type == .textURL) + #expect(entity.offset == 0) + #expect(entity.length == Int64(range.length)) + #expect(entity.textURL.url == "tel:+14155551234") + } + @Test("Detect email entity from plain text") func testDetectEmailFromPlainText() { let text = "Email test@example.com for updates" @@ -560,6 +609,96 @@ struct ProcessEntitiesTests { #expect(emailEntity?.length == Int64(emailRange.length)) } + @Test("Detect phone entity from plain text") + func testDetectPhoneFromPlainText() { + let text = "Call +1(415)555-1234 for updates" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [.font: testConfiguration.font, .foregroundColor: testConfiguration.textColor] + ) + + let result = ProcessEntities.fromAttributedString(attributedString) + + let phoneRange = rangeOfSubstring("+1(415)555-1234", in: text) + let phoneEntity = result.entities.entities.first { + $0.type == .textURL && $0.textURL.url == "tel:+14155551234" + } + #expect(phoneEntity != nil) + #expect(phoneEntity?.offset == Int64(phoneRange.location)) + #expect(phoneEntity?.length == Int64(phoneRange.length)) + } + + @Test("Detect digits-only phone entity from plain text") + func testDetectDigitsOnlyPhoneFromPlainText() { + let text = "Call 4155551234 for updates" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [.font: testConfiguration.font, .foregroundColor: testConfiguration.textColor] + ) + + let result = ProcessEntities.fromAttributedString(attributedString) + + let phoneRange = rangeOfSubstring("4155551234", in: text) + let phoneEntity = result.entities.entities.first { + $0.type == .textURL && $0.textURL.url == "tel:4155551234" + } + #expect(phoneEntity != nil) + #expect(phoneEntity?.offset == Int64(phoneRange.location)) + #expect(phoneEntity?.length == Int64(phoneRange.length)) + } + + @Test("Does not detect phone numbers with whitespace") + func testRejectPhoneWithWhitespace() { + let text = "Call 415 555 1234 for updates" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [.font: testConfiguration.font, .foregroundColor: testConfiguration.textColor] + ) + + let result = ProcessEntities.fromAttributedString(attributedString) + let phoneEntity = result.entities.entities.first { $0.type == .textURL && $0.textURL.url.hasPrefix("tel:") } + #expect(phoneEntity == nil) + } + + @Test("Does not detect date-like numbers as phone numbers") + func testRejectDateLikePhoneNumber() { + let text = "Release 2025-09-12 is scheduled" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [.font: testConfiguration.font, .foregroundColor: testConfiguration.textColor] + ) + + let result = ProcessEntities.fromAttributedString(attributedString) + let phoneEntity = result.entities.entities.first { $0.type == .textURL && $0.textURL.url.hasPrefix("tel:") } + #expect(phoneEntity == nil) + } + + @Test("Does not detect short dashed numbers as phone numbers") + func testRejectShortDashedPhoneNumber() { + let text = "SSN 123-45-6789 is not a phone" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [.font: testConfiguration.font, .foregroundColor: testConfiguration.textColor] + ) + + let result = ProcessEntities.fromAttributedString(attributedString) + let phoneEntity = result.entities.entities.first { $0.type == .textURL && $0.textURL.url.hasPrefix("tel:") } + #expect(phoneEntity == nil) + } + + @Test("Does not detect short numeric strings as phone numbers") + func testRejectShortDigitsOnlyPhoneNumber() { + let text = "Code 1234567 should not match" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [.font: testConfiguration.font, .foregroundColor: testConfiguration.textColor] + ) + + let result = ProcessEntities.fromAttributedString(attributedString) + let phoneEntity = result.entities.entities.first { $0.type == .textURL && $0.textURL.url.hasPrefix("tel:") } + #expect(phoneEntity == nil) + } + @Test("Extract url from attributed string when visible text matches target") func testExtractURLFromAttributedString() { let urlText = "https://example.com"