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