Skip to content

Commit 5ecd8db

Browse files
author
Dev
committed
Refactor reaction updates and harden typing stop parsing
1 parent a791ea7 commit 5ecd8db

File tree

7 files changed

+182
-50
lines changed

7 files changed

+182
-50
lines changed

lib/config/providers/chat_provider.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import '../../core/services/profile_service.dart';
1313
import '../../core/services/session_manager_service.dart';
1414
import '../../core/utils/hashtree_attachments.dart';
1515
import '../../core/utils/nostr_rumor.dart';
16+
import '../../core/utils/reaction_updates.dart';
1617
import '../../core/utils/typing_rumor.dart';
1718
import '../../features/chat/data/datasources/group_local_datasource.dart';
1819
import '../../features/chat/data/datasources/group_message_local_datasource.dart';

lib/config/providers/chat_provider_chat_notifier.dart

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,37 +1086,18 @@ class ChatNotifier extends StateNotifier<ChatState> {
10861086
DateTime? reactionTimestamp,
10871087
}) async {
10881088
final currentMessages = state.messages[sessionId] ?? [];
1089-
// Match by internal id first, then by eventId
1090-
var messageIndex = currentMessages.indexWhere((m) => m.id == messageId);
1091-
if (messageIndex == -1) {
1092-
messageIndex = currentMessages.indexWhere((m) => m.eventId == messageId);
1093-
}
1094-
if (messageIndex == -1) {
1095-
messageIndex = currentMessages.indexWhere((m) => m.rumorId == messageId);
1096-
}
1097-
if (messageIndex == -1) return;
1098-
1099-
final message = currentMessages[messageIndex];
1100-
1101-
// Create updated reactions - remove user from any existing reactions first
1102-
final reactions = <String, List<String>>{};
1103-
for (final entry in message.reactions.entries) {
1104-
final filtered = entry.value.where((u) => u != pubkey).toList();
1105-
if (filtered.isNotEmpty) {
1106-
reactions[entry.key] = filtered;
1107-
}
1108-
}
1109-
1110-
// Add user to new reaction
1111-
reactions[emoji] = [...(reactions[emoji] ?? []), pubkey];
1112-
1113-
// Update message
1114-
final updatedMessage = message.copyWith(reactions: reactions);
1115-
final updatedMessages = [...currentMessages];
1116-
updatedMessages[messageIndex] = updatedMessage;
1089+
final applied = applyReactionToMessages(
1090+
currentMessages,
1091+
messageId: messageId,
1092+
emoji: emoji,
1093+
actorPubkeyHex: pubkey,
1094+
matchEventId: true,
1095+
);
1096+
if (applied == null) return;
1097+
final updatedMessage = applied.updatedMessage;
11171098

11181099
state = state.copyWith(
1119-
messages: {...state.messages, sessionId: updatedMessages},
1100+
messages: {...state.messages, sessionId: applied.updatedMessages},
11201101
);
11211102

11221103
final ownerPubkey = _sessionManagerService.ownerPubkeyHex;

lib/config/providers/chat_provider_group_notifier.dart

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,25 +1065,17 @@ class GroupNotifier extends StateNotifier<GroupState> {
10651065
DateTime? reactionTimestamp,
10661066
}) async {
10671067
final current = state.messages[groupId] ?? const <ChatMessage>[];
1068-
var idx = current.indexWhere((m) => m.id == messageId);
1069-
if (idx == -1) {
1070-
idx = current.indexWhere((m) => m.rumorId == messageId);
1071-
}
1072-
if (idx == -1) return;
1073-
1074-
final message = current[idx];
1075-
final reactions = <String, List<String>>{};
1076-
1077-
for (final entry in message.reactions.entries) {
1078-
final filtered = entry.value.where((u) => u != pubkeyHex).toList();
1079-
if (filtered.isNotEmpty) reactions[entry.key] = filtered;
1080-
}
1081-
reactions[emoji] = [...(reactions[emoji] ?? []), pubkeyHex];
1082-
1083-
final updatedMessage = message.copyWith(reactions: reactions);
1084-
final next = [...current];
1085-
next[idx] = updatedMessage;
1086-
state = state.copyWith(messages: {...state.messages, groupId: next});
1068+
final applied = applyReactionToMessages(
1069+
current,
1070+
messageId: messageId,
1071+
emoji: emoji,
1072+
actorPubkeyHex: pubkeyHex,
1073+
);
1074+
if (applied == null) return;
1075+
final updatedMessage = applied.updatedMessage;
1076+
state = state.copyWith(
1077+
messages: {...state.messages, groupId: applied.updatedMessages},
1078+
);
10871079

10881080
final myPubkeyHex = _myPubkeyHex();
10891081
final shouldNotify =

lib/core/utils/nostr_rumor.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,15 @@ List<String> getTagValues(List<List<String>> tags, String name) {
7272
int? getExpirationTimestampSeconds(List<List<String>> tags) {
7373
final raw = getFirstTagValue(tags, 'expiration');
7474
if (raw == null || raw.isEmpty) return null;
75-
final v = int.tryParse(raw);
76-
if (v == null || v <= 0) return null;
75+
final parsed = int.tryParse(raw);
76+
if (parsed == null || parsed <= 0) return null;
77+
var v = parsed;
78+
79+
// Accept clients that accidentally send millisecond or microsecond unix time.
80+
while (v > 9999999999) {
81+
v ~/= 1000;
82+
}
83+
if (v <= 0) return null;
7784
return v;
7885
}
7986

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import '../../features/chat/domain/models/message.dart';
2+
3+
class ReactionApplyResult {
4+
const ReactionApplyResult({
5+
required this.updatedMessages,
6+
required this.updatedMessage,
7+
});
8+
9+
final List<ChatMessage> updatedMessages;
10+
final ChatMessage updatedMessage;
11+
}
12+
13+
ReactionApplyResult? applyReactionToMessages(
14+
List<ChatMessage> messages, {
15+
required String messageId,
16+
required String emoji,
17+
required String actorPubkeyHex,
18+
bool matchEventId = false,
19+
}) {
20+
var targetIndex = messages.indexWhere((m) => m.id == messageId);
21+
if (targetIndex == -1 && matchEventId) {
22+
targetIndex = messages.indexWhere((m) => m.eventId == messageId);
23+
}
24+
if (targetIndex == -1) {
25+
targetIndex = messages.indexWhere((m) => m.rumorId == messageId);
26+
}
27+
if (targetIndex == -1) return null;
28+
29+
final message = messages[targetIndex];
30+
final nextReactions = <String, List<String>>{};
31+
32+
for (final entry in message.reactions.entries) {
33+
final filtered = entry.value.where((u) => u != actorPubkeyHex).toList();
34+
if (filtered.isNotEmpty) {
35+
nextReactions[entry.key] = filtered;
36+
}
37+
}
38+
39+
nextReactions[emoji] = [...(nextReactions[emoji] ?? []), actorPubkeyHex];
40+
41+
final updatedMessage = message.copyWith(reactions: nextReactions);
42+
final updatedMessages = [...messages];
43+
updatedMessages[targetIndex] = updatedMessage;
44+
45+
return ReactionApplyResult(
46+
updatedMessages: updatedMessages,
47+
updatedMessage: updatedMessage,
48+
);
49+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:iris_chat/core/utils/reaction_updates.dart';
3+
import 'package:iris_chat/features/chat/domain/models/message.dart';
4+
5+
void main() {
6+
ChatMessage message({
7+
required String id,
8+
String? eventId,
9+
String? rumorId,
10+
Map<String, List<String>> reactions = const {},
11+
}) {
12+
return ChatMessage(
13+
id: id,
14+
sessionId: 'session-1',
15+
text: 'hello',
16+
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
17+
direction: MessageDirection.incoming,
18+
status: MessageStatus.delivered,
19+
eventId: eventId,
20+
rumorId: rumorId,
21+
reactions: reactions,
22+
);
23+
}
24+
25+
test('applies reaction by internal id and moves actor between emojis', () {
26+
final current = [
27+
message(
28+
id: 'm1',
29+
reactions: {
30+
'❤️': ['alice', 'bob'],
31+
'👍': ['carol'],
32+
},
33+
),
34+
];
35+
36+
final applied = applyReactionToMessages(
37+
current,
38+
messageId: 'm1',
39+
emoji: '👍',
40+
actorPubkeyHex: 'alice',
41+
);
42+
43+
expect(applied, isNotNull);
44+
expect(applied!.updatedMessage.reactions['❤️'], ['bob']);
45+
expect(applied.updatedMessage.reactions['👍'], ['carol', 'alice']);
46+
});
47+
48+
test('matches event id only when enabled', () {
49+
final current = [message(id: 'm1', eventId: 'evt-1', rumorId: 'rumor-1')];
50+
51+
final noEventMatch = applyReactionToMessages(
52+
current,
53+
messageId: 'evt-1',
54+
emoji: '🔥',
55+
actorPubkeyHex: 'alice',
56+
matchEventId: false,
57+
);
58+
expect(noEventMatch, isNull);
59+
60+
final withEventMatch = applyReactionToMessages(
61+
current,
62+
messageId: 'evt-1',
63+
emoji: '🔥',
64+
actorPubkeyHex: 'alice',
65+
matchEventId: true,
66+
);
67+
expect(withEventMatch, isNotNull);
68+
expect(withEventMatch!.updatedMessage.reactions['🔥'], ['alice']);
69+
});
70+
71+
test('falls back to rumor id match', () {
72+
final current = [message(id: 'm1', rumorId: 'rumor-42')];
73+
74+
final applied = applyReactionToMessages(
75+
current,
76+
messageId: 'rumor-42',
77+
emoji: '✅',
78+
actorPubkeyHex: 'alice',
79+
);
80+
81+
expect(applied, isNotNull);
82+
expect(applied!.updatedMessage.reactions['✅'], ['alice']);
83+
});
84+
}

test/unit/nostr_rumor_test.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ void main() {
6969
expect(getExpirationTimestampSeconds(rumor.tags), 1704067260);
7070
});
7171

72+
test(
73+
'getExpirationTimestampSeconds normalizes millisecond expiration tag',
74+
() {
75+
final rumor = NostrRumor.fromJsonMap({
76+
'id': 'abc',
77+
'pubkey': 'peer',
78+
'created_at': 1,
79+
'kind': 14,
80+
'content': 'hi',
81+
'tags': [
82+
['expiration', '1704067260123'],
83+
],
84+
});
85+
86+
expect(getExpirationTimestampSeconds(rumor.tags), 1704067260);
87+
},
88+
);
89+
7290
test('getExpirationTimestampSeconds returns null for invalid values', () {
7391
expect(getExpirationTimestampSeconds(const []), isNull);
7492
expect(

0 commit comments

Comments
 (0)