mirror of
				https://gitlab.com/famedly/fluffychat.git
				synced 2025-11-04 06:17:26 +01:00 
			
		
		
		
	Merge branch 'krille/stories' into 'main'
feat: Stories See merge request famedly/fluffychat!635
This commit is contained in:
		
						commit
						48727e139b
					
				@ -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"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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(),
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										158
									
								
								lib/pages/add_story/add_story.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								lib/pages/add_story/add_story.dart
									
									
									
									
									
										Normal file
									
								
							@ -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<AddStoryPage> {
 | 
			
		||||
  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<FileTypeCross>(
 | 
			
		||||
      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<bool>(
 | 
			
		||||
        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);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										115
									
								
								lib/pages/add_story/add_story_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								lib/pages/add_story/add_story_view.dart
									
									
									
									
									
										Normal file
									
								
							@ -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),
 | 
			
		||||
                ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										109
									
								
								lib/pages/add_story/invite_story_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								lib/pages/add_story/invite_story_page.dart
									
									
									
									
									
										Normal file
									
								
							@ -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<InviteStoryPage> {
 | 
			
		||||
  Set<String> _undecided = {};
 | 
			
		||||
  final Set<String> _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<bool>(true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<User>>? 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<bool>(false),
 | 
			
		||||
        ),
 | 
			
		||||
        title: Text(L10n.of(context)!.whoCanSeeMyStories),
 | 
			
		||||
      ),
 | 
			
		||||
      body: FutureBuilder<List<User>>(
 | 
			
		||||
          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),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										203
									
								
								lib/pages/chat_list/stories_header.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										203
									
								
								lib/pages/chat_list/stories_header.dart
									
									
									
									
									
										Normal file
									
								
							@ -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<ContextualRoomAction>(
 | 
			
		||||
      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<Object>(
 | 
			
		||||
        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,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										178
									
								
								lib/pages/story/story_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								lib/pages/story/story_page.dart
									
									
									
									
									
										Normal file
									
								
							@ -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<StoryPage> {
 | 
			
		||||
  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<VideoPlayerController> 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<String, Future<MatrixFile>> _fileCache = {};
 | 
			
		||||
 | 
			
		||||
  Future<MatrixFile> 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<List<Event>>? loadStory;
 | 
			
		||||
 | 
			
		||||
  Future<List<Event>> _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<Event> {
 | 
			
		||||
  bool removeOutdatedEvents() {
 | 
			
		||||
    final outdatedIndex = indexWhere((event) =>
 | 
			
		||||
        DateTime.now().difference(event.originServerTs).inHours >
 | 
			
		||||
        ClientStoriesExtension.lifeTimeInHours);
 | 
			
		||||
    if (outdatedIndex != -1) {
 | 
			
		||||
      removeRange(outdatedIndex, length);
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										187
									
								
								lib/pages/story/story_view.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								lib/pages/story/story_view.dart
									
									
									
									
									
										Normal file
									
								
							@ -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<List<Event>>(
 | 
			
		||||
        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<String>('body')?.color ??
 | 
			
		||||
              Theme.of(context).primaryColor;
 | 
			
		||||
          final backgroundColorDark =
 | 
			
		||||
              event.content.tryGet<String>('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<VideoPlayerController>(
 | 
			
		||||
                    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<MatrixFile>(
 | 
			
		||||
                      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<String>('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,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -85,7 +85,12 @@ abstract class ClientManager {
 | 
			
		||||
          if (kIsWeb || PlatformInfos.isMobile || PlatformInfos.isLinux)
 | 
			
		||||
            KeyVerificationMethod.emoji,
 | 
			
		||||
        },
 | 
			
		||||
        importantStateEvents: <String>{'im.ponies.room_emotes'},
 | 
			
		||||
        importantStateEvents: <String>{
 | 
			
		||||
          // 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: {
 | 
			
		||||
 | 
			
		||||
@ -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<User> get contacts => rooms
 | 
			
		||||
      .where((room) => room.isDirectChat)
 | 
			
		||||
      .map((room) => room.getUserByMXIDSync(room.directChatMatrixID!))
 | 
			
		||||
      .toList();
 | 
			
		||||
 | 
			
		||||
  List<Room> get storiesRooms => rooms
 | 
			
		||||
      .where((room) =>
 | 
			
		||||
          room
 | 
			
		||||
              .getState(EventTypes.RoomCreate)
 | 
			
		||||
              ?.content
 | 
			
		||||
              .tryGet<String>('type') ==
 | 
			
		||||
          _storiesRoomType)
 | 
			
		||||
      .toList();
 | 
			
		||||
 | 
			
		||||
  Future<List<User>> 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<String> get storiesBlockList =>
 | 
			
		||||
      accountData[_storiesBlockListType]?.content.tryGetList<String>('users') ??
 | 
			
		||||
      [];
 | 
			
		||||
 | 
			
		||||
  Future<void> setStoriesBlockList(List<String> users) => setAccountData(
 | 
			
		||||
        userID!,
 | 
			
		||||
        _storiesBlockListType,
 | 
			
		||||
        {'users': users},
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
  Future<void> createStoriesRoom([List<String>? 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<Room?> getStoriesRoom(BuildContext context) async {
 | 
			
		||||
    final candidates = rooms.where((room) =>
 | 
			
		||||
        room.getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
 | 
			
		||||
            _storiesRoomType &&
 | 
			
		||||
        room.ownPowerLevel >= 100);
 | 
			
		||||
    if (candidates.isEmpty) return null;
 | 
			
		||||
    if (candidates.length == 1) return candidates.single;
 | 
			
		||||
    return await showModalActionSheet<Room>(
 | 
			
		||||
        context: context,
 | 
			
		||||
        actions: candidates
 | 
			
		||||
            .map(
 | 
			
		||||
              (room) => SheetAction(label: room.displayname, key: room),
 | 
			
		||||
            )
 | 
			
		||||
            .toList());
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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:
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user