//@dart=2.12 import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.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:path_provider/path_provider.dart'; import 'package:video_player/video_player.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/pages/story/story_view.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.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/matrix.dart'; class StoryPage extends StatefulWidget { const StoryPage({Key? key}) : super(key: key); @override StoryPageController createState() => StoryPageController(); } class StoryPageController extends State { int index = 0; int max = 0; Duration progress = Duration.zero; Timer? _progressTimer; bool loadingMode = false; final TextEditingController replyController = TextEditingController(); final FocusNode replyFocus = FocusNode(); final List events = []; Timeline? timeline; Event? get currentEvent => index < events.length ? events[index] : null; bool replyLoading = false; bool _modalOpened = false; VideoPlayerController? _videoPlayerController; void replyEmojiAction() async { if (replyLoading) return; _modalOpened = true; await showModalBottomSheet( context: context, builder: (context) => EmojiPicker( onEmojiSelected: (c, e) { Navigator.of(context).pop(); replyAction(e.emoji); }, ), ); _modalOpened = false; } void replyAction([String? message]) async { message ??= replyController.text; final currentEvent = this.currentEvent; if (currentEvent == null) return; setState(() { replyLoading = true; }); try { final client = Matrix.of(context).client; final roomId = await client.startDirectChat(currentEvent.senderId); var replyText = L10n.of(context)!.storyFrom( currentEvent.originServerTs.localizedTime(context), currentEvent.content.tryGet('body') ?? ''); replyText = replyText.split('\n').map((line) => '> $line').join('\n'); message = '$replyText\n\n$message'; await client.getRoomById(roomId)!.sendTextEvent(message); replyController.clear(); replyFocus.unfocus(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context)!.replyHasBeenSent)), ); } catch (e, s) { Logs().w('Unable to reply to story', e, s); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toLocalizedString(context))), ); } finally { setState(() { replyLoading = false; }); } } List get currentSeenByUsers { final timeline = this.timeline; final currentEvent = this.currentEvent; if (timeline == null || currentEvent == null) return []; return Matrix.of(context).client.getRoomById(roomId)?.getSeenByUsers( timeline, events, {}, eventId: currentEvent.eventId, ) ?? []; } void displaySeenByUsers() async { _modalOpened = true; await showModalBottomSheet( context: context, builder: (context) => Scaffold( appBar: AppBar( title: Text(seenByUsersTitle), ), body: ListView.builder( itemCount: currentSeenByUsers.length, itemBuilder: (context, i) => ListTile( leading: Avatar( mxContent: currentSeenByUsers[i].avatarUrl, name: currentSeenByUsers[i].calcDisplayname(), ), title: Text(currentSeenByUsers[i].calcDisplayname()), ), ), ), ); _modalOpened = false; } String get seenByUsersTitle { final seenByUsers = currentSeenByUsers; if (seenByUsers.isEmpty) return ''; if (seenByUsers.length == 1) { return L10n.of(context)!.seenByUser(seenByUsers.single.calcDisplayname()); } if (seenByUsers.length == 2) { return L10n.of(context)!.seenByUserAndUser( seenByUsers.first.calcDisplayname(), seenByUsers.last.calcDisplayname(), ); } return L10n.of(context)!.seenByUserAndCountOthers( seenByUsers.first.calcDisplayname(), seenByUsers.length - 1, ); } static const Duration _step = Duration(milliseconds: 50); static const Duration maxProgress = Duration(seconds: 5); void _restartTimer([bool reset = true]) { _progressTimer?.cancel(); if (reset) progress = Duration.zero; _progressTimer = Timer.periodic(_step, (_) { if (replyFocus.hasFocus || _modalOpened) return; if (!mounted) { _progressTimer?.cancel(); return; } if (loadingMode) return; setState(() { final video = _videoPlayerController; if (video == null) { progress += _step; } else { progress = video.value.position; } }); final max = _videoPlayerController?.value.duration ?? maxProgress; if (progress > max) { skip(); } }); } bool get isOwnStory { final client = Matrix.of(context).client; final room = client.getRoomById(roomId); if (room == null) return false; return room.ownPowerLevel >= 100; } String get roomId => VRouter.of(context).pathParameters['roomid'] ?? ''; Future loadVideoController(Event event) async { final matrixFile = await event.downloadAndDecryptAttachment(); final tmpDirectory = await getTemporaryDirectory(); final file = File(tmpDirectory.path + matrixFile.name); final videoPlayerController = VideoPlayerController.file(file); await videoPlayerController.initialize(); videoPlayerController.play(); return videoPlayerController; } void skip() { if (index + 1 >= max) { VRouter.of(context).to('/rooms'); return; } setState(() { index++; }); _restartTimer(); maybeSetReadMarker(); } DateTime _holdedAt = DateTime.fromMicrosecondsSinceEpoch(0); bool isHold = false; void hold([_]) { _holdedAt = DateTime.now(); if (loadingMode) return; _progressTimer?.cancel(); setState(() { isHold = true; }); } void unhold([_]) { isHold = false; if (DateTime.now().millisecondsSinceEpoch - _holdedAt.millisecondsSinceEpoch < 200) { skip(); return; } _restartTimer(false); } void loadingModeOn() => _setLoadingMode(true); void loadingModeOff() => _setLoadingMode(false); final Map> _fileCache = {}; void report(_) async { _modalOpened = true; final event = currentEvent; if (event == null) return; final score = await showConfirmationDialog( 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( roomId, event.eventId, reason: reason.single, score: score, ), ); _modalOpened = false; if (result.error != null) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context)!.contentHasBeenReported)), ); } Future downloadAndDecryptAttachment( Event event, bool getThumbnail) async { return _fileCache[event.eventId] ??= event.downloadAndDecryptAttachment(getThumbnail: getThumbnail); } void _setLoadingMode(bool mode) => loadingMode != mode ? WidgetsBinding.instance?.addPostFrameCallback((_) { setState(() { loadingMode = mode; }); }) : null; Uri? get avatar => Matrix.of(context) .client .getRoomById(roomId) ?.getState(EventTypes.RoomCreate) ?.sender .avatarUrl; String get title => Matrix.of(context) .client .getRoomById(roomId) ?.getState(EventTypes.RoomCreate) ?.sender .calcDisplayname() ?? 'Story not found'; Future? loadStory; Future _loadStory() async { try { final client = Matrix.of(context).client; await client.roomsLoading; await client.accountDataLoading; final room = client.getRoomById(roomId); if (room == null) return; if (room.membership != Membership.join) { final joinedFuture = room.client.onSync.stream .where((u) => u.rooms?.join?.containsKey(room.id) ?? false) .first; await room.join(); await joinedFuture; } final timeline = this.timeline = await room.getTimeline(); timeline.requestKeys(); var events = timeline.events.where((e) => e.type == EventTypes.Message).toList(); final hasOutdatedEvents = events.removeOutdatedEvents(); // Request history if possible if (!hasOutdatedEvents && timeline.events.first.type != EventTypes.RoomCreate && events.length < 30) { try { await timeline .requestHistory(historyCount: 100) .timeout(const Duration(seconds: 5)); events = timeline.events .where((e) => e.type == EventTypes.Message) .toList(); events.removeOutdatedEvents(); } catch (e, s) { Logs().d('Unable to request history in stories', e, s); } } max = events.length; if (events.isNotEmpty) { _restartTimer(); } // Preload images and videos events .where((event) => {MessageTypes.Image, MessageTypes.Video} .contains(event.messageType)) .forEach((event) => downloadAndDecryptAttachment( event, event.messageType == MessageTypes.Video && PlatformInfos.isMobile)); // Reverse list this.events.clear(); this.events.addAll(events.reversed.toList()); // Set start position if (this.events.isNotEmpty) { final receiptId = room.roomAccountData['m.receipt']?.content .tryGetMap(room.client.userID!) ?.tryGet('event_id'); index = this.events.indexWhere((event) => event.eventId == receiptId); index++; if (index >= this.events.length) index = 0; } maybeSetReadMarker(); } catch (e, s) { Logs().e('Unable to load story', e, s); } return; } void maybeSetReadMarker() { final currentEvent = this.currentEvent; if (currentEvent == null) return; final room = currentEvent.room; if (!currentSeenByUsers.any((u) => u.id == u.room.client.userID)) { room.setReadMarker( currentEvent.eventId, mRead: currentEvent.eventId, ); } } @override Widget build(BuildContext context) { loadStory ??= _loadStory(); return StoryView(this); } } extension on List { bool removeOutdatedEvents() { final outdatedIndex = indexWhere((event) => DateTime.now().difference(event.originServerTs).inHours > ClientStoriesExtension.lifeTimeInHours); if (outdatedIndex != -1) { removeRange(outdatedIndex, length); return true; } return false; } } enum PopupStoryAction { report, message, }