Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TW-1774: Update avatar when have changes #1953

Closed
wants to merge 6 commits into from
Closed
33 changes: 33 additions & 0 deletions docs/adr/0024-rate-limit-stream-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 23. Implement Real-time Avatar Updates in Chat List

Date: 2024-08-20

## Status

Accepted

## Context

Currently, when a user changes their avatar or a group avatar is updated, the chat list items do not reflect these changes in real-time. On the web platform, users must reload the page to see the updated avatars. This leads to an inconsistent user experience and delays in displaying the most current information.

## Decision

We will implement a new function called `rateLimitWithSyncUpdate` to complement our existing `rateLimit` function. This new function will:

1. Maintain the rate-limiting functionality of the original `rateLimit` function.
2. Capture and process `syncUpdate` events received from the server.
3. Use the `syncUpdate` events to identify which rooms or private chats have new avatars.
4. Trigger real-time updates to the relevant chat list items without requiring a page reload.

## Consequences

### Positive

- Improved user experience with real-time avatar updates in the chat list.
- Reduced need for manual page reloads to see updated avatars.
- More consistent information display across the application.

### Negative

- Increased complexity in the client-side code to handle real-time updates.
- Potential for increased network traffic due to more frequent updates.
9 changes: 7 additions & 2 deletions lib/pages/chat_list/chat_list_body_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,11 @@ class ChatListBodyView extends StatelessWidget {
),
stream: controller.activeClient.onSync.stream
.where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1)),
builder: (context, _) {
.rateLimitWithSyncUpdate(const Duration(seconds: 1)),
builder: (context, syncUpdateSnapshot) {
Logs().v(
'ChatListBodyView: StreamBuilder: snapshot: ${syncUpdateSnapshot.data?.rooms}',
);
if (controller.activeFilter == ActiveFilter.spaces) {
return SpaceView(
controller,
Expand Down Expand Up @@ -163,6 +166,7 @@ class ChatListBodyView extends StatelessWidget {
child: ChatListViewBuilder(
controller: controller,
rooms: controller.filteredRoomsForPin,
syncUpdate: syncUpdateSnapshot.data,
),
),
if (!controller.filteredRoomsForAllIsEmpty)
Expand Down Expand Up @@ -192,6 +196,7 @@ class ChatListBodyView extends StatelessWidget {
child: ChatListViewBuilder(
controller: controller,
rooms: controller.filteredRoomsForAll,
syncUpdate: syncUpdateSnapshot.data,
),
),
],
Expand Down
14 changes: 6 additions & 8 deletions lib/pages/chat_list/chat_list_item.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item_avatar.dart';
import 'package:fluffychat/presentation/mixins/chat_list_item_mixin.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item_style.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item_subtitle.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item_title.dart';
import 'package:fluffychat/utils/dialog/twake_dialog.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/twake_snackbar.dart';
import 'package:fluffychat/widgets/avatar/avatar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

Expand All @@ -27,6 +26,7 @@ class ChatListItem extends StatelessWidget with ChatListItemMixin {
final void Function()? onTapAvatar;
final void Function(TapDownDetails)? onSecondaryTapDown;
final void Function()? onLongPress;
final JoinedRoomUpdate? joinedRoomUpdate;

const ChatListItem(
this.room, {
Expand All @@ -39,6 +39,7 @@ class ChatListItem extends StatelessWidget with ChatListItemMixin {
this.onSecondaryTapDown,
this.onLongPress,
super.key,
this.joinedRoomUpdate,
});

void clickAction(BuildContext context) async {
Expand Down Expand Up @@ -87,9 +88,6 @@ class ChatListItem extends StatelessWidget with ChatListItemMixin {

@override
Widget build(BuildContext context) {
final displayName = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!),
);
return Padding(
padding: ChatListItemStyle.paddingConversation,
child: Material(
Expand All @@ -114,10 +112,10 @@ class ChatListItem extends StatelessWidget with ChatListItemMixin {
padding: ChatListItemStyle.paddingAvatar,
child: Stack(
children: [
Avatar(
mxContent: room.avatar,
name: displayName,
ChatListItemAvatar(
room: room,
onTap: onTapAvatar,
joinedRoomUpdate: joinedRoomUpdate,
),
if (_isGroupChat)
Positioned(
Expand Down
108 changes: 108 additions & 0 deletions lib/pages/chat_list/chat_list_item_avatar.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/avatar/avatar.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

class ChatListItemAvatar extends StatefulWidget {
final Room room;
final void Function()? onTap;
final JoinedRoomUpdate? joinedRoomUpdate;

const ChatListItemAvatar({
required this.room,
this.onTap,
this.joinedRoomUpdate,
super.key,
});

@override
State<ChatListItemAvatar> createState() => _ChatListItemAvatarState();
}

class _ChatListItemAvatarState extends State<ChatListItemAvatar> {
final ValueNotifier<Uri?> avatarUrlNotifier = ValueNotifier<Uri>(Uri());

@override
void initState() {
avatarUrlNotifier.value = widget.room.avatar ?? Uri();
super.initState();
}

@override
void dispose() {
avatarUrlNotifier.dispose();
super.dispose();
}

@override
void didUpdateWidget(ChatListItemAvatar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.joinedRoomUpdate != widget.joinedRoomUpdate) {
updateAvatarUrlFromJoinedRoomUpdate();
}
}

@override
Widget build(BuildContext context) {
final displayName = widget.room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!),
);
return ValueListenableBuilder(
valueListenable: avatarUrlNotifier,
builder: (context, avatarUrl, child) {
return Avatar(
mxContent: avatarUrl,
name: displayName,
onTap: widget.onTap,
);
},
);
}

void updateAvatarUrlFromJoinedRoomUpdate() {
if (isChatHaveAvatarUpdated) {
if (isGroupChatAvatarUpdated) {
updateGroupAvatar();
} else if (isDirectChatAvatarUpdated) {
updateDirectChatAvatar();
}
}
}

bool get isChatHaveAvatarUpdated =>
widget.joinedRoomUpdate?.timeline?.events?.isNotEmpty == true;

bool get isDirectChatAvatarUpdated {
return widget.room.isDirectChat &&
widget.joinedRoomUpdate?.timeline?.events?.last.type ==
EventTypes.RoomMember;
}

bool get isGroupChatAvatarUpdated =>
widget.joinedRoomUpdate?.timeline?.events?.last.type ==
EventTypes.RoomAvatar;

void updateDirectChatAvatar() {
final event = widget.joinedRoomUpdate?.timeline?.events?.last;
final avatarMxc = event?.content['avatar_url'];
if (event?.senderId != widget.room.directChatMatrixID) {
return;
}
updateAvatarUrl(avatarMxc);
}

void updateGroupAvatar() {
final avatarMxc =
widget.joinedRoomUpdate?.timeline?.events?.last.content['url'];
updateAvatarUrl(avatarMxc);
}

void updateAvatarUrl(Object? avatarMxc) {
if (avatarMxc is String) {
avatarUrlNotifier.value = Uri.tryParse(avatarMxc) ?? Uri();
} else if (avatarMxc == null) {
avatarUrlNotifier.value = Uri();
}
}
}
4 changes: 4 additions & 0 deletions lib/pages/chat_list/chat_list_view_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import 'package:matrix/matrix.dart';
class ChatListViewBuilder extends StatelessWidget {
final ChatListController controller;
final List<Room> rooms;
final SyncUpdate? syncUpdate;

const ChatListViewBuilder({
super.key,
required this.controller,
required this.rooms,
this.syncUpdate,
});

@override
Expand All @@ -36,12 +38,14 @@ class ChatListViewBuilder extends StatelessWidget {
chatListItem: CommonChatListItem(
controller: controller,
room: rooms[index],
joinedRoomUpdate: syncUpdate?.rooms?.join?[rooms[index].id],
),
);
}
return CommonChatListItem(
controller: controller,
room: rooms[index],
joinedRoomUpdate: syncUpdate?.rooms?.join?[rooms[index].id],
);
},
);
Expand Down
3 changes: 3 additions & 0 deletions lib/pages/chat_list/common_chat_list_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import 'package:matrix/matrix.dart';
class CommonChatListItem extends StatelessWidget {
final ChatListController controller;
final Room room;
final JoinedRoomUpdate? joinedRoomUpdate;

const CommonChatListItem({
super.key,
required this.controller,
required this.room,
this.joinedRoomUpdate,
});

@override
Expand Down Expand Up @@ -49,6 +51,7 @@ class CommonChatListItem extends StatelessWidget {
},
),
activeChat: activeRoomId == room.id,
joinedRoomUpdate: joinedRoomUpdate,
);
},
);
Expand Down
40 changes: 40 additions & 0 deletions lib/utils/stream_extension.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'dart:async';

import 'package:matrix/matrix.dart';

extension StreamExtension on Stream {
/// Returns a new Stream which outputs only `true` for every update of the original
/// stream, ratelimited by the Duration t
Expand Down Expand Up @@ -45,4 +47,42 @@ extension StreamExtension on Stream {
};
return controller.stream;
}

Stream<SyncUpdate> rateLimitWithSyncUpdate(Duration t) {
Te-Z marked this conversation as resolved.
Show resolved Hide resolved
final controller = StreamController<SyncUpdate>();
Timer? timer;
SyncUpdate? pendingMessage;

void processMessage() {
if (controller.isClosed) return;

if (timer == null && pendingMessage != null) {
controller.add(pendingMessage!);
pendingMessage = null;
timer = Timer(t, () {
timer = null;
if (pendingMessage != null) {
processMessage();
}
});
}
}

final subscription = listen(
(data) {
pendingMessage = data;
processMessage();
},
onDone: () => controller.close(),
onError: (e, s) => controller.addError(e, s),
);

controller.onCancel = () {
subscription.cancel();
timer?.cancel();
controller.close();
};

return controller.stream;
}
}
12 changes: 12 additions & 0 deletions test/utils/mock_sync_update.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:matrix/matrix.dart';
import 'package:mockito/mockito.dart';

class MockSyncUpdate extends Mock implements SyncUpdate {
@override
final String nextBatch;

MockSyncUpdate({required this.nextBatch});

@override
String toString() => 'MockSyncUpdate(nextBatch: $nextBatch)';
}
Loading