feat: stable conversation emoji in shared metadata + invite payloads#686
feat: stable conversation emoji in shared metadata + invite payloads#686
Conversation
|
Claude finished @yewreeka's task —— View job Code Review Complete ✅Reviewed PR #686: stable conversation emoji in shared metadata + invite payloads SummaryThis 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
|
ApprovabilityVerdict: 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. |
332e416 to
e6b2f07
Compare
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')
- 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>
a862f89 to
e50410f
Compare
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 adraft-...ID) and other members (who use the stable XMTP group ID). This means:Solution
New shared field:
ConversationCustomMetadata.emojioptional string emoji = 6toconversation_custom_metadata.protoclientConversationIdseed at creation timeensureConversationEmoji(seed:)Invite payload support
optional string emoji = 10toinvite.protoinvite.protosource file (was omitted during package extraction)InviteSlugOptions.emojithreaded through invite creationSignedInvite.emojiaccessor for invite preview renderingAvatar priority (conversation-level)
Database
conversationEmojicolumn toDBConversationaddConversationEmoji(guarded for idempotency)ConversationmodelFiles changed
Schema (protobuf)
ConvosAppData/.../conversation_custom_metadata.proto— new fieldConvosAppData/.../conversation_custom_metadata.pb.swift— regeneratedConvosInvites/.../invite.proto— new field + restored sourceConvosInvites/.../invite.pb.swift— regeneratedCore
XMTPGroup+CustomMetadata.swift— read/write helpersConversationWriter.swift— ensures emoji at creation, placeholder supportDBConversation.swift— new columnConversation.swift— avatar priority, defaultEmoji fallbackSharedDatabaseMigrator.swift— migrationDBConversationDetails+Conversation.swift— hydrationMessagesRepository.swift— conversation hydrationUnusedConversationCache.swift— new field in constructorsModelMocks.swift— updated mocksInvites
InvitePayloadExtensions.swift— emoji in options/payload/accessorsSignedInvite+Signing.swift— threads emoji into invite creationBackward compatibility
Testing
swift build --package-path ConvosInvites✅swift build --package-path ConvosCore✅Still needed
signedInvite.emojiwhen no imageNote
Add stable conversation emoji to shared metadata and invite payloads
emojifield toConversationCustomMetadataproto andInvitePayloadproto, persisted via a newconversationEmojidatabase column onDBConversationand exposed on theConversationmodel.ensureConversationEmoji(seed:)so the same emoji appears consistently across invite slugs, invite cards, and conversation avatars.InviteSlugOptions, andMessageInviteView/ReplyReferenceInvitePreviewrender it when no custom image is set.ComposerInvitePreviewCardwithComposerSideConvoCard, 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.insertPendingInvite/finalizeInviteAPIs onOutgoingMessageWriterso the invite message is created locally first and updated in-place when sent, avoiding a UI flash.GlobalConvoDefaults.includeInfoWithInvitesandincludeInfoInPublicPreviewnow default totrueinstead offalse.Macroscope summarized c4a6c7f.