Skip to content

Commit 0af531c

Browse files
committed
prefer unified profile data over contact table data
1 parent 55c119b commit 0af531c

15 files changed

Lines changed: 299 additions & 129 deletions

Convos/Contacts/ContactRowView.swift

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Combine
12
import ConvosCore
23
import CryptoKit
34
import Foundation
@@ -78,17 +79,76 @@ struct ContactAvatarView: View {
7879
let contact: Contact
7980

8081
var body: some View {
81-
AvatarView(
82+
InboxProfileAvatarView(
83+
inboxId: contact.inboxId,
8284
fallbackName: contact.resolvedDisplayName,
83-
cacheableObject: contact,
84-
placeholderImage: nil,
8585
placeholderEmoji: contact.profileEmoji,
86-
placeholderImageName: nil,
8786
agentVerification: contact.agentVerification ?? .unverified
8887
)
8988
}
9089
}
9190

91+
/// Avatar keyed by inbox id that renders the canonical avatar from the unified
92+
/// profile system (newest avatar across every conversation) and stays in sync
93+
/// with profile changes via `ProfilesRepository.profilePublisher`. Sharing the
94+
/// `Profile.imageCacheIdentifier == inboxId` key means this shares the
95+
/// encrypted-image cache with the chat surfaces (message bubbles, members
96+
/// list), so an update flowing through the repository invalidates once and
97+
/// every consumer picks up the new image.
98+
///
99+
/// When no repository is injected (previews / uninjected subtrees) the view
100+
/// falls through to the placeholder for the empty profile rather than crashing.
101+
struct InboxProfileAvatarView: View {
102+
let inboxId: String
103+
let fallbackName: String
104+
let placeholderEmoji: String?
105+
let agentVerification: AgentVerification
106+
107+
@Environment(\.profilesRepository) private var repository: ProfilesRepository?
108+
@State private var renderedProfile: Profile?
109+
110+
var body: some View {
111+
let cacheable: Profile = renderedProfile ?? Profile.empty(inboxId: inboxId)
112+
AvatarView(
113+
fallbackName: fallbackName,
114+
cacheableObject: cacheable,
115+
placeholderImage: nil,
116+
placeholderEmoji: placeholderEmoji,
117+
placeholderImageName: nil,
118+
agentVerification: agentVerification
119+
)
120+
.task(id: inboxId) {
121+
await observeProfile(for: inboxId)
122+
}
123+
}
124+
125+
/// Subscribes to the unified profile for `subscribedInboxId` and maps each
126+
/// emission into a `Profile` for the shared avatar cache. Awaiting the
127+
/// publisher's async `values` sequence keeps the subscription tied to the
128+
/// view's lifetime (cancelled when the task is torn down or `inboxId`
129+
/// changes) without the resubscription churn a recomputed `onReceive`
130+
/// publisher would cause. No repository (previews) leaves the placeholder.
131+
private func observeProfile(for subscribedInboxId: String) async {
132+
guard let repository else { return }
133+
let stream = repository.profilePublisher(inboxId: subscribedInboxId).values
134+
for await unified in stream {
135+
let avatar: Avatar? = unified.displayAvatar(for: nil)
136+
let profile = Profile(
137+
inboxId: unified.inboxId,
138+
conversationId: "",
139+
name: unified.name,
140+
avatar: avatar?.url,
141+
avatarSalt: avatar?.salt,
142+
avatarNonce: avatar?.nonce,
143+
avatarKey: avatar?.key,
144+
isAgent: unified.isAgent,
145+
metadata: unified.metadata
146+
)
147+
renderedProfile = profile
148+
}
149+
}
150+
}
151+
92152
#Preview {
93153
VStack(alignment: .leading) {
94154
ContactRowView(contact: .mock(displayName: "Alice"), subtitle: "Bike Trip 2026")

Convos/Contacts/MemberNameOverride.swift

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,21 @@ import ConvosCore
22
import Foundation
33
import SwiftUI
44

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

33-
/// Name-only adapter derived from `memberContactOverride`. Returns
34-
/// the contact's display name when present and non-empty, otherwise
35-
/// `nil` so ConvosCore APIs fall back to per-conversation profile
36-
/// names. Read-only - inject via `.memberContactOverride(_:)` and
37-
/// this adapter follows automatically.
30+
/// Deprecated no-op name adapter. Always returns `nil` so ConvosCore APIs
31+
/// source names from the `Profile` database.
3832
var memberNameOverride: @Sendable (String) -> String? {
39-
let resolver = memberContactOverride
40-
return { inboxId in
41-
guard let name = resolver(inboxId)?.displayName, !name.isEmpty else {
42-
return nil
43-
}
44-
return name
45-
}
33+
{ _ in nil }
4634
}
4735
}
4836

4937
extension View {
50-
/// Injects an inbox-to-contact override closure into the SwiftUI
51-
/// environment so descendant surfaces (conversation list, pinned
52-
/// tiles, info previews, system-message cells, ...) substitute the
53-
/// user's contact data (name and avatar) in place of the
54-
/// per-conversation profile data.
38+
/// Deprecated no-op. Contact data no longer overrides `Profile` identity, so
39+
/// this ignores the resolver and leaves the environment at its `nil` default.
40+
/// Retained so call sites compile until the plumbing is removed.
5541
func memberContactOverride(_ resolver: @escaping @Sendable (String) -> Contact?) -> some View {
56-
environment(\.memberContactOverride, resolver)
42+
self
5743
}
5844
}

Convos/Contacts/Profile+ContactOverlay.swift

Lines changed: 13 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -51,80 +51,27 @@ extension Contact {
5151
)
5252
}
5353

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

7668
extension Profile {
77-
/// Returns a new `Profile` with `name` and `avatar*` substituted
78-
/// from the supplied contact when - and only when - the contact
79-
/// actually carries the corresponding data. A name-only contact
80-
/// does NOT clobber the per-conversation avatar, and a contact
81-
/// with neither field does not clobber a perfectly good per-
82-
/// conversation profile. `conversationId`, `isAgent`,
83-
/// `imageSourceContentDigest`, and `metadata` are always preserved
84-
/// from the original.
85-
///
86-
/// The chat layer uses this so a system-message or avatar row
87-
/// shows the contact's name and photo when the inbox is a known
88-
/// contact whose per-conversation profile has not landed yet,
89-
/// without regressing rows where the per-conversation profile is
90-
/// already richer than the contact entry.
69+
/// Deprecated no-op. Identity (name and image) is sourced authoritatively
70+
/// from the `Profile` database; contact data never overrides it, so this
71+
/// returns the profile unchanged. Retained so call sites compile until the
72+
/// contact-override plumbing is removed - see the cleanup checklist in
73+
/// docs/plans/2026-06-29-profile-table-implementation.md.
9174
func overlaying(contact: Contact) -> Profile {
92-
let resolvedName: String?
93-
if let contactName = contact.displayName, !contactName.isEmpty {
94-
resolvedName = contactName
95-
} else {
96-
resolvedName = name
97-
}
98-
// Avatar fields move as a set: a contact without an avatar URL
99-
// has no usable encryption material either, so substituting
100-
// any one of them in isolation would leave the cache key
101-
// pointing at an inaccessible blob.
102-
let resolvedAvatar: String?
103-
let resolvedAvatarSalt: Data?
104-
let resolvedAvatarNonce: Data?
105-
let resolvedAvatarKey: Data?
106-
if contact.avatarURL != nil {
107-
resolvedAvatar = contact.avatarURL
108-
resolvedAvatarSalt = contact.avatarSalt
109-
resolvedAvatarNonce = contact.avatarNonce
110-
resolvedAvatarKey = contact.avatarKey
111-
} else {
112-
resolvedAvatar = avatar
113-
resolvedAvatarSalt = avatarSalt
114-
resolvedAvatarNonce = avatarNonce
115-
resolvedAvatarKey = avatarKey
116-
}
117-
return Profile(
118-
inboxId: inboxId,
119-
conversationId: conversationId,
120-
name: resolvedName,
121-
avatar: resolvedAvatar,
122-
avatarSalt: resolvedAvatarSalt,
123-
avatarNonce: resolvedAvatarNonce,
124-
avatarKey: resolvedAvatarKey,
125-
isAgent: isAgent,
126-
imageSourceContentDigest: imageSourceContentDigest,
127-
metadata: metadata
128-
)
75+
self
12976
}
13077
}

Convos/MainTabView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ struct MainTabView: View {
279279

280280
var body: some View {
281281
bodyCore
282+
.profilesRepository(conversationsViewModel.session.messagingServiceSync().profilesRepository())
282283
.environment(promptHints)
283284
.task {
284285
await promptHints.loadOnLaunch()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import ConvosCore
2+
import SwiftUI
3+
4+
/// Injects the canonical `ProfilesRepository` into the SwiftUI environment so
5+
/// avatar surfaces keyed by inbox id (see `InboxProfileAvatarView`) can
6+
/// subscribe to unified profile changes without threading the session through
7+
/// every view. Defaults to nil so previews and any uninjected subtree render a
8+
/// placeholder rather than crashing.
9+
private struct ProfilesRepositoryKey: EnvironmentKey {
10+
static let defaultValue: ProfilesRepository? = nil
11+
}
12+
13+
extension EnvironmentValues {
14+
var profilesRepository: ProfilesRepository? {
15+
get { self[ProfilesRepositoryKey.self] }
16+
set { self[ProfilesRepositoryKey.self] = newValue }
17+
}
18+
}
19+
20+
extension View {
21+
func profilesRepository(_ repository: ProfilesRepository?) -> some View {
22+
environment(\.profilesRepository, repository)
23+
}
24+
}

ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilesRepository.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ public actor ProfilesRepository {
6161
/// Reactive identity for one inbox, hydrated fresh from the stores via
6262
/// `ValueObservation`. This is the read path ViewModels subscribe to; it does
6363
/// not depend on the in-memory cache, so it is always current.
64-
nonisolated func profilePublisher(inboxId: String) -> AnyPublisher<UnifiedProfile, Never> {
64+
public nonisolated func profilePublisher(inboxId: String) -> AnyPublisher<UnifiedProfile, Never> {
6565
ValueObservation
6666
.tracking { db in try Self.fetchProfile(db, inboxId: inboxId) }
6767
.removeDuplicates()
68-
.publisher(in: databaseReader, scheduling: .immediate)
68+
.publisher(in: databaseReader)
6969
.catch { _ in Just(UnifiedProfile.empty(inboxId: inboxId)) }
7070
.eraseToAnyPublisher()
7171
}
@@ -80,7 +80,7 @@ public actor ProfilesRepository {
8080
return result
8181
}
8282
.removeDuplicates()
83-
.publisher(in: databaseReader, scheduling: .immediate)
83+
.publisher(in: databaseReader)
8484
.catch { _ in Just([:]) }
8585
.eraseToAnyPublisher()
8686
}
@@ -89,7 +89,7 @@ public actor ProfilesRepository {
8989
ValueObservation
9090
.tracking { db -> UnifiedProfile? in try Self.fetchSelfProfile(db) }
9191
.removeDuplicates()
92-
.publisher(in: databaseReader, scheduling: .immediate)
92+
.publisher(in: databaseReader)
9393
.catch { _ in Just(nil) }
9494
.eraseToAnyPublisher()
9595
}

ConvosCore/Sources/ConvosCore/Profiles/Repository/UnifiedProfile.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import Foundation
77
/// Temporary name: `Profile` is still the conversation-scoped presentation type
88
/// in use until the cutover. This type replaces it then (renamed to `Profile`
99
/// or kept), so `UnifiedProfile` is intentionally transitional.
10-
struct UnifiedProfile: Identifiable, Hashable, Sendable {
11-
var id: String { inboxId }
12-
let inboxId: String
13-
let name: String?
10+
public struct UnifiedProfile: Identifiable, Hashable, Sendable {
11+
public var id: String { inboxId }
12+
public let inboxId: String
13+
public let name: String?
1414
let memberKind: DBMemberKind?
15-
let metadata: ProfileMetadata?
15+
public let metadata: ProfileMetadata?
1616
let avatars: [String: Avatar]
1717
let updatedAt: Date
1818

@@ -32,14 +32,14 @@ struct UnifiedProfile: Identifiable, Hashable, Sendable {
3232
self.updatedAt = updatedAt
3333
}
3434

35-
var isAgent: Bool {
35+
public var isAgent: Bool {
3636
memberKind?.isAgent ?? false
3737
}
3838

3939
/// The avatar to show for a conversation: that conversation's slot if
4040
/// present, otherwise the most recently updated slot across all
4141
/// conversations. nil when the person has no (non-cleared) avatar anywhere.
42-
func displayAvatar(for conversationId: String?) -> Avatar? {
42+
public func displayAvatar(for conversationId: String?) -> Avatar? {
4343
if let conversationId, let slot = avatars[conversationId] {
4444
return slot
4545
}

ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@ struct DBConversationMember: Codable, FetchableRecord, PersistableRecord, Hashab
5353
using: ForeignKey([Columns.inboxId], to: [DBProfile.Columns.inboxId])
5454
)
5555

56-
static let avatarSlot: HasOneAssociation<DBConversationMember, DBProfileAvatar> = hasOne(
57-
DBProfileAvatar.self,
56+
// Joins the newest avatar per inbox (the `profileAvatarLatest` view), keyed by
57+
// inboxId only, so a person's latest avatar renders consistently across every
58+
// conversation rather than each conversation's own per-conversation slot.
59+
static let avatarSlot: HasOneAssociation<DBConversationMember, DBProfileAvatarLatest> = hasOne(
60+
DBProfileAvatarLatest.self,
5861
key: "avatarSlot",
59-
using: ForeignKey(
60-
[Columns.conversationId, Columns.inboxId],
61-
to: [DBProfileAvatar.Columns.conversationId, DBProfileAvatar.Columns.inboxId]
62-
)
62+
using: ForeignKey([Columns.inboxId], to: [DBProfileAvatarLatest.Columns.inboxId])
6363
)
6464

6565
static let inviterProfileIdentity: BelongsToAssociation<DBConversationMember, DBProfile> = belongsTo(

ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMemberProfileWithRole.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ struct DBConversationMemberProfileWithRole: Codable, FetchableRecord, Hashable {
99
let role: MemberRole
1010
let createdAt: Date
1111
let profile: DBProfile?
12-
let avatarSlot: DBProfileAvatar?
12+
let avatarSlot: DBProfileAvatarLatest?
1313
let inviterProfile: DBProfile?
1414
}
1515

@@ -23,7 +23,7 @@ extension DBConversationMemberProfileWithRole {
2323
}
2424

2525
func hydratedProfile() -> Profile {
26-
Profile.from(profile: profile, avatar: avatarSlot, inboxId: inboxId, conversationId: conversationId)
26+
Profile.from(profile: profile, avatar: avatarSlot?.asProfileAvatar, inboxId: inboxId, conversationId: conversationId)
2727
}
2828

2929
func hydrateConversationMember(currentInboxId: String) -> ConversationMember {

0 commit comments

Comments
 (0)