Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
3743125
new profile database tables
cameronvoell Jun 30, 2026
a5e059e
lint fixes
cameronvoell Jun 30, 2026
5b78405
improve ci result output
cameronvoell Jun 30, 2026
f8ab31f
improve ci result output
cameronvoell Jun 30, 2026
774b9a4
test fix
cameronvoell Jun 30, 2026
ef96ce8
add profile-table persistence stores
cameronvoell Jun 30, 2026
39f1f5c
profile merge, repository, and unified profile
cameronvoell Jun 30, 2026
2f062f0
fix test
cameronvoell Jul 1, 2026
69a817b
profile publisher and backfill
cameronvoell Jul 1, 2026
3841411
start writing to new profile table
cameronvoell Jul 1, 2026
2169155
concrete publish session written and verified
cameronvoell Jul 1, 2026
da33455
unified profile: wire repository reads, self-publish, and self-profil…
cameronvoell Jul 1, 2026
15ec38a
unified profile: global-only self profile + drop member_profile
cameronvoell Jul 1, 2026
21f4e92
make the legacy member_profile table inert
cameronvoell Jul 1, 2026
4316f8e
keep member_profile populated (drop the clear)
cameronvoell Jul 1, 2026
cd40fce
existing users get seeded into DBSelfProfile
cameronvoell Jul 1, 2026
0097dfa
prefer unified profile data over contact table data
cameronvoell Jul 1, 2026
7b147fb
fixed issue with self name not rendering in some chat views
cameronvoell Jul 3, 2026
5c66e78
use existing myProfile table instead of new selfProfile
cameronvoell Jul 3, 2026
4461e27
migrate legacy DBMemberProfile readers onto canonical profile tables
cameronvoell Jul 3, 2026
a896035
write app-data profiles into canonical profile tables at .appData source
cameronvoell Jul 3, 2026
75d1c31
resolve removed-member message/reaction senders from canonical DBProfile
cameronvoell Jul 3, 2026
97a2209
fix DB-only-agent snapshot test to seed canonical profile tables
cameronvoell Jul 3, 2026
a6b3409
give AgentTemplateCacheCoordinator observation test CI headroom
cameronvoell Jul 3, 2026
99953f7
never clear avatars from an inbound message and surface the self avat…
cameronvoell Jul 3, 2026
a0b578b
guard redundant profile publish on conversation open
cameronvoell Jul 3, 2026
5a31aad
clear canonical profile tables on delete-all
cameronvoell Jul 3, 2026
119d8f2
lazy, change-aware profile propagation
cameronvoell Jul 6, 2026
3d73755
fix DeleteAllDataCleanSlateTests
cameronvoell Jul 6, 2026
a0c7b42
make sure profile update gets sent in error cases and after delete all
cameronvoell Jul 6, 2026
1bdc893
feedback: harden the profile publish queue
cameronvoell Jul 6, 2026
66b2f69
remove dead code
cameronvoell Jul 6, 2026
92c3728
temporarily disable firebase build while we finish PR feedback
cameronvoell Jul 6, 2026
665ec4b
update plan to match implementation
cameronvoell Jul 6, 2026
ddf432d
fix self-profile scoping + publish-queue races
cameronvoell Jul 6, 2026
c496d24
fix regression on truncating prof names, historical group member name…
cameronvoell Jul 6, 2026
7e5fed6
cancel launch service-bootstrap task on teardown
cameronvoell Jul 6, 2026
57f3d04
resolve left-self historical messages from DBMyProfile
cameronvoell Jul 6, 2026
d5eb11f
cancel freshly-built profile-services task on teardown
cameronvoell Jul 6, 2026
41ff651
fix conversation list dm prof pic updates
cameronvoell Jul 6, 2026
57e0e35
adds a cooperative-cancellation guard before loadOrCreateService
cameronvoell Jul 6, 2026
6f5c4f1
re-enable firebase build
cameronvoell Jul 6, 2026
14ea335
clean up comments and update plan with latest follow up items
cameronvoell Jul 6, 2026
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
10 changes: 10 additions & 0 deletions .github/workflows/convoscore-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ jobs:
- name: Run unit tests
run: ./ci/run-tests.sh --unit

- name: Upload unit test logs
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-logs
path: ci-test-logs/
retention-days: 7
if-no-files-found: ignore

deploy-backend:
name: Deploy XMTP Backend
runs-on: ubuntu-latest
Expand Down Expand Up @@ -109,6 +118,7 @@ jobs:
with:
name: integration-test-logs
path: |
ci-test-logs/
ConvosCore/.build/debug/*.log
xmtp-test-logs/
retention-days: 7
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ playground.xcworkspace

.build/

# CI test logs captured by ci/run-tests.sh (uploaded as workflow artifacts).
ci-test-logs/
# XMTP integration test logs (CONVOS_TEST_XMTP_LOG_DIR in CI).
xmtp-test-logs/

# SwiftLint local cache, used by `swiftlint --cache-path .swiftlint-cache`
# and the GitHub Actions cache.
.swiftlint-cache/
Expand Down
68 changes: 64 additions & 4 deletions Convos/Contacts/ContactRowView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import ConvosCore
import CryptoKit
import Foundation
Expand Down Expand Up @@ -78,17 +79,76 @@ struct ContactAvatarView: View {
let contact: Contact

var body: some View {
AvatarView(
InboxProfileAvatarView(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
inboxId: contact.inboxId,
fallbackName: contact.resolvedDisplayName,
cacheableObject: contact,
placeholderImage: nil,
placeholderEmoji: contact.profileEmoji,
placeholderImageName: nil,
agentVerification: contact.agentVerification ?? .unverified
)
}
}

/// Avatar keyed by inbox id that renders the canonical avatar from the unified
/// profile system (newest avatar across every conversation) and stays in sync
/// with profile changes via `ProfilesRepository.profilePublisher`. Sharing the
/// `Profile.imageCacheIdentifier == inboxId` key means this shares the
/// encrypted-image cache with the chat surfaces (message bubbles, members
/// list), so an update flowing through the repository invalidates once and
/// every consumer picks up the new image.
///
/// When no repository is injected (previews / uninjected subtrees) the view
/// falls through to the placeholder for the empty profile rather than crashing.
struct InboxProfileAvatarView: View {
let inboxId: String
let fallbackName: String
let placeholderEmoji: String?
let agentVerification: AgentVerification

@Environment(\.profilesRepository) private var repository: ProfilesRepository?
@State private var renderedProfile: Profile?

var body: some View {
let cacheable: Profile = renderedProfile ?? Profile.empty(inboxId: inboxId)
AvatarView(
fallbackName: fallbackName,
cacheableObject: cacheable,
placeholderImage: nil,
placeholderEmoji: placeholderEmoji,
placeholderImageName: nil,
agentVerification: agentVerification
)
.task(id: inboxId) {
await observeProfile(for: inboxId)
}
}

/// Subscribes to the unified profile for `subscribedInboxId` and maps each
/// emission into a `Profile` for the shared avatar cache. Awaiting the
/// publisher's async `values` sequence keeps the subscription tied to the
/// view's lifetime (cancelled when the task is torn down or `inboxId`
/// changes) without the resubscription churn a recomputed `onReceive`
/// publisher would cause. No repository (previews) leaves the placeholder.
private func observeProfile(for subscribedInboxId: String) async {
guard let repository else { return }
let stream = repository.profilePublisher(inboxId: subscribedInboxId).values
for await unified in stream {
let avatar: Avatar? = unified.displayAvatar(for: nil)
let profile = Profile(
inboxId: unified.inboxId,
conversationId: "",
name: unified.name,
avatar: avatar?.url,
avatarSalt: avatar?.salt,
avatarNonce: avatar?.nonce,
avatarKey: avatar?.key,
isAgent: unified.isAgent,
metadata: unified.metadata
)
renderedProfile = profile
}
}
}

#Preview {
VStack(alignment: .leading) {
ContactRowView(contact: .mock(displayName: "Alice"), subtitle: "Bike Trip 2026")
Expand Down
50 changes: 18 additions & 32 deletions Convos/Contacts/MemberNameOverride.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,21 @@ import ConvosCore
import Foundation
import SwiftUI

/// SwiftUI environment carrier for the inbox-to-contact override
/// closure. Each surface that wants contact-authoritative rendering
/// injects the closure once near the top of its view tree, typically
/// `.memberContactOverride(contactsRepository.contact(for:))`.
/// Descendants read `@Environment(\.memberContactOverride)` for richer
/// substitutions (avatar, encrypted image keys, agent verification) or
/// `@Environment(\.memberNameOverride)` for the name-only adapter that
/// ConvosCore APIs (`Conversation.computedDisplayName(memberNameOverride:)`,
/// `ConversationUpdate.summary(memberNameOverride:)`) accept.
/// SwiftUI environment carrier for the (now deprecated) inbox-to-contact
/// override closure. Identity - display name and image - is sourced
/// authoritatively from the `Profile` database; contact data never overrides
/// it. The resolver is always the no-op returning `nil` for every inbox, so
/// every consumer falls through to the member `Profile`.
///
/// The default is a no-op resolver returning `nil` for every inbox, which
/// preserves the legacy per-conversation profile behavior in previews
/// and any uninjected surface. The lookup itself lives on
/// `ContactsRepositoryProtocol.contact(for:)`.
/// The environment key, the `memberNameOverride` adapter, and the
/// `.memberContactOverride(_:)` modifier are retained (the modifier ignoring
/// its argument) so existing call sites compile until the override plumbing is
/// removed - see the cleanup checklist in
/// docs/plans/2026-06-29-profile-table-implementation.md.
private struct MemberContactOverrideKey: EnvironmentKey {
// `@Sendable` because SwiftUI's `EnvironmentKey.defaultValue` is
// required to be `Sendable` under strict concurrency, and the
// override is read from many isolation domains (main-actor SwiftUI
// resolver is read from many isolation domains (main-actor SwiftUI
// bodies, background contact fetches).
static let defaultValue: @Sendable (String) -> Contact? = { _ in nil }
}
Expand All @@ -30,29 +27,18 @@ extension EnvironmentValues {
set { self[MemberContactOverrideKey.self] = newValue }
}

/// Name-only adapter derived from `memberContactOverride`. Returns
/// the contact's display name when present and non-empty, otherwise
/// `nil` so ConvosCore APIs fall back to per-conversation profile
/// names. Read-only - inject via `.memberContactOverride(_:)` and
/// this adapter follows automatically.
/// Deprecated no-op name adapter. Always returns `nil` so ConvosCore APIs
/// source names from the `Profile` database.
var memberNameOverride: @Sendable (String) -> String? {
let resolver = memberContactOverride
return { inboxId in
guard let name = resolver(inboxId)?.displayName, !name.isEmpty else {
return nil
}
return name
}
{ _ in nil }
}
}

extension View {
/// Injects an inbox-to-contact override closure into the SwiftUI
/// environment so descendant surfaces (conversation list, pinned
/// tiles, info previews, system-message cells, ...) substitute the
/// user's contact data (name and avatar) in place of the
/// per-conversation profile data.
/// Deprecated no-op. Contact data no longer overrides `Profile` identity, so
/// this ignores the resolver and leaves the environment at its `nil` default.
/// Retained so call sites compile until the plumbing is removed.
func memberContactOverride(_ resolver: @escaping @Sendable (String) -> Contact?) -> some View {
environment(\.memberContactOverride, resolver)
self
}
}
79 changes: 13 additions & 66 deletions Convos/Contacts/Profile+ContactOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,80 +51,27 @@ extension Contact {
)
}

/// Builds a member-aware contact resolver: freshens the current conversation
/// member's avatar image (the source the message bubble uses) over the lagging
/// stored contact, falling back to the stored contact for non-members. Name
/// and other identity fields are left as the stored contact's. See
/// `liveOverride`. Interim stopgap; see
/// docs/plans/2026-06-29-profile-table-implementation.md, section 10.1.
/// Deprecated no-op. Identity (name and image) is now sourced authoritatively
/// from the `Profile` database; contact data never overrides it. Returns a
/// resolver that yields `nil` for every inbox so callers fall through to the
/// member `Profile`. Retained (ignoring its arguments) so call sites compile
/// until the contact-override plumbing is fully removed - see the cleanup
/// checklist in docs/plans/2026-06-29-profile-table-implementation.md.
static func memberAwareResolver(
members: [ConversationMember],
contactLookup: @escaping @Sendable (String) -> Contact?
) -> @Sendable (String) -> Contact? {
let memberProfiles: [String: Profile] = Dictionary(
members.map { ($0.profile.inboxId, $0.profile) },
uniquingKeysWith: { current, _ in current }
)
return { inboxId in
let stored = contactLookup(inboxId)
guard let member = memberProfiles[inboxId] else { return stored }
return Contact.liveOverride(member: member, stored: stored)
}
{ _ in nil }
}
}

extension Profile {
/// Returns a new `Profile` with `name` and `avatar*` substituted
/// from the supplied contact when - and only when - the contact
/// actually carries the corresponding data. A name-only contact
/// does NOT clobber the per-conversation avatar, and a contact
/// with neither field does not clobber a perfectly good per-
/// conversation profile. `conversationId`, `isAgent`,
/// `imageSourceContentDigest`, and `metadata` are always preserved
/// from the original.
///
/// The chat layer uses this so a system-message or avatar row
/// shows the contact's name and photo when the inbox is a known
/// contact whose per-conversation profile has not landed yet,
/// without regressing rows where the per-conversation profile is
/// already richer than the contact entry.
/// Deprecated no-op. Identity (name and image) is sourced authoritatively
/// from the `Profile` database; contact data never overrides it, so this
/// returns the profile unchanged. Retained so call sites compile until the
/// contact-override plumbing is removed - see the cleanup checklist in
/// docs/plans/2026-06-29-profile-table-implementation.md.
func overlaying(contact: Contact) -> Profile {
let resolvedName: String?
if let contactName = contact.displayName, !contactName.isEmpty {
resolvedName = contactName
} else {
resolvedName = name
}
// Avatar fields move as a set: a contact without an avatar URL
// has no usable encryption material either, so substituting
// any one of them in isolation would leave the cache key
// pointing at an inaccessible blob.
let resolvedAvatar: String?
let resolvedAvatarSalt: Data?
let resolvedAvatarNonce: Data?
let resolvedAvatarKey: Data?
if contact.avatarURL != nil {
resolvedAvatar = contact.avatarURL
resolvedAvatarSalt = contact.avatarSalt
resolvedAvatarNonce = contact.avatarNonce
resolvedAvatarKey = contact.avatarKey
} else {
resolvedAvatar = avatar
resolvedAvatarSalt = avatarSalt
resolvedAvatarNonce = avatarNonce
resolvedAvatarKey = avatarKey
}
return Profile(
inboxId: inboxId,
conversationId: conversationId,
name: resolvedName,
avatar: resolvedAvatar,
avatarSalt: resolvedAvatarSalt,
avatarNonce: resolvedAvatarNonce,
avatarKey: resolvedAvatarKey,
isAgent: isAgent,
imageSourceContentDigest: imageSourceContentDigest,
metadata: metadata
)
self
}
}
Loading
Loading