Skip to content

feat: stable conversation emoji in shared metadata + invite payloads#686

Merged
yewreeka merged 47 commits intodevfrom
jarod/stable-conversation-emoji
Apr 14, 2026
Merged

feat: stable conversation emoji in shared metadata + invite payloads#686
yewreeka merged 47 commits intodevfrom
jarod/stable-conversation-emoji

Conversation

@yewreeka
Copy link
Copy Markdown
Contributor

@yewreeka yewreeka commented Apr 10, 2026

Summary

Adds a conversation-level emoji to shared XMTP metadata so all members see the same default emoji. Stacked on top of PR #683 (profile emoji avatars).

Problem

Currently, the default conversation emoji is derived from clientConversationId, which differs between the creator (who has a draft-... ID) and other members (who use the stable XMTP group ID). This means:

  • Creator sees one emoji, other members see a different one
  • The emoji can't be included in invites because it's not shared

Solution

New shared field: ConversationCustomMetadata.emoji

  • Added optional string emoji = 6 to conversation_custom_metadata.proto
  • Creator generates emoji once from their clientConversationId seed at creation time
  • Stored in shared XMTP group appData via ensureConversationEmoji(seed:)
  • All members read the same emoji from shared metadata
  • No flip for the creator (seed matches their local draft ID)

Invite payload support

  • Added optional string emoji = 10 to invite.proto
  • Restored missing invite.proto source file (was omitted during package extraction)
  • InviteSlugOptions.emoji threaded through invite creation
  • SignedInvite.emoji accessor for invite preview rendering

Avatar priority (conversation-level)

  1. Custom conversation image
  2. Conversation emoji from shared metadata
  3. Profile/cluster member fallback
  4. Generated fallback (only if no stored emoji)

Database

  • Added conversationEmoji column to DBConversation
  • Migration: addConversationEmoji (guarded for idempotency)
  • Hydrated into Conversation model

Files changed

Schema (protobuf)

  • ConvosAppData/.../conversation_custom_metadata.proto — new field
  • ConvosAppData/.../conversation_custom_metadata.pb.swift — regenerated
  • ConvosInvites/.../invite.proto — new field + restored source
  • ConvosInvites/.../invite.pb.swift — regenerated

Core

  • XMTPGroup+CustomMetadata.swift — read/write helpers
  • ConversationWriter.swift — ensures emoji at creation, placeholder support
  • DBConversation.swift — new column
  • Conversation.swift — avatar priority, defaultEmoji fallback
  • SharedDatabaseMigrator.swift — migration
  • DBConversationDetails+Conversation.swift — hydration
  • MessagesRepository.swift — conversation hydration
  • UnusedConversationCache.swift — new field in constructors
  • ModelMocks.swift — updated mocks

Invites

  • InvitePayloadExtensions.swift — emoji in options/payload/accessors
  • SignedInvite+Signing.swift — threads emoji into invite creation

Backward compatibility

  • Old conversations without stored emoji fall back to the existing local derivation
  • Old invite payloads without emoji field decode cleanly (protobuf optional)
  • Migration is guarded against duplicate column addition

Testing

  • swift build --package-path ConvosInvites
  • swift build --package-path ConvosCore
  • Full app build via xcodebuild ✅
  • Launched on two fresh simulators ✅

Still needed

  • Invite preview UI to render signedInvite.emoji when no image
  • Tests for emoji metadata round-trip, avatar priority, invite encoding
  • End-to-end validation: create convo on sim A, join on sim B, verify same emoji

Note

Add stable conversation emoji to shared metadata and invite payloads

  • Adds an emoji field to ConversationCustomMetadata proto and InvitePayload proto, persisted via a new conversationEmoji database column on DBConversation and exposed on the Conversation model.
  • When creating or joining a group, the state machine seeds a stable emoji via ensureConversationEmoji(seed:) so the same emoji appears consistently across invite slugs, invite cards, and conversation avatars.
  • Invite slugs now include the emoji in InviteSlugOptions, and MessageInviteView/ReplyReferenceInvitePreview render it when no custom image is set.
  • Replaces ComposerInvitePreviewCard with ComposerSideConvoCard, which lets users edit the side-convo name, pick a cover image, and configure an explode duration before sending; explosion scheduling is deferred to send time instead of being applied immediately on selection.
  • Adds insertPendingInvite/finalizeInvite APIs on OutgoingMessageWriter so the invite message is created locally first and updated in-place when sent, avoiding a UI flash.
  • Behavioral Change: GlobalConvoDefaults.includeInfoWithInvites and includeInfoInPublicPreview now default to true instead of false.

Macroscope summarized c4a6c7f.

@claude
Copy link
Copy Markdown

claude bot commented Apr 10, 2026

Claude finished @yewreeka's task —— View job


Code Review Complete ✅

Reviewed PR #686: stable conversation emoji in shared metadata + invite payloads

Summary

This PR successfully implements stable conversation emojis by storing them in shared XMTP metadata and including them in invite payloads. The implementation is well-structured with proper database migrations, protobuf schema updates, and UI integration. Macroscope has already identified and you've fixed most critical issues. However, there are a few remaining concerns and recommendations.


Critical Issues 🔴

None remaining - all critical issues identified by Macroscope have been resolved.


High Priority Issues 🟠

1. Missing Verification in ensureConversationEmoji

Location: ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/XMTPGroup+CustomMetadata.swift:61-76

The verification closure at line 72 checks metadata.hasEmoji && !metadata.emoji.isEmpty, which is correct for verifying an emoji exists, but the implementation could be clearer about intent. The verify closure ensures any emoji is set, not necessarily the specific generatedEmoji.

Current behavior: If a concurrent caller sets a different emoji between read and write, the verify will pass (correctly), but this is an "ensure exists" operation, not "ensure this specific value" operation.

Recommendation: The current implementation is actually correct for idempotent "ensure" semantics. Consider adding a comment clarifying this is intentional:

// Verify that *an* emoji exists (not necessarily the generated one,
// as another client may have set a different emoji concurrently)
} verify: { metadata in
    metadata.hasEmoji && !metadata.emoji.isEmpty
}

Medium Priority Issues 🟡

2. Fallback Emoji Logic Inconsistency

Location: ConvosCore/Sources/ConvosCore/Storage/Models/Conversation.swift:78-83

The defaultEmoji property returns conversationEmoji if non-empty, else generates one from clientConversationId. However, in avatarType (lines 85-99), there's duplicate logic checking conversationEmoji again.

Issue: The logic is correct but duplicated. If conversationEmoji is an empty string "", defaultEmoji correctly falls through to generation (line 82), but avatarType checks again at line 89.

Recommendation: Consider consolidating the logic:

var avatarType: ConversationAvatarType {
    if imageURL != nil {
        return .customImage
    }
    if !defaultEmoji.isEmpty {  // Use defaultEmoji instead of checking conversationEmoji again
        return .emoji(defaultEmoji)
    }
    // ... rest of logic
}

This eliminates the duplicate check and centralizes the emoji priority logic.

3. Error Handling in finalizeSideConvo

Location: Convos/Conversation Detail/ConversationViewModel.swift:1128-1137

The fallback error handler at line 1132 uses try await messageWriter.finalizeInvite() wrapped in a do-catch, but if this fallback also fails, the function returns (inviteURL, nil) which sets finalInviteURL = nil in the caller (line 1051 in onSendMessage).

Issue: When both the image upload path and the fallback path fail, the invite message is never sent. The pending message remains in the database in an unfinalized state.

Recommendation: Consider either:

  1. Queueing a retry mechanism for failed finalization
  2. Marking the pending message as failed and notifying the user
  3. Documenting this edge case behavior

Low Priority / Code Quality 🟢

4. Test Coverage Gaps

Current tests:

  • DefaultConversationDisplayTests.swift covers emoji selector, avatar type, and display logic ✅
  • QA test plan 34-side-convo-stable-emoji.md covers end-to-end flow ✅

Missing tests:

  • ensureConversationEmoji() metadata round-trip (concurrent updates)
  • MessageInvite.from(text:) with emoji field
  • DBMessage.with(text:invite:) contentType update (fixed per Macroscope review)
  • Database migration idempotency for addConversationEmoji
  • Invite payload encoding/decoding with emoji field

Recommendation: Add unit tests for the protobuf round-trip and metadata operations to ensure the emoji field is correctly preserved across serialization boundaries.

5. Database Migration Safety

Location: ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift:479-487

The migration correctly checks for column existence before adding:

let hasConversationEmoji = columns.contains(where: { $0.name == "conversationEmoji" })
guard !hasConversationEmoji else { return }

This is good defensive programming. ✅

Recommendation: Consider adding a test that runs the migration twice to verify idempotency (though this is covered by the guard).

6. Accessibility Label Priority Mismatch (Fixed)

Location: Convos/Conversation Detail/Messages/Messages View Controller/View Controller/Views/MessagesInputView.swift:504

Per Macroscope review, the accessibility label now correctly prioritizes convoImage over resolvedEmoji, matching the visual display order. ✅ This is resolved.


Security Considerations 🔒

Protobuf Emoji Field:

  • The emoji field in both ConversationCustomMetadata and InvitePayload is marked optional
  • No length validation on emoji input (could be a very long string)

Recommendation: Consider adding validation in EmojiSelector.emoji(for:) callers to ensure the stored emoji is a single valid emoji character. Currently, if a malicious actor sets metadata.emoji = "A".repeat(10000), it would be stored and displayed.

Suggested fix:

// In ensureConversationEmoji or wherever emoji is set from user input
let validatedEmoji = generatedEmoji.prefix(10)  // or use proper emoji validation
metadata.emoji = String(validatedEmoji)

Performance Considerations ⚡

Image Caching

The cacheAfterUpload call at ConversationViewModel.swift:1109 correctly pre-caches the invite image. ✅

Metadata Updates

The atomicUpdateMetadata pattern in ensureConversationEmoji uses optimistic locking with retry, which is appropriate for distributed consensus. ✅

No performance concerns identified.


Backward Compatibility ✅

  • Old conversations without conversationEmoji fall back to local derivation ✅
  • Old invite payloads decode cleanly (protobuf optional field) ✅
  • Migration is guarded for idempotency ✅

All backward compatibility requirements met.


Documentation & Code Quality

Positive Observations:

  • Clear separation of concerns (proto, core, UI layers)
  • Proper use of protocols for testability
  • Good error logging throughout
  • QA test plan is thorough and well-structured

Suggestions:

  • Add inline comments for the emoji priority logic in Conversation.avatarType
  • Document the "ensure exists" semantics in ensureConversationEmoji
  • Consider extracting ComposerSideConvoCard emoji resolution logic into a computed property for clarity

Open Macroscope Comments Status

All Macroscope review comments marked "No longer relevant" have been properly addressed:

  • with(text:invite:) now sets contentType: .invite
  • finalizeInvite uses clientMessageId column lookup
  • publishText uses raw SQL UPDATE for ID changes
  • ✅ Accessibility label priority fixed
  • selectedPhotoItem reset after loading
  • operation parameter added to atomicUpdateMetadata

Recommendations Summary

Must fix before merge:

  • None - all critical issues resolved

Should fix:

  1. Add emoji length/validation to prevent malicious extremely long emoji strings
  2. Clarify error handling in finalizeSideConvo fallback path
  3. Add unit tests for protobuf emoji field round-trip

Nice to have:

  1. Consolidate emoji fallback logic in Conversation.avatarType
  2. Add comments clarifying "ensure exists" semantics
  3. Extract emoji resolution logic in ComposerSideConvoCard

Final Verdict

Approvable with minor improvements. The core implementation is solid, backward compatible, and properly tested at the integration level. The remaining issues are primarily around edge case error handling, validation hardening, and unit test coverage. None are blocking.

Great work on the comprehensive implementation spanning proto schemas, database migrations, core business logic, and UI integration! 🎉


• Branch: jarod/stable-conversation-emoji

Comment thread ConvosCore/Sources/ConvosCore/Storage/Models/Conversation.swift
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 10, 2026

Approvability

Verdict: Needs human review

This PR introduces a new feature for stable conversation emoji across devices, including protobuf schema changes, a database migration, new UI components for side convo editing, and a default behavior change for invite metadata. The scope and nature of these changes—new user-facing functionality, schema impacts, and default behavior modification—warrant human review.

You can customize Macroscope's approvability policy. Learn more.

Base automatically changed from jarod/profile-emoji-avatar to dev April 10, 2026 19:26
Comment thread Convos/Conversation Detail/ConversationViewModel.swift Outdated
@yewreeka yewreeka force-pushed the jarod/stable-conversation-emoji branch from 332e416 to e6b2f07 Compare April 13, 2026 16:57
yewreeka added 18 commits April 14, 2026 13:18
Add a conversation-level emoji field to ConversationCustomMetadata and
InvitePayload so that all members see the same default emoji for a
conversation, and invites carry the emoji for preview rendering.

Schema changes:
- conversation_custom_metadata.proto: added optional string emoji = 6
- invite.proto: added optional string emoji = 10
- Both .pb.swift files regenerated via protoc with Visibility=Public

Conversation emoji lifecycle:
- Creator generates emoji from clientConversationId seed at creation
- Stored once in shared XMTP group appData via ensureConversationEmoji()
- All members read the same emoji from shared metadata
- No flip for creator (seed matches their local draft ID)

Avatar priority (conversation-level):
1. Custom conversation image
2. Conversation emoji from shared metadata
3. Profile/cluster member fallback
4. Generated fallback (only if no stored emoji)

Database:
- Added conversationEmoji column to DBConversation
- Migration: addConversationEmoji (guarded for idempotency)
- Hydrated into Conversation model

Invite support:
- InviteSlugOptions.emoji threaded through invite creation
- SignedInvite.emoji accessor for invite preview rendering
- Restored invite.proto source (was missing from repo)

Files changed:
- ConvosAppData: proto + generated pb.swift
- ConvosInvites: proto + generated pb.swift + payload extensions
- ConvosCore: XMTPGroup+CustomMetadata, ConversationWriter,
  DBConversation, Conversation, MessagesRepository,
  SharedDatabaseMigrator, SignedInvite+Signing,
  UnusedConversationCache, ModelMocks, hydration
Update invite bubble, reply reference, and MessageInvite model to
display the conversation emoji from the invite payload when no
custom image is available.

Changes:
- MessageInvite: added emoji field, threaded from SignedInvite
- MessageInviteContainerView: emoji fallback before convosOrangeIcon
- ReplyReferenceInvitePreview: emoji fallback before convosOrangeIcon
- Mock updated with sample emoji for previews
…ting

The emoji field was gated behind includePublicPreview, which defaults
to false for new conversations. Unlike name/description/imageURL,
the emoji is a lightweight non-identifying field that should always
be included for invite rendering.
…metadata

Replace OG metadata fetch in ComposerInvitePreviewCard with direct
invite slug decoding via MessageInvite.from(text:). This is faster
and uses the structured invite data (emoji, name, imageURL) instead
of making a network request.

Priority for composer invite preview image area:
1. Invite image (from invite payload imageURL)
2. Conversation emoji (from invite payload emoji)
3. Convos logo fallback

Also renamed the side convo button:
- accessibilityLabel: 'Convos' -> 'Side convo'
- accessibilityIdentifier: 'convos-action-button' -> 'side-convo-button'
…cache

The conversation emoji was only being ensured in ConversationWriter._store(),
which runs after the invite is already generated. For pre-warmed unused
conversations, the invite was cached without emoji before _store() ran.

Now ensureConversationEmoji() is called in all three creation paths:
- StreamProcessor.processConversation (creator path)
- UnusedConversationCache (pre-warmed conversation setup)
- ConversationWriter._store() (existing, as backup)

Also thread conversation emoji from local DB through composer:
ConversationView -> MessagesView -> MessagesBottomBar -> MessagesInputView
-> ComposerInvitePreviewCard (as conversationEmoji fallback)
The unused cache was seeding emoji with group.id, but the creator's
local view uses clientConversationId (draft-XXX) for the default emoji.
This caused a mismatch: conversation header showed one emoji while the
invite preview showed a different one.

The emoji should only be seeded when the creator's processConversation
or ConversationWriter._store runs, which has access to the correct
clientConversationId seed. This ensures no mismatch between the
creator's local emoji and the shared metadata emoji.
…aft ID

Guard ensureConversationEmoji to only run when clientConversationId is
provided. This prevents the XMTP conversation stream from racing with
the explicit creation path and seeding a different emoji (from group ID
instead of draft ID).

The conversation stream calls processConversation with nil clientConversationId,
which was falling back to conversation.id and potentially winning the race,
storing a different emoji than the one the creator sees locally.

Now the emoji is only seeded by:
- ConversationStateMachine -> StreamProcessor (has clientConversationId)
- ConversationWriter._store (only when clientConversationId is provided)

The stream/sync path will read existing emoji but never seed a new one.
Move ensureConversationEmoji to the two places where a conversation
is first associated with a user-visible identity:

1. handleCreate() - right after publish, using clientConversationId (draft ID)
2. handleUseExisting() - when consuming an unused conversation, using
   clientConversationId (draft ID)

Removed emoji seeding from:
- ConversationWriter._store() - was racing with stream path
- StreamProcessor.processConversation - was redundant/racing

This ensures the emoji is seeded exactly once with the correct draft ID
seed before any other path can race to set a different one.
…onId

The invite now always has an emoji regardless of XMTP metadata timing.
It prefers conversationEmoji from shared metadata if available, and
falls back to EmojiSelector.emoji(for: clientConversationId) which
deterministically produces the same emoji the creator sees locally.

This eliminates the dependency on ensureConversationEmoji completing
before the invite is generated.
…xisting

XMTPiOS.Conversation is an enum (.group/.dm), not a class hierarchy.
The 'as? XMTPiOS.Group' cast was silently failing, so the emoji was
never being seeded for consumed unused conversations.

Also ensure invite always has emoji via deterministic fallback from
clientConversationId in SignedInvite+Signing.
… explode menu

Replace ComposerInvitePreviewCard with ComposerSideConvoCard:
- Large square emoji area (80pt font, square aspect ratio)
- Editable 'Convo name' text field that updates the linked conversation
  name and regenerates the invite slug on submit
- 'Explodes in...' label with chevron for duration picker menu
- Removed 'Pop into this convo' / 'You're invited' text

Threading:
- pendingInviteConvoName binding from ConversationViewModel through
  MessagesView -> MessagesBottomBar -> MessagesInputView
- onInviteConvoNameEditingEnded callback updates linked conversation
  name via metadataWriter and refreshes the invite URL
- Renamed button: 'convos-action-button' -> 'side-convo-button'
Prevents adding multiple side convo invites. The button is visually
dimmed (0.3 opacity) and functionally disabled when pendingInviteURL
is non-nil.
…iss/send

- Move name outside includePublicPreview gate in invite encoding,
  same as emoji. Name and emoji are lightweight fields that should
  always be included for invite rendering.
- Reset pendingInviteConvoName to empty string when invite is sent
  or cleared, so the next side convo starts fresh.
Reverted name back inside the includePublicPreview gate (respecting
the user's privacy preference). Changed the default value of
includeInfoWithInvites from false to true so new users get name/
description/image in invites by default.
When a side convo invite is created via the side convo button,
automatically set the explode duration to 1 day and schedule
the explosion on the linked conversation.
Added a 'Never' button to the explode duration menu that sets the
duration to nil. The label shows 'Not exploding' when no duration
is set.
- Title shows conversation name or 'New Convo' fallback (instead of
  'Pop into my convo')
- Description always shows 'Tap to join' (instead of 'convos.org')
yewreeka and others added 23 commits April 14, 2026 13:18
- Added colorBackgroundPic color asset (#FAFAFA light / #FFFFFF dark)
- Use colorBackgroundPic for emoji/image area in both composer card
  and invite message cell
- Default includeInfoInPublicPreview to true in all conversation
  creation paths (UnusedConversationCache, ConversationWriter)
  so name/description/image are included in invite slugs by default
- Matches the GlobalConvoDefaults change to default true
When the user types a name in the composer card and hits send without
pressing Return first, the name update + invite refresh now happens
inside the send Task before the invite URL is actually sent. This
ensures the invite slug always contains the latest convo name.
- Invite cell title font weight: bold -> medium
- Invite cell emoji size: 72pt -> 160pt (nearly fills the square)
- Composer card emoji size: 80pt -> 120pt
- Composer convo name field: rounded rect -> capsule shape
Previously, selecting an explode duration in the composer immediately
scheduled the explosion on the linked conversation. Now:

- setInviteExplodeDuration just updates the local UI state
- The explosion is scheduled in the send Task alongside the name update
- Both name and explosion are applied before refreshing the invite URL
- 'Never' works correctly since it just means no explosion is scheduled

This means the side convo only gets its explosion set when the invite
is actually sent, not while the user is still editing.
Use MessageContainer style .none to prevent the dark bubble background
from bleeding through the invite cell. Apply own RoundedRectangle
clip shape instead.
Options: Never, 60 seconds, 1 hour, 24 hours, Sunday at midnight
Section title: 'Explode messages and members'
Composer label uses short labels (60s, 1h, 24h, Sun)
Menu items use full labels
Default side convo duration: 24 hours
1. Empty string handling in defaultEmoji: check for non-empty before
   returning conversationEmoji, matching the pattern used in avatarType
2. Overly strict verification in ensureConversationEmoji: verify that
   *an* emoji exists rather than checking for the specific generated
   value, matching the pattern used in ensureInviteTag()
3. Error handling in handleCreate: wrap emoji seeding in try/catch so
   a failure doesn't abort successful conversation creation, matching
   the pattern used in handleUseExisting()
4. Don't clear cachedImage on network error in invite cell
Tapping the emoji/image area in the composer side convo card opens
a PhotosPicker. When an image is selected:
- It replaces the emoji in the card preview
- On send, the image is uploaded to the linked conversation via
  metadataWriter.updateImage (encrypted + public preview)
- The invite URL is refreshed to include the public image URL

The image is cleared on send/dismiss along with the name.
Split the side convo send flow into two phases:

Fast path (before sending invite message):
- Update conversation name
- Schedule explosion
- Refresh invite URL

Background (after sending invite message):
- Upload conversation image (encrypted + public preview)

This ensures the invite message appears immediately in the messages
list. The image upload happens in a detached Task and will be visible
when the recipient opens the side convo.
Before sending the invite message, pre-cache the selected image using
the invite slug as the cache key. The invite cell's .cachedImage(for:)
modifier will find it immediately in memory.

The image upload still happens in a background Task so it doesn't
block the invite message from appearing. The invite cell shows the
local image right away, and when the recipient opens the side convo
they'll see the uploaded version.
The image upload and invite refresh now happen before the invite
message is sent (blocking). This ensures the invite URL contains
the public image URL so the receiver sees the image in the invite
preview without needing to join the convo first.

The local image is still pre-cached using the final invite slug
so the sender sees it immediately in the message cell.
Add insertPendingInvite/finalizeInvite to OutgoingMessageWriter for
a two-phase invite send pattern:

1. Fast metadata ops (name, explosion, invite refresh)
2. Insert pending invite (cell appears immediately with cached image)
3. Upload image to linked conversation (blocking)
4. Refresh invite URL with public image URL
5. Finalize: update pending message text and publish

Extracted finalizeSideConvo method to reduce cyclomatic complexity.

If image upload fails, the invite is finalized with the original
URL (emoji only, no image). If there's no image, the invite goes
through the normal single-phase send path.
Side convos created from pre-warmed unused conversations may have
includeInfoInPublicPreview=false (the old default). This caused
the image URL and name to be excluded from the invite slug even
after uploading.

Now finalizeSideConvo explicitly enables includeInfoInPublicPreview
before updating name/image and refreshing the invite.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, a11y, contentType

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…eter_count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@yewreeka yewreeka force-pushed the jarod/stable-conversation-emoji branch from a862f89 to e50410f Compare April 14, 2026 21:25
Comment thread Convos/Conversation Detail/ConversationViewModel.swift
Comment thread ConvosCore/Sources/ConvosCore/Storage/Writers/OutgoingMessageWriter.swift Outdated
@yewreeka yewreeka merged commit 20dbe8b into dev Apr 14, 2026
7 of 8 checks passed
@yewreeka yewreeka deleted the jarod/stable-conversation-emoji branch April 14, 2026 22:28
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.

1 participant