fluffychat/lib/views/chat.dart
Sorunome bce709f521 Merge branch 'soru/better-read-indicator' into 'main'
fix: Respect hidden events when calculating read receipt message

See merge request ChristianPauly/fluffychat-flutter!310
2020-12-19 15:37:58 +00:00

1227 lines
52 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:emoji_picker/emoji_picker.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:fluffychat/components/adaptive_page_layout.dart';
import 'package:fluffychat/components/avatar.dart';
import 'package:fluffychat/components/chat_settings_popup_menu.dart';
import 'package:fluffychat/components/connection_status_header.dart';
import 'package:fluffychat/components/dialogs/recording_dialog.dart';
import 'package:fluffychat/components/dialogs/simple_dialogs.dart';
import 'package:fluffychat/components/encryption_button.dart';
import 'package:fluffychat/components/list_items/message.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:fluffychat/components/reply_content.dart';
import 'package:fluffychat/components/user_bottom_sheet.dart';
import 'package:fluffychat/config/app_emojis.dart';
import 'package:fluffychat/utils/app_route.dart';
import 'package:fluffychat/utils/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/room_status_extension.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:image_picker/image_picker.dart';
import 'package:pedantic/pedantic.dart';
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';
import '../components/dialogs/send_file_dialog.dart';
import '../components/input_bar.dart';
import '../app_config.dart';
import '../utils/matrix_file_extension.dart';
import 'chat_details.dart';
import 'chat_list.dart';
class ChatView extends StatelessWidget {
final String id;
final String scrollToEventId;
const ChatView(this.id, {Key key, this.scrollToEventId}) : super(key: key);
@override
Widget build(BuildContext context) {
// TODO: implement build
return AdaptivePageLayout(
primaryPage: FocusPage.SECOND,
firstScaffold: ChatList(
activeChat: id,
),
secondScaffold: _Chat(id, scrollToEventId: scrollToEventId),
);
}
}
class _Chat extends StatefulWidget {
final String id;
final String scrollToEventId;
const _Chat(this.id, {Key key, this.scrollToEventId}) : super(key: key);
@override
_ChatState createState() => _ChatState();
}
class _ChatState extends State<_Chat> {
Room room;
Timeline timeline;
MatrixState matrix;
String seenByText = '';
final AutoScrollController _scrollController = AutoScrollController();
FocusNode inputFocus = FocusNode();
Timer typingCoolDown;
Timer typingTimeout;
bool currentlyTyping = false;
List<Event> selectedEvents = [];
bool _collapseRoomCreate = true;
Event replyEvent;
Event editEvent;
bool showScrollDownButton = false;
bool get selectMode => selectedEvents.isNotEmpty;
bool _loadingHistory = false;
final int _loadHistoryCount = 100;
String inputText = '';
String pendingText = '';
bool get _canLoadMore => timeline.events.last.type != EventTypes.RoomCreate;
void requestHistory() async {
if (_canLoadMore) {
setState(() => _loadingHistory = true);
await SimpleDialogs(context).tryRequestWithErrorToast(
timeline.requestHistory(historyCount: _loadHistoryCount),
);
// 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() {
if (!mounted) {
return;
}
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);
}
}
@override
void initState() {
_scrollController.addListener(_updateScrollController);
super.initState();
}
void updateView() {
if (!mounted) return;
var seenByText = '';
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);
if (lastReceipts.length == 1) {
seenByText =
L10n.of(context).seenByUser(lastReceipts.first.calcDisplayname());
} else if (lastReceipts.length == 2) {
seenByText = seenByText = L10n.of(context).seenByUserAndUser(
lastReceipts.first.calcDisplayname(),
lastReceipts.last.calcDisplayname());
} else if (lastReceipts.length > 2) {
seenByText = L10n.of(context).seenByUserAndCountOthers(
lastReceipts.first.calcDisplayname(),
(lastReceipts.length - 1).toString());
}
}
if (timeline != null) {
setState(() {
this.seenByText = seenByText;
});
}
}
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) {
if (err is MatrixException && err.errcode == 'M_FORBIDDEN') {
// ignore if the user is not in the room (still joining)
return;
}
throw err;
}));
}
// 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 {
if (mounted) {
if (widget.scrollToEventId != null) {
_scrollToEventId(widget.scrollToEventId, context: context);
}
_updateScrollController();
}
});
}
updateView();
return true;
}
@override
void dispose() {
timeline?.cancelSubscriptions();
timeline = null;
matrix.activeRoomId = '';
super.dispose();
}
TextEditingController sendController = TextEditingController();
void send() {
if (sendController.text.isEmpty) return;
room.sendTextEvent(sendController.text,
inReplyTo: replyEvent, editEventId: editEvent?.eventId);
sendController.text = pendingText;
setState(() {
inputText = pendingText;
replyEvent = null;
editEvent = null;
pendingText = '';
});
}
void sendFileAction(BuildContext context) async {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.any);
if (result == null) return;
await showDialog(
context: context,
builder: (context) => SendFileDialog(
file: MatrixFile(
bytes: result.toUint8List(),
name: result.fileName,
).detectFileType,
room: room,
),
);
}
void sendImageAction(BuildContext context) async {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.image);
if (result == null) return;
await showDialog(
context: context,
builder: (context) => SendFileDialog(
file: MatrixImageFile(
bytes: result.toUint8List(),
name: result.fileName,
),
room: room,
),
);
}
void openCameraAction(BuildContext context) async {
var file = await ImagePicker().getImage(source: ImageSource.camera);
if (file == null) return;
final bytes = await file.readAsBytes();
await showDialog(
context: context,
builder: (context) => SendFileDialog(
file: MatrixImageFile(
bytes: bytes,
name: file.path,
),
room: room,
),
);
}
void voiceMessageAction(BuildContext context) async {
String result;
await showDialog(
context: context,
builder: (context) => RecordingDialog(
onFinished: (r) => result = r,
));
if (result == null) return;
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),
),
);
}
String _getSelectedEventString(BuildContext context) {
var copyString = '';
if (selectedEvents.length == 1) {
return selectedEvents.first
.getDisplayEvent(timeline)
.getLocalizedBody(MatrixLocals(L10n.of(context)));
}
for (var event in selectedEvents) {
if (copyString.isNotEmpty) copyString += '\n\n';
copyString += event.getDisplayEvent(timeline).getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: true);
}
return copyString;
}
void copyEventsAction(BuildContext context) {
Clipboard.setData(ClipboardData(text: _getSelectedEventString(context)));
setState(() => selectedEvents.clear());
}
void redactEventsAction(BuildContext context) async {
var confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).messageWillBeRemovedWarning,
okLabel: L10n.of(context).remove,
) ==
OkCancelResult.ok;
if (!confirmed) return;
for (var event in selectedEvents) {
await SimpleDialogs(context).tryRequestWithLoadingDialog(
event.status > 0 ? event.redact() : event.remove());
}
setState(() => selectedEvents.clear());
}
bool get canRedactSelectedEvents {
for (var event in selectedEvents) {
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 = {
'msgtype': 'm.text',
'body': _getSelectedEventString(context),
};
}
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();
}
setState(() => selectedEvents.clear());
}
void replyAction({Event replyTo}) {
setState(() {
replyEvent = replyTo ?? selectedEvents.first;
selectedEvents.clear();
});
inputFocus.requestFocus();
}
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
final task = Future.microtask(() async {
// 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);
}
});
if (context != null) {
await SimpleDialogs(context).tryRequestWithLoadingDialog(task);
} else {
await task;
}
}
if (!mounted) {
return;
}
await _scrollController.scrollToIndex(eventIndex,
preferPosition: AutoScrollPosition.middle);
_updateScrollController();
}
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) &&
// 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))
.toList();
// Hide state events from the room creater right after the room created event
if (_collapseRoomCreate &&
filteredEvents[filteredEvents.length - 1].type ==
EventTypes.RoomCreate) {
while (filteredEvents.length >= 3 &&
filteredEvents[filteredEvents.length - 2].senderId ==
filteredEvents[filteredEvents.length - 1].senderId &&
![EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted]
.contains(filteredEvents[filteredEvents.length - 2].type)) {
filteredEvents.removeAt(filteredEvents.length - 2);
}
}
return filteredEvents;
}
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());
}
@override
Widget build(BuildContext context) {
matrix = Matrix.of(context);
var client = matrix.client;
room ??= client.getRoomById(widget.id);
if (room == null) {
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).oopsSomethingWentWrong),
),
body: Center(
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
),
);
}
matrix.activeRoomId = widget.id;
if (room.membership == Membership.invite) {
SimpleDialogs(context).tryRequestWithLoadingDialog(room.join());
}
final typingText = room.getLocalizedTypingText(context);
return Scaffold(
appBar: AppBar(
leading: selectMode
? IconButton(
icon: Icon(Icons.close),
onPressed: () => setState(() => selectedEvents.clear()),
)
: null,
titleSpacing: 0,
title: selectedEvents.isEmpty
? StreamBuilder<Object>(
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),
),
),
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,
),
),
],
),
))
: Text(L10n.of(context)
.numberSelected(selectedEvents.length.toString())),
actions: selectMode
? <Widget>[
if (selectedEvents.length == 1 &&
selectedEvents.first.status > 0 &&
selectedEvents.first.senderId == client.userID)
IconButton(
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();
},
),
IconButton(
icon: Icon(Icons.content_copy_outlined),
onPressed: () => copyEventsAction(context),
),
if (canRedactSelectedEvents)
IconButton(
icon: Icon(Icons.delete_outlined),
onPressed: () => redactEventsAction(context),
),
]
: <Widget>[ChatSettingsPopupMenu(room, !room.isDirectChat)],
),
floatingActionButton: showScrollDownButton
? Padding(
padding: const EdgeInsets.only(bottom: 56.0),
child: FloatingActionButton(
child: Icon(Icons.arrow_downward_outlined,
color: Theme.of(context).primaryColor),
onPressed: () => _scrollController.jumpTo(0),
foregroundColor: Theme.of(context).textTheme.bodyText2.color,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
mini: true,
),
)
: null,
body: Stack(
children: <Widget>[
if (Matrix.of(context).wallpaper != null)
Image.file(
Matrix.of(context).wallpaper,
height: double.infinity,
width: double.infinity,
fit: BoxFit.cover,
),
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);
}
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) {
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),
),
child: Text(
seenByText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context)
.primaryColor,
),
),
),
padding: EdgeInsets.only(
left: 8,
right: 8,
bottom: 8,
),
)
: AutoScrollTag(
key: ValueKey(
filteredEvents[i - 1].eventId),
index: i - 1,
controller: _scrollController,
child: Swipeable(
key: ValueKey(
filteredEvents[i - 1].eventId),
background: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12.0),
child: Center(
child: Icon(Icons.reply_outlined),
),
),
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}',
),
),
onSelect: (Event event) {
if (event.type ==
EventTypes.RoomCreate) {
return setState(() =>
_collapseRoomCreate =
false);
}
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),
);
}
},
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;
},
),
);
},
),
),
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),
),
),
),
);
}),
),
),
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)),
),
],
),
),
),
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>[
Icon(Icons
.keyboard_arrow_left_outlined),
Text(L10n.of(context).forward),
],
),
),
),
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),
],
),
),
)
: Container(
height: 56,
child: FlatButton(
onPressed: () =>
sendAgainAction(timeline),
child: Row(
children: <Widget>[
Text(L10n.of(context)
.tryToSendAgain),
SizedBox(width: 4),
Icon(Icons.send_outlined,
size: 16),
],
),
),
)
: Container(),
]
: <Widget>[
if (inputText.isEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: PopupMenuButton<String>(
icon: Icon(Icons.add_outlined),
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>>[
PopupMenuItem<String>(
value: 'file',
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
child: Icon(
Icons.attachment_outlined),
),
title: Text(
L10n.of(context).sendFile),
contentPadding: EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'image',
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
child:
Icon(Icons.image_outlined),
),
title: Text(
L10n.of(context).sendImage),
contentPadding: EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'camera',
child: ListTile(
leading: CircleAvatar(
backgroundColor:
Colors.purple,
foregroundColor: Colors.white,
child: Icon(Icons
.camera_alt_outlined),
),
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,
child: Icon(
Icons.mic_none_outlined),
),
title: Text(L10n.of(context)
.voiceMessage),
contentPadding:
EdgeInsets.all(0),
),
),
],
),
),
Container(
height: 56,
alignment: Alignment.center,
child: EncryptionButton(room),
),
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);
}
},
),
),
),
if (PlatformInfos.isMobile &&
inputText.isEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
icon: Icon(Icons.mic_none_outlined),
onPressed: () =>
voiceMessageAction(context),
),
),
if (!PlatformInfos.isMobile ||
inputText.isNotEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
icon: Icon(Icons.send_outlined),
onPressed: () => send(),
),
),
],
),
)
: Container(),
],
),
),
],
),
);
}
}
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,
),
),
],
);
}
}