Skip to content

Commit 38b9227

Browse files
authored
feat: handle paste of file content (#108)
Fixes #106
1 parent a614da2 commit 38b9227

12 files changed

+294
-95
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
3+
import 'package:future_loading_dialog/future_loading_dialog.dart';
4+
import 'package:watch_it/watch_it.dart';
5+
6+
import '../../chat_room/input/draft_manager.dart';
7+
import '../../common/chat_manager.dart';
8+
import '../../l10n/l10n.dart';
9+
10+
class MouseAndKeyboardCommandWrapper extends StatelessWidget {
11+
const MouseAndKeyboardCommandWrapper({super.key, required this.child});
12+
13+
final Widget child;
14+
15+
@override
16+
Widget build(BuildContext context) => Shortcuts(
17+
shortcuts: <LogicalKeySet, Intent>{
18+
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyV):
19+
const _PasteIntent(),
20+
},
21+
child: Actions(
22+
actions: <Type, Action<Intent>>{
23+
_PasteIntent: CallbackAction<_PasteIntent>(
24+
onInvoke: (intent) async {
25+
if (di<ChatManager>().selectedRoom != null) {
26+
await showFutureLoadingDialog(
27+
context: context,
28+
backLabel: context.l10n.cancel,
29+
title: context.l10n.loadingPleaseWait,
30+
future: () => di<DraftManager>().addAttachMentFromClipboard(
31+
di<ChatManager>().selectedRoom!.id,
32+
fileIsTooLarge: context.l10n.fileIsTooLarge,
33+
clipboardNotAvailable: context.l10n.clipboardNotAvailable,
34+
noSupportedFormatFoundInClipboard:
35+
context.l10n.noSupportedFormatFoundInClipboard,
36+
),
37+
);
38+
}
39+
return null;
40+
},
41+
),
42+
},
43+
child: child,
44+
),
45+
);
46+
}
47+
48+
class _PasteIntent extends Intent {
49+
const _PasteIntent();
50+
}

lib/chat_room/common/view/chat_room_page.dart

Lines changed: 94 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:super_drag_and_drop/super_drag_and_drop.dart';
77
import 'package:watch_it/watch_it.dart';
88

99
import '../../../app/view/error_page.dart';
10+
import '../../../app/view/mouse_and_keyboard_command_wrapper.dart';
1011
import '../../../common/chat_manager.dart';
1112
import '../../../common/view/build_context_x.dart';
1213
import '../../../common/view/common_widgets.dart';
@@ -98,100 +99,104 @@ class _ChatRoomPageState extends State<ChatRoomPage> {
9899
preserveState: false,
99100
).data;
100101

101-
return DropRegion(
102-
formats: Formats.standardFormats,
103-
hitTestBehavior: HitTestBehavior.opaque,
104-
onPerformDrop: (e) async {
105-
for (var item in e.session.items.take(5)) {
106-
item.dataReader?.getValue(
107-
Formats.fileUri,
108-
(value) async {
109-
if (value == null) return;
110-
final file = File.fromUri(value);
102+
return MouseAndKeyboardCommandWrapper(
103+
child: DropRegion(
104+
formats: Formats.standardFormats,
105+
hitTestBehavior: HitTestBehavior.opaque,
106+
onPerformDrop: (e) async {
107+
for (var item in e.session.items.take(5)) {
108+
item.dataReader?.getValue(
109+
Formats.fileUri,
110+
(value) async {
111+
if (value == null) return;
112+
final file = File.fromUri(value);
111113

112-
await di<DraftManager>().addAttachment(
113-
widget.room.id,
114-
onFail: (error) => showSnackBar(context, content: Text(error)),
115-
existingFiles: [XFile.fromData(file.readAsBytesSync())],
116-
);
117-
},
118-
onError: (e) => showSnackBar(context, content: Text(e.toString())),
119-
);
120-
}
121-
},
122-
onDropOver: (event) {
123-
if (event.session.allowedOperations.contains(DropOperation.copy)) {
124-
return DropOperation.copy;
125-
} else {
126-
return DropOperation.none;
127-
}
128-
},
129-
child: Stack(
130-
alignment: Alignment.center,
131-
children: [
132-
Scaffold(
133-
key: chatRoomScaffoldKey,
134-
endDrawer: ChatRoomInfoDrawer(room: widget.room),
135-
appBar: ChatRoomTitleBar(room: widget.room),
136-
bottomNavigationBar:
137-
widget.room.isArchived ||
138-
unAcceptedDirectChat != false ||
139-
widget.room.isSpace
140-
? null
141-
: ChatInput(
142-
key: ValueKey('${widget.room.id}input'),
143-
room: widget.room,
144-
),
145-
body: unAcceptedDirectChat == null
146-
? const Center(child: Progress())
147-
: unAcceptedDirectChat == true
148-
? const ChatRoomUnacceptedDirectChatBody()
149-
: FutureBuilder<Timeline>(
150-
key: ValueKey(widget.room.id),
151-
future: _timelineFuture,
152-
builder: (context, snapshot) {
153-
if (snapshot.hasError) {
154-
return ErrorBody(error: snapshot.error.toString());
155-
}
156-
if (snapshot.hasData) {
157-
return Padding(
158-
padding: const EdgeInsets.only(
159-
bottom: kMediumPadding,
160-
),
161-
child: ChatRoomTimelineList(
162-
timeline: snapshot.data!,
163-
listKey: _roomListKey,
164-
),
165-
);
166-
} else {
167-
return const Center(child: Progress());
168-
}
169-
},
170-
),
171-
),
172-
if (updating &&
173-
chatRoomScaffoldKey.currentState?.isEndDrawerOpen != true)
174-
Positioned(
175-
top: 4 * kBigPadding,
176-
child: RepaintBoundary(
177-
child: CircleAvatar(
178-
backgroundColor: getMonochromeBg(
179-
theme: context.theme,
180-
factor: 3,
181-
darkFactor: 4,
182-
),
183-
maxRadius: 15,
184-
child: SizedBox.square(
185-
dimension: 18,
186-
child: Progress(
187-
strokeWidth: 2,
188-
color: colorScheme.onSurface,
114+
await di<DraftManager>().addAttachment(
115+
widget.room.id,
116+
onFail: (error) =>
117+
showSnackBar(context, content: Text(error)),
118+
existingFiles: [XFile.fromData(file.readAsBytesSync())],
119+
);
120+
},
121+
onError: (e) =>
122+
showSnackBar(context, content: Text(e.toString())),
123+
);
124+
}
125+
},
126+
onDropOver: (event) {
127+
if (event.session.allowedOperations.contains(DropOperation.copy)) {
128+
return DropOperation.copy;
129+
} else {
130+
return DropOperation.none;
131+
}
132+
},
133+
child: Stack(
134+
alignment: Alignment.center,
135+
children: [
136+
Scaffold(
137+
key: chatRoomScaffoldKey,
138+
endDrawer: ChatRoomInfoDrawer(room: widget.room),
139+
appBar: ChatRoomTitleBar(room: widget.room),
140+
bottomNavigationBar:
141+
widget.room.isArchived ||
142+
unAcceptedDirectChat != false ||
143+
widget.room.isSpace
144+
? null
145+
: ChatInput(
146+
key: ValueKey('${widget.room.id}input'),
147+
room: widget.room,
148+
),
149+
body: unAcceptedDirectChat == null
150+
? const Center(child: Progress())
151+
: unAcceptedDirectChat == true
152+
? const ChatRoomUnacceptedDirectChatBody()
153+
: FutureBuilder<Timeline>(
154+
key: ValueKey(widget.room.id),
155+
future: _timelineFuture,
156+
builder: (context, snapshot) {
157+
if (snapshot.hasError) {
158+
return ErrorBody(error: snapshot.error.toString());
159+
}
160+
if (snapshot.hasData) {
161+
return Padding(
162+
padding: const EdgeInsets.only(
163+
bottom: kMediumPadding,
164+
),
165+
child: ChatRoomTimelineList(
166+
timeline: snapshot.data!,
167+
listKey: _roomListKey,
168+
),
169+
);
170+
} else {
171+
return const Center(child: Progress());
172+
}
173+
},
174+
),
175+
),
176+
if (updating &&
177+
chatRoomScaffoldKey.currentState?.isEndDrawerOpen != true)
178+
Positioned(
179+
top: 4 * kBigPadding,
180+
child: RepaintBoundary(
181+
child: CircleAvatar(
182+
backgroundColor: getMonochromeBg(
183+
theme: context.theme,
184+
factor: 3,
185+
darkFactor: 4,
186+
),
187+
maxRadius: 15,
188+
child: SizedBox.square(
189+
dimension: 18,
190+
child: Progress(
191+
strokeWidth: 2,
192+
color: colorScheme.onSurface,
193+
),
189194
),
190195
),
191196
),
192197
),
193-
),
194-
],
198+
],
199+
),
195200
),
196201
);
197202
}

lib/chat_room/input/draft_manager.dart

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import 'dart:typed_data';
2+
3+
import 'package:collection/collection.dart';
14
import 'package:file_picker/file_picker.dart';
25
import 'package:file_selector/file_selector.dart';
36
import 'package:matrix/matrix.dart';
47
import 'package:mime/mime.dart';
58
import 'package:safe_change_notifier/safe_change_notifier.dart';
9+
import 'package:super_clipboard/super_clipboard.dart';
610
import 'package:video_compress/video_compress.dart';
711

812
import '../../common/local_image_service.dart';
@@ -299,8 +303,6 @@ class DraftManager extends SafeChangeNotifier {
299303
);
300304
}
301305

302-
// final Map<MatrixVideoFile, XFile> _fileMap = {};
303-
304306
Future<MatrixImageFile?> getVideoThumbnail(XFile xFile) async {
305307
if (!(Platforms.isMobile || Platforms.isMacOS)) return null;
306308

@@ -323,4 +325,87 @@ class DraftManager extends SafeChangeNotifier {
323325

324326
bool isMessageSelected({required String eventId}) =>
325327
_selectedMessages[eventId] ?? false;
328+
329+
Future<List<void>> addAttachMentFromClipboard(
330+
String roomId, {
331+
required String clipboardNotAvailable,
332+
required String noSupportedFormatFoundInClipboard,
333+
required String fileIsTooLarge,
334+
}) async {
335+
final clipboard = SystemClipboard.instance;
336+
if (clipboard == null) {
337+
return Future.error(clipboardNotAvailable);
338+
}
339+
ClipboardReader reader;
340+
try {
341+
reader = await clipboard.read();
342+
} on Exception catch (e) {
343+
return Future.error(e.toString());
344+
}
345+
346+
if (reader.items.isEmpty) {
347+
return Future.value([]);
348+
}
349+
350+
if (reader.items.none(
351+
(item) => availableFormats.any((format) => item.canProvide(format)),
352+
)) {
353+
return Future.error(noSupportedFormatFoundInClipboard);
354+
}
355+
356+
return Future.wait(
357+
availableFormats.map(
358+
(format) => _processClipboardReader(
359+
roomId: roomId,
360+
format: format,
361+
reader: reader,
362+
fileIsTooLarge: fileIsTooLarge,
363+
),
364+
),
365+
);
366+
}
367+
368+
Future<void> _processClipboardReader({
369+
required String roomId,
370+
required SimpleFileFormat format,
371+
required ClipboardReader reader,
372+
required String fileIsTooLarge,
373+
}) async {
374+
if (reader.canProvide(format)) {
375+
reader.getFile(format, (dataReaderFile) async {
376+
if (dataReaderFile.fileSize == null ||
377+
dataReaderFile.fileSize! > maxUploadSize) {
378+
return Future.error(fileIsTooLarge);
379+
}
380+
381+
final data = await dataReaderFile.readAll();
382+
383+
setAttaching(true);
384+
385+
addFileToDraft(
386+
roomId: roomId,
387+
file: MatrixFile.fromMimeType(
388+
bytes: Uint8List.fromList(data),
389+
name: dataReaderFile.fileName ?? 'clipboard',
390+
mimeType: format.mimeTypes?.firstOrNull,
391+
),
392+
);
393+
394+
setAttaching(false);
395+
396+
return Future.value(null);
397+
});
398+
}
399+
}
326400
}
401+
402+
Set<SimpleFileFormat> get availableFormats => {
403+
Formats.jpeg,
404+
Formats.png,
405+
Formats.gif,
406+
Formats.tiff,
407+
Formats.bmp,
408+
Formats.mp3,
409+
Formats.mp4,
410+
Formats.pdf,
411+
};

lib/chat_room/input/view/chat_attachment_draft_panel.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class ChatAttachmentDraftPanel extends StatelessWidget with WatchItMixin {
2121
final draftFilesL = watchPropertyValue(
2222
(DraftManager m) => m.getFilesDraft(roomId).length,
2323
);
24+
2425
final sending = watchPropertyValue((DraftManager m) => m.sending);
2526

2627
if (!attaching && draftFilesL == 0) return const SizedBox.shrink();

lib/l10n/app_en.arb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3184,5 +3184,8 @@
31843184
},
31853185
"playNowButton": "Play now",
31863186
"@playNowButton": {},
3187-
"appendMediaToQueueButton": "Append to queue"
3187+
"appendMediaToQueueButton": "Append to queue",
3188+
"clipboardNotAvailable": "Clipboard not available",
3189+
"noSupportedFormatFoundInClipboard": "No supported format found in clipboard",
3190+
"fileIsTooLarge": "File is too large"
31883191
}

0 commit comments

Comments
 (0)