design: Hide unimportant state events instead of folding

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

View file

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

View file

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

View file

@ -3,6 +3,8 @@ abstract class SettingKeys {
static const String renderHtml = 'chat.fluffy.renderHtml';
static const String hideRedactedEvents = 'chat.fluffy.hideRedactedEvents';
static const String hideUnknownEvents = 'chat.fluffy.hideUnknownEvents';
static const String hideUnimportantStateEvents =
'chat.fluffy.hideUnimportantStateEvents';
static const String showDirectChatsInSpaces =
'chat.fluffy.showDirectChatsInSpaces';
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 '../../utils/account_bundles.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 'send_file_dialog.dart';
import 'send_location_dialog.dart';
@ -111,8 +110,6 @@ class ChatController extends State<Chat> {
List<Event> selectedEvents = [];
late List<Event> filteredEvents;
final Set<String> unfolded = {};
Event? replyEvent;
@ -184,22 +181,7 @@ class ChatController extends State<Chat> {
void updateView() {
if (!mounted) return;
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);
});
setState(() {});
}
Future<bool> getTimeline() async {
@ -225,7 +207,6 @@ class ChatController extends State<Chat> {
}
});
}
filteredEvents = timeline!.getFilteredEvents(unfolded: unfolded);
timeline!.requestKeys(onlineKeyBackupOnly: false);
return true;
}
@ -656,7 +637,7 @@ class ChatController extends State<Chat> {
}
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) {
// event id not found...maybe we can fetch it?
// the try...finally is here to start and close the loading dialog reliably
@ -693,7 +674,7 @@ class ChatController extends State<Chat> {
rethrow;
}
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/typing_indicators.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';
class ChatEventList extends StatelessWidget {
@ -26,9 +27,10 @@ class ChatEventList extends StatelessWidget {
// create a map of eventId --> index to greatly improve performance of
// ListView's findChildIndexCallback
final thisEventsKeyMap = <String, int>{};
for (var i = 0; i < controller.filteredEvents.length; i++) {
thisEventsKeyMap[controller.filteredEvents[i].eventId] = i;
for (var i = 0; i < controller.timeline!.events.length; i++) {
thisEventsKeyMap[controller.timeline!.events[i].eventId] = i;
}
return ListView.custom(
padding: EdgeInsets.only(
top: 16,
@ -55,7 +57,7 @@ class ChatEventList extends StatelessWidget {
}
// Request history button or progress indicator:
if (i == controller.filteredEvents.length + 1) {
if (i == controller.timeline!.events.length + 1) {
if (controller.timeline!.isRequestingHistory) {
return const Center(
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
@ -76,37 +78,40 @@ class ChatEventList extends StatelessWidget {
}
// The message at this index:
final event = controller.timeline!.events[i - 1];
return AutoScrollTag(
key: ValueKey(controller.filteredEvents[i - 1].eventId),
key: ValueKey(event.eventId),
index: i - 1,
controller: controller.scrollController,
child: Message(controller.filteredEvents[i - 1],
onSwipe: (direction) => controller.replyAction(
replyTo: controller.filteredEvents[i - 1]),
onInfoTab: controller.showEventInfo,
onAvatarTab: (Event event) => showModalBottomSheet(
context: context,
builder: (c) => UserBottomSheet(
user: event.senderFromMemoryOrFallback,
outerContext: context,
onMention: () => controller.sendController.text +=
'${event.senderFromMemoryOrFallback.mention} ',
),
),
unfold: controller.unfold,
onSelect: controller.onSelectMessage,
scrollToEventId: (String eventId) =>
controller.scrollToEventId(eventId),
longPressSelect: controller.selectedEvents.isEmpty,
selected: controller.selectedEvents.any((e) =>
e.eventId == controller.filteredEvents[i - 1].eventId),
timeline: controller.timeline!,
nextEvent: i < controller.filteredEvents.length
? controller.filteredEvents[i]
: null),
child: event.isVisibleInGui
? Message(event,
onSwipe: (direction) =>
controller.replyAction(replyTo: event),
onInfoTab: controller.showEventInfo,
onAvatarTab: (Event event) => showModalBottomSheet(
context: context,
builder: (c) => UserBottomSheet(
user: event.senderFromMemoryOrFallback,
outerContext: context,
onMention: () => controller.sendController.text +=
'${event.senderFromMemoryOrFallback.mention} ',
),
),
onSelect: controller.onSelectMessage,
scrollToEventId: (String eventId) =>
controller.scrollToEventId(eventId),
longPressSelect: controller.selectedEvents.isEmpty,
selected: controller.selectedEvents
.any((e) => e.eventId == event.eventId),
timeline: controller.timeline!,
nextEvent: i < controller.timeline!.events.length
? controller.timeline!.events[i]
: null)
: Container(),
);
},
childCount: controller.filteredEvents.length + 2,
childCount: controller.timeline!.events.length + 2,
findChildIndexCallback: (key) =>
controller.findChildIndexCallback(key, thisEventsKeyMap),
),

View file

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

View file

@ -8,9 +8,7 @@ import '../../../config/app_config.dart';
class StateMessage extends StatelessWidget {
final Event event;
final void Function(String) unfold;
const StateMessage(this.event, {required this.unfold, Key? key})
: super(key: key);
const StateMessage(this.event, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -25,48 +23,43 @@ class StateMessage extends StatelessWidget {
vertical: 4.0,
),
child: Center(
child: InkWell(
onTap: counter != 0 ? () => unfold(event.eventId) : null,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? Colors.white
: Colors.grey.shade900,
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FutureBuilder<String>(
future: event
.calcLocalizedBody(MatrixLocals(L10n.of(context)!)),
builder: (context, snapshot) {
return Text(
snapshot.data ??
event.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!)),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14 * AppConfig.fontSizeFactor,
color: Theme.of(context).textTheme.bodyText2!.color,
decoration: event.redacted
? TextDecoration.lineThrough
: null,
),
);
}),
if (counter != 0)
Text(
L10n.of(context)!.moreEvents(counter),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14 * AppConfig.fontSizeFactor,
),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? Colors.white
: Colors.grey.shade900,
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FutureBuilder<String>(
future:
event.calcLocalizedBody(MatrixLocals(L10n.of(context)!)),
builder: (context, snapshot) {
return Text(
snapshot.data ??
event.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!)),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14 * AppConfig.fontSizeFactor,
color: Theme.of(context).textTheme.bodyText2!.color,
decoration:
event.redacted ? TextDecoration.lineThrough : null,
),
);
}),
if (counter != 0)
Text(
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
Widget build(BuildContext context) {
final seenByUsers = controller.room!.getSeenByUsers(
controller.timeline!,
controller.filteredEvents,
controller.unfolded,
);
final seenByUsers = controller.room!.getSeenByUsers(controller.timeline!);
const maxAvatars = 7;
return Container(
width: double.infinity,
@ -28,8 +24,8 @@ class SeenByRow extends StatelessWidget {
duration: seenByUsers.isEmpty
? const Duration(milliseconds: 0)
: const Duration(milliseconds: 300),
alignment: controller.filteredEvents.isNotEmpty &&
controller.filteredEvents.first.senderId ==
alignment: controller.timeline!.events.isNotEmpty &&
controller.timeline!.events.first.senderId ==
Matrix.of(context).client.userID
? Alignment.topRight
: Alignment.topLeft,

View file

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

View file

@ -45,6 +45,12 @@ class SettingsChatView extends StatelessWidget {
storeKey: SettingKeys.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)
SettingsSwitchListTile.adaptive(
title: L10n.of(context)!.autoplayImages,

View file

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

View file

@ -2,50 +2,39 @@ import 'package:matrix/matrix.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 {
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 => !{
EventTypes.Message,
EventTypes.Sticker,

View file

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