Skip to content
Open
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
1 change: 1 addition & 0 deletions apple/InlineIOS/Features/Compose/ComposeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
10 changes: 10 additions & 0 deletions apple/InlineIOS/Features/Message/UIMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
28 changes: 28 additions & 0 deletions apple/InlineMac/Views/Compose/ComposeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions apple/InlineMac/Views/Message/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ class MessageViewAppKit: NSView {
}()

private var reactionsView: MessageReactionsView?
private var emailClickGesture: NSClickGestureRecognizer?

// MARK: - Link Detection

Expand Down Expand Up @@ -490,6 +491,7 @@ class MessageViewAppKit: NSView {

if hasText {
contentView.addSubview(textView)
setupEmailClickHandling()
}

if hasReactions {
Expand Down Expand Up @@ -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 {
Expand Down
91 changes: 90 additions & 1 deletion apple/InlineUI/Sources/TextProcessing/ProcessEntities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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 }

Expand Down Expand Up @@ -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 }

Expand All @@ -369,7 +408,7 @@ public class ProcessEntities {
return (text: text, entities: messageEntities)
}

private static let allowedExternalLinkSchemes: Set<String> = ["http", "https", "mailto"]
private static let allowedExternalLinkSchemes: Set<String> = ["http", "https"]

private static func isAllowedExternalLink(_ urlString: String) -> Bool {
guard let url = URL(string: urlString),
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions apple/InlineUI/Tests/InlineUITests/ProcessEntitiesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
25 changes: 23 additions & 2 deletions server/src/modules/message/parseMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading