Merge pull request #249 from GetStream/reactions-optimistic-update

[LLC] Add optimistic reaction update
This commit is contained in:
Salvatore Giordano
2021-02-03 16:55:59 +01:00
committed by GitHub
5 changed files with 171 additions and 158 deletions
+115 -142
View File
@@ -274,28 +274,111 @@ class Channel {
bool enforceUnique = false, bool enforceUnique = false,
}) async { }) async {
final messageId = message.id; final messageId = message.id;
final now = DateTime.now();
final user = _client.state.user;
final latestReactions = [...message.latestReactions ?? <Reaction>[]];
if (enforceUnique) {
latestReactions.removeWhere((it) => it.userId == user.id);
}
final newReaction = Reaction(
messageId: messageId,
createdAt: now,
type: type,
user: user,
score: 1,
extraData: extraData,
);
// Inserting at the 0th index as it's the latest reaction
latestReactions.insert(0, newReaction);
final ownReactions = [...latestReactions]
..removeWhere((it) => it.userId != user.id);
final newMessage = message.copyWith(
reactionCounts: {...message?.reactionCounts ?? <String, int>{}}
..update(type, (value) {
if (enforceUnique) return value;
return value + 1;
}, ifAbsent: () => 1),
reactionScores: {...message.reactionScores ?? <String, int>{}}
..update(type, (value) {
if (enforceUnique) return value;
return value + 1;
}, ifAbsent: () => 1),
latestReactions: latestReactions,
ownReactions: ownReactions,
);
state?.addMessage(newMessage);
final data = Map<String, dynamic>.from(extraData) final data = Map<String, dynamic>.from(extraData)
..addAll({ ..addAll({
'type': type, 'type': type,
}); });
final res = await _client.post( try {
'/messages/$messageId/reaction', final res = await _client.post(
data: { '/messages/$messageId/reaction',
'reaction': data, data: {
'enforce_unique': enforceUnique, 'reaction': data,
}, 'enforce_unique': enforceUnique,
); },
return _client.decode(res.data, SendReactionResponse.fromJson); );
final reactionResp =
_client.decode(res.data, SendReactionResponse.fromJson);
return reactionResp;
} catch (_) {
// Reset the message if the update fails
state?.addMessage(message);
rethrow;
}
} }
/// Delete a reaction from this channel /// Delete a reaction from this channel
Future<EmptyResponse> deleteReaction(Message message, Reaction reaction) { Future<EmptyResponse> deleteReaction(
_checkInitialized(); Message message, Reaction reaction) async {
final type = reaction.type;
final user = _client.state.user;
return client final reactionCounts = {...message.reactionCounts ?? <String, int>{}};
.delete('/messages/${message.id}/reaction/${reaction.type}') if (reactionCounts.containsKey(type)) {
.then((res) => _client.decode(res.data, EmptyResponse.fromJson)); reactionCounts.update(type, (value) => value - 1);
}
final reactionScores = {...message.reactionScores ?? <String, int>{}};
if (reactionScores.containsKey(type)) {
reactionScores.update(type, (value) => value - 1);
}
final latestReactions = [...message.latestReactions ?? <Reaction>[]]
..removeWhere((r) {
return r.userId == reaction.userId &&
r.type == reaction.type &&
r.messageId == reaction.messageId;
});
final ownReactions = [...latestReactions ?? <Reaction>[]]
..removeWhere((it) => it.userId != user.id);
final newMessage = message.copyWith(
reactionCounts: reactionCounts..removeWhere((_, value) => value == 0),
reactionScores: reactionScores..removeWhere((_, value) => value == 0),
latestReactions: latestReactions,
ownReactions: ownReactions,
);
state?.addMessage(newMessage);
try {
final res = await client
.delete('/messages/${message.id}/reaction/${reaction.type}');
return _client.decode(res.data, EmptyResponse.fromJson);
} catch (_) {
// Reset the message if the update fails
state?.addMessage(message);
rethrow;
}
} }
/// Edit the channel custom data /// Edit the channel custom data
@@ -998,73 +1081,24 @@ class ChannelClientState {
void _listenReactionDeleted() { void _listenReactionDeleted() {
_subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) {
final reaction = event.reaction; final userId = _channel.client.state.user.id;
final message = event.message; final message = event.message.copyWith(
_removeMessageReaction(message, reaction); ownReactions: [...event.message.latestReactions]
})); ..removeWhere((it) => it.userId != userId),
}
void _removeMessageReaction(Message message, Reaction reaction) {
if (message.parentId == null || message.showInChannel == true) {
_channelState = _channelState.copyWith(
messages: _channelState?.messages?.map((m) {
if (m.id == message.id) {
return _removeReactionFromMessage(m, reaction);
}
return m;
})?.toList(),
); );
} addMessage(message);
}));
if (message.parentId != null) {
final newThreads = threads;
if (newThreads.containsKey(message.parentId)) {
newThreads[message.parentId] = newThreads[message.parentId].map((m) {
if (m.id == message.id) {
return _removeReactionFromMessage(m, reaction);
}
return m;
}).toList();
_threads = newThreads;
}
}
} }
void _listenReactions() { void _listenReactions() {
_subscriptions.add(_channel _subscriptions.add(_channel.on(EventType.reactionNew).listen((event) {
.on( final userId = _channel.client.state.user.id;
EventType.reactionNew, final message = event.message.copyWith(
) ownReactions: [...event.message.latestReactions]
.listen((event) { ..removeWhere((it) => it.userId != userId),
final message = event.message;
_addMessageReaction(message, event.reaction);
}));
}
void _addMessageReaction(Message message, Reaction reaction) {
if (message.parentId == null || message.showInChannel == true) {
_channelState = _channelState.copyWith(
messages: _channelState.messages.map((m) {
if (message.id == m.id) {
return _addReactionToMessage(m, reaction);
}
return m;
}).toList(),
); );
} addMessage(message);
}));
if (message.parentId != null) {
final newThreads = threads;
if (newThreads.containsKey(message.parentId)) {
newThreads[message.parentId] = newThreads[message.parentId].map((m) {
if (message.id == m.id) {
return _addReactionToMessage(m, reaction);
}
return m;
}).toList();
_threads = newThreads;
}
}
} }
void _listenMessageUpdated() { void _listenMessageUpdated() {
@@ -1074,13 +1108,12 @@ class ChannelClientState {
EventType.reactionUpdated, EventType.reactionUpdated,
) )
.listen((event) { .listen((event) {
final message = event.message; final userId = _channel.client.state.user.id;
addMessage(message.copyWith( final message = event.message.copyWith(
ownReactions: message.latestReactions ownReactions: [...event.message.latestReactions]
.where( ..removeWhere((it) => it.userId != userId),
(element) => element.user?.id == _channel._client.state.user.id) );
.toList(), addMessage(message);
));
})); }));
} }
@@ -1163,66 +1196,6 @@ class ChannelClientState {
})); }));
} }
Message _addReactionToMessage(Message message, Reaction reaction) {
final newMessage = message.copyWith(
latestReactions: message.latestReactions..add(reaction),
reactionCounts: {
...message.reactionCounts ?? {},
reaction.type: (message.reactionCounts == null
? 0
: message.reactionCounts[reaction.type] ?? 0) +
1,
},
reactionScores: {
...message.reactionScores ?? {},
reaction.type: (message.reactionScores == null
? 0
: message.reactionScores[reaction.type] ?? 0) +
reaction.score,
},
);
if (reaction.user.id == _channel.client.state.user.id) {
return newMessage.copyWith(
ownReactions: message.ownReactions..add(reaction),
);
}
return newMessage;
}
Message _removeReactionFromMessage(Message message, Reaction reaction) {
final newMessage = message.copyWith(
latestReactions: message.latestReactions
..removeWhere(
(r) => r.type == reaction.type && r.userId == reaction.userId),
reactionCounts: {
...message.reactionCounts,
reaction.type: (message.reactionCounts[reaction.type] ?? 0) - 1,
},
reactionScores: {
...message.reactionScores ?? {},
reaction.type: max(
(message.reactionScores == null
? 0
: message.reactionScores[reaction.type] ?? 0) -
reaction.score,
0),
},
);
newMessage.reactionCounts.removeWhere((_, v) => v <= 0);
if (reaction.user.id == _channel.client.state.user.id) {
return newMessage.copyWith(
ownReactions: message.ownReactions
..removeWhere((r) => r.type == reaction.type),
);
}
return newMessage;
}
/// Channel message list /// Channel message list
List<Message> get messages => _channelState.messages; List<Message> get messages => _channelState.messages;
@@ -49,10 +49,10 @@ class Reaction {
this.createdAt, this.createdAt,
this.type, this.type,
this.user, this.user,
this.userId, String userId,
this.score, this.score,
this.extraData, this.extraData,
}); }) : userId = userId ?? user?.id;
/// Create a new instance from a json /// Create a new instance from a json
factory Reaction.fromJson(Map<String, dynamic> json) { factory Reaction.fromJson(Map<String, dynamic> json) {
@@ -65,4 +65,40 @@ class Reaction {
return Serialization.moveFromExtraDataToRoot( return Serialization.moveFromExtraDataToRoot(
_$ReactionToJson(this), topLevelFields); _$ReactionToJson(this), topLevelFields);
} }
/// Creates a copy of [Reaction] with specified attributes overridden.
Reaction copyWith({
String messageId,
DateTime createdAt,
String type,
User user,
String userId,
int score,
Map<String, dynamic> extraData,
}) {
return Reaction(
messageId: messageId ?? this.messageId,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
user: user ?? this.user,
userId: userId ?? this.userId,
score: score ?? this.score,
extraData: extraData ?? this.extraData,
);
}
/// Returns a new [Reaction] that is a combination of this reaction and the given
/// [other] reaction.
Reaction merge(Reaction other) {
if (other == null) return this;
return copyWith(
messageId: other.messageId,
createdAt: other.createdAt,
type: other.type,
user: other.user,
userId: other.userId,
score: other.score,
extraData: other.extraData,
);
}
} }
@@ -7,6 +7,7 @@ import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/models/event.dart'; import 'package:stream_chat/src/models/event.dart';
import 'package:stream_chat/src/models/message.dart'; import 'package:stream_chat/src/models/message.dart';
import 'package:stream_chat/src/models/reaction.dart'; import 'package:stream_chat/src/models/reaction.dart';
import 'package:stream_chat/src/models/own_user.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
class MockDio extends Mock implements DioForNative {} class MockDio extends Mock implements DioForNative {}
@@ -379,7 +380,8 @@ void main() {
'api-key', 'api-key',
httpClient: mockDio, httpClient: mockDio,
tokenProvider: (_) async => '', tokenProvider: (_) async => '',
); )..state.user = OwnUser(id: 'test-id');
final channelClient = client.channel('messaging', id: 'testid'); final channelClient = client.channel('messaging', id: 'testid');
final reactionType = 'test'; final reactionType = 'test';
@@ -396,6 +398,10 @@ void main() {
await channelClient.sendReaction( await channelClient.sendReaction(
Message( Message(
id: 'messageid', id: 'messageid',
reactionCounts: const <String, int>{},
reactionScores: const <String, int>{},
latestReactions: const <Reaction>[],
ownReactions: const <Reaction>[],
), ),
reactionType, reactionType,
); );
@@ -418,23 +424,21 @@ void main() {
'api-key', 'api-key',
httpClient: mockDio, httpClient: mockDio,
tokenProvider: (_) async => '', tokenProvider: (_) async => '',
); )..state.user = OwnUser(id: 'test-id');
final channelClient = client.channel('messaging', id: 'testid');
when(mockDio.post<String>( final channelClient = client.channel('messaging', id: 'testid');
any,
data: anyNamed('data'),
)).thenAnswer((_) async => Response(
data: '{}',
statusCode: 200,
));
await channelClient.watch();
when(mockDio.delete<String>('/messages/messageid/reaction/test')) when(mockDio.delete<String>('/messages/messageid/reaction/test'))
.thenAnswer((_) async => Response(data: '{}', statusCode: 200)); .thenAnswer((_) async => Response(data: '{}', statusCode: 200));
await channelClient.deleteReaction( await channelClient.deleteReaction(
Message(id: 'messageid'), Message(
id: 'messageid',
reactionCounts: const <String, int>{},
reactionScores: const <String, int>{},
latestReactions: const <Reaction>[],
ownReactions: const <Reaction>[],
),
Reaction(type: 'test'), Reaction(type: 'test'),
); );
@@ -261,7 +261,7 @@ class MessageReactionsModal extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
reaction.user.name, reaction.user.name.split(' ')[0],
style: StreamChatTheme.of(context).textTheme.footnoteBold, style: StreamChatTheme.of(context).textTheme.footnoteBold,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -96,7 +96,7 @@ void main() {
findsNWidgets(2)); findsNWidgets(2));
expect(find.byKey(Key('StreamSvgIcon-Icon_love_reaction.svg')), expect(find.byKey(Key('StreamSvgIcon-Icon_love_reaction.svg')),
findsNWidgets(2)); findsNWidgets(2));
expect(find.text(testUserId), findsNWidgets(2)); expect(find.text(testUserId.split(' ')[0]), findsNWidgets(2));
}, },
); );
} }