add support for sending media

Still needs camera support.
This commit is contained in:
Henry Hiles 2026-07-21 20:19:01 -04:00
commit c4f63d18b5
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
13 changed files with 303 additions and 122 deletions

View file

@ -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

View 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,
);
}

View file

@ -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 {

View 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,
);
}

View file

@ -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(

View file

@ -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;

View 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);
}

View file

@ -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...",
),
),
),
],
),
),
),
],
),
],
),
),
],
),
);
}

View file

@ -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"))

View file

@ -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>

View file

@ -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>

View file

@ -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:

View file

@ -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