mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2024-11-24 13:09:24 +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": "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": {},
|
"@pleaseEnterSecurityKeyDescription": {},
|
||||||
"saveTheSecurityKeyNow": "Save the security key now",
|
"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:vrouter/vrouter.dart';
|
||||||
|
|
||||||
|
import 'package:fluffychat/pages/add_story/add_story.dart';
|
||||||
import 'package:fluffychat/pages/archive/archive.dart';
|
import 'package:fluffychat/pages/archive/archive.dart';
|
||||||
import 'package:fluffychat/pages/chat/chat.dart';
|
import 'package:fluffychat/pages/chat/chat.dart';
|
||||||
import 'package:fluffychat/pages/chat_details/chat_details.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_security/settings_security.dart';
|
||||||
import 'package:fluffychat/pages/settings_style/settings_style.dart';
|
import 'package:fluffychat/pages/settings_style/settings_style.dart';
|
||||||
import 'package:fluffychat/pages/sign_up/signup.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/empty_page.dart';
|
||||||
import 'package:fluffychat/widgets/layouts/loading_view.dart';
|
import 'package:fluffychat/widgets/layouts/loading_view.dart';
|
||||||
import 'package:fluffychat/widgets/layouts/side_view_layout.dart';
|
import 'package:fluffychat/widgets/layouts/side_view_layout.dart';
|
||||||
@ -51,6 +53,14 @@ class AppRoutes {
|
|||||||
path: '/rooms',
|
path: '/rooms',
|
||||||
widget: const ChatList(),
|
widget: const ChatList(),
|
||||||
stackedRoutes: [
|
stackedRoutes: [
|
||||||
|
VWidget(
|
||||||
|
path: '/stories/create',
|
||||||
|
widget: const AddStoryPage(),
|
||||||
|
),
|
||||||
|
VWidget(
|
||||||
|
path: '/stories/:roomid',
|
||||||
|
widget: const StoryPage(),
|
||||||
|
),
|
||||||
VWidget(
|
VWidget(
|
||||||
path: '/spaces/:roomid',
|
path: '/spaces/:roomid',
|
||||||
widget: const ChatDetails(),
|
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/chat_list_item.dart';
|
||||||
import 'package:fluffychat/pages/chat_list/client_chooser_button.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/spaces_bottom_bar.dart';
|
||||||
|
import 'package:fluffychat/pages/chat_list/stories_header.dart';
|
||||||
import 'package:fluffychat/widgets/connection_status_header.dart';
|
import 'package:fluffychat/widgets/connection_status_header.dart';
|
||||||
import '../../utils/stream_extension.dart';
|
import '../../utils/stream_extension.dart';
|
||||||
import '../../widgets/matrix.dart';
|
import '../../widgets/matrix.dart';
|
||||||
@ -203,6 +204,7 @@ class ChatListView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (controller.waitForFirstSync) const StoriesHeader(),
|
||||||
Expanded(child: _ChatListViewBody(controller)),
|
Expanded(child: _ChatListViewBody(controller)),
|
||||||
]),
|
]),
|
||||||
floatingActionButton: selectMode == SelectMode.normal
|
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)
|
if (kIsWeb || PlatformInfos.isMobile || PlatformInfos.isLinux)
|
||||||
KeyVerificationMethod.emoji,
|
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,
|
databaseBuilder: FlutterFluffyBoxDatabase.databaseBuilder,
|
||||||
legacyDatabaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder,
|
legacyDatabaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder,
|
||||||
supportedLoginTypes: {
|
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
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.1.1"
|
||||||
video_player:
|
video_player:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: video_player
|
name: video_player
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.7"
|
version: "2.2.10"
|
||||||
video_player_platform_interface:
|
video_player_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -72,6 +72,7 @@ dependencies:
|
|||||||
unifiedpush: ^3.0.1
|
unifiedpush: ^3.0.1
|
||||||
universal_html: ^2.0.8
|
universal_html: ^2.0.8
|
||||||
url_launcher: ^6.0.12
|
url_launcher: ^6.0.12
|
||||||
|
video_player: ^2.2.10
|
||||||
vrouter: ^1.2.0+15
|
vrouter: ^1.2.0+15
|
||||||
wakelock: ^0.5.6
|
wakelock: ^0.5.6
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user