Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TW-731: copy/paste image #774

Merged
merged 12 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ if (keystorePropertiesFile.exists()) {

android {
compileSdkVersion 33
ndkVersion flutter.ndkVersion

sourceSets {
main.java.srcDirs += 'src/main/kotlin'
Expand All @@ -44,7 +45,7 @@ android {

defaultConfig {
applicationId "com.twake.twake"
minSdkVersion 21
minSdkVersion 23
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
Expand Down
8 changes: 7 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
android:requestLegacyExternalStorage="true"
android:allowBackup="false"
android:fullBackupContent="false"
>
>
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
Expand Down Expand Up @@ -139,5 +139,11 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<provider
android:name="com.superlist.super_native_extensions.DataProvider"
android:authorities="com.twake.twake.SuperClipboardDataProvider"
android:exported="true"
android:grantUriPermissions="true" >
</provider>
</application>
</manifest>
8 changes: 7 additions & 1 deletion assets/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2760,5 +2760,11 @@
"thisMessageHasBeenEncrypted": "This message has been encrypted",
"roomCreationFailed": "Room creation failed",
"errorGettingPdf": "Error getting PDF",
"errorPreviewingFile": "Error previewing file"
"errorPreviewingFile": "Error previewing file",
"paste": "Paste",
"cut": "Cut",
"pasteImageFailed": "Paste image failed",
sherlockvn marked this conversation as resolved.
Show resolved Hide resolved
"copyImageFailed": "Copy image failed",
"fileFormatNotSupported": "File format not supported",
"copyImageSuccess": "Image copied to clipboard"
}
39 changes: 36 additions & 3 deletions lib/pages/chat/chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import 'package:fluffychat/presentation/mixins/media_picker_mixin.dart';
import 'package:fluffychat/presentation/mixins/send_files_mixin.dart';
import 'package:fluffychat/presentation/model/forward/forward_argument.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/clipboard.dart';
import 'package:fluffychat/utils/extension/build_context_extension.dart';
import 'package:fluffychat/utils/extension/value_notifier_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart';
Expand All @@ -47,7 +48,6 @@ import 'package:fluffychat/widgets/mixins/popup_context_menu_action_mixin.dart';
import 'package:fluffychat/widgets/mixins/popup_menu_widget_mixin.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
Expand Down Expand Up @@ -776,8 +776,41 @@ class ChatController extends State<Chat>
return copyString;
}

void copySingleEventAction() async {
if (selectedEvents.length == 1) {
final event = selectedEvents.first;
if (event.messageType == MessageTypes.Image && PlatformInfos.isWeb) {
final matrixFile = event.getMatrixFile() ??
await event.downloadAndDecryptAttachment(
getThumbnail: true,
);
try {
if (matrixFile.filePath != null) {
await Clipboard.instance.copyImageAsStream(
File(matrixFile.filePath!),
mimeType: event.mimeType,
);
} else if (matrixFile.bytes != null) {
await Clipboard.instance.copyImageAsBytes(
matrixFile.bytes!,
mimeType: event.mimeType,
);
}
} catch (e) {
TwakeSnackBar.show(context, L10n.of(context)!.copyImageFailed);
Logs().e(
'copySingleEventAction(): failed to copy file ${matrixFile.name}',
);
}
} else {
copyEventsAction();
}
}
}

void copyEventsAction() {
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
Clipboard.instance.copyText(_getSelectedEventString());

showEmojiPickerNotifier.value = false;
setState(() {
selectedEvents.clear();
Expand Down Expand Up @@ -1467,7 +1500,7 @@ class ChatController extends State<Chat>
break;
case ChatContextMenuActions.copyMessage:
onSelectMessage(event);
copyEventsAction();
copySingleEventAction();
break;
case ChatContextMenuActions.pinMessage:
onSelectMessage(event);
Expand Down
2 changes: 1 addition & 1 deletion lib/pages/chat/chat_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class ChatView extends StatelessWidget {
icon: Icons.copy_outlined,
tooltip: L10n.of(context)!.copy,
onTap: () => controller
.actionWithClearSelections(controller.copyEventsAction),
.actionWithClearSelections(controller.copySingleEventAction),
),
if (controller.canRedactSelectedEvents)
TwakeIconButton(
Expand Down
208 changes: 169 additions & 39 deletions lib/pages/chat/input_bar.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import 'package:fluffychat/presentation/enum/chat/popup_menu_item_web_enum.dart';
import 'package:fluffychat/presentation/extensions/text_editting_controller_extension.dart';
import 'package:fluffychat/presentation/mixins/paste_image_mixin.dart';
import 'package:fluffychat/utils/clipboard.dart';
import 'package:fluffychat/utils/extension/raw_key_event_extension.dart';
import 'package:fluffychat/widgets/avatar/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' as flutter;

import 'package:emojis/emoji.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
Expand All @@ -15,7 +21,7 @@ import 'package:fluffychat/widgets/mxc_image.dart';

import 'command_hints.dart';

class InputBar extends StatelessWidget {
class InputBar extends StatelessWidget with PasteImageMixin {
final Room? room;
final int? minLines;
final int? maxLines;
Expand Down Expand Up @@ -311,52 +317,176 @@ class InputBar extends StatelessWidget {
onSubmitted?.call(controller?.text ?? '');
}
},
child: TypeAheadField<Map<String, String?>>(
direction: AxisDirection.up,
hideOnEmpty: true,
hideOnLoading: true,
keepSuggestionsOnSuggestionSelected: true,
debounceDuration: const Duration(milliseconds: 50),
// show suggestions after 50ms idle time (default is 300)
textFieldConfiguration: TextFieldConfiguration(
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType!,
textInputAction: textInputAction,
autofocus: autofocus!,
style: InputBarStyle.getTypeAheadTextStyle(context),
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
onSubmitted!(text);
child: CallbackShortcuts(
bindings: {
SingleActivator(
flutter.LogicalKeyboardKey.keyV,
meta: PlatformInfos.isMacKeyboardPlatform,
control: !PlatformInfos.isMacKeyboardPlatform,
): () async {
if (await Clipboard.instance.isReadableImageFormat()) {
await pasteImage(context, room!);
} else if (controller != null) {
await controller!.pasteText();
}
},
controller: controller,
decoration: decoration!,
focusNode: focusNode,
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
onChanged!(text);
const SingleActivator(
flutter.LogicalKeyboardKey.keyC,
meta: true,
): () {
if (controller != null) {
controller!.copyText();
}
},
textCapitalization: TextCapitalization.sentences,
),
suggestionsCallback: getSuggestions,
itemBuilder: (context, suggestion) => SuggestionTile(
suggestion: suggestion,
client: Matrix.of(context).client,
},
child: Listener(
onPointerDown: (PointerDownEvent event) async {
if (event.kind == PointerDeviceKind.mouse &&
event.buttons == kSecondaryMouseButton) {
// FIXME: the contextMenuBuilder.editable can do this but its style in web is not customizable
// currently this is only solution
final screenSize = MediaQuery.of(context).size;
final offset = event.position;
final position = RelativeRect.fromLTRB(
offset.dx,
offset.dy,
screenSize.width - offset.dx,
screenSize.height - offset.dy,
);
final menuItem = await showMenu<InputBarContextMenu>(
useRootNavigator: PlatformInfos.isWeb,
context: context,
items: [
PopupMenuItem(
value: InputBarContextMenu.copy,
child: Text(L10n.of(context)!.copy),
),
PopupMenuItem(
value: InputBarContextMenu.cut,
child: Text(L10n.of(context)!.cut),
),
PopupMenuItem(
value: InputBarContextMenu.paste,
child: Text(L10n.of(context)!.paste),
),
],
position: position,
);

if (menuItem == null) {
return;
}

if (controller == null) {
return;
}

switch (menuItem) {
case InputBarContextMenu.copy:
controller!.copyText();
break;
case InputBarContextMenu.cut:
controller!.cutText();
break;
case InputBarContextMenu.paste:
if (await Clipboard.instance.isReadableImageFormat()) {
await pasteImage(context, room!);
} else {
await controller!.pasteText();
}
break;
}
}
},
child: TypeAheadField<Map<String, String?>>(
direction: AxisDirection.up,
hideOnEmpty: true,
hideOnLoading: true,
keepSuggestionsOnSuggestionSelected: true,
debounceDuration: const Duration(milliseconds: 50),
// show suggestions after 50ms idle time (default is 300)
textFieldConfiguration: TextFieldConfiguration(
minLines: minLines,
maxLines: maxLines,
keyboardType: keyboardType!,
textInputAction: textInputAction,
autofocus: autofocus!,
style: InputBarStyle.getTypeAheadTextStyle(context),
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
onSubmitted!(text);
},
controller: controller,
decoration: decoration!,
focusNode: focusNode,
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
onChanged!(text);
},
contextMenuBuilder: !PlatformInfos.isWeb
? (
BuildContext contextMenucontext,
EditableTextState editableTextState,
) {
return AdaptiveTextSelectionToolbar.editable(
anchors: editableTextState.contextMenuAnchors,
clipboardStatus: ClipboardStatus.pasteable,
onPaste: !PlatformInfos.isWeb
? () async {
if (room == null) {
// FIXME: need to handle the case when in draft chat
return;
}
editableTextState
.pasteText(SelectionChangedCause.toolbar);
}
: null,
onCopy: () {
editableTextState
.copySelection(SelectionChangedCause.toolbar);
},
onCut: () {
editableTextState
.cutSelection(SelectionChangedCause.toolbar);
},
onSelectAll: () {
editableTextState
.selectAll(SelectionChangedCause.toolbar);
},
);
}
: null,
textCapitalization: TextCapitalization.sentences,
),
suggestionsCallback: getSuggestions,
itemBuilder: (context, suggestion) => SuggestionTile(
suggestion: suggestion,
client: Matrix.of(context).client,
),
onSuggestionSelected: (Map<String, String?> suggestion) =>
insertSuggestion(context, suggestion),
errorBuilder: (BuildContext context, Object? error) => Container(),
loadingBuilder: (BuildContext context) => Container(),
// fix loading briefly flickering a dark box
noItemsFoundBuilder: (BuildContext context) =>
Container(), // fix loading briefly showing no suggestions
),
),
onSuggestionSelected: (Map<String, String?> suggestion) =>
insertSuggestion(context, suggestion),
errorBuilder: (BuildContext context, Object? error) => Container(),
loadingBuilder: (BuildContext context) => Container(),
// fix loading briefly flickering a dark box
noItemsFoundBuilder: (BuildContext context) =>
Container(), // fix loading briefly showing no suggestions
),
);
}
}

class PasteIntent extends Intent {
const PasteIntent();
}

class CopyIntent extends Intent {
const CopyIntent();
}

class NewLineIntent extends Intent {}

class SubmitLineIntent extends Intent {}
Expand Down
Loading