diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 36221a20..90f62aa3 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2662,5 +2662,10 @@ "pleaseEnterSecurityKeyDescription": "To unlock your chat backup, please enter your security key that has been generated in a previous session. Your security key is NOT your password.", "@pleaseEnterSecurityKeyDescription": {}, "saveTheSecurityKeyNow": "Save the security key now", - "@saveTheSecurityKeyNow": {} + "@saveTheSecurityKeyNow": {}, + "addToStory": "Add to story", + "publish": "Publish", + "whoCanSeeMyStories": "Who can see my stories?", + "unsubscribeStories": "Unsubscribe stories", + "thisUserHasNotPostedAnythingYet": "This user has not posted anything in their story yet" } diff --git a/lib/config/routes.dart b/lib/config/routes.dart index bbc06a5b..db4ae208 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:vrouter/vrouter.dart'; +import 'package:fluffychat/pages/add_story/add_story.dart'; import 'package:fluffychat/pages/archive/archive.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; @@ -29,6 +30,7 @@ import 'package:fluffychat/pages/settings_notifications/settings_notifications.d import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; import 'package:fluffychat/pages/sign_up/signup.dart'; +import 'package:fluffychat/pages/story/story_page.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/loading_view.dart'; import 'package:fluffychat/widgets/layouts/side_view_layout.dart'; @@ -51,6 +53,14 @@ class AppRoutes { path: '/rooms', widget: const ChatList(), stackedRoutes: [ + VWidget( + path: '/stories/create', + widget: const AddStoryPage(), + ), + VWidget( + path: '/stories/:roomid', + widget: const StoryPage(), + ), VWidget( path: '/spaces/:roomid', widget: const ChatDetails(), diff --git a/lib/pages/add_story/add_story.dart b/lib/pages/add_story/add_story.dart new file mode 100644 index 00000000..44b8961e --- /dev/null +++ b/lib/pages/add_story/add_story.dart @@ -0,0 +1,158 @@ +//@dart=2.12 + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:file_picker_cross/file_picker_cross.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:matrix/matrix.dart'; +import 'package:video_player/video_player.dart'; +import 'package:vrouter/vrouter.dart'; + +import 'package:fluffychat/pages/add_story/add_story_view.dart'; +import 'package:fluffychat/pages/add_story/invite_story_page.dart'; +import 'package:fluffychat/utils/resize_image.dart'; +import 'package:fluffychat/utils/string_color.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../../utils/matrix_sdk_extensions.dart/client_stories_extension.dart'; + +class AddStoryPage extends StatefulWidget { + const AddStoryPage({Key? key}) : super(key: key); + + @override + AddStoryController createState() => AddStoryController(); +} + +class AddStoryController extends State { + final TextEditingController controller = TextEditingController(); + late Color backgroundColor; + late Color backgroundColorDark; + MatrixFile? image; + MatrixFile? video; + + VideoPlayerController? videoPlayerController; + + bool get hasMedia => image != null || video != null; + + void updateColors(String text) => hasMedia + ? null + : setState(() { + backgroundColor = text.color; + backgroundColorDark = text.darkColor; + }); + + void importMedia() async { + final type = await showModalActionSheet( + context: context, + actions: [ + SheetAction( + label: L10n.of(context)!.pickImage, + key: FileTypeCross.image, + icon: Icons.photo_album_outlined, + ), + SheetAction( + label: L10n.of(context)!.sendVideo, + key: FileTypeCross.video, + icon: Icons.video_camera_back_outlined, + ), + ], + ); + if (type == null) return; + final picked = await FilePickerCross.importFromStorage(type: type); + final fileName = picked.fileName; + if (fileName == null) return; + setState(() { + image = MatrixFile(bytes: picked.toUint8List(), name: fileName); + }); + } + + void capturePhoto() async { + final picked = await ImagePicker().pickImage( + source: ImageSource.camera, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + setState(() { + image = MatrixFile(bytes: bytes, name: picked.name); + }); + } + + void captureVideo() async { + final picked = await ImagePicker().pickVideo( + source: ImageSource.camera, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + + setState(() { + video = MatrixFile(bytes: bytes, name: picked.name); + videoPlayerController = VideoPlayerController.file(File(picked.path)) + ..setLooping(true); + }); + } + + void postStory() async { + final client = Matrix.of(context).client; + final storiesRoom = await client.getStoriesRoom(context); + + // Invite contacts if necessary + final undecided = await showFutureLoadingDialog( + context: context, + future: () => client.getUndecidedContactsForStories(storiesRoom), + ); + final result = undecided.result; + if (result == null) return; + if (result.isNotEmpty) { + final created = await showDialog( + context: context, + useRootNavigator: false, + builder: (context) => InviteStoryPage(storiesRoom: storiesRoom), + ); + if (created != true) return; + } + + // Post story + final postResult = await showFutureLoadingDialog( + context: context, + future: () async { + if (storiesRoom == null) throw ('Stories room is null'); + final video = this.video; + if (video != null) { + await storiesRoom.sendFileEvent( + video, + extraContent: {'body': controller.text}, + ); + return; + } + var image = this.image; + if (image != null) { + image = await image.resizeImage(); + await storiesRoom.sendFileEvent( + image, + extraContent: {'body': controller.text}, + ); + return; + } + await storiesRoom.sendTextEvent(controller.text); + }, + ); + if (postResult.error == null) { + VRouter.of(context).to('/rooms'); + } + } + + @override + void initState() { + super.initState(); + final text = Matrix.of(context).client.userID!; + backgroundColor = text.color; + backgroundColorDark = text.darkColor; + } + + @override + Widget build(BuildContext context) => AddStoryView(this); +} diff --git a/lib/pages/add_story/add_story_view.dart b/lib/pages/add_story/add_story_view.dart new file mode 100644 index 00000000..e83a5d4d --- /dev/null +++ b/lib/pages/add_story/add_story_view.dart @@ -0,0 +1,115 @@ +//@dart=2.12 + +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:video_player/video_player.dart'; + +import 'package:fluffychat/utils/platform_infos.dart'; +import 'add_story.dart'; + +class AddStoryView extends StatelessWidget { + final AddStoryController controller; + const AddStoryView(this.controller, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final video = controller.videoPlayerController; + return Scaffold( + appBar: AppBar( + backgroundColor: + Theme.of(context).appBarTheme.backgroundColor?.withOpacity(0.5), + title: Text(L10n.of(context)!.addToStory), + actions: controller.hasMedia + ? null + : [ + IconButton( + icon: const Icon(Icons.photo_outlined), + onPressed: controller.importMedia, + ), + if (PlatformInfos.isMobile) + IconButton( + icon: const Icon(Icons.camera_alt_outlined), + onPressed: controller.capturePhoto, + ), + if (PlatformInfos.isMobile) + IconButton( + icon: const Icon(Icons.video_camera_back_outlined), + onPressed: controller.captureVideo, + ), + ], + ), + extendBodyBehindAppBar: true, + body: Stack( + children: [ + if (video != null) + FutureBuilder( + future: video.initialize().then((_) => video.play()), + builder: (_, __) => Center(child: VideoPlayer(video)), + ), + AnimatedContainer( + duration: const Duration(seconds: 2), + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + image: controller.image == null + ? null + : DecorationImage( + image: MemoryImage(controller.image!.bytes), + fit: BoxFit.cover, + opacity: 0.75, + ), + gradient: controller.hasMedia + ? null + : LinearGradient( + colors: [ + controller.backgroundColor, + controller.backgroundColorDark, + controller.backgroundColor, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: TextField( + controller: controller.controller, + minLines: 1, + maxLines: 20, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + color: Colors.white, + backgroundColor: !controller.hasMedia ? null : Colors.black, + ), + onChanged: controller.updateColors, + decoration: InputDecoration( + border: InputBorder.none, + hintText: + controller.hasMedia ? 'Add description' : 'How are you?', + filled: false, + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.5), + backgroundColor: Colors.transparent, + ), + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + ), + ), + ), + ], + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: + controller.controller.text.isEmpty && !controller.hasMedia + ? null + : FloatingActionButton.extended( + onPressed: controller.postStory, + label: Text(L10n.of(context)!.publish), + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).colorScheme.onSurface, + icon: const Icon(Icons.check_circle), + ), + ); + } +} diff --git a/lib/pages/add_story/invite_story_page.dart b/lib/pages/add_story/invite_story_page.dart new file mode 100644 index 00000000..e1516b7d --- /dev/null +++ b/lib/pages/add_story/invite_story_page.dart @@ -0,0 +1,109 @@ +//@dart=2.12 + +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/matrix.dart'; + +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class InviteStoryPage extends StatefulWidget { + final Room? storiesRoom; + const InviteStoryPage({ + required this.storiesRoom, + Key? key, + }) : super(key: key); + + @override + _InviteStoryPageState createState() => _InviteStoryPageState(); +} + +class _InviteStoryPageState extends State { + Set _undecided = {}; + final Set _invite = {}; + + void _inviteAction() async { + final result = await showFutureLoadingDialog( + context: context, + future: () async { + final client = Matrix.of(context).client; + final room = await client.getStoriesRoom(context); + if (room == null) { + await client.createStoriesRoom(_invite.toList()); + } else { + for (final userId in _invite) { + room.invite(userId); + } + } + + _undecided.removeAll(_invite); + await client.setStoriesBlockList(_undecided.toList()); + }, + ); + if (result.error != null) return; + Navigator.of(context).pop(true); + } + + Future>? loadContacts; + + @override + Widget build(BuildContext context) { + loadContacts ??= Matrix.of(context) + .client + .getUndecidedContactsForStories(widget.storiesRoom) + .then((contacts) { + if (contacts.length < 20) { + _invite.addAll(contacts.map((u) => u.id)); + } + return contacts; + }); + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(false), + ), + title: Text(L10n.of(context)!.whoCanSeeMyStories), + ), + body: FutureBuilder>( + future: loadContacts, + builder: (context, snapshot) { + final contacts = snapshot.data; + if (contacts == null) { + final error = snapshot.error; + if (error != null) { + return Center(child: Text(error.toLocalizedString(context))); + } + return const Center(child: CircularProgressIndicator.adaptive()); + } + _undecided = contacts.map((u) => u.id).toSet(); + return ListView.builder( + itemCount: contacts.length, + itemBuilder: (context, i) => SwitchListTile.adaptive( + value: _invite.contains(contacts[i].id), + onChanged: (b) => setState(() => b + ? _invite.add(contacts[i].id) + : _invite.remove(contacts[i].id)), + secondary: Avatar( + mxContent: contacts[i].avatarUrl, + name: contacts[i].calcDisplayname(), + ), + title: Text(contacts[i].calcDisplayname()), + ), + ); + }), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: FloatingActionButton.extended( + onPressed: _inviteAction, + label: Text(L10n.of(context)!.publish), + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).colorScheme.onSurface, + icon: const Icon(Icons.upload_outlined), + ), + ); + } +} diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index b27c2ec1..cd530105 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/client_chooser_button.dart'; import 'package:fluffychat/pages/chat_list/spaces_bottom_bar.dart'; +import 'package:fluffychat/pages/chat_list/stories_header.dart'; import 'package:fluffychat/widgets/connection_status_header.dart'; import '../../utils/stream_extension.dart'; import '../../widgets/matrix.dart'; @@ -203,6 +204,7 @@ class ChatListView extends StatelessWidget { ), ), ), + if (controller.waitForFirstSync) const StoriesHeader(), Expanded(child: _ChatListViewBody(controller)), ]), floatingActionButton: selectMode == SelectMode.normal diff --git a/lib/pages/chat_list/stories_header.dart b/lib/pages/chat_list/stories_header.dart new file mode 100644 index 00000000..60f0ad28 --- /dev/null +++ b/lib/pages/chat_list/stories_header.dart @@ -0,0 +1,203 @@ +//@dart=2.12 + +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 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +enum ContextualRoomAction { mute, unmute, leave } + +class StoriesHeader extends StatelessWidget { + const StoriesHeader({Key? key}) : super(key: key); + + void _addToStoryAction(BuildContext context) => + VRouter.of(context).to('/stories/create'); + + void _goToStoryAction(BuildContext context, String roomId) => + VRouter.of(context).toSegments(['stories', roomId]); + + void _contextualActions(BuildContext context, Room room) async { + final action = await showModalActionSheet( + context: context, + actions: [ + if (room.pushRuleState != PushRuleState.notify) + SheetAction( + label: L10n.of(context)!.unmuteChat, + key: ContextualRoomAction.unmute, + icon: Icons.notifications_outlined, + ) + else + SheetAction( + label: L10n.of(context)!.muteChat, + key: ContextualRoomAction.mute, + icon: Icons.notifications_off_outlined, + ), + SheetAction( + label: L10n.of(context)!.unsubscribeStories, + key: ContextualRoomAction.leave, + icon: Icons.unsubscribe_outlined, + isDestructiveAction: true, + ), + ], + ); + if (action == null) return; + switch (action) { + case ContextualRoomAction.mute: + await showFutureLoadingDialog( + context: context, + future: () => room.setPushRuleState(PushRuleState.dontNotify), + ); + break; + case ContextualRoomAction.unmute: + await showFutureLoadingDialog( + context: context, + future: () => room.setPushRuleState(PushRuleState.notify), + ); + break; + case ContextualRoomAction.leave: + await showFutureLoadingDialog( + context: context, + future: () => room.leave(), + ); + break; + } + } + + @override + Widget build(BuildContext context) { + final client = Matrix.of(context).client; + return StreamBuilder( + stream: client.onSync.stream + .where((syncUpdate) => syncUpdate.hasRoomUpdate), + builder: (context, snapshot) { + if (client.storiesRooms.isEmpty && client.contacts.isEmpty) { + return Container(); + } + if (client.storiesRooms.isEmpty) { + return ListTile( + leading: CircleAvatar( + radius: Avatar.defaultSize / 2, + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).textTheme.bodyText1?.color, + child: const Icon(Icons.add), + ), + title: const Text('Add to story'), + onTap: () => _addToStoryAction(context), + ); + } + return SizedBox( + height: 82, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 2), + scrollDirection: Axis.horizontal, + children: [ + _StoryButton( + label: 'Add to story', + onPressed: () => _addToStoryAction(context), + child: const Icon(Icons.add), + ), + ...client.storiesRooms.map( + (room) => _StoryButton( + label: room.creatorDisplayname, + child: Avatar( + mxContent: room + .getState(EventTypes.RoomCreate)! + .sender + .avatarUrl, + name: room.creatorDisplayname, + ), + unread: room.notificationCount > 0, + onPressed: () => _goToStoryAction(context, room.id), + onLongPressed: () => _contextualActions(context, room), + ), + ), + ], + ), + ); + }); + } +} + +extension on Room { + String get creatorDisplayname => + getState(EventTypes.RoomCreate)!.sender.calcDisplayname(); +} + +class _StoryButton extends StatelessWidget { + final Widget child; + final String label; + final void Function() onPressed; + final void Function()? onLongPressed; + final bool unread; + + const _StoryButton({ + required this.child, + required this.label, + required this.onPressed, + this.unread = false, + this.onLongPressed, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 74, + child: InkWell( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + onTap: onPressed, + onLongPress: onLongPressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + children: [ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + gradient: unread + ? const LinearGradient( + colors: [ + Colors.red, + Colors.purple, + Colors.orange, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + color: unread ? null : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(Avatar.defaultSize), + ), + child: CircleAvatar( + radius: Avatar.defaultSize / 2, + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).textTheme.bodyText1?.color, + child: child, + ), + ), + const SizedBox(height: 8), + Text( + label, + maxLines: 1, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/story/story_page.dart b/lib/pages/story/story_page.dart new file mode 100644 index 00000000..f85b7b50 --- /dev/null +++ b/lib/pages/story/story_page.dart @@ -0,0 +1,178 @@ +//@dart=2.12 + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.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/matrix_sdk_extensions.dart/client_stories_extension.dart'; +import 'package:fluffychat/utils/platform_infos.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; + + 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 (!mounted) { + _progressTimer?.cancel(); + return; + } + if (loadingMode) return; + setState(() { + progress = progress += _step; + }); + if (progress > maxProgress) { + skip(); + } + }); + } + + 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) + ..setLooping(true); + await videoPlayerController.initialize(); + videoPlayerController.play(); + return videoPlayerController; + } + + void skip() { + if (index + 1 >= max) { + VRouter.of(context).pop(); + return; + } + setState(() { + index++; + }); + _restartTimer(); + } + + DateTime _holdedAt = DateTime.fromMicrosecondsSinceEpoch(0); + + void hold(_) { + _holdedAt = DateTime.now(); + if (loadingMode) return; + _progressTimer?.cancel(); + } + + void unhold([_]) { + if (DateTime.now().millisecondsSinceEpoch - + _holdedAt.millisecondsSinceEpoch < + 200) { + skip(); + return; + } + _restartTimer(false); + } + + void loadingModeOn() => _setLoadingMode(true); + void loadingModeOff() => _setLoadingMode(false); + + final Map> _fileCache = {}; + + 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; + + String get title => + Matrix.of(context) + .client + .getRoomById(roomId) + ?.getState(EventTypes.RoomCreate) + ?.sender + .calcDisplayname() ?? + 'Story not found'; + + Future>? loadStory; + + Future> _loadStory() async { + final room = Matrix.of(context).client.getRoomById(roomId); + if (room == null) return []; + final timeline = await room.getTimeline(); + 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); + 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(); + } + events + .where((event) => {MessageTypes.Image, MessageTypes.Video} + .contains(event.messageType)) + .forEach((event) => downloadAndDecryptAttachment(event, + event.messageType == MessageTypes.Video && PlatformInfos.isMobile)); + return events; + } + + @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; + } +} diff --git a/lib/pages/story/story_view.dart b/lib/pages/story/story_view.dart new file mode 100644 index 00000000..48bd266c --- /dev/null +++ b/lib/pages/story/story_view.dart @@ -0,0 +1,187 @@ +//@dart=2.12 + +import 'package:flutter/material.dart'; + +import 'package:flutter_blurhash/flutter_blurhash.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; +import 'package:video_player/video_player.dart'; + +import 'package:fluffychat/pages/story/story_page.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/string_color.dart'; + +class StoryView extends StatelessWidget { + final StoryPageController controller; + const StoryView(this.controller, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(controller.title), + backgroundColor: + Theme.of(context).appBarTheme.backgroundColor?.withOpacity(0.5), + ), + extendBodyBehindAppBar: true, + body: FutureBuilder>( + future: controller.loadStory, + builder: (context, snapshot) { + final error = snapshot.error; + if (error != null) { + return Center(child: Text(error.toLocalizedString(context))); + } + final events = snapshot.data; + if (events == null) { + return const Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + )); + } + if (events.isEmpty) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Text( + L10n.of(context)!.thisUserHasNotPostedAnythingYet, + textAlign: TextAlign.center, + )), + ); + } + final event = events[controller.index]; + final backgroundColor = event.content.tryGet('body')?.color ?? + Theme.of(context).primaryColor; + final backgroundColorDark = + event.content.tryGet('body')?.darkColor ?? + Theme.of(context).primaryColorDark; + if (event.messageType == MessageTypes.Text) { + controller.loadingModeOff(); + } + return GestureDetector( + onTapDown: controller.hold, + onTapUp: controller.unhold, + child: Stack( + children: [ + if (event.messageType == MessageTypes.Video && + PlatformInfos.isMobile) + FutureBuilder( + future: controller.loadVideoController(event), + builder: (context, snapshot) { + final videoPlayerController = snapshot.data; + if (videoPlayerController == null) { + controller.loadingModeOn(); + return Container(); + } + controller.loadingModeOff(); + return Center(child: VideoPlayer(videoPlayerController)); + }, + ), + if (event.messageType == MessageTypes.Image || + (event.messageType == MessageTypes.Video && + !PlatformInfos.isMobile)) + Positioned( + top: 0, + bottom: 0, + left: 0, + right: 0, + child: FutureBuilder( + future: controller.downloadAndDecryptAttachment( + event, event.messageType == MessageTypes.Video), + builder: (context, snapshot) { + final matrixFile = snapshot.data; + if (matrixFile == null) { + controller.loadingModeOn(); + final hash = event.infoMap['xyz.amorgan.blurhash']; + return hash is String + ? BlurHash( + hash: hash, + imageFit: BoxFit.cover, + ) + : Container(); + } + controller.loadingModeOff(); + return Image.memory( + matrixFile.bytes, + fit: BoxFit.cover, + ); + }, + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + gradient: event.messageType == MessageTypes.Text + ? LinearGradient( + colors: [ + backgroundColor, + backgroundColorDark, + backgroundColor, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + ), + alignment: Alignment.center, + child: Text( + controller.loadingMode + ? L10n.of(context)!.loadingPleaseWait + : event.content.tryGet('body') ?? '', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + color: Colors.white, + backgroundColor: event.messageType == MessageTypes.Text + ? null + : Colors.black, + ), + ), + ), + Positioned( + bottom: 8, + left: 8, + right: 8, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < events.length; i++) + Container( + margin: const EdgeInsets.all(4), + width: 8, + height: 8, + decoration: BoxDecoration( + border: Border.all(color: Colors.black, width: 1), + color: i == controller.index + ? Colors.white + : Colors.grey.shade400, + borderRadius: BorderRadius.circular(8), + ), + ), + ], + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: LinearProgressIndicator( + color: Theme.of(context).primaryColor, + backgroundColor: Theme.of(context).colorScheme.surface, + value: controller.loadingMode + ? null + : controller.progress.inMilliseconds / + StoryPageController.maxProgress.inMilliseconds, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 6ebdeb19..1e9e0cf9 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -85,7 +85,12 @@ abstract class ClientManager { if (kIsWeb || PlatformInfos.isMobile || PlatformInfos.isLinux) KeyVerificationMethod.emoji, }, - importantStateEvents: {'im.ponies.room_emotes'}, + importantStateEvents: { + // To make room emotes work + 'im.ponies.room_emotes', + // To check which story room we can post in + EventTypes.RoomPowerLevels, + }, databaseBuilder: FlutterFluffyBoxDatabase.databaseBuilder, legacyDatabaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder, supportedLoginTypes: { diff --git a/lib/utils/matrix_sdk_extensions.dart/client_stories_extension.dart b/lib/utils/matrix_sdk_extensions.dart/client_stories_extension.dart new file mode 100644 index 00000000..a1d28c5d --- /dev/null +++ b/lib/utils/matrix_sdk_extensions.dart/client_stories_extension.dart @@ -0,0 +1,88 @@ +//@dart=2.12 + +import 'package:flutter/cupertino.dart'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:matrix/matrix.dart'; + +extension ClientStoriesExtension on Client { + static const String _storiesRoomType = 'msc3588.stories.stories-room'; + static const String _storiesBlockListType = 'msc3588.stories.block-list'; + + static const int lifeTimeInHours = 24; + static const int maxPostsPerStory = 20; + + List get contacts => rooms + .where((room) => room.isDirectChat) + .map((room) => room.getUserByMXIDSync(room.directChatMatrixID!)) + .toList(); + + List get storiesRooms => rooms + .where((room) => + room + .getState(EventTypes.RoomCreate) + ?.content + .tryGet('type') == + _storiesRoomType) + .toList(); + + Future> getUndecidedContactsForStories(Room? storiesRoom) async { + if (storiesRoom == null) return contacts; + final invitedContacts = + (await storiesRoom.requestParticipants()).map((user) => user.id); + final decidedContacts = storiesBlockList.toSet()..addAll(invitedContacts); + return contacts + .where((contact) => !decidedContacts.contains(contact.id)) + .toList(); + } + + List get storiesBlockList => + accountData[_storiesBlockListType]?.content.tryGetList('users') ?? + []; + + Future setStoriesBlockList(List users) => setAccountData( + userID!, + _storiesBlockListType, + {'users': users}, + ); + + Future createStoriesRoom([List? invite]) async { + final roomId = await createRoom( + creationContent: {"type": "msc3588.stories.stories-room"}, + preset: CreateRoomPreset.privateChat, + powerLevelContentOverride: {"events_default": 100}, + name: 'Stories from ${userID!.localpart}', + initialState: [ + StateEvent( + type: EventTypes.Encryption, + stateKey: '', + content: { + 'algorithm': 'm.megolm.v1.aes-sha2', + }, + ), + ], + invite: invite, + ); + if (getRoomById(roomId) == null) { + // Wait for room actually appears in sync + await onSync.stream + .firstWhere((sync) => sync.rooms?.join?.containsKey(roomId) ?? false); + } + } + + Future getStoriesRoom(BuildContext context) async { + final candidates = rooms.where((room) => + room.getState(EventTypes.RoomCreate)?.content.tryGet('type') == + _storiesRoomType && + room.ownPowerLevel >= 100); + if (candidates.isEmpty) return null; + if (candidates.length == 1) return candidates.single; + return await showModalActionSheet( + context: context, + actions: candidates + .map( + (room) => SheetAction(label: room.displayname, key: room), + ) + .toList()); + } +} diff --git a/pubspec.lock b/pubspec.lock index b34b5ce0..11400b68 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1595,12 +1595,12 @@ packages: source: hosted version: "2.1.1" video_player: - dependency: transitive + dependency: "direct main" description: name: video_player url: "https://pub.dartlang.org" source: hosted - version: "2.2.7" + version: "2.2.10" video_player_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fda514e9..7a297c77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: unifiedpush: ^3.0.1 universal_html: ^2.0.8 url_launcher: ^6.0.12 + video_player: ^2.2.10 vrouter: ^1.2.0+15 wakelock: ^0.5.6