add support for sending media
Still needs camera support.
This commit is contained in:
parent
a8285548e8
commit
c4f63d18b5
13 changed files with 303 additions and 122 deletions
|
|
@ -48,7 +48,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
|||
- [x] Replies
|
||||
- [x] Choose ping on/off
|
||||
- [x] Per message profiles
|
||||
- [ ] Attachments
|
||||
- [x] Attachments
|
||||
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
|
||||
- [x] Mentions
|
||||
- [x] Users
|
||||
|
|
|
|||
41
lib/controllers/attachment.dart
Normal file
41
lib/controllers/attachment.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import "package:file_selector/file_selector.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:path/path.dart";
|
||||
|
||||
class AttachmentController extends Notifier<(String, MessageContent?)?> {
|
||||
final String roomId;
|
||||
AttachmentController(this.roomId);
|
||||
|
||||
@override
|
||||
Null build() => null;
|
||||
|
||||
Future<void> add(XFile file) async {
|
||||
final filename = basename(file.path);
|
||||
state = (filename, null);
|
||||
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[roomId]),
|
||||
);
|
||||
|
||||
final content = await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.uploadMedia(
|
||||
.new(
|
||||
path: file.path,
|
||||
encrypted:
|
||||
room?.state[EventType.encryption.name]?.isNotEmpty == true,
|
||||
),
|
||||
);
|
||||
|
||||
state = (filename, content);
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.family
|
||||
.autoDispose<AttachmentController, (String, MessageContent?)?, String>(
|
||||
AttachmentController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import "package:nexus/controllers/sync_status.dart";
|
|||
import "package:nexus/controllers/top_level_spaces.dart";
|
||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/oauth_auth_code_response.dart";
|
||||
import "package:nexus/models/paginate.dart";
|
||||
|
|
@ -32,6 +33,7 @@ import "package:nexus/models/requests/send_event.dart";
|
|||
import "package:nexus/models/requests/send_message.dart";
|
||||
import "package:nexus/models/requests/set_membership.dart";
|
||||
import "package:nexus/models/requests/set_state.dart";
|
||||
import "package:nexus/models/requests/upload_media.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:nexus/models/sync_data.dart";
|
||||
import "package:nexus/src/third_party/gomuks.g.dart";
|
||||
|
|
@ -266,6 +268,9 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<void> setMembership(SetMembershipRequest request) =>
|
||||
_sendCommand("set_membership", request.toJson());
|
||||
|
||||
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
|
||||
.fromJson(await _sendCommand("upload_media", request.toJson()));
|
||||
|
||||
Future<void> logout() => _sendCommand("logout");
|
||||
|
||||
Future<void> markRead(Room room) async {
|
||||
|
|
|
|||
11
lib/controllers/image_picker.dart
Normal file
11
lib/controllers/image_picker.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:image_picker/image_picker.dart";
|
||||
|
||||
class ImagePickerController extends Notifier<ImagePicker> {
|
||||
@override
|
||||
ImagePicker build() => .new();
|
||||
|
||||
static final provider = NotifierProvider<ImagePickerController, ImagePicker>(
|
||||
ImagePickerController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import "package:collection/collection.dart";
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:fluttertagger/fluttertagger.dart";
|
||||
import "package:nexus/controllers/attachment.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
|
|
@ -120,6 +121,10 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
|
|||
required RelationType relationType,
|
||||
Event? relation,
|
||||
}) async {
|
||||
final attachment = ref.watch(AttachmentController.provider(roomId));
|
||||
|
||||
if (attachment != null && attachment.$2 == null) return;
|
||||
|
||||
var taggedMessage = text;
|
||||
|
||||
for (final tag in tags) {
|
||||
|
|
@ -136,6 +141,7 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
|
|||
final event = await client.sendMessage(
|
||||
SendMessageRequest(
|
||||
roomId: roomId,
|
||||
baseContent: attachment?.$2,
|
||||
mentions: Mentions(
|
||||
userIds: [
|
||||
if (shouldMention == true &&
|
||||
|
|
@ -152,6 +158,8 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
|
|||
),
|
||||
);
|
||||
|
||||
ref.invalidate(AttachmentController.provider(roomId));
|
||||
|
||||
ref
|
||||
.watch(RoomsController.provider.notifier)
|
||||
.update(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
part "send_message.freezed.dart";
|
||||
part "send_message.g.dart";
|
||||
|
|
@ -9,6 +10,7 @@ abstract class SendMessageRequest with _$SendMessageRequest {
|
|||
const factory SendMessageRequest({
|
||||
required String roomId,
|
||||
required String text,
|
||||
Content? baseContent,
|
||||
@Default(Mentions()) @JsonKey(name: "mentions") Mentions mentions,
|
||||
@JsonKey(name: "relates_to") Relation? relation,
|
||||
}) = _SendMessageRequest;
|
||||
|
|
|
|||
24
lib/models/requests/upload_media.dart
Normal file
24
lib/models/requests/upload_media.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
part "upload_media.freezed.dart";
|
||||
part "upload_media.g.dart";
|
||||
|
||||
@freezed
|
||||
abstract class UploadMediaRequest with _$UploadMediaRequest {
|
||||
const factory UploadMediaRequest({
|
||||
required String path,
|
||||
required bool encrypted,
|
||||
String? filename,
|
||||
@Default(false) @JsonKey(name: "voice_message") bool isVoiceMessage,
|
||||
@Default(false) bool forceFile,
|
||||
|
||||
// Below params only work if encodeTo is set
|
||||
String? encodeTo,
|
||||
int? resizeWidth,
|
||||
int? resizeHeight,
|
||||
int? resizePercent,
|
||||
@Default(80) int quality,
|
||||
}) = _UploadMediaRequest;
|
||||
|
||||
factory UploadMediaRequest.fromJson(Map<String, Object?> json) =>
|
||||
_$UploadMediaRequestFromJson(json);
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import "dart:io";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:file_selector/file_selector.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:fluttertagger/fluttertagger.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/attachment.dart";
|
||||
import "package:nexus/controllers/image_picker.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
|
@ -11,6 +15,7 @@ import "package:nexus/models/relation_type.dart";
|
|||
import "package:nexus/widgets/composer/mention_overlay.dart";
|
||||
import "package:nexus/widgets/composer/relation_preview.dart";
|
||||
import "package:nexus/widgets/emoji_picker_button.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
||||
class Composer extends HookConsumerWidget {
|
||||
final String roomId;
|
||||
|
|
@ -68,131 +73,190 @@ class Composer extends HookConsumerWidget {
|
|||
fontWeight: .bold,
|
||||
);
|
||||
|
||||
final attachment = ref.watch(AttachmentController.provider(roomId));
|
||||
|
||||
return Padding(
|
||||
padding: .all(12),
|
||||
child: ClipRRect(
|
||||
borderRadius: .all(.circular(12)),
|
||||
child: Column(
|
||||
children: [
|
||||
RelationPreview(
|
||||
relatedEvent,
|
||||
shouldMention: shouldMention.value,
|
||||
toggleShouldMention: () =>
|
||||
shouldMention.value = !shouldMention.value,
|
||||
relationType: relationType,
|
||||
onDismiss: onDismiss,
|
||||
child: Column(
|
||||
children: [
|
||||
if (attachment != null)
|
||||
Card(
|
||||
margin: .only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: attachment.$2 == null
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: Icon(Icons.file_copy),
|
||||
title: Text(attachment.$1),
|
||||
trailing: IconButton(
|
||||
onPressed: () =>
|
||||
ref.invalidate(AttachmentController.provider(roomId)),
|
||||
icon: Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
padding: .symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: .center,
|
||||
children:
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.new(eventType: .message, roomId: roomId),
|
||||
),
|
||||
)
|
||||
? [
|
||||
EmojiPickerButton(
|
||||
context: context,
|
||||
onSelection: (_) => node?.requestFocus(),
|
||||
controller: controller.value,
|
||||
),
|
||||
PopupMenuButton(
|
||||
tooltip: "Add media",
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text("Camera"),
|
||||
leading: Icon(Icons.add_a_photo),
|
||||
ClipRRect(
|
||||
borderRadius: .all(.circular(12)),
|
||||
child: Column(
|
||||
children: [
|
||||
RelationPreview(
|
||||
relatedEvent,
|
||||
shouldMention: shouldMention.value,
|
||||
toggleShouldMention: () =>
|
||||
shouldMention.value = !shouldMention.value,
|
||||
relationType: relationType,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
Container(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
padding: .symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: .center,
|
||||
children:
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.new(eventType: .message, roomId: roomId),
|
||||
),
|
||||
)
|
||||
? [
|
||||
EmojiPickerButton(
|
||||
context: context,
|
||||
onSelection: (_) => node?.requestFocus(),
|
||||
controller: controller.value,
|
||||
),
|
||||
PopupMenuButton(
|
||||
tooltip: "Add media",
|
||||
enabled: attachment == null,
|
||||
itemBuilder: (context) => [
|
||||
if (Platform.isAndroid || Platform.isIOS)
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text("Camera"),
|
||||
leading: Icon(Icons.add_a_photo),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text("Gallery"),
|
||||
leading: Icon(Icons.add_photo_alternate),
|
||||
),
|
||||
onTap: () async => ref
|
||||
.watch(
|
||||
AttachmentController.provider(
|
||||
roomId,
|
||||
).notifier,
|
||||
)
|
||||
.add(
|
||||
(await ref
|
||||
.watch(
|
||||
ImagePickerController.provider,
|
||||
)
|
||||
.pickImage(source: .gallery))!,
|
||||
)
|
||||
.onError(showError),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () async => ref
|
||||
.watch(
|
||||
AttachmentController.provider(
|
||||
roomId,
|
||||
).notifier,
|
||||
)
|
||||
.add((await openFile())!)
|
||||
.onError(showError),
|
||||
child: ListTile(
|
||||
title: Text("Files"),
|
||||
leading: Icon(Icons.attachment),
|
||||
),
|
||||
),
|
||||
],
|
||||
icon: Icon(Icons.add),
|
||||
),
|
||||
Expanded(
|
||||
child: FlutterTagger(
|
||||
triggerStrategy: .eager,
|
||||
overlay: MentionOverlay(
|
||||
roomId,
|
||||
query: query.value,
|
||||
triggerCharacter: triggerCharacter.value,
|
||||
addTag: ({required id, required name}) {
|
||||
controller.value.addTag(id: id, name: name);
|
||||
node?.requestFocus();
|
||||
},
|
||||
),
|
||||
controller: controller.value,
|
||||
onSearch: (newQuery, newTriggerCharacter) {
|
||||
triggerCharacter.value = newTriggerCharacter;
|
||||
query.value = newQuery;
|
||||
},
|
||||
triggerCharacterAndStyles: {
|
||||
"@": style,
|
||||
"#": style,
|
||||
},
|
||||
builder: (context, key) => Focus(
|
||||
onKeyEvent: (_, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey ==
|
||||
LogicalKeyboardKey.enter) {
|
||||
final shiftPressed = HardwareKeyboard
|
||||
.instance
|
||||
.isShiftPressed;
|
||||
|
||||
if (!shiftPressed) {
|
||||
send();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: TextField(
|
||||
maxLines: 12,
|
||||
minLines: 1,
|
||||
autofocus: true,
|
||||
decoration: .new(
|
||||
hintText: "Your message here...",
|
||||
border: .none,
|
||||
),
|
||||
controller: controller.value,
|
||||
key: key,
|
||||
focusNode: node,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text("Gallery"),
|
||||
leading: Icon(Icons.add_photo_alternate),
|
||||
),
|
||||
IconButton(
|
||||
onPressed:
|
||||
attachment != null && attachment.$2 == null
|
||||
? null
|
||||
: send,
|
||||
icon: Icon(Icons.send),
|
||||
tooltip: "Send message",
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text("Files"),
|
||||
leading: Icon(Icons.attachment),
|
||||
]
|
||||
: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: .symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Text(
|
||||
"You don't have permission to send messages in this room...",
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
icon: Icon(Icons.add),
|
||||
),
|
||||
Expanded(
|
||||
child: FlutterTagger(
|
||||
triggerStrategy: .eager,
|
||||
overlay: MentionOverlay(
|
||||
roomId,
|
||||
query: query.value,
|
||||
triggerCharacter: triggerCharacter.value,
|
||||
addTag: ({required id, required name}) {
|
||||
controller.value.addTag(id: id, name: name);
|
||||
node?.requestFocus();
|
||||
},
|
||||
),
|
||||
controller: controller.value,
|
||||
onSearch: (newQuery, newTriggerCharacter) {
|
||||
triggerCharacter.value = newTriggerCharacter;
|
||||
query.value = newQuery;
|
||||
},
|
||||
triggerCharacterAndStyles: {"@": style, "#": style},
|
||||
builder: (context, key) => Focus(
|
||||
onKeyEvent: (_, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey ==
|
||||
LogicalKeyboardKey.enter) {
|
||||
final shiftPressed =
|
||||
HardwareKeyboard.instance.isShiftPressed;
|
||||
|
||||
if (!shiftPressed) {
|
||||
send();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: TextField(
|
||||
maxLines: 12,
|
||||
minLines: 1,
|
||||
autofocus: true,
|
||||
decoration: .new(
|
||||
hintText: "Your message here...",
|
||||
border: .none,
|
||||
),
|
||||
controller: controller.value,
|
||||
key: key,
|
||||
focusNode: node,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: send,
|
||||
icon: Icon(Icons.send),
|
||||
tooltip: "Send message",
|
||||
),
|
||||
]
|
||||
: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: .symmetric(horizontal: 8, vertical: 12),
|
||||
child: Text(
|
||||
"You don't have permission to send messages in this room...",
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import Foundation
|
|||
|
||||
import app_links
|
||||
import dynamic_color
|
||||
import file_picker
|
||||
import file_selector_macos
|
||||
import media_kit_libs_macos_video
|
||||
import media_kit_video
|
||||
|
|
@ -21,7 +20,6 @@ import window_manager
|
|||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@
|
|||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -6,5 +6,7 @@
|
|||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
32
pubspec.lock
32
pubspec.lock
|
|
@ -402,14 +402,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
file_selector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be
|
||||
name: file_selector
|
||||
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.0-beta.7"
|
||||
version: "1.1.0"
|
||||
file_selector_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_android
|
||||
sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2+8"
|
||||
file_selector_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_ios
|
||||
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.3+5"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -434,6 +450,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_web
|
||||
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ dependencies:
|
|||
path_provider: 2.1.6
|
||||
url_launcher: 6.3.2
|
||||
freezed_annotation: 3.1.0
|
||||
image_picker: 1.2.3
|
||||
file_picker: 12.0.0-beta.7
|
||||
image_picker: ^1.2.3
|
||||
path: 1.9.1
|
||||
dynamic_color: 1.8.1
|
||||
collection: 1.19.1
|
||||
|
|
@ -78,6 +77,7 @@ dependencies:
|
|||
xdg_directories: 1.1.0
|
||||
package_info_plus: 10.2.1
|
||||
app_links: 7.2.1
|
||||
file_selector: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.15.1
|
||||
|
|
|
|||
Loading…
Reference in a new issue