refactor: Pages folder structure

This commit is contained in:
Krille Fear 2021-11-09 21:32:16 +01:00
commit 1abb7310f3
88 changed files with 188 additions and 250 deletions

795
lib/pages/chat/chat.dart Normal file
View file

@ -0,0 +1,795 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:flutter_app_badger/flutter_app_badger.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:image_picker/image_picker.dart';
import 'package:matrix/matrix.dart';
import 'package:record/record.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat_view.dart';
import 'package:fluffychat/pages/chat/recording_dialog.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../utils/account_bundles.dart';
import '../../utils/localized_exception_extension.dart';
import '../../utils/matrix_sdk_extensions.dart/filtered_timeline_extension.dart';
import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
import '../new_private_chat/send_file_dialog.dart';
import '../new_private_chat/send_location_dialog.dart';
import 'sticker_picker_dialog.dart';
class Chat extends StatefulWidget {
final Widget sideView;
const Chat({Key key, this.sideView}) : super(key: key);
@override
ChatController createState() => ChatController();
}
class ChatController extends State<Chat> {
Room room;
Client sendingClient;
Timeline timeline;
MatrixState matrix;
String get roomId => context.vRouter.pathParameters['roomid'];
final AutoScrollController scrollController = AutoScrollController();
FocusNode inputFocus = FocusNode();
Timer typingCoolDown;
Timer typingTimeout;
bool currentlyTyping = false;
List<Event> selectedEvents = [];
List<Event> filteredEvents;
final Set<String> unfolded = {};
Event replyEvent;
Event editEvent;
bool showScrollDownButton = false;
bool get selectMode => selectedEvents.isNotEmpty;
final int _loadHistoryCount = 100;
String inputText = '';
String pendingText = '';
bool get canLoadMore =>
timeline.events.isEmpty ||
timeline.events.last.type != EventTypes.RoomCreate;
bool showEmojiPicker = false;
void startCallAction() 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);
}
void requestHistory() async {
if (canLoadMore) {
try {
await timeline.requestHistory(historyCount: _loadHistoryCount);
} catch (err) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
(err as Object).toLocalizedString(context),
),
),
);
rethrow;
}
}
}
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;
setState(
() {
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
},
);
}
void unfold(String eventId) {
var i = filteredEvents.indexWhere((e) => e.eventId == eventId);
setState(() {
while (i < filteredEvents.length - 1 && filteredEvents[i].isState) {
unfolded.add(filteredEvents[i].eventId);
i++;
}
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
});
}
Future<bool> getTimeline() async {
if (timeline == null) {
timeline = await room.getTimeline(onUpdate: updateView);
if (timeline.events.isNotEmpty) {
// ignore: unawaited_futures
room.markUnread(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) {
final event = VRouter.of(context).queryParameters['event'];
if (event != null) {
scrollToEventId(event);
}
_updateScrollController();
}
});
}
filteredEvents = timeline.getFilteredEvents(unfolded: unfolded);
if (room.notificationCount != null &&
room.notificationCount > 0 &&
timeline != null &&
timeline.events.isNotEmpty &&
Matrix.of(context).webHasFocus) {
// ignore: unawaited_futures
timeline.setReadMarker();
if (PlatformInfos.isIOS) {
// Workaround for iOS not clearing notifications with fcm_shared_isolate
if (!room.client.rooms.any((r) =>
r.membership == Membership.invite ||
(r.notificationCount != null && r.notificationCount > 0))) {
// ignore: unawaited_futures
FlutterLocalNotificationsPlugin().cancelAll();
FlutterAppBadger.removeBadge();
}
}
}
return true;
}
@override
void dispose() {
timeline?.cancelSubscriptions();
timeline = null;
super.dispose();
}
TextEditingController sendController = TextEditingController();
void setSendingClient(Client c) {
// first cancle typing with the old sending client
if (currentlyTyping) {
// no need to have the setting typing to false be blocking
typingCoolDown?.cancel();
typingCoolDown = null;
room.setTyping(false);
currentlyTyping = false;
}
// then set the new sending client
setState(() => sendingClient = c);
}
void setActiveClient(Client c) => setState(() {
Matrix.of(context).setActiveClient(c);
});
Future<void> send() async {
if (sendController.text.trim().isEmpty) return;
var parseCommands = true;
final commandMatch = RegExp(r'^\/(\w+)').firstMatch(sendController.text);
if (commandMatch != null &&
!room.client.commands.keys.contains(commandMatch[1].toLowerCase())) {
final l10n = L10n.of(context);
final dialogResult = await showOkCancelAlertDialog(
context: context,
useRootNavigator: false,
title: l10n.commandInvalid,
message: l10n.commandMissing(commandMatch[0]),
okLabel: l10n.sendAsText,
cancelLabel: l10n.cancel,
);
if (dialogResult == null || dialogResult == OkCancelResult.cancel) return;
parseCommands = false;
}
// ignore: unawaited_futures
room.sendTextEvent(sendController.text,
inReplyTo: replyEvent,
editEventId: editEvent?.eventId,
parseCommands: parseCommands);
sendController.value = TextEditingValue(
text: pendingText,
selection: const TextSelection.collapsed(offset: 0),
);
setState(() {
inputText = pendingText;
replyEvent = null;
editEvent = null;
pendingText = '';
});
}
void sendFileAction() async {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.any);
if (result == null) return;
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
file: MatrixFile(
bytes: result.toUint8List(),
name: result.fileName,
).detectFileType,
room: room,
),
);
}
void sendImageAction() async {
final result =
await FilePickerCross.importFromStorage(type: FileTypeCross.image);
if (result == null) return;
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
file: MatrixImageFile(
bytes: result.toUint8List(),
name: result.fileName,
),
room: room,
),
);
}
void openCameraAction() async {
// Make sure the textfield is unfocused before opening the camera
FocusScope.of(context).requestFocus(FocusNode());
final file = await ImagePicker().pickImage(source: ImageSource.camera);
if (file == null) return;
final bytes = await file.readAsBytes();
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
file: MatrixImageFile(
bytes: bytes,
name: file.path,
),
room: room,
),
);
}
void sendStickerAction() async {
final sticker = await showModalBottomSheet<ImagePackImageContent>(
context: context,
useRootNavigator: false,
builder: (c) => StickerPickerDialog(room: room),
);
if (sticker == null) return;
final eventContent = <String, dynamic>{
'body': sticker.body,
if (sticker.info != null) 'info': sticker.info,
'url': sticker.url.toString(),
};
// send the sticker
await showFutureLoadingDialog(
context: context,
future: () => room.sendEvent(
eventContent,
type: EventTypes.Sticker,
),
);
}
void voiceMessageAction() async {
if (await Record().hasPermission() == false) return;
final result = await showDialog<RecordingResult>(
context: context,
useRootNavigator: false,
builder: (c) => const RecordingDialog(),
);
if (result == null) return;
final audioFile = File(result.path);
final file = MatrixAudioFile(
bytes: audioFile.readAsBytesSync(),
name: audioFile.path,
);
await showFutureLoadingDialog(
context: context,
future: () =>
room.sendFileEvent(file, inReplyTo: replyEvent, extraContent: {
'info': {
...file.info,
'duration': result.duration,
},
'org.matrix.msc3245.voice': {},
}),
);
setState(() {
replyEvent = null;
});
}
void sendLocationAction() async {
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendLocationDialog(room: room),
);
}
String _getSelectedEventString() {
var copyString = '';
if (selectedEvents.length == 1) {
return selectedEvents.first
.getDisplayEvent(timeline)
.getLocalizedBody(MatrixLocals(L10n.of(context)));
}
for (final event in selectedEvents) {
if (copyString.isNotEmpty) copyString += '\n\n';
copyString += event.getDisplayEvent(timeline).getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: true);
}
return copyString;
}
void copyEventsAction() {
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
setState(() => selectedEvents.clear());
}
void reportEventAction() async {
final event = selectedEvents.single;
final score = await showConfirmationDialog<int>(
context: context,
title: L10n.of(context).reportMessage,
message: L10n.of(context).howOffensiveIsThisContent,
cancelLabel: L10n.of(context).cancel,
okLabel: L10n.of(context).ok,
actions: [
AlertDialogAction(
key: -100,
label: L10n.of(context).extremeOffensive,
),
AlertDialogAction(
key: -50,
label: L10n.of(context).offensive,
),
AlertDialogAction(
key: 0,
label: L10n.of(context).inoffensive,
),
]);
if (score == null) return;
final reason = await showTextInputDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).whyDoYouWantToReportThis,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
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.reportContent(
event.roomId,
event.eventId,
reason: reason.single,
score: score,
),
);
if (result.error != null) return;
setState(() => selectedEvents.clear());
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).contentHasBeenReported)));
}
void redactEventsAction() async {
final confirmed = await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).messageWillBeRemovedWarning,
okLabel: L10n.of(context).remove,
cancelLabel: L10n.of(context).cancel,
) ==
OkCancelResult.ok;
if (!confirmed) return;
for (final event in selectedEvents) {
await showFutureLoadingDialog(
context: context,
future: () async {
if (event.status.isSent) {
if (event.canRedact) {
await event.redactEvent();
} else {
final client = currentRoomBundle.firstWhere(
(cl) => selectedEvents.first.senderId == cl.userID,
orElse: () => null);
if (client == null) {
return;
}
final room = client.getRoomById(roomId);
await Event.fromJson(event.toJson(), room).redactEvent();
}
} else {
await event.remove();
}
});
}
setState(() => selectedEvents.clear());
}
List<Client> get currentRoomBundle {
final clients = matrix.currentBundle;
clients.removeWhere((c) => c.getRoomById(roomId) == null);
return clients;
}
bool get canRedactSelectedEvents {
final clients = matrix.currentBundle;
for (final event in selectedEvents) {
if (event.canRedact == false &&
!(clients.any((cl) => event.senderId == cl.userID))) return false;
}
return true;
}
bool get canEditSelectedEvents {
if (selectedEvents.length != 1 || !selectedEvents.first.status.isSent) {
return false;
}
return currentRoomBundle
.any((cl) => selectedEvents.first.senderId == cl.userID);
}
void forwardEventsAction() async {
if (selectedEvents.length == 1) {
Matrix.of(context).shareContent = selectedEvents.first.content;
} else {
Matrix.of(context).shareContent = {
'msgtype': 'm.text',
'body': _getSelectedEventString(),
};
}
setState(() => selectedEvents.clear());
VRouter.of(context).to('/rooms');
}
void sendAgainAction() {
final event = selectedEvents.first;
if (event.status.isError) {
event.sendAgain();
}
final allEditEvents = event
.aggregatedEvents(timeline, RelationshipTypes.edit)
.where((e) => e.status.isError);
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) async {
var eventIndex = filteredEvents.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
await showFutureLoadingDialog(
context: context,
future: () 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 =
filteredEvents.indexWhere((e) => e.eventId == eventId);
}
});
}
if (!mounted) {
return;
}
await scrollController.scrollToIndex(
eventIndex,
preferPosition: AutoScrollPosition.middle,
);
_updateScrollController();
}
void scrollDown() => scrollController.jumpTo(0);
void onEmojiSelected(_, emoji) {
setState(() => showEmojiPicker = false);
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(emoji.emoji);
}
Iterable<Event> _allReactionEvents;
void cancelEmojiPicker() => setState(() => showEmojiPicker = false);
void pickEmojiAction(Iterable<Event> allReactionEvents) async {
_allReactionEvents = allReactionEvents;
setState(() => showEmojiPicker = true);
}
void sendEmojiAction(String emoji) async {
await showFutureLoadingDialog(
context: context,
future: () => room.sendReaction(
selectedEvents.single.eventId,
emoji,
),
);
setState(() => selectedEvents.clear());
}
void clearSelectedEvents() => setState(() {
selectedEvents.clear();
showEmojiPicker = false;
});
void editSelectedEventAction() {
final client = currentRoomBundle.firstWhere(
(cl) => selectedEvents.first.senderId == cl.userID,
orElse: () => null);
if (client == null) {
return;
}
setSendingClient(client);
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();
}
void goToNewRoomAction() async {
if (OkCancelResult.ok !=
await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).goToTheNewRoom,
message: room
.getState(EventTypes.RoomTombstone)
.parsedTombstoneContent
.body,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
)) {
return;
}
final result = await showFutureLoadingDialog(
context: context,
future: () => room.client.joinRoom(room
.getState(EventTypes.RoomTombstone)
.parsedTombstoneContent
.replacementRoom),
);
await showFutureLoadingDialog(
context: context,
future: room.leave,
);
if (result.error == null) {
VRouter.of(context).toSegments(['rooms', result.result]);
}
}
void onSelectMessage(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),
);
}
}
int findChildIndexCallback(Key key, Map<String, int> thisEventsKeyMap) {
// 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;
}
void onInputBarSubmitted(_) {
send();
FocusScope.of(context).requestFocus(inputFocus);
}
void onAddPopupMenuButtonSelected(String choice) {
if (choice == 'file') {
sendFileAction();
}
if (choice == 'image') {
sendImageAction();
}
if (choice == 'camera') {
openCameraAction();
}
if (choice == 'sticker') {
sendStickerAction();
}
if (choice == 'voice') {
voiceMessageAction();
}
if (choice == 'location') {
sendLocationAction();
}
}
void onInputBarChanged(String text) {
if (text.endsWith(' ') && matrix.hasComplexBundles) {
final clients = currentRoomBundle;
for (final client in clients) {
final prefix = client.sendPrefix;
if ((prefix?.isNotEmpty ?? false) &&
text.toLowerCase() == '${prefix.toLowerCase()} ') {
setSendingClient(client);
setState(() {
inputText = '';
sendController.text = '';
});
return;
}
}
}
typingCoolDown?.cancel();
typingCoolDown = Timer(const Duration(seconds: 2), () {
typingCoolDown = null;
currentlyTyping = false;
room.setTyping(false);
});
typingTimeout ??= Timer(const Duration(seconds: 30), () {
typingTimeout = null;
currentlyTyping = false;
});
if (!currentlyTyping) {
currentlyTyping = true;
room.setTyping(true, timeout: const Duration(seconds: 30).inMilliseconds);
}
setState(() => inputText = text);
}
void cancelReplyEventAction() => setState(() {
if (editEvent != null) {
inputText = sendController.text = pendingText;
pendingText = '';
}
replyEvent = null;
editEvent = null;
});
@override
Widget build(BuildContext context) => ChatView(this);
}

View file

@ -0,0 +1,885 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:swipe_to_action/swipe_to_action.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/app_emojis.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/encryption_button.dart';
import 'package:fluffychat/pages/chat/input_bar.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/room_status_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/chat_settings_popup_menu.dart';
import 'package:fluffychat/widgets/connection_status_header.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/unread_badge_back_button.dart';
import '../../utils/stream_extension.dart';
import 'events/message.dart';
import 'events/reply_content.dart';
class ChatView extends StatelessWidget {
final ChatController controller;
const ChatView(this.controller, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
controller.matrix ??= Matrix.of(context);
final client = controller.matrix.client;
controller.sendingClient ??= client;
controller.room = controller.sendingClient.getRoomById(controller.roomId);
if (controller.room == null) {
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).oopsSomethingWentWrong),
),
body: Center(
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
),
);
}
if (controller.room.membership == Membership.invite) {
showFutureLoadingDialog(
context: context, future: () => controller.room.join());
}
return VWidgetGuard(
onSystemPop: (redirector) async {
if (controller.selectedEvents.isNotEmpty) {
controller.clearSelectedEvents();
redirector.stopRedirection();
}
},
child: StreamBuilder(
stream: controller.room.onUpdate.stream
.rateLimit(const Duration(milliseconds: 250)),
builder: (context, snapshot) => Scaffold(
appBar: AppBar(
actionsIconTheme: IconThemeData(
color: controller.selectedEvents.isEmpty
? null
: Theme.of(context).colorScheme.primary,
),
leading: controller.selectMode
? IconButton(
icon: const Icon(Icons.close),
onPressed: controller.clearSelectedEvents,
tooltip: L10n.of(context).close,
color: Theme.of(context).colorScheme.primary,
)
: UnreadBadgeBackButton(roomId: controller.roomId),
titleSpacing: 0,
title: controller.selectedEvents.isEmpty
? ListTile(
leading: Avatar(
controller.room.avatar, controller.room.displayname),
contentPadding: EdgeInsets.zero,
onTap: controller.room.isDirectChat
? () => showModalBottomSheet(
context: context,
builder: (c) => UserBottomSheet(
user: controller.room.getUserByMXIDSync(
controller.room.directChatMatrixID),
outerContext: context,
onMention: () => controller
.sendController.text +=
'${controller.room.getUserByMXIDSync(controller.room.directChatMatrixID).mention} ',
),
)
: () => VRouter.of(context).toSegments(
['rooms', controller.room.id, 'details']),
title: Text(
controller.room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context))),
maxLines: 1),
subtitle: controller.room
.getLocalizedTypingText(context)
.isEmpty
? StreamBuilder<Object>(
stream: Matrix.of(context)
.client
.onPresence
.stream
.where((p) =>
p.senderId ==
controller.room.directChatMatrixID)
.rateLimit(const Duration(seconds: 1)),
builder: (context, snapshot) => Text(
controller.room.getLocalizedStatus(context),
maxLines: 1,
//overflow: TextOverflow.ellipsis,
))
: Row(
children: <Widget>[
Icon(Icons.edit_outlined,
color:
Theme.of(context).colorScheme.secondary,
size: 13),
const SizedBox(width: 4),
Expanded(
child: Text(
controller.room
.getLocalizedTypingText(context),
maxLines: 1,
style: TextStyle(
color:
Theme.of(context).colorScheme.secondary,
fontStyle: FontStyle.italic,
),
),
),
],
),
)
: Text(controller.selectedEvents.length.toString()),
actions: controller.selectMode
? <Widget>[
if (controller.canEditSelectedEvents)
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: L10n.of(context).edit,
onPressed: controller.editSelectedEventAction,
),
IconButton(
icon: const Icon(Icons.copy_outlined),
tooltip: L10n.of(context).copy,
onPressed: controller.copyEventsAction,
),
if (controller.selectedEvents.length == 1)
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: L10n.of(context).reportMessage,
onPressed: controller.reportEventAction,
),
if (controller.canRedactSelectedEvents)
IconButton(
icon: const Icon(Icons.delete_outlined),
tooltip: L10n.of(context).redactMessage,
onPressed: controller.redactEventsAction,
),
]
: <Widget>[
if (controller.room.canSendDefaultStates)
IconButton(
tooltip: L10n.of(context).videoCall,
icon: const Icon(Icons.video_call_outlined),
onPressed: controller.startCallAction,
),
ChatSettingsPopupMenu(
controller.room, !controller.room.isDirectChat),
],
),
floatingActionButton: controller.showScrollDownButton
? Padding(
padding: const EdgeInsets.only(bottom: 56.0),
child: FloatingActionButton(
onPressed: controller.scrollDown,
foregroundColor:
Theme.of(context).textTheme.bodyText2.color,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
mini: true,
child: Icon(Icons.arrow_downward_outlined,
color: Theme.of(context).primaryColor),
),
)
: null,
body: Stack(
children: <Widget>[
if (Matrix.of(context).wallpaper != null)
Image.file(
Matrix.of(context).wallpaper,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
SafeArea(
child: Column(
children: <Widget>[
if (controller.room.getState(EventTypes.RoomTombstone) !=
null)
SizedBox(
height: 72,
child: Material(
color: Theme.of(context).secondaryHeaderColor,
elevation: 1,
child: ListTile(
leading: CircleAvatar(
foregroundColor:
Theme.of(context).colorScheme.secondary,
backgroundColor:
Theme.of(context).backgroundColor,
child: const Icon(Icons.upgrade_outlined),
),
title: Text(
controller.room
.getState(EventTypes.RoomTombstone)
.parsedTombstoneContent
.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(L10n.of(context).goToTheNewRoom),
onTap: controller.goToNewRoomAction,
),
),
),
Expanded(
child: FutureBuilder<bool>(
future: controller.getTimeline(),
builder: (BuildContext context, snapshot) {
if (controller.timeline == null) {
return const Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2),
);
}
// create a map of eventId --> index to greatly improve performance of
// ListView's findChildIndexCallback
final thisEventsKeyMap = <String, int>{};
for (var i = 0;
i < controller.filteredEvents.length;
i++) {
thisEventsKeyMap[
controller.filteredEvents[i].eventId] = i;
}
final seenByText =
controller.room.getLocalizedSeenByText(
context,
controller.timeline,
controller.filteredEvents,
controller.unfolded,
);
return ListView.custom(
padding: const EdgeInsets.only(
top: 16,
bottom: 4,
),
reverse: true,
controller: controller.scrollController,
keyboardDismissBehavior: PlatformInfos.isIOS
? ScrollViewKeyboardDismissBehavior.onDrag
: ScrollViewKeyboardDismissBehavior.manual,
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int i) {
return i == controller.filteredEvents.length + 1
? controller.timeline.isRequestingHistory
? const Center(
child: CircularProgressIndicator
.adaptive(strokeWidth: 2),
)
: controller.canLoadMore
? Center(
child: OutlinedButton(
style:
OutlinedButton.styleFrom(
backgroundColor: Theme.of(
context)
.scaffoldBackgroundColor,
),
onPressed:
controller.requestHistory,
child: Text(L10n.of(context)
.loadMore),
),
)
: Container()
: i == 0
? AnimatedContainer(
height: seenByText.isEmpty ? 0 : 24,
duration: seenByText.isEmpty
? const Duration(
milliseconds: 0)
: const Duration(
milliseconds: 300),
alignment: controller.filteredEvents
.isNotEmpty &&
controller.filteredEvents
.first.senderId ==
client.userID
? Alignment.topRight
: Alignment.topLeft,
padding: const EdgeInsets.only(
left: 8,
right: 8,
bottom: 8,
),
child: Container(
padding:
const 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)
.colorScheme
.secondary,
),
),
),
)
: AutoScrollTag(
key: ValueKey(controller
.filteredEvents[i - 1].eventId),
index: i - 1,
controller:
controller.scrollController,
child: Swipeable(
key: ValueKey(controller
.filteredEvents[i - 1]
.eventId),
background: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 12.0),
child: Center(
child: Icon(
Icons.reply_outlined),
),
),
direction:
SwipeDirection.endToStart,
onSwipe: (direction) =>
controller.replyAction(
replyTo: controller
.filteredEvents[
i - 1]),
child: Message(
controller
.filteredEvents[i - 1],
onAvatarTab: (Event event) =>
showModalBottomSheet(
context: context,
builder: (c) =>
UserBottomSheet(
user: event.sender,
outerContext: context,
onMention: () => controller
.sendController
.text +=
'${event.sender.mention} ',
),
),
unfold: controller.unfold,
onSelect: controller
.onSelectMessage,
scrollToEventId: (String eventId) =>
controller.scrollToEventId(
eventId),
longPressSelect: controller
.selectedEvents.isEmpty,
selected: controller
.selectedEvents
.contains(
controller.filteredEvents[
i - 1]),
timeline: controller.timeline,
nextEvent: i >= 2
? controller.filteredEvents[i - 2]
: null),
),
);
},
childCount: controller.filteredEvents.length + 2,
findChildIndexCallback: (key) =>
controller.findChildIndexCallback(
key, thisEventsKeyMap),
),
);
},
),
),
const ConnectionStatusHeader(),
if (!controller.showEmojiPicker)
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: (controller.editEvent == null &&
controller.replyEvent == null &&
controller.room.canSendDefaultMessages &&
controller.selectedEvents.length == 1)
? 56
: 0,
child: Material(
color: Theme.of(context).secondaryHeaderColor,
child: Builder(builder: (context) {
if (!(controller.editEvent == null &&
controller.replyEvent == null &&
controller.selectedEvents.length == 1)) {
return Container();
}
final emojis = List<String>.from(AppEmojis.emojis);
final allReactionEvents = controller
.selectedEvents.first
.aggregatedEvents(controller.timeline,
RelationshipTypes.reaction)
?.where((event) =>
event.senderId ==
event.room.client.userID &&
event.type == 'm.reaction');
for (final event in allReactionEvents) {
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),
onTap: () => controller
.pickEmojiAction(allReactionEvents),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: const Icon(Icons.add_outlined),
),
)
: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () =>
controller.sendEmojiAction(emojis[i]),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
emojis[i],
style: const TextStyle(fontSize: 30),
),
),
),
);
}),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: controller.editEvent != null ||
controller.replyEvent != null
? 56
: 0,
child: Material(
color: Theme.of(context).secondaryHeaderColor,
child: Row(
children: <Widget>[
IconButton(
tooltip: L10n.of(context).close,
icon: const Icon(Icons.close),
onPressed: controller.cancelReplyEventAction,
),
Expanded(
child: controller.replyEvent != null
? ReplyContent(controller.replyEvent,
timeline: controller.timeline)
: _EditContent(controller.editEvent
?.getDisplayEvent(controller.timeline)),
),
],
),
),
),
if (controller.showScrollDownButton)
const Divider(
height: 1,
),
if (controller.room.canSendDefaultMessages &&
controller.room.membership == Membership.join &&
!controller.showEmojiPicker)
Padding(
padding: EdgeInsets.all(
FluffyThemes.isColumnMode(context) ? 16.0 : 8.0),
child: Material(
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
elevation: 4,
color: Theme.of(context).scaffoldBackgroundColor,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: controller.selectMode
? <Widget>[
SizedBox(
height: 56,
child: TextButton(
onPressed:
controller.forwardEventsAction,
child: Row(
children: <Widget>[
const Icon(Icons
.keyboard_arrow_left_outlined),
Text(L10n.of(context).forward),
],
),
),
),
controller.selectedEvents.length == 1
? controller.selectedEvents.first
.getDisplayEvent(
controller.timeline)
.status
.isSent
? SizedBox(
height: 56,
child: TextButton(
onPressed:
controller.replyAction,
child: Row(
children: <Widget>[
Text(L10n.of(context)
.reply),
const Icon(Icons
.keyboard_arrow_right),
],
),
),
)
: SizedBox(
height: 56,
child: TextButton(
onPressed: controller
.sendAgainAction,
child: Row(
children: <Widget>[
Text(L10n.of(context)
.tryToSendAgain),
const SizedBox(width: 4),
const Icon(
Icons.send_outlined,
size: 16),
],
),
),
)
: Container(),
]
: <Widget>[
AnimatedContainer(
duration:
const Duration(milliseconds: 200),
height: 56,
width:
controller.inputText.isEmpty ? 56 : 0,
alignment: Alignment.center,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: PopupMenuButton<String>(
icon: const Icon(Icons.add_outlined),
onSelected: controller
.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'file',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
child: Icon(
Icons.attachment_outlined),
),
title: Text(
L10n.of(context).sendFile),
contentPadding:
const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'image',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
child:
Icon(Icons.image_outlined),
),
title: Text(
L10n.of(context).sendImage),
contentPadding:
const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'camera',
child: ListTile(
leading: const CircleAvatar(
backgroundColor:
Colors.purple,
foregroundColor: Colors.white,
child: Icon(Icons
.camera_alt_outlined),
),
title: Text(L10n.of(context)
.openCamera),
contentPadding:
const EdgeInsets.all(0),
),
),
if (controller.room
.getImagePacks(
ImagePackUsage.sticker)
.isNotEmpty)
PopupMenuItem<String>(
value: 'sticker',
child: ListTile(
leading: const CircleAvatar(
backgroundColor:
Colors.orange,
foregroundColor: Colors.white,
child: Icon(Icons
.emoji_emotions_outlined),
),
title: Text(L10n.of(context)
.sendSticker),
contentPadding:
const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'voice',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
child: Icon(
Icons.mic_none_outlined),
),
title: Text(L10n.of(context)
.voiceMessage),
contentPadding:
const EdgeInsets.all(0),
),
),
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'location',
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.brown,
foregroundColor: Colors.white,
child: Icon(
Icons.gps_fixed_outlined),
),
title: Text(L10n.of(context)
.shareLocation),
contentPadding:
const EdgeInsets.all(0),
),
),
],
),
),
Container(
height: 56,
alignment: Alignment.center,
child: EncryptionButton(controller.room),
),
if (controller.matrix.isMultiAccount &&
controller.matrix.hasComplexBundles &&
controller.matrix.currentBundle.length >
1)
Container(
height: 56,
alignment: Alignment.center,
child: _ChatAccountPicker(controller),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0),
child: InputBar(
room: controller.room,
minLines: 1,
maxLines: 8,
autofocus: !PlatformInfos.isMobile,
keyboardType: TextInputType.multiline,
textInputAction: AppConfig.sendOnEnter
? TextInputAction.send
: null,
onSubmitted:
controller.onInputBarSubmitted,
focusNode: controller.inputFocus,
controller: controller.sendController,
decoration: InputDecoration(
hintText:
L10n.of(context).writeAMessage,
hintMaxLines: 1,
border: InputBorder.none,
enabledBorder: InputBorder.none,
filled: false,
),
onChanged:
controller.onInputBarChanged,
),
),
),
if (PlatformInfos.isMobile &&
controller.inputText.isEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
tooltip:
L10n.of(context).voiceMessage,
icon: const Icon(
Icons.mic_none_outlined),
onPressed:
controller.voiceMessageAction,
),
),
if (!PlatformInfos.isMobile ||
controller.inputText.isNotEmpty)
Container(
height: 56,
alignment: Alignment.center,
child: IconButton(
icon: const Icon(Icons.send_outlined),
onPressed: controller.send,
tooltip: L10n.of(context).send,
),
),
],
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: controller.showEmojiPicker
? MediaQuery.of(context).size.height / 2
: 0,
child: controller.showEmojiPicker
? EmojiPicker(
onEmojiSelected: controller.onEmojiSelected,
onBackspacePressed: controller.cancelEmojiPicker,
)
: null,
),
],
),
),
],
),
),
),
);
}
}
class _EditContent extends StatelessWidget {
final Event event;
const _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,
),
),
],
);
}
}
class _ChatAccountPicker extends StatelessWidget {
final ChatController controller;
const _ChatAccountPicker(this.controller, {Key key}) : super(key: key);
void _popupMenuButtonSelected(String mxid) {
final client = controller.matrix.currentBundle
.firstWhere((cl) => cl.userID == mxid, orElse: () => null);
if (client == null) {
Logs().w('Attempted to switch to a non-existing client $mxid');
return;
}
controller.setSendingClient(client);
}
@override
Widget build(BuildContext context) {
controller.matrix ??= Matrix.of(context);
final clients = controller.currentRoomBundle;
return Padding(
padding: const EdgeInsets.all(8.0),
child: FutureBuilder<Profile>(
future: controller.sendingClient.ownProfile,
builder: (context, snapshot) => PopupMenuButton<String>(
onSelected: _popupMenuButtonSelected,
itemBuilder: (BuildContext context) => clients
.map((client) => PopupMenuItem<String>(
value: client.userID,
child: FutureBuilder<Profile>(
future: client.ownProfile,
builder: (context, snapshot) => ListTile(
leading: Avatar(
snapshot.data?.avatarUrl,
snapshot.data?.displayName ?? client.userID.localpart,
size: 20,
),
title:
Text(snapshot.data?.displayName ?? client.userID),
contentPadding: const EdgeInsets.all(0),
),
),
))
.toList(),
child: Avatar(
snapshot.data?.avatarUrl,
snapshot.data?.displayName ??
controller.matrix.client.userID.localpart,
size: 20,
),
),
),
);
}
}

View file

@ -0,0 +1,115 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import 'package:vrouter/vrouter.dart';
import '../../widgets/matrix.dart';
class EncryptionButton extends StatefulWidget {
final Room room;
const EncryptionButton(this.room, {Key key}) : super(key: key);
@override
_EncryptionButtonState createState() => _EncryptionButtonState();
}
class _EncryptionButtonState extends State<EncryptionButton> {
StreamSubscription _onSyncSub;
void _enableEncryptionAction() async {
if (widget.room.encrypted) {
VRouter.of(context).toSegments(['rooms', widget.room.id, 'encryption']);
return;
}
if (widget.room.joinRules == JoinRules.public) {
await showOkAlertDialog(
useRootNavigator: false,
context: context,
okLabel: L10n.of(context).ok,
message: L10n.of(context).noEncryptionForPublicRooms,
);
return;
}
if (await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).enableEncryption,
message: widget.room.client.encryptionEnabled
? L10n.of(context).enableEncryptionWarning
: L10n.of(context).needPantalaimonWarning,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).cancel,
) ==
OkCancelResult.ok) {
await showFutureLoadingDialog(
context: context,
future: () => widget.room.enableEncryption(),
);
// we want to enable the lock icon
setState(() => null);
}
}
@override
void dispose() {
_onSyncSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.room.encrypted) {
_onSyncSub ??= Matrix.of(context)
.client
.onSync
.stream
.where((s) => s.deviceLists != null)
.listen((s) => setState(() => null));
}
return FutureBuilder<List<User>>(
future:
widget.room.encrypted ? widget.room.requestParticipants() : null,
builder: (BuildContext context, snapshot) {
Color color;
if (widget.room.encrypted && snapshot.hasData) {
final users = snapshot.data;
users.removeWhere((u) =>
!{Membership.invite, Membership.join}.contains(u.membership) ||
!widget.room.client.userDeviceKeys.containsKey(u.id));
var allUsersValid = true;
var oneUserInvalid = false;
for (final u in users) {
final status = widget.room.client.userDeviceKeys[u.id].verified;
if (status != UserVerifiedStatus.verified) {
allUsersValid = false;
}
if (status == UserVerifiedStatus.unknownDevice) {
oneUserInvalid = true;
}
}
color = oneUserInvalid
? Colors.red
: (allUsersValid ? Colors.green : Colors.orange);
} else if (!widget.room.encrypted &&
widget.room.joinRules != JoinRules.public) {
color = Colors.red;
}
return IconButton(
tooltip: widget.room.encrypted
? L10n.of(context).encrypted
: L10n.of(context).encryptionNotEnabled,
icon: Icon(
widget.room.encrypted
? Icons.lock_outlined
: Icons.lock_open_outlined,
size: 20,
color: color),
onPressed: _enableEncryptionAction,
);
});
}
}

View file

@ -0,0 +1,197 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:fluffychat/utils/sentry_controller.dart';
import '../../../utils/matrix_sdk_extensions.dart/event_extension.dart';
class AudioPlayerWidget extends StatefulWidget {
final Color color;
final Event event;
static String currentId;
const AudioPlayerWidget(this.event, {this.color = Colors.black, Key key})
: super(key: key);
@override
_AudioPlayerState createState() => _AudioPlayerState();
}
enum AudioPlayerStatus { notDownloaded, downloading, downloaded }
class _AudioPlayerState extends State<AudioPlayerWidget> {
AudioPlayerStatus status = AudioPlayerStatus.notDownloaded;
final AudioPlayer audioPlayer = AudioPlayer();
StreamSubscription onAudioPositionChanged;
StreamSubscription onDurationChanged;
StreamSubscription onPlayerStateChanged;
StreamSubscription onPlayerError;
String statusText;
double currentPosition = 0;
double maxPosition = 0;
File audioFile;
@override
void dispose() {
if (audioPlayer.state == PlayerState.PLAYING) {
audioPlayer.stop();
}
onAudioPositionChanged?.cancel();
onDurationChanged?.cancel();
onPlayerStateChanged?.cancel();
onPlayerError?.cancel();
super.dispose();
}
Future<void> _downloadAction() async {
if (status != AudioPlayerStatus.notDownloaded) return;
setState(() => status = AudioPlayerStatus.downloading);
try {
final matrixFile =
await widget.event.downloadAndDecryptAttachmentCached();
final tempDir = await getTemporaryDirectory();
final fileName =
widget.event.content.tryGet<String>('filename') ?? matrixFile.name;
final file = File('${tempDir.path}/$fileName');
await file.writeAsBytes(matrixFile.bytes);
setState(() {
audioFile = file;
status = AudioPlayerStatus.downloaded;
});
_playAction();
} catch (e, s) {
Logs().v('Could not download audio file', e, s);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
),
);
}
}
void _playAction() async {
if (AudioPlayerWidget.currentId != widget.event.eventId) {
if (AudioPlayerWidget.currentId != null) {
if (audioPlayer.state != PlayerState.STOPPED) {
await audioPlayer.stop();
setState(() => null);
}
}
AudioPlayerWidget.currentId = widget.event.eventId;
}
switch (audioPlayer.state) {
case PlayerState.PLAYING:
await audioPlayer.pause();
break;
case PlayerState.PAUSED:
await audioPlayer.resume();
break;
case PlayerState.STOPPED:
default:
onAudioPositionChanged ??=
audioPlayer.onAudioPositionChanged.listen((state) {
setState(() {
statusText =
'${state.inMinutes.toString().padLeft(2, '0')}:${(state.inSeconds % 60).toString().padLeft(2, '0')}';
currentPosition = state.inMilliseconds.toDouble();
});
});
onDurationChanged ??= audioPlayer.onDurationChanged.listen((max) =>
setState(() => maxPosition = max.inMilliseconds.toDouble()));
onPlayerStateChanged ??= audioPlayer.onPlayerStateChanged
.listen((_) => setState(() => null));
onPlayerError ??= audioPlayer.onPlayerError.listen((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).oopsSomethingWentWrong),
),
);
SentryController.captureException(e, StackTrace.current);
});
await audioPlayer.play(audioFile.path);
break;
}
}
static const double buttonSize = 36;
String get _durationString {
final durationInt = widget.event.content
.tryGetMap<String, dynamic>('info')
?.tryGet<int>('duration');
if (durationInt == null) return null;
final duration = Duration(milliseconds: durationInt);
return '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
statusText ??= _durationString ?? '00:00';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SizedBox(
width: buttonSize,
height: buttonSize,
child: status == AudioPlayerStatus.downloading
? CircularProgressIndicator(strokeWidth: 2, color: widget.color)
: InkWell(
borderRadius: BorderRadius.circular(64),
child: Material(
color: widget.color.withAlpha(64),
borderRadius: BorderRadius.circular(64),
child: Icon(
audioPlayer.state == PlayerState.PLAYING
? Icons.pause_outlined
: Icons.play_arrow_outlined,
color: widget.color,
),
),
onLongPress: () => widget.event.saveFile(context),
onTap: () {
if (status == AudioPlayerStatus.downloaded) {
_playAction();
} else {
_downloadAction();
}
},
),
),
Expanded(
child: Slider(
activeColor: Theme.of(context).colorScheme.secondaryVariant,
inactiveColor: widget.color.withAlpha(64),
value: currentPosition,
onChanged: (double position) =>
audioPlayer.seek(Duration(milliseconds: position.toInt())),
max: status == AudioPlayerStatus.downloaded ? maxPosition : 0,
min: 0,
),
),
Text(
statusText,
style: TextStyle(
color: widget.color,
),
),
],
),
);
}
}

View file

@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_matrix_html/flutter_html.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../../config/app_config.dart';
import '../../../config/setting_keys.dart';
import '../../../pages/image_viewer/image_viewer.dart';
import '../../../utils/matrix_sdk_extensions.dart/matrix_locals.dart';
import '../../../utils/url_launcher.dart';
class HtmlMessage extends StatelessWidget {
final String html;
final int maxLines;
final Room room;
final TextStyle defaultTextStyle;
final TextStyle linkStyle;
final double emoteSize;
const HtmlMessage({
Key key,
this.html,
this.maxLines,
this.room,
this.defaultTextStyle,
this.linkStyle,
this.emoteSize,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// riot-web is notorious for creating bad reply fallback events from invalid messages which, if
// not handled properly, can lead to impersination. As such, we strip the entire `<mx-reply>` tags
// here already, to prevent that from happening.
// We do *not* do this in an AST and just with simple regex here, as riot-web tends to create
// miss-matching tags, and this way we actually correctly identify what we want to strip and, well,
// strip it.
final renderHtml = html.replaceAll(
RegExp('<mx-reply>.*</mx-reply>',
caseSensitive: false, multiLine: false, dotAll: true),
'');
// there is no need to pre-validate the html, as we validate it while rendering
final matrix = Matrix.of(context);
final themeData = Theme.of(context);
return Html(
data: renderHtml,
defaultTextStyle: defaultTextStyle,
emoteSize: emoteSize,
linkStyle: linkStyle ??
themeData.textTheme.bodyText2.copyWith(
color: themeData.colorScheme.secondary,
decoration: TextDecoration.underline,
),
shrinkToFit: true,
maxLines: maxLines,
onLinkTap: (url) => UrlLauncher(context, url).launchUrl(),
onPillTap: (url) => UrlLauncher(context, url).launchUrl(),
getMxcUrl: (String mxc, double width, double height,
{bool animated = false}) {
final ratio = MediaQuery.of(context).devicePixelRatio;
return Uri.parse(mxc)
?.getThumbnail(
matrix.client,
width: (width ?? 800) * ratio,
height: (height ?? 800) * ratio,
method: ThumbnailMethod.scale,
animated: AppConfig.autoplayImages ? animated : false,
)
.toString();
},
onImageTap: (String mxc) => showDialog(
context: Matrix.of(context).navigatorContext,
useRootNavigator: false,
builder: (_) => ImageViewer(Event.fromJson({
'type': EventTypes.Message,
'content': <String, dynamic>{
'body': mxc,
'url': mxc,
'msgtype': MessageTypes.Image,
},
'event_id': 'fake_event',
}, room))),
setCodeLanguage: (String key, String value) async {
await matrix.store.setItem('${SettingKeys.codeLanguage}.$key', value);
},
getCodeLanguage: (String key) async {
return await matrix.store.getItem('${SettingKeys.codeLanguage}.$key');
},
getPillInfo: (String url) async {
if (room == null) {
return null;
}
final identityParts = url.parseIdentifierIntoParts();
final identifier = identityParts?.primaryIdentifier;
if (identifier == null) {
return null;
}
if (identifier.sigil == '@') {
// we have a user pill
final user = room.getState('m.room.member', identifier);
if (user != null) {
return user.content;
}
// there might still be a profile...
final profile = await room.client.getProfileFromUserId(identifier);
if (profile != null) {
return {
'displayname': profile.displayName,
'avatar_url': profile.avatarUrl.toString(),
};
}
return null;
}
if (identifier.sigil == '#') {
// we have an alias pill
for (final r in room.client.rooms) {
final state = r.getState('m.room.canonical_alias');
if (state != null &&
((state.content['alias'] is String &&
state.content['alias'] == identifier) ||
(state.content['alt_aliases'] is List &&
state.content['alt_aliases'].contains(identifier)))) {
// we have a room!
return {
'displayname':
r.getLocalizedDisplayname(MatrixLocals(L10n.of(context))),
'avatar_url': r.getState('m.room.avatar')?.content['url'],
};
}
}
return null;
}
if (identifier.sigil == '!') {
// we have a room ID pill
final r = room.client.getRoomById(identifier);
if (r == null) {
return null;
}
return {
'displayname':
r.getLocalizedDisplayname(MatrixLocals(L10n.of(context))),
'avatar_url': r.getState('m.room.avatar')?.content['url'],
};
}
return null;
},
);
}
}

View file

@ -0,0 +1,464 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:lottie/lottie.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/image_viewer/image_viewer.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../../utils/matrix_sdk_extensions.dart/event_extension.dart';
class ImageBubble extends StatefulWidget {
final Event event;
final bool tapToView;
final BoxFit fit;
final bool maxSize;
final Color backgroundColor;
final double radius;
final bool thumbnailOnly;
final bool animated;
final double width;
final double height;
final void Function() onLoaded;
final void Function() onTap;
const ImageBubble(
this.event, {
this.tapToView = true,
this.maxSize = true,
this.backgroundColor,
this.fit = BoxFit.cover,
this.radius = 10.0,
this.thumbnailOnly = true,
this.onLoaded,
this.width = 400,
this.height = 300,
this.animated = false,
this.onTap,
Key key,
}) : super(key: key);
@override
_ImageBubbleState createState() => _ImageBubbleState();
}
class _ImageBubbleState extends State<ImageBubble> {
// for plaintext: holds the http URL for the thumbnail
String thumbnailUrl;
// for plaintext: holds the http URL of the original
String attachmentUrl;
MatrixFile _file;
MatrixFile _thumbnail;
bool _requestedThumbnailOnFailure = false;
// In case we have animated = false, this will hold the first frame so that we make
// sure that things are never animated
Widget _firstFrame;
// the mimetypes that we know how to render, from the flutter Image widget
final _knownMimetypes = <String>{
'image/jpg',
'image/jpeg',
'image/png',
'image/apng',
'image/webp',
'image/gif',
'image/bmp',
'image/x-bmp',
};
// overrides for certain mimetypes if they need different images to render
// memory are for in-memory renderers (e2ee rooms), network for network url renderers.
// The map values themself are set in initState() as they need to be able to access
// `this`.
final _contentRenderers = <String, _ImageBubbleContentRenderer>{};
String getMimetype([bool thumbnail = false]) => thumbnail
? widget.event.thumbnailMimetype.toLowerCase()
: widget.event.attachmentMimetype.toLowerCase();
MatrixFile get _displayFile => _file ?? _thumbnail;
String get displayUrl => widget.thumbnailOnly ? thumbnailUrl : attachmentUrl;
dynamic _error;
Future<void> _requestFile({bool getThumbnail = false}) async {
try {
final res = await widget.event
.downloadAndDecryptAttachmentCached(getThumbnail: getThumbnail);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (getThumbnail) {
if (mounted) {
setState(() => _thumbnail = res);
}
} else {
if (widget.onLoaded != null) {
widget.onLoaded();
}
if (mounted) {
setState(() => _file = res);
}
}
});
} catch (err) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() => _error = err);
}
});
}
}
Widget frameBuilder(_, Widget child, int frame, __) {
// as servers might return animated gifs as thumbnails and we want them to *not* play
// animated, we'll have to store the first frame in a variable and display that instead
if (widget.animated) {
return child;
}
if (frame == 0) {
_firstFrame = child;
}
return _firstFrame ?? child;
}
@override
void initState() {
// add the custom renderers for other mimetypes
_contentRenderers['image/svg+xml'] = _ImageBubbleContentRenderer(
memory: (Uint8List bytes, String key) => SvgPicture.memory(
bytes,
key: ValueKey(key),
fit: widget.fit,
),
network: (String url) => SvgPicture.network(
url,
key: ValueKey(url),
placeholderBuilder: (context) => getPlaceholderWidget(),
fit: widget.fit,
),
);
_contentRenderers['image/lottie+json'] = _ImageBubbleContentRenderer(
memory: (Uint8List bytes, String key) => Lottie.memory(
bytes,
key: ValueKey(key),
fit: widget.fit,
errorBuilder: (context, error, stacktrace) =>
getErrorWidget(context, error),
animate: widget.animated,
),
network: (String url) => Lottie.network(
url,
key: ValueKey(url),
fit: widget.fit,
errorBuilder: (context, error, stacktrace) =>
getErrorWidget(context, error),
animate: widget.animated,
),
);
// add all the custom content renderer mimetypes to the known mimetypes set
for (final key in _contentRenderers.keys) {
_knownMimetypes.add(key);
}
thumbnailUrl = widget.event
.getAttachmentUrl(getThumbnail: true, animated: widget.animated)
?.toString();
attachmentUrl =
widget.event.getAttachmentUrl(animated: widget.animated)?.toString();
if (thumbnailUrl == null) {
_requestFile(getThumbnail: true);
}
if (!widget.thumbnailOnly && attachmentUrl == null) {
_requestFile();
} else {
// if the full attachment is cached, we might as well fetch it anyways.
// no need to stick with thumbnail only, since we don't do any networking
widget.event.isAttachmentCached().then((cached) {
if (cached) {
_requestFile();
}
});
}
super.initState();
}
Widget getErrorWidget(BuildContext context, [dynamic error]) {
final String filename = widget.event.content.containsKey('filename')
? widget.event.content['filename']
: widget.event.body;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).scaffoldBackgroundColor,
onPrimary: Theme.of(context).textTheme.bodyText1.color,
),
onPressed: () => widget.event.saveFile(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.download_outlined),
const SizedBox(width: 8),
Text(
filename,
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
],
),
),
if (widget.event.sizeString != null) Text(widget.event.sizeString),
const SizedBox(height: 8),
Text((error ?? _error).toString()),
],
),
);
}
Widget getPlaceholderWidget({Widget child}) {
Widget blurhash;
if (widget.event.infoMap['xyz.amorgan.blurhash'] is String) {
final ratio =
widget.event.infoMap['w'] is int && widget.event.infoMap['h'] is int
? widget.event.infoMap['w'] / widget.event.infoMap['h']
: 1.0;
var width = 32;
var height = 32;
if (ratio > 1.0) {
height = (width / ratio).round();
} else {
width = (height * ratio).round();
}
blurhash = BlurHash(
hash: widget.event.infoMap['xyz.amorgan.blurhash'],
decodingWidth: width,
decodingHeight: height,
imageFit: widget.fit,
);
}
return Stack(
children: <Widget>[
if (blurhash != null) blurhash,
Center(
child:
child ?? const CircularProgressIndicator.adaptive(strokeWidth: 2),
),
],
);
}
// Build a memory file (e2ee)
Widget getMemoryWidget() {
final isOriginal = _file != null ||
widget.event.attachmentOrThumbnailMxcUrl(getThumbnail: true) ==
widget.event.attachmentMxcUrl;
final key = isOriginal
? widget.event.attachmentMxcUrl.toString()
: widget.event.thumbnailMxcUrl.toString();
final mimetype = getMimetype(!isOriginal);
if (_contentRenderers.containsKey(mimetype)) {
return _contentRenderers[mimetype].memory(_displayFile.bytes, key);
} else {
return Image.memory(
_displayFile.bytes,
key: ValueKey(key),
fit: widget.fit,
errorBuilder: (context, error, stacktrace) {
if (widget.event.hasThumbnail && !_requestedThumbnailOnFailure) {
_requestedThumbnailOnFailure = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_file = null;
_requestFile(getThumbnail: true);
});
});
return getPlaceholderWidget();
}
return getErrorWidget(context, error);
},
frameBuilder: frameBuilder,
);
}
}
// build a network file (plaintext)
Widget getNetworkWidget() {
// For network files we try to utilize server-side thumbnailing as much as possible.
// Thus, we do the following logic:
// - try to display our URL
// - on failure: Attempt to display the in-event thumbnail instead
// - on failrue / non-existance: Display button to download or view in-app
final mimetype = getMimetype(_requestedThumbnailOnFailure);
if (displayUrl == attachmentUrl &&
_contentRenderers.containsKey(mimetype)) {
return _contentRenderers[mimetype].network(displayUrl);
} else {
return CachedNetworkImage(
// as we change the url on-error we need a key so that the widget actually updates
key: ValueKey(displayUrl),
imageUrl: displayUrl,
placeholder: (context, url) {
if (!widget.thumbnailOnly &&
displayUrl != thumbnailUrl &&
displayUrl == attachmentUrl) {
// we have to display the thumbnail while loading
return CachedNetworkImage(
key: ValueKey(thumbnailUrl),
imageUrl: thumbnailUrl,
placeholder: (c, u) => getPlaceholderWidget(),
imageBuilder: (context, imageProvider) => Image(
image: imageProvider,
frameBuilder: frameBuilder,
fit: widget.fit,
),
);
}
return getPlaceholderWidget();
},
imageBuilder: (context, imageProvider) => Image(
image: imageProvider,
frameBuilder: frameBuilder,
fit: widget.fit,
),
errorWidget: (context, url, error) {
if (widget.event.hasThumbnail && !_requestedThumbnailOnFailure) {
// the image failed to load but the event has a thumbnail attached....so we can
// try to load this one!
_requestedThumbnailOnFailure = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
thumbnailUrl = widget.event
.getAttachmentUrl(
getThumbnail: true,
useThumbnailMxcUrl: true,
animated: widget.animated)
?.toString();
attachmentUrl = widget.event
.getAttachmentUrl(
useThumbnailMxcUrl: true, animated: widget.animated)
?.toString();
});
});
return getPlaceholderWidget();
} else if (widget.thumbnailOnly &&
displayUrl != attachmentUrl &&
_knownMimetypes.contains(mimetype)) {
// Okay, the thumbnail failed to load, but we do know how to render the image
// ourselves. Let's offer the user a button to view it.
return getPlaceholderWidget(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).scaffoldBackgroundColor,
onPrimary: Theme.of(context).textTheme.bodyText1.color,
),
onPressed: () => onTap(context),
child: Text(
L10n.of(context).tapToShowImage,
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
),
if (widget.event.sizeString != null)
Text(widget.event.sizeString),
],
));
}
return getErrorWidget(context, error);
},
);
}
}
@override
Widget build(BuildContext context) {
Widget content;
String key;
if (_error != null) {
content = getErrorWidget(context);
key = 'error';
} else if (_displayFile != null) {
content = getMemoryWidget();
key = 'memory-' + (content.key as ValueKey).value;
} else if (displayUrl != null) {
content = getNetworkWidget();
key = 'network-' + (content.key as ValueKey).value;
} else {
content = getPlaceholderWidget();
key = 'placeholder';
}
if (widget.maxSize) {
content = AspectRatio(
aspectRatio: widget.width / widget.height,
child: content,
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(widget.radius),
child: InkWell(
onTap: () => onTap(context),
child: Hero(
tag: widget.event.eventId,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 1000),
child: Container(
key: ValueKey(key),
constraints: widget.maxSize
? BoxConstraints.loose(Size(
widget.width,
widget.height,
))
: null,
child: content,
),
),
),
),
);
}
void onTap(BuildContext context) {
if (widget.onTap != null) {
widget.onTap();
return;
}
if (!widget.tapToView) return;
showDialog(
context: Matrix.of(context).navigatorContext,
useRootNavigator: false,
builder: (_) => ImageViewer(widget.event, onLoaded: () {
// If the original file didn't load yet, we want to do that now.
// This is so that the original file displays after going on the image viewer,
// waiting for it to load, and then hitting back. This ensures that we always
// display the best image available, with requiring as little network as possible
if (_file == null) {
widget.event.isAttachmentCached().then((cached) {
if (cached) {
_requestFile();
}
});
}
}),
);
}
}
class _ImageBubbleContentRenderer {
final Widget Function(Uint8List, String) memory;
final Widget Function(String) network;
_ImageBubbleContentRenderer({this.memory, this.network});
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
class MapBubble extends StatelessWidget {
final double latitude;
final double longitude;
final double zoom;
final double width;
final double height;
final double radius;
const MapBubble({
this.latitude,
this.longitude,
this.zoom = 14.0,
this.width = 400,
this.height = 400,
this.radius = 10.0,
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Container(
constraints: BoxConstraints.loose(Size(width, height)),
child: AspectRatio(
aspectRatio: width / height,
child: FlutterMap(
options: MapOptions(
center: LatLng(latitude, longitude),
zoom: zoom,
),
layers: [
TileLayerOptions(
urlTemplate:
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
subdomains: ['a', 'b', 'c'],
),
MarkerLayerOptions(
markers: [
Marker(
point: LatLng(latitude, longitude),
builder: (context) => const Icon(
Icons.location_pin,
color: Colors.red,
),
),
],
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,301 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart';
import 'package:fluffychat/utils/string_color.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../../config/app_config.dart';
import 'message_content.dart';
import 'message_reactions.dart';
import 'reply_content.dart';
import 'state_message.dart';
import 'verification_request_content.dart';
class Message extends StatelessWidget {
final Event event;
final Event nextEvent;
final void Function(Event) onSelect;
final void Function(Event) onAvatarTab;
final void Function(String) scrollToEventId;
final void Function(String) unfold;
final bool longPressSelect;
final bool selected;
final Timeline timeline;
const Message(this.event,
{this.nextEvent,
this.longPressSelect,
this.onSelect,
this.onAvatarTab,
this.scrollToEventId,
@required this.unfold,
this.selected,
this.timeline,
Key key})
: super(key: key);
/// Indicates wheither the user may use a mouse instead
/// of touchscreen.
static bool useMouse = false;
@override
Widget build(BuildContext context) {
if (![EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted]
.contains(event.type)) {
return StateMessage(event, unfold: unfold);
}
if (event.type == EventTypes.Message &&
event.messageType == EventTypes.KeyVerificationRequest) {
return VerificationRequestContent(event: event, timeline: timeline);
}
final client = Matrix.of(context).client;
final ownMessage = event.senderId == client.userID;
final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft;
var color = Theme.of(context).secondaryHeaderColor;
final sameSender = nextEvent != null &&
[EventTypes.Message, EventTypes.Sticker].contains(nextEvent.type)
? nextEvent.sender.id == event.sender.id
: false;
var textColor = ownMessage
? Colors.white
: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black;
final rowMainAxisAlignment =
ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start;
final displayEvent = event.getDisplayEvent(timeline);
if (event.showThumbnail) {
color = Colors.transparent;
textColor = Theme.of(context).textTheme.bodyText2.color;
} else if (ownMessage) {
color = displayEvent.status.isError
? Colors.redAccent
: Theme.of(context).primaryColor;
}
final rowChildren = <Widget>[
Expanded(
child: Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Material(
color: color,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: InkWell(
onHover: (b) => useMouse = true,
onTap: !useMouse && longPressSelect
? () => null
: () => onSelect(event),
onLongPress: !longPressSelect ? null : () => onSelect(event),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 1.5),
child: Stack(
children: <Widget>[
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (event.relationshipType == RelationshipTypes.reply)
FutureBuilder<Event>(
future: event.getReplyEvent(timeline),
builder: (BuildContext context, snapshot) {
final replyEvent = snapshot.hasData
? snapshot.data
: Event(
eventId: event.relationshipEventId,
content: {
'msgtype': 'm.text',
'body': '...'
},
senderId: event.senderId,
type: 'm.room.message',
room: event.room,
status: EventStatus.sent,
originServerTs: DateTime.now(),
);
return InkWell(
onTap: () {
if (scrollToEventId != null) {
scrollToEventId(replyEvent.eventId);
}
},
child: AbsorbPointer(
child: Container(
margin: const EdgeInsets.symmetric(
vertical: 4.0),
child: ReplyContent(replyEvent,
lightText: ownMessage,
timeline: timeline),
),
),
);
},
),
MessageContent(
displayEvent,
textColor: textColor,
),
const SizedBox(height: 3),
Opacity(
opacity: 0,
child: _MetaRow(
event, // meta information should be from the unedited event
ownMessage,
textColor,
timeline,
displayEvent,
),
),
],
),
Positioned(
bottom: 0,
right: ownMessage ? 0 : null,
left: !ownMessage ? 0 : null,
child: _MetaRow(
event,
ownMessage,
textColor,
timeline,
displayEvent,
),
),
],
),
),
),
),
),
),
];
final avatarOrSizedBox = sameSender
? const SizedBox(width: Avatar.defaultSize)
: Avatar(
event.sender.avatarUrl,
event.sender.calcDisplayname(),
onTap: () => onAvatarTab(event),
);
if (ownMessage) {
rowChildren.add(avatarOrSizedBox);
} else {
rowChildren.insert(0, avatarOrSizedBox);
}
final row = Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: rowMainAxisAlignment,
children: rowChildren,
);
Widget container;
if (event.hasAggregatedEvents(timeline, RelationshipTypes.reaction)) {
container = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
ownMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: <Widget>[
row,
Padding(
padding: EdgeInsets.only(
top: 4.0,
left: (ownMessage ? 0 : Avatar.defaultSize) + 12.0,
right: (ownMessage ? Avatar.defaultSize : 0) + 12.0,
),
child: MessageReactions(event, timeline),
),
],
);
} else {
container = row;
}
return Center(
child: Container(
color: selected
? Theme.of(context).primaryColor.withAlpha(100)
: Theme.of(context).primaryColor.withAlpha(0),
constraints:
const BoxConstraints(maxWidth: FluffyThemes.columnWidth * 2.5),
child: Padding(
padding: const EdgeInsets.only(
left: 8.0, right: 8.0, bottom: 4.0, top: 4.0),
child: container,
),
),
);
}
}
class _MetaRow extends StatelessWidget {
final Event event;
final bool ownMessage;
final Color color;
final Timeline timeline;
final Event displayEvent;
const _MetaRow(
this.event, this.ownMessage, this.color, this.timeline, this.displayEvent,
{Key key})
: super(key: key);
@override
Widget build(BuildContext context) {
final displayname = event.sender.calcDisplayname();
final showDisplayname =
!ownMessage && event.senderId != event.room.directChatMatrixID;
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (showDisplayname)
Text(
displayname,
style: TextStyle(
fontSize: 10 * AppConfig.fontSizeFactor,
fontWeight: FontWeight.bold,
color: (Theme.of(context).brightness == Brightness.light
? displayname.darkColor
: displayname.lightColor)
.withAlpha(200),
),
),
if (showDisplayname) const SizedBox(width: 4),
Text(
event.originServerTs.localizedTime(context),
style: TextStyle(
color: color.withAlpha(180),
fontSize: 10 * AppConfig.fontSizeFactor,
),
),
if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit))
Padding(
padding: const EdgeInsets.only(left: 2.0),
child: Icon(
Icons.edit_outlined,
size: 12 * AppConfig.fontSizeFactor,
color: color,
),
),
if (ownMessage) const SizedBox(width: 2),
if (ownMessage)
Icon(
displayEvent.statusIcon,
size: 14 * AppConfig.fontSizeFactor,
color: color,
),
],
);
}
}

View file

@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/encryption/utils/key_verification.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix_link_text/link_text.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:fluffychat/pages/key_verification/key_verification_dialog.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../../config/app_config.dart';
import '../../../pages/video_viewer/video_viewer.dart';
import '../../../utils/platform_infos.dart';
import '../../../utils/url_launcher.dart';
import 'audio_player.dart';
import 'html_message.dart';
import 'image_bubble.dart';
import 'map_bubble.dart';
import 'message_download_content.dart';
import 'sticker.dart';
class MessageContent extends StatelessWidget {
final Event event;
final Color textColor;
const MessageContent(this.event, {Key key, this.textColor}) : super(key: key);
void _verifyOrRequestKey(BuildContext context) async {
if (event.content['can_request_session'] != true) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
event.type == EventTypes.Encrypted
? L10n.of(context).needPantalaimonWarning
: event.getLocalizedBody(
MatrixLocals(L10n.of(context)),
),
)));
return;
}
final client = Matrix.of(context).client;
if (client.isUnknownSession && client.encryption.crossSigning.enabled) {
final req =
await client.userDeviceKeys[client.userID].startVerification();
req.onUpdate = () async {
if (req.state == KeyVerificationState.done) {
for (var i = 0; i < 12; i++) {
if (await client.encryption.keyManager.isCached()) {
break;
}
await Future.delayed(const Duration(seconds: 1));
}
final timeline = await event.room.getTimeline();
timeline.requestKeys();
timeline.cancelSubscriptions();
}
};
await KeyVerificationDialog(request: req).show(context);
} else {
final success = await showFutureLoadingDialog(
context: context,
future: () => event.requestKey(),
);
if (success.error == null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(L10n.of(context).requestToReadOlderMessages)));
}
}
}
@override
Widget build(BuildContext context) {
final fontSize =
DefaultTextStyle.of(context).style.fontSize * AppConfig.fontSizeFactor;
switch (event.type) {
case EventTypes.Message:
case EventTypes.Encrypted:
case EventTypes.Sticker:
switch (event.messageType) {
case MessageTypes.Image:
if (event.showThumbnail) {
return ImageBubble(
event,
width: 400,
height: 300,
fit: BoxFit.cover,
);
}
return MessageDownloadContent(event, textColor);
case MessageTypes.Sticker:
if (event.showThumbnail) {
return Sticker(event);
}
return MessageDownloadContent(event, textColor);
case MessageTypes.Audio:
if (PlatformInfos.isMobile) {
return AudioPlayerWidget(
event,
color: textColor,
);
}
return MessageDownloadContent(event, textColor);
case MessageTypes.Video:
if (event.showThumbnail &&
(PlatformInfos.isMobile || PlatformInfos.isWeb)) {
return InkWell(
onTap: () => showDialog(
context: Matrix.of(context).navigatorContext,
useRootNavigator: false,
builder: (_) => VideoViewer(event),
),
child: Stack(
alignment: Alignment.center,
children: <Widget>[
ImageBubble(
event,
width: 400,
height: 300,
fit: BoxFit.cover,
tapToView: false,
),
const Icon(Icons.play_circle_outline,
size: 200, color: Colors.grey),
],
),
);
}
return MessageDownloadContent(event, textColor);
case MessageTypes.File:
return MessageDownloadContent(event, textColor);
case MessageTypes.Text:
case MessageTypes.Notice:
case MessageTypes.Emote:
if (AppConfig.renderHtml &&
!event.redacted &&
event.isRichMessage) {
var html = event.formattedText;
if (event.messageType == MessageTypes.Emote) {
html = '* $html';
}
final bigEmotes = event.onlyEmotes &&
event.numberEmotes > 0 &&
event.numberEmotes <= 10;
return HtmlMessage(
html: html,
defaultTextStyle: TextStyle(
color: textColor,
fontSize: bigEmotes ? fontSize * 3 : fontSize,
),
linkStyle: TextStyle(
color: textColor.withAlpha(150),
fontSize: bigEmotes ? fontSize * 3 : fontSize,
decoration: TextDecoration.underline,
),
room: event.room,
emoteSize: bigEmotes ? fontSize * 3 : fontSize * 1.5,
);
}
// else we fall through to the normal message rendering
continue textmessage;
case MessageTypes.BadEncrypted:
case EventTypes.Encrypted:
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).scaffoldBackgroundColor,
onPrimary: Theme.of(context).textTheme.bodyText1.color,
),
onPressed: () => _verifyOrRequestKey(context),
icon: const Icon(Icons.lock_outline),
label: Text(L10n.of(context).encrypted),
);
case MessageTypes.Location:
final geoUri =
Uri.tryParse(event.content.tryGet<String>('geo_uri'));
if (geoUri != null &&
geoUri.scheme == 'geo' &&
geoUri.path != null) {
final latlong = geoUri.path
.split(';')
.first
.split(',')
.map((s) => double.tryParse(s))
.toList();
if (latlong.length == 2 &&
latlong.first != null &&
latlong.last != null) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
MapBubble(
latitude: latlong.first,
longitude: latlong.last,
),
const SizedBox(height: 6),
OutlinedButton.icon(
icon: Icon(Icons.location_on_outlined, color: textColor),
onPressed:
UrlLauncher(context, geoUri.toString()).launchUrl,
label: Text(
L10n.of(context).openInMaps,
style: TextStyle(color: textColor),
),
),
],
);
}
}
continue textmessage;
case MessageTypes.None:
textmessage:
default:
if (event.content['msgtype'] == Matrix.callNamespace) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).scaffoldBackgroundColor,
onPrimary: Theme.of(context).textTheme.bodyText1.color,
),
onPressed: () => launch(event.body),
icon: const Icon(Icons.phone_outlined, color: Colors.green),
label: Text(L10n.of(context).videoCall),
);
}
if (event.redacted) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.delete_forever_outlined, color: textColor),
const SizedBox(width: 4),
Text(
event.getLocalizedBody(MatrixLocals(L10n.of(context)),
hideReply: true),
style: TextStyle(
color: textColor,
fontSize: fontSize,
fontWeight: FontWeight.bold,
decoration: TextDecoration.lineThrough,
decorationThickness: 0.5,
),
),
],
);
}
final bigEmotes = event.onlyEmotes &&
event.numberEmotes > 0 &&
event.numberEmotes <= 10;
return LinkText(
text: event.getLocalizedBody(MatrixLocals(L10n.of(context)),
hideReply: true),
textStyle: TextStyle(
color: textColor,
fontSize: bigEmotes ? fontSize * 3 : fontSize,
decoration: event.redacted ? TextDecoration.lineThrough : null,
),
linkStyle: TextStyle(
color: textColor.withAlpha(150),
fontSize: bigEmotes ? fontSize * 3 : fontSize,
decoration: TextDecoration.underline,
),
onLinkTap: (url) => UrlLauncher(context, url).launchUrl(),
);
}
break;
default:
return Text(
L10n.of(context)
.userSentUnknownEvent(event.sender.calcDisplayname(), event.type),
style: TextStyle(
color: textColor,
decoration: event.redacted ? TextDecoration.lineThrough : null,
),
);
}
return Container(); // else flutter analyze complains
}
}

View file

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart';
class MessageDownloadContent extends StatelessWidget {
final Event event;
final Color textColor;
const MessageDownloadContent(this.event, this.textColor, {Key key})
: super(key: key);
@override
Widget build(BuildContext context) {
final String filename = event.content.containsKey('filename')
? event.content['filename']
: event.body;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).scaffoldBackgroundColor,
onPrimary: Theme.of(context).textTheme.bodyText1.color,
),
onPressed: () => event.saveFile(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.download_outlined),
const SizedBox(width: 8),
Expanded(
child: Text(
filename,
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
),
],
),
),
if (event.sizeString != null)
Text(
event.sizeString,
style: TextStyle(
color: textColor,
),
),
],
);
}
}

View file

@ -0,0 +1,229 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:characters/characters.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
class MessageReactions extends StatelessWidget {
final Event event;
final Timeline timeline;
const MessageReactions(this.event, this.timeline, {Key key})
: super(key: key);
@override
Widget build(BuildContext context) {
final allReactionEvents =
event.aggregatedEvents(timeline, RelationshipTypes.reaction);
final reactionMap = <String, _ReactionEntry>{};
final client = Matrix.of(context).client;
for (final e in allReactionEvents) {
if (e.content['m.relates_to'].containsKey('key')) {
final key = e.content['m.relates_to']['key'];
if (!reactionMap.containsKey(key)) {
reactionMap[key] = _ReactionEntry(
key: key,
count: 0,
reacted: false,
reactors: [],
);
}
reactionMap[key].count++;
reactionMap[key].reactors.add(e.sender);
reactionMap[key].reacted |= e.senderId == e.room.client.userID;
}
}
final reactionList = reactionMap.values.toList();
reactionList.sort((a, b) => b.count - a.count > 0 ? 1 : -1);
return Wrap(
spacing: 4.0,
runSpacing: 4.0,
children: reactionList
.map(
(r) => _Reaction(
reactionKey: r.key,
count: r.count,
reacted: r.reacted,
onTap: () {
if (r.reacted) {
final evt = allReactionEvents.firstWhere(
(e) =>
e.senderId == e.room.client.userID &&
e.content['m.relates_to']['key'] == r.key,
orElse: () => null);
if (evt != null) {
showFutureLoadingDialog(
context: context,
future: () => evt.redactEvent(),
);
}
} else {
showFutureLoadingDialog(
context: context,
future: () =>
event.room.sendReaction(event.eventId, r.key));
}
},
onLongPress: () async => await _AdaptableReactorsDialog(
client: client,
reactionEntry: r,
).show(context),
),
)
.toList());
}
}
class _Reaction extends StatelessWidget {
final String reactionKey;
final int count;
final bool reacted;
final void Function() onTap;
final void Function() onLongPress;
const _Reaction({
this.reactionKey,
this.count,
this.reacted,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
final borderColor = reacted
? Theme.of(context).primaryColor
: Theme.of(context).secondaryHeaderColor;
final textColor = Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black;
final color = Theme.of(context).scaffoldBackgroundColor;
final fontSize = DefaultTextStyle.of(context).style.fontSize;
final padding = fontSize / 5;
Widget content;
if (reactionKey.startsWith('mxc://')) {
final src = Uri.parse(reactionKey)?.getThumbnail(
Matrix.of(context).client,
width: 9999,
height: fontSize * MediaQuery.of(context).devicePixelRatio,
method: ThumbnailMethod.scale,
);
content = Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
CachedNetworkImage(
imageUrl: src.toString(),
height: fontSize,
),
Container(width: 4),
Text(count.toString(),
style: TextStyle(
color: textColor,
fontSize: DefaultTextStyle.of(context).style.fontSize,
)),
],
);
} else {
var renderKey = Characters(reactionKey);
if (renderKey.length > 10) {
renderKey = renderKey.getRange(0, 9) + Characters('');
}
content = Text('$renderKey $count',
style: TextStyle(
color: textColor,
fontSize: DefaultTextStyle.of(context).style.fontSize,
));
}
return InkWell(
onTap: () => onTap != null ? onTap() : null,
onLongPress: () => onLongPress != null ? onLongPress() : null,
child: Container(
decoration: BoxDecoration(
color: color,
border: Border.all(
width: 1,
color: borderColor,
),
borderRadius: BorderRadius.circular(8),
),
padding: EdgeInsets.all(padding),
child: content,
),
);
}
}
class _ReactionEntry {
String key;
int count;
bool reacted;
List<User> reactors;
_ReactionEntry({this.key, this.count, this.reacted, this.reactors});
}
class _AdaptableReactorsDialog extends StatelessWidget {
final Client client;
final _ReactionEntry reactionEntry;
const _AdaptableReactorsDialog({
Key key,
this.client,
this.reactionEntry,
}) : super(key: key);
Future<bool> show(BuildContext context) => PlatformInfos.isCupertinoStyle
? showCupertinoDialog(
context: context,
builder: (context) => this,
barrierDismissible: true,
useRootNavigator: false,
)
: showDialog(
context: context,
builder: (context) => this,
barrierDismissible: true,
useRootNavigator: false,
);
@override
Widget build(BuildContext context) {
final body = SingleChildScrollView(
child: Wrap(
spacing: 8.0,
runSpacing: 4.0,
alignment: WrapAlignment.center,
children: <Widget>[
for (var reactor in reactionEntry.reactors)
Chip(
avatar: Avatar(
reactor.avatarUrl,
reactor.displayName,
client: client,
),
label: Text(reactor.displayName),
),
],
),
);
final title = Center(child: Text(reactionEntry.key));
return PlatformInfos.isCupertinoStyle
? CupertinoAlertDialog(
title: title,
content: body,
)
: AlertDialog(
title: title,
content: body,
);
}
}

View file

@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
import '../../../config/app_config.dart';
import 'html_message.dart';
class ReplyContent extends StatelessWidget {
final Event replyEvent;
final bool lightText;
final Timeline timeline;
const ReplyContent(this.replyEvent,
{this.lightText = false, Key key, this.timeline})
: super(key: key);
@override
Widget build(BuildContext context) {
Widget replyBody;
final displayEvent = replyEvent != null && timeline != null
? replyEvent.getDisplayEvent(timeline)
: replyEvent;
final fontSize =
DefaultTextStyle.of(context).style.fontSize * AppConfig.fontSizeFactor;
if (displayEvent != null &&
AppConfig.renderHtml &&
[EventTypes.Message, EventTypes.Encrypted]
.contains(displayEvent.type) &&
[MessageTypes.Text, MessageTypes.Notice, MessageTypes.Emote]
.contains(displayEvent.messageType) &&
!displayEvent.redacted &&
displayEvent.content['format'] == 'org.matrix.custom.html' &&
displayEvent.content['formatted_body'] is String) {
String html = displayEvent.content['formatted_body'];
if (displayEvent.messageType == MessageTypes.Emote) {
html = '* $html';
}
replyBody = HtmlMessage(
html: html,
defaultTextStyle: TextStyle(
color: lightText
? Colors.white
: Theme.of(context).textTheme.bodyText2.color,
fontSize: fontSize,
),
maxLines: 1,
room: displayEvent.room,
emoteSize: fontSize * 1.5,
);
} else {
replyBody = Text(
displayEvent?.getLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: false,
hideReply: true,
) ??
'',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: lightText
? Colors.white
: Theme.of(context).textTheme.bodyText2.color,
fontSize: fontSize,
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 3,
height: fontSize * 2 + 6,
color: lightText ? Colors.white : Theme.of(context).primaryColor,
),
const SizedBox(width: 6),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
(displayEvent?.sender?.calcDisplayname() ?? '') + ':',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
color:
lightText ? Colors.white : Theme.of(context).primaryColor,
fontSize: fontSize,
),
),
replyBody,
],
),
),
],
);
}
}

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart';
import '../../../config/app_config.dart';
class StateMessage extends StatelessWidget {
final Event event;
final void Function(String) unfold;
const StateMessage(this.event, {@required this.unfold, Key key})
: super(key: key);
@override
Widget build(BuildContext context) {
if (event.unsigned['im.fluffychat.collapsed_state_event'] == true) {
return Container();
}
final int counter =
event.unsigned['im.fluffychat.collapsed_state_event_count'] ?? 0;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
),
child: Center(
child: InkWell(
onTap: counter != 0 ? () => unfold(event.eventId) : null,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
event.getLocalizedBody(MatrixLocals(L10n.of(context))),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: Theme.of(context).textTheme.bodyText1.fontSize *
AppConfig.fontSizeFactor,
color: Theme.of(context).textTheme.bodyText2.color,
decoration:
event.redacted ? TextDecoration.lineThrough : null,
),
),
if (counter != 0)
Text(
L10n.of(context).moreEvents(counter),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import '../../../config/app_config.dart';
import 'image_bubble.dart';
class Sticker extends StatefulWidget {
final Event event;
const Sticker(this.event, {Key key}) : super(key: key);
@override
_StickerState createState() => _StickerState();
}
class _StickerState extends State<Sticker> {
bool animated;
@override
Widget build(BuildContext context) {
// stickers should default to a ratio of 1:1
var ratio = 1.0;
// if a width and a height is specified for stickers, use those!
if (widget.event.infoMap['w'] is int && widget.event.infoMap['h'] is int) {
ratio = widget.event.infoMap['w'] / widget.event.infoMap['h'];
// make sure the ratio is within 0.9 - 2.0
if (ratio > 2.0) {
ratio = 2.0;
}
if (ratio < 0.9) {
ratio = 0.9;
}
}
return ImageBubble(
widget.event,
width: 400,
height: 400 / ratio,
fit: ratio <= 1.0 ? BoxFit.contain : BoxFit.cover,
onTap: () {
setState(() => animated = true);
showOkAlertDialog(
context: context,
message: widget.event.body,
okLabel: L10n.of(context).ok,
);
},
animated: animated ?? AppConfig.autoplayImages,
);
}
}

View file

@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import '../../../config/app_config.dart';
class VerificationRequestContent extends StatelessWidget {
final Event event;
final Timeline timeline;
const VerificationRequestContent({this.event, this.timeline, Key key})
: super(key: key);
@override
Widget build(BuildContext context) {
final events = event.aggregatedEvents(timeline, 'm.reference');
final done = events.where((e) => e.type == EventTypes.KeyVerificationDone);
final start =
events.where((e) => e.type == EventTypes.KeyVerificationStart);
final cancel =
events.where((e) => e.type == EventTypes.KeyVerificationCancel);
final fullyDone = done.length >= 2;
final started = start.isNotEmpty;
final canceled = cancel.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
),
child: Center(
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
color: Theme.of(context).backgroundColor,
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(Icons.lock_outlined,
color: canceled
? Colors.red
: (fullyDone ? Colors.green : Colors.grey)),
const SizedBox(width: 8),
Text(canceled
? 'Error ${cancel.first.content.tryGet<String>('code')}: ${cancel.first.content.tryGet<String>('reason')}'
: (fullyDone
? L10n.of(context).verifySuccess
: (started
? L10n.of(context).loadingPleaseWait
: L10n.of(context).newVerificationRequest)))
],
),
),
),
);
}
}

View file

@ -0,0 +1,445 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:matrix/matrix.dart';
import 'package:slugify/slugify.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import '../../widgets/avatar.dart';
import '../../widgets/matrix.dart';
class InputBar extends StatelessWidget {
final Room room;
final int minLines;
final int maxLines;
final TextInputType keyboardType;
final TextInputAction textInputAction;
final ValueChanged<String> onSubmitted;
final FocusNode focusNode;
final TextEditingController controller;
final InputDecoration decoration;
final ValueChanged<String> onChanged;
final bool autofocus;
const InputBar({
this.room,
this.minLines,
this.maxLines,
this.keyboardType,
this.onSubmitted,
this.focusNode,
this.controller,
this.decoration,
this.onChanged,
this.autofocus,
this.textInputAction,
Key key,
}) : super(key: key);
List<Map<String, String>> getSuggestions(String text) {
if (controller.selection.baseOffset != controller.selection.extentOffset ||
controller.selection.baseOffset < 0) {
return []; // no entries if there is selected text
}
final searchText =
controller.text.substring(0, controller.selection.baseOffset);
final ret = <Map<String, String>>[];
const maxResults = 30;
final commandMatch = RegExp(r'^\/([\w]*)$').firstMatch(searchText);
if (commandMatch != null) {
final commandSearch = commandMatch[1].toLowerCase();
for (final command in room.client.commands.keys) {
if (command.contains(commandSearch)) {
ret.add({
'type': 'command',
'name': command,
});
}
if (ret.length > maxResults) return ret;
}
}
final emojiMatch =
RegExp(r'(?:\s|^):(?:([-\w]+)~)?([-\w]+)$').firstMatch(searchText);
if (emojiMatch != null) {
final packSearch = emojiMatch[1];
final emoteSearch = emojiMatch[2].toLowerCase();
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
if (packSearch == null || packSearch.isEmpty) {
for (final pack in emotePacks.entries) {
for (final emote in pack.value.images.entries) {
if (emote.key.toLowerCase().contains(emoteSearch)) {
ret.add({
'type': 'emote',
'name': emote.key,
'pack': pack.key,
'pack_avatar_url': pack.value.pack.avatarUrl?.toString(),
'pack_display_name': pack.value.pack.displayName ?? pack.key,
'mxc': emote.value.url.toString(),
});
}
if (ret.length > maxResults) {
break;
}
}
if (ret.length > maxResults) {
break;
}
}
} else if (emotePacks[packSearch] != null) {
for (final emote in emotePacks[packSearch].images.entries) {
if (emote.key.toLowerCase().contains(emoteSearch)) {
ret.add({
'type': 'emote',
'name': emote.key,
'pack': packSearch,
'pack_avatar_url':
emotePacks[packSearch].pack.avatarUrl?.toString(),
'pack_display_name':
emotePacks[packSearch].pack.displayName ?? packSearch,
'mxc': emote.value.url.toString(),
});
}
if (ret.length > maxResults) {
break;
}
}
}
}
final userMatch = RegExp(r'(?:\s|^)@([-\w]+)$').firstMatch(searchText);
if (userMatch != null) {
final userSearch = userMatch[1].toLowerCase();
for (final user in room.getParticipants()) {
if ((user.displayName != null &&
(user.displayName.toLowerCase().contains(userSearch) ||
slugify(user.displayName.toLowerCase())
.contains(userSearch))) ||
user.id.split(':')[0].toLowerCase().contains(userSearch)) {
ret.add({
'type': 'user',
'mxid': user.id,
'mention': user.mention,
'displayname': user.displayName,
'avatar_url': user.avatarUrl?.toString(),
});
}
if (ret.length > maxResults) {
break;
}
}
}
final roomMatch = RegExp(r'(?:\s|^)#([-\w]+)$').firstMatch(searchText);
if (roomMatch != null) {
final roomSearch = roomMatch[1].toLowerCase();
for (final r in room.client.rooms) {
if (r.getState(EventTypes.RoomTombstone) != null) {
continue; // we don't care about tombstoned rooms
}
final state = r.getState(EventTypes.RoomCanonicalAlias);
if ((state != null &&
((state.content['alias'] is String &&
state.content['alias']
.split(':')[0]
.toLowerCase()
.contains(roomSearch)) ||
(state.content['alt_aliases'] is List &&
state.content['alt_aliases'].any((l) =>
l is String &&
l
.split(':')[0]
.toLowerCase()
.contains(roomSearch))))) ||
(r.name != null && r.name.toLowerCase().contains(roomSearch))) {
ret.add({
'type': 'room',
'mxid': (r.canonicalAlias != null && r.canonicalAlias.isNotEmpty)
? r.canonicalAlias
: r.id,
'displayname': r.displayname,
'avatar_url': r.avatar?.toString(),
});
}
if (ret.length > maxResults) {
break;
}
}
}
return ret;
}
String _commandHint(L10n l10n, String command) {
switch (command) {
case 'send':
return l10n.commandHintSend;
case 'me':
return l10n.commandHintMe;
case 'plain':
return l10n.commandHintPlain;
case 'html':
return l10n.commandHintHtml;
case 'react':
return l10n.commandHintReact;
case 'join':
return l10n.commandHintJoin;
case 'leave':
return l10n.commandHintLeave;
case 'op':
return l10n.commandHintOp;
case 'kick':
return l10n.commandHintKick;
case 'ban':
return l10n.commandHintBan;
case 'unban':
return l10n.commandHintUnBan;
case 'invite':
return l10n.commandHintInvite;
case 'myroomnick':
return l10n.commandHintMyRoomNick;
case 'myroomavatar':
return l10n.commandHintMyRoomAvatar;
default:
return '';
}
}
Widget buildSuggestion(
BuildContext context,
Map<String, String> suggestion,
Client client,
) {
const size = 30.0;
const padding = EdgeInsets.all(4.0);
if (suggestion['type'] == 'command') {
final command = suggestion['name'];
return Container(
padding: padding,
height: size + padding.bottom + padding.top,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('/' + command,
style: const TextStyle(fontFamily: 'monospace')),
Text(_commandHint(L10n.of(context), command),
style: Theme.of(context).textTheme.caption),
],
),
);
}
if (suggestion['type'] == 'emote') {
final ratio = MediaQuery.of(context).devicePixelRatio;
final url = Uri.parse(suggestion['mxc'] ?? '')?.getThumbnail(
room.client,
width: size * ratio,
height: size * ratio,
method: ThumbnailMethod.scale,
animated: true,
);
return Container(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
CachedNetworkImage(
imageUrl: url.toString(),
width: size,
height: size,
),
const SizedBox(width: 6),
Text(suggestion['name']),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Opacity(
opacity: suggestion['pack_avatar_url'] != null ? 0.8 : 0.5,
child: suggestion['pack_avatar_url'] != null
? Avatar(
Uri.parse(suggestion['pack_avatar_url']),
suggestion['pack_display_name'],
size: size * 0.9,
client: client,
)
: Text(suggestion['pack_display_name']),
),
),
),
],
),
);
}
if (suggestion['type'] == 'user' || suggestion['type'] == 'room') {
final url = Uri.parse(suggestion['avatar_url'] ?? '');
return Container(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Avatar(
url,
suggestion['displayname'] ?? suggestion['mxid'],
size: size,
client: client,
),
const SizedBox(width: 6),
Text(suggestion['displayname'] ?? suggestion['mxid']),
],
),
);
}
return Container();
}
void insertSuggestion(_, Map<String, String> suggestion) {
final replaceText =
controller.text.substring(0, controller.selection.baseOffset);
var startText = '';
final afterText = replaceText == controller.text
? ''
: controller.text.substring(controller.selection.baseOffset + 1);
var insertText = '';
if (suggestion['type'] == 'command') {
insertText = suggestion['name'] + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'^(\/[\w]*)$'),
(Match m) => '/' + insertText,
);
}
if (suggestion['type'] == 'emote') {
var isUnique = true;
final insertEmote = suggestion['name'];
final insertPack = suggestion['pack'];
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
for (final pack in emotePacks.entries) {
if (pack.key == insertPack) {
continue;
}
for (final emote in pack.value.images.entries) {
if (emote.key == insertEmote) {
isUnique = false;
break;
}
}
if (!isUnique) {
break;
}
}
insertText = ':${isUnique ? '' : insertPack + '~'}$insertEmote: ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(:(?:[-\w]+~)?[-\w]+)$'),
(Match m) => '${m[1]}$insertText',
);
}
if (suggestion['type'] == 'user') {
insertText = suggestion['mention'] + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(@[-\w]+)$'),
(Match m) => '${m[1]}$insertText',
);
}
if (suggestion['type'] == 'room') {
insertText = suggestion['mxid'] + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(#[-\w]+)$'),
(Match m) => '${m[1]}$insertText',
);
}
if (insertText.isNotEmpty && startText.isNotEmpty) {
controller.text = startText + afterText;
controller.selection = TextSelection(
baseOffset: startText.length,
extentOffset: startText.length,
);
}
}
@override
Widget build(BuildContext context) {
final useShortCuts = (PlatformInfos.isWeb ||
PlatformInfos.isDesktop ||
AppConfig.sendOnEnter);
return Shortcuts(
shortcuts: !useShortCuts
? {}
: {
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.enter):
NewLineIntent(),
LogicalKeySet(LogicalKeyboardKey.enter): SubmitLineIntent(),
},
child: Actions(
actions: !useShortCuts
? {}
: {
NewLineIntent: CallbackAction(onInvoke: (i) {
final val = controller.value;
final selection = val.selection.start;
final messageWithoutNewLine =
controller.text.substring(0, val.selection.start) +
'\n' +
controller.text.substring(val.selection.end);
controller.value = TextEditingValue(
text: messageWithoutNewLine,
selection: TextSelection.fromPosition(
TextPosition(offset: selection + 1),
),
);
return null;
}),
SubmitLineIntent: CallbackAction(onInvoke: (i) {
onSubmitted(controller.text);
return null;
}),
},
child: TypeAheadField<Map<String, String>>(
direction: AxisDirection.up,
hideOnEmpty: true,
hideOnLoading: true,
keepSuggestionsOnSuggestionSelected: true,
debounceDuration: const Duration(
milliseconds:
50), // show suggestions after 50ms idle time (default is 300)
textFieldConfiguration: TextFieldConfiguration(
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType,
textInputAction: textInputAction,
autofocus: autofocus,
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
onSubmitted(text);
},
//focusNode: focusNode,
controller: controller,
decoration: decoration,
focusNode: focusNode,
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
onChanged(text);
},
textCapitalization: TextCapitalization.sentences,
),
suggestionsCallback: getSuggestions,
itemBuilder: (c, s) =>
buildSuggestion(c, s, Matrix.of(context).client),
onSuggestionSelected: (Map<String, String> suggestion) =>
insertSuggestion(context, suggestion),
errorBuilder: (BuildContext context, Object error) => Container(),
loadingBuilder: (BuildContext context) =>
Container(), // fix loading briefly flickering a dark box
noItemsFoundBuilder: (BuildContext context) =>
Container(), // fix loading briefly showing no suggestions
),
),
);
}
}
class NewLineIntent extends Intent {}
class SubmitLineIntent extends Intent {}

View file

@ -0,0 +1,197 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import 'package:wakelock/wakelock.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/sentry_controller.dart';
class RecordingDialog extends StatefulWidget {
static const String recordingFileType = 'm4a';
const RecordingDialog({
Key key,
}) : super(key: key);
@override
_RecordingDialogState createState() => _RecordingDialogState();
}
class _RecordingDialogState extends State<RecordingDialog> {
Timer _recorderSubscription;
Duration _duration = Duration.zero;
bool error = false;
String _recordedPath;
final _audioRecorder = Record();
Amplitude _amplitude;
Future<void> startRecording() async {
try {
final tempDir = await getTemporaryDirectory();
_recordedPath =
'${tempDir.path}/recording${DateTime.now().microsecondsSinceEpoch}.${RecordingDialog.recordingFileType}';
final result = await _audioRecorder.hasPermission();
if (result != true) {
setState(() => error = true);
return;
}
await Wakelock.enable();
await _audioRecorder.start(
path: _recordedPath, encoder: AudioEncoder.AAC);
setState(() => _duration = Duration.zero);
_recorderSubscription?.cancel();
_recorderSubscription =
Timer.periodic(const Duration(milliseconds: 100), (_) async {
_amplitude = await _audioRecorder.getAmplitude();
setState(() {
_duration += const Duration(milliseconds: 100);
});
});
} catch (e, s) {
SentryController.captureException(e, s);
setState(() => error = true);
}
}
@override
void initState() {
super.initState();
startRecording();
}
@override
void dispose() {
Wakelock.disable();
_recorderSubscription?.cancel();
_audioRecorder.stop();
super.dispose();
}
void _stopAndSend() async {
_recorderSubscription?.cancel();
await _audioRecorder.stop();
Navigator.of(context, rootNavigator: false)
.pop<RecordingResult>(RecordingResult(
path: _recordedPath,
duration: _duration.inMilliseconds,
));
}
@override
Widget build(BuildContext context) {
const maxDecibalWidth = 64.0;
final decibalWidth =
((_amplitude == null || _amplitude.current == double.negativeInfinity
? 0
: 1 / _amplitude.current / _amplitude.max)
.abs() +
2) *
(maxDecibalWidth / 4).toDouble();
final time =
'${_duration.inMinutes.toString().padLeft(2, '0')}:${(_duration.inSeconds % 60).toString().padLeft(2, '0')}';
final content = error
? Text(L10n.of(context).oopsSomethingWentWrong)
: Row(
children: <Widget>[
Container(
width: maxDecibalWidth,
height: maxDecibalWidth,
alignment: Alignment.center,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: decibalWidth,
height: decibalWidth,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(decibalWidth),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${L10n.of(context).recording}: $time',
style: const TextStyle(
fontSize: 18,
),
),
),
],
);
if (PlatformInfos.isCupertinoStyle) {
return CupertinoAlertDialog(
content: content,
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
child: Text(
L10n.of(context).cancel.toUpperCase(),
style: TextStyle(
color:
Theme.of(context).textTheme.bodyText2.color.withAlpha(150),
),
),
),
if (error != true)
CupertinoDialogAction(
onPressed: _stopAndSend,
child: Text(L10n.of(context).send.toUpperCase()),
),
],
);
}
return AlertDialog(
content: content,
actions: [
TextButton(
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
child: Text(
L10n.of(context).cancel.toUpperCase(),
style: TextStyle(
color: Theme.of(context).textTheme.bodyText2.color.withAlpha(150),
),
),
),
if (error != true)
TextButton(
onPressed: _stopAndSend,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(L10n.of(context).send.toUpperCase()),
const SizedBox(width: 4),
const Icon(Icons.send_outlined, size: 15),
],
),
),
],
);
}
}
class RecordingResult {
final String path;
final int duration;
const RecordingResult({
@required this.path,
@required this.duration,
});
factory RecordingResult.fromJson(Map<String, dynamic> json) =>
RecordingResult(
path: json['path'],
duration: json['duration'],
);
Map<String, dynamic> toJson() => {
'path': path,
'duration': duration,
};
}

View file

@ -0,0 +1,135 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import '../../widgets/avatar.dart';
import '../../widgets/default_app_bar_search_field.dart';
import 'events/image_bubble.dart';
class StickerPickerDialog extends StatefulWidget {
final Room room;
const StickerPickerDialog({this.room, Key key}) : super(key: key);
@override
StickerPickerDialogState createState() => StickerPickerDialogState();
}
class StickerPickerDialogState extends State<StickerPickerDialog> {
String searchFilter;
@override
Widget build(BuildContext context) {
final stickerPacks = widget.room.getImagePacks(ImagePackUsage.sticker);
final packSlugs = stickerPacks.keys.toList();
// ignore: prefer_function_declarations_over_variables
final _packBuilder = (BuildContext context, int packIndex) {
final pack = stickerPacks[packSlugs[packIndex]];
final filteredImagePackImageEntried = pack.images.entries.toList();
if (searchFilter?.isNotEmpty ?? false) {
filteredImagePackImageEntried.removeWhere((e) =>
!(e.key.toLowerCase().contains(searchFilter.toLowerCase()) ||
(e.value.body
?.toLowerCase()
?.contains(searchFilter.toLowerCase()) ??
false)));
}
final imageKeys =
filteredImagePackImageEntried.map((e) => e.key).toList();
if (imageKeys.isEmpty) {
return Container();
}
final packName = pack.pack.displayName ?? packSlugs[packIndex];
return Column(
children: <Widget>[
if (packIndex != 0) const SizedBox(height: 20),
if (packName != 'user')
ListTile(
leading: Avatar(
pack.pack.avatarUrl,
packName,
client: widget.room.client,
),
title: Text(packName),
),
const SizedBox(height: 6),
GridView.builder(
itemCount: imageKeys.length,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int imageIndex) {
final image = pack.images[imageKeys[imageIndex]];
final fakeEvent = Event.fromJson(<String, dynamic>{
'type': EventTypes.Sticker,
'content': <String, dynamic>{
'url': image.url.toString(),
'info': image.info,
},
'event_id': 'fake_event',
}, widget.room);
return InkWell(
key: ValueKey(image.url.toString()),
onTap: () {
// copy the image
final imageCopy =
ImagePackImageContent.fromJson(image.toJson().copy());
// set the body, if it doesn't exist, to the key
imageCopy.body ??= imageKeys[imageIndex];
Navigator.of(context, rootNavigator: false)
.pop<ImagePackImageContent>(imageCopy);
},
child: AbsorbPointer(
absorbing: true,
child: ImageBubble(
fakeEvent,
tapToView: false,
fit: BoxFit.contain,
width: 100,
height: 100,
),
),
);
},
),
],
);
};
return Scaffold(
body: SizedBox(
width: double.maxFinite,
child: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
floating: true,
pinned: true,
automaticallyImplyLeading: false,
titleSpacing: 0,
backgroundColor: Theme.of(context).dialogBackgroundColor,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: Navigator.of(context, rootNavigator: false).pop,
),
title: DefaultAppBarSearchField(
autofocus: false,
hintText: L10n.of(context).search,
suffix: const Icon(Icons.search_outlined),
onChanged: (s) => setState(() => searchFilter = s),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
_packBuilder,
childCount: packSlugs.length,
)),
],
),
),
);
}
}