Add Share Inbox flow for unresolved WhatsApp imports
This commit is contained in:
@@ -32,20 +32,47 @@ class SharedMessageIngestInput {
|
||||
}
|
||||
|
||||
/// Result of shared-message ingest with profile resolution metadata.
|
||||
enum SharedMessageIngestStatus { imported, queuedForResolution }
|
||||
|
||||
class SharedMessageIngestResult {
|
||||
const SharedMessageIngestResult({
|
||||
required this.status,
|
||||
required this.createdProfile,
|
||||
required this.createdSourceLink,
|
||||
this.profileId,
|
||||
this.profileName,
|
||||
this.momentId,
|
||||
this.inboxEntryId,
|
||||
});
|
||||
|
||||
const SharedMessageIngestResult.imported({
|
||||
required this.profileId,
|
||||
required this.profileName,
|
||||
required this.createdProfile,
|
||||
required this.createdSourceLink,
|
||||
required this.momentId,
|
||||
});
|
||||
}) : status = SharedMessageIngestStatus.imported,
|
||||
inboxEntryId = null;
|
||||
|
||||
final String profileId;
|
||||
final String profileName;
|
||||
const SharedMessageIngestResult.queuedForResolution({
|
||||
required this.inboxEntryId,
|
||||
}) : status = SharedMessageIngestStatus.queuedForResolution,
|
||||
createdProfile = false,
|
||||
createdSourceLink = false,
|
||||
profileId = null,
|
||||
profileName = null,
|
||||
momentId = null;
|
||||
|
||||
final SharedMessageIngestStatus status;
|
||||
final String? profileId;
|
||||
final String? profileName;
|
||||
final bool createdProfile;
|
||||
final bool createdSourceLink;
|
||||
final String momentId;
|
||||
final String? momentId;
|
||||
final String? inboxEntryId;
|
||||
|
||||
bool get isQueuedForResolution =>
|
||||
status == SharedMessageIngestStatus.queuedForResolution;
|
||||
}
|
||||
|
||||
/// Persisted local data source for offline-first product state.
|
||||
@@ -170,10 +197,29 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
resolvedPerson = _findPersonById(current.people, matchedLink.profileId);
|
||||
}
|
||||
|
||||
if (resolvedPerson == null && normalizedDisplayName.isNotEmpty) {
|
||||
resolvedPerson = _findPersonByNormalizedName(
|
||||
current.people,
|
||||
normalizedDisplayName,
|
||||
final List<PersonProfile> matchingPeople = normalizedDisplayName.isEmpty
|
||||
? const <PersonProfile>[]
|
||||
: _findPeopleByNormalizedName(current.people, normalizedDisplayName);
|
||||
|
||||
if (resolvedPerson == null && matchingPeople.length == 1) {
|
||||
resolvedPerson = matchingPeople.first;
|
||||
}
|
||||
|
||||
if (resolvedPerson == null && matchingPeople.length > 1) {
|
||||
return _queueSharedInbox(
|
||||
current: current,
|
||||
sourceApp: sourceApp,
|
||||
messageText: trimmedMessage,
|
||||
sharedAt: sharedAt,
|
||||
receivedAt: now,
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
reason: SharedInboxReason.ambiguousProfileMatch,
|
||||
candidateProfileIds: matchingPeople
|
||||
.map((PersonProfile person) => person.id)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,10 +228,30 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
growable: true,
|
||||
);
|
||||
if (resolvedPerson == null) {
|
||||
final String? inferredName = _deriveAutoProfileName(
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
);
|
||||
if (inferredName == null) {
|
||||
return _queueSharedInbox(
|
||||
current: current,
|
||||
sourceApp: sourceApp,
|
||||
messageText: trimmedMessage,
|
||||
sharedAt: sharedAt,
|
||||
receivedAt: now,
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
reason: SharedInboxReason.missingIdentity,
|
||||
candidateProfileIds: const <String>[],
|
||||
);
|
||||
}
|
||||
createdProfile = true;
|
||||
resolvedPerson = PersonProfile(
|
||||
id: 'p-${_uuid.v4()}',
|
||||
name: sourceDisplayName ?? 'WhatsApp Contact',
|
||||
name: inferredName,
|
||||
relationship: 'WhatsApp Contact',
|
||||
affinityScore: 70,
|
||||
nextMoment: DateTime.now().add(const Duration(days: 2)),
|
||||
@@ -195,106 +261,142 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
nextPeople.insert(0, resolvedPerson);
|
||||
}
|
||||
|
||||
final bool createdSourceLink = matchedLink == null;
|
||||
final List<SourceProfileLink> nextLinks = current.sourceLinks.toList(
|
||||
growable: true,
|
||||
return _ingestIntoResolvedProfile(
|
||||
current: current,
|
||||
sourceApp: sourceApp,
|
||||
messageText: trimmedMessage,
|
||||
sharedAt: sharedAt,
|
||||
importedAt: now,
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
resolvedPerson: resolvedPerson,
|
||||
matchedLink: matchedLink,
|
||||
createdProfile: createdProfile,
|
||||
people: nextPeople,
|
||||
resolvedAutomatically: true,
|
||||
);
|
||||
if (matchedLink == null) {
|
||||
nextLinks.insert(
|
||||
0,
|
||||
SourceProfileLink(
|
||||
id: 'sl-${_uuid.v4()}',
|
||||
sourceApp: sourceApp,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
profileId: resolvedPerson.id,
|
||||
firstSeenAt: sharedAt,
|
||||
lastSeenAt: sharedAt,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final int index = nextLinks.indexWhere(
|
||||
(SourceProfileLink link) => link.id == matchedLink.id,
|
||||
);
|
||||
if (index >= 0) {
|
||||
nextLinks[index] = matchedLink.copyWith(
|
||||
sourceUserId: sourceUserId ?? matchedLink.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId,
|
||||
normalizedDisplayName: normalizedDisplayName.isEmpty
|
||||
? matchedLink.normalizedDisplayName
|
||||
: normalizedDisplayName,
|
||||
profileId: resolvedPerson.id,
|
||||
lastSeenAt: sharedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> resolveSharedInboxToExistingProfile({
|
||||
required String inboxEntryId,
|
||||
required String profileId,
|
||||
}) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final SharedInboxEntry entry = _requireSharedInboxEntry(
|
||||
current,
|
||||
inboxEntryId,
|
||||
);
|
||||
final PersonProfile? resolvedPerson = _findPersonById(
|
||||
current.people,
|
||||
profileId,
|
||||
);
|
||||
if (resolvedPerson == null) {
|
||||
throw ArgumentError('Profile not found for inbox entry');
|
||||
}
|
||||
|
||||
final String summary = trimmedMessage.length > 1800
|
||||
? trimmedMessage.substring(0, 1800)
|
||||
: trimmedMessage;
|
||||
final RelationshipMoment moment = RelationshipMoment(
|
||||
id: 'm-${_uuid.v4()}',
|
||||
personId: resolvedPerson.id,
|
||||
title: _titleFromSummary(summary),
|
||||
summary: summary,
|
||||
at: sharedAt,
|
||||
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
|
||||
final SourceProfileLink? matchedLink = _findExistingSourceLink(
|
||||
links: current.sourceLinks,
|
||||
sourceApp: entry.sourceApp,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
);
|
||||
|
||||
final List<RelationshipMoment> nextMoments = <RelationshipMoment>[
|
||||
moment,
|
||||
...current.moments,
|
||||
];
|
||||
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
|
||||
SharedMessageEntry(
|
||||
id: 'sm-${_uuid.v4()}',
|
||||
sourceApp: sourceApp,
|
||||
profileId: resolvedPerson.id,
|
||||
messageText: summary,
|
||||
sharedAt: sharedAt,
|
||||
importedAt: now,
|
||||
resolvedAutomatically: true,
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
),
|
||||
...current.sharedMessages,
|
||||
];
|
||||
return _ingestIntoResolvedProfile(
|
||||
current: current,
|
||||
sourceApp: entry.sourceApp,
|
||||
messageText: entry.messageText,
|
||||
sharedAt: entry.sharedAt,
|
||||
importedAt: DateTime.now(),
|
||||
sourceDisplayName: entry.sourceDisplayName,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
resolvedPerson: resolvedPerson,
|
||||
matchedLink: matchedLink,
|
||||
createdProfile: false,
|
||||
people: current.people,
|
||||
resolvedAutomatically: false,
|
||||
consumedInboxEntryId: entry.id,
|
||||
);
|
||||
}
|
||||
|
||||
await _setState(
|
||||
current.copyWith(
|
||||
people: nextPeople,
|
||||
moments: nextMoments,
|
||||
sourceLinks: nextLinks,
|
||||
sharedMessages: nextMessages,
|
||||
),
|
||||
Future<SharedMessageIngestResult> resolveSharedInboxByCreatingProfile({
|
||||
required String inboxEntryId,
|
||||
String? name,
|
||||
String relationship = 'WhatsApp Contact',
|
||||
String? location,
|
||||
}) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final SharedInboxEntry entry = _requireSharedInboxEntry(
|
||||
current,
|
||||
inboxEntryId,
|
||||
);
|
||||
final String profileName =
|
||||
_trimToNull(name) ??
|
||||
_deriveAutoProfileName(
|
||||
sourceDisplayName: entry.sourceDisplayName,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
) ??
|
||||
'WhatsApp Contact';
|
||||
final String normalizedRelationship = relationship.trim().isEmpty
|
||||
? 'WhatsApp Contact'
|
||||
: relationship.trim();
|
||||
final PersonProfile createdPerson = PersonProfile(
|
||||
id: 'p-${_uuid.v4()}',
|
||||
name: profileName,
|
||||
relationship: normalizedRelationship,
|
||||
affinityScore: 70,
|
||||
nextMoment: DateTime.now().add(const Duration(days: 2)),
|
||||
tags: const <String>['whatsapp'],
|
||||
notes: 'Created from Share Inbox resolution.',
|
||||
location: _trimToNull(location),
|
||||
);
|
||||
|
||||
final List<ChangeEnvelope> outbound = <ChangeEnvelope>[
|
||||
if (createdProfile)
|
||||
_buildEnvelope(
|
||||
entityType: 'person',
|
||||
entityId: resolvedPerson.id,
|
||||
op: ChangeOperation.upsert,
|
||||
payload: _personPayload(resolvedPerson),
|
||||
),
|
||||
_buildEnvelope(
|
||||
entityType: 'capture',
|
||||
entityId: moment.id,
|
||||
op: ChangeOperation.upsert,
|
||||
payload: _momentPayload(moment),
|
||||
),
|
||||
];
|
||||
await _enqueueChanges(outbound);
|
||||
|
||||
return SharedMessageIngestResult(
|
||||
profileId: resolvedPerson.id,
|
||||
profileName: resolvedPerson.name,
|
||||
createdProfile: createdProfile,
|
||||
createdSourceLink: createdSourceLink,
|
||||
momentId: moment.id,
|
||||
final SourceProfileLink? matchedLink = _findExistingSourceLink(
|
||||
links: current.sourceLinks,
|
||||
sourceApp: entry.sourceApp,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
);
|
||||
|
||||
final List<PersonProfile> nextPeople = <PersonProfile>[
|
||||
createdPerson,
|
||||
...current.people,
|
||||
];
|
||||
|
||||
return _ingestIntoResolvedProfile(
|
||||
current: current,
|
||||
sourceApp: entry.sourceApp,
|
||||
messageText: entry.messageText,
|
||||
sharedAt: entry.sharedAt,
|
||||
importedAt: DateTime.now(),
|
||||
sourceDisplayName: entry.sourceDisplayName,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
resolvedPerson: createdPerson,
|
||||
matchedLink: matchedLink,
|
||||
createdProfile: true,
|
||||
people: nextPeople,
|
||||
resolvedAutomatically: false,
|
||||
consumedInboxEntryId: entry.id,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dismissSharedInboxEntry(String inboxEntryId) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final List<SharedInboxEntry> nextInbox = current.sharedInbox
|
||||
.where((SharedInboxEntry entry) => entry.id != inboxEntryId)
|
||||
.toList(growable: false);
|
||||
if (nextInbox.length == current.sharedInbox.length) {
|
||||
return;
|
||||
}
|
||||
await _setState(current.copyWith(sharedInbox: nextInbox));
|
||||
}
|
||||
|
||||
Future<void> updatePerson(PersonProfile person) async {
|
||||
@@ -1162,6 +1264,172 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> _queueSharedInbox({
|
||||
required LocalDataState current,
|
||||
required String sourceApp,
|
||||
required String messageText,
|
||||
required DateTime sharedAt,
|
||||
required DateTime receivedAt,
|
||||
required SharedInboxReason reason,
|
||||
required List<String> candidateProfileIds,
|
||||
required String normalizedDisplayName,
|
||||
String? sourceDisplayName,
|
||||
String? sourceUserId,
|
||||
String? sourceThreadId,
|
||||
}) async {
|
||||
final String summary = messageText.length > 1800
|
||||
? messageText.substring(0, 1800)
|
||||
: messageText;
|
||||
final SharedInboxEntry entry = SharedInboxEntry(
|
||||
id: 'si-${_uuid.v4()}',
|
||||
sourceApp: sourceApp,
|
||||
messageText: summary,
|
||||
sharedAt: sharedAt,
|
||||
receivedAt: receivedAt,
|
||||
reason: reason,
|
||||
candidateProfileIds: candidateProfileIds,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
);
|
||||
final List<SharedInboxEntry> nextInbox = <SharedInboxEntry>[
|
||||
entry,
|
||||
...current.sharedInbox,
|
||||
];
|
||||
await _setState(current.copyWith(sharedInbox: nextInbox));
|
||||
return SharedMessageIngestResult.queuedForResolution(
|
||||
inboxEntryId: entry.id,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> _ingestIntoResolvedProfile({
|
||||
required LocalDataState current,
|
||||
required String sourceApp,
|
||||
required String messageText,
|
||||
required DateTime sharedAt,
|
||||
required DateTime importedAt,
|
||||
required String normalizedDisplayName,
|
||||
required PersonProfile resolvedPerson,
|
||||
required bool createdProfile,
|
||||
required List<PersonProfile> people,
|
||||
required bool resolvedAutomatically,
|
||||
SourceProfileLink? matchedLink,
|
||||
String? sourceDisplayName,
|
||||
String? sourceUserId,
|
||||
String? sourceThreadId,
|
||||
String? consumedInboxEntryId,
|
||||
}) async {
|
||||
final bool createdSourceLink = matchedLink == null;
|
||||
final List<SourceProfileLink> nextLinks = current.sourceLinks.toList(
|
||||
growable: true,
|
||||
);
|
||||
if (matchedLink == null) {
|
||||
nextLinks.insert(
|
||||
0,
|
||||
SourceProfileLink(
|
||||
id: 'sl-${_uuid.v4()}',
|
||||
sourceApp: sourceApp,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
profileId: resolvedPerson.id,
|
||||
firstSeenAt: sharedAt,
|
||||
lastSeenAt: sharedAt,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final int index = nextLinks.indexWhere(
|
||||
(SourceProfileLink link) => link.id == matchedLink.id,
|
||||
);
|
||||
if (index >= 0) {
|
||||
nextLinks[index] = matchedLink.copyWith(
|
||||
sourceUserId: sourceUserId ?? matchedLink.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId,
|
||||
normalizedDisplayName: normalizedDisplayName.isEmpty
|
||||
? matchedLink.normalizedDisplayName
|
||||
: normalizedDisplayName,
|
||||
profileId: resolvedPerson.id,
|
||||
lastSeenAt: sharedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final String summary = messageText.length > 1800
|
||||
? messageText.substring(0, 1800)
|
||||
: messageText;
|
||||
final RelationshipMoment moment = RelationshipMoment(
|
||||
id: 'm-${_uuid.v4()}',
|
||||
personId: resolvedPerson.id,
|
||||
title: _titleFromSummary(summary),
|
||||
summary: summary,
|
||||
at: sharedAt,
|
||||
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
|
||||
);
|
||||
|
||||
final List<RelationshipMoment> nextMoments = <RelationshipMoment>[
|
||||
moment,
|
||||
...current.moments,
|
||||
];
|
||||
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
|
||||
SharedMessageEntry(
|
||||
id: 'sm-${_uuid.v4()}',
|
||||
sourceApp: sourceApp,
|
||||
profileId: resolvedPerson.id,
|
||||
messageText: summary,
|
||||
sharedAt: sharedAt,
|
||||
importedAt: importedAt,
|
||||
resolvedAutomatically: resolvedAutomatically,
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
),
|
||||
...current.sharedMessages,
|
||||
];
|
||||
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
|
||||
? current.sharedInbox
|
||||
: current.sharedInbox
|
||||
.where(
|
||||
(SharedInboxEntry entry) => entry.id != consumedInboxEntryId,
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
await _setState(
|
||||
current.copyWith(
|
||||
people: people,
|
||||
moments: nextMoments,
|
||||
sourceLinks: nextLinks,
|
||||
sharedMessages: nextMessages,
|
||||
sharedInbox: nextInbox,
|
||||
),
|
||||
);
|
||||
|
||||
final List<ChangeEnvelope> outbound = <ChangeEnvelope>[
|
||||
if (createdProfile)
|
||||
_buildEnvelope(
|
||||
entityType: 'person',
|
||||
entityId: resolvedPerson.id,
|
||||
op: ChangeOperation.upsert,
|
||||
payload: _personPayload(resolvedPerson),
|
||||
),
|
||||
_buildEnvelope(
|
||||
entityType: 'capture',
|
||||
entityId: moment.id,
|
||||
op: ChangeOperation.upsert,
|
||||
payload: _momentPayload(moment),
|
||||
),
|
||||
];
|
||||
await _enqueueChanges(outbound);
|
||||
|
||||
return SharedMessageIngestResult.imported(
|
||||
profileId: resolvedPerson.id,
|
||||
profileName: resolvedPerson.name,
|
||||
createdProfile: createdProfile,
|
||||
createdSourceLink: createdSourceLink,
|
||||
momentId: moment.id,
|
||||
);
|
||||
}
|
||||
|
||||
String _titleFromSummary(String summary) {
|
||||
final List<String> words = summary.split(RegExp(r'\s+'));
|
||||
final int take = words.length < 5 ? words.length : 5;
|
||||
@@ -1213,16 +1481,38 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
PersonProfile? _findPersonByNormalizedName(
|
||||
List<PersonProfile> _findPeopleByNormalizedName(
|
||||
List<PersonProfile> people,
|
||||
String normalizedDisplayName,
|
||||
) {
|
||||
for (final PersonProfile person in people) {
|
||||
if (_normalizeDisplayName(person.name) == normalizedDisplayName) {
|
||||
return person;
|
||||
return people
|
||||
.where(
|
||||
(PersonProfile person) =>
|
||||
_normalizeDisplayName(person.name) == normalizedDisplayName,
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
SharedInboxEntry _requireSharedInboxEntry(
|
||||
LocalDataState current,
|
||||
String inboxEntryId,
|
||||
) {
|
||||
for (final SharedInboxEntry entry in current.sharedInbox) {
|
||||
if (entry.id == inboxEntryId) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
throw ArgumentError('Shared inbox entry not found');
|
||||
}
|
||||
|
||||
String? _deriveAutoProfileName({
|
||||
required String? sourceDisplayName,
|
||||
required String? sourceUserId,
|
||||
required String? sourceThreadId,
|
||||
}) {
|
||||
return _trimToNull(sourceDisplayName) ??
|
||||
_trimToNull(sourceUserId) ??
|
||||
_trimToNull(sourceThreadId);
|
||||
}
|
||||
|
||||
String _normalizeDisplayName(String? raw) {
|
||||
|
||||
Reference in New Issue
Block a user