Add stable share fingerprints and near-match conflict routing
This commit is contained in:
@@ -335,12 +335,14 @@ class SourceProfileLink {
|
||||
required this.lastSeenAt,
|
||||
this.sourceUserId,
|
||||
this.sourceThreadId,
|
||||
this.sourceFingerprint,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String sourceApp;
|
||||
final String? sourceUserId;
|
||||
final String? sourceThreadId;
|
||||
final String? sourceFingerprint;
|
||||
final String normalizedDisplayName;
|
||||
final String profileId;
|
||||
final DateTime firstSeenAt;
|
||||
@@ -351,6 +353,7 @@ class SourceProfileLink {
|
||||
String? sourceApp,
|
||||
String? sourceUserId,
|
||||
String? sourceThreadId,
|
||||
String? sourceFingerprint,
|
||||
String? normalizedDisplayName,
|
||||
String? profileId,
|
||||
DateTime? firstSeenAt,
|
||||
@@ -361,6 +364,7 @@ class SourceProfileLink {
|
||||
sourceApp: sourceApp ?? this.sourceApp,
|
||||
sourceUserId: sourceUserId ?? this.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? this.sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
|
||||
normalizedDisplayName:
|
||||
normalizedDisplayName ?? this.normalizedDisplayName,
|
||||
profileId: profileId ?? this.profileId,
|
||||
@@ -375,6 +379,7 @@ class SourceProfileLink {
|
||||
'sourceApp': sourceApp,
|
||||
'sourceUserId': sourceUserId,
|
||||
'sourceThreadId': sourceThreadId,
|
||||
'sourceFingerprint': sourceFingerprint,
|
||||
'normalizedDisplayName': normalizedDisplayName,
|
||||
'profileId': profileId,
|
||||
'firstSeenAt': firstSeenAt.toUtc().toIso8601String(),
|
||||
@@ -388,6 +393,7 @@ class SourceProfileLink {
|
||||
sourceApp: json['sourceApp'] as String,
|
||||
sourceUserId: json['sourceUserId'] as String?,
|
||||
sourceThreadId: json['sourceThreadId'] as String?,
|
||||
sourceFingerprint: json['sourceFingerprint'] as String?,
|
||||
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
|
||||
profileId: json['profileId'] as String,
|
||||
firstSeenAt: DateTime.parse(json['firstSeenAt'] as String).toLocal(),
|
||||
@@ -480,7 +486,11 @@ class SharedMessageEntry {
|
||||
}
|
||||
}
|
||||
|
||||
enum SharedInboxReason { ambiguousProfileMatch, missingIdentity }
|
||||
enum SharedInboxReason {
|
||||
ambiguousProfileMatch,
|
||||
nearProfileConflict,
|
||||
missingIdentity,
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SharedInboxEntry {
|
||||
@@ -493,6 +503,7 @@ class SharedInboxEntry {
|
||||
required this.reason,
|
||||
required this.candidateProfileIds,
|
||||
required this.normalizedDisplayName,
|
||||
this.sourceFingerprint,
|
||||
this.sourceDisplayName,
|
||||
this.sourceUserId,
|
||||
this.sourceThreadId,
|
||||
@@ -506,6 +517,7 @@ class SharedInboxEntry {
|
||||
final SharedInboxReason reason;
|
||||
final List<String> candidateProfileIds;
|
||||
final String normalizedDisplayName;
|
||||
final String? sourceFingerprint;
|
||||
final String? sourceDisplayName;
|
||||
final String? sourceUserId;
|
||||
final String? sourceThreadId;
|
||||
@@ -519,6 +531,7 @@ class SharedInboxEntry {
|
||||
SharedInboxReason? reason,
|
||||
List<String>? candidateProfileIds,
|
||||
String? normalizedDisplayName,
|
||||
String? sourceFingerprint,
|
||||
String? sourceDisplayName,
|
||||
String? sourceUserId,
|
||||
String? sourceThreadId,
|
||||
@@ -533,6 +546,7 @@ class SharedInboxEntry {
|
||||
candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds,
|
||||
normalizedDisplayName:
|
||||
normalizedDisplayName ?? this.normalizedDisplayName,
|
||||
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
|
||||
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
|
||||
sourceUserId: sourceUserId ?? this.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? this.sourceThreadId,
|
||||
@@ -549,6 +563,7 @@ class SharedInboxEntry {
|
||||
'reason': reason.name,
|
||||
'candidateProfileIds': candidateProfileIds,
|
||||
'normalizedDisplayName': normalizedDisplayName,
|
||||
'sourceFingerprint': sourceFingerprint,
|
||||
'sourceDisplayName': sourceDisplayName,
|
||||
'sourceUserId': sourceUserId,
|
||||
'sourceThreadId': sourceThreadId,
|
||||
@@ -573,6 +588,7 @@ class SharedInboxEntry {
|
||||
.map((dynamic id) => '$id')
|
||||
.toList(growable: false),
|
||||
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
|
||||
sourceFingerprint: json['sourceFingerprint'] as String?,
|
||||
sourceDisplayName: json['sourceDisplayName'] as String?,
|
||||
sourceUserId: json['sourceUserId'] as String?,
|
||||
sourceThreadId: json['sourceThreadId'] as String?,
|
||||
|
||||
@@ -183,10 +183,16 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
);
|
||||
final String? sourceUserId = _trimToNull(input.sourceUserId);
|
||||
final String? sourceThreadId = _trimToNull(input.sourceThreadId);
|
||||
final String? sourceFingerprint = _buildStableSourceFingerprint(
|
||||
sourceApp: sourceApp,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
);
|
||||
|
||||
final SourceProfileLink? matchedLink = _findExistingSourceLink(
|
||||
links: current.sourceLinks,
|
||||
sourceApp: sourceApp,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
@@ -215,6 +221,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
reason: SharedInboxReason.ambiguousProfileMatch,
|
||||
candidateProfileIds: matchingPeople
|
||||
@@ -223,6 +230,31 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
);
|
||||
}
|
||||
|
||||
final List<PersonProfile> nearMatchPeople = resolvedPerson == null
|
||||
? _findNearMatchPeople(
|
||||
current.people,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
)
|
||||
: const <PersonProfile>[];
|
||||
if (resolvedPerson == null && nearMatchPeople.isNotEmpty) {
|
||||
return _queueSharedInbox(
|
||||
current: current,
|
||||
sourceApp: sourceApp,
|
||||
messageText: trimmedMessage,
|
||||
sharedAt: sharedAt,
|
||||
receivedAt: now,
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
reason: SharedInboxReason.nearProfileConflict,
|
||||
candidateProfileIds: nearMatchPeople
|
||||
.map((PersonProfile person) => person.id)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
bool createdProfile = false;
|
||||
final List<PersonProfile> nextPeople = current.people.toList(
|
||||
growable: true,
|
||||
@@ -243,6 +275,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
reason: SharedInboxReason.missingIdentity,
|
||||
candidateProfileIds: const <String>[],
|
||||
@@ -270,6 +303,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
resolvedPerson: resolvedPerson,
|
||||
matchedLink: matchedLink,
|
||||
@@ -299,6 +333,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
final SourceProfileLink? matchedLink = _findExistingSourceLink(
|
||||
links: current.sourceLinks,
|
||||
sourceApp: entry.sourceApp,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
@@ -313,6 +348,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
sourceDisplayName: entry.sourceDisplayName,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
resolvedPerson: resolvedPerson,
|
||||
matchedLink: matchedLink,
|
||||
@@ -359,6 +395,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
final SourceProfileLink? matchedLink = _findExistingSourceLink(
|
||||
links: current.sourceLinks,
|
||||
sourceApp: entry.sourceApp,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
@@ -378,6 +415,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
sourceDisplayName: entry.sourceDisplayName,
|
||||
sourceUserId: entry.sourceUserId,
|
||||
sourceThreadId: entry.sourceThreadId,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
resolvedPerson: createdPerson,
|
||||
matchedLink: matchedLink,
|
||||
@@ -1489,6 +1527,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
required SharedInboxReason reason,
|
||||
required List<String> candidateProfileIds,
|
||||
required String normalizedDisplayName,
|
||||
String? sourceFingerprint,
|
||||
String? sourceDisplayName,
|
||||
String? sourceUserId,
|
||||
String? sourceThreadId,
|
||||
@@ -1505,6 +1544,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
reason: reason,
|
||||
candidateProfileIds: candidateProfileIds,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
@@ -1526,6 +1566,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
required DateTime sharedAt,
|
||||
required DateTime importedAt,
|
||||
required String normalizedDisplayName,
|
||||
required String? sourceFingerprint,
|
||||
required PersonProfile resolvedPerson,
|
||||
required bool createdProfile,
|
||||
required List<PersonProfile> people,
|
||||
@@ -1548,6 +1589,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
sourceApp: sourceApp,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
profileId: resolvedPerson.id,
|
||||
firstSeenAt: sharedAt,
|
||||
@@ -1562,6 +1604,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
nextLinks[index] = matchedLink.copyWith(
|
||||
sourceUserId: sourceUserId ?? matchedLink.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName.isEmpty
|
||||
? matchedLink.normalizedDisplayName
|
||||
: normalizedDisplayName,
|
||||
@@ -1655,10 +1698,27 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
SourceProfileLink? _findExistingSourceLink({
|
||||
required List<SourceProfileLink> links,
|
||||
required String sourceApp,
|
||||
required String? sourceFingerprint,
|
||||
required String? sourceUserId,
|
||||
required String? sourceThreadId,
|
||||
required String normalizedDisplayName,
|
||||
}) {
|
||||
if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) {
|
||||
for (final SourceProfileLink link in links) {
|
||||
final String? linkFingerprint =
|
||||
link.sourceFingerprint ??
|
||||
_buildStableSourceFingerprint(
|
||||
sourceApp: link.sourceApp,
|
||||
sourceUserId: link.sourceUserId,
|
||||
sourceThreadId: link.sourceThreadId,
|
||||
);
|
||||
if (link.sourceApp == sourceApp &&
|
||||
linkFingerprint == sourceFingerprint) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceUserId != null && sourceUserId.isNotEmpty) {
|
||||
for (final SourceProfileLink link in links) {
|
||||
if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) {
|
||||
@@ -1709,6 +1769,98 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<PersonProfile> _findNearMatchPeople(
|
||||
List<PersonProfile> people, {
|
||||
required String normalizedDisplayName,
|
||||
}) {
|
||||
if (normalizedDisplayName.isEmpty) {
|
||||
return const <PersonProfile>[];
|
||||
}
|
||||
|
||||
final Map<String, int> distanceById = <String, int>{};
|
||||
final List<PersonProfile> candidates = <PersonProfile>[];
|
||||
for (final PersonProfile person in people) {
|
||||
final String personName = _normalizeDisplayName(person.name);
|
||||
if (personName.isEmpty || personName == normalizedDisplayName) {
|
||||
continue;
|
||||
}
|
||||
final int distance = _levenshteinDistance(
|
||||
normalizedDisplayName,
|
||||
personName,
|
||||
);
|
||||
if (_isNearDisplayName(
|
||||
normalizedDisplayName,
|
||||
personName,
|
||||
distance: distance,
|
||||
)) {
|
||||
distanceById[person.id] = distance;
|
||||
candidates.add(person);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((PersonProfile a, PersonProfile b) {
|
||||
final int left = distanceById[a.id] ?? 999;
|
||||
final int right = distanceById[b.id] ?? 999;
|
||||
if (left != right) {
|
||||
return left.compareTo(right);
|
||||
}
|
||||
return a.name.length.compareTo(b.name.length);
|
||||
});
|
||||
if (candidates.length <= 5) {
|
||||
return candidates;
|
||||
}
|
||||
return candidates.take(5).toList(growable: false);
|
||||
}
|
||||
|
||||
bool _isNearDisplayName(String left, String right, {required int distance}) {
|
||||
final int maxLength = left.length > right.length
|
||||
? left.length
|
||||
: right.length;
|
||||
final int minLength = left.length < right.length
|
||||
? left.length
|
||||
: right.length;
|
||||
if (minLength >= 4 && (left.contains(right) || right.contains(left))) {
|
||||
return true;
|
||||
}
|
||||
final int threshold = maxLength >= 14 ? 3 : 2;
|
||||
if (distance <= threshold) {
|
||||
return true;
|
||||
}
|
||||
return maxLength >= 8 && (distance / maxLength) <= 0.22;
|
||||
}
|
||||
|
||||
int _levenshteinDistance(String left, String right) {
|
||||
if (left == right) {
|
||||
return 0;
|
||||
}
|
||||
if (left.isEmpty) {
|
||||
return right.length;
|
||||
}
|
||||
if (right.isEmpty) {
|
||||
return left.length;
|
||||
}
|
||||
|
||||
List<int> previous = List<int>.generate(
|
||||
right.length + 1,
|
||||
(int index) => index,
|
||||
growable: false,
|
||||
);
|
||||
for (int i = 1; i <= left.length; i += 1) {
|
||||
final List<int> current = List<int>.filled(right.length + 1, 0);
|
||||
current[0] = i;
|
||||
for (int j = 1; j <= right.length; j += 1) {
|
||||
final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1;
|
||||
final int deletion = previous[j] + 1;
|
||||
final int insertion = current[j - 1] + 1;
|
||||
final int substitution = previous[j - 1] + substitutionCost;
|
||||
final int value = deletion < insertion ? deletion : insertion;
|
||||
current[j] = value < substitution ? value : substitution;
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
return previous[right.length];
|
||||
}
|
||||
|
||||
SharedInboxEntry _requireSharedInboxEntry(
|
||||
LocalDataState current,
|
||||
String inboxEntryId,
|
||||
@@ -1731,6 +1883,25 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
_trimToNull(sourceThreadId);
|
||||
}
|
||||
|
||||
String? _buildStableSourceFingerprint({
|
||||
required String sourceApp,
|
||||
required String? sourceUserId,
|
||||
required String? sourceThreadId,
|
||||
}) {
|
||||
final String? userId = _trimToNull(sourceUserId);
|
||||
final String? threadId = _trimToNull(sourceThreadId);
|
||||
if (userId == null && threadId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<String> parts = <String>[
|
||||
sourceApp.trim().toLowerCase(),
|
||||
if (userId != null) 'u:${userId.toLowerCase()}',
|
||||
if (threadId != null) 't:${threadId.toLowerCase()}',
|
||||
];
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
String _normalizeDisplayName(String? raw) {
|
||||
if (raw == null) {
|
||||
return '';
|
||||
|
||||
@@ -291,6 +291,7 @@ class _InboxEntryCard extends StatelessWidget {
|
||||
final bool hasCandidates = suggestedMatches.isNotEmpty;
|
||||
final String reasonLabel = switch (entry.reason) {
|
||||
SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match',
|
||||
SharedInboxReason.nearProfileConflict => 'Near match conflict',
|
||||
SharedInboxReason.missingIdentity => 'Missing identity',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user