design: Hide unimportant state events instead of folding

This commit is contained in:
Christian Pauly 2022-10-15 10:38:06 +02:00
parent b4bf23cd34
commit c365469dc5
13 changed files with 126 additions and 162 deletions

View File

@ -2949,5 +2949,6 @@
"placeholders": { "placeholders": {
"number": {} "number": {}
} }
} },
"hideUnimportantStateEvents": "Hide unimportant state events"
} }

View File

@ -37,6 +37,7 @@ abstract class AppConfig {
static bool renderHtml = true; static bool renderHtml = true;
static bool hideRedactedEvents = false; static bool hideRedactedEvents = false;
static bool hideUnknownEvents = true; static bool hideUnknownEvents = true;
static bool hideUnimportantStateEvents = true;
static bool showDirectChatsInSpaces = true; static bool showDirectChatsInSpaces = true;
static bool separateChatTypes = false; static bool separateChatTypes = false;
static bool autoplayImages = true; static bool autoplayImages = true;

View File

@ -3,6 +3,8 @@ abstract class SettingKeys {
static const String renderHtml = 'chat.fluffy.renderHtml'; static const String renderHtml = 'chat.fluffy.renderHtml';
static const String hideRedactedEvents = 'chat.fluffy.hideRedactedEvents'; static const String hideRedactedEvents = 'chat.fluffy.hideRedactedEvents';
static const String hideUnknownEvents = 'chat.fluffy.hideUnknownEvents'; static const String hideUnknownEvents = 'chat.fluffy.hideUnknownEvents';
static const String hideUnimportantStateEvents =
'chat.fluffy.hideUnimportantStateEvents';
static const String showDirectChatsInSpaces = static const String showDirectChatsInSpaces =
'chat.fluffy.showDirectChatsInSpaces'; 'chat.fluffy.showDirectChatsInSpaces';
static const String separateChatTypes = 'chat.fluffy.separateChatTypes'; static const String separateChatTypes = 'chat.fluffy.separateChatTypes';

View File

@ -28,7 +28,6 @@ import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
import '../../utils/account_bundles.dart'; import '../../utils/account_bundles.dart';
import '../../utils/localized_exception_extension.dart'; import '../../utils/localized_exception_extension.dart';
import '../../utils/matrix_sdk_extensions.dart/filtered_timeline_extension.dart';
import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart'; import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
import 'send_file_dialog.dart'; import 'send_file_dialog.dart';
import 'send_location_dialog.dart'; import 'send_location_dialog.dart';
@ -111,8 +110,6 @@ class ChatController extends State<Chat> {
List<Event> selectedEvents = []; List<Event> selectedEvents = [];
late List<Event> filteredEvents;
final Set<String> unfolded = {}; final Set<String> unfolded = {};
Event? replyEvent; Event? replyEvent;
@ -184,22 +181,7 @@ class ChatController extends State<Chat> {
void updateView() { void updateView() {
if (!mounted) return; if (!mounted) return;
setState( setState(() {});
() {
filteredEvents = timeline!.getFilteredEvents(unfolded: unfolded);
},
);
}
void unfold(String eventId) {
var i = filteredEvents.indexWhere((e) => e.eventId == eventId);
setState(() {
while (i < filteredEvents.length - 1 && filteredEvents[i].isState) {
unfolded.add(filteredEvents[i].eventId);
i++;
}
filteredEvents = timeline!.getFilteredEvents(unfolded: unfolded);
});
} }
Future<bool> getTimeline() async { Future<bool> getTimeline() async {
@ -225,7 +207,6 @@ class ChatController extends State<Chat> {
} }
}); });
} }
filteredEvents = timeline!.getFilteredEvents(unfolded: unfolded);
timeline!.requestKeys(onlineKeyBackupOnly: false); timeline!.requestKeys(onlineKeyBackupOnly: false);
return true; return true;
} }
@ -656,7 +637,7 @@ class ChatController extends State<Chat> {
} }
void scrollToEventId(String eventId) async { void scrollToEventId(String eventId) async {
var eventIndex = filteredEvents.indexWhere((e) => e.eventId == eventId); var eventIndex = timeline!.events.indexWhere((e) => e.eventId == eventId);
if (eventIndex == -1) { if (eventIndex == -1) {
// event id not found...maybe we can fetch it? // event id not found...maybe we can fetch it?
// the try...finally is here to start and close the loading dialog reliably // the try...finally is here to start and close the loading dialog reliably
@ -693,7 +674,7 @@ class ChatController extends State<Chat> {
rethrow; rethrow;
} }
eventIndex = eventIndex =
filteredEvents.indexWhere((e) => e.eventId == eventId); timeline!.events.indexWhere((e) => e.eventId == eventId);
} }
}); });
} }

View File

@ -10,6 +10,7 @@ import 'package:fluffychat/pages/chat/events/message.dart';
import 'package:fluffychat/pages/chat/seen_by_row.dart'; import 'package:fluffychat/pages/chat/seen_by_row.dart';
import 'package:fluffychat/pages/chat/typing_indicators.dart'; import 'package:fluffychat/pages/chat/typing_indicators.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/filtered_timeline_extension.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
class ChatEventList extends StatelessWidget { class ChatEventList extends StatelessWidget {
@ -26,9 +27,10 @@ class ChatEventList extends StatelessWidget {
// create a map of eventId --> index to greatly improve performance of // create a map of eventId --> index to greatly improve performance of
// ListView's findChildIndexCallback // ListView's findChildIndexCallback
final thisEventsKeyMap = <String, int>{}; final thisEventsKeyMap = <String, int>{};
for (var i = 0; i < controller.filteredEvents.length; i++) { for (var i = 0; i < controller.timeline!.events.length; i++) {
thisEventsKeyMap[controller.filteredEvents[i].eventId] = i; thisEventsKeyMap[controller.timeline!.events[i].eventId] = i;
} }
return ListView.custom( return ListView.custom(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 16, top: 16,
@ -55,7 +57,7 @@ class ChatEventList extends StatelessWidget {
} }
// Request history button or progress indicator: // Request history button or progress indicator:
if (i == controller.filteredEvents.length + 1) { if (i == controller.timeline!.events.length + 1) {
if (controller.timeline!.isRequestingHistory) { if (controller.timeline!.isRequestingHistory) {
return const Center( return const Center(
child: CircularProgressIndicator.adaptive(strokeWidth: 2), child: CircularProgressIndicator.adaptive(strokeWidth: 2),
@ -76,37 +78,40 @@ class ChatEventList extends StatelessWidget {
} }
// The message at this index: // The message at this index:
final event = controller.timeline!.events[i - 1];
return AutoScrollTag( return AutoScrollTag(
key: ValueKey(controller.filteredEvents[i - 1].eventId), key: ValueKey(event.eventId),
index: i - 1, index: i - 1,
controller: controller.scrollController, controller: controller.scrollController,
child: Message(controller.filteredEvents[i - 1], child: event.isVisibleInGui
onSwipe: (direction) => controller.replyAction( ? Message(event,
replyTo: controller.filteredEvents[i - 1]), onSwipe: (direction) =>
onInfoTab: controller.showEventInfo, controller.replyAction(replyTo: event),
onAvatarTab: (Event event) => showModalBottomSheet( onInfoTab: controller.showEventInfo,
context: context, onAvatarTab: (Event event) => showModalBottomSheet(
builder: (c) => UserBottomSheet( context: context,
user: event.senderFromMemoryOrFallback, builder: (c) => UserBottomSheet(
outerContext: context, user: event.senderFromMemoryOrFallback,
onMention: () => controller.sendController.text += outerContext: context,
'${event.senderFromMemoryOrFallback.mention} ', onMention: () => controller.sendController.text +=
), '${event.senderFromMemoryOrFallback.mention} ',
), ),
unfold: controller.unfold, ),
onSelect: controller.onSelectMessage, onSelect: controller.onSelectMessage,
scrollToEventId: (String eventId) => scrollToEventId: (String eventId) =>
controller.scrollToEventId(eventId), controller.scrollToEventId(eventId),
longPressSelect: controller.selectedEvents.isEmpty, longPressSelect: controller.selectedEvents.isEmpty,
selected: controller.selectedEvents.any((e) => selected: controller.selectedEvents
e.eventId == controller.filteredEvents[i - 1].eventId), .any((e) => e.eventId == event.eventId),
timeline: controller.timeline!, timeline: controller.timeline!,
nextEvent: i < controller.filteredEvents.length nextEvent: i < controller.timeline!.events.length
? controller.filteredEvents[i] ? controller.timeline!.events[i]
: null), : null)
: Container(),
); );
}, },
childCount: controller.filteredEvents.length + 2, childCount: controller.timeline!.events.length + 2,
findChildIndexCallback: (key) => findChildIndexCallback: (key) =>
controller.findChildIndexCallback(key, thisEventsKeyMap), controller.findChildIndexCallback(key, thisEventsKeyMap),
), ),

View File

@ -22,7 +22,6 @@ class Message extends StatelessWidget {
final void Function(Event)? onAvatarTab; final void Function(Event)? onAvatarTab;
final void Function(Event)? onInfoTab; final void Function(Event)? onInfoTab;
final void Function(String)? scrollToEventId; final void Function(String)? scrollToEventId;
final void Function(String) unfold;
final void Function(SwipeDirection) onSwipe; final void Function(SwipeDirection) onSwipe;
final bool longPressSelect; final bool longPressSelect;
final bool selected; final bool selected;
@ -36,7 +35,6 @@ class Message extends StatelessWidget {
this.onAvatarTab, this.onAvatarTab,
this.scrollToEventId, this.scrollToEventId,
required this.onSwipe, required this.onSwipe,
required this.unfold,
this.selected = false, this.selected = false,
required this.timeline, required this.timeline,
Key? key}) Key? key})
@ -57,7 +55,7 @@ class Message extends StatelessWidget {
if (event.type.startsWith('m.call.')) { if (event.type.startsWith('m.call.')) {
return Container(); return Container();
} }
return StateMessage(event, unfold: unfold); return StateMessage(event);
} }
if (event.type == EventTypes.Message && if (event.type == EventTypes.Message &&

View File

@ -8,9 +8,7 @@ import '../../../config/app_config.dart';
class StateMessage extends StatelessWidget { class StateMessage extends StatelessWidget {
final Event event; final Event event;
final void Function(String) unfold; const StateMessage(this.event, {Key? key}) : super(key: key);
const StateMessage(this.event, {required this.unfold, Key? key})
: super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -25,48 +23,43 @@ class StateMessage extends StatelessWidget {
vertical: 4.0, vertical: 4.0,
), ),
child: Center( child: Center(
child: InkWell( child: Container(
onTap: counter != 0 ? () => unfold(event.eventId) : null, padding: const EdgeInsets.all(8),
borderRadius: BorderRadius.circular(AppConfig.borderRadius), decoration: BoxDecoration(
child: Container( color: Theme.of(context).brightness == Brightness.light
padding: const EdgeInsets.all(8), ? Colors.white
decoration: BoxDecoration( : Colors.grey.shade900,
color: Theme.of(context).brightness == Brightness.light borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
? Colors.white ),
: Colors.grey.shade900, child: Column(
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), mainAxisSize: MainAxisSize.min,
), children: [
child: Column( FutureBuilder<String>(
mainAxisSize: MainAxisSize.min, future:
children: [ event.calcLocalizedBody(MatrixLocals(L10n.of(context)!)),
FutureBuilder<String>( builder: (context, snapshot) {
future: event return Text(
.calcLocalizedBody(MatrixLocals(L10n.of(context)!)), snapshot.data ??
builder: (context, snapshot) { event.calcLocalizedBodyFallback(
return Text( MatrixLocals(L10n.of(context)!)),
snapshot.data ?? textAlign: TextAlign.center,
event.calcLocalizedBodyFallback( style: TextStyle(
MatrixLocals(L10n.of(context)!)), fontSize: 14 * AppConfig.fontSizeFactor,
textAlign: TextAlign.center, color: Theme.of(context).textTheme.bodyText2!.color,
style: TextStyle( decoration:
fontSize: 14 * AppConfig.fontSizeFactor, event.redacted ? TextDecoration.lineThrough : null,
color: Theme.of(context).textTheme.bodyText2!.color, ),
decoration: event.redacted );
? TextDecoration.lineThrough }),
: null, if (counter != 0)
), Text(
); L10n.of(context)!.moreEvents(counter),
}), style: TextStyle(
if (counter != 0) fontWeight: FontWeight.bold,
Text( fontSize: 14 * AppConfig.fontSizeFactor,
L10n.of(context)!.moreEvents(counter),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14 * AppConfig.fontSizeFactor,
),
), ),
], ),
), ],
), ),
), ),
), ),

View File

@ -12,11 +12,7 @@ class SeenByRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final seenByUsers = controller.room!.getSeenByUsers( final seenByUsers = controller.room!.getSeenByUsers(controller.timeline!);
controller.timeline!,
controller.filteredEvents,
controller.unfolded,
);
const maxAvatars = 7; const maxAvatars = 7;
return Container( return Container(
width: double.infinity, width: double.infinity,
@ -28,8 +24,8 @@ class SeenByRow extends StatelessWidget {
duration: seenByUsers.isEmpty duration: seenByUsers.isEmpty
? const Duration(milliseconds: 0) ? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300), : const Duration(milliseconds: 300),
alignment: controller.filteredEvents.isNotEmpty && alignment: controller.timeline!.events.isNotEmpty &&
controller.filteredEvents.first.senderId == controller.timeline!.events.first.senderId ==
Matrix.of(context).client.userID Matrix.of(context).client.userID
? Alignment.topRight ? Alignment.topRight
: Alignment.topLeft, : Alignment.topLeft,

View File

@ -26,8 +26,8 @@ class TypingIndicators extends StatelessWidget {
height: typingUsers.isEmpty ? 0 : Avatar.defaultSize + bottomPadding, height: typingUsers.isEmpty ? 0 : Avatar.defaultSize + bottomPadding,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.bounceInOut, curve: Curves.bounceInOut,
alignment: controller.filteredEvents.isNotEmpty && alignment: controller.timeline!.events.isNotEmpty &&
controller.filteredEvents.first.senderId == controller.timeline!.events.first.senderId ==
Matrix.of(context).client.userID Matrix.of(context).client.userID
? Alignment.topRight ? Alignment.topRight
: Alignment.topLeft, : Alignment.topLeft,

View File

@ -45,6 +45,12 @@ class SettingsChatView extends StatelessWidget {
storeKey: SettingKeys.hideUnknownEvents, storeKey: SettingKeys.hideUnknownEvents,
defaultValue: AppConfig.hideUnknownEvents, defaultValue: AppConfig.hideUnknownEvents,
), ),
SettingsSwitchListTile.adaptive(
title: L10n.of(context)!.hideUnimportantStateEvents,
onChanged: (b) => AppConfig.hideUnimportantStateEvents = b,
storeKey: SettingKeys.hideUnimportantStateEvents,
defaultValue: AppConfig.hideUnimportantStateEvents,
),
if (PlatformInfos.isMobile) if (PlatformInfos.isMobile)
SettingsSwitchListTile.adaptive( SettingsSwitchListTile.adaptive(
title: L10n.of(context)!.autoplayImages, title: L10n.of(context)!.autoplayImages,

View File

@ -110,8 +110,6 @@ class StoryPageController extends State<StoryPage> {
if (timeline == null || currentEvent == null) return []; if (timeline == null || currentEvent == null) return [];
return Matrix.of(context).client.getRoomById(roomId)?.getSeenByUsers( return Matrix.of(context).client.getRoomById(roomId)?.getSeenByUsers(
timeline, timeline,
events,
{},
eventId: currentEvent.eventId, eventId: currentEvent.eventId,
) ?? ) ??
[]; [];

View File

@ -2,50 +2,39 @@ import 'package:matrix/matrix.dart';
import '../../config/app_config.dart'; import '../../config/app_config.dart';
extension FilteredTimelineExtension on Timeline {
List<Event> getFilteredEvents({Set<String> unfolded = const {}}) {
final filteredEvents = events
.where((e) =>
// always filter out edit and reaction relationships
!{RelationshipTypes.edit, RelationshipTypes.reaction}
.contains(e.relationshipType) &&
// always filter out m.key.* events
!e.type.startsWith('m.key.verification.') &&
// event types to hide: redaction and reaction events
// if a reaction has been redacted we also want it to be hidden in the timeline
!{EventTypes.Reaction, EventTypes.Redaction}.contains(e.type) &&
// if we enabled to hide all redacted events, don't show those
(!AppConfig.hideRedactedEvents || !e.redacted) &&
// if we enabled to hide all unknown events, don't show those
(!AppConfig.hideUnknownEvents || e.isEventTypeKnown) &&
// remove state events that we don't want to render
(e.isState || !AppConfig.hideAllStateEvents))
.toList();
// Fold state events
var counter = 0;
for (var i = filteredEvents.length - 1; i >= 0; i--) {
if (!filteredEvents[i].isState) continue;
if (i > 0 &&
filteredEvents[i - 1].isState &&
!unfolded.contains(filteredEvents[i - 1].eventId)) {
counter++;
filteredEvents[i].unsigned ??= {};
filteredEvents[i].unsigned!['im.fluffychat.collapsed_state_event'] =
true;
} else {
filteredEvents[i].unsigned!['im.fluffychat.collapsed_state_event'] =
false;
filteredEvents[i]
.unsigned!['im.fluffychat.collapsed_state_event_count'] = counter;
counter = 0;
}
}
return filteredEvents;
}
}
extension IsStateExtension on Event { extension IsStateExtension on Event {
bool get isVisibleInGui =>
// always filter out edit and reaction relationships
!{RelationshipTypes.edit, RelationshipTypes.reaction}
.contains(relationshipType) &&
// always filter out m.key.* events
!type.startsWith('m.key.verification.') &&
// event types to hide: redaction and reaction events
// if a reaction has been redacted we also want it to be hidden in the timeline
!{EventTypes.Reaction, EventTypes.Redaction}.contains(type) &&
// if we enabled to hide all redacted events, don't show those
(!AppConfig.hideRedactedEvents || !redacted) &&
// if we enabled to hide all unknown events, don't show those
(!AppConfig.hideUnknownEvents || isEventTypeKnown) &&
// remove state events that we don't want to render
(isState || !AppConfig.hideAllStateEvents) &&
// hide unimportant state events
(!AppConfig.hideUnimportantStateEvents ||
!isState ||
importantStateEvents.contains(type)) &&
// hide member events in public rooms
(!AppConfig.hideUnimportantStateEvents ||
type != EventTypes.RoomMember ||
room.joinRules != JoinRules.public);
static const Set<String> importantStateEvents = {
EventTypes.Encryption,
EventTypes.RoomCreate,
EventTypes.RoomMember,
EventTypes.RoomTombstone,
EventTypes.CallInvite,
};
bool get isState => !{ bool get isState => !{
EventTypes.Message, EventTypes.Message,
EventTypes.Sticker, EventTypes.Sticker,

View File

@ -5,7 +5,6 @@ import 'package:matrix/matrix.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import 'date_time_extension.dart'; import 'date_time_extension.dart';
import 'matrix_sdk_extensions.dart/filtered_timeline_extension.dart';
extension RoomStatusExtension on Room { extension RoomStatusExtension on Room {
CachedPresence? get directChatPresence => CachedPresence? get directChatPresence =>
@ -65,14 +64,9 @@ extension RoomStatusExtension on Room {
return typingText; return typingText;
} }
List<User> getSeenByUsers( List<User> getSeenByUsers(Timeline timeline, {String? eventId}) {
Timeline timeline, List<Event> filteredEvents, Set<String> unfolded,
{String? eventId}) {
if (timeline.events.isEmpty) return []; if (timeline.events.isEmpty) return [];
eventId ??= timeline.events.first.eventId;
final filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
if (filteredEvents.isEmpty) return [];
eventId ??= filteredEvents.first.eventId;
final lastReceipts = <User>{}; final lastReceipts = <User>{};
// now we iterate the timeline events until we hit the first rendered event // now we iterate the timeline events until we hit the first rendered event
@ -83,7 +77,7 @@ extension RoomStatusExtension on Room {
} }
} }
lastReceipts.removeWhere((user) => lastReceipts.removeWhere((user) =>
user.id == client.userID || user.id == filteredEvents.first.senderId); user.id == client.userID || user.id == timeline.events.first.senderId);
return lastReceipts.toList(); return lastReceipts.toList();
} }
} }