diff --git a/apple/InlineIOS/Features/Compose/ComposeTextView.swift b/apple/InlineIOS/Features/Compose/ComposeTextView.swift index 117e0880..97ec586b 100644 --- a/apple/InlineIOS/Features/Compose/ComposeTextView.swift +++ b/apple/InlineIOS/Features/Compose/ComposeTextView.swift @@ -37,6 +37,7 @@ class ComposeTextView: UITextView { textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) translatesAutoresizingMaskIntoConstraints = false tintColor = ThemeManager.shared.selected.accent + dataDetectorTypes = [] } private func setupPlaceholder() { diff --git a/apple/InlineIOS/Features/Message/UIMessageView.swift b/apple/InlineIOS/Features/Message/UIMessageView.swift index 3dc3c3de..ca4b17f3 100644 --- a/apple/InlineIOS/Features/Message/UIMessageView.swift +++ b/apple/InlineIOS/Features/Message/UIMessageView.swift @@ -625,6 +625,16 @@ class UIMessageView: UIView { // If not a mention, check for links if !foundMention { + if let email = attributedText.attribute(.emailAddress, at: characterIndex, effectiveRange: nil) as? String { + UIPasteboard.general.string = email + ToastManager.shared.showToast( + "Copied email", + 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 8c571bdf..cfabe5c8 100644 --- a/apple/InlineKit/Sources/InlineKit/RichTextHelpers/AttributedStringHelpers.swift +++ b/apple/InlineKit/Sources/InlineKit/RichTextHelpers/AttributedStringHelpers.swift @@ -103,6 +103,7 @@ public class AttributedStringHelpers { public extension NSAttributedString.Key { static let mentionUserId = NSAttributedString.Key("mentionUserId") + static let emailAddress = NSAttributedString.Key("emailAddress") static let inlineCode = NSAttributedString.Key("inlineCode") static let preCode = NSAttributedString.Key("preCode") static let italic = NSAttributedString.Key("italic") diff --git a/apple/InlineMac/Views/Compose/ComposeTextView.swift b/apple/InlineMac/Views/Compose/ComposeTextView.swift index 1431ce01..b5725b07 100644 --- a/apple/InlineMac/Views/Compose/ComposeTextView.swift +++ b/apple/InlineMac/Views/Compose/ComposeTextView.swift @@ -21,6 +21,8 @@ protocol ComposeTextViewDelegate: NSTextViewDelegate { } class ComposeNSTextView: NSTextView { + private var isStrippingEmailLinks = false + override func keyDown(with event: NSEvent) { // Handle return key if event.keyCode == 36 { @@ -81,6 +83,11 @@ class ComposeNSTextView: NSTextView { super.keyDown(with: event) } + override func didChangeText() { + super.didChangeText() + stripEmailLinkAttributes() + } + @discardableResult override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() @@ -167,6 +174,27 @@ class ComposeNSTextView: NSTextView { insertText(text, replacementRange: range) } + private func stripEmailLinkAttributes() { + guard !isStrippingEmailLinks else { return } + guard let textStorage else { return } + + isStrippingEmailLinks = true + let fullRange = NSRange(location: 0, length: textStorage.length) + textStorage.enumerateAttribute(.link, in: fullRange, options: []) { value, range, _ in + let urlString: String? = { + if let url = value as? URL { return url.absoluteString } + if let string = value as? String { return string } + return nil + }() + + guard let urlString, let url = URL(string: urlString) else { return } + if url.scheme?.lowercased() == "mailto" { + textStorage.removeAttribute(.link, range: range) + } + } + isStrippingEmailLinks = false + } + #if false // Temporarily disabled rich-text paste sanitization pipeline. Plain text paste is significantly more reliable // for AppKit undo/redo, IME composition, selection behavior, and for preventing unsupported styling leaks. diff --git a/apple/InlineMac/Views/Message/MessageView.swift b/apple/InlineMac/Views/Message/MessageView.swift index 96369462..915b9d65 100644 --- a/apple/InlineMac/Views/Message/MessageView.swift +++ b/apple/InlineMac/Views/Message/MessageView.swift @@ -375,6 +375,7 @@ class MessageViewAppKit: NSView { }() private var reactionsView: MessageReactionsView? + private var emailClickGesture: NSClickGestureRecognizer? // MARK: - Link Detection @@ -490,6 +491,7 @@ class MessageViewAppKit: NSView { if hasText { contentView.addSubview(textView) + setupEmailClickHandling() } if hasReactions { @@ -526,6 +528,40 @@ class MessageViewAppKit: NSView { } } + private func setupEmailClickHandling() { + let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleEmailClick(_:))) + gesture.numberOfClicksRequired = 1 + gesture.delaysPrimaryMouseButtonEvents = false + gesture.delegate = self + textView.addGestureRecognizer(gesture) + emailClickGesture = gesture + } + + @objc private func handleEmailClick(_ gesture: NSClickGestureRecognizer) { + guard gesture.state == .ended else { return } + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer + else { return } + + let location = gesture.location(in: textView) + let characterIndex = layoutManager.characterIndex( + for: location, + in: textContainer, + fractionOfDistanceBetweenInsertionPoints: nil + ) + + guard characterIndex != NSNotFound, + let textStorage = textView.textStorage, + characterIndex < textStorage.length + else { return } + + if let email = textStorage.attribute(.emailAddress, at: characterIndex, effectiveRange: nil) as? String { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(email, forType: .string) + ToastCenter.shared.showSuccess("Copied email") + } + } + private func updateShineEffect(isTranslating: Bool) { if isTranslating { if shineEffectView == nil { diff --git a/apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift b/apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift index 739ff747..cf45282e 100644 --- a/apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift +++ b/apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift @@ -112,6 +112,20 @@ public class ProcessEntities { attributedString.addAttributes(attributes, range: range) } + case .email: + let emailText = (text as NSString).substring(with: range) + var attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: configuration.linkColor, + .underlineStyle: 0, + .emailAddress: emailText, + ] + + #if os(macOS) + attributes[.cursor] = NSCursor.pointingHand + #endif + + attributedString.addAttributes(attributes, range: range) + case .mention: if case let .mention(mention) = entity.entity { if configuration.convertMentionsToLink { @@ -202,6 +216,20 @@ public class ProcessEntities { } } + attributedString.enumerateAttribute( + .emailAddress, + in: fullRange, + options: [] + ) { value, range, _ in + if let emailAddress = value as? String, !emailAddress.isEmpty { + var entity = MessageEntity() + entity.type = .email + entity.offset = Int64(range.location) + entity.length = Int64(range.length) + entities.append(entity) + } + } + // Extract link entities (excluding mention links). attributedString.enumerateAttribute( .link, @@ -224,6 +252,15 @@ public class ProcessEntities { guard let urlString, !urlString.isEmpty else { return } + if emailAddress(from: urlString) != nil { + var entity = MessageEntity() + entity.type = .email + entity.offset = Int64(range.location) + entity.length = Int64(range.length) + entities.append(entity) + return + } + // Ignore data-detector / non-web link targets (we only support actual URLs as entities). guard isAllowedExternalLink(urlString) else { return } @@ -360,6 +397,8 @@ public class ProcessEntities { // NOTE: Only extract if not within code blocks entities = extractItalicFromMarkdown(text: &text, existingEntities: entities) + entities = extractEmailEntities(text: text, existingEntities: entities) + // Sort entities by offset entities.sort { $0.offset < $1.offset } @@ -369,7 +408,7 @@ public class ProcessEntities { return (text: text, entities: messageEntities) } - private static let allowedExternalLinkSchemes: Set = ["http", "https", "mailto"] + private static let allowedExternalLinkSchemes: Set = ["http", "https"] private static func isAllowedExternalLink(_ urlString: String) -> Bool { guard let url = URL(string: urlString), @@ -378,6 +417,13 @@ public class ProcessEntities { return allowedExternalLinkSchemes.contains(scheme) } + private static func emailAddress(from urlString: String) -> String? { + guard let url = URL(string: urlString), + url.scheme?.lowercased() == "mailto" + else { return nil } + return url.resourceSpecifier + } + // MARK: - Helper Methods // MARK: - Constants @@ -494,6 +540,49 @@ public class ProcessEntities { let length: Int } + private static let emailRegex: NSRegularExpression = { + let pattern = "\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b" + return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + }() + + private static func extractEmailEntities( + 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 = emailRegex.matches(in: text, options: [], range: range) + + 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 + } + + var entity = MessageEntity() + entity.type = .email + entity.offset = Int64(match.range.location) + entity.length = Int64(match.range.length) + 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) + let lhsRange = NSRange(location: start, length: end - start) + return NSIntersectionRange(lhsRange, rhs).length > 0 + } + private static func totalRemovedCharacters(before offset: Int, removals: [OffsetRemoval]) -> Int { var total = 0 for removal in removals { diff --git a/apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift b/apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift index 2ed1cc1c..53408541 100644 --- a/apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift +++ b/apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift @@ -189,6 +189,29 @@ struct ProcessEntitiesTests { #expect((textUrlAttributes[.link] as? URL)?.absoluteString == "https://docs.example.com") } + @Test("Email entities apply email attributes without link") + func testEmailEntities() { + let text = "Contact test@example.com for details" + let emailRange = rangeOfSubstring("test@example.com", in: text) + var emailEntity = MessageEntity() + emailEntity.type = .email + emailEntity.offset = Int64(emailRange.location) + emailEntity.length = Int64(emailRange.length) + + let entities = createMessageEntities([emailEntity]) + + let result = ProcessEntities.toAttributedString( + text: text, + entities: entities, + configuration: testConfiguration + ) + + let emailAttributes = result.attributes(at: emailRange.location, effectiveRange: nil) + #expect(emailAttributes[.foregroundColor] as? PlatformColor == testConfiguration.linkColor) + #expect(emailAttributes[.emailAddress] as? String == "test@example.com") + #expect(emailAttributes[.link] == nil) + } + @Test("Italic text") func testItalicText() { let text = "This is italic text" @@ -520,6 +543,45 @@ struct ProcessEntitiesTests { #expect(entity.length == Int64(range.length)) } + @Test("Extract email from mailto link attributes") + func testExtractEmailFromMailtoLinkAttributes() { + let text = "reach 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: "mailto:test@example.com", 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 == .email) + #expect(entity.offset == 0) + #expect(entity.length == Int64(range.length)) + } + + @Test("Detect email entity from plain text") + func testDetectEmailFromPlainText() { + let text = "Email test@example.com for updates" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [.font: testConfiguration.font, .foregroundColor: testConfiguration.textColor] + ) + + let result = ProcessEntities.fromAttributedString(attributedString) + + let emailRange = rangeOfSubstring("test@example.com", in: text) + let emailEntity = result.entities.entities.first { $0.type == .email } + #expect(emailEntity != nil) + #expect(emailEntity?.offset == Int64(emailRange.location)) + #expect(emailEntity?.length == Int64(emailRange.length)) + } + @Test("Extract bold from attributed string") func testExtractBold() { let text = "This is bold text" diff --git a/server/src/modules/message/parseMarkdown.ts b/server/src/modules/message/parseMarkdown.ts index 345146c3..a1465bf7 100644 --- a/server/src/modules/message/parseMarkdown.ts +++ b/server/src/modules/message/parseMarkdown.ts @@ -35,10 +35,13 @@ export function parseMarkdown(input: string): ParsedMarkdown { // 3. Links: [text](url) findLinks(input, matches) - // 4. Bold: **text** or __text__ + // 4. Emails: example@domain.com + findEmails(input, matches) + + // 5. Bold: **text** or __text__ findBold(input, matches) - // 5. Italic: *text* or _text_ + // 6. Italic: *text* or _text_ findItalic(input, matches) // Remove overlapping matches (earlier patterns win) @@ -176,6 +179,24 @@ function findLinks(text: string, matches: Match[]): void { } } +function findEmails(text: string, matches: Match[]): void { + const regex = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi + let match: RegExpExecArray | null + + while ((match = regex.exec(text)) !== null) { + const email = match[0] ?? "" + + if (email.length > 0) { + matches.push({ + start: match.index, + end: match.index + email.length, + content: email, + type: MessageEntity_Type.EMAIL, + }) + } + } +} + function findBold(text: string, matches: Match[]): void { const regex = /(\*\*|__)(.+?)\1/g let match: RegExpExecArray | null diff --git a/server/src/modules/message/processText.test.ts b/server/src/modules/message/processText.test.ts index 4b585070..0f30ec4e 100644 --- a/server/src/modules/message/processText.test.ts +++ b/server/src/modules/message/processText.test.ts @@ -74,6 +74,17 @@ describe("parseMarkdown", () => { textUrl: { url: "https://example.com" }, }) }) + + test("email", () => { + const result = parseMarkdown("Reach me at test@example.com") + expect(result.text).toBe("Reach me at test@example.com") + expect(result.entities).toHaveLength(1) + expect(result.entities[0]).toMatchObject({ + offset: BigInt(12), + length: BigInt(16), + type: MessageEntity_Type.EMAIL, + }) + }) }) describe("code blocks", () => {