fluffychat/lib/views/chat.dart

1227 lines
52 KiB
Dart
Raw Normal View History

2020-01-05 12:27:03 +01:00
import 'dart:async';
2020-01-01 19:10:13 +01:00
import 'dart:io';
2020-05-15 19:57:53 +02:00
import 'dart:math';
2020-11-14 10:08:13 +01:00
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:emoji_picker/emoji_picker.dart';
2020-01-01 19:10:13 +01:00
import 'package:famedlysdk/famedlysdk.dart';
2020-10-04 17:01:54 +02:00
import 'package:file_picker_cross/file_picker_cross.dart';
2020-01-01 19:10:13 +01:00
import 'package:fluffychat/components/adaptive_page_layout.dart';
2020-05-15 19:57:53 +02:00
import 'package:fluffychat/components/avatar.dart';
2020-01-01 19:10:13 +01:00
import 'package:fluffychat/components/chat_settings_popup_menu.dart';
2020-07-04 10:22:51 +02:00
import 'package:fluffychat/components/connection_status_header.dart';
2020-03-15 11:27:51 +01:00
import 'package:fluffychat/components/dialogs/recording_dialog.dart';
2020-02-16 11:36:18 +01:00
import 'package:fluffychat/components/dialogs/simple_dialogs.dart';
2020-02-22 09:03:44 +01:00
import 'package:fluffychat/components/encryption_button.dart';
2020-01-01 19:10:13 +01:00
import 'package:fluffychat/components/list_items/message.dart';
import 'package:fluffychat/components/matrix.dart';
2020-02-11 12:49:39 +01:00
import 'package:fluffychat/components/reply_content.dart';
2020-10-28 07:23:50 +01:00
import 'package:fluffychat/components/user_bottom_sheet.dart';
import 'package:fluffychat/config/app_emojis.dart';
2020-05-15 19:57:53 +02:00
import 'package:fluffychat/utils/app_route.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
2020-10-04 17:01:54 +02:00
import 'package:fluffychat/utils/platform_infos.dart';
2020-05-15 19:57:53 +02:00
import 'package:fluffychat/utils/room_status_extension.dart';
2020-01-01 19:10:13 +01:00
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
2020-02-09 15:15:29 +01:00
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:image_picker/image_picker.dart';
2020-01-24 12:10:35 +01:00
import 'package:pedantic/pedantic.dart';
2020-09-19 19:21:33 +02:00
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:swipe_to_action/swipe_to_action.dart';
import 'package:shared_preferences/shared_preferences.dart';
2020-01-01 19:10:13 +01:00
2020-09-04 12:56:25 +02:00
import '../components/dialogs/send_file_dialog.dart';
import '../components/input_bar.dart';
2020-12-11 14:14:33 +01:00
import '../app_config.dart';
import '../utils/matrix_file_extension.dart';
import 'chat_details.dart';
import 'chat_list.dart';
2020-01-01 19:10:13 +01:00
2020-01-27 10:14:38 +01:00
class ChatView extends StatelessWidget {
2020-01-01 19:10:13 +01:00
final String id;
2020-09-19 19:21:33 +02:00
final String scrollToEventId;
2020-01-01 19:10:13 +01:00
2020-09-19 19:21:33 +02:00
const ChatView(this.id, {Key key, this.scrollToEventId}) : super(key: key);
2020-01-27 10:14:38 +01:00
@override
Widget build(BuildContext context) {
// TODO: implement build
return AdaptivePageLayout(
primaryPage: FocusPage.SECOND,
firstScaffold: ChatList(
activeChat: id,
),
2020-09-19 19:21:33 +02:00
secondScaffold: _Chat(id, scrollToEventId: scrollToEventId),
2020-01-27 10:14:38 +01:00
);
}
}
class _Chat extends StatefulWidget {
final String id;
2020-09-19 19:21:33 +02:00
final String scrollToEventId;
2020-01-27 10:14:38 +01:00
2020-09-19 19:21:33 +02:00
const _Chat(this.id, {Key key, this.scrollToEventId}) : super(key: key);
2020-01-01 19:10:13 +01:00
@override
_ChatState createState() => _ChatState();
}
2020-01-27 10:14:38 +01:00
class _ChatState extends State<_Chat> {
2020-01-01 19:10:13 +01:00
Room room;
Timeline timeline;
2020-01-08 14:19:15 +01:00
MatrixState matrix;
2020-05-13 15:58:59 +02:00
String seenByText = '';
2020-01-05 12:27:03 +01:00
2020-09-19 19:21:33 +02:00
final AutoScrollController _scrollController = AutoScrollController();
2020-01-01 19:10:13 +01:00
2020-01-24 11:51:23 +01:00
FocusNode inputFocus = FocusNode();
2020-01-05 12:27:03 +01:00
Timer typingCoolDown;
Timer typingTimeout;
bool currentlyTyping = false;
2020-04-02 13:14:39 +02:00
List<Event> selectedEvents = [];
2020-02-09 15:15:29 +01:00
2020-11-22 19:34:19 +01:00
bool _collapseRoomCreate = true;
2020-02-09 15:15:29 +01:00
Event replyEvent;
Event editEvent;
2020-02-09 16:58:49 +01:00
bool showScrollDownButton = false;
2020-02-09 15:15:29 +01:00
bool get selectMode => selectedEvents.isNotEmpty;
2020-02-14 14:34:28 +01:00
bool _loadingHistory = false;
final int _loadHistoryCount = 100;
2020-05-13 15:58:59 +02:00
String inputText = '';
2020-02-22 09:03:44 +01:00
String pendingText = '';
2020-05-09 09:30:03 +02:00
bool get _canLoadMore => timeline.events.last.type != EventTypes.RoomCreate;
2020-02-14 14:34:28 +01:00
void requestHistory() async {
2020-05-09 09:30:03 +02:00
if (_canLoadMore) {
2020-05-13 15:58:59 +02:00
setState(() => _loadingHistory = true);
2020-07-04 10:22:51 +02:00
await SimpleDialogs(context).tryRequestWithErrorToast(
timeline.requestHistory(historyCount: _loadHistoryCount),
);
2020-09-19 19:21:33 +02:00
// we do NOT setState() here as then the event order will be wrong.
// instead, we just set our variable to false, and rely on timeline update to set the
// new state, thus triggering a re-render, for us
_loadingHistory = false;
}
}
void _updateScrollController() {
2020-11-16 11:31:31 +01:00
if (!mounted) {
return;
}
2020-09-19 19:21:33 +02:00
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent &&
timeline.events.isNotEmpty &&
timeline.events[timeline.events.length - 1].type !=
EventTypes.RoomCreate) {
requestHistory();
}
if (_scrollController.position.pixels > 0 &&
showScrollDownButton == false) {
setState(() => showScrollDownButton = true);
} else if (_scrollController.position.pixels == 0 &&
showScrollDownButton == true) {
setState(() => showScrollDownButton = false);
2020-02-14 14:37:31 +01:00
}
2020-02-14 14:34:28 +01:00
}
2020-01-01 19:10:13 +01:00
@override
void initState() {
2020-09-20 14:36:53 +02:00
_scrollController.addListener(_updateScrollController);
2020-01-01 19:10:13 +01:00
super.initState();
}
2020-01-05 12:27:03 +01:00
void updateView() {
if (!mounted) return;
2020-05-13 15:58:59 +02:00
var seenByText = '';
2020-01-05 12:27:03 +01:00
if (timeline.events.isNotEmpty) {
final filteredEvents = getFilteredEvents();
final lastReceipts = <User>{};
// now we iterate the timeline events until we hit the first rendered event
for (final event in timeline.events) {
lastReceipts.addAll(event.receipts.map((r) => r.user));
if (event.eventId == filteredEvents.first.eventId) {
break;
}
}
lastReceipts.removeWhere((user) =>
user.id == room.client.userID ||
user.id == filteredEvents.first.senderId);
2020-01-05 12:27:03 +01:00
if (lastReceipts.length == 1) {
seenByText =
L10n.of(context).seenByUser(lastReceipts.first.calcDisplayname());
2020-01-05 12:27:03 +01:00
} else if (lastReceipts.length == 2) {
2020-05-07 07:52:40 +02:00
seenByText = seenByText = L10n.of(context).seenByUserAndUser(
lastReceipts.first.calcDisplayname(),
lastReceipts.last.calcDisplayname());
2020-01-05 12:27:03 +01:00
} else if (lastReceipts.length > 2) {
2020-05-07 07:52:40 +02:00
seenByText = L10n.of(context).seenByUserAndCountOthers(
lastReceipts.first.calcDisplayname(),
2020-01-20 13:46:39 +01:00
(lastReceipts.length - 1).toString());
2020-01-05 12:27:03 +01:00
}
}
if (timeline != null) {
setState(() {
this.seenByText = seenByText;
});
}
2020-01-05 12:27:03 +01:00
}
2020-09-19 19:21:33 +02:00
Future<bool> getTimeline(BuildContext context) async {
if (timeline == null) {
timeline = await room.getTimeline(onUpdate: updateView);
if (timeline.events.isNotEmpty) {
unawaited(room.setUnread(false).catchError((err) {
2020-11-16 11:31:31 +01:00
if (err is MatrixException && err.errcode == 'M_FORBIDDEN') {
// ignore if the user is not in the room (still joining)
return;
}
throw err;
}));
}
2020-09-19 19:21:33 +02:00
// 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
// "load more" button is visible on the screen
SchedulerBinding.instance.addPostFrameCallback((_) async {
2020-11-16 11:31:31 +01:00
if (mounted) {
if (widget.scrollToEventId != null) {
_scrollToEventId(widget.scrollToEventId, context: context);
}
_updateScrollController();
2020-09-19 19:21:33 +02:00
}
});
}
2020-01-05 12:27:03 +01:00
updateView();
2020-01-01 19:10:13 +01:00
return true;
}
@override
void dispose() {
2020-02-22 08:27:08 +01:00
timeline?.cancelSubscriptions();
timeline = null;
2020-05-13 15:58:59 +02:00
matrix.activeRoomId = '';
2020-01-01 19:10:13 +01:00
super.dispose();
}
2020-01-17 10:41:28 +01:00
TextEditingController sendController = TextEditingController();
2020-01-01 19:10:13 +01:00
void send() {
if (sendController.text.isEmpty) return;
room.sendTextEvent(sendController.text,
inReplyTo: replyEvent, editEventId: editEvent?.eventId);
sendController.text = pendingText;
2020-02-23 08:52:28 +01:00
setState(() {
inputText = pendingText;
replyEvent = null;
editEvent = null;
pendingText = '';
});
2020-01-01 19:10:13 +01:00
}
void sendFileAction(BuildContext context) async {
2020-10-04 17:01:54 +02:00
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.any);
if (result == null) return;
2020-09-04 12:56:25 +02:00
await showDialog(
2020-10-04 17:01:54 +02:00
context: context,
builder: (context) => SendFileDialog(
file: MatrixFile(
bytes: result.toUint8List(),
name: result.fileName,
).detectFileType,
room: room,
),
);
2020-01-01 19:10:13 +01:00
}
void sendImageAction(BuildContext context) async {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.image);
if (result == null) return;
2020-09-04 12:56:25 +02:00
await showDialog(
2020-10-04 17:01:54 +02:00
context: context,
builder: (context) => SendFileDialog(
file: MatrixImageFile(
bytes: result.toUint8List(),
name: result.fileName,
),
2020-10-04 17:01:54 +02:00
room: room,
),
);
2020-01-01 19:10:13 +01:00
}
void openCameraAction(BuildContext context) async {
2020-10-04 17:01:54 +02:00
var file = await ImagePicker().getImage(source: ImageSource.camera);
2020-01-01 19:10:13 +01:00
if (file == null) return;
2020-10-04 17:01:54 +02:00
final bytes = await file.readAsBytes();
2020-09-04 12:56:25 +02:00
await showDialog(
2020-10-04 17:01:54 +02:00
context: context,
builder: (context) => SendFileDialog(
file: MatrixImageFile(
bytes: bytes,
name: file.path,
),
room: room,
),
);
2020-01-01 19:10:13 +01:00
}
2020-03-15 11:27:51 +01:00
void voiceMessageAction(BuildContext context) async {
String result;
await showDialog(
context: context,
builder: (context) => RecordingDialog(
onFinished: (r) => result = r,
));
if (result == null) return;
2020-05-13 15:58:59 +02:00
final audioFile = File(result);
// as we already explicitly say send in the recording dialog,
// we do not need the send file dialog anymore. We can just send this straight away.
await SimpleDialogs(context).tryRequestWithLoadingDialog(
room.sendFileEvent(
MatrixAudioFile(
bytes: audioFile.readAsBytesSync(), name: audioFile.path),
),
);
2020-03-15 11:27:51 +01:00
}
2020-02-09 15:15:29 +01:00
String _getSelectedEventString(BuildContext context) {
2020-05-13 15:58:59 +02:00
var copyString = '';
2020-04-02 13:14:39 +02:00
if (selectedEvents.length == 1) {
return selectedEvents.first
.getDisplayEvent(timeline)
.getLocalizedBody(MatrixLocals(L10n.of(context)));
2020-04-02 13:14:39 +02:00
}
2020-05-13 15:58:59 +02:00
for (var event in selectedEvents) {
if (copyString.isNotEmpty) copyString += '\n\n';
copyString += event.getDisplayEvent(timeline).getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: true);
2020-02-09 15:15:29 +01:00
}
return copyString;
}
void copyEventsAction(BuildContext context) {
Clipboard.setData(ClipboardData(text: _getSelectedEventString(context)));
setState(() => selectedEvents.clear());
}
void redactEventsAction(BuildContext context) async {
2020-11-14 10:08:13 +01:00
var confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).messageWillBeRemovedWarning,
okLabel: L10n.of(context).remove,
) ==
OkCancelResult.ok;
2020-02-09 15:15:29 +01:00
if (!confirmed) return;
2020-05-13 15:58:59 +02:00
for (var event in selectedEvents) {
2020-04-27 13:36:39 +02:00
await SimpleDialogs(context).tryRequestWithLoadingDialog(
2020-02-09 15:15:29 +01:00
event.status > 0 ? event.redact() : event.remove());
}
setState(() => selectedEvents.clear());
}
bool get canRedactSelectedEvents {
2020-05-13 15:58:59 +02:00
for (var event in selectedEvents) {
2020-02-09 15:15:29 +01:00
if (event.canRedact == false) return false;
}
return true;
}
void forwardEventsAction(BuildContext context) async {
if (selectedEvents.length == 1) {
Matrix.of(context).shareContent = selectedEvents.first.content;
} else {
Matrix.of(context).shareContent = {
2020-05-13 15:58:59 +02:00
'msgtype': 'm.text',
'body': _getSelectedEventString(context),
2020-02-09 15:15:29 +01:00
};
}
setState(() => selectedEvents.clear());
Navigator.of(context).popUntil((r) => r.isFirst);
}
void sendAgainAction(Timeline timeline) {
final event = selectedEvents.first;
if (event.status == -1) {
event.sendAgain();
}
final allEditEvents = event
.aggregatedEvents(timeline, RelationshipTypes.Edit)
.where((e) => e.status == -1);
for (final e in allEditEvents) {
e.sendAgain();
}
2020-02-09 15:15:29 +01:00
setState(() => selectedEvents.clear());
}
void replyAction({Event replyTo}) {
2020-02-09 15:15:29 +01:00
setState(() {
replyEvent = replyTo ?? selectedEvents.first;
2020-02-09 15:15:29 +01:00
selectedEvents.clear();
});
2020-02-16 11:36:18 +01:00
inputFocus.requestFocus();
2020-02-09 15:15:29 +01:00
}
2020-09-19 19:21:33 +02:00
void _scrollToEventId(String eventId, {BuildContext context}) async {
var eventIndex =
getFilteredEvents().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
2020-11-14 10:08:13 +01:00
final task = Future.microtask(() async {
2020-09-19 19:21:33 +02:00
// okay, we first have to fetch if the event is in the room
try {
final event = await timeline.getEventById(eventId);
if (event == null) {
// event is null...meaning something is off
return;
}
} catch (err) {
if (err is MatrixException && err.errcode == 'M_NOT_FOUND') {
// event wasn't found, as the server gave a 404 or something
return;
}
rethrow;
}
// okay, we know that the event *is* in the room
while (eventIndex == -1) {
if (!_canLoadMore) {
// we can't load any more events but still haven't found ours yet...better stop here
return;
}
try {
await timeline.requestHistory(historyCount: _loadHistoryCount);
} catch (err) {
if (err is TimeoutException) {
// loading the history timed out...so let's do nothing
return;
}
rethrow;
}
eventIndex =
getFilteredEvents().indexWhere((e) => e.eventId == eventId);
}
2020-11-14 10:08:13 +01:00
});
if (context != null) {
await SimpleDialogs(context).tryRequestWithLoadingDialog(task);
} else {
await task;
2020-09-19 19:21:33 +02:00
}
}
2020-11-16 11:31:31 +01:00
if (!mounted) {
return;
}
2020-09-19 19:21:33 +02:00
await _scrollController.scrollToIndex(eventIndex,
preferPosition: AutoScrollPosition.middle);
_updateScrollController();
}
2020-11-22 19:34:19 +01:00
List<Event> getFilteredEvents() {
final filteredEvents = timeline.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) &&
2020-11-22 19:34:19 +01:00
// 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
(!{EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted}
.contains(e.type) ||
!AppConfig.hideAllStateEvents))
2020-11-22 19:34:19 +01:00
.toList();
// Hide state events from the room creater right after the room created event
if (_collapseRoomCreate &&
filteredEvents[filteredEvents.length - 1].type ==
EventTypes.RoomCreate) {
2020-11-23 14:27:10 +01:00
while (filteredEvents.length >= 3 &&
filteredEvents[filteredEvents.length - 2].senderId ==
2020-11-22 19:34:19 +01:00
filteredEvents[filteredEvents.length - 1].senderId &&
![EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted]
.contains(filteredEvents[filteredEvents.length - 2].type)) {
filteredEvents.removeAt(filteredEvents.length - 2);
}
}
return filteredEvents;
}
2020-09-19 19:21:33 +02:00
void _pickEmojiAction(
BuildContext context, Iterable<Event> allReactionEvents) async {
final emoji = await showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Column(
children: [
Spacer(),
EmojiPicker(
onEmojiSelected: (emoji, category) {
// recent emojis don't work, so we sadly have to re-implement them
// https://github.com/JeffG05/emoji_picker/issues/31
SharedPreferences.getInstance().then((prefs) {
final recents = prefs.getStringList('recents') ?? <String>[];
recents.insert(0, emoji.name);
// make sure we remove duplicates
prefs.setStringList('recents', recents.toSet().toList());
});
Navigator.of(context).pop<Emoji>(emoji);
},
),
],
),
);
if (emoji == null) return;
// make sure we don't send the same emoji twice
if (allReactionEvents
.any((e) => e.content['m.relates_to']['key'] == emoji.emoji)) return;
return _sendEmojiAction(context, emoji.emoji);
}
void _sendEmojiAction(BuildContext context, String emoji) {
SimpleDialogs(context).tryRequestWithLoadingDialog(
room.sendReaction(
selectedEvents.first.eventId,
emoji,
),
);
setState(() => selectedEvents.clear());
}
2020-01-01 19:10:13 +01:00
@override
Widget build(BuildContext context) {
2020-01-08 14:19:15 +01:00
matrix = Matrix.of(context);
2020-05-13 15:58:59 +02:00
var client = matrix.client;
2020-01-01 19:10:13 +01:00
room ??= client.getRoomById(widget.id);
2020-01-05 12:27:03 +01:00
if (room == null) {
2020-01-20 13:46:39 +01:00
return Scaffold(
appBar: AppBar(
2020-05-07 07:52:40 +02:00
title: Text(L10n.of(context).oopsSomethingWentWrong),
2020-01-20 13:46:39 +01:00
),
body: Center(
2020-05-07 07:52:40 +02:00
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
2020-01-20 13:46:39 +01:00
),
2020-01-05 12:27:03 +01:00
);
}
2020-01-08 14:19:15 +01:00
matrix.activeRoomId = widget.id;
2020-01-05 12:27:03 +01:00
if (room.membership == Membership.invite) {
2020-04-27 13:36:39 +02:00
SimpleDialogs(context).tryRequestWithLoadingDialog(room.join());
2020-01-05 12:27:03 +01:00
}
2020-01-01 19:10:13 +01:00
2020-11-22 15:25:13 +01:00
final typingText = room.getLocalizedTypingText(context);
2020-01-27 10:14:38 +01:00
return Scaffold(
appBar: AppBar(
2020-02-09 15:15:29 +01:00
leading: selectMode
? IconButton(
icon: Icon(Icons.close),
onPressed: () => setState(() => selectedEvents.clear()),
)
: null,
2020-05-15 19:57:53 +02:00
titleSpacing: 0,
2020-02-09 15:15:29 +01:00
title: selectedEvents.isEmpty
2020-05-15 19:57:53 +02:00
? StreamBuilder<Object>(
2020-12-17 19:14:33 +01:00
stream: room.onUpdate.stream,
builder: (context, snapshot) => ListTile(
leading: Avatar(room.avatar, room.displayname),
contentPadding: EdgeInsets.zero,
onTap: room.isDirectChat
? () => showModalBottomSheet(
context: context,
builder: (context) => UserBottomSheet(
user: room.getUserByMXIDSync(
room.directChatMatrixID),
onMention: () => sendController.text +=
' ${room.directChatMatrixID}',
),
)
: () => Navigator.of(context).push(
AppRoute.defaultRoute(
context,
ChatDetails(room),
2020-05-15 19:57:53 +02:00
),
),
2020-12-17 19:14:33 +01:00
title: Text(
room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context))),
maxLines: 1),
subtitle: typingText.isEmpty
? StreamBuilder<Object>(
stream: Matrix.of(context)
.client
.onPresence
.stream
.where((p) =>
p.senderId == room.directChatMatrixID),
builder: (context, snapshot) => Text(
room.getLocalizedStatus(context),
maxLines: 1,
))
: Row(
children: <Widget>[
Icon(Icons.edit_outlined,
color: Theme.of(context).primaryColor,
size: 13),
SizedBox(width: 4),
Text(
typingText,
maxLines: 1,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontStyle: FontStyle.italic,
fontSize: 16,
),
),
],
),
))
2020-05-07 07:52:40 +02:00
: Text(L10n.of(context)
2020-02-09 15:15:29 +01:00
.numberSelected(selectedEvents.length.toString())),
actions: selectMode
? <Widget>[
if (selectedEvents.length == 1 &&
selectedEvents.first.status > 0 &&
selectedEvents.first.senderId == client.userID)
IconButton(
2020-12-06 10:31:35 +01:00
icon: Icon(Icons.edit_outlined),
onPressed: () {
setState(() {
pendingText = sendController.text;
editEvent = selectedEvents.first;
inputText = sendController.text = editEvent
.getDisplayEvent(timeline)
.getLocalizedBody(MatrixLocals(L10n.of(context)),
withSenderNamePrefix: false, hideReply: true);
selectedEvents.clear();
});
inputFocus.requestFocus();
},
),
2020-02-09 15:15:29 +01:00
IconButton(
2020-12-06 10:31:35 +01:00
icon: Icon(Icons.content_copy_outlined),
2020-02-09 15:15:29 +01:00
onPressed: () => copyEventsAction(context),
),
if (canRedactSelectedEvents)
IconButton(
2020-12-06 10:31:35 +01:00
icon: Icon(Icons.delete_outlined),
2020-02-09 15:15:29 +01:00
onPressed: () => redactEventsAction(context),
),
]
: <Widget>[ChatSettingsPopupMenu(room, !room.isDirectChat)],
2020-01-27 10:14:38 +01:00
),
2020-02-09 16:58:49 +01:00
floatingActionButton: showScrollDownButton
? Padding(
padding: const EdgeInsets.only(bottom: 56.0),
child: FloatingActionButton(
2020-12-06 10:31:35 +01:00
child: Icon(Icons.arrow_downward_outlined,
2020-02-09 16:58:49 +01:00
color: Theme.of(context).primaryColor),
onPressed: () => _scrollController.jumpTo(0),
2020-05-06 18:43:30 +02:00
foregroundColor: Theme.of(context).textTheme.bodyText2.color,
2020-02-09 16:58:49 +01:00
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
mini: true,
),
)
: null,
2020-02-14 14:34:28 +01:00
body: Stack(
children: <Widget>[
2020-04-03 20:24:25 +02:00
if (Matrix.of(context).wallpaper != null)
2020-10-17 13:04:33 +02:00
Image.file(
Matrix.of(context).wallpaper,
height: double.infinity,
width: double.infinity,
fit: BoxFit.cover,
2020-01-27 10:14:38 +01:00
),
2020-11-07 10:18:51 +01:00
SafeArea(
child: Column(
children: <Widget>[
ConnectionStatusHeader(),
Expanded(
child: FutureBuilder<bool>(
future: getTimeline(context),
builder: (BuildContext context, snapshot) {
if (!snapshot.hasData) {
return Center(
child: CircularProgressIndicator(),
);
}
if (room.notificationCount != null &&
room.notificationCount > 0 &&
timeline != null &&
timeline.events.isNotEmpty &&
Matrix.of(context).webHasFocus) {
room.sendReadMarker(timeline.events.first.eventId);
2020-11-07 10:18:51 +01:00
}
final filteredEvents = getFilteredEvents();
// create a map of eventId --> index to greatly improve performance of
// ListView's findChildIndexCallback
final thisEventsKeyMap = <String, int>{};
for (var i = 0; i < filteredEvents.length; i++) {
thisEventsKeyMap[filteredEvents[i].eventId] = i;
}
return ListView.custom(
padding: EdgeInsets.symmetric(
horizontal: max(
0,
(MediaQuery.of(context).size.width -
AdaptivePageLayout.defaultMinWidth *
3.5) /
2),
),
reverse: true,
controller: _scrollController,
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int i) {
2020-11-07 10:18:51 +01:00
return i == filteredEvents.length + 1
? _loadingHistory
? Container(
height: 50,
alignment: Alignment.center,
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
)
: _canLoadMore
? FlatButton(
child: Text(
L10n.of(context).loadMore,
style: TextStyle(
color: Theme.of(context)
.primaryColor,
fontWeight: FontWeight.bold,
decoration:
TextDecoration.underline,
),
),
onPressed: requestHistory,
)
: Container()
: i == 0
? AnimatedContainer(
height: seenByText.isEmpty ? 0 : 24,
duration: seenByText.isEmpty
? Duration(milliseconds: 0)
: Duration(milliseconds: 300),
alignment:
filteredEvents.first.senderId ==
client.userID
? Alignment.topRight
: Alignment.topLeft,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 4),
decoration: BoxDecoration(
color: Theme.of(context)
.scaffoldBackgroundColor
.withOpacity(0.8),
borderRadius:
BorderRadius.circular(4),
),
2020-07-04 10:22:51 +02:00
child: Text(
2020-11-07 10:18:51 +01:00
seenByText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
2020-07-04 10:22:51 +02:00
style: TextStyle(
color: Theme.of(context)
.primaryColor,
),
),
2020-09-21 19:21:24 +02:00
),
2020-11-07 10:18:51 +01:00
padding: EdgeInsets.only(
left: 8,
right: 8,
bottom: 8,
2020-05-09 09:30:03 +02:00
),
2020-11-07 10:18:51 +01:00
)
: AutoScrollTag(
key: ValueKey(
filteredEvents[i - 1].eventId),
2020-11-07 10:18:51 +01:00
index: i - 1,
controller: _scrollController,
child: Swipeable(
key: ValueKey(
filteredEvents[i - 1].eventId),
background: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12.0),
child: Center(
2020-12-06 10:31:35 +01:00
child: Icon(Icons.reply_outlined),
2020-11-07 10:18:51 +01:00
),
),
2020-11-07 10:18:51 +01:00
direction: SwipeDirection.endToStart,
onSwipe: (direction) => replyAction(
replyTo: filteredEvents[i - 1]),
child: Message(filteredEvents[i - 1],
onAvatarTab: (Event event) =>
showModalBottomSheet(
context: context,
builder: (context) =>
UserBottomSheet(
user: event.sender,
onMention: () =>
sendController.text +=
' ${event.senderId}',
),
2020-10-28 07:23:50 +01:00
),
2020-11-07 10:18:51 +01:00
onSelect: (Event event) {
2020-11-22 19:34:19 +01:00
if (event.type ==
EventTypes.RoomCreate) {
return setState(() =>
_collapseRoomCreate =
false);
}
2020-11-07 10:18:51 +01:00
if (!event.redacted) {
if (selectedEvents
.contains(event)) {
setState(
() => selectedEvents
.remove(event),
);
} else {
setState(
() => selectedEvents
.add(event),
);
}
selectedEvents.sort(
(a, b) => a.originServerTs
.compareTo(
b.originServerTs),
);
}
2020-11-07 10:18:51 +01:00
},
scrollToEventId:
(String eventId) =>
_scrollToEventId(eventId,
context: context),
longPressSelect:
selectedEvents.isEmpty,
selected: selectedEvents.contains(
filteredEvents[i - 1]),
timeline: timeline,
nextEvent: i >= 2
? filteredEvents[i - 2]
: null),
),
);
},
childCount: filteredEvents.length + 2,
findChildIndexCallback: (Key key) {
// this method is called very often. As such, it has to be optimized for speed.
if (!(key is ValueKey)) {
return null;
}
final eventId = (key as ValueKey).value;
if (!(eventId is String)) {
return null;
}
// first fetch the last index the event was at
final index = thisEventsKeyMap[eventId];
if (index == null) {
return null;
}
// we need to +1 as 0 is the typing thing at the bottom
return index + 1;
},
),
);
2020-11-07 10:18:51 +01:00
},
),
2020-02-14 14:34:28 +01:00
),
2020-11-07 10:18:51 +01:00
AnimatedContainer(
duration: Duration(milliseconds: 300),
height: (editEvent == null &&
replyEvent == null &&
selectedEvents.length == 1)
? 56
: 0,
child: Material(
color: Theme.of(context).secondaryHeaderColor,
child: Builder(builder: (context) {
if (!(editEvent == null &&
replyEvent == null &&
selectedEvents.length == 1)) {
return Container();
}
var emojis = List<String>.from(AppEmojis.emojis);
final allReactionEvents = selectedEvents.first
.aggregatedEvents(
timeline, RelationshipTypes.Reaction)
?.where((event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction');
allReactionEvents.forEach((event) {
try {
emojis.remove(event.content['m.relates_to']['key']);
} catch (_) {}
});
return ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: emojis.length + 1,
itemBuilder: (c, i) => i == emojis.length
? InkWell(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Icon(Icons.add_outlined),
),
onTap: () => _pickEmojiAction(
context, allReactionEvents),
)
: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () =>
_sendEmojiAction(context, emojis[i]),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
emojis[i],
style: TextStyle(fontSize: 30),
),
),
2020-11-07 10:18:51 +01:00
),
);
}),
),
2020-09-20 20:13:42 +02:00
),
2020-11-07 10:18:51 +01:00
AnimatedContainer(
duration: Duration(milliseconds: 300),
height: editEvent != null || replyEvent != null ? 56 : 0,
child: Material(
color: Theme.of(context).secondaryHeaderColor,
child: Row(
children: <Widget>[
IconButton(
icon: Icon(Icons.close),
onPressed: () => setState(() {
if (editEvent != null) {
inputText = sendController.text = pendingText;
pendingText = '';
}
replyEvent = null;
editEvent = null;
}),
),
Expanded(
child: replyEvent != null
? ReplyContent(replyEvent, timeline: timeline)
: _EditContent(
editEvent?.getDisplayEvent(timeline)),
),
],
),
2020-06-18 13:39:24 +02:00
),
2020-04-02 14:16:45 +02:00
),
2020-11-07 10:18:51 +01:00
Divider(
height: 1,
color: Theme.of(context).secondaryHeaderColor,
thickness: 1,
),
room.canSendDefaultMessages &&
room.membership == Membership.join
? Container(
decoration: BoxDecoration(
color: Theme.of(context).backgroundColor,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: selectMode
? <Widget>[
Container(
height: 56,
child: FlatButton(
onPressed: () =>
forwardEventsAction(context),
child: Row(
children: <Widget>[
2020-12-06 10:31:35 +01:00
Icon(Icons
.keyboard_arrow_left_outlined),
2020-11-07 10:18:51 +01:00
Text(L10n.of(context).forward),
],
),
2020-02-14 14:34:28 +01:00
),
2020-01-27 10:14:38 +01:00
),
2020-11-07 10:18:51 +01:00
selectedEvents.length == 1
? selectedEvents.first
.getDisplayEvent(timeline)
.status >
0
? Container(
height: 56,
child: FlatButton(
onPressed: () => replyAction(),
child: Row(
children: <Widget>[
Text(
L10n.of(context).reply),
Icon(Icons
.keyboard_arrow_right),
],
),
2020-02-14 14:34:28 +01:00
),
2020-11-07 10:18:51 +01:00
)
: Container(
height: 56,
child: FlatButton(
onPressed: () =>
sendAgainAction(timeline),
child: Row(
children: <Widget>[
Text(L10n.of(context)
.tryToSendAgain),
SizedBox(width: 4),
2020-12-06 10:31:35 +01:00
Icon(Icons.send_outlined,
size: 16),
2020-11-07 10:18:51 +01:00
],
),
2020-02-14 14:34:28 +01:00
),
2020-11-07 10:18:51 +01:00
)
: Container(),
]
: <Widget>[
if (inputText.isEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: PopupMenuButton<String>(
2020-12-06 10:31:35 +01:00
icon: Icon(Icons.add_outlined),
2020-11-07 10:18:51 +01:00
onSelected: (String choice) async {
if (choice == 'file') {
sendFileAction(context);
} else if (choice == 'image') {
sendImageAction(context);
}
if (choice == 'camera') {
openCameraAction(context);
}
if (choice == 'voice') {
voiceMessageAction(context);
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
2020-06-24 09:42:28 +02:00
PopupMenuItem<String>(
2020-11-07 10:18:51 +01:00
value: 'file',
2020-06-24 09:42:28 +02:00
child: ListTile(
leading: CircleAvatar(
2020-11-07 10:18:51 +01:00
backgroundColor: Colors.green,
2020-06-24 09:42:28 +02:00
foregroundColor: Colors.white,
2020-12-06 10:31:35 +01:00
child: Icon(
Icons.attachment_outlined),
2020-06-24 09:42:28 +02:00
),
title: Text(
2020-11-07 10:18:51 +01:00
L10n.of(context).sendFile),
2020-06-24 09:42:28 +02:00
contentPadding: EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
2020-11-07 10:18:51 +01:00
value: 'image',
2020-06-24 09:42:28 +02:00
child: ListTile(
leading: CircleAvatar(
2020-11-07 10:18:51 +01:00
backgroundColor: Colors.blue,
2020-06-24 09:42:28 +02:00
foregroundColor: Colors.white,
2020-12-06 10:31:35 +01:00
child:
Icon(Icons.image_outlined),
2020-06-24 09:42:28 +02:00
),
2020-11-07 10:18:51 +01:00
title: Text(
L10n.of(context).sendImage),
2020-06-24 09:42:28 +02:00
contentPadding: EdgeInsets.all(0),
),
),
2020-11-07 10:18:51 +01:00
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'camera',
child: ListTile(
leading: CircleAvatar(
backgroundColor:
Colors.purple,
foregroundColor: Colors.white,
2020-12-06 10:31:35 +01:00
child: Icon(Icons
.camera_alt_outlined),
2020-11-07 10:18:51 +01:00
),
title: Text(L10n.of(context)
.openCamera),
contentPadding:
EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'voice',
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
2020-12-06 10:31:35 +01:00
child: Icon(
Icons.mic_none_outlined),
2020-11-07 10:18:51 +01:00
),
title: Text(L10n.of(context)
.voiceMessage),
contentPadding:
EdgeInsets.all(0),
),
),
],
2020-06-18 13:39:24 +02:00
),
2020-03-15 11:27:51 +01:00
),
2020-06-24 09:42:28 +02:00
Container(
height: 56,
alignment: Alignment.center,
2020-11-07 10:18:51 +01:00
child: EncryptionButton(room),
2020-06-18 13:39:24 +02:00
),
2020-11-07 10:18:51 +01:00
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0),
child: InputBar(
room: room,
minLines: 1,
maxLines: kIsWeb ? 1 : 8,
autofocus: !PlatformInfos.isMobile,
keyboardType: !PlatformInfos.isMobile
? TextInputType.text
: TextInputType.multiline,
onSubmitted: (String text) {
send();
FocusScope.of(context)
.requestFocus(inputFocus);
},
focusNode: inputFocus,
controller: sendController,
decoration: InputDecoration(
hintText:
L10n.of(context).writeAMessage,
hintMaxLines: 1,
border: InputBorder.none,
),
onChanged: (String text) {
typingCoolDown?.cancel();
typingCoolDown =
Timer(Duration(seconds: 2), () {
typingCoolDown = null;
currentlyTyping = false;
room.sendTypingInfo(false);
});
typingTimeout ??=
Timer(Duration(seconds: 30), () {
typingTimeout = null;
currentlyTyping = false;
});
if (!currentlyTyping) {
currentlyTyping = true;
room.sendTypingInfo(true,
timeout: Duration(seconds: 30)
.inMilliseconds);
}
// Workaround for a current desktop bug
if (!PlatformInfos.isBetaDesktop) {
setState(() => inputText = text);
}
},
),
2020-06-24 09:42:28 +02:00
),
2020-06-18 13:39:24 +02:00
),
2020-11-07 10:18:51 +01:00
if (PlatformInfos.isMobile &&
inputText.isEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
2020-12-06 10:31:35 +01:00
icon: Icon(Icons.mic_none_outlined),
2020-11-07 10:18:51 +01:00
onPressed: () =>
voiceMessageAction(context),
),
),
if (!PlatformInfos.isMobile ||
inputText.isNotEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
2020-12-06 10:31:35 +01:00
icon: Icon(Icons.send_outlined),
2020-11-07 10:18:51 +01:00
onPressed: () => send(),
),
),
],
),
)
: Container(),
],
),
2020-02-14 14:34:28 +01:00
),
],
2020-01-01 19:10:13 +01:00
),
);
}
}
class _EditContent extends StatelessWidget {
final Event event;
_EditContent(this.event);
@override
Widget build(BuildContext context) {
if (event == null) {
return Container();
}
return Row(
children: <Widget>[
Icon(
Icons.edit,
color: Theme.of(context).primaryColor,
),
Container(width: 15.0),
Text(
event?.getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: false,
hideReply: true,
) ??
'',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: Theme.of(context).textTheme.bodyText2.color,
),
),
],
);
}
}