diff --git a/.cspell/dart_flutter.txt b/.cspell/dart_flutter.txt
index a618a83cf00..59636699ba9 100644
--- a/.cspell/dart_flutter.txt
+++ b/.cspell/dart_flutter.txt
@@ -14,4 +14,5 @@ sublist
todos
unawaited
unfocus
+videocam
writeln
diff --git a/.cspell/nextcloud.txt b/.cspell/nextcloud.txt
index 16a4f5a97d8..d80edf30877 100644
--- a/.cspell/nextcloud.txt
+++ b/.cspell/nextcloud.txt
@@ -52,6 +52,7 @@ trashbin
turnservers
undelete
unifiedpush
+unmute
unstar
updatenotification
uppush
diff --git a/.cspell/tools.txt b/.cspell/tools.txt
index d135e9991ca..0a37300b700 100644
--- a/.cspell/tools.txt
+++ b/.cspell/tools.txt
@@ -63,6 +63,7 @@ strfreev
subprojects
sysroot
tsvg
+webrtc
werror
xxxh
xxxhdpi
diff --git a/README.md b/README.md
index e1cc420c716..2ae9245f990 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ See [here](packages/app/README.md) for screenshots.
| [News](packages/neon/neon_news) | :heavy_check_mark: |
| [Notes](packages/neon/neon_notes) | :heavy_check_mark: |
| [Notifications](packages/neon/neon_notifications) | :heavy_check_mark: |
+| [Talk](packages/neon/neon_spreed) | :heavy_check_mark: |
| Activity | :rocket: |
| Calendar | :rocket: |
| Contacts | :rocket: |
@@ -62,7 +63,6 @@ See [here](packages/app/README.md) for screenshots.
| Deck | :rocket: |
| Photos | :rocket: |
| Photos | :rocket: |
-| Talk | :construction: |
| Tasks | :rocket: |
## Platform support
diff --git a/docs/architecture.puml b/docs/architecture.puml
index 99b32e140df..df952333b37 100644
--- a/docs/architecture.puml
+++ b/docs/architecture.puml
@@ -13,6 +13,7 @@ package "Clients" {
component neon_news
component neon_notes
component neon_notifications
+ component neon_spreed
}
package "OpenAPI" {
@@ -27,12 +28,14 @@ app ..> neon_files
app ..> neon_news
app ..> neon_notes
app ..> neon_notifications
+app ..> neon_spreed
neon_dashboard --> neon_framework
neon_files --> neon_framework
neon_news --> neon_framework
neon_notes --> neon_framework
neon_notifications --> neon_framework
+neon_spreed --> neon_framework
neon_framework --> nextcloud
diff --git a/docs/architecture.svg b/docs/architecture.svg
index e7e3ce00459..51be5110bf7 100644
--- a/docs/architecture.svg
+++ b/docs/architecture.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/app/android/app/src/main/AndroidManifest.xml b/packages/app/android/app/src/main/AndroidManifest.xml
index 29f4f8ff0e2..4c385816b5f 100644
--- a/packages/app/android/app/src/main/AndroidManifest.xml
+++ b/packages/app/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,15 @@
+
+
+
+
+
+
+
+
+
#include
#include
+#include
#include
#include
#include
@@ -23,6 +24,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
+ g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
+ flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
diff --git a/packages/app/linux/flutter/generated_plugins.cmake b/packages/app/linux/flutter/generated_plugins.cmake
index 64a9330bf03..f1c01d0d2ce 100644
--- a/packages/app/linux/flutter/generated_plugins.cmake
+++ b/packages/app/linux/flutter/generated_plugins.cmake
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color
emoji_picker_flutter
file_selector_linux
+ flutter_webrtc
screen_retriever
url_launcher_linux
window_manager
diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock
index 686cd3bf19f..c482668c8dd 100644
--- a/packages/app/pubspec.lock
+++ b/packages/app/pubspec.lock
@@ -273,6 +273,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.6"
+ dart_webrtc:
+ dependency: transitive
+ description:
+ name: dart_webrtc
+ sha256: "5cbc40bd9b33d0c9b8004cff52e9883c71f0f54799afc8faca77535eeb9ef857"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
dbus:
dependency: transitive
description:
@@ -297,6 +305,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.16.4+3"
+ diffutil_dart:
+ dependency: transitive
+ description:
+ name: diffutil_dart
+ sha256: e0297e4600b9797edff228ed60f4169a778ea357691ec98408fa3b72994c7d06
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.0"
dynamic_color:
dependency: transitive
description:
@@ -320,6 +336,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
+ equatable:
+ dependency: transitive
+ description:
+ name: equatable
+ sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.5"
fake_async:
dependency: transitive
description:
@@ -412,6 +436,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_chat_types:
+ dependency: transitive
+ description:
+ name: flutter_chat_types
+ sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.6.2"
+ flutter_chat_ui:
+ dependency: transitive
+ description:
+ name: flutter_chat_ui
+ sha256: c8580c85e2d29359ffc84147e643d08d883eb6e757208652377f0105ef58807f
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.6.12"
flutter_file_dialog:
dependency: transitive
description:
@@ -428,6 +468,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0-beta.2"
+ flutter_link_previewer:
+ dependency: transitive
+ description:
+ name: flutter_link_previewer
+ sha256: "007069e60f42419fb59872beb7a3cc3ea21e9f1bdff5d40239f376fa62ca9f20"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.2"
+ flutter_linkify:
+ dependency: transitive
+ description:
+ name: flutter_linkify
+ sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.0"
flutter_local_notifications:
dependency: transitive
description:
@@ -481,6 +537,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.10"
+ flutter_parsed_text:
+ dependency: transitive
+ description:
+ name: flutter_parsed_text
+ sha256: "529cf5793b7acdf16ee0f97b158d0d4ba0bf06e7121ef180abe1a5b59e32c1e2"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -507,6 +571,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_webrtc:
+ dependency: transitive
+ description:
+ name: flutter_webrtc
+ sha256: "2f17fb96e0c9c6ff75f6b1c36d94755461fc7f36a5c28386f5ee5a18b98688c8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.48+hotfix.1"
flutter_zxing:
dependency: transitive
description:
@@ -707,6 +779,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
+ linkify:
+ dependency: transitive
+ description:
+ name: linkify
+ sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.0.0"
list_counter:
dependency: transitive
description:
@@ -987,6 +1067,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.2"
+ photo_view:
+ dependency: transitive
+ description:
+ name: photo_view
+ sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.14.0"
platform:
dependency: transitive
description:
@@ -995,6 +1083,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.4"
+ platform_detect:
+ dependency: transitive
+ description:
+ name: platform_detect
+ sha256: "08f4ee79c0e1c4858d37e06b22352a3ebdef5466b613749a3adb03e703d4f5b0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.11"
plugin_platform_interface:
dependency: transitive
description:
@@ -1115,6 +1211,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.9"
+ scroll_to_index:
+ dependency: transitive
+ description:
+ name: scroll_to_index
+ sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.1"
scrollable_positioned_list:
dependency: transitive
description:
@@ -1487,6 +1591,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
+ visibility_detector:
+ dependency: transitive
+ description:
+ name: visibility_detector
+ sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.0+2"
vm_service:
dependency: transitive
description:
@@ -1527,6 +1639,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
+ webrtc_interface:
+ dependency: transitive
+ description:
+ name: webrtc_interface
+ sha256: "2efbd3e4e5ebeb2914253bcc51dafd3053c4b87b43f3076c74835a9deecbae3a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
webview_flutter:
dependency: transitive
description:
diff --git a/packages/neon/neon_talk/analysis_options.yaml b/packages/neon/neon_talk/analysis_options.yaml
index 66de1efdbdd..6c0ada98cc1 100644
--- a/packages/neon/neon_talk/analysis_options.yaml
+++ b/packages/neon/neon_talk/analysis_options.yaml
@@ -1,5 +1,10 @@
include: package:neon_lints/flutter.yaml
+linter:
+ rules:
+ # TODO
+ public_member_api_docs: false
+
analyzer:
exclude:
- lib/l10n/**
diff --git a/packages/neon/neon_talk/lib/l10n/en.arb b/packages/neon/neon_talk/lib/l10n/en.arb
index ccf41500b73..23fde3234a3 100644
--- a/packages/neon/neon_talk/lib/l10n/en.arb
+++ b/packages/neon/neon_talk/lib/l10n/en.arb
@@ -1,5 +1,18 @@
{
"@@locale": "en",
"actorSelf": "You",
- "actorGuest": "Guest"
+ "actorGuest": "Guest",
+ "roomCreate": "Create room",
+ "roomCreateUserName": "User name",
+ "roomCreateGroupName": "Group name",
+ "roomCreateRoomName": "Room name",
+ "roomTypeOneToOne": "Private",
+ "roomTypeGroup": "Group",
+ "roomTypePublic": "Public",
+ "callStart": "Start call",
+ "callJoin": "Join call",
+ "callLeave": "Leave call",
+ "screenSharingSelectScreen": "Select screen",
+ "screenSharingSelectScreenScreens": "Screens",
+ "screenSharingSelectScreenWindows": "Windows"
}
diff --git a/packages/neon/neon_talk/lib/l10n/localizations.dart b/packages/neon/neon_talk/lib/l10n/localizations.dart
index e1fe29054c2..486819dc971 100644
--- a/packages/neon/neon_talk/lib/l10n/localizations.dart
+++ b/packages/neon/neon_talk/lib/l10n/localizations.dart
@@ -100,6 +100,84 @@ abstract class TalkLocalizations {
/// In en, this message translates to:
/// **'Guest'**
String get actorGuest;
+
+ /// No description provided for @roomCreate.
+ ///
+ /// In en, this message translates to:
+ /// **'Create room'**
+ String get roomCreate;
+
+ /// No description provided for @roomCreateUserName.
+ ///
+ /// In en, this message translates to:
+ /// **'User name'**
+ String get roomCreateUserName;
+
+ /// No description provided for @roomCreateGroupName.
+ ///
+ /// In en, this message translates to:
+ /// **'Group name'**
+ String get roomCreateGroupName;
+
+ /// No description provided for @roomCreateRoomName.
+ ///
+ /// In en, this message translates to:
+ /// **'Room name'**
+ String get roomCreateRoomName;
+
+ /// No description provided for @roomTypeOneToOne.
+ ///
+ /// In en, this message translates to:
+ /// **'Private'**
+ String get roomTypeOneToOne;
+
+ /// No description provided for @roomTypeGroup.
+ ///
+ /// In en, this message translates to:
+ /// **'Group'**
+ String get roomTypeGroup;
+
+ /// No description provided for @roomTypePublic.
+ ///
+ /// In en, this message translates to:
+ /// **'Public'**
+ String get roomTypePublic;
+
+ /// No description provided for @callStart.
+ ///
+ /// In en, this message translates to:
+ /// **'Start call'**
+ String get callStart;
+
+ /// No description provided for @callJoin.
+ ///
+ /// In en, this message translates to:
+ /// **'Join call'**
+ String get callJoin;
+
+ /// No description provided for @callLeave.
+ ///
+ /// In en, this message translates to:
+ /// **'Leave call'**
+ String get callLeave;
+
+ /// No description provided for @screenSharingSelectScreen.
+ ///
+ /// In en, this message translates to:
+ /// **'Select screen'**
+ String get screenSharingSelectScreen;
+
+ /// No description provided for @screenSharingSelectScreenScreens.
+ ///
+ /// In en, this message translates to:
+ /// **'Screens'**
+ String get screenSharingSelectScreenScreens;
+
+ /// No description provided for @screenSharingSelectScreenWindows.
+ ///
+ /// In en, this message translates to:
+ /// **'Windows'**
+ String get screenSharingSelectScreenWindows;
}
class _TalkLocalizationsDelegate extends LocalizationsDelegate {
diff --git a/packages/neon/neon_talk/lib/l10n/localizations_en.dart b/packages/neon/neon_talk/lib/l10n/localizations_en.dart
index 4a01892dddb..92fae33546f 100644
--- a/packages/neon/neon_talk/lib/l10n/localizations_en.dart
+++ b/packages/neon/neon_talk/lib/l10n/localizations_en.dart
@@ -9,4 +9,43 @@ class TalkLocalizationsEn extends TalkLocalizations {
@override
String get actorGuest => 'Guest';
+
+ @override
+ String get roomCreate => 'Create room';
+
+ @override
+ String get roomCreateUserName => 'User name';
+
+ @override
+ String get roomCreateGroupName => 'Group name';
+
+ @override
+ String get roomCreateRoomName => 'Room name';
+
+ @override
+ String get roomTypeOneToOne => 'Private';
+
+ @override
+ String get roomTypeGroup => 'Group';
+
+ @override
+ String get roomTypePublic => 'Public';
+
+ @override
+ String get callStart => 'Start call';
+
+ @override
+ String get callJoin => 'Join call';
+
+ @override
+ String get callLeave => 'Leave call';
+
+ @override
+ String get screenSharingSelectScreen => 'Select screen';
+
+ @override
+ String get screenSharingSelectScreenScreens => 'Screens';
+
+ @override
+ String get screenSharingSelectScreenWindows => 'Windows';
}
diff --git a/packages/neon/neon_talk/lib/src/blocs/call.dart b/packages/neon/neon_talk/lib/src/blocs/call.dart
new file mode 100644
index 00000000000..8a919e1e92f
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/blocs/call.dart
@@ -0,0 +1,491 @@
+import 'dart:async';
+
+import 'package:built_collection/built_collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:logging/logging.dart';
+import 'package:neon_framework/blocs.dart';
+import 'package:neon_talk/src/utils/participants.dart';
+import 'package:nextcloud/nextcloud.dart';
+import 'package:nextcloud/spreed.dart' as spreed;
+import 'package:rxdart/rxdart.dart';
+
+sealed class TalkCallBloc implements InteractiveBloc {
+ factory TalkCallBloc(
+ spreed.SignalingSettings settings,
+ NextcloudClient client,
+ String roomToken,
+ String sessionID,
+ ) =>
+ _TalkCallBloc(
+ settings,
+ client,
+ roomToken,
+ sessionID,
+ );
+
+ Future leaveCall();
+
+ // ignore: avoid_positional_boolean_parameters
+ void changeAudio(bool enabled);
+
+ // ignore: avoid_positional_boolean_parameters
+ void changeVideo(bool enabled);
+
+ // ignore: avoid_positional_boolean_parameters
+ void changeScreen(bool enabled);
+
+ BehaviorSubject> get remoteParticipants;
+
+ BehaviorSubject get audioEnabled;
+
+ BehaviorSubject get videoEnabled;
+
+ BehaviorSubject get screenEnabled;
+
+ TalkLocalCallParticipant get localParticipant;
+}
+
+class _TalkCallBloc extends InteractiveBloc implements TalkCallBloc {
+ _TalkCallBloc(
+ this.settings,
+ this.client,
+ this.roomToken,
+ this.sessionID,
+ ) {
+ unawaited(_setupLocalParticipant().then((_) => refresh()));
+
+ listenForSignalingMessagesSubscription = _pullSignalingMessages().listen(onSignalingMessages);
+ }
+
+ @override
+ final log = Logger('TalkCallBloc');
+
+ final spreed.SignalingSettings settings;
+ final NextcloudClient client;
+ final String roomToken;
+ final String sessionID;
+ late final StreamSubscription> listenForSignalingMessagesSubscription;
+
+ @override
+ late TalkLocalCallParticipant localParticipant;
+
+ @override
+ void dispose() {
+ unawaited(listenForSignalingMessagesSubscription.cancel());
+ for (final participant in remoteParticipants.value) {
+ participant.dispose();
+ }
+ unawaited(remoteParticipants.close());
+ unawaited(audioEnabled.close());
+ unawaited(videoEnabled.close());
+ unawaited(screenEnabled.close());
+ super.dispose();
+ }
+
+ @override
+ final remoteParticipants = BehaviorSubject.seeded(BuiltList());
+
+ @override
+ final audioEnabled = BehaviorSubject.seeded(false);
+
+ @override
+ final videoEnabled = BehaviorSubject.seeded(false);
+
+ @override
+ final screenEnabled = BehaviorSubject.seeded(false);
+
+ @override
+ Future refresh() async {
+ await wrapAction(() async {
+ await client.spreed.call.joinCall(token: roomToken);
+ });
+ }
+
+ @override
+ Future leaveCall() async {
+ await wrapAction(() async {
+ await client.spreed.call.leaveCall(token: roomToken);
+ });
+ }
+
+ @override
+ Future changeAudio(bool enabled) async {
+ audioEnabled.add(enabled);
+ await _updateLocalParticipant();
+ }
+
+ @override
+ Future changeVideo(bool enabled) async {
+ videoEnabled.add(enabled);
+ await _updateLocalParticipant();
+ }
+
+ @override
+ void changeScreen(bool enabled) {
+ screenEnabled.add(enabled);
+ }
+
+ Future _setupLocalParticipant() async {
+ final stream = await navigator.mediaDevices.getUserMedia({
+ 'audio': true,
+ 'video': true,
+ });
+ for (final track in stream.getTracks()) {
+ track.enabled = false;
+ }
+ final renderer = await _getInitializedRenderer();
+ renderer.srcObject = stream;
+ localParticipant = TalkLocalCallParticipant(
+ settings.userId!,
+ sessionID,
+ renderer,
+ stream,
+ );
+ }
+
+ Future _sendSignalingMessages(BuiltList messages) async {
+ for (final message in messages) {
+ // TODO: Send all messages at once, needs to send it over the body and not the URL, because that gets too long
+ await wrapAction(() async {
+ await client.spreed.signaling.sendMessages(
+ token: roomToken,
+ messages: ContentString(
+ (b) => b
+ ..content = BuiltList([
+ spreed.SignalingSendMessagesMessages(
+ (b) => b
+ ..fn.update(
+ (b) => b..content = message,
+ )
+ ..sessionId = sessionID,
+ ),
+ ]),
+ ),
+ );
+ });
+ }
+ }
+
+ TalkRemoteCallParticipant? _getRemoteParticipant(String sessionID) {
+ final remoteParticipantMatches =
+ remoteParticipants.value.where((participant) => participant.sessionID == sessionID);
+ if (remoteParticipantMatches.length == 1) {
+ return remoteParticipantMatches.single;
+ }
+ return null;
+ }
+
+ Future _updateRemoteParticipant(
+ String sessionID,
+ Future Function(TalkRemoteCallParticipant) call,
+ ) async {
+ final updatedRemoteParticipants = ListBuilder();
+ for (final remoteParticipant in remoteParticipants.value) {
+ if (remoteParticipant.sessionID == sessionID) {
+ updatedRemoteParticipants.add(await call(remoteParticipant));
+ } else {
+ updatedRemoteParticipants.add(remoteParticipant);
+ }
+ }
+ remoteParticipants.add(updatedRemoteParticipants.build());
+ }
+
+ Stream> _pullSignalingMessages() async* {
+ // TODO: Cancel the loop
+ while (true) {
+ try {
+ yield (await client.spreed.signaling.pullMessages(token: roomToken)).body.ocs.data;
+ } on Exception catch (e, s) {
+ if (e is DynamiteStatusCodeException && e.statusCode >= 500) {
+ continue;
+ }
+ debugPrint(e.toString());
+ debugPrint(s.toString());
+ addError(e);
+ }
+ }
+ }
+
+ Future _updateLocalParticipant() async {
+ if (localParticipant.stream != null) {
+ for (final track in localParticipant.stream!.getTracks()) {
+ switch (track.kind) {
+ case 'video':
+ track.enabled = videoEnabled.value;
+ case 'audio':
+ track.enabled = audioEnabled.value;
+ default:
+ debugPrint('Unknown track kind ${track.kind}');
+ }
+ }
+ }
+
+ await _sendSignalingMessages(_generateMuteMessages(remoteParticipants.value));
+ }
+
+ BuiltList _generateMuteMessages(BuiltList participants) =>
+ BuiltList.build((b) {
+ for (final remoteParticipant in participants) {
+ for (final entry in {
+ spreed.SignalingMuteMessage_Payload_Name.audio: audioEnabled.value,
+ spreed.SignalingMuteMessage_Payload_Name.video: videoEnabled.value,
+ }.entries) {
+ b.add(
+ (
+ signalingICECandidateMessage: null,
+ signalingMuteMessage: spreed.SignalingMuteMessage(
+ (b) => b
+ ..from = sessionID
+ ..to = remoteParticipant.sessionID
+ ..type = entry.value ? spreed.SignalingMessageType.unmute : spreed.SignalingMessageType.mute
+ ..payload.update(
+ (b) => b.name = entry.key,
+ ),
+ ),
+ signalingSessionDescriptionMessage: null,
+ ),
+ );
+ }
+ }
+ });
+
+ bool _isWeakerParticipant(TalkRemoteCallParticipant remoteParticipant) =>
+ sessionID.compareTo(remoteParticipant.sessionID) > 0;
+
+ Future _sendOffer(TalkRemoteCallParticipant remoteParticipant) async {
+ debugPrint('Sending offer to ${remoteParticipant.userID} ${remoteParticipant.sessionID}');
+ final connection = await _setupConnection(remoteParticipant);
+ final localSDP = await connection.createOffer();
+ await connection.setLocalDescription(localSDP);
+ await _sendSignalingMessages(
+ BuiltList.build((b) {
+ b
+ ..add(
+ (
+ signalingICECandidateMessage: null,
+ signalingMuteMessage: null,
+ signalingSessionDescriptionMessage: spreed.SignalingSessionDescriptionMessage(
+ (b) => b
+ ..from = sessionID
+ ..to = remoteParticipant.sessionID
+ ..type = spreed.SignalingMessageType.offer
+ ..payload.update(
+ (b) => b
+ ..type = spreed.SignalingSessionDescriptionMessage_Payload_Type.offer
+ ..sdp = localSDP.sdp
+ ..nick = '',
+ ),
+ ),
+ ),
+ )
+ ..addAll(_generateMuteMessages(BuiltList.from([remoteParticipant])));
+ }),
+ );
+ }
+
+ Future _setupConnection(TalkRemoteCallParticipant remoteParticipant) async {
+ final connection = await createPeerConnection(
+ {
+ 'sdpSemantics': 'unified-plan',
+ 'iceServers': [
+ ...settings.stunservers.map((s) => s.toJson()),
+ ...settings.turnservers.map((s) => s.toJson()),
+ ],
+ },
+ );
+ connection
+ ..onTrack = (event) async {
+ if (event.track.kind == 'video') {
+ final stream = event.streams.first;
+ final renderer = await _getInitializedRenderer();
+ renderer.srcObject = stream;
+ await _updateRemoteParticipant(
+ remoteParticipant.sessionID,
+ (remoteParticipant) async => remoteParticipant
+ ..renderer = renderer
+ ..stream = stream,
+ );
+ }
+ }
+ ..onIceCandidate = (candidate) async {
+ await _sendSignalingMessages(
+ BuiltList.build((b) {
+ b.add(
+ (
+ signalingICECandidateMessage: spreed.SignalingICECandidateMessage(
+ (b) => b
+ ..from = sessionID
+ ..to = remoteParticipant.sessionID
+ ..type = spreed.SignalingMessageType.answer
+ ..payload.update(
+ (b) => b
+ ..candidate.update(
+ (b) => b
+ ..candidate = candidate.candidate
+ ..sdpMid = candidate.sdpMid
+ ..sdpMLineIndex = candidate.sdpMLineIndex,
+ ),
+ ),
+ ),
+ signalingMuteMessage: null,
+ signalingSessionDescriptionMessage: null,
+ ),
+ );
+ }),
+ );
+ }
+ ..onIceGatheringState = print
+ ..onIceConnectionState = print
+ ..onConnectionState = print;
+ await remoteParticipant.acceptNewConnection(connection);
+ await remoteParticipant.acceptNewLocalStream(localParticipant.stream);
+
+ return connection;
+ }
+
+ Future onSignalingMessages(BuiltList messages) async {
+ for (final message in messages) {
+ if (message.signalingSessions != null) {
+ final users = message.signalingSessions!.data.where(
+ (user) =>
+ spreed.ParticipantInCallFlag.values.byBinary(user.inCall).contains(spreed.ParticipantInCallFlag.inCall),
+ );
+
+ final currentParticipants = remoteParticipants.value;
+ final updatedParticipants = ListBuilder();
+
+ for (final currentParticipant in currentParticipants) {
+ if (users.where((user) => user.userId == currentParticipant.userID).isNotEmpty) {
+ updatedParticipants.add(currentParticipant);
+ } else {
+ currentParticipant.dispose();
+ }
+ }
+
+ for (final user in users) {
+ if (currentParticipants.where((currentParticipant) => user.userId == currentParticipant.userID).isEmpty &&
+ user.sessionId != sessionID) {
+ final remoteParticipant = TalkRemoteCallParticipant(
+ user.userId,
+ user.sessionId,
+ null,
+ null,
+ null,
+ null,
+ );
+ if (_isWeakerParticipant(remoteParticipant)) {
+ await _sendOffer(remoteParticipant);
+ }
+ updatedParticipants.add(remoteParticipant);
+ }
+ }
+ remoteParticipants.add(updatedParticipants.build());
+
+ continue;
+ }
+
+ if (message.signalingMessageWrapper != null) {
+ final signalingMessage = message.signalingMessageWrapper!.data.content;
+
+ if (signalingMessage.signalingSessionDescriptionMessage != null) {
+ final remoteSDP = signalingMessage.signalingSessionDescriptionMessage!;
+
+ await _updateRemoteParticipant(remoteSDP.from, (remoteParticipant) async {
+ switch (remoteSDP.payload.type) {
+ case spreed.SignalingSessionDescriptionMessage_Payload_Type.offer:
+ debugPrint('Received offer from ${remoteParticipant.userID} ${remoteParticipant.sessionID}');
+ final connection = await _setupConnection(remoteParticipant);
+ await connection.setRemoteDescription(
+ RTCSessionDescription(
+ remoteSDP.payload.sdp,
+ 'offer',
+ ),
+ );
+ final localSDP = await connection.createAnswer();
+ await connection.setLocalDescription(localSDP);
+ await _sendSignalingMessages(
+ BuiltList.build((b) {
+ b
+ ..add(
+ (
+ signalingICECandidateMessage: null,
+ signalingMuteMessage: null,
+ signalingSessionDescriptionMessage: spreed.SignalingSessionDescriptionMessage(
+ (b) => b
+ ..from = sessionID
+ ..to = remoteParticipant.sessionID
+ ..type = spreed.SignalingMessageType.answer
+ ..payload.update(
+ (b) => b
+ ..type = spreed.SignalingSessionDescriptionMessage_Payload_Type.answer
+ ..sdp = localSDP.sdp
+ ..nick = '',
+ ),
+ ),
+ ),
+ )
+ ..addAll(_generateMuteMessages(BuiltList.from([remoteParticipant])));
+ }),
+ );
+ case spreed.SignalingSessionDescriptionMessage_Payload_Type.answer:
+ debugPrint('Received answer from ${remoteParticipant.userID} ${remoteParticipant.sessionID}');
+ }
+
+ return remoteParticipant;
+ });
+
+ continue;
+ }
+
+ if (signalingMessage.signalingICECandidateMessage != null) {
+ final iceCandidateMessage = signalingMessage.signalingICECandidateMessage!;
+ final remoteParticipant = _getRemoteParticipant(iceCandidateMessage.from);
+ if (remoteParticipant == null) {
+ continue;
+ }
+
+ if (iceCandidateMessage.payload.candidate.candidate.isEmpty) {
+ // TODO: Handle end-of-candidates properly
+ continue;
+ }
+
+ await remoteParticipant.addCandidate(
+ RTCIceCandidate(
+ iceCandidateMessage.payload.candidate.candidate,
+ iceCandidateMessage.payload.candidate.sdpMid,
+ iceCandidateMessage.payload.candidate.sdpMLineIndex,
+ ),
+ );
+
+ continue;
+ }
+
+ if (signalingMessage.signalingMuteMessage != null) {
+ final muteMessage = signalingMessage.signalingMuteMessage!;
+
+ await _updateRemoteParticipant(muteMessage.from, (remoteParticipant) async {
+ final isUnmute = muteMessage.type == spreed.SignalingMessageType.unmute;
+ switch (muteMessage.payload.name) {
+ case spreed.SignalingMuteMessage_Payload_Name.audio:
+ remoteParticipant.audioEnabled = isUnmute;
+ case spreed.SignalingMuteMessage_Payload_Name.video:
+ remoteParticipant.videoEnabled = isUnmute;
+ }
+ return remoteParticipant;
+ });
+
+ continue;
+ }
+ }
+
+ debugPrint('Unknown signaling message ${message.toJson()}');
+ }
+ }
+}
+
+Future _getInitializedRenderer() async {
+ final renderer = RTCVideoRenderer();
+ await renderer.initialize();
+ return renderer;
+}
diff --git a/packages/neon/neon_talk/lib/src/blocs/room.dart b/packages/neon/neon_talk/lib/src/blocs/room.dart
new file mode 100644
index 00000000000..fd260299ac6
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/blocs/room.dart
@@ -0,0 +1,195 @@
+import 'dart:async';
+
+import 'package:built_collection/built_collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:logging/logging.dart';
+import 'package:neon_framework/blocs.dart';
+import 'package:neon_framework/models.dart';
+import 'package:neon_framework/utils.dart';
+import 'package:nextcloud/spreed.dart' as spreed;
+import 'package:rxdart/rxdart.dart';
+
+sealed class TalkRoomBloc implements InteractiveBloc {
+ factory TalkRoomBloc(
+ Account account,
+ spreed.Room room,
+ ) =>
+ _TalkRoomBloc(
+ account,
+ room,
+ );
+
+ Future loadMoreMessages();
+
+ void sendMessage(String message);
+
+ Future leaveRoom();
+
+ BehaviorSubject> get room;
+
+ BehaviorSubject>> get messages;
+
+ BehaviorSubject get allLoaded;
+
+ BehaviorSubject get sendingMessage;
+
+ BehaviorSubject get lastCommonReadMessageId;
+
+ String get roomToken;
+
+ String get actorId;
+}
+
+class _TalkRoomBloc extends InteractiveBloc implements TalkRoomBloc {
+ _TalkRoomBloc(
+ this.account,
+ spreed.Room r,
+ ) {
+ roomToken = r.token;
+ room.add(Result.success(r));
+
+ unawaited(refresh());
+ }
+
+ @override
+ final log = Logger('TalkCallBloc');
+
+ final Account account;
+ @override
+ late final String roomToken;
+ final _limit = 100;
+
+ int? _lastKnownMessageId;
+
+ @override
+ String get actorId => account.username;
+
+ @override
+ void dispose() {
+ unawaited(room.close());
+ unawaited(messages.close());
+ unawaited(allLoaded.close());
+ unawaited(sendingMessage.close());
+ unawaited(lastCommonReadMessageId.close());
+ super.dispose();
+ }
+
+ @override
+ final allLoaded = BehaviorSubject.seeded(false);
+
+ @override
+ final lastCommonReadMessageId = BehaviorSubject.seeded(null);
+
+ @override
+ final messages = BehaviorSubject();
+
+ @override
+ final room = BehaviorSubject();
+
+ @override
+ final sendingMessage = BehaviorSubject.seeded(null);
+
+ @override
+ Future refresh() async {
+ await RequestManager.instance.wrapNextcloud(
+ account: account,
+ cacheKey: 'spreed-room-$roomToken',
+ subject: room,
+ rawResponse: account.client.spreed.room.joinRoomRaw(token: roomToken),
+ unwrap: (response) => response.body.ocs.data,
+ );
+ await _loadMessages(force: true);
+ }
+
+ @override
+ Future sendMessage(String message) async {
+ sendingMessage.add(message);
+ await wrapAction(
+ () async {
+ await account.client.spreed.chat.sendMessage(
+ token: roomToken,
+ message: message,
+ );
+ },
+ refresh: () async {
+ await _loadMessages(force: true);
+ },
+ );
+ sendingMessage.add(null);
+ }
+
+ @override
+ Future loadMoreMessages() async {
+ await _loadMessages(force: false);
+ }
+
+ @override
+ Future leaveRoom() async {
+ await wrapAction(() async {
+ await account.client.spreed.room.leaveRoom(token: roomToken);
+ });
+ }
+
+ Future _loadMessages({required bool force}) async {
+ if (!force && (allLoaded.valueOrNull ?? false)) {
+ return;
+ }
+
+ final previousData = messages.valueOrNull?.data;
+ try {
+ messages.add(
+ Result(
+ previousData,
+ null,
+ isLoading: true,
+ isCached: true,
+ ),
+ );
+ final data = await account.client.spreed.chat.receiveMessages(
+ token: roomToken,
+ lookIntoFuture: spreed.ChatReceiveMessagesLookIntoFuture.$0,
+ includeLastKnown: spreed.ChatReceiveMessagesIncludeLastKnown.$1,
+ limit: _limit,
+ lastKnownMessageId: (force ? null : _lastKnownMessageId) ?? 0,
+ lastCommonReadId: lastCommonReadMessageId.valueOrNull ?? 0,
+ );
+
+ _lastKnownMessageId = data.headers.xChatLastGiven != null ? int.parse(data.headers.xChatLastGiven!) : null;
+ lastCommonReadMessageId.add(
+ data.headers.xChatLastCommonRead != null ? int.parse(data.headers.xChatLastCommonRead!) : null,
+ );
+
+ if (data.body.ocs.data.length < _limit) {
+ allLoaded.add(true);
+ }
+
+ messages.add(
+ Result.success(
+ BuiltList.from(
+ BuiltMap.build((b) {
+ if (previousData != null) {
+ for (final message in previousData) {
+ b[message.id] = message;
+ }
+ }
+ for (final message in data.body.ocs.data) {
+ b[message.id] = message;
+ }
+ }).values,
+ ),
+ ),
+ );
+ } catch (e, s) {
+ debugPrint(e.toString());
+ debugPrint(s.toString());
+ messages.add(
+ Result(
+ previousData,
+ e,
+ isLoading: false,
+ isCached: true,
+ ),
+ );
+ }
+ }
+}
diff --git a/packages/neon/neon_talk/lib/src/blocs/talk.dart b/packages/neon/neon_talk/lib/src/blocs/talk.dart
index 4616129f218..8764792631f 100644
--- a/packages/neon/neon_talk/lib/src/blocs/talk.dart
+++ b/packages/neon/neon_talk/lib/src/blocs/talk.dart
@@ -6,6 +6,7 @@ import 'package:meta/meta.dart';
import 'package:neon_framework/blocs.dart';
import 'package:neon_framework/models.dart';
import 'package:neon_framework/utils.dart';
+import 'package:nextcloud/core.dart' as core;
import 'package:nextcloud/spreed.dart' as spreed;
import 'package:rxdart/rxdart.dart';
@@ -20,6 +21,12 @@ sealed class TalkBloc implements InteractiveBloc {
/// The total number of unread messages.
BehaviorSubject get unreadCounter;
+
+ void createRoom(
+ spreed.RoomType type,
+ String? roomName,
+ core.AutocompleteResult? invite,
+ );
}
class _TalkBloc extends InteractiveBloc implements TalkBloc {
@@ -72,4 +79,20 @@ class _TalkBloc extends InteractiveBloc implements TalkBloc {
),
);
}
+
+ @override
+ Future createRoom(
+ spreed.RoomType type,
+ String? roomName,
+ core.AutocompleteResult? invite,
+ ) async {
+ await wrapAction(
+ () async => account.client.spreed.room.createRoom(
+ roomType: type.value,
+ roomName: roomName ?? '',
+ invite: invite?.id ?? '',
+ source: invite?.source ?? '',
+ ),
+ );
+ }
}
diff --git a/packages/neon/neon_talk/lib/src/dialogs/create_room.dart b/packages/neon/neon_talk/lib/src/dialogs/create_room.dart
new file mode 100644
index 00000000000..6b855f8d367
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/dialogs/create_room.dart
@@ -0,0 +1,149 @@
+import 'package:flutter/material.dart';
+import 'package:neon_framework/blocs.dart';
+import 'package:neon_framework/utils.dart';
+import 'package:neon_framework/widgets.dart';
+import 'package:neon_talk/l10n/localizations.dart';
+import 'package:nextcloud/core.dart' as core;
+import 'package:nextcloud/spreed.dart' as spreed;
+
+class TalkCreateRoomDialog extends StatefulWidget {
+ const TalkCreateRoomDialog({
+ super.key,
+ });
+
+ @override
+ State createState() => _TalkCreateRoomDialogState();
+}
+
+class _TalkCreateRoomDialogState extends State {
+ late final values = {
+ spreed.RoomType.oneToOne: TalkLocalizations.of(context).roomTypeOneToOne,
+ spreed.RoomType.group: TalkLocalizations.of(context).roomTypeGroup,
+ spreed.RoomType.public: TalkLocalizations.of(context).roomTypePublic,
+ };
+
+ final formKey = GlobalKey();
+ final controller = TextEditingController();
+ final focusNode = FocusNode();
+
+ spreed.RoomType? selectedType;
+ core.AutocompleteResult? selectedAutocompleteEntry;
+
+ void changeType(spreed.RoomType? type) {
+ controller.clear();
+ setState(() {
+ selectedType = type;
+ });
+ }
+
+ void submit() {
+ if (formKey.currentState!.validate()) {
+ Navigator.of(context).pop(
+ TalkCreateRoomDetails(
+ selectedType!,
+ selectedType! == spreed.RoomType.public ? controller.text : null,
+ selectedType! != spreed.RoomType.public ? selectedAutocompleteEntry : null,
+ ),
+ );
+ }
+ }
+
+ @override
+ void dispose() {
+ controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => NeonDialog(
+ title: Text(TalkLocalizations.of(context).roomCreate),
+ content: Form(
+ key: formKey,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (final type in values.keys)
+ ListTile(
+ title: Text(values[type]!),
+ leading: Icon(
+ type == spreed.RoomType.oneToOne
+ ? Icons.person
+ : type == spreed.RoomType.group
+ ? Icons.group
+ : Icons.public,
+ ),
+ trailing: Radio(
+ value: type,
+ groupValue: selectedType,
+ onChanged: changeType,
+ ),
+ onTap: () {
+ changeType(type);
+ },
+ ),
+ const Divider(),
+ if (selectedType == spreed.RoomType.oneToOne || selectedType == spreed.RoomType.group)
+ NeonAutocomplete(
+ key: Key(selectedType!.index.toString()),
+ account: NeonProvider.of(context).activeAccount.value!,
+ itemType: 'call',
+ itemId: 'new',
+ shareTypes: [
+ if (selectedType == spreed.RoomType.oneToOne)
+ core.ShareType.user
+ else if (selectedType == spreed.RoomType.group)
+ core.ShareType.group,
+ ],
+ validator: (input) => validateNotEmpty(context, input),
+ decoration: InputDecoration(
+ hintText: selectedType == spreed.RoomType.oneToOne
+ ? TalkLocalizations.of(context).roomCreateUserName
+ : TalkLocalizations.of(context).roomCreateGroupName,
+ ),
+ onSelected: (entry) {
+ setState(() {
+ selectedAutocompleteEntry = entry;
+ });
+ },
+ onFieldSubmitted: (_) {
+ submit();
+ },
+ ),
+ if (selectedType == spreed.RoomType.public)
+ TextFormField(
+ controller: controller,
+ focusNode: focusNode,
+ validator: (input) => validateNotEmpty(context, input),
+ decoration: InputDecoration(
+ hintText: TalkLocalizations.of(context).roomCreateRoomName,
+ ),
+ onFieldSubmitted: (_) {
+ submit();
+ },
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ NeonDialogAction(
+ onPressed: selectedType == null ? null : submit,
+ child: Text(TalkLocalizations.of(context).roomCreate),
+ ),
+ ],
+ );
+}
+
+class TalkCreateRoomDetails {
+ TalkCreateRoomDetails(
+ this.type,
+ this.roomName,
+ this.invite,
+ );
+
+ final spreed.RoomType type;
+
+ final String? roomName;
+
+ final core.AutocompleteResult? invite;
+}
diff --git a/packages/neon/neon_talk/lib/src/dialogs/select_screen.dart b/packages/neon/neon_talk/lib/src/dialogs/select_screen.dart
new file mode 100644
index 00000000000..cb853c1f437
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/dialogs/select_screen.dart
@@ -0,0 +1,111 @@
+import 'dart:async';
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:neon_framework/widgets.dart';
+import 'package:neon_talk/l10n/localizations.dart';
+import 'package:neon_talk/src/widgets/screen_preview.dart';
+
+class TalkSelectScreenDialog extends StatefulWidget {
+ const TalkSelectScreenDialog({
+ super.key,
+ });
+
+ @override
+ State createState() => _TalkSelectScreenDialogState();
+}
+
+class _TalkSelectScreenDialogState extends State {
+ List? sources;
+ DesktopCapturerSource? selectedSource;
+ late Timer timer;
+
+ @override
+ void initState() {
+ super.initState();
+
+ unawaited(
+ desktopCapturer.getSources(types: SourceType.values).then((sources) {
+ setState(() {
+ this.sources = sources;
+ });
+ }),
+ );
+ timer = Timer.periodic(const Duration(seconds: 1), (_) async {
+ await desktopCapturer.updateSources(types: SourceType.values);
+ });
+ }
+
+ @override
+ void dispose() {
+ timer.cancel();
+
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => NeonDialog(
+ title: Text(TalkLocalizations.of(context).screenSharingSelectScreen),
+ content: sources == null
+ ? const SizedBox.shrink()
+ : ListView(
+ children: _buildSources().toList(),
+ ),
+ actions: [
+ NeonDialogAction(
+ onPressed: selectedSource == null
+ ? null
+ : () {
+ Navigator.of(context).pop(selectedSource);
+ },
+ child: Text(TalkLocalizations.of(context).screenSharingSelectScreen),
+ ),
+ ],
+ );
+
+ Iterable _buildSources() sync* {
+ for (var i = 0; i < SourceType.values.length; i++) {
+ final sourceType = SourceType.values.reversed.elementAt(i);
+
+ if (sourceType == SourceType.Screen) {
+ yield Text(TalkLocalizations.of(context).screenSharingSelectScreenScreens);
+ } else {
+ yield Text(TalkLocalizations.of(context).screenSharingSelectScreenWindows);
+ }
+
+ yield Wrap(
+ spacing: 10,
+ runSpacing: 10,
+ children: [
+ for (final source in sources!.where((source) => source.type == sourceType))
+ InkWell(
+ onTap: () {
+ setState(() {
+ selectedSource = source;
+ });
+ },
+ child: Container(
+ padding: const EdgeInsets.all(5),
+ decoration: BoxDecoration(
+ border: Border.all(
+ // Transparent to prevent the image from jumping around when changing the selected source
+ color: selectedSource == source ? Theme.of(context).colorScheme.primary : Colors.transparent,
+ width: 2,
+ ),
+ ),
+ width: max(MediaQuery.of(context).size.width, MediaQuery.of(context).size.height) / 5,
+ child: TalkScreenPreview(
+ source: source,
+ ),
+ ),
+ ),
+ ],
+ );
+
+ if (i != SourceType.values.length - 1) {
+ yield const Divider();
+ }
+ }
+ }
+}
diff --git a/packages/neon/neon_talk/lib/src/pages/call.dart b/packages/neon/neon_talk/lib/src/pages/call.dart
new file mode 100644
index 00000000000..f45a19a0872
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/pages/call.dart
@@ -0,0 +1,153 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:intersperse/intersperse.dart';
+import 'package:neon_framework/widgets.dart';
+import 'package:neon_talk/src/blocs/call.dart';
+import 'package:neon_talk/src/dialogs/select_screen.dart';
+import 'package:neon_talk/src/utils/view_size.dart';
+import 'package:neon_talk/src/widgets/call_button.dart';
+import 'package:neon_talk/src/widgets/call_participant_view.dart';
+
+class TalkCallPage extends StatefulWidget {
+ const TalkCallPage({
+ required this.bloc,
+ super.key,
+ });
+
+ final TalkCallBloc bloc;
+
+ @override
+ State createState() => _TalkCallPageState();
+}
+
+class _TalkCallPageState extends State {
+ @override
+ void initState() {
+ widget.bloc.errors.listen((error) {
+ if (!mounted) {
+ return;
+ }
+ NeonError.showSnackbar(context, error);
+ });
+
+ super.initState();
+ }
+
+ @override
+ Widget build(BuildContext context) => StreamBuilder(
+ stream: widget.bloc.audioEnabled,
+ builder: (context, audioEnabledSnapshot) => StreamBuilder(
+ stream: widget.bloc.videoEnabled,
+ builder: (context, videoEnabledSnapshot) => StreamBuilder(
+ stream: widget.bloc.screenEnabled,
+ builder: (context, screenEnabledSnapshot) {
+ final audioEnabled = audioEnabledSnapshot.data ?? false;
+ final videoEnabled = videoEnabledSnapshot.data ?? false;
+ final screenEnabled = screenEnabledSnapshot.data ?? false;
+ return Scaffold(
+ appBar: AppBar(
+ automaticallyImplyLeading: false,
+ actions: [
+ IconButton(
+ icon: Icon(
+ audioEnabled ? MdiIcons.microphone : MdiIcons.microphoneOff,
+ color: !audioEnabled ? Colors.red : null,
+ ),
+ onPressed: () {
+ widget.bloc.changeAudio(!audioEnabled);
+ },
+ ),
+ IconButton(
+ icon: Icon(
+ videoEnabled ? MdiIcons.video : MdiIcons.videoOff,
+ color: !videoEnabled ? Colors.red : null,
+ ),
+ onPressed: () {
+ widget.bloc.changeVideo(!videoEnabled);
+ },
+ ),
+ IconButton(
+ icon: Icon(
+ screenEnabled ? MdiIcons.monitorShare : MdiIcons.monitorOff,
+ color: !screenEnabled ? Colors.red : null,
+ ),
+ onPressed: () async {
+ if (!screenEnabled) {
+ final result = await showDialog(
+ context: context,
+ builder: (context) => const TalkSelectScreenDialog(),
+ );
+ if (result == null) {
+ return;
+ }
+ }
+ widget.bloc.changeScreen(!screenEnabled);
+ },
+ ),
+ TalkCallButton(
+ type: TalkCallButtonType.leaveCall,
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ ),
+ ]
+ .intersperse(
+ const SizedBox(
+ width: 20,
+ ),
+ )
+ .toList(),
+ ),
+ body: StreamBuilder(
+ stream: widget.bloc.remoteParticipants,
+ builder: (context, remoteParticipantsSnapshot) {
+ if (remoteParticipantsSnapshot.data == null) {
+ return Center(
+ child: LayoutBuilder(
+ builder: (context, constraints) => SizedBox(
+ width: constraints.maxWidth / 2,
+ child: const NeonLinearProgressIndicator(),
+ ),
+ ),
+ );
+ }
+
+ final participants = [
+ ...remoteParticipantsSnapshot.requireData,
+ widget.bloc.localParticipant,
+ ];
+
+ return Center(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final viewSize = calculateViewSize(participants.length, constraints.biggest);
+ return Wrap(
+ alignment: WrapAlignment.center,
+ children: [
+ for (final participant in participants)
+ Container(
+ constraints: BoxConstraints(
+ maxWidth: viewSize.width,
+ maxHeight: viewSize.height,
+ ),
+ child: TalkCallParticipantView(
+ participant: participant,
+ localAudioEnabled: audioEnabled,
+ localVideoEnabled: videoEnabled,
+ localScreenEnabled: screenEnabled,
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ },
+ ),
+ );
+ },
+ ),
+ ),
+ );
+}
diff --git a/packages/neon/neon_talk/lib/src/pages/main.dart b/packages/neon/neon_talk/lib/src/pages/main.dart
index e1009777bc2..098acc244b1 100644
--- a/packages/neon/neon_talk/lib/src/pages/main.dart
+++ b/packages/neon/neon_talk/lib/src/pages/main.dart
@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
import 'package:neon_framework/blocs.dart';
import 'package:neon_framework/utils.dart';
import 'package:neon_framework/widgets.dart';
+import 'package:neon_talk/src/blocs/room.dart';
import 'package:neon_talk/src/blocs/talk.dart';
+import 'package:neon_talk/src/dialogs/create_room.dart';
+import 'package:neon_talk/src/pages/room.dart';
import 'package:neon_talk/src/widgets/message.dart';
import 'package:neon_talk/src/widgets/room_avatar.dart';
import 'package:neon_talk/src/widgets/unread_indicator.dart';
@@ -40,15 +43,34 @@ class _TalkMainPageState extends State {
}
@override
- Widget build(BuildContext context) => ResultBuilder.behaviorSubject(
- subject: bloc.rooms,
- builder: (context, rooms) => NeonListView(
- scrollKey: 'talk-rooms',
- isLoading: rooms.isLoading,
- error: rooms.error,
- onRefresh: bloc.refresh,
- itemCount: rooms.data?.length ?? 0,
- itemBuilder: (context, index) => buildRoom(rooms.requireData[index]),
+ Widget build(BuildContext context) => Scaffold(
+ body: ResultBuilder.behaviorSubject(
+ subject: bloc.rooms,
+ builder: (context, rooms) => NeonListView(
+ scrollKey: 'talk-rooms',
+ isLoading: rooms.isLoading,
+ error: rooms.error,
+ onRefresh: bloc.refresh,
+ itemCount: rooms.data?.length ?? 0,
+ itemBuilder: (context, index) => buildRoom(rooms.requireData[index]),
+ ),
+ ),
+ floatingActionButton: FloatingActionButton(
+ child: const Icon(Icons.add),
+ onPressed: () async {
+ final result = await showDialog(
+ context: context,
+ builder: (context) => const TalkCreateRoomDialog(),
+ );
+ if (result == null) {
+ return;
+ }
+ bloc.createRoom(
+ result.type,
+ result.roomName,
+ result.invite,
+ );
+ },
),
);
@@ -78,6 +100,21 @@ class _TalkMainPageState extends State {
title: Text(room.displayName),
subtitle: subtitle,
trailing: trailing,
+ onTap: () async {
+ final roomBloc = TalkRoomBloc(
+ NeonProvider.of(context).activeAccount.value!,
+ room,
+ );
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (context) => TalkRoomPage(
+ bloc: roomBloc,
+ ),
+ ),
+ );
+ await roomBloc.leaveRoom();
+ bloc.dispose();
+ },
);
}
}
diff --git a/packages/neon/neon_talk/lib/src/pages/room.dart b/packages/neon/neon_talk/lib/src/pages/room.dart
new file mode 100644
index 00000000000..46ba602d8a6
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/pages/room.dart
@@ -0,0 +1,408 @@
+import 'package:built_collection/built_collection.dart';
+import 'package:built_value/json_object.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_chat_types/flutter_chat_types.dart' as chat_types;
+import 'package:flutter_chat_ui/flutter_chat_ui.dart' as chat_ui;
+import 'package:go_router/go_router.dart';
+import 'package:neon_framework/blocs.dart';
+import 'package:neon_framework/utils.dart';
+import 'package:neon_framework/widgets.dart';
+import 'package:neon_talk/src/blocs/call.dart';
+import 'package:neon_talk/src/blocs/room.dart';
+import 'package:neon_talk/src/pages/call.dart';
+import 'package:neon_talk/src/utils/text_matchers.dart';
+import 'package:neon_talk/src/widgets/call_button.dart';
+import 'package:neon_talk/src/widgets/room_avatar.dart';
+import 'package:nextcloud/core.dart';
+import 'package:nextcloud/spreed.dart' as spreed;
+
+class TalkRoomPage extends StatefulWidget {
+ const TalkRoomPage({
+ required this.bloc,
+ super.key,
+ });
+
+ final TalkRoomBloc bloc;
+
+ @override
+ State createState() => _TalkRoomPageState();
+}
+
+class _TalkRoomPageState extends State {
+ final defaultChatTheme = const chat_ui.DefaultChatTheme();
+
+ late final chatTheme = chat_ui.DefaultChatTheme(
+ backgroundColor: Theme.of(context).colorScheme.background,
+ primaryColor: Theme.of(context).colorScheme.primary,
+ secondaryColor: Theme.of(context).colorScheme.secondary,
+ inputBackgroundColor: Theme.of(context).colorScheme.primary,
+ inputTextColor: Theme.of(context).colorScheme.onPrimary,
+ inputTextCursorColor: Theme.of(context).colorScheme.onPrimary,
+ receivedMessageBodyTextStyle: defaultChatTheme.receivedMessageBodyTextStyle.copyWith(
+ color: Theme.of(context).colorScheme.onSecondary,
+ ),
+ sentMessageBodyTextStyle: defaultChatTheme.sentMessageBodyTextStyle.copyWith(
+ color: Theme.of(context).colorScheme.onPrimary,
+ ),
+ unreadHeaderTheme: chat_ui.UnreadHeaderTheme(
+ color: Theme.of(context).colorScheme.background,
+ textStyle: TextStyle(
+ color: Theme.of(context).colorScheme.primary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ );
+
+ final inputOptions = const chat_ui.InputOptions(
+ sendButtonVisibilityMode: chat_ui.SendButtonVisibilityMode.always,
+ );
+
+ late final user = chat_types.User(
+ id: widget.bloc.actorId,
+ );
+
+ void onSendPressed(chat_types.PartialText partialText) {
+ widget.bloc.sendMessage(partialText.text);
+ }
+
+ Future openCall(spreed.Room room) async {
+ try {
+ final client = NeonProvider.of(context).activeAccount.value!.client;
+ final settings = (await client.spreed.signaling.getSettings(token: widget.bloc.roomToken)).body.ocs.data;
+ final bloc = TalkCallBloc(
+ settings,
+ client,
+ widget.bloc.roomToken,
+ room.sessionId,
+ );
+ if (!mounted) {
+ return;
+ }
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (context) => TalkCallPage(
+ bloc: bloc,
+ ),
+ ),
+ );
+ await bloc.leaveCall();
+ bloc.dispose();
+ } catch (e, s) {
+ debugPrint(e.toString());
+ debugPrint(s.toString());
+ if (mounted) {
+ NeonError.showSnackbar(context, e);
+ }
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+
+ widget.bloc.errors.listen((error) {
+ NeonError.showSnackbar(context, error);
+ });
+ }
+
+ Iterable _buildTitle(Result room) sync* {
+ if (room.hasData) {
+ final roomType = spreed.RoomType.fromValue(room.requireData.type);
+
+ if (roomType.isSingleUser) {
+ yield TalkRoomAvatar(
+ room: room.requireData,
+ );
+
+ yield const SizedBox(
+ width: 8,
+ );
+ }
+
+ yield Flexible(
+ child: Text(room.requireData.displayName),
+ );
+ }
+
+ if (room.hasError) {
+ yield const SizedBox(
+ width: 8,
+ );
+
+ yield Icon(
+ Icons.error_outline,
+ size: 30,
+ color: Theme.of(context).colorScheme.onPrimary,
+ );
+ }
+
+ if (room.isLoading) {
+ yield const SizedBox(
+ width: 8,
+ );
+
+ yield Expanded(
+ child: NeonLinearProgressIndicator(
+ color: Theme.of(context).appBarTheme.foregroundColor,
+ ),
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) => StreamBuilder(
+ stream: widget.bloc.allLoaded,
+ builder: (context, allLoadedSnapshot) => ResultBuilder(
+ stream: widget.bloc.room,
+ builder: (context, room) => StreamBuilder(
+ stream: widget.bloc.lastCommonReadMessageId,
+ builder: (context, lastCommonReadMessageIdSnapshot) => StreamBuilder(
+ stream: widget.bloc.sendingMessage,
+ builder: (context, sendingMessageSnapshot) => ResultBuilder(
+ stream: widget.bloc.messages,
+ builder: (context, messages) {
+ final roomType = room.hasData ? spreed.RoomType.fromValue(room.requireData.type) : null;
+ return Scaffold(
+ resizeToAvoidBottomInset: false,
+ appBar: AppBar(
+ titleSpacing: 0,
+ title: Row(
+ children: _buildTitle(room).toList(),
+ ),
+ actions: [
+ if (room.hasData && room.requireData.readOnly == 0)
+ if (room.requireData.hasCall)
+ TalkCallButton(
+ type: TalkCallButtonType.joinCall,
+ onPressed: () async {
+ await openCall(room.requireData);
+ },
+ )
+ else if (room.requireData.canStartCall)
+ TalkCallButton(
+ type: TalkCallButtonType.startCall,
+ onPressed: () async {
+ await openCall(room.requireData);
+ },
+ ),
+ ],
+ ),
+ body: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: 1120,
+ ),
+ child: chat_ui.Chat(
+ useTopSafeAreaInset: false,
+ showUserNames: true,
+ showUserAvatars: !(roomType?.isSingleUser ?? true),
+ theme: chatTheme,
+ inputOptions: inputOptions,
+ scrollToUnreadOptions: chat_ui.ScrollToUnreadOptions(
+ lastReadMessageId: room.data?.lastReadMessage.toString(),
+ scrollOnOpen: true,
+ scrollDelay: Duration.zero,
+ ),
+ avatarBuilder: (user) => NeonUserAvatar(
+ username: user.id,
+ showStatus: false,
+ ),
+ textMessageBuilder: (
+ message, {
+ required messageWidth,
+ required showName,
+ }) =>
+ chat_ui.TextMessage(
+ emojiEnlargementBehavior: chat_ui.EmojiEnlargementBehavior.multi,
+ hideBackgroundOnEmojiMessages: true,
+ message: message,
+ showName: showName,
+ usePreviewData: true,
+ options: chat_ui.TextMessageOptions(
+ matchers: getTextMatchers(
+ messageParameters: message.metadata,
+ style: chatTheme.sentMessageBodyTextStyle,
+ fullContent: true,
+ ),
+ ),
+ ),
+ systemMessageBuilder: (message) => chat_ui.SystemMessage(
+ message: message.text,
+ options: chat_ui.TextMessageOptions(
+ matchers: getTextMatchers(
+ messageParameters: message.metadata,
+ style: chatTheme.sentMessageBodyTextStyle,
+ fullContent: true,
+ ),
+ ),
+ ),
+ fileMessageBuilder: (message, {required messageWidth}) => InkWell(
+ onTap: () {
+ final account = NeonProvider.of(context).activeAccount.value!;
+ context.go(account.completeUri(Uri.parse(message.uri)).toString());
+ },
+ child: chat_ui.FileMessage(
+ message: message,
+ ),
+ ),
+ imageMessageBuilder: (message, {required messageWidth}) {
+ final file = message.metadata!['file'] as BuiltMap;
+ final id = int.parse((file['id']! as StringJsonObject).value);
+ final path = (file['path']! as StringJsonObject).value;
+ final etag = (file['etag']! as StringJsonObject).value;
+ final width = (file['width']! as NumJsonObject).value.toInt();
+ final height = (file['height']! as NumJsonObject).value.toInt();
+
+ return InkWell(
+ onTap: () {
+ final account = NeonProvider.of(context).activeAccount.value!;
+ context.go(account.completeUri(Uri.parse(message.uri)).toString());
+ },
+ child: NeonApiImage(
+ getImage: (client) => client.core.preview.getPreviewByFileIdRaw(
+ fileId: id,
+ x: width,
+ y: height,
+ ),
+ cacheKey: 'preview-$path-$width-$height',
+ etag: etag,
+ expires: null,
+ ),
+ );
+ },
+ customBottomWidget: Column(
+ children: [
+ NeonError(
+ messages.error,
+ onRetry: widget.bloc.refresh,
+ ),
+ if (messages.isLoading)
+ const NeonLinearProgressIndicator(
+ margin: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ ),
+ if ((room.data?.readOnly ?? 0) == 0)
+ chat_ui.Input(
+ onSendPressed: onSendPressed,
+ options: inputOptions,
+ ),
+ ],
+ ),
+ user: user,
+ onEndReached: widget.bloc.loadMoreMessages,
+ onSendPressed: onSendPressed,
+ isLastPage: allLoadedSnapshot.data ?? false,
+ messages: [
+ if (sendingMessageSnapshot.hasData)
+ chat_types.TextMessage(
+ id: 'sending',
+ author: user,
+ text: sendingMessageSnapshot.data!,
+ showStatus: true,
+ status: chat_types.Status.sending,
+ ),
+ if (messages.hasData)
+ ...messages.requireData
+ .map(
+ (message) => _talkMessageToChatMessage(
+ message,
+ lastCommonReadMessageId: lastCommonReadMessageIdSnapshot.data,
+ ),
+ )
+ .whereNotNull(),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+
+ chat_types.Message? _talkMessageToChatMessage(
+ spreed.$ChatMessageInterface message, {
+ int? lastCommonReadMessageId,
+ }) {
+ final id = message.id.toString();
+ final author = chat_types.User(
+ id: message.actorId,
+ firstName: message.actorDisplayName,
+ imageUrl: message.actorId,
+ );
+ final createdAt = message.timestamp * 1000;
+ // TODO: Doesn't work yet in the UI. See https://github.com/flyerhq/flutter_chat_ui/pull/256
+ final repliedMessage = message is spreed.ChatMessageWithParent && message.parent != null
+ ? _talkMessageToChatMessage(message.parent!)
+ : null;
+ final status = lastCommonReadMessageId != null && lastCommonReadMessageId >= message.id
+ ? chat_types.Status.seen
+ : chat_types.Status.sent;
+ final metadata = message.messageParameters.toMap();
+
+ switch (message.messageType) {
+ case spreed.MessageType.comment:
+ final file = metadata['file'];
+ if (file != null) {
+ final name = (file['name']! as StringJsonObject).value;
+ final size = (file['size']! as NumJsonObject).value.toInt();
+ final link = (file['link']! as StringJsonObject).value;
+ final mimetype = (file['mimetype']! as StringJsonObject).value;
+ final previewAvailable = (file['preview-available']! as StringJsonObject).value == 'yes';
+
+ // TODO: Handle file images with text
+ if (previewAvailable) {
+ return chat_types.ImageMessage(
+ id: id,
+ author: author,
+ createdAt: createdAt,
+ repliedMessage: repliedMessage,
+ showStatus: true,
+ status: status,
+ metadata: metadata,
+ name: name,
+ size: size,
+ uri: link,
+ );
+ } else {
+ return chat_types.FileMessage(
+ id: id,
+ author: author,
+ createdAt: createdAt,
+ repliedMessage: repliedMessage,
+ showStatus: true,
+ status: status,
+ metadata: metadata,
+ name: name,
+ size: size,
+ uri: link,
+ mimeType: mimetype,
+ );
+ }
+ }
+
+ return chat_types.TextMessage(
+ id: id,
+ author: author,
+ createdAt: createdAt,
+ repliedMessage: repliedMessage,
+ showStatus: true,
+ status: status,
+ metadata: metadata,
+ text: message.message,
+ );
+ case spreed.MessageType.command:
+ case spreed.MessageType.system:
+ return chat_types.SystemMessage(
+ id: id,
+ createdAt: createdAt,
+ text: message.message,
+ metadata: metadata,
+ );
+ default:
+ return null;
+ }
+ }
+}
diff --git a/packages/neon/neon_talk/lib/src/utils/participants.dart b/packages/neon/neon_talk/lib/src/utils/participants.dart
new file mode 100644
index 00000000000..e20a2002d68
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/utils/participants.dart
@@ -0,0 +1,113 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+
+abstract class TalkCallParticipant {
+ TalkCallParticipant(
+ this.userID,
+ this.sessionID,
+ this.renderer,
+ this.stream,
+ );
+
+ final String userID;
+ final String sessionID;
+ RTCVideoRenderer? renderer;
+ MediaStream? stream;
+
+ void dispose() {
+ stream?.getTracks().forEach((track) => unawaited(track.stop()));
+ unawaited(stream?.dispose());
+ renderer?.srcObject = null;
+ unawaited(renderer?.dispose());
+ }
+}
+
+class TalkLocalCallParticipant extends TalkCallParticipant {
+ TalkLocalCallParticipant(
+ super.userID,
+ super.sessionID,
+ super.renderer,
+ super.stream,
+ );
+}
+
+class TalkRemoteCallParticipant extends TalkCallParticipant {
+ TalkRemoteCallParticipant(
+ super.userID,
+ super.sessionID,
+ super.renderer,
+ super.stream,
+ this._connection,
+ this._senders, {
+ this.audioEnabled = false,
+ this.videoEnabled = false,
+ });
+
+ RTCPeerConnection? _connection;
+ List? _senders;
+ final List _candidates = [];
+ bool audioEnabled;
+ bool videoEnabled;
+
+ RTCPeerConnection? get connection => _connection;
+ List? get senders => _senders;
+
+ Future _clearSenders() async {
+ if (_senders != null && _connection != null) {
+ for (final sender in _senders!) {
+ await _connection!.removeTrack(sender);
+ }
+ }
+ if (_senders != null) {
+ for (final sender in _senders!) {
+ try {
+ await sender.dispose();
+ } catch (_) {
+ // TODO: Somehow peerConnection is null when calling this on disposing the participant
+ }
+ }
+ _senders = null;
+ }
+ }
+
+ Future acceptNewConnection(RTCPeerConnection? connection) async {
+ await _clearSenders();
+ await _connection?.close();
+ _connection = connection;
+ if (_connection != null) {
+ for (final candidate in _candidates) {
+ debugPrint('Loading candidate');
+ await _connection!.addCandidate(candidate);
+ }
+ _candidates.clear();
+ }
+ }
+
+ Future acceptNewLocalStream(MediaStream? stream) async {
+ await _clearSenders();
+ if (_connection != null && stream != null) {
+ _senders = [];
+ for (final track in stream.getTracks()) {
+ _senders!.add(await _connection!.addTrack(track, stream));
+ }
+ }
+ }
+
+ Future addCandidate(RTCIceCandidate candidate) async {
+ if (connection != null) {
+ await connection!.addCandidate(candidate);
+ } else {
+ _candidates.add(candidate);
+ debugPrint('Storing candidate for later use');
+ }
+ }
+
+ @override
+ void dispose() {
+ unawaited(_clearSenders());
+ unawaited(_connection?.close());
+ super.dispose();
+ }
+}
diff --git a/packages/neon/neon_talk/lib/src/utils/text_matchers.dart b/packages/neon/neon_talk/lib/src/utils/text_matchers.dart
new file mode 100644
index 00000000000..b6f3e6a4532
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/utils/text_matchers.dart
@@ -0,0 +1,38 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_chat_ui/flutter_chat_ui.dart' as chat_ui;
+import 'package:flutter_parsed_text/flutter_parsed_text.dart';
+import 'package:neon_framework/widgets.dart';
+
+List getTextMatchers({
+ required Map? messageParameters,
+ required bool fullContent,
+ required TextStyle style,
+}) =>
+ [
+ chat_ui.mailToMatcher(
+ style: style.copyWith(
+ decoration: TextDecoration.underline,
+ decorationColor: style.color,
+ ),
+ ),
+ chat_ui.urlMatcher(
+ style: style.copyWith(
+ decoration: TextDecoration.underline,
+ decorationColor: style.color,
+ ),
+ ),
+ chat_ui.boldMatcher(style: style.merge(chat_ui.PatternStyle.bold.textStyle)),
+ chat_ui.italicMatcher(style: style.merge(chat_ui.PatternStyle.italic.textStyle)),
+ chat_ui.lineThroughMatcher(
+ style: style.merge(chat_ui.PatternStyle.lineThrough.textStyle).copyWith(
+ decorationColor: style.color,
+ ),
+ ),
+ chat_ui.codeMatcher(style: style.merge(chat_ui.PatternStyle.code.textStyle)),
+ if (messageParameters != null)
+ NeonRichObject(
+ parameters: messageParameters,
+ style: style,
+ fullContent: fullContent,
+ ),
+ ];
diff --git a/packages/neon/neon_talk/lib/src/utils/view_size.dart b/packages/neon/neon_talk/lib/src/utils/view_size.dart
new file mode 100644
index 00000000000..f2fe6ab3414
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/utils/view_size.dart
@@ -0,0 +1,22 @@
+import 'dart:math';
+import 'dart:ui';
+
+Size calculateViewSize(int count, Size constraints) {
+ const aspectRatio = 2 / 3;
+ Size? bestSize;
+
+ for (var i = 1.0; i < min(constraints.width, constraints.height / aspectRatio) + 1; i++) {
+ final width = i;
+ final height = i * aspectRatio;
+ if ((constraints.width ~/ width) * (constraints.height ~/ height) >= count) {
+ bestSize = Size(
+ width,
+ height,
+ );
+ } else {
+ break;
+ }
+ }
+
+ return bestSize ?? Size.zero;
+}
diff --git a/packages/neon/neon_talk/lib/src/widgets/call_button.dart b/packages/neon/neon_talk/lib/src/widgets/call_button.dart
new file mode 100644
index 00000000000..c03a3773e43
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/widgets/call_button.dart
@@ -0,0 +1,55 @@
+import 'package:flutter/material.dart';
+import 'package:neon_talk/l10n/localizations.dart';
+
+class TalkCallButton extends StatelessWidget {
+ const TalkCallButton({
+ required this.type,
+ required this.onPressed,
+ super.key,
+ });
+
+ final TalkCallButtonType type;
+
+ final VoidCallback? onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ late final String label;
+ late final IconData icon;
+ late final Color? backgroundColor;
+ switch (type) {
+ case TalkCallButtonType.startCall:
+ icon = Icons.videocam;
+ label = TalkLocalizations.of(context).callStart;
+ backgroundColor = null;
+ case TalkCallButtonType.joinCall:
+ icon = Icons.phone;
+ label = TalkLocalizations.of(context).callJoin;
+ backgroundColor = Colors.green;
+ case TalkCallButtonType.leaveCall:
+ icon = Icons.videocam_off;
+ label = TalkLocalizations.of(context).callLeave;
+ backgroundColor = Colors.red;
+ }
+ return Container(
+ margin: const EdgeInsets.all(5),
+ child: ElevatedButton.icon(
+ onPressed: onPressed,
+ icon: Icon(icon),
+ label: Text(label),
+ style: backgroundColor != null
+ ? ElevatedButton.styleFrom(
+ backgroundColor: backgroundColor,
+ foregroundColor: Theme.of(context).colorScheme.background,
+ )
+ : null,
+ ),
+ );
+ }
+}
+
+enum TalkCallButtonType {
+ startCall,
+ joinCall,
+ leaveCall,
+}
diff --git a/packages/neon/neon_talk/lib/src/widgets/call_participant_view.dart b/packages/neon/neon_talk/lib/src/widgets/call_participant_view.dart
new file mode 100644
index 00000000000..7f7775647b6
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/widgets/call_participant_view.dart
@@ -0,0 +1,73 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:neon_framework/widgets.dart';
+import 'package:neon_talk/src/utils/participants.dart';
+
+class TalkCallParticipantView extends StatelessWidget {
+ const TalkCallParticipantView({
+ required this.participant,
+ required this.localAudioEnabled,
+ required this.localVideoEnabled,
+ required this.localScreenEnabled,
+ super.key,
+ });
+
+ final TalkCallParticipant participant;
+ final bool localAudioEnabled;
+ final bool localVideoEnabled;
+ final bool localScreenEnabled;
+
+ @override
+ Widget build(BuildContext context) {
+ final hasEnabledVideoTracks =
+ participant.renderer?.srcObject?.getVideoTracks().where((track) => track.enabled).isNotEmpty ?? false;
+ final audioEnabled = participant is TalkLocalCallParticipant
+ ? localAudioEnabled
+ : (participant as TalkRemoteCallParticipant).audioEnabled;
+ final videoEnabled = participant is TalkLocalCallParticipant
+ ? localVideoEnabled
+ : (participant as TalkRemoteCallParticipant).videoEnabled;
+ return LayoutBuilder(
+ builder: (context, constraints) => Container(
+ margin: const EdgeInsets.all(5),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(10),
+ child: ColoredBox(
+ color: Theme.of(context).colorScheme.primary,
+ child: Stack(
+ children: [
+ Center(
+ child: hasEnabledVideoTracks && videoEnabled
+ ? RTCVideoView(
+ participant.renderer!,
+ objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
+ )
+ : NeonUserAvatar(
+ username: participant.userID,
+ showStatus: false,
+ size: min(constraints.maxHeight, constraints.maxWidth) / 2,
+ ),
+ ),
+ if (!audioEnabled)
+ Align(
+ alignment: Alignment.bottomRight,
+ child: Container(
+ margin: const EdgeInsets.all(5),
+ child: Icon(
+ MdiIcons.microphoneOff,
+ size: 28,
+ color: Theme.of(context).colorScheme.onPrimary,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/packages/neon/neon_talk/lib/src/widgets/screen_preview.dart b/packages/neon/neon_talk/lib/src/widgets/screen_preview.dart
new file mode 100644
index 00000000000..bf30a915e4f
--- /dev/null
+++ b/packages/neon/neon_talk/lib/src/widgets/screen_preview.dart
@@ -0,0 +1,62 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+
+class TalkScreenPreview extends StatefulWidget {
+ const TalkScreenPreview({
+ required this.source,
+ super.key,
+ });
+
+ final DesktopCapturerSource source;
+
+ @override
+ State createState() => _TalkScreenPreviewState();
+}
+
+class _TalkScreenPreviewState extends State {
+ late final List> subscriptions = [];
+
+ @override
+ void initState() {
+ super.initState();
+ subscriptions.addAll([
+ widget.source.onThumbnailChanged.stream.listen((_) => setState(() {})),
+ widget.source.onNameChanged.stream.listen((_) => setState(() {})),
+ ]);
+ }
+
+ @override
+ void dispose() {
+ for (final subscription in subscriptions) {
+ unawaited(subscription.cancel());
+ }
+
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => Column(
+ children: [
+ if (widget.source.thumbnail != null)
+ AspectRatio(
+ aspectRatio: 3 / 2,
+ child: Image.memory(
+ widget.source.thumbnail!,
+ gaplessPlayback: true,
+ fit: BoxFit.contain,
+ ),
+ ),
+ Text(
+ widget.source.name,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ );
+}
diff --git a/packages/neon/neon_talk/pubspec.yaml b/packages/neon/neon_talk/pubspec.yaml
index 658ed817975..28e6fc96c33 100644
--- a/packages/neon/neon_talk/pubspec.yaml
+++ b/packages/neon/neon_talk/pubspec.yaml
@@ -8,10 +8,17 @@ environment:
dependencies:
built_collection: ^5.0.0
+ built_value: ^8.9.0
+ collection: ^1.0.0
flutter:
sdk: flutter
+ flutter_chat_types: ^3.6.2
+ flutter_chat_ui: ^1.6.12
flutter_localizations:
sdk: flutter
+ flutter_material_design_icons: ^1.0.0
+ flutter_parsed_text: ^2.1.0
+ flutter_webrtc: ^0.9.48+hotfix.1
go_router: ^13.0.0
intersperse: ^2.0.0
logging: ^1.0.0
@@ -22,6 +29,7 @@ dependencies:
path: packages/neon_framework
nextcloud: ^5.0.2
rxdart: ^0.27.0
+ vector_graphics: ^1.0.0
dev_dependencies:
build_runner: ^2.4.8
diff --git a/packages/neon_framework/lib/src/widgets/autocomplete.dart b/packages/neon_framework/lib/src/widgets/autocomplete.dart
new file mode 100644
index 00000000000..50ef301124a
--- /dev/null
+++ b/packages/neon_framework/lib/src/widgets/autocomplete.dart
@@ -0,0 +1,95 @@
+// ignore_for_file: public_member_api_docs
+
+import 'package:built_collection/built_collection.dart';
+import 'package:flutter/material.dart';
+import 'package:neon_framework/src/models/account.dart';
+import 'package:neon_framework/src/widgets/group_avatar.dart';
+import 'package:neon_framework/src/widgets/user_avatar.dart';
+import 'package:nextcloud/core.dart' as core;
+
+class NeonAutocomplete extends StatelessWidget {
+ const NeonAutocomplete({
+ required this.account,
+ required this.itemType,
+ required this.itemId,
+ required this.shareTypes,
+ required this.onSelected,
+ this.sorter,
+ this.limit = 10,
+ this.validator,
+ this.decoration,
+ this.onFieldSubmitted,
+ super.key,
+ });
+
+ final Account account;
+
+ final String itemType;
+ final String itemId;
+ final List shareTypes;
+ final void Function(core.AutocompleteResult entry) onSelected;
+ final String? sorter;
+ final int limit;
+ final FormFieldValidator? validator;
+ final InputDecoration? decoration;
+ final ValueChanged? onFieldSubmitted;
+
+ @override
+ Widget build(BuildContext context) => Autocomplete(
+ fieldViewBuilder: (
+ context,
+ controller,
+ focusNode,
+ onFieldSubmitted,
+ ) =>
+ TextFormField(
+ controller: controller,
+ focusNode: focusNode,
+ validator: validator,
+ decoration: decoration,
+ onFieldSubmitted: (value) {
+ onFieldSubmitted();
+ this.onFieldSubmitted?.call(value);
+ },
+ ),
+ optionsBuilder: (text) async {
+ final result = await account.client.core.autoComplete.$get(
+ search: text.text,
+ itemType: itemType,
+ itemId: itemId,
+ shareTypes: BuiltList(shareTypes.map((s) => s.index)),
+ );
+ return result.body.ocs.data;
+ },
+ displayStringForOption: (option) => option.id,
+ optionsViewBuilder: (context, onSelected, options) => Material(
+ elevation: 4,
+ child: ListView.builder(
+ padding: EdgeInsets.zero,
+ shrinkWrap: true,
+ itemCount: options.length,
+ itemBuilder: (context, index) {
+ final option = options.elementAt(index);
+ Widget? icon;
+ switch (option.source) {
+ case 'users':
+ icon = NeonUserAvatar(
+ username: option.id,
+ );
+ case 'groups':
+ icon = const NeonGroupAvatar();
+ }
+ return ListTile(
+ title: Text(option.label),
+ subtitle: Text(option.id),
+ leading: icon,
+ onTap: () {
+ onSelected(option);
+ },
+ );
+ },
+ ),
+ ),
+ onSelected: onSelected,
+ );
+}
diff --git a/packages/neon_framework/lib/src/widgets/group_avatar.dart b/packages/neon_framework/lib/src/widgets/group_avatar.dart
new file mode 100644
index 00000000000..134af0c2cbc
--- /dev/null
+++ b/packages/neon_framework/lib/src/widgets/group_avatar.dart
@@ -0,0 +1,30 @@
+// ignore_for_file: public_member_api_docs
+
+import 'package:flutter/material.dart';
+import 'package:neon_framework/src/theme/sizes.dart';
+
+class NeonGroupAvatar extends StatelessWidget {
+ const NeonGroupAvatar({
+ this.icon = Icons.group,
+ this.backgroundColor,
+ this.foregroundColor,
+ this.size = smallIconSize,
+ super.key,
+ });
+
+ final IconData icon;
+ final Color? backgroundColor;
+ final Color? foregroundColor;
+ final double size;
+
+ @override
+ Widget build(BuildContext context) => CircleAvatar(
+ radius: size,
+ backgroundColor: backgroundColor,
+ child: Icon(
+ icon,
+ color: foregroundColor,
+ size: size,
+ ),
+ );
+}
diff --git a/packages/neon_framework/lib/src/widgets/rich_object.dart b/packages/neon_framework/lib/src/widgets/rich_object.dart
new file mode 100644
index 00000000000..f41bf443663
--- /dev/null
+++ b/packages/neon_framework/lib/src/widgets/rich_object.dart
@@ -0,0 +1,133 @@
+// ignore_for_file: public_member_api_docs
+
+import 'package:built_collection/built_collection.dart';
+import 'package:built_value/json_object.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_parsed_text/flutter_parsed_text.dart';
+import 'package:go_router/go_router.dart';
+import 'package:neon_framework/src/blocs/accounts.dart';
+import 'package:neon_framework/src/utils/provider.dart';
+import 'package:neon_framework/src/widgets/group_avatar.dart';
+import 'package:neon_framework/src/widgets/image.dart';
+import 'package:neon_framework/src/widgets/user_avatar.dart';
+import 'package:nextcloud/core.dart';
+
+class NeonRichObject extends MatchText {
+ NeonRichObject({
+ required Map parameters,
+ required TextStyle style,
+ required bool fullContent,
+ }) : super(
+ onTap: (_) {},
+ pattern: '{(${parameters.keys.join('|')})}',
+ renderWidget: ({
+ required pattern,
+ required text,
+ }) =>
+ Builder(
+ builder: (context) {
+ final richObject = parameters[text.substring(1, text.length - 1)] as BuiltMap;
+ final type = (richObject['type']! as StringJsonObject).value;
+
+ switch (type) {
+ case 'user':
+ final account = NeonProvider.of(context).activeAccount.value!;
+ final id = (richObject['id']! as StringJsonObject).value;
+
+ final highlight = id == account.username;
+ return _buildChip(
+ context: context,
+ avatar: NeonUserAvatar.withAccount(
+ account: account,
+ username: id,
+ showStatus: false,
+ size: 20,
+ ),
+ label: Text(
+ (richObject['name']! as StringJsonObject).value,
+ style: style.copyWith(
+ color: highlight
+ ? Theme.of(context).colorScheme.onPrimary
+ : Theme.of(context).colorScheme.onBackground,
+ ),
+ ),
+ highlight: highlight,
+ );
+ case 'group':
+ case 'call':
+ return _buildChip(
+ context: context,
+ avatar: const NeonGroupAvatar(
+ size: 10,
+ ),
+ label: Text(
+ (richObject['name']! as StringJsonObject).value,
+ style: style,
+ ),
+ highlight: true,
+ );
+ case 'file':
+ void onTap() {
+ final link = Uri.parse((richObject['link']! as StringJsonObject).value);
+ final account = NeonProvider.of(context).activeAccount.value!;
+ context.go(account.completeUri(link).toString());
+ }
+ final previewAvailable = (richObject['preview-available']! as StringJsonObject).value == 'yes';
+ if (previewAvailable && fullContent) {
+ final id = int.parse((richObject['id']! as StringJsonObject).value);
+ final path = (richObject['path']! as StringJsonObject).value;
+ final etag = (richObject['etag']! as StringJsonObject).value;
+ final width = (richObject['width']! as NumJsonObject).value.toInt();
+ final height = (richObject['height']! as NumJsonObject).value.toInt();
+ return InkWell(
+ onTap: onTap,
+ child: NeonApiImage(
+ cacheKey: 'preview-$path-$width-$height',
+ etag: etag,
+ expires: null,
+ getImage: (client) => client.core.preview.getPreviewByFileIdRaw(
+ fileId: id,
+ x: width,
+ y: height,
+ ),
+ ),
+ );
+ } else {
+ final name = (richObject['name']! as StringJsonObject).value;
+ return InkWell(
+ onTap: onTap,
+ child: Text(
+ name,
+ style: style.copyWith(
+ decoration: TextDecoration.underline,
+ decorationColor: style.color,
+ ),
+ ),
+ );
+ }
+ default:
+ debugPrint('Rich message type $richObject not implemented yet');
+ return Text(
+ text,
+ style: style,
+ );
+ }
+ },
+ ),
+ );
+
+ static Widget _buildChip({
+ required BuildContext context,
+ required Widget avatar,
+ required Widget label,
+ required bool highlight,
+ }) =>
+ Chip(
+ shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50))),
+ avatar: avatar,
+ label: label,
+ backgroundColor: highlight ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.background,
+ padding: EdgeInsets.zero,
+ labelPadding: const EdgeInsets.only(right: 7.5),
+ );
+}
diff --git a/packages/neon_framework/lib/widgets.dart b/packages/neon_framework/lib/widgets.dart
index fac6ab9cf27..e0893866430 100644
--- a/packages/neon_framework/lib/widgets.dart
+++ b/packages/neon_framework/lib/widgets.dart
@@ -1,11 +1,14 @@
+export 'package:neon_framework/src/widgets/autocomplete.dart';
export 'package:neon_framework/src/widgets/custom_background.dart';
export 'package:neon_framework/src/widgets/dialog.dart'
hide AccountDeletion, NeonAccountDeletionDialog, NeonAccountSelectionDialog, NeonUnifiedPushDialog;
export 'package:neon_framework/src/widgets/error.dart';
+export 'package:neon_framework/src/widgets/group_avatar.dart';
export 'package:neon_framework/src/widgets/image.dart' hide NeonImage;
export 'package:neon_framework/src/widgets/linear_progress_indicator.dart';
export 'package:neon_framework/src/widgets/list_view.dart';
export 'package:neon_framework/src/widgets/relative_time.dart';
+export 'package:neon_framework/src/widgets/rich_object.dart';
export 'package:neon_framework/src/widgets/server_icon.dart';
export 'package:neon_framework/src/widgets/user_avatar.dart' hide NeonUserStatusIndicator;
export 'package:neon_framework/src/widgets/user_status_icon.dart';
diff --git a/packages/neon_framework/pubspec.yaml b/packages/neon_framework/pubspec.yaml
index 286b7ed927a..efc4437cca3 100644
--- a/packages/neon_framework/pubspec.yaml
+++ b/packages/neon_framework/pubspec.yaml
@@ -27,6 +27,7 @@ dependencies:
sdk: flutter
flutter_material_design_icons: ^1.0.0
flutter_native_splash: ^2.0.0
+ flutter_parsed_text: ^2.1.0
flutter_svg: ^2.0.0
flutter_zxing: ^1.0.0
go_router: ^13.0.0
diff --git a/packages/nextcloud/lib/src/api/spreed.openapi.dart b/packages/nextcloud/lib/src/api/spreed.openapi.dart
index d5c8c70d3f0..41472363bc9 100644
--- a/packages/nextcloud/lib/src/api/spreed.openapi.dart
+++ b/packages/nextcloud/lib/src/api/spreed.openapi.dart
@@ -13586,7 +13586,7 @@ class $SignalingClient {
/// See:
/// * [sendMessagesRaw] for an experimental operation that returns a `DynamiteRawResponse` that can be serialized.
Future<_i1.DynamiteResponse> sendMessages({
- required String messages,
+ required ContentString> messages,
required String token,
SignalingSendMessagesApiVersion? apiVersion,
bool? oCSAPIRequest,
@@ -13622,7 +13622,7 @@ class $SignalingClient {
/// * [sendMessages] for an operation that returns a `DynamiteResponse` with a stable API.
@_i2.experimental
Future<_i1.DynamiteRawResponse> sendMessagesRaw({
- required String messages,
+ required ContentString> messages,
required String token,
SignalingSendMessagesApiVersion? apiVersion,
bool? oCSAPIRequest,
@@ -13645,7 +13645,12 @@ class $SignalingClient {
}
// coverage:ignore-end
- final $messages = _$jsonSerializers.serialize(messages, specifiedType: const FullType(String));
+ final $messages = _$jsonSerializers.serialize(
+ messages,
+ specifiedType: const FullType(ContentString, [
+ FullType(BuiltList, [FullType(SignalingSendMessagesMessages)]),
+ ]),
+ );
_parameters['messages'] = $messages;
final $token = _$jsonSerializers.serialize(token, specifiedType: const FullType(String));
@@ -38655,36 +38660,296 @@ abstract class SignalingSession
static Serializer get serializer => _$signalingSessionSerializer;
}
-typedef SignalingPullMessagesResponseApplicationJson_Ocs_Data_Data = ({
- BuiltList? builtListSignalingSession,
- String? string
-});
-
@BuiltValue(instantiable: false)
-abstract interface class $SignalingPullMessagesResponseApplicationJson_Ocs_DataInterface {
+abstract interface class $SignalingSessionsInterface {
String get type;
- SignalingPullMessagesResponseApplicationJson_Ocs_Data_Data get data;
+ BuiltList get data;
+}
+
+abstract class SignalingSessions
+ implements $SignalingSessionsInterface, Built {
+ /// Creates a new SignalingSessions object using the builder pattern.
+ factory SignalingSessions([void Function(SignalingSessionsBuilder)? b]) = _$SignalingSessions;
+
+ // coverage:ignore-start
+ const SignalingSessions._();
+ // coverage:ignore-end
+
+ /// Creates a new object from the given [json] data.
+ ///
+ /// Use [toJson] to serialize it back into json.
+ // coverage:ignore-start
+ factory SignalingSessions.fromJson(Map json) => _$jsonSerializers.deserializeWith(serializer, json)!;
+ // coverage:ignore-end
+
+ /// Parses this object into a json like map.
+ ///
+ /// Use the fromJson factory to revive it again.
+ // coverage:ignore-start
+ Map toJson() => _$jsonSerializers.serializeWith(serializer, this)! as Map;
+ // coverage:ignore-end
+
+ /// Serializer for SignalingSessions.
+ static Serializer get serializer => _$signalingSessionsSerializer;
+}
+
+class SignalingMessageType extends EnumClass {
+ const SignalingMessageType._(super.name);
+
+ /// `offer`
+ static const SignalingMessageType offer = _$signalingMessageTypeOffer;
+
+ /// `answer`
+ static const SignalingMessageType answer = _$signalingMessageTypeAnswer;
+
+ /// `candidate`
+ static const SignalingMessageType candidate = _$signalingMessageTypeCandidate;
+
+ /// `unshareScreen`
+ static const SignalingMessageType unshareScreen = _$signalingMessageTypeUnshareScreen;
+
+ /// `remove-candidates`
+ @BuiltValueEnumConst(wireName: 'remove-candidates')
+ static const SignalingMessageType removeCandidates = _$signalingMessageTypeRemoveCandidates;
+
+ /// `control`
+ static const SignalingMessageType control = _$signalingMessageTypeControl;
+
+ /// `forceMute`
+ static const SignalingMessageType forceMute = _$signalingMessageTypeForceMute;
+
+ /// `mute`
+ static const SignalingMessageType mute = _$signalingMessageTypeMute;
+
+ /// `unmute`
+ static const SignalingMessageType unmute = _$signalingMessageTypeUnmute;
+
+ /// `nickChanged`
+ static const SignalingMessageType nickChanged = _$signalingMessageTypeNickChanged;
+
+ /// Returns a set with all values this enum contains.
+ // coverage:ignore-start
+ static BuiltSet get values => _$signalingMessageTypeValues;
+ // coverage:ignore-end
+
+ /// Returns the enum value associated to the [name].
+ static SignalingMessageType valueOf(String name) => _$valueOfSignalingMessageType(name);
+
+ /// Returns the serialized value of this enum value.
+ String get value => _$jsonSerializers.serializeWith(serializer, this)! as String;
+
+ /// Serializer for SignalingMessageType.
+ @BuiltValueSerializer(custom: true)
+ static Serializer get serializer => const _$SignalingMessageTypeSerializer();
+}
+
+class _$SignalingMessageTypeSerializer implements PrimitiveSerializer {
+ const _$SignalingMessageTypeSerializer();
+
+ static const Map _toWire = {
+ SignalingMessageType.offer: 'offer',
+ SignalingMessageType.answer: 'answer',
+ SignalingMessageType.candidate: 'candidate',
+ SignalingMessageType.unshareScreen: 'unshareScreen',
+ SignalingMessageType.removeCandidates: 'remove-candidates',
+ SignalingMessageType.control: 'control',
+ SignalingMessageType.forceMute: 'forceMute',
+ SignalingMessageType.mute: 'mute',
+ SignalingMessageType.unmute: 'unmute',
+ SignalingMessageType.nickChanged: 'nickChanged',
+ };
+
+ static const Map