refactor: Enhance logic when to mark room as read
This commit is contained in:
parent
03511a1e8d
commit
0cf6a1d74a
2 changed files with 235 additions and 231 deletions
|
|
@ -252,6 +252,7 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
setState(() => _scrolledUp = true);
|
setState(() => _scrolledUp = true);
|
||||||
} else if (scrollController.position.pixels <= 0 && _scrolledUp == true) {
|
} else if (scrollController.position.pixels <= 0 && _scrolledUp == true) {
|
||||||
setState(() => _scrolledUp = false);
|
setState(() => _scrolledUp = false);
|
||||||
|
setReadMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollController.position.pixels == 0 ||
|
if (scrollController.position.pixels == 0 ||
|
||||||
|
|
@ -275,6 +276,7 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
_loadDraft();
|
_loadDraft();
|
||||||
super.initState();
|
super.initState();
|
||||||
sendingClient = Matrix.of(context).client;
|
sendingClient = Matrix.of(context).client;
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_tryLoadTimeline();
|
_tryLoadTimeline();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,7 +288,6 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
if (fullyRead.isEmpty) return;
|
if (fullyRead.isEmpty) return;
|
||||||
if (timeline!.events.any((event) => event.eventId == fullyRead)) {
|
if (timeline!.events.any((event) => event.eventId == fullyRead)) {
|
||||||
Logs().v('Scroll up to visible event', fullyRead);
|
Logs().v('Scroll up to visible event', fullyRead);
|
||||||
setReadMarker();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -317,6 +318,11 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
int? animateInEventIndex;
|
int? animateInEventIndex;
|
||||||
|
|
||||||
void onInsert(int i) {
|
void onInsert(int i) {
|
||||||
|
if (timeline?.events[i].status == EventStatus.synced) {
|
||||||
|
final index = timeline!.events.firstIndexWhereNotError;
|
||||||
|
if (i == index) setReadMarker(eventId: timeline?.events[i].eventId);
|
||||||
|
}
|
||||||
|
|
||||||
// setState will be called by updateView() anyway
|
// setState will be called by updateView() anyway
|
||||||
animateInEventIndex = i;
|
animateInEventIndex = i;
|
||||||
}
|
}
|
||||||
|
|
@ -350,6 +356,7 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
}
|
}
|
||||||
timeline!.requestKeys(onlineKeyBackupOnly: false);
|
timeline!.requestKeys(onlineKeyBackupOnly: false);
|
||||||
if (room.markedUnread) room.markUnread(false);
|
if (room.markedUnread) room.markUnread(false);
|
||||||
|
setReadMarker();
|
||||||
|
|
||||||
// when the scroll controller is attached we want to scroll to an event id, if specified
|
// when the scroll controller is attached we want to scroll to an event id, if specified
|
||||||
// and update the scroll controller...which will trigger a request history, if the
|
// and update the scroll controller...which will trigger a request history, if the
|
||||||
|
|
@ -371,7 +378,6 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state != AppLifecycleState.resumed) return;
|
if (state != AppLifecycleState.resumed) return;
|
||||||
if (!_scrolledUp) return;
|
|
||||||
setReadMarker();
|
setReadMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,13 +385,19 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
|
|
||||||
void setReadMarker({String? eventId}) {
|
void setReadMarker({String? eventId}) {
|
||||||
if (_setReadMarkerFuture != null) return;
|
if (_setReadMarkerFuture != null) return;
|
||||||
|
if (_scrolledUp) return;
|
||||||
if (scrollUpBannerEventId != null) return;
|
if (scrollUpBannerEventId != null) return;
|
||||||
if (eventId == null &&
|
if (eventId == null &&
|
||||||
!room.hasNewMessages &&
|
!room.hasNewMessages &&
|
||||||
room.notificationCount == 0) {
|
room.notificationCount == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Matrix.of(context).webHasFocus) return;
|
|
||||||
|
// Do not send read markers when app is not in foreground
|
||||||
|
if (!Matrix.of(context).webHasFocus ||
|
||||||
|
WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final timeline = this.timeline;
|
final timeline = this.timeline;
|
||||||
if (timeline == null || timeline.events.isEmpty) return;
|
if (timeline == null || timeline.events.isEmpty) return;
|
||||||
|
|
@ -932,7 +944,6 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await loadTimelineFuture;
|
await loadTimelineFuture;
|
||||||
setReadMarker();
|
|
||||||
}
|
}
|
||||||
scrollController.jumpTo(0);
|
scrollController.jumpTo(0);
|
||||||
}
|
}
|
||||||
|
|
@ -1174,7 +1185,6 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
|
|
||||||
void onInputBarChanged(String text) {
|
void onInputBarChanged(String text) {
|
||||||
if (_inputTextIsEmpty != text.isEmpty) {
|
if (_inputTextIsEmpty != text.isEmpty) {
|
||||||
setReadMarker();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_inputTextIsEmpty = text.isEmpty;
|
_inputTextIsEmpty = text.isEmpty;
|
||||||
});
|
});
|
||||||
|
|
@ -1300,3 +1310,12 @@ class ChatController extends State<ChatPageWithRoom>
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EmojiPickerType { reaction, keyboard }
|
enum EmojiPickerType { reaction, keyboard }
|
||||||
|
|
||||||
|
extension on List<Event> {
|
||||||
|
int get firstIndexWhereNotError {
|
||||||
|
if (isEmpty) return 0;
|
||||||
|
final index = indexWhere((event) => !event.status.isError);
|
||||||
|
if (index == -1) return length;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,238 +150,223 @@ class ChatView extends StatelessWidget {
|
||||||
controller.emojiPickerAction();
|
controller.emojiPickerAction();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: GestureDetector(
|
child: StreamBuilder(
|
||||||
onTapDown: (_) => controller.setReadMarker(),
|
stream: controller.room.onUpdate.stream
|
||||||
behavior: HitTestBehavior.opaque,
|
.rateLimit(const Duration(seconds: 1)),
|
||||||
child: MouseRegion(
|
builder: (context, snapshot) => FutureBuilder(
|
||||||
onEnter: (_) => controller.setReadMarker(),
|
future: controller.loadTimelineFuture,
|
||||||
child: StreamBuilder(
|
builder: (BuildContext context, snapshot) {
|
||||||
stream: controller.room.onUpdate.stream
|
return Scaffold(
|
||||||
.rateLimit(const Duration(seconds: 1)),
|
appBar: AppBar(
|
||||||
builder: (context, snapshot) => FutureBuilder(
|
actionsIconTheme: IconThemeData(
|
||||||
future: controller.loadTimelineFuture,
|
color: controller.selectedEvents.isEmpty
|
||||||
builder: (BuildContext context, snapshot) {
|
? null
|
||||||
return Scaffold(
|
: Theme.of(context).colorScheme.primary,
|
||||||
appBar: AppBar(
|
),
|
||||||
actionsIconTheme: IconThemeData(
|
leading: controller.selectMode
|
||||||
color: controller.selectedEvents.isEmpty
|
? IconButton(
|
||||||
? null
|
icon: const Icon(Icons.close),
|
||||||
: Theme.of(context).colorScheme.primary,
|
onPressed: controller.clearSelectedEvents,
|
||||||
),
|
tooltip: L10n.of(context)!.close,
|
||||||
leading: controller.selectMode
|
color: Theme.of(context).colorScheme.primary,
|
||||||
? IconButton(
|
)
|
||||||
icon: const Icon(Icons.close),
|
: UnreadRoomsBadge(
|
||||||
onPressed: controller.clearSelectedEvents,
|
filter: (r) => r.id != controller.roomId,
|
||||||
tooltip: L10n.of(context)!.close,
|
badgePosition: BadgePosition.topEnd(end: 8, top: 4),
|
||||||
color: Theme.of(context).colorScheme.primary,
|
child: const Center(child: BackButton()),
|
||||||
)
|
),
|
||||||
: UnreadRoomsBadge(
|
titleSpacing: 0,
|
||||||
filter: (r) => r.id != controller.roomId,
|
title: ChatAppBarTitle(controller),
|
||||||
badgePosition: BadgePosition.topEnd(end: 8, top: 4),
|
actions: _appBarActions(context),
|
||||||
child: const Center(child: BackButton()),
|
),
|
||||||
),
|
floatingActionButton: controller.showScrollDownButton &&
|
||||||
titleSpacing: 0,
|
controller.selectedEvents.isEmpty
|
||||||
title: ChatAppBarTitle(controller),
|
? Padding(
|
||||||
actions: _appBarActions(context),
|
padding: const EdgeInsets.only(bottom: 56.0),
|
||||||
),
|
child: FloatingActionButton(
|
||||||
floatingActionButton: controller.showScrollDownButton &&
|
onPressed: controller.scrollDown,
|
||||||
controller.selectedEvents.isEmpty
|
heroTag: null,
|
||||||
? Padding(
|
mini: true,
|
||||||
padding: const EdgeInsets.only(bottom: 56.0),
|
child: const Icon(Icons.arrow_downward_outlined),
|
||||||
child: FloatingActionButton(
|
),
|
||||||
onPressed: controller.scrollDown,
|
)
|
||||||
heroTag: null,
|
: null,
|
||||||
mini: true,
|
body: DropTarget(
|
||||||
child: const Icon(Icons.arrow_downward_outlined),
|
onDragDone: controller.onDragDone,
|
||||||
),
|
onDragEntered: controller.onDragEntered,
|
||||||
)
|
onDragExited: controller.onDragExited,
|
||||||
: null,
|
child: Stack(
|
||||||
body: DropTarget(
|
children: <Widget>[
|
||||||
onDragDone: controller.onDragDone,
|
if (accountConfig.wallpaperUrl != null)
|
||||||
onDragEntered: controller.onDragEntered,
|
Opacity(
|
||||||
onDragExited: controller.onDragExited,
|
opacity: accountConfig.wallpaperOpacity ?? 1,
|
||||||
child: Stack(
|
child: MxcImage(
|
||||||
children: <Widget>[
|
uri: accountConfig.wallpaperUrl,
|
||||||
if (accountConfig.wallpaperUrl != null)
|
fit: BoxFit.cover,
|
||||||
Opacity(
|
isThumbnail: true,
|
||||||
opacity: accountConfig.wallpaperOpacity ?? 1,
|
width: FluffyThemes.columnWidth * 4,
|
||||||
child: MxcImage(
|
height: FluffyThemes.columnWidth * 4,
|
||||||
uri: accountConfig.wallpaperUrl,
|
placeholder: (_) => Container(),
|
||||||
fit: BoxFit.cover,
|
),
|
||||||
isThumbnail: true,
|
),
|
||||||
width: FluffyThemes.columnWidth * 4,
|
SafeArea(
|
||||||
height: FluffyThemes.columnWidth * 4,
|
child: Column(
|
||||||
placeholder: (_) => Container(),
|
children: <Widget>[
|
||||||
),
|
TombstoneDisplay(controller),
|
||||||
),
|
if (scrollUpBannerEventId != null)
|
||||||
SafeArea(
|
Material(
|
||||||
child: Column(
|
color:
|
||||||
children: <Widget>[
|
Theme.of(context).colorScheme.surfaceVariant,
|
||||||
TombstoneDisplay(controller),
|
shape: Border(
|
||||||
if (scrollUpBannerEventId != null)
|
bottom: BorderSide(
|
||||||
Material(
|
width: 1,
|
||||||
color: Theme.of(context)
|
color: Theme.of(context).dividerColor,
|
||||||
.colorScheme
|
|
||||||
.surfaceVariant,
|
|
||||||
shape: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
width: 1,
|
|
||||||
color: Theme.of(context).dividerColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
leading: IconButton(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant,
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
tooltip: L10n.of(context)!.close,
|
|
||||||
onPressed: () {
|
|
||||||
controller
|
|
||||||
.discardScrollUpBannerEventId();
|
|
||||||
controller.setReadMarker();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
L10n.of(context)!.jumpToLastReadMessage,
|
|
||||||
),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.only(left: 8),
|
|
||||||
trailing: TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
controller.scrollToEventId(
|
|
||||||
scrollUpBannerEventId,
|
|
||||||
);
|
|
||||||
controller
|
|
||||||
.discardScrollUpBannerEventId();
|
|
||||||
},
|
|
||||||
child: Text(L10n.of(context)!.jump),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PinnedEvents(controller),
|
|
||||||
Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: controller.clearSingleSelectedEvent,
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
if (controller.timeline == null) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator
|
|
||||||
.adaptive(
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ChatEventList(
|
|
||||||
controller: controller,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (controller.room.canSendDefaultMessages &&
|
child: ListTile(
|
||||||
controller.room.membership == Membership.join)
|
leading: IconButton(
|
||||||
Container(
|
color: Theme.of(context)
|
||||||
margin: EdgeInsets.only(
|
.colorScheme
|
||||||
bottom: bottomSheetPadding,
|
.onSurfaceVariant,
|
||||||
left: bottomSheetPadding,
|
icon: const Icon(Icons.close),
|
||||||
right: bottomSheetPadding,
|
tooltip: L10n.of(context)!.close,
|
||||||
),
|
onPressed: () {
|
||||||
constraints: const BoxConstraints(
|
controller.discardScrollUpBannerEventId();
|
||||||
maxWidth: FluffyThemes.columnWidth * 2.5,
|
controller.setReadMarker();
|
||||||
),
|
},
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Material(
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
bottomLeft: Radius.circular(
|
|
||||||
AppConfig.borderRadius,
|
|
||||||
),
|
|
||||||
bottomRight: Radius.circular(
|
|
||||||
AppConfig.borderRadius,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
elevation: 4,
|
|
||||||
shadowColor: Colors.black.withAlpha(64),
|
|
||||||
clipBehavior: Clip.hardEdge,
|
|
||||||
color: Theme.of(context).brightness ==
|
|
||||||
Brightness.light
|
|
||||||
? Colors.white
|
|
||||||
: Colors.black,
|
|
||||||
child: controller.room.isAbandonedDMRoom ==
|
|
||||||
true
|
|
||||||
? Row(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.all(
|
|
||||||
16,
|
|
||||||
),
|
|
||||||
foregroundColor:
|
|
||||||
Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.error,
|
|
||||||
),
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.archive_outlined,
|
|
||||||
),
|
|
||||||
onPressed: controller.leaveChat,
|
|
||||||
label: Text(
|
|
||||||
L10n.of(context)!.leave,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.all(
|
|
||||||
16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.forum_outlined,
|
|
||||||
),
|
|
||||||
onPressed:
|
|
||||||
controller.recreateChat,
|
|
||||||
label: Text(
|
|
||||||
L10n.of(context)!.reopenChat,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const ConnectionStatusHeader(),
|
|
||||||
ReactionsPicker(controller),
|
|
||||||
ReplyDisplay(controller),
|
|
||||||
ChatInputRow(controller),
|
|
||||||
ChatEmojiPicker(controller),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
title: Text(
|
||||||
),
|
L10n.of(context)!.jumpToLastReadMessage,
|
||||||
),
|
),
|
||||||
if (controller.dragging)
|
contentPadding: const EdgeInsets.only(left: 8),
|
||||||
Container(
|
trailing: TextButton(
|
||||||
color: Theme.of(context)
|
onPressed: () {
|
||||||
.scaffoldBackgroundColor
|
controller.scrollToEventId(
|
||||||
.withOpacity(0.9),
|
scrollUpBannerEventId,
|
||||||
alignment: Alignment.center,
|
);
|
||||||
child: const Icon(
|
controller.discardScrollUpBannerEventId();
|
||||||
Icons.upload_outlined,
|
},
|
||||||
size: 100,
|
child: Text(L10n.of(context)!.jump),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PinnedEvents(controller),
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: controller.clearSingleSelectedEvent,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
if (controller.timeline == null) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator.adaptive(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ChatEventList(
|
||||||
|
controller: controller,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (controller.room.canSendDefaultMessages &&
|
||||||
|
controller.room.membership == Membership.join)
|
||||||
|
Container(
|
||||||
|
margin: EdgeInsets.only(
|
||||||
|
bottom: bottomSheetPadding,
|
||||||
|
left: bottomSheetPadding,
|
||||||
|
right: bottomSheetPadding,
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: FluffyThemes.columnWidth * 2.5,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Material(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(
|
||||||
|
AppConfig.borderRadius,
|
||||||
|
),
|
||||||
|
bottomRight: Radius.circular(
|
||||||
|
AppConfig.borderRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: Colors.black.withAlpha(64),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
color: Theme.of(context).brightness ==
|
||||||
|
Brightness.light
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black,
|
||||||
|
child: controller.room.isAbandonedDMRoom == true
|
||||||
|
? Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.all(
|
||||||
|
16,
|
||||||
|
),
|
||||||
|
foregroundColor: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.error,
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.archive_outlined,
|
||||||
|
),
|
||||||
|
onPressed: controller.leaveChat,
|
||||||
|
label: Text(
|
||||||
|
L10n.of(context)!.leave,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.all(
|
||||||
|
16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.forum_outlined,
|
||||||
|
),
|
||||||
|
onPressed: controller.recreateChat,
|
||||||
|
label: Text(
|
||||||
|
L10n.of(context)!.reopenChat,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const ConnectionStatusHeader(),
|
||||||
|
ReactionsPicker(controller),
|
||||||
|
ReplyDisplay(controller),
|
||||||
|
ChatInputRow(controller),
|
||||||
|
ChatEmojiPicker(controller),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (controller.dragging)
|
||||||
);
|
Container(
|
||||||
},
|
color: Theme.of(context)
|
||||||
),
|
.scaffoldBackgroundColor
|
||||||
),
|
.withOpacity(0.9),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.upload_outlined,
|
||||||
|
size: 100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue