fluffychat/lib/views/chat.dart

1282 lines
55 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';
2021-01-16 12:46:38 +01:00
import 'package:adaptive_page_layout/adaptive_page_layout.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';
2021-01-16 16:39:07 +01:00
import 'package:fluffychat/app_config.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';
2021-01-16 12:46:38 +01:00
import 'package:fluffychat/config/themes.dart';
import 'package:flushbar/flushbar_helper.dart';
2020-12-25 09:58:34 +01:00
import 'package:future_loading_dialog/future_loading_dialog.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';
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';
import 'package:permission_handler/permission_handler.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';
2021-01-16 16:39:07 +01:00
import 'package:url_launcher/url_launcher.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-23 11:23:19 +01:00
import '../utils/filtered_timeline_extension.dart';
import '../utils/matrix_file_extension.dart';
2020-01-01 19:10:13 +01:00
2021-01-16 12:46:38 +01:00
class Chat extends StatefulWidget {
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
2021-01-16 12:46:38 +01:00
Chat(this.id, {Key key, this.scrollToEventId})
: super(key: key ?? Key('chatroom-$id'));
2020-01-01 19:10:13 +01:00
@override
_ChatState createState() => _ChatState();
}
2021-01-16 12:46: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-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-12-23 11:23:19 +01:00
List<Event> filteredEvents;
final Set<String> _unfolded = {};
2020-11-22 19:34:19 +01:00
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
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;
2021-01-16 16:39:07 +01:00
void startCallAction(BuildContext context) async {
final url =
'${AppConfig.jitsiInstance}${Uri.encodeComponent(Matrix.of(context).client.generateUniqueTransactionId())}';
final success = await showFutureLoadingDialog(
context: context,
future: () => room.sendEvent({
'msgtype': Matrix.callNamespace,
'body': url,
}));
if (success.error != null) return;
await launch(url);
}
2020-02-14 14:34:28 +01:00
void requestHistory() async {
2020-05-09 09:30:03 +02:00
if (_canLoadMore) {
try {
await timeline.requestHistory(historyCount: _loadHistoryCount);
} catch (err) {
await FlushbarHelper.createError(
message: err.toLocalizedString(context))
.show(context);
}
2020-09-19 19:21:33 +02:00
}
}
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-12-23 11:23:19 +01:00
setState(
() {
filteredEvents = timeline.getFilteredEvents(unfolded: _unfolded);
2020-12-23 11:23:19 +01:00
},
);
2020-01-05 12:27:03 +01:00
}
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);
});
}
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;
2021-02-07 17:18:38 +01:00
matrix.client.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() {
2021-01-16 19:44:46 +01:00
if (sendController.text.trim().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,
2021-02-24 12:17:23 +01:00
useRootNavigator: false,
2021-01-18 10:43:00 +01:00
builder: (c) => SendFileDialog(
2020-10-04 17:01:54 +02:00
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,
2021-02-24 12:17:23 +01:00
useRootNavigator: false,
2021-01-18 10:43:00 +01:00
builder: (c) => 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,
2021-02-24 12:17:23 +01:00
useRootNavigator: false,
2021-01-18 10:43:00 +01:00
builder: (c) => SendFileDialog(
2020-10-04 17:01:54 +02:00
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 {
if (await Permission.microphone.isGranted != true) {
final status = await Permission.microphone.request();
if (status != PermissionStatus.granted) return;
}
final result = await showDialog<String>(
context: context,
2021-02-24 12:17:23 +01:00
builder: (c) => RecordingDialog(),
useRootNavigator: false,
);
2020-03-15 11:27:51 +01:00
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.
2020-12-25 09:58:34 +01:00
await showFutureLoadingDialog(
context: context,
future: () => 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());
}
2021-02-01 21:26:02 +01:00
void reportEventAction(BuildContext context) async {
final event = selectedEvents.single;
final score = await showConfirmationDialog<int>(
context: context,
title: L10n.of(context).howOffensiveIsThisContent,
2021-02-24 12:17:23 +01:00
useRootNavigator: false,
2021-02-01 21:26:02 +01:00
actions: [
AlertDialogAction(
2021-02-01 22:00:41 +01:00
key: -100,
2021-02-01 21:26:02 +01:00
label: L10n.of(context).extremeOffensive,
),
AlertDialogAction(
2021-02-01 22:00:41 +01:00
key: -50,
2021-02-01 21:26:02 +01:00
label: L10n.of(context).offensive,
),
AlertDialogAction(
key: 0,
label: L10n.of(context).inoffensive,
),
]);
if (score == null) return;
final reason = await showTextInputDialog(
context: context,
title: L10n.of(context).whyDoYouWantToReportThis,
2021-02-23 11:03:54 +01:00
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
2021-02-24 12:17:23 +01:00
useRootNavigator: false,
2021-02-01 21:26:02 +01:00
textFields: [DialogTextField(hintText: L10n.of(context).reason)]);
if (reason == null || reason.single.isEmpty) return;
final result = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.reportEvent(
event.roomId,
event.eventId,
reason.single,
score,
),
);
if (result.error != null) return;
setState(() => selectedEvents.clear());
await FlushbarHelper.createSuccess(
message: L10n.of(context).contentHasBeenReported)
.show(context);
}
2020-02-09 15:15:29 +01:00
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,
2021-02-18 14:23:22 +01:00
cancelLabel: L10n.of(context).cancel,
2021-02-24 12:17:23 +01:00
useRootNavigator: false,
2020-11-14 10:08:13 +01:00
) ==
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-12-25 09:58:34 +01:00
await showFutureLoadingDialog(
context: context,
future: () => event.status > 0 ? event.redact() : event.remove());
2020-02-09 15:15:29 +01:00
}
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());
2021-01-16 12:46:38 +01:00
AdaptivePageLayout.of(context).popUntilIsFirst();
2020-02-09 15:15:29 +01:00
}
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 {
2020-12-23 11:23:19 +01:00
var eventIndex = filteredEvents.indexWhere((e) => e.eventId == eventId);
2020-09-19 19:21:33 +02:00
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;
}
2020-12-23 11:23:19 +01:00
eventIndex = filteredEvents.indexWhere((e) => e.eventId == eventId);
2020-09-19 19:21:33 +02:00
}
2020-11-14 10:08:13 +01:00
});
if (context != null) {
2020-12-25 09:58:34 +01:00
await showFutureLoadingDialog(context: context, future: () => task);
2020-11-14 10:08:13 +01:00
} 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();
}
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());
});
2021-02-24 12:17:23 +01:00
Navigator.of(context, rootNavigator: false).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);
}
2020-12-27 19:24:47 +01:00
void _sendEmojiAction(BuildContext context, String emoji) async {
await showFutureLoadingDialog(
2020-12-25 09:58:34 +01:00
context: context,
future: () => room.sendReaction(
2020-12-27 19:24:47 +01:00
selectedEvents.single.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
);
}
2021-02-07 17:18:38 +01:00
matrix.client.activeRoomId = widget.id;
2020-01-05 12:27:03 +01:00
if (room.membership == Membership.invite) {
2020-12-25 09:58:34 +01:00
showFutureLoadingDialog(context: context, future: () => room.join());
2020-01-05 12:27:03 +01:00
}
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()),
2021-02-20 07:40:42 +01:00
tooltip: L10n.of(context).close,
2020-02-09 15:15:29 +01:00
)
: null,
2021-01-16 14:24:52 +01:00
titleSpacing:
AdaptivePageLayout.of(context).columnMode(context) ? null : 0,
2020-02-09 15:15:29 +01:00
title: selectedEvents.isEmpty
2021-01-16 21:44:34 +01:00
? StreamBuilder(
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,
2021-01-19 15:48:50 +01:00
builder: (c) => UserBottomSheet(
2020-12-17 19:14:33 +01:00
user: room.getUserByMXIDSync(
room.directChatMatrixID),
onMention: () => sendController.text +=
'${room.directChatMatrixID} ',
2020-12-17 19:14:33 +01:00
),
)
2021-01-16 12:46:38 +01:00
: () => AdaptivePageLayout.of(context)
2021-01-16 14:24:52 +01:00
.viewDataStack
.length <
3
? AdaptivePageLayout.of(context)
.pushNamed('/rooms/${room.id}/details')
: null,
2020-12-17 19:14:33 +01:00
title: Text(
room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context))),
maxLines: 1),
2021-01-16 21:44:34 +01:00
subtitle: room.getLocalizedTypingText(context).isEmpty
2020-12-17 19:14:33 +01:00
? 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,
2021-02-03 15:47:51 +01:00
color: Theme.of(context).accentColor,
2020-12-17 19:14:33 +01:00
size: 13),
SizedBox(width: 4),
Text(
2021-01-16 21:44:34 +01:00
room.getLocalizedTypingText(context),
2020-12-17 19:14:33 +01:00
maxLines: 1,
style: TextStyle(
2021-02-03 15:47:51 +01:00
color: Theme.of(context).accentColor,
2020-12-17 19:14:33 +01:00
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),
2021-02-20 07:40:42 +01:00
tooltip: L10n.of(context).edit,
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();
},
),
2021-02-01 21:26:02 +01:00
PopupMenuButton(
onSelected: (selected) {
switch (selected) {
case 'copy':
copyEventsAction(context);
break;
case 'redact':
redactEventsAction(context);
break;
case 'report':
reportEventAction(context);
break;
}
},
itemBuilder: (_) => [
PopupMenuItem(
child: Text(L10n.of(context).copy),
value: 'copy',
),
if (canRedactSelectedEvents)
PopupMenuItem(
child: Text(
L10n.of(context).redactMessage,
style: TextStyle(color: Colors.orange),
),
value: 'redact',
),
if (selectedEvents.length == 1)
PopupMenuItem(
child: Text(
L10n.of(context).reportMessage,
style: TextStyle(color: Colors.red),
),
value: 'report',
),
],
2020-02-09 15:15:29 +01:00
),
]
2021-01-16 16:39:07 +01:00
: <Widget>[
2021-02-27 10:41:58 +01:00
if (room.canSendDefaultMessages)
IconButton(
tooltip: L10n.of(context).videoCall,
icon: Icon(Icons.video_call_outlined),
onPressed: () => startCallAction(context),
),
2021-01-16 16:39:07 +01:00
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) {
2020-12-23 11:23:19 +01:00
room.sendReadMarker(
timeline.events.first.eventId,
readReceiptLocationEventId:
timeline.events.first.eventId,
);
2020-11-07 10:18:51 +01:00
}
// 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;
}
final horizontalPadding = max(
0,
(MediaQuery.of(context).size.width -
2021-01-16 17:32:56 +01:00
FluffyThemes.columnWidth *
2021-01-16 20:52:39 +01:00
(AdaptivePageLayout.of(context)
.currentViewData
.rightView !=
null
? 4.5
: 3.5)) /
2)
.toDouble();
return ListView.custom(
padding: EdgeInsets.only(
top: 16,
left: horizontalPadding,
right: horizontalPadding,
),
reverse: true,
controller: _scrollController,
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int i) {
2020-11-07 10:18:51 +01:00
return i == filteredEvents.length + 1
2021-01-16 16:11:50 +01:00
? timeline.isRequestingHistory
2020-11-07 10:18:51 +01:00
? Container(
height: 50,
alignment: Alignment.center,
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
)
: _canLoadMore
2021-02-27 07:53:34 +01:00
? TextButton(
2020-11-07 10:18:51 +01:00
child: Text(
L10n.of(context).loadMore,
style: TextStyle(
color: Theme.of(context)
.primaryColor,
fontWeight: FontWeight.bold,
decoration:
TextDecoration.underline,
),
),
onPressed: requestHistory,
)
: Container()
: i == 0
2021-02-13 10:34:02 +01:00
? StreamBuilder(
stream: room.onUpdate.stream,
builder: (_, __) {
final seenByText =
room.getLocalizedSeenByText(
context,
timeline,
filteredEvents,
_unfolded,
);
2021-02-13 10:34:02 +01:00
return 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),
),
child: Text(
seenByText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context)
.accentColor,
),
),
),
padding: EdgeInsets.only(
left: 8,
right: 8,
bottom: 8,
2020-07-04 10:22:51 +02:00
),
2021-02-13 10:34:02 +01: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,
2021-01-19 15:48:50 +01:00
builder: (c) =>
2020-11-07 10:18:51 +01:00
UserBottomSheet(
user: event.sender,
onMention: () =>
sendController.text +=
'${event.senderId} ',
2020-11-07 10:18:51 +01:00
),
2020-10-28 07:23:50 +01:00
),
unfold: _unfold,
2020-11-07 10:18:51 +01:00
onSelect: (Event event) {
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(
2021-02-20 07:40:42 +01:00
tooltip: L10n.of(context).close,
2020-11-07 10:18:51 +01:00
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,
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,
2021-02-27 07:53:34 +01:00
child: TextButton(
2020-11-07 10:18:51 +01:00
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,
2021-02-27 07:53:34 +01:00
child: TextButton(
2020-11-07 10:18:51 +01:00
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,
2021-02-27 07:53:34 +01:00
child: TextButton(
2020-11-07 10:18:51 +01:00
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,
2021-02-03 18:48:48 +01:00
enabledBorder: InputBorder.none,
2021-01-24 17:26:59 +01:00
filled: false,
2020-11-07 10:18:51 +01:00
),
onChanged: (String text) {
typingCoolDown?.cancel();
typingCoolDown =
Timer(Duration(seconds: 2), () {
typingCoolDown = null;
currentlyTyping = false;
room.sendTypingNotification(false);
2020-11-07 10:18:51 +01:00
});
typingTimeout ??=
Timer(Duration(seconds: 30), () {
typingTimeout = null;
currentlyTyping = false;
});
if (!currentlyTyping) {
currentlyTyping = true;
room.sendTypingNotification(true,
2020-11-07 10:18:51 +01:00
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(
2021-02-20 07:40:42 +01:00
tooltip: L10n.of(context).voiceMessage,
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(),
2021-02-20 07:40:42 +01:00
tooltip: L10n.of(context).send,
2020-11-07 10:18:51 +01:00
),
),
],
),
)
: 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,
),
),
],
);
}
}