Skip to content

Update invite copy and app icon, show invite preview in composer#541

Merged
yewreeka merged 4 commits into
devfrom
invite-url-detection
Mar 4, 2026
Merged

Update invite copy and app icon, show invite preview in composer#541
yewreeka merged 4 commits into
devfrom
invite-url-detection

Conversation

@yewreeka

@yewreeka yewreeka commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • Detect invite URLs when pasted/typed in message input
  • Show invite preview in attachments area with Convos icon
  • Send full invite URL as separate message before any text
  • Replace old icon assets with convosOrangeIcon throughout

Note

Add invite URL detection and attachment preview in the message composer

  • Adds InviteURLDetector.swift, a utility that uses NSDataDetector to find Convos invite URLs or raw invite codes in message text, returning the code, full URL, and character range.
  • As the user types, ConversationViewModel.checkForInviteURL() extracts any detected invite URL from messageText and stores it as pending invite state; the send button enables when a pending invite exists.
  • On send, the invite URL is dispatched first via messageWriter.send(text:afterPhoto:), followed by any remaining text or photo.
  • MessagesInputView renders a removable chip preview for the pending invite alongside photo attachments, with a poof animation on removal.
  • Replaces several references to removed convosIcon/convosIconLarge image assets with convosOrangeIcon across share, QR, and invite preview views.

Changes since #541 opened

  • Fixed range calculation in InviteURLDetector.detectInviteURL to map ranges from trimmed text back to the original input string and implemented cached NSDataDetector instance [ecb62c4]
  • Replaced separate pending invite properties with unified PendingInvite struct in ConversationViewModel [ecb62c4]
  • Changed pendingInviteCode from binding to value parameter throughout SwiftUI view hierarchy [ecb62c4]
  • Added test suite InviteURLDetectorTests covering invite URL detection, code validation, and range mapping scenarios [ecb62c4]
  • Modified InviteURLDetector.detectInviteURL to operate on the full input text instead of trimmed substrings, with nsRange created from the full text, enumerateMatches running on the full text, and match ranges taken directly from the full text, and for inferred codes without explicit URLs, validation now uses the asterisk-stripped potentialCode and constructs fullURL with potentialCode, returning the range as the entire original text rather than the trimmed range [a6bad93]
  • Changed ConversationViewModel message send flow to pass afterPhoto parameter set to photoTrackingKey instead of nil when sending invite URLs [a6bad93]
  • Modified invite URL handling in message composition and storage to support sending invites as reply messages [ec574ce]
📊 Macroscope summarized a72f154. 9 files reviewed, 10 issues evaluated, 7 issues filtered, 1 comment posted

🗂️ Filtered Issues

Convos/Conversation Detail/ConversationView.swift — 0 comments posted, 2 evaluated, 2 filtered
  • [line 51](https://github.com/xmtplabs/convos-ios/blob/a72f154ce3379895a59fbc9ce85c95abe6f04aeb/Convos/Conversation Detail/ConversationView.swift#L51): The guard condition checks pendingInviteCode != nil to allow sending, but the actual send logic uses pendingInviteURL. If pendingInviteCode is set but pendingInviteURL is nil (due to sync issues between these properties), the guard will pass allowing the send to proceed, but no invite will actually be sent. This could result in a user expecting an invite to be sent when only an empty message (or nothing) is sent. [ Low confidence ]
  • [line 103](https://github.com/xmtplabs/convos-ios/blob/a72f154ce3379895a59fbc9ce85c95abe6f04aeb/Convos/Conversation Detail/ConversationView.swift#L103): The .onChange(of: viewModel.messageText) modifier calls viewModel.checkForInviteURL() on every keystroke. If checkForInviteURL() performs expensive operations (like regex matching or URL parsing) synchronously, this could cause UI lag during typing, especially for longer messages. [ Low confidence ]
Convos/Conversation Detail/ConversationViewModel.swift — 0 comments posted, 3 evaluated, 1 filtered
  • [line 214](https://github.com/xmtplabs/convos-ios/blob/a72f154ce3379895a59fbc9ce85c95abe6f04aeb/Convos/Conversation Detail/ConversationViewModel.swift#L214): sendButtonEnabled now returns true when pendingInviteCode != nil, enabling the send button with only a pending invite code. However, there is no visible implementation in the provided code showing how the send action handles this case. If the send logic does not check for and process pendingInviteCode, pressing send with only a pending invite (no text, no photo) may result in a no-op or unexpected behavior. [ Low confidence ]
Convos/Conversation Detail/Messages/Messages View Controller/View Controller/Cells/MessageInviteContainerView.swift — 0 comments posted, 1 evaluated, 1 filtered
  • [line 91](https://github.com/xmtplabs/convos-ios/blob/a72f154ce3379895a59fbc9ce85c95abe6f04aeb/Convos/Conversation Detail/Messages/Messages View Controller/View Controller/Cells/MessageInviteContainerView.swift#L91): The image asset name was changed from "convosIconLarge" to "convosOrangeIcon". If the convosOrangeIcon asset does not exist in the asset catalog, SwiftUI's Image(_:) initializer will silently fail and render an empty view, causing the placeholder to appear blank instead of showing the intended fallback icon. [ Low confidence ]
Convos/Conversation Detail/Messages/MessagesView.swift — 0 comments posted, 2 evaluated, 2 filtered
  • [line 88](https://github.com/xmtplabs/convos-ios/blob/a72f154ce3379895a59fbc9ce85c95abe6f04aeb/Convos/Conversation Detail/Messages/MessagesView.swift#L88): The guard condition checks pendingInviteCode != nil to allow sending, but the actual send logic uses pendingInviteURL. If these two state variables can become inconsistent (e.g., pendingInviteCode is set but pendingInviteURL is nil), the send operation will pass the guard but silently send nothing when the user only has an invite pending (no text or attachment). [ Low confidence ]
  • [line 714](https://github.com/xmtplabs/convos-ios/blob/a72f154ce3379895a59fbc9ce85c95abe6f04aeb/Convos/Conversation Detail/Messages/MessagesView.swift#L714): When sending a message with both a photo attachment and a pending invite, the invite URL is sent with afterPhoto: nil instead of using photoTrackingKey. This could cause the invite message to appear before the photo in the conversation, rather than maintaining the expected order where messages sent together appear in sequence. [ Skipped comment generation ]
Convos/Utilities & Extensions/InviteURLDetector.swift — 1 comment posted, 2 evaluated, 1 filtered

- Detect invite URLs when pasted/typed in message input
- Show invite preview in attachments area with Convos icon
- Send full invite URL as separate message before any text
- Replace old icon assets with convosOrangeIcon throughout

Copy link
Copy Markdown
Contributor Author

How to use the Graphite Merge Queue

Add the label merge-queue to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@claude

claude Bot commented Feb 27, 2026

Copy link
Copy Markdown

Claude finished @yewreeka's task —— View job


Code Review Complete

I've thoroughly reviewed this PR. Overall, the implementation is solid with good test coverage, but there's one critical bug that needs to be addressed along with several other improvements.


🔴 Critical Issue

Reply Context Lost for Invite-Only Sends

Location: ConversationViewModel.swift:716-723

When a user replies to a message and sends only an invite (no text, no attachment), the invite URL is sent as a regular message instead of as a reply. The replyTarget is captured but never used for invite-only sends, causing the reply context to be lost.

The problem:

if let inviteURL = prevInviteURL {
    try await messageWriter.send(text: inviteURL, afterPhoto: photoTrackingKey)
}

This code doesn't check replyTarget, so the invite won't be associated with the original message.

Fix:

if let inviteURL = prevInviteURL {
    let inviteIsReply = replyTarget != nil && !hasAttachment && !hasText
    if inviteIsReply, let replyTarget {
        try await messageWriter.sendReply(text: inviteURL, afterPhoto: photoTrackingKey, toMessageWithClientId: replyTarget.base.id)
    } else {
        try await messageWriter.send(text: inviteURL, afterPhoto: photoTrackingKey)
    }
}

Impact: Users expecting to reply with an invite will see the invite appear as a regular message instead, breaking conversational context.


🟡 Code Quality & Best Practices

1. Performance - Invite Detection on Every Keystroke

Location: ConversationView.swift:103, ConversationViewModel.swift:1217-1224

The .onChange(of: viewModel.messageText) triggers checkForInviteURL() on every keystroke. While the implementation looks efficient (using cached NSDataDetector), consider adding debouncing if users report typing lag with long messages.

Current implementation is acceptable, but monitor for performance issues.


2. Invite Code Length Validation Issue

Location: InviteURLDetector.swift:43-45

When validating invite codes containing asterisks (e.g., *code*), the length check happens after asterisks are stripped. This could cause valid codes with asterisks to fail the 50-character threshold.

Current code:

let potentialCode = trimmedText.replacingOccurrences(of: "*", with: "")
if isLikelyInviteCode(potentialCode),  // checks potentialCode.count >= 50
   (try? SignedInvite.fromURLSafeSlug(potentialCode)) != nil {

Why this matters: If a user pastes * + 48-char-code + *, the stripped version is only 48 characters and fails validation, even though the code itself might be valid.

However, looking at the latest commit (a6bad93), this appears to have been addressed by using potentialCode for validation. ✅


3. Test Coverage - Missing Edge Cases

Location: InviteURLDetectorTests.swift

Great test coverage overall (305 lines!), but missing a few scenarios:

  • Multiple invite URLs in one message - Test confirms only the first is detected, which is correct ✅
  • Invite code with leading/trailing asterisks - testIsLikelyInviteCode_AllowsAsterisks covers this ✅
  • Invalid base64 characters in URL-safe slug - Not explicitly tested
  • Concurrent detection calls - Not tested (likely not needed for this use case)

Recommendation: Add a test for invalid base64 characters to ensure graceful handling.


4. UI State Consistency

Location: MessagesInputView.swift:13, ConversationViewModel.swift:215

The view receives pendingInviteCode: String? as a value parameter (line 13), but ConversationViewModel stores the full PendingInvite struct (line 215). This is correct design - the view only needs to know if an invite exists, not the details.

However, consider passing the full PendingInvite? to the view for future flexibility (e.g., showing invite metadata in the preview).

Current approach is acceptable for this scope.


🟢 Positive Observations

1. Excellent Separation of Concerns

  • InviteURLDetector is a pure utility with no side effects ✅
  • PendingInvite struct consolidates state (addresses earlier PR feedback) ✅
  • SwiftUI views are properly declarative and stateless ✅

2. Strong Test Coverage

  • 305 lines of tests for InviteURLDetector
  • Covers edge cases like whitespace handling, multiple URLs, and range accuracy ✅
  • Tests validate against actual ConfigManager domains (good integration testing) ✅

3. Proper iOS Patterns

  • Uses cached NSDataDetector instance (line 12-14) for performance ✅
  • Poof animation for attachment removal is delightful UX ✅
  • Accessibility labels on all interactive elements ✅

4. Database Integration

Location: DecodedMessage+DBRepresentation.swift:98-108, 135-145

The invite detection integrates seamlessly with the existing message content type system, detecting invites in both regular text messages and replies. This ensures invite URLs are properly rendered as invite cards in the message list.


📋 SwiftLint Compliance Check

Reviewing against the project's SwiftLint rules:

  • ✅ No force unwrapping (!) - Uses optional binding throughout
  • ✅ No implicitly unwrapped optionals
  • ✅ No assertions in non-test code
  • ✅ Uses first(where:) pattern correctly
  • ✅ Implicit returns in closures only (not functions)
  • ✅ Modifier order appears correct
  • ✅ No // Created by... headers
  • ✅ Line length under 200 characters
  • ✅ No all-caps emphasis in comments

One minor note: The poof animation dispatch (line 161) could use a comment explaining the 0.2s delay, but this is a minor style preference.


🔍 Security & Architecture Review

1. Input Validation ✅

  • isLikelyInviteCode properly validates character set before decoding
  • Uses SignedInvite.fromURLSafeSlug() for cryptographic validation
  • Gracefully handles malformed invite codes with try?

2. Cross-Platform Compatibility ✅

  • No UIKit imports in ConvosCore (uses cross-platform types)
  • Detector logic is platform-agnostic

3. State Management ✅

  • Uses modern @Observable framework
  • Proper state cleanup in onSendMessage (lines 677-681)
  • No race conditions detected

📝 Summary

Must Fix:

  1. ✅ Add reply support for invite-only sends (ConversationViewModel.swift:716-723)

Should Consider:

  1. Monitor keystroke detection performance with long messages
  2. Add test for invalid base64 characters in invite codes

Nice to Have:

  1. Add comment explaining poof animation delay
  2. Consider passing full PendingInvite? to view for future flexibility

Excellent Work:

  • Comprehensive test coverage
  • Clean separation of concerns
  • Proper iOS accessibility support
  • Seamless database integration
  • No SwiftLint violations detected

The critical bug is straightforward to fix - just add the conditional reply logic for invites. Once that's addressed, this PR is in great shape! 🚀


@yewreeka yewreeka marked this pull request as ready for review February 27, 2026 18:28
@yewreeka yewreeka changed the title Add invite URL detection in message composer Update invite copy and app icon, show invite preview in composer Feb 27, 2026
Comment thread Convos/Utilities & Extensions/InviteURLDetector.swift Outdated
- Fix crash: range indices from trimmedText were used on original text,
  causing invalid index when input had leading/trailing whitespace
- Add 33 unit tests covering URL detection, range accuracy, edge cases
- Consolidate pendingInviteCode/URL/Range into PendingInvite struct
- Cache NSDataDetector as static property instead of recreating per call
- Replace silent try? with guard + Log.error
- Extract magic number 50 to named constant minimumInviteCodeLength
- Change view pendingInviteCode from @binding to plain value (read-only)
Comment thread Convos/Utilities & Extensions/InviteURLDetector.swift
…ring

- Run NSDataDetector on original text instead of trimmedText, eliminating
  error-prone range translation logic
- Use potentialCode (asterisks stripped) for SignedInvite.fromURLSafeSlug
  instead of trimmedText, fixing decode failure for starred codes
- Pass afterPhoto:photoTrackingKey when sending invite URL so photo
  always appears before the invite in message ordering
Comment thread Convos/Conversation Detail/ConversationViewModel.swift
- Fix reply hydration dropping invite messages (returned nil for .invite
  content type in composeReplyMessage)
- Fix XMTP reply decoding not detecting invite URLs in text replies,
  causing invite card to briefly appear then revert to raw text
- Send invite-only replies via sendReply to preserve reply association
- Run NSDataDetector on original text to simplify range handling
@yewreeka yewreeka merged commit e387d98 into dev Mar 4, 2026
7 of 8 checks passed
@yewreeka yewreeka deleted the invite-url-detection branch March 4, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants