From 895de76e70466616d1baa45f21fa0a3fdeb30ec7 Mon Sep 17 00:00:00 2001 From: Krille Date: Fri, 22 Dec 2023 17:15:14 +0100 Subject: [PATCH] refactor: Replace stories feature with presence status msg --- assets/l10n/intl_en.arb | 3 +- lib/config/routes.dart | 37 -- lib/pages/add_story/add_story.dart | 314 ----------- lib/pages/add_story/add_story_view.dart | 200 ------- lib/pages/add_story/invite_story_page.dart | 140 ----- lib/pages/chat_list/chat_list.dart | 13 +- lib/pages/chat_list/chat_list_body.dart | 20 +- lib/pages/chat_list/chat_list_header.dart | 14 +- .../chat_list/client_chooser_button.dart | 28 +- lib/pages/chat_list/status_msg_list.dart | 305 ++++++++++ lib/pages/chat_list/stories_header.dart | 361 ------------ .../settings_security_view.dart | 6 - .../settings_stories/settings_stories.dart | 97 ---- .../settings_stories_view.dart | 68 --- lib/pages/story/story_page.dart | 532 ------------------ lib/pages/story/story_view.dart | 412 -------------- .../user_bottom_sheet_view.dart | 21 + lib/utils/background_push.dart | 10 +- lib/utils/client_manager.dart | 2 - .../client_stories_extension.dart | 125 ---- lib/utils/story_theme_data.dart | 44 -- 21 files changed, 365 insertions(+), 2387 deletions(-) delete mode 100644 lib/pages/add_story/add_story.dart delete mode 100644 lib/pages/add_story/add_story_view.dart delete mode 100644 lib/pages/add_story/invite_story_page.dart create mode 100644 lib/pages/chat_list/status_msg_list.dart delete mode 100644 lib/pages/chat_list/stories_header.dart delete mode 100644 lib/pages/settings_stories/settings_stories.dart delete mode 100644 lib/pages/settings_stories/settings_stories_view.dart delete mode 100644 lib/pages/story/story_page.dart delete mode 100644 lib/pages/story/story_view.dart delete mode 100644 lib/utils/matrix_sdk_extensions/client_stories_extension.dart delete mode 100644 lib/utils/story_theme_data.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 739ac510..f26d4611 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2551,5 +2551,6 @@ "startConversation": "Start conversation", "commandHint_sendraw": "Send raw json", "databaseMigrationTitle": "Database is optimized", - "databaseMigrationBody": "Please wait. This may take a moment." + "databaseMigrationBody": "Please wait. This may take a moment.", + "leaveEmptyToClearStatus": "Leave empty to clear your status." } diff --git a/lib/config/routes.dart b/lib/config/routes.dart index ae4f85c9..5aaf5cb1 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -5,7 +5,6 @@ import 'package:flutter/cupertino.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/themes.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'; @@ -28,9 +27,7 @@ import 'package:fluffychat/pages/settings_ignore_list/settings_ignore_list.dart' import 'package:fluffychat/pages/settings_multiple_emotes/settings_multiple_emotes.dart'; import 'package:fluffychat/pages/settings_notifications/settings_notifications.dart'; import 'package:fluffychat/pages/settings_security/settings_security.dart'; -import 'package:fluffychat/pages/settings_stories/settings_stories.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; -import 'package:fluffychat/pages/story/story_page.dart'; import 'package:fluffychat/pages/tasks/tasks.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; @@ -113,32 +110,6 @@ abstract class AppRoutes { ), ), routes: [ - GoRoute( - path: 'stories/create', - pageBuilder: (context, state) => defaultPageBuilder( - context, - const AddStoryPage(), - ), - redirect: loggedOutRedirect, - ), - GoRoute( - path: 'stories/:roomid', - pageBuilder: (context, state) => defaultPageBuilder( - context, - const StoryPage(), - ), - redirect: loggedOutRedirect, - routes: [ - GoRoute( - path: 'share', - pageBuilder: (context, state) => defaultPageBuilder( - context, - const AddStoryPage(), - ), - redirect: loggedOutRedirect, - ), - ], - ), GoRoute( path: 'archive', pageBuilder: (context, state) => defaultPageBuilder( @@ -271,14 +242,6 @@ abstract class AppRoutes { const SettingsSecurity(), ), routes: [ - GoRoute( - path: 'stories', - pageBuilder: (context, state) => defaultPageBuilder( - context, - const SettingsStories(), - ), - redirect: loggedOutRedirect, - ), GoRoute( path: 'ignorelist', pageBuilder: (context, state) { diff --git a/lib/pages/add_story/add_story.dart b/lib/pages/add_story/add_story.dart deleted file mode 100644 index 6f96fdf6..00000000 --- a/lib/pages/add_story/add_story.dart +++ /dev/null @@ -1,314 +0,0 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; -import 'package:go_router/go_router.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:matrix/matrix.dart'; -import 'package:video_player/video_player.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/matrix_sdk_extensions/matrix_file_extension.dart'; -import 'package:fluffychat/utils/resize_image.dart'; -import 'package:fluffychat/utils/story_theme_data.dart'; -import 'package:fluffychat/utils/string_color.dart'; -import 'package:fluffychat/widgets/app_lock.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../utils/matrix_sdk_extensions/client_stories_extension.dart'; - -class AddStoryPage extends StatefulWidget { - const AddStoryPage({super.key}); - - @override - AddStoryController createState() => AddStoryController(); -} - -class AddStoryController extends State { - final TextEditingController controller = TextEditingController(); - final FocusNode focusNode = FocusNode(); - late Color backgroundColor; - late Color backgroundColorDark; - MatrixImageFile? image; - MatrixVideoFile? video; - - VideoPlayerController? videoPlayerController; - - bool get hasMedia => image != null || video != null; - - bool hasText = false; - - bool textFieldHasFocus = false; - - BoxFit fit = BoxFit.contain; - - int alignmentX = 0; - int alignmentY = 0; - - void toggleBoxFit() { - if (fit == BoxFit.contain) { - setState(() { - fit = BoxFit.cover; - }); - } else { - setState(() { - fit = BoxFit.contain; - }); - } - } - - void updateHasText(String text) { - if (hasText != text.isNotEmpty) { - setState(() { - hasText = text.isNotEmpty; - }); - } - } - - void importMedia() async { - final picked = await AppLock.of(context).pauseWhile( - FilePicker.platform.pickFiles( - type: FileType.image, - withData: true, - ), - ); - final file = picked?.files.firstOrNull; - if (file == null) return; - final matrixFile = MatrixImageFile( - bytes: file.bytes!, - name: file.name, - ); - setState(() { - image = matrixFile; - }); - } - - void capturePhoto() async { - final picked = await ImagePicker().pickImage( - source: ImageSource.camera, - ); - if (picked == null) return; - final matrixFile = await showFutureLoadingDialog( - context: context, - future: () async { - final bytes = await picked.readAsBytes(); - return MatrixImageFile( - bytes: bytes, - name: picked.name, - ); - }, - ); - - setState(() { - image = matrixFile.result; - }); - } - - void updateColor() { - final rand = Random().nextInt(1000).toString(); - setState(() { - backgroundColor = rand.color; - backgroundColorDark = rand.darkColor; - }); - } - - void captureVideo() async { - final picked = await ImagePicker().pickVideo( - source: ImageSource.camera, - ); - if (picked == null) return; - final bytes = await picked.readAsBytes(); - - setState(() { - video = MatrixVideoFile(bytes: bytes, name: picked.name); - videoPlayerController = VideoPlayerController.file(File(picked.path)) - ..setLooping(true); - }); - } - - void reset() => setState(() { - image = video = null; - alignmentX = alignmentY = 0; - controller.clear(); - }); - - void postStory() async { - if (video == null && image == null && controller.text.isEmpty) return; - final client = Matrix.of(context).client; - var 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; - storiesRoom ??= await client.getStoriesRoom(context); - } - - // Post story - final postResult = await showFutureLoadingDialog( - context: context, - future: () async { - if (storiesRoom == null) throw ('Stories room is null'); - var video = this.video?.detectFileType; - if (video != null) { - video = await video.resizeVideo(); - final thumbnail = await video.getVideoThumbnail(); - await storiesRoom.sendFileEvent( - video, - extraContent: { - 'body': controller.text, - StoryThemeData.contentKey: StoryThemeData( - fit: fit, - alignmentX: alignmentX, - alignmentY: alignmentY, - ).toJson(), - }, - thumbnail: thumbnail, - ); - return; - } - final image = this.image; - if (image != null) { - await storiesRoom.sendFileEvent( - image, - extraContent: { - 'body': controller.text, - StoryThemeData.contentKey: StoryThemeData( - fit: fit, - alignmentX: alignmentX, - alignmentY: alignmentY, - ).toJson(), - }, - ); - return; - } - await storiesRoom.sendEvent({ - 'msgtype': MessageTypes.Text, - 'body': controller.text, - StoryThemeData.contentKey: StoryThemeData( - color1: backgroundColor, - color2: backgroundColorDark, - fit: fit, - alignmentX: alignmentX, - alignmentY: alignmentY, - ).toJson(), - }); - }, - ); - if (postResult.error == null) { - context.pop(); - } - } - - void onVerticalDragUpdate(DragUpdateDetails details) { - final delta = details.primaryDelta; - if (delta == null) return; - if (delta > 0 && alignmentY < 100) { - setState(() { - alignmentY += 1; - }); - } else if (delta < 0 && alignmentY > -100) { - setState(() { - alignmentY -= 1; - }); - } - } - - void onHorizontalDragUpdate(DragUpdateDetails details) { - final delta = details.primaryDelta; - if (delta == null) return; - if (delta > 0 && alignmentX < 100) { - setState(() { - alignmentX += 1; - }); - } else if (delta < 0 && alignmentX > -100) { - setState(() { - alignmentX -= 1; - }); - } - } - - @override - void initState() { - super.initState(); - final rand = Random().nextInt(1000).toString(); - backgroundColor = rand.color; - backgroundColorDark = rand.darkColor; - focusNode.addListener(() { - if (textFieldHasFocus != focusNode.hasFocus) { - setState(() { - textFieldHasFocus = focusNode.hasFocus; - }); - } - }); - - final shareContent = Matrix.of(context).shareContent; - if (shareContent != null) { - controller.text = shareContent.tryGet('body') ?? ''; - final shareFile = shareContent.tryGet('file')?.detectFileType; - - if (shareFile is MatrixImageFile) { - setState(() { - image = shareFile; - }); - } else if (shareFile is MatrixVideoFile) { - setState(() { - video = shareFile; - }); - } - - final msgType = shareContent.tryGet('msgtype'); - if (msgType == MessageTypes.Image) { - Event( - content: shareContent, - type: EventTypes.Message, - room: Room(id: '!tmproom', client: Matrix.of(context).client), - eventId: 'tmpevent', - senderId: '@tmpsender:example', - originServerTs: DateTime.now(), - ).downloadAndDecryptAttachment().then((file) { - setState(() { - image = file.detectFileType as MatrixImageFile; - }); - }); - } else if (msgType == MessageTypes.Video) { - Event( - content: shareContent, - type: EventTypes.Message, - room: Room(id: '!tmproom', client: Matrix.of(context).client), - eventId: 'tmpevent', - senderId: '@tmpsender:example', - originServerTs: DateTime.now(), - ).downloadAndDecryptAttachment().then((file) { - setState(() { - video = file.detectFileType as MatrixVideoFile; - }); - }); - } - Matrix.of(context).shareContent = null; - } - } - - @override - void dispose() { - videoPlayerController?.dispose(); - super.dispose(); - } - - @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 deleted file mode 100644 index ea828aad..00000000 --- a/lib/pages/add_story/add_story_view.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:video_player/video_player.dart'; - -import 'add_story.dart'; - -class AddStoryView extends StatelessWidget { - final AddStoryController controller; - const AddStoryView(this.controller, {super.key}); - - @override - Widget build(BuildContext context) { - final video = controller.videoPlayerController; - - return Scaffold( - backgroundColor: Colors.blueGrey.shade900, - appBar: AppBar( - leading: const BackButton(color: Colors.white), - systemOverlayStyle: SystemUiOverlayStyle.light, - backgroundColor: Colors.transparent, - elevation: 0, - iconTheme: const IconThemeData(color: Colors.white), - title: Text( - L10n.of(context)!.addToStory, - style: const TextStyle( - color: Colors.white, - shadows: [ - Shadow( - color: Colors.black, - offset: Offset(0, 0), - blurRadius: 5, - ), - ], - ), - ), - actions: [ - if (controller.hasMedia) - IconButton( - icon: const Icon(Icons.fullscreen_outlined), - color: Colors.white, - onPressed: controller.toggleBoxFit, - ), - if (!controller.hasMedia) - IconButton( - icon: const Icon(Icons.color_lens_outlined), - color: Colors.white, - onPressed: controller.updateColor, - ), - IconButton( - icon: const Icon(Icons.delete_outlined), - color: Colors.white, - onPressed: controller.reset, - ), - ], - ), - extendBodyBehindAppBar: true, - body: GestureDetector( - onVerticalDragUpdate: controller.onVerticalDragUpdate, - onHorizontalDragUpdate: controller.onHorizontalDragUpdate, - child: Stack( - children: [ - if (video != null) - Padding( - padding: const EdgeInsets.symmetric(vertical: 80.0), - child: FutureBuilder( - future: video.initialize().then((_) => video.play()), - builder: (_, __) => Center(child: VideoPlayer(video)), - ), - ), - AnimatedContainer( - duration: const Duration(seconds: 1), - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 80.0, - ), - decoration: BoxDecoration( - image: controller.image == null - ? null - : DecorationImage( - image: MemoryImage(controller.image!.bytes), - fit: controller.fit, - opacity: 0.75, - ), - gradient: controller.hasMedia - ? null - : LinearGradient( - colors: [ - controller.backgroundColorDark, - controller.backgroundColor, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - child: Align( - alignment: Alignment( - controller.alignmentX / 100, - controller.alignmentY / 100, - ), - child: IntrinsicWidth( - child: TextField( - controller: controller.controller, - focusNode: controller.focusNode, - minLines: 1, - maxLines: 15, - autofocus: false, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 24, - color: Colors.white, - shadows: controller.hasMedia - ? const [ - Shadow( - color: Colors.black, - offset: Offset(5, 5), - blurRadius: 20, - ), - Shadow( - color: Colors.black, - offset: Offset(5, 5), - blurRadius: 20, - ), - Shadow( - color: Colors.black, - offset: Offset(-5, -5), - blurRadius: 20, - ), - Shadow( - color: Colors.black, - offset: Offset(-5, -5), - blurRadius: 20, - ), - ] - : null, - ), - onChanged: controller.updateHasText, - decoration: InputDecoration( - border: InputBorder.none, - hintText: controller.hasMedia - ? L10n.of(context)!.addDescription - : L10n.of(context)!.whatIsGoingOn, - filled: false, - hintStyle: TextStyle( - color: Colors.white.withOpacity(0.5), - backgroundColor: Colors.transparent, - ), - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - ), - ), - ), - ), - ), - ], - ), - ), - floatingActionButton: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!controller.hasMedia) ...[ - FloatingActionButton( - onPressed: controller.importMedia, - backgroundColor: controller.backgroundColorDark, - foregroundColor: Colors.white, - heroTag: null, - child: const Icon(Icons.photo_outlined), - ), - const SizedBox(width: 16), - FloatingActionButton( - onPressed: controller.capturePhoto, - backgroundColor: controller.backgroundColorDark, - foregroundColor: Colors.white, - heroTag: null, - child: const Icon(Icons.camera_alt_outlined), - ), - const SizedBox(width: 16), - FloatingActionButton( - onPressed: controller.captureVideo, - backgroundColor: controller.backgroundColorDark, - foregroundColor: Colors.white, - heroTag: null, - child: const Icon(Icons.video_camera_front_outlined), - ), - ], - if (controller.hasMedia || controller.hasText) ...[ - const SizedBox(width: 16), - FloatingActionButton( - onPressed: controller.postStory, - backgroundColor: Theme.of(context).colorScheme.surface, - foregroundColor: Theme.of(context).colorScheme.onSurface, - child: const Icon(Icons.send_rounded), - ), - ], - ], - ), - ); - } -} diff --git a/lib/pages/add_story/invite_story_page.dart b/lib/pages/add_story/invite_story_page.dart deleted file mode 100644 index c62a0b81..00000000 --- a/lib/pages/add_story/invite_story_page.dart +++ /dev/null @@ -1,140 +0,0 @@ -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:fluffychat/utils/localized_exception_extension.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/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, - super.key, - }); - - @override - InviteStoryPageState createState() => InviteStoryPageState(); -} - -class InviteStoryPageState extends State { - Set _undecided = {}; - final Set _invite = {}; - - void _inviteAction() async { - final confirmed = await showOkCancelAlertDialog( - context: context, - message: L10n.of(context)!.storyPrivacyWarning, - okLabel: L10n.of(context)!.iUnderstand, - cancelLabel: L10n.of(context)!.cancel, - ); - if (confirmed != OkCancelResult.ok) return; - final result = await showFutureLoadingDialog( - context: context, - future: () async { - final client = Matrix.of(context).client; - var room = await client.getStoriesRoom(context); - final inviteList = _invite.toList(); - if (room == null) { - room = await client.createStoriesRoom(inviteList.take(10).toList()); - if (inviteList.length > 10) { - inviteList.removeRange(0, 10); - } else { - inviteList.clear(); - } - } - for (final userId in inviteList) { - room.invite(userId); - } - - _undecided.removeAll(_invite); - _undecided.addAll(client.storiesBlockList); - 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) { - 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), - elevation: 0, - ), - body: Column( - children: [ - ListTile( - title: Text(L10n.of(context)!.whoCanSeeMyStoriesDesc), - leading: CircleAvatar( - backgroundColor: Theme.of(context).secondaryHeaderColor, - foregroundColor: Theme.of(context).colorScheme.secondary, - child: const Icon(Icons.lock), - ), - ), - const Divider(height: 1), - Expanded( - child: 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()), - ), - ); - }, - ), - ), - ], - ), - 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.send_rounded), - ), - ); - } -} diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 8828081b..3c43dee0 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -18,7 +18,6 @@ import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import '../../../utils/account_bundles.dart'; @@ -139,13 +138,11 @@ class ChatListController extends State bool Function(Room) getRoomFilterByActiveFilter(ActiveFilter activeFilter) { switch (activeFilter) { case ActiveFilter.allChats: - return (room) => !room.isSpace && !room.isStoryRoom; + return (room) => !room.isSpace; case ActiveFilter.groups: - return (room) => - !room.isSpace && !room.isDirectChat && !room.isStoryRoom; + return (room) => !room.isSpace && !room.isDirectChat; case ActiveFilter.messages: - return (room) => - !room.isSpace && room.isDirectChat && !room.isStoryRoom; + return (room) => !room.isSpace && room.isDirectChat; case ActiveFilter.spaces: return (r) => r.isSpace; } @@ -487,11 +484,15 @@ class ChatListController extends State useRootNavigator: false, context: context, title: L10n.of(context)!.setStatus, + message: L10n.of(context)!.leaveEmptyToClearStatus, okLabel: L10n.of(context)!.ok, cancelLabel: L10n.of(context)!.cancel, textFields: [ DialogTextField( hintText: L10n.of(context)!.statusExampleMessage, + maxLines: 6, + minLines: 1, + maxLength: 255, ), ], ); diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 520adaaa..4bed79f1 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -9,10 +9,9 @@ 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/search_title.dart'; import 'package:fluffychat/pages/chat_list/space_view.dart'; -import 'package:fluffychat/pages/chat_list/stories_header.dart'; +import 'package:fluffychat/pages/chat_list/status_msg_list.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -71,11 +70,6 @@ class ChatListViewBody extends StatelessWidget { ); } final rooms = controller.filteredRooms; - final displayStoriesHeader = { - ActiveFilter.allChats, - ActiveFilter.messages, - }.contains(controller.activeFilter) && - client.storiesRooms.isNotEmpty; return SafeArea( child: CustomScrollView( controller: controller.scrollController, @@ -158,16 +152,10 @@ class ChatListViewBody extends StatelessWidget { ), ), ), - SearchTitle( - title: L10n.of(context)!.stories, - icon: const Icon(Icons.camera_alt_outlined), - ), ], - if (displayStoriesHeader) - StoriesHeader( - key: const Key('stories_header'), - filter: controller.searchController.text, - ), + StatusMessageList( + onStatusEdit: controller.setStatus, + ), const ConnectionStatusHeader(), AnimatedContainer( height: controller.isTorBrowser ? 64 : 0, diff --git a/lib/pages/chat_list/chat_list_header.dart b/lib/pages/chat_list/chat_list_header.dart index 177aec5f..594081e3 100644 --- a/lib/pages/chat_list/chat_list_header.dart +++ b/lib/pages/chat_list/chat_list_header.dart @@ -18,7 +18,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { return SliverAppBar( floating: true, - toolbarHeight: Theme.of(context).appBarTheme.toolbarHeight ?? 56, + toolbarHeight: (Theme.of(context).appBarTheme.toolbarHeight ?? 56) + 16, pinned: FluffyThemes.isColumnMode(context) || selectMode != SelectMode.normal, scrolledUnderElevation: selectMode == SelectMode.normal ? 0 : null, @@ -55,19 +55,27 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { borderRadius: BorderRadius.circular(99), ), hintText: L10n.of(context)!.searchChatsRooms, + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), floatingLabelBehavior: FloatingLabelBehavior.never, prefixIcon: controller.isSearchMode ? IconButton( tooltip: L10n.of(context)!.cancel, icon: const Icon(Icons.close_outlined), onPressed: controller.cancelSearch, - color: Theme.of(context).colorScheme.onBackground, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, ) : IconButton( onPressed: controller.startSearch, icon: Icon( Icons.search_outlined, - color: Theme.of(context).colorScheme.onBackground, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, ), ), suffixIcon: controller.isSearchMode diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index f2394bb6..a23f44b5 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -28,16 +28,6 @@ class ClientChooserButton extends StatelessWidget { : 1, ); return >[ - PopupMenuItem( - value: SettingsAction.newStory, - child: Row( - children: [ - const Icon(Icons.camera_outlined), - const SizedBox(width: 18), - Text(L10n.of(context)!.yourStory), - ], - ), - ), PopupMenuItem( value: SettingsAction.newGroup, child: Row( @@ -58,6 +48,16 @@ class ClientChooserButton extends StatelessWidget { ], ), ), + PopupMenuItem( + value: SettingsAction.setStatus, + child: Row( + children: [ + const Icon(Icons.edit_outlined), + const SizedBox(width: 18), + Text(L10n.of(context)!.setStatus), + ], + ), + ), PopupMenuItem( value: SettingsAction.invite, child: Row( @@ -260,9 +260,6 @@ class ClientChooserButton extends StatelessWidget { if (consent != OkCancelResult.ok) return; context.go('/rooms/settings/addaccount'); break; - case SettingsAction.newStory: - context.go('/rooms/stories/create'); - break; case SettingsAction.newGroup: context.go('/rooms/newgroup'); break; @@ -278,6 +275,9 @@ class ClientChooserButton extends StatelessWidget { case SettingsAction.archive: context.go('/rooms/archive'); break; + case SettingsAction.setStatus: + controller.setStatus(); + break; } } } @@ -354,9 +354,9 @@ class ClientChooserButton extends StatelessWidget { enum SettingsAction { addAccount, - newStory, newGroup, newSpace, + setStatus, invite, settings, archive, diff --git a/lib/pages/chat_list/status_msg_list.dart b/lib/pages/chat_list/status_msg_list.dart new file mode 100644 index 00000000..a955d888 --- /dev/null +++ b/lib/pages/chat_list/status_msg_list.dart @@ -0,0 +1,305 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; +import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; +import 'package:fluffychat/utils/stream_extension.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class StatusMessageList extends StatelessWidget { + final void Function() onStatusEdit; + const StatusMessageList({ + required this.onStatusEdit, + super.key, + }); + + static const double height = 108; + + void _onStatusTab(BuildContext context, Profile profile) { + final client = Matrix.of(context).client; + if (profile.userId == client.userID) return onStatusEdit(); + + showAdaptiveBottomSheet( + context: context, + builder: (c) => UserBottomSheet( + profile: profile, + outerContext: context, + ), + ); + return; + } + + @override + Widget build(BuildContext context) { + final client = Matrix.of(context).client; + return StreamBuilder( + stream: client.onSync.stream.rateLimit(const Duration(seconds: 3)), + builder: (context, snapshot) { + return AnimatedSize( + duration: FluffyThemes.animationDuration, + curve: Curves.easeInOut, + child: FutureBuilder( + future: Future.wait( + client.interestingPresences + .map((userId) => client.fetchCurrentPresence(userId)), + ), + builder: (context, snapshot) { + final presences = + snapshot.data?.where(isInterestingPresence).toList(); + + // If no other presences than the own entry is interesting, we + // hide the presence header. + if (presences == null || presences.length <= 1) { + return const SizedBox.shrink(); + } + + // Make sure own entry is at the first position. Sort by last + // active instead. + presences.sort((a, b) { + if (a.userid == client.userID) return -1; + if (b.userid == client.userID) return 1; + return b.sortOrderDateTime.compareTo(a.sortOrderDateTime); + }); + + return SizedBox( + height: StatusMessageList.height, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8), + scrollDirection: Axis.horizontal, + itemCount: presences.length, + itemBuilder: (context, i) => PresenceAvatar( + presence: presences[i], + height: StatusMessageList.height, + onTap: (profile) => _onStatusTab(context, profile), + ), + ), + ); + }, + ), + ); + }, + ); + } +} + +class PresenceAvatar extends StatelessWidget { + final CachedPresence presence; + final double height; + final void Function(Profile) onTap; + + const PresenceAvatar({ + required this.presence, + required this.height, + required this.onTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + final avatarSize = height - 16 - 16; + final client = Matrix.of(context).client; + return FutureBuilder( + future: client.getProfileFromUserId(presence.userid), + builder: (context, snapshot) { + final profile = snapshot.data; + final displayName = profile?.displayName ?? + presence.userid.localpart ?? + presence.userid; + final statusMsg = presence.statusMsg; + + final statusMsgBubbleElevation = + Theme.of(context).appBarTheme.scrolledUnderElevation ?? 4; + final statusMsgBubbleShadowColor = + Theme.of(context).appBarTheme.shadowColor; + final statusMsgBubbleColor = Colors.white.withAlpha(245); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SizedBox( + width: avatarSize, + child: Column( + children: [ + HoverBuilder( + builder: (context, hovered) { + return AnimatedScale( + scale: hovered ? 1.15 : 1.0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + child: InkWell( + borderRadius: BorderRadius.circular(avatarSize), + onTap: profile == null ? null : () => onTap(profile), + child: Material( + borderRadius: BorderRadius.circular(avatarSize), + child: Stack( + children: [ + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + gradient: presence.gradient, + borderRadius: + BorderRadius.circular(avatarSize), + ), + child: Avatar( + name: displayName, + mxContent: profile?.avatarUrl, + size: avatarSize - 6, + ), + ), + if (presence.userid == client.userID) + Positioned( + right: 0, + bottom: 0, + child: SizedBox( + width: 24, + height: 24, + child: FloatingActionButton.small( + heroTag: null, + onPressed: () => onTap( + profile ?? + Profile(userId: presence.userid), + ), + child: const Icon( + Icons.add_outlined, + size: 16, + ), + ), + ), + ), + if (statusMsg != null) ...[ + Positioned( + left: 0, + top: 0, + right: 8, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Material( + elevation: statusMsgBubbleElevation, + shadowColor: statusMsgBubbleShadowColor, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 2, + ), + color: statusMsgBubbleColor, + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Text( + statusMsg, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.black, + fontSize: 10.5, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 26.0, + top: 4.0, + ), + child: Center( + child: SizedBox( + width: 12, + height: 12, + child: Material( + elevation: + statusMsgBubbleElevation, + shadowColor: + statusMsgBubbleShadowColor, + borderRadius: + BorderRadius.circular(99), + color: statusMsgBubbleColor, + ), + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ); + }, + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + displayName, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +extension on Client { + Set get interestingPresences { + final allHeroes = rooms.map((room) => room.summary.mHeroes).fold( + {}, + (previousValue, element) => previousValue..addAll(element ?? {}), + ); + allHeroes.add(userID!); + return allHeroes; + } +} + +bool isInterestingPresence(CachedPresence presence) => + !presence.presence.isOffline || (presence.statusMsg?.isNotEmpty ?? false); + +extension on CachedPresence { + DateTime get sortOrderDateTime => + lastActiveTimestamp ?? + (currentlyActive == true + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(0)); + LinearGradient get gradient => presence.isOnline == true + ? LinearGradient( + colors: [ + Colors.green, + Colors.green.shade200, + Colors.green.shade900, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : presence.isUnavailable + ? LinearGradient( + colors: [ + Colors.yellow, + Colors.yellow.shade200, + Colors.yellow.shade900, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : LinearGradient( + colors: [ + Colors.grey, + Colors.grey.shade200, + Colors.grey.shade900, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); +} diff --git a/lib/pages/chat_list/stories_header.dart b/lib/pages/chat_list/stories_header.dart deleted file mode 100644 index d5325b91..00000000 --- a/lib/pages/chat_list/stories_header.dart +++ /dev/null @@ -1,361 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../config/themes.dart'; - -enum ContextualRoomAction { - mute, - unmute, - leave, -} - -class StoriesHeader extends StatelessWidget { - final String filter; - - const StoriesHeader({required this.filter, super.key}); - - void _addToStoryAction(BuildContext context) => - context.go('/rooms/stories/create'); - - void _goToStoryAction(BuildContext context, String roomId) async { - final room = Matrix.of(context).client.getRoomById(roomId); - if (room == null) return; - if (room.membership != Membership.join) { - final result = await showFutureLoadingDialog( - context: context, - future: room.join, - ); - if (result.error != null) return; - } - context.go('/rooms/stories/$roomId'); - } - - void _contextualActions(BuildContext context, Room room) async { - final action = await showModalActionSheet( - cancelLabel: L10n.of(context)!.cancel, - 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; - if (Matrix.of(context).shareContent != null) { - return ListTile( - leading: CircleAvatar( - radius: Avatar.defaultSize / 2, - backgroundColor: Theme.of(context).colorScheme.surface, - foregroundColor: Theme.of(context).textTheme.bodyLarge?.color, - child: const Icon(Icons.camera_alt_outlined), - ), - title: Text(L10n.of(context)!.addToStory), - onTap: () => _addToStoryAction(context), - ); - } - final ownStoryRoom = client.storiesRooms - .firstWhereOrNull((r) => r.creatorId == client.userID); - final stories = [ - if (ownStoryRoom != null) ownStoryRoom, - ...client.storiesRooms..remove(ownStoryRoom), - ]; - return SizedBox( - height: 104, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12), - scrollDirection: Axis.horizontal, - itemCount: stories.length, - itemBuilder: (context, i) { - final room = stories[i]; - final creator = room - .unsafeGetUserFromMemoryOrFallback(room.creatorId ?? 'Unknown'); - final userId = room.creatorId; - final displayname = creator.calcDisplayname(); - final avatarUrl = creator.avatarUrl; - if (!displayname.toLowerCase().contains(filter.toLowerCase())) { - return const SizedBox.shrink(); - } - return _StoryButton( - profile: Profile( - displayName: displayname, - avatarUrl: avatarUrl, - userId: userId ?? 'Unknown', - ), - lastMessage: room.hasPosts - ? room.lastEvent?.calcLocalizedBodyFallback( - MatrixLocals( - L10n.of(context)!, - ), - ) - : null, - heroTag: 'stories_${room.id}', - hasPosts: room.hasPosts || room == ownStoryRoom, - showEditFab: userId == client.userID, - unread: room.membership == Membership.invite || - (room.hasNewMessages && room.hasPosts), - onPressed: () => _goToStoryAction(context, room.id), - onLongPressed: () => _contextualActions(context, room), - ); - }, - ), - ); - } -} - -extension on Room { - bool get hasPosts { - if (membership == Membership.invite) return true; - final lastEvent = this.lastEvent; - if (lastEvent == null) return false; - if (lastEvent.type != EventTypes.Message) return false; - if (DateTime.now().difference(lastEvent.originServerTs).inHours > - ClientStoriesExtension.lifeTimeInHours) { - return false; - } - return true; - } -} - -class _StoryButton extends StatefulWidget { - final Profile profile; - final bool showEditFab; - final bool unread; - final bool hasPosts; - final void Function() onPressed; - final void Function()? onLongPressed; - final String heroTag; - final String? lastMessage; - - const _StoryButton({ - required this.profile, - required this.onPressed, - required this.heroTag, - required this.lastMessage, - this.showEditFab = false, - this.hasPosts = true, - this.unread = false, - this.onLongPressed, - }); - - @override - State<_StoryButton> createState() => _StoryButtonState(); -} - -class _StoryButtonState extends State<_StoryButton> { - bool _hovered = false; - - void _onHover(bool hover) { - if (hover == _hovered) return; - setState(() { - _hovered = hover; - }); - } - - @override - Widget build(BuildContext context) { - final lastMessage = widget.lastMessage; - final lastMessageBubbleElevation = - Theme.of(context).appBarTheme.scrolledUnderElevation ?? 4; - final lastMessageBubbleShadowColor = - Theme.of(context).appBarTheme.shadowColor; - final lastMessageBubbleColor = Colors.white.withAlpha(245); - return SizedBox( - width: 82, - child: InkWell( - onHover: _onHover, - borderRadius: BorderRadius.circular(7), - onTap: widget.onPressed, - onLongPress: widget.onLongPressed, - child: Opacity( - opacity: widget.hasPosts ? 1 : 0.4, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Column( - children: [ - const SizedBox(height: 8), - AnimatedScale( - scale: _hovered ? 1.15 : 1.0, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - child: Material( - borderRadius: BorderRadius.circular(Avatar.defaultSize), - child: Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - gradient: widget.unread - ? const LinearGradient( - colors: [ - Colors.red, - Colors.purple, - Colors.orange, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) - : null, - color: widget.unread - ? null - : Theme.of(context).colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(Avatar.defaultSize), - ), - child: Stack( - children: [ - Hero( - tag: widget.heroTag, - child: Avatar( - mxContent: widget.profile.avatarUrl, - name: widget.profile.displayName, - size: 72, - fontSize: 26, - ), - ), - if (widget.showEditFab) - Positioned( - right: 0, - bottom: 0, - child: SizedBox( - width: 24, - height: 24, - child: FloatingActionButton.small( - heroTag: null, - onPressed: () => - context.go('/rooms/stories/create'), - child: const Icon( - Icons.add_outlined, - size: 16, - ), - ), - ), - ), - if (lastMessage != null) ...[ - Positioned( - left: 0, - top: 0, - right: 8, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Material( - elevation: lastMessageBubbleElevation, - shadowColor: lastMessageBubbleShadowColor, - borderRadius: BorderRadius.circular( - AppConfig.borderRadius / 2, - ), - color: lastMessageBubbleColor, - child: Padding( - padding: const EdgeInsets.all(2.0), - child: Text( - lastMessage, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.black, - fontSize: 11, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only( - left: 26.0, - top: 4.0, - ), - child: Center( - child: SizedBox( - width: 12, - height: 12, - child: Material( - elevation: lastMessageBubbleElevation, - shadowColor: - lastMessageBubbleShadowColor, - borderRadius: - BorderRadius.circular(99), - color: lastMessageBubbleColor, - ), - ), - ), - ), - ], - ), - ), - ], - ], - ), - ), - ), - ), - Center( - child: Text( - widget.profile.displayName ?? '', - maxLines: 1, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - fontWeight: widget.unread ? FontWeight.bold : null, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -extension on Room { - String? get creatorId => getState(EventTypes.RoomCreate)?.senderId; -} diff --git a/lib/pages/settings_security/settings_security_view.dart b/lib/pages/settings_security/settings_security_view.dart index c7649496..7a82483a 100644 --- a/lib/pages/settings_security/settings_security_view.dart +++ b/lib/pages/settings_security/settings_security_view.dart @@ -22,12 +22,6 @@ class SettingsSecurityView extends StatelessWidget { child: MaxWidthBody( child: Column( children: [ - ListTile( - leading: const Icon(Icons.camera_outlined), - trailing: const Icon(Icons.chevron_right_outlined), - title: Text(L10n.of(context)!.whoCanSeeMyStories), - onTap: () => context.go('/rooms/settings/security/stories'), - ), ListTile( leading: const Icon(Icons.block_outlined), trailing: const Icon(Icons.chevron_right_outlined), diff --git a/lib/pages/settings_stories/settings_stories.dart b/lib/pages/settings_stories/settings_stories.dart deleted file mode 100644 index 16e9208b..00000000 --- a/lib/pages/settings_stories/settings_stories.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:future_loading_dialog/future_loading_dialog.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pages/settings_stories/settings_stories_view.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../utils/matrix_sdk_extensions/client_stories_extension.dart'; - -class SettingsStories extends StatefulWidget { - const SettingsStories({super.key}); - - @override - SettingsStoriesController createState() => SettingsStoriesController(); -} - -class SettingsStoriesController extends State { - final Map users = {}; - - Room? _storiesRoom; - - Future? loadUsers; - - bool noStoriesRoom = false; - - Future toggleUser(User user) async { - final room = _storiesRoom; - if (room == null) return; - - if (users[user] ?? false) { - // Kick user from stories room and add to block list - final blockList = room.client.storiesBlockList; - blockList.add(user.id); - await showFutureLoadingDialog( - context: context, - future: () async { - await user.kick(); - await room.client.setStoriesBlockList(blockList.toSet().toList()); - setState(() { - users[user] = false; - }); - }, - ); - return; - } - - // Invite user to stories room and remove from block list - final blockList = room.client.storiesBlockList; - blockList.remove(user.id); - await showFutureLoadingDialog( - context: context, - future: () async { - await room.client.setStoriesBlockList(blockList); - await room.invite(user.id); - setState(() { - users[user] = true; - }); - }, - ); - return; - } - - Future _loadUsers() async { - final room = - _storiesRoom = await Matrix.of(context).client.getStoriesRoom(context); - if (room == null) { - noStoriesRoom = true; - return; - } - final users = await room.requestParticipants(); - users.removeWhere((u) => u.id == room.client.userID); - final contacts = Matrix.of(context) - .client - .contacts - .where((contact) => !users.any((u) => u.id == contact.id)); - for (final user in contacts) { - this.users[user] = false; - } - for (final user in users) { - this.users[user] = true; - } - return; - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - loadUsers = _loadUsers(); - }); - }); - } - - @override - Widget build(BuildContext context) => SettingsStoriesView(this); -} diff --git a/lib/pages/settings_stories/settings_stories_view.dart b/lib/pages/settings_stories/settings_stories_view.dart deleted file mode 100644 index f5d71fab..00000000 --- a/lib/pages/settings_stories/settings_stories_view.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -import 'package:fluffychat/pages/settings_stories/settings_stories.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:fluffychat/widgets/avatar.dart'; - -class SettingsStoriesView extends StatelessWidget { - final SettingsStoriesController controller; - const SettingsStoriesView(this.controller, {super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(L10n.of(context)!.whoCanSeeMyStories), - elevation: 0, - ), - body: Column( - children: [ - ListTile( - title: Text(L10n.of(context)!.whoCanSeeMyStoriesDesc), - leading: CircleAvatar( - backgroundColor: Theme.of(context).secondaryHeaderColor, - foregroundColor: Theme.of(context).colorScheme.secondary, - child: const Icon(Icons.lock), - ), - ), - const Divider(height: 1), - Expanded( - child: FutureBuilder( - future: controller.loadUsers, - builder: (context, snapshot) { - final error = snapshot.error; - if (error != null) { - return Center(child: Text(error.toLocalizedString(context))); - } - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ); - } - return ListView.builder( - itemCount: controller.users.length, - itemBuilder: (context, i) { - final user = controller.users.keys.toList()[i]; - return SwitchListTile.adaptive( - value: controller.users[user] ?? false, - onChanged: (_) => controller.toggleUser(user), - secondary: Avatar( - mxContent: user.avatarUrl, - name: user.calcDisplayname(), - ), - title: Text(user.calcDisplayname()), - ); - }, - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/story/story_page.dart b/lib/pages/story/story_page.dart deleted file mode 100644 index 1a0333c2..00000000 --- a/lib/pages/story/story_page.dart +++ /dev/null @@ -1,532 +0,0 @@ -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:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:video_player/video_player.dart'; - -import 'package:fluffychat/pages/story/story_view.dart'; -import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; -import 'package:fluffychat/utils/date_time_extension.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/room_status_extension.dart'; -import 'package:fluffychat/utils/story_theme_data.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class StoryPage extends StatefulWidget { - const StoryPage({super.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; - StoryThemeData get storyThemeData => StoryThemeData.fromJson( - currentEvent?.content - .tryGetMap(StoryThemeData.contentKey) ?? - {}, - ); - - bool replyLoading = false; - bool _modalOpened = false; - - VideoPlayerController? _videoPlayerController; - - void replyEmojiAction() async { - if (replyLoading) return; - _modalOpened = true; - await showAdaptiveBottomSheet( - 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; - if (message.isEmpty) return; - 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, - eventId: currentEvent.eventId, - ) ?? - []; - } - - void share() async { - Matrix.of(context).shareContent = currentEvent?.content; - hold(); - context.go('share'); - } - - void displaySeenByUsers() async { - _modalOpened = true; - await showAdaptiveBottomSheet( - 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 => GoRouterState.of(context).pathParameters['roomid'] ?? ''; - - Future? loadVideoControllerFuture; - - Future loadVideoController(Event event) async { - try { - final matrixFile = await event.downloadAndDecryptAttachment(); - if (!mounted) return null; - final tmpDirectory = await getTemporaryDirectory(); - final fileName = - event.content.tryGet('filename') ?? 'unknown_story_video.mp4'; - final file = File('${tmpDirectory.path}/$fileName'); - await file.writeAsBytes(matrixFile.bytes); - if (!mounted) return null; - final videoPlayerController = - _videoPlayerController = VideoPlayerController.file(file); - await videoPlayerController.initialize(); - await videoPlayerController.play(); - return videoPlayerController; - } catch (e, s) { - Logs().w('Unable to load video story. Try again...', e, s); - await Future.delayed(const Duration(seconds: 3)); - return loadVideoController(event); - } - } - - void skip() { - if (index + 1 >= max) { - if (isOwnStory) { - context.go('/rooms/stories/create'); - } else { - context.go('/rooms'); - } - return; - } - setState(() { - _videoPlayerController?.dispose(); - _videoPlayerController = null; - loadVideoControllerFuture = null; - index++; - }); - _restartTimer(); - maybeSetReadMarker(); - } - - DateTime _holdedAt = DateTime.fromMicrosecondsSinceEpoch(0); - - bool isHold = false; - - @override - void dispose() { - _videoPlayerController?.dispose(); - super.dispose(); - } - - 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 _delete() async { - final event = currentEvent; - if (event == null) return; - _modalOpened = true; - if (await showOkCancelAlertDialog( - context: context, - title: L10n.of(context)!.deleteMessage, - message: L10n.of(context)!.areYouSure, - okLabel: L10n.of(context)!.yes, - cancelLabel: L10n.of(context)!.cancel, - ) != - OkCancelResult.ok) { - return; - } - await showFutureLoadingDialog( - context: context, - future: event.redactEvent, - ); - setState(() { - events.remove(event); - _modalOpened = false; - }); - } - - 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) - ?.senderFromMemoryOrFallback - .avatarUrl; - - String get title => - Matrix.of(context) - .client - .getRoomById(roomId) - ?.getState(EventTypes.RoomCreate) - ?.senderFromMemoryOrFallback - .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 && - !e.redacted && - e.status == EventStatus.synced, - ) - .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; - if (index == events.length - 1) { - timeline!.setReadMarker(); - return; - } - if (!currentSeenByUsers.any((u) => u.id == u.room.client.userID)) { - timeline!.setReadMarker(eventId: currentEvent.eventId); - } - } - - void onPopupStoryAction(PopupStoryAction action) async { - switch (action) { - case PopupStoryAction.report: - _report(); - break; - case PopupStoryAction.delete: - _delete(); - break; - case PopupStoryAction.message: - final roomIdResult = await showFutureLoadingDialog( - context: context, - future: () => - currentEvent!.senderFromMemoryOrFallback.startDirectChat(), - ); - if (roomIdResult.error != null) return; - context.go('/rooms/${roomIdResult.result!}'); - - break; - } - } - - @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, - delete, - message, -} diff --git a/lib/pages/story/story_view.dart b/lib/pages/story/story_view.dart deleted file mode 100644 index 913becfb..00000000 --- a/lib/pages/story/story_view.dart +++ /dev/null @@ -1,412 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:flutter_blurhash/flutter_blurhash.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; -import 'package:matrix/matrix.dart'; -import 'package:video_player/video_player.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pages/story/story_page.dart'; -import 'package:fluffychat/utils/date_time_extension.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/string_color.dart'; -import 'package:fluffychat/utils/url_launcher.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import '../../config/themes.dart'; - -class StoryView extends StatelessWidget { - final StoryPageController controller; - const StoryView(this.controller, {super.key}); - - static const List textShadows = [ - Shadow( - color: Colors.black, - offset: Offset(5, 5), - blurRadius: 20, - ), - Shadow( - color: Colors.black, - offset: Offset(5, 5), - blurRadius: 20, - ), - Shadow( - color: Colors.black, - offset: Offset(-5, -5), - blurRadius: 20, - ), - Shadow( - color: Colors.black, - offset: Offset(-5, -5), - blurRadius: 20, - ), - ]; - - @override - Widget build(BuildContext context) { - final currentEvent = controller.currentEvent; - return Scaffold( - backgroundColor: Colors.blueGrey.shade900, - appBar: AppBar( - titleSpacing: 0, - leading: IconButton( - color: Colors.white, - icon: const Icon(Icons.close), - onPressed: Navigator.of(context).pop, - ), - title: ListTile( - contentPadding: EdgeInsets.zero, - title: Text( - controller.title, - style: const TextStyle( - color: Colors.white, - shadows: [ - Shadow( - color: Colors.black, - offset: Offset(0, 0), - blurRadius: 5, - ), - ], - ), - ), - subtitle: currentEvent != null - ? Text( - currentEvent.originServerTs.localizedTime(context), - style: const TextStyle( - color: Colors.white70, - shadows: [ - Shadow( - color: Colors.black, - offset: Offset(0, 0), - blurRadius: 5, - ), - ], - ), - ) - : null, - leading: Hero( - tag: 'stories_${controller.roomId}', - child: Avatar( - mxContent: controller.avatar, - name: controller.title, - ), - ), - ), - actions: currentEvent == null - ? null - : [ - if (!controller.isOwnStory) - IconButton( - color: Colors.white, - icon: Icon(Icons.adaptive.share_outlined), - onPressed: controller.share, - ), - PopupMenuButton( - color: Colors.white, - onSelected: controller.onPopupStoryAction, - icon: Icon( - Icons.adaptive.more_outlined, - color: Colors.white, - ), - itemBuilder: (context) => [ - if (controller.currentEvent?.canRedact ?? false) - PopupMenuItem( - value: PopupStoryAction.delete, - child: Text(L10n.of(context)!.delete), - ), - PopupMenuItem( - value: PopupStoryAction.report, - child: Text(L10n.of(context)!.reportMessage), - ), - if (!controller.isOwnStory) - PopupMenuItem( - value: PopupStoryAction.message, - child: Text(L10n.of(context)!.sendAMessage), - ), - ], - ), - ], - systemOverlayStyle: SystemUiOverlayStyle.light, - iconTheme: const IconThemeData(color: Colors.white), - elevation: 0, - backgroundColor: Colors.transparent, - ), - 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 = controller.events; - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ); - } - if (events.isEmpty) { - return Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Avatar( - mxContent: controller.avatar, - name: controller.title, - size: 128, - fontSize: 64, - ), - const SizedBox(height: 32), - Text( - L10n.of(context)!.thisUserHasNotPostedAnythingYet, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 20, - color: Colors.white, - ), - ), - ], - ), - ); - } - final event = events[controller.index]; - final backgroundColor = controller.storyThemeData.color1 ?? - event.content.tryGet('body')?.color ?? - Theme.of(context).primaryColor; - final backgroundColorDark = controller.storyThemeData.color2 ?? - event.content.tryGet('body')?.darkColor ?? - Theme.of(context).primaryColorDark; - if (event.messageType == MessageTypes.Text) { - controller.loadingModeOff(); - } - final hash = event.infoMap['xyz.amorgan.blurhash']; - return Stack( - children: [ - if (hash is String) - BlurHash( - hash: hash, - imageFit: BoxFit.cover, - ), - if ({MessageTypes.Video, MessageTypes.Audio} - .contains(event.messageType) && - PlatformInfos.isMobile) - Positioned( - top: 80, - bottom: 64, - left: 0, - right: 0, - child: FutureBuilder( - future: controller.loadVideoControllerFuture ??= - controller.loadVideoController(event), - builder: (context, snapshot) { - final videoPlayerController = snapshot.data; - if (videoPlayerController == null) { - controller.loadingModeOn(); - return const SizedBox.shrink(); - } - controller.loadingModeOff(); - return Center(child: VideoPlayer(videoPlayerController)); - }, - ), - ), - if (event.messageType == MessageTypes.Image || - (event.messageType == MessageTypes.Video && - !PlatformInfos.isMobile)) - FutureBuilder( - future: controller.downloadAndDecryptAttachment( - event, - event.messageType == MessageTypes.Video, - ), - builder: (context, snapshot) { - final matrixFile = snapshot.data; - if (matrixFile == null) { - controller.loadingModeOn(); - return const SizedBox.shrink(); - } - controller.loadingModeOff(); - return Container( - constraints: const BoxConstraints.expand(), - alignment: controller.storyThemeData.fit == BoxFit.cover - ? null - : Alignment.center, - child: Image.memory( - matrixFile.bytes, - fit: controller.storyThemeData.fit, - ), - ); - }, - ), - GestureDetector( - onTapDown: controller.hold, - onTapUp: controller.unhold, - onTapCancel: controller.unhold, - onVerticalDragStart: controller.hold, - onVerticalDragEnd: controller.unhold, - onHorizontalDragStart: controller.hold, - onHorizontalDragEnd: controller.unhold, - child: AnimatedContainer( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 80, - ), - decoration: BoxDecoration( - gradient: event.messageType == MessageTypes.Text - ? LinearGradient( - colors: [ - backgroundColorDark, - backgroundColor, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ) - : null, - ), - alignment: Alignment( - controller.storyThemeData.alignmentX.toDouble() / 100, - controller.storyThemeData.alignmentY.toDouble() / 100, - ), - child: SafeArea( - child: Linkify( - text: controller.loadingMode - ? L10n.of(context)!.loadingPleaseWait - : event.content.tryGet('body') ?? '', - textAlign: TextAlign.center, - options: const LinkifyOptions(humanize: false), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - linkStyle: TextStyle( - fontSize: 24, - color: Colors.blue.shade50, - decoration: TextDecoration.underline, - decorationColor: Colors.blue.shade50, - shadows: event.messageType == MessageTypes.Text - ? null - : textShadows, - ), - style: TextStyle( - fontSize: 24, - color: Colors.white, - shadows: event.messageType == MessageTypes.Text - ? null - : textShadows, - ), - ), - ), - ), - ), - Positioned( - top: 4, - left: 4, - right: 4, - child: SafeArea( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (var i = 0; i < events.length; i++) - Expanded( - child: i == controller.index - ? LinearProgressIndicator( - color: Colors.white, - minHeight: 2, - backgroundColor: - Colors.white.withOpacity(0.25), - value: controller.loadingMode - ? null - : controller.progress.inMilliseconds / - StoryPageController - .maxProgress.inMilliseconds, - ) - : Container( - margin: const EdgeInsets.all(4), - height: 2, - color: i < controller.index - ? Colors.white - : Colors.white.withOpacity(0.25), - ), - ), - ], - ), - ), - ), - if (!controller.isOwnStory && currentEvent != null) - Positioned( - bottom: 8, - left: 8, - right: 8, - child: SafeArea( - child: Material( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(AppConfig.borderRadius), - bottomRight: Radius.circular(AppConfig.borderRadius), - ), - shadowColor: Colors.black.withAlpha(64), - clipBehavior: Clip.hardEdge, - elevation: 4, - child: TextField( - focusNode: controller.replyFocus, - controller: controller.replyController, - onSubmitted: controller.replyAction, - textInputAction: TextInputAction.send, - readOnly: controller.replyLoading, - decoration: InputDecoration( - contentPadding: - const EdgeInsets.fromLTRB(0, 16, 0, 16), - hintText: L10n.of(context)!.reply, - prefixIcon: IconButton( - onPressed: controller.replyEmojiAction, - icon: const Icon(Icons.emoji_emotions_outlined), - ), - suffixIcon: controller.replyLoading - ? const SizedBox( - width: 16, - height: 16, - child: Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ), - ) - : IconButton( - onPressed: controller.replyAction, - icon: const Icon(Icons.send_outlined), - ), - fillColor: Theme.of(context).colorScheme.background, - ), - ), - ), - ), - ), - if (controller.isOwnStory && - controller.currentSeenByUsers.isNotEmpty) - Positioned( - bottom: 16, - left: 16, - right: 16, - child: SafeArea( - child: Center( - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.surface, - ), - onPressed: controller.displaySeenByUsers, - icon: const Icon(Icons.visibility_outlined), - label: Text(controller.seenByUsersTitle), - ), - ), - ), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart index 874748f8..ff463151 100644 --- a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart +++ b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/presence_builder.dart'; import '../../widgets/matrix.dart'; @@ -195,6 +197,25 @@ class UserBottomSheetView extends StatelessWidget { ), ), ), + PresenceBuilder( + userId: userId, + client: client, + builder: (context, presence) { + final status = presence?.statusMsg; + if (status == null || status.isEmpty) { + return const SizedBox.shrink(); + } + return ListTile( + title: SelectableLinkify( + text: status, + style: const TextStyle(fontSize: 16), + options: const LinkifyOptions(humanize: false), + linkStyle: const TextStyle(color: Colors.blueAccent), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + ), + ); + }, + ), if (controller.widget.onMention != null) ListTile( leading: const Icon(Icons.alternate_email_outlined), diff --git a/lib/utils/background_push.dart b/lib/utils/background_push.dart index 8e67ae92..2576d911 100644 --- a/lib/utils/background_push.dart +++ b/lib/utils/background_push.dart @@ -31,7 +31,6 @@ import 'package:http/http.dart' as http; import 'package:matrix/matrix.dart'; import 'package:unifiedpush/unifiedpush.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart'; import 'package:fluffychat/utils/push_helper.dart'; import 'package:fluffychat/widgets/fluffy_chat_app.dart'; import '../config/app_config.dart'; @@ -314,14 +313,7 @@ class BackgroundPush { } await client.roomsLoading; await client.accountDataLoading; - final isStory = client - .getRoomById(roomId) - ?.getState(EventTypes.RoomCreate) - ?.content - .tryGet('type') == - ClientStoriesExtension.storiesRoomType; - FluffyChatApp.router - .go('/${isStory ? 'rooms/stories' : 'rooms'}/$roomId'); + FluffyChatApp.router.go('/rooms/$roomId'); } catch (e, s) { Logs().e('[Push] Failed to open room', e, s); } diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 77e815a5..fced546a 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -111,8 +111,6 @@ abstract class ClientManager { importantStateEvents: { // To make room emotes work 'im.ponies.room_emotes', - // To check which story room we can post in - EventTypes.RoomPowerLevels, }, logLevel: kReleaseMode ? Level.warning : Level.verbose, databaseBuilder: flutterMatrixSdkDatabaseBuilder, diff --git a/lib/utils/matrix_sdk_extensions/client_stories_extension.dart b/lib/utils/matrix_sdk_extensions/client_stories_extension.dart deleted file mode 100644 index dd11af0f..00000000 --- a/lib/utils/matrix_sdk_extensions/client_stories_extension.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.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.unsafeGetUserFromMemoryOrFallback(room.directChatMatrixID!), - ) - .toList(); - - List get storiesRooms => - rooms.where((room) => room.isStoryRoom).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}', - topic: - 'This is a room for stories sharing, not unlike the similarly named features in other messaging networks. For best experience please use FluffyChat or minesTrix. Feature development can be followed on: https://github.com/matrix-org/matrix-doc/pull/3588', - initialState: [ - StateEvent( - type: EventTypes.Encryption, - stateKey: '', - content: { - 'algorithm': 'm.megolm.v1.aes-sha2', - }, - ), - StateEvent( - type: 'm.room.retention', - stateKey: '', - content: { - 'min_lifetime': 86400000, - 'max_lifetime': 86400000, - }, - ), - ], - invite: invite, - ); - if (getRoomById(roomId) == null) { - // Wait for room actually appears in sync and is encrypted. This is a - // workaround for https://github.com/krille-chan/fluffychat/issues/520 - await onSync.stream.firstWhere( - (sync) => - sync.rooms?.join?[roomId]?.state - ?.any((state) => state.type == EventTypes.Encrypted) ?? - false, - ); - } - final room = getRoomById(roomId); - if (room == null || !room.encrypted) { - throw Exception( - 'Unable to create and wait for encrypted room to appear in Sync.', - ); - } - return room; - } - - 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.getLocalizedDisplayname( - MatrixLocals(L10n.of(context)!), - ), - key: room, - ), - ) - .toList(), - ); - } -} - -extension StoryRoom on Room { - bool get isStoryRoom => - getState(EventTypes.RoomCreate)?.content.tryGet('type') == - ClientStoriesExtension.storiesRoomType; -} diff --git a/lib/utils/story_theme_data.dart b/lib/utils/story_theme_data.dart deleted file mode 100644 index 9006dcc0..00000000 --- a/lib/utils/story_theme_data.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:matrix/matrix.dart'; - -class StoryThemeData { - final Color? color1; - final Color? color2; - final BoxFit fit; - final int alignmentX; - final int alignmentY; - - static const String contentKey = 'msc3588.stories.design'; - - const StoryThemeData({ - this.color1, - this.color2, - this.fit = BoxFit.contain, - this.alignmentX = 0, - this.alignmentY = 0, - }); - - factory StoryThemeData.fromJson(Map json) { - final color1Int = json.tryGet('color1'); - final color2Int = json.tryGet('color2'); - final color1 = color1Int == null ? null : Color(color1Int); - final color2 = color2Int == null ? null : Color(color2Int); - return StoryThemeData( - color1: color1, - color2: color2, - fit: - json.tryGet('fit') == 'cover' ? BoxFit.cover : BoxFit.contain, - alignmentX: json.tryGet('alignment_x') ?? 0, - alignmentY: json.tryGet('alignment_y') ?? 0, - ); - } - - Map toJson() => { - if (color1 != null) 'color1': color1?.value, - if (color2 != null) 'color2': color2?.value, - 'fit': fit.name, - 'alignment_x': alignmentX, - 'alignment_y': alignmentY, - }; -}