diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 4fa457ba54..f6641a2a6e 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/video.svg b/assets/icons/video.svg new file mode 100644 index 0000000000..efeaa6d55a --- /dev/null +++ b/assets/icons/video.svg @@ -0,0 +1,8 @@ + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 5afc9544d2..7003f4a12c 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -478,6 +478,10 @@ "@errorCouldNotEditMessageTitle": { "description": "Error title when an exception prevented us from opening the compose box for editing a message." }, + "errorCouldNotAppendCallUrl": "Fail to get call URL", + "@errorCouldNotAppendCallUrl": { + "description": "Error title when fetching a call URL failed." + }, "successLinkCopied": "Link copied", "@successLinkCopied": { "description": "Success message after copy link action completed." @@ -574,6 +578,14 @@ "@composeBoxAttachFromCameraTooltip": { "description": "Tooltip for compose box icon to attach an image from the camera to the message." }, + "composeBoxAddVideoCallTooltip": "Add video call", + "@composeBoxAddVideoCallTooltip": { + "description": "Tooltip for compose box icon to add a video call url to the message." + }, + "composeBoxAddVoiceCallTooltip": "Add voice call", + "@composeBoxAddVoiceCallTooltip": { + "description": "Tooltip for compose box icon to add a voice call url to the message." + }, "composeBoxGenericContentHint": "Type a message", "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." @@ -654,6 +666,14 @@ "filename": {"type": "String", "example": "file.txt"} } }, + "composeBoxVideoCallLinkText": "Join video call.", + "@composeBoxVideoCallLinkText": { + "description": "Text for a Markdown link to a video-call URL." + }, + "composeBoxVoiceCallLinkText": "Join voice call.", + "@composeBoxVoiceCallLinkText": { + "description": "Text for a Markdown link to a voice-call URL." + }, "composeBoxLoadingMessage": "(loading message {messageId})", "@composeBoxLoadingMessage": { "description": "Placeholder in compose box showing the quoted message is currently loading.", diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index c3a2eb0126..b76ea7d9a1 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -29,6 +29,7 @@ sealed class Event { case 'update': return UserSettingsUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'has_zoom_token': return HasZoomTokenEvent.fromJson(json); case 'custom_profile_fields': return CustomProfileFieldsEvent.fromJson(json); case 'user_group': switch (json['op'] as String) { @@ -1632,6 +1633,27 @@ enum ReactionOp { remove, } +/// A Zulip event of type `hasZoomToken`: https://zulip.com/api/get-events#has_zoom_token +@JsonSerializable(fieldRename: FieldRename.snake) +class HasZoomTokenEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'has_zoom_token'; + + final bool value; + + HasZoomTokenEvent({ + required super.id, + required this.value, + }); + + factory HasZoomTokenEvent.fromJson(Map json) => + _$HasZoomTokenEventFromJson(json); + + @override + Map toJson() => _$HasZoomTokenEventToJson(this); +} + /// A Zulip event of type `heartbeat`: https://zulip.com/api/get-events#heartbeat @JsonSerializable(fieldRename: FieldRename.snake) class HeartbeatEvent extends Event { diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index fc0c3a95e2..dc633bfae2 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -1025,6 +1025,19 @@ const _$ReactionTypeEnumMap = { ReactionType.zulipExtraEmoji: 'zulip_extra_emoji', }; +HasZoomTokenEvent _$HasZoomTokenEventFromJson(Map json) => + HasZoomTokenEvent( + id: (json['id'] as num).toInt(), + value: json['value'] as bool, + ); + +Map _$HasZoomTokenEventToJson(HasZoomTokenEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'value': instance.value, + }; + HeartbeatEvent _$HeartbeatEventFromJson(Map json) => HeartbeatEvent(id: (json['id'] as num).toInt()); diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 2f0257eaac..b4bfd34445 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -76,6 +76,8 @@ class InitialSnapshot { final List userTopics; + final bool hasZoomToken; + final GroupSettingValue? realmCanDeleteAnyMessageGroup; // TODO(server-10) final GroupSettingValue? realmCanDeleteOwnMessageGroup; // TODO(server-10) @@ -96,6 +98,10 @@ class InitialSnapshot { final String realmName; + final RealmVideoChatProvider realmVideoChatProvider; + + final String? realmJitsiServerUrl; // TODO(server-8) + /// The number of days until a user's account is treated as a full member. /// /// Search for "realm_waiting_period_threshold" in https://zulip.com/api/register-queue. @@ -113,14 +119,20 @@ class InitialSnapshot { final Uri realmIconUrl; + final Map realmAvailableVideoChatProviders; + final bool realmPresenceDisabled; final Map realmDefaultExternalAccounts; + final String? jitsiServerUrl; // TODO(server-8): Can ignore this deprecated field + final int maxFileUploadSizeMib; final Uri serverEmojiDataUrl; + final String? serverJitsiServerUrl; // TODO(server-8) + final String? realmEmptyTopicDisplayName; // TODO(server-10) @JsonKey(readValue: _readUsersIsActiveFallbackTrue) @@ -182,22 +194,28 @@ class InitialSnapshot { required this.userStatuses, required this.userSettings, required this.userTopics, + required this.hasZoomToken, required this.realmCanDeleteAnyMessageGroup, required this.realmCanDeleteOwnMessageGroup, required this.realmDeleteOwnMessagePolicy, required this.realmWildcardMentionPolicy, required this.realmMandatoryTopics, required this.realmName, + required this.realmVideoChatProvider, + required this.realmJitsiServerUrl, required this.realmWaitingPeriodThreshold, required this.realmMessageContentDeleteLimitSeconds, required this.realmAllowMessageEditing, required this.realmMessageContentEditLimitSeconds, required this.realmEnableReadReceipts, required this.realmIconUrl, + required this.realmAvailableVideoChatProviders, required this.realmPresenceDisabled, required this.realmDefaultExternalAccounts, + required this.jitsiServerUrl, required this.maxFileUploadSizeMib, required this.serverEmojiDataUrl, + required this.serverJitsiServerUrl, required this.realmEmptyTopicDisplayName, required this.realmUsers, required this.realmNonActiveUsers, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index dac7c141f7..9288769af8 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -86,6 +86,7 @@ InitialSnapshot _$InitialSnapshotFromJson( userTopics: (json['user_topics'] as List) .map((e) => UserTopicItem.fromJson(e as Map)) .toList(), + hasZoomToken: json['has_zoom_token'] as bool, realmCanDeleteAnyMessageGroup: json['realm_can_delete_any_message_group'] == null ? null @@ -104,6 +105,11 @@ InitialSnapshot _$InitialSnapshotFromJson( ), realmMandatoryTopics: json['realm_mandatory_topics'] as bool, realmName: json['realm_name'] as String, + realmVideoChatProvider: $enumDecode( + _$RealmVideoChatProviderEnumMap, + json['realm_video_chat_provider'], + ), + realmJitsiServerUrl: json['realm_jitsi_server_url'] as String?, realmWaitingPeriodThreshold: (json['realm_waiting_period_threshold'] as num) .toInt(), realmMessageContentDeleteLimitSeconds: @@ -113,6 +119,16 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['realm_message_content_edit_limit_seconds'] as num?)?.toInt(), realmEnableReadReceipts: json['realm_enable_read_receipts'] as bool, realmIconUrl: Uri.parse(json['realm_icon_url'] as String), + realmAvailableVideoChatProviders: + (json['realm_available_video_chat_providers'] as Map) + .map( + (k, e) => MapEntry( + k, + RealmAvailableVideoChatProviders.fromJson( + e as Map, + ), + ), + ), realmPresenceDisabled: json['realm_presence_disabled'] as bool, realmDefaultExternalAccounts: (json['realm_default_external_accounts'] as Map).map( @@ -121,8 +137,10 @@ InitialSnapshot _$InitialSnapshotFromJson( RealmDefaultExternalAccount.fromJson(e as Map), ), ), + jitsiServerUrl: json['jitsi_server_url'] as String?, maxFileUploadSizeMib: (json['max_file_upload_size_mib'] as num).toInt(), serverEmojiDataUrl: Uri.parse(json['server_emoji_data_url'] as String), + serverJitsiServerUrl: json['server_jitsi_server_url'] as String?, realmEmptyTopicDisplayName: json['realm_empty_topic_display_name'] as String?, realmUsers: (InitialSnapshot._readUsersIsActiveFallbackTrue(json, 'realm_users') @@ -179,12 +197,15 @@ Map _$InitialSnapshotToJson( 'user_status': instance.userStatuses.map((k, e) => MapEntry(k.toString(), e)), 'user_settings': instance.userSettings, 'user_topics': instance.userTopics, + 'has_zoom_token': instance.hasZoomToken, 'realm_can_delete_any_message_group': instance.realmCanDeleteAnyMessageGroup, 'realm_can_delete_own_message_group': instance.realmCanDeleteOwnMessageGroup, 'realm_delete_own_message_policy': instance.realmDeleteOwnMessagePolicy, 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy, 'realm_mandatory_topics': instance.realmMandatoryTopics, 'realm_name': instance.realmName, + 'realm_video_chat_provider': instance.realmVideoChatProvider, + 'realm_jitsi_server_url': instance.realmJitsiServerUrl, 'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold, 'realm_message_content_delete_limit_seconds': instance.realmMessageContentDeleteLimitSeconds, @@ -193,10 +214,14 @@ Map _$InitialSnapshotToJson( instance.realmMessageContentEditLimitSeconds, 'realm_enable_read_receipts': instance.realmEnableReadReceipts, 'realm_icon_url': instance.realmIconUrl.toString(), + 'realm_available_video_chat_providers': + instance.realmAvailableVideoChatProviders, 'realm_presence_disabled': instance.realmPresenceDisabled, 'realm_default_external_accounts': instance.realmDefaultExternalAccounts, + 'jitsi_server_url': instance.jitsiServerUrl, 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, 'server_emoji_data_url': instance.serverEmojiDataUrl.toString(), + 'server_jitsi_server_url': instance.serverJitsiServerUrl, 'realm_empty_topic_display_name': instance.realmEmptyTopicDisplayName, 'realm_users': instance.realmUsers, 'realm_non_active_users': instance.realmNonActiveUsers, @@ -220,6 +245,15 @@ const _$RealmWildcardMentionPolicyEnumMap = { RealmWildcardMentionPolicy.moderators: 7, }; +const _$RealmVideoChatProviderEnumMap = { + RealmVideoChatProvider.none: 0, + RealmVideoChatProvider.jitsiMeet: 1, + RealmVideoChatProvider.zoomUserOAuth: 3, + RealmVideoChatProvider.bigBlueButton: 4, + RealmVideoChatProvider.zoomServerToServerOAuth: 5, + RealmVideoChatProvider.unknown: null, +}; + RealmDefaultExternalAccount _$RealmDefaultExternalAccountFromJson( Map json, ) => RealmDefaultExternalAccount( diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index c7ae2fb6ac..69bd493271 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -56,6 +56,44 @@ class GroupSettingValueNameless extends GroupSettingValue { Map toJson() => _$GroupSettingValueNamelessToJson(this); } +/// As in [InitialSnapshot.realmAvailableVideoChatProviders]. +/// +/// For docs, search for "realm_available_video_chat_providers:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class RealmAvailableVideoChatProviders { + final String name; + final int id; + + RealmAvailableVideoChatProviders({ + required this.name, + required this.id, + }); + + factory RealmAvailableVideoChatProviders.fromJson(Map json) => + _$RealmAvailableVideoChatProvidersFromJson(json); + + Map toJson() => _$RealmAvailableVideoChatProvidersToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class VideoCallResponse { + final String msg; + final String result; + final String url; + + VideoCallResponse({ + required this.msg, + required this.result, + required this.url, + }); + + factory VideoCallResponse.fromJson(Map json) => + _$VideoCallResponseFromJson(json); + + Map toJson() => _$VideoCallResponseToJson(this); +} + /// As in [InitialSnapshot.customProfileFields]. /// /// For docs, search for "custom_profile_fields:" @@ -406,6 +444,28 @@ enum Emojiset { String toJson() => _$EmojisetEnumMap[this]!; } +/// As in [InitialSnapshot.realmVideoChatProvider]. +/// +/// For docs, search for "realm_video_chat_provider:" +/// in . +@JsonEnum(fieldRename: FieldRename.snake, valueField: "apiValue") +enum RealmVideoChatProvider { + none(apiValue: 0), + jitsiMeet(apiValue: 1), + zoomUserOAuth(apiValue: 3), + bigBlueButton(apiValue: 4), + zoomServerToServerOAuth(apiValue: 5), + unknown(apiValue: null); + + const RealmVideoChatProvider({ + required this.apiValue, + }); + + final int? apiValue; + + int? toJson() => apiValue; +} + /// As in [InitialSnapshot.realmUserGroups] or [UserGroupAddEvent]. @JsonSerializable(fieldRename: FieldRename.snake) class UserGroup { diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 95c5588392..57708c00ba 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -26,6 +26,31 @@ Map _$GroupSettingValueNamelessToJson( 'direct_subgroups': instance.directSubgroups, }; +RealmAvailableVideoChatProviders _$RealmAvailableVideoChatProvidersFromJson( + Map json, +) => RealmAvailableVideoChatProviders( + name: json['name'] as String, + id: (json['id'] as num).toInt(), +); + +Map _$RealmAvailableVideoChatProvidersToJson( + RealmAvailableVideoChatProviders instance, +) => {'name': instance.name, 'id': instance.id}; + +VideoCallResponse _$VideoCallResponseFromJson(Map json) => + VideoCallResponse( + msg: json['msg'] as String, + result: json['result'] as String, + url: json['url'] as String, + ); + +Map _$VideoCallResponseToJson(VideoCallResponse instance) => + { + 'msg': instance.msg, + 'result': instance.result, + 'url': instance.url, + }; + CustomProfileField _$CustomProfileFieldFromJson(Map json) => CustomProfileField( id: (json['id'] as num).toInt(), diff --git a/lib/api/route/video_call.dart b/lib/api/route/video_call.dart new file mode 100644 index 0000000000..18bbdc3b7e --- /dev/null +++ b/lib/api/route/video_call.dart @@ -0,0 +1,23 @@ +import '../core.dart'; +import '../model/model.dart'; + +/// Creates a Zoom video/audio call +/// +/// POST /api/v1/calls/zoom/create +Future createZoomCall(ApiConnection connection, { + required bool isVideoCall, +}) async { + return connection.post('createZoomCall', VideoCallResponse.fromJson, + '/calls/zoom/create', {'is_video_call': isVideoCall}); +} + +/// Creates a BigBlueButton meeting +/// +/// GET /api/v1/calls/bigbluebutton/create +Future createBigBlueButtonCall(ApiConnection connection, { + required String meetingName, + required bool voiceOnly, +}) async { + return connection.get('createBigBlueButtonCall', VideoCallResponse.fromJson, + '/calls/bigbluebutton/create', {'meeting_name': meetingName, 'voice_only': voiceOnly}); +} diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 7e0b9096a6..3d066095d6 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -783,6 +783,12 @@ abstract class ZulipLocalizations { /// **'Could not edit message'** String get errorCouldNotEditMessageTitle; + /// Error title when fetching a call URL failed. + /// + /// In en, this message translates to: + /// **'Fail to get call URL'** + String get errorCouldNotAppendCallUrl; + /// Success message after copy link action completed. /// /// In en, this message translates to: @@ -927,6 +933,18 @@ abstract class ZulipLocalizations { /// **'Take a photo'** String get composeBoxAttachFromCameraTooltip; + /// Tooltip for compose box icon to add a video call url to the message. + /// + /// In en, this message translates to: + /// **'Add video call'** + String get composeBoxAddVideoCallTooltip; + + /// Tooltip for compose box icon to add a voice call url to the message. + /// + /// In en, this message translates to: + /// **'Add voice call'** + String get composeBoxAddVoiceCallTooltip; + /// Hint text for content input when sending a message. /// /// In en, this message translates to: @@ -1029,6 +1047,18 @@ abstract class ZulipLocalizations { /// **'Uploading {filename}…'** String composeBoxUploadingFilename(String filename); + /// Text for a Markdown link to a video-call URL. + /// + /// In en, this message translates to: + /// **'Join video call.'** + String get composeBoxVideoCallLinkText; + + /// Text for a Markdown link to a voice-call URL. + /// + /// In en, this message translates to: + /// **'Join voice call.'** + String get composeBoxVoiceCallLinkText; + /// Placeholder in compose box showing the quoted message is currently loading. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 0de3988707..9099abc085 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index e1f57cd9ae..3ad409d7a6 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -423,6 +423,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get errorCouldNotEditMessageTitle => 'Konnte Nachricht nicht bearbeiten'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link kopiert'; @@ -502,6 +505,12 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Ein Foto aufnehmen'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Eine Nachricht eingeben'; @@ -563,6 +572,12 @@ class ZulipLocalizationsDe extends ZulipLocalizations { return 'Lade $filename hoch…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(lade Nachricht $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_el.dart b/lib/generated/l10n/zulip_localizations_el.dart index 37510410f9..8cf0c276c7 100644 --- a/lib/generated/l10n/zulip_localizations_el.dart +++ b/lib/generated/l10n/zulip_localizations_el.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsEl extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsEl extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsEl extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index feb32d69e5..c4f5405f13 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_es.dart b/lib/generated/l10n/zulip_localizations_es.dart index 163fb4e6a4..d6553cf0c2 100644 --- a/lib/generated/l10n/zulip_localizations_es.dart +++ b/lib/generated/l10n/zulip_localizations_es.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsEs extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsEs extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsEs extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 979a819efc..fd82fdc848 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -422,6 +422,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { String get errorCouldNotEditMessageTitle => 'Le message n\'a pas pu être modifié'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Lien copié'; @@ -501,6 +504,12 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -560,6 +569,12 @@ class ZulipLocalizationsFr extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_he.dart b/lib/generated/l10n/zulip_localizations_he.dart index 4a813be1b0..f51998cdda 100644 --- a/lib/generated/l10n/zulip_localizations_he.dart +++ b/lib/generated/l10n/zulip_localizations_he.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsHe extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsHe extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsHe extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_hu.dart b/lib/generated/l10n/zulip_localizations_hu.dart index 6b3805bf99..942706dc28 100644 --- a/lib/generated/l10n/zulip_localizations_hu.dart +++ b/lib/generated/l10n/zulip_localizations_hu.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsHu extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsHu extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsHu extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 0c06cd4b38..270ddee6f9 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -417,6 +417,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get errorCouldNotEditMessageTitle => 'Impossibile modificare il messaggio'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Collegamento copiato'; @@ -498,6 +501,12 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Fai una foto'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Batti un messaggio'; @@ -557,6 +566,12 @@ class ZulipLocalizationsIt extends ZulipLocalizations { return 'Caricamento $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(caricamento messaggio $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index d59e294c8a..269bb7c833 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -398,6 +398,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'メッセージを編集できませんでした'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'リンクをコピーしました'; @@ -473,6 +476,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => '写真を撮る'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'メッセージを入力'; @@ -532,6 +541,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return '$filename をアップロード中…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(メッセージ $messageId を読み込み中)'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d828f44d95..17b7dc6f2e 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 88fbc24a57..f25939f7e5 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -418,6 +418,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Nie można zmienić wiadomości'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Skopiowano odnośnik'; @@ -497,6 +500,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Zrób zdjęcie'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Wpisz wiadomość'; @@ -557,6 +566,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { return 'Przekazywanie $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(ładowanie wiadomości $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 06cfea774d..e6db692880 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -420,6 +420,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Сбой редактирования'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Ссылка скопирована'; @@ -499,6 +502,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Сделать снимок'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Ввести сообщение'; @@ -558,6 +567,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { return 'Загрузка $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(загрузка сообщения $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 4841efd265..e7b09e1e56 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index c4c6d91ed1..a797eb9b92 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -429,6 +429,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Sporočila ni mogoče urediti'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Povezava je bila kopirana'; @@ -510,6 +513,12 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Fotografiraj'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Vnesite sporočilo'; @@ -569,6 +578,12 @@ class ZulipLocalizationsSl extends ZulipLocalizations { return 'Nalaganje $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(nalaganje sporočila $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index e202787969..e382834e7a 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -419,6 +419,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorCouldNotEditMessageTitle => 'Не вдалося редагувати повідомлення'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Посилання скопійовано'; @@ -499,6 +502,12 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Зробити фото'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Ввести повідомлення'; @@ -558,6 +567,12 @@ class ZulipLocalizationsUk extends ZulipLocalizations { return 'Завантаження $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(завантаження повідомлення $messageId)'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 292a511d57..de1f2e75ea 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -406,6 +406,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get errorCouldNotEditMessageTitle => 'Could not edit message'; + @override + String get errorCouldNotAppendCallUrl => 'Fail to get call URL'; + @override String get successLinkCopied => 'Link copied'; @@ -485,6 +488,12 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get composeBoxAttachFromCameraTooltip => 'Take a photo'; + @override + String get composeBoxAddVideoCallTooltip => 'Add video call'; + + @override + String get composeBoxAddVoiceCallTooltip => 'Add voice call'; + @override String get composeBoxGenericContentHint => 'Type a message'; @@ -544,6 +553,12 @@ class ZulipLocalizationsZh extends ZulipLocalizations { return 'Uploading $filename…'; } + @override + String get composeBoxVideoCallLinkText => 'Join video call.'; + + @override + String get composeBoxVoiceCallLinkText => 'Join voice call.'; + @override String composeBoxLoadingMessage(int messageId) { return '(loading message $messageId)'; diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 9acfbda757..b82f0f0139 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -32,6 +32,8 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { Duration get serverTypingStartedWaitPeriod => Duration(milliseconds: serverTypingStartedWaitPeriodMilliseconds); int get serverTypingStartedWaitPeriodMilliseconds; + String? get serverJitsiServerUrl; + //|////////////////////////////////////////////////////////////// // Realm settings. @@ -46,6 +48,7 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { GroupSettingValue? get realmCanDeleteAnyMessageGroup; // TODO(server-10) GroupSettingValue? get realmCanDeleteOwnMessageGroup; // TODO(server-10) bool get realmEnableReadReceipts; + String? get realmJitsiServerUrl; bool get realmMandatoryTopics; int get maxFileUploadSizeMib; int? get realmMessageContentDeleteLimitSeconds; @@ -54,6 +57,7 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { : Duration(seconds: realmMessageContentEditLimitSeconds!); int? get realmMessageContentEditLimitSeconds; bool get realmPresenceDisabled; + RealmVideoChatProvider get realmVideoChatProvider; int get realmWaitingPeriodThreshold; //|////////////////////////////// @@ -74,11 +78,15 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { // TODO(server-10) simplify this String get realmEmptyTopicDisplayName; + Map get realmAvailableVideoChatProviders; + Map get realmDefaultExternalAccounts; int get maxChannelNameLength; int get maxTopicLength; + String? get jitsiServerUrl; + //|////////////////////////////// // Realm settings with their own events. @@ -167,6 +175,8 @@ mixin ProxyRealmStore on RealmStore { @override int get serverTypingStartedWaitPeriodMilliseconds => realmStore.serverTypingStartedWaitPeriodMilliseconds; @override + String? get serverJitsiServerUrl => realmStore.serverJitsiServerUrl; + @override bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing; @override GroupSettingValue? get realmCanDeleteAnyMessageGroup => realmStore.realmCanDeleteAnyMessageGroup; @@ -175,6 +185,8 @@ mixin ProxyRealmStore on RealmStore { @override bool get realmEnableReadReceipts => realmStore.realmEnableReadReceipts; @override + String? get realmJitsiServerUrl => realmStore.realmJitsiServerUrl; + @override bool get realmMandatoryTopics => realmStore.realmMandatoryTopics; @override int get maxFileUploadSizeMib => realmStore.maxFileUploadSizeMib; @@ -185,6 +197,8 @@ mixin ProxyRealmStore on RealmStore { @override bool get realmPresenceDisabled => realmStore.realmPresenceDisabled; @override + RealmVideoChatProvider get realmVideoChatProvider => realmStore.realmVideoChatProvider; + @override int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold; @override RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; @@ -193,12 +207,16 @@ mixin ProxyRealmStore on RealmStore { @override String get realmEmptyTopicDisplayName => realmStore.realmEmptyTopicDisplayName; @override + Map get realmAvailableVideoChatProviders => realmStore.realmAvailableVideoChatProviders; + @override Map get realmDefaultExternalAccounts => realmStore.realmDefaultExternalAccounts; @override int get maxChannelNameLength => realmStore.maxChannelNameLength; @override int get maxTopicLength => realmStore.maxTopicLength; @override + String? get jitsiServerUrl => realmStore.jitsiServerUrl; + @override List get customProfileFields => realmStore.customProfileFields; @override bool selfHasPassedWaitingPeriod({required DateTime byDate}) => @@ -233,22 +251,27 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds, serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds, serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds, + serverJitsiServerUrl = initialSnapshot.serverJitsiServerUrl, realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing, realmCanDeleteAnyMessageGroup = initialSnapshot.realmCanDeleteAnyMessageGroup, realmCanDeleteOwnMessageGroup = initialSnapshot.realmCanDeleteOwnMessageGroup, + realmEnableReadReceipts = initialSnapshot.realmEnableReadReceipts, + realmJitsiServerUrl = initialSnapshot.realmJitsiServerUrl, realmMandatoryTopics = initialSnapshot.realmMandatoryTopics, maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib, realmMessageContentDeleteLimitSeconds = initialSnapshot.realmMessageContentDeleteLimitSeconds, realmMessageContentEditLimitSeconds = initialSnapshot.realmMessageContentEditLimitSeconds, - realmEnableReadReceipts = initialSnapshot.realmEnableReadReceipts, realmPresenceDisabled = initialSnapshot.realmPresenceDisabled, + realmVideoChatProvider = initialSnapshot.realmVideoChatProvider, realmWaitingPeriodThreshold = initialSnapshot.realmWaitingPeriodThreshold, realmWildcardMentionPolicy = initialSnapshot.realmWildcardMentionPolicy, realmDeleteOwnMessagePolicy = initialSnapshot.realmDeleteOwnMessagePolicy, _realmEmptyTopicDisplayName = initialSnapshot.realmEmptyTopicDisplayName, + realmAvailableVideoChatProviders = initialSnapshot.realmAvailableVideoChatProviders, realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, maxChannelNameLength = initialSnapshot.maxChannelNameLength, maxTopicLength = initialSnapshot.maxTopicLength, + jitsiServerUrl = initialSnapshot.jitsiServerUrl, customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields); @override @@ -377,6 +400,8 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { final int serverTypingStoppedWaitPeriodMilliseconds; @override final int serverTypingStartedWaitPeriodMilliseconds; + @override + final String? serverJitsiServerUrl; @override final bool realmAllowMessageEditing; @@ -387,6 +412,8 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final bool realmEnableReadReceipts; @override + final String? realmJitsiServerUrl; + @override final bool realmMandatoryTopics; @override final int maxFileUploadSizeMib; @@ -397,6 +424,8 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final bool realmPresenceDisabled; @override + final RealmVideoChatProvider realmVideoChatProvider; + @override final int realmWaitingPeriodThreshold; @override @@ -412,6 +441,9 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { } final String? _realmEmptyTopicDisplayName; + @override + final Map realmAvailableVideoChatProviders; + @override final Map realmDefaultExternalAccounts; @@ -420,6 +452,9 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { @override final int maxTopicLength; + @override + final String? jitsiServerUrl; + @override List customProfileFields; diff --git a/lib/model/store.dart b/lib/model/store.dart index b051e75b72..1de0798d4a 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -17,6 +17,7 @@ import '../api/route/events.dart'; import '../api/backoff.dart'; import '../api/route/realm.dart'; import '../log.dart'; +import '../widgets/compose_box.dart'; import 'actions.dart'; import 'autocomplete.dart'; import 'database.dart'; @@ -562,6 +563,7 @@ class PerAccountStore extends PerAccountStoreBase with emoji: EmojiStoreImpl(core: core, allRealmEmoji: initialSnapshot.realmEmoji), userSettings: initialSnapshot.userSettings, + hasZoomToken: initialSnapshot.hasZoomToken, pushDevices: PushDeviceManager(core: core), savedSnippets: SavedSnippetStoreImpl(core: core, savedSnippets: initialSnapshot.savedSnippets ?? []), @@ -586,6 +588,7 @@ class PerAccountStore extends PerAccountStoreBase with required RealmStoreImpl realm, required EmojiStoreImpl emoji, required this.userSettings, + required this.hasZoomToken, required this.pushDevices, required SavedSnippetStoreImpl savedSnippets, required this.typingNotifier, @@ -658,6 +661,8 @@ class PerAccountStore extends PerAccountStoreBase with final UserSettings userSettings; + bool hasZoomToken; + final PushDeviceManager pushDevices; @override @@ -792,6 +797,15 @@ class PerAccountStore extends PerAccountStoreBase with } notifyListeners(); + case HasZoomTokenEvent(): + assert(debugLog("server event: has_zoom_token")); + final hasZoomTokenUpdated = event.value; + if (hasZoomToken != hasZoomTokenUpdated) { + hasZoomToken = hasZoomTokenUpdated; + if(hasZoomToken) await ComposeCall.handleHasZoomTokenEvent(); + notifyListeners(); + } + case CustomProfileFieldsEvent(): assert(debugLog("server event: custom_profile_fields")); _realm.handleCustomProfileFieldsEvent(event); diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 52faeaf151..a341e9aad6 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -7,10 +7,12 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:mime/mime.dart'; import 'package:path/path.dart' as path; +import 'package:url_launcher/url_launcher.dart'; import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; +import '../api/route/video_call.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/binding.dart'; import '../model/compose.dart'; @@ -1034,6 +1036,220 @@ Future _uploadFiles({ } } +class ComposeCall { + static final Map Function()> _zoomTokenCallbacks = {}; + static void clearZoomCallbacks(int? editMessageId) { + final key = editMessageId?.toString() ?? ''; + _zoomTokenCallbacks.remove(key); + } + + static Future handleHasZoomTokenEvent() async { + final callbacks = Map Function()>.from(_zoomTokenCallbacks); + _zoomTokenCallbacks.clear(); + for (final callback in callbacks.values) { + await callback(); + } + } + + static int generateRandomId(int min, int max) { + return min + (DateTime.now().microsecondsSinceEpoch % (max - min)); + } +} + +class _AddComposeCallUrlButton extends StatefulWidget { + const _AddComposeCallUrlButton({ + required this.controller, + required this.enabled, + required this.isVideoCall, + }); + final ComposeBoxController controller; + final bool enabled; + final bool isVideoCall; + + @override + State<_AddComposeCallUrlButton> createState() => _AddComposeCallUrlButtonState(); +} + +class _AddComposeCallUrlButtonState extends State<_AddComposeCallUrlButton> { + + static String getJitsiServerUrl(PerAccountStore store) { + return store.realmJitsiServerUrl ?? store.serverJitsiServerUrl ?? + store.jitsiServerUrl ?? 'https://meet.jit.si'; + } + + Future _openZoomOAuthWindow() async { + final store = PerAccountStoreWidget.of(context); + final url = store.realmUrl.resolve('/calls/zoom/register'); + + await launchUrl(url, mode: LaunchMode.platformDefault); + } + + void _insertCallUrl(String url, String visibleText) { + final placeholder = inlineLink(visibleText, url); + final contentController = widget.controller.content; + final insertionRange = contentController.insertionIndex(); + contentController.value = contentController.value.replaced(insertionRange, '$placeholder\n\n'); + widget.controller.contentFocusNode.requestFocus(); + } + + void _handleJitsiCall({ + required ComposeContentController contentController, + required bool isAudioCall, + }) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalization = ZulipLocalizations.of(context); + final videoCallId = ComposeCall.generateRandomId(100000000000000, 999999999999999); + final jitsiServerUrl = getJitsiServerUrl(store); + final videoCallLink = '$jitsiServerUrl/$videoCallId'; + + if (!isAudioCall) { + _insertCallUrl('$videoCallLink#config.startWithVideoMuted=false', + zulipLocalization.composeBoxVideoCallLinkText); + } else { + _insertCallUrl('$videoCallLink#config.startWithVideoMuted=true', + zulipLocalization.composeBoxVideoCallLinkText); + } + + } + + Future _createBigBlueButtonCall ({ + required ComposeContentController contentController, + required bool voiceOnly, + }) async { + final zulipLocalization = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + try { + final connection = store.connection; + final result = await createBigBlueButtonCall( + connection, meetingName: "Null", //TODO: Fetch message stream title + voiceOnly: voiceOnly); + + if (!voiceOnly) { + _insertCallUrl(result.url, zulipLocalization.composeBoxVideoCallLinkText); + } else { + _insertCallUrl(result.url, zulipLocalization.composeBoxAddVoiceCallTooltip); + } + } on ApiRequestException catch (e) { + if (!mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + final message = switch (e) { + ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), + _ => e.message, + }; + showErrorDialog(context: context, + title: zulipLocalizations.errorCouldNotAppendCallUrl, + message: message); + return; + } + } + + + + Future handleZoomCall({ + required ComposeContentController contentController, + required bool isVideoCall, + required bool isServerToServer, + int? editMessageId, + }) async { + final store = PerAccountStoreWidget.of(context); + final key = editMessageId?.toString() ?? ''; + ComposeCall.clearZoomCallbacks(editMessageId); + Future prepareZoomCall() async { + final zulipLocalization = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + try { + final connection = store.connection; + final result = await createZoomCall(connection, isVideoCall: isVideoCall); + + if (isVideoCall) { + _insertCallUrl(result.url, zulipLocalization.composeBoxVideoCallLinkText); + } else { + _insertCallUrl(result.url, zulipLocalization.composeBoxAddVoiceCallTooltip); + } + + } on ApiRequestException catch (e) { + if (!mounted) return; + store.hasZoomToken = false; + ComposeCall.clearZoomCallbacks(editMessageId); + final zulipLocalizations = ZulipLocalizations.of(context); + final message = switch (e) { + ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), + _ => e.message, + }; + showErrorDialog(context: context, + title: zulipLocalizations.errorCouldNotAppendCallUrl, + message: message); + return; + } + + } + + if (store.hasZoomToken || isServerToServer) { + await prepareZoomCall(); + } else { + ComposeCall._zoomTokenCallbacks[key] = prepareZoomCall; + await _openZoomOAuthWindow(); + } + + } + + + Future generateComposeCallUrl({ + required ComposeContentController contentController, + required bool isAudioCall, + int? editMessageId, + }) async { + final store = PerAccountStoreWidget.of(context); + final realmAvailableVideoChatProviders = store.realmAvailableVideoChatProviders; + final realmVideoChatProvider = store.realmVideoChatProvider; + + final providerIsZoom = realmAvailableVideoChatProviders['zoom'] != null && + realmVideoChatProvider.apiValue == realmAvailableVideoChatProviders['zoom']!.id; + final providerIsZoomServerToServer = + realmAvailableVideoChatProviders['zoom_server_to_server'] != null && + realmVideoChatProvider.apiValue == realmAvailableVideoChatProviders['zoom_server_to_server']!.id; + + if (providerIsZoom || providerIsZoomServerToServer) { + await handleZoomCall( + contentController: contentController, + isVideoCall: !isAudioCall, + isServerToServer: providerIsZoomServerToServer, + ); + } else if (realmAvailableVideoChatProviders['big_blue_button'] != null && + realmVideoChatProvider.apiValue == realmAvailableVideoChatProviders['big_blue_button']!.id) { + await _createBigBlueButtonCall( + contentController: contentController, + voiceOnly: isAudioCall, + ); + } else { + _handleJitsiCall( + contentController: contentController, + isAudioCall: isAudioCall, + ); + } + } + + + Future _handlePress(BuildContext context) async { + final contentController = widget.controller.content; + await generateComposeCallUrl(contentController: contentController, + isAudioCall: !widget.isVideoCall, editMessageId: null); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return SizedBox( + width: _composeButtonSize, + child: IconButton( + icon: Icon(ZulipIcons.video, color: designVariables.foreground.withFadedAlpha(0.5)), + tooltip: zulipLocalizations.composeBoxAddVideoCallTooltip, + onPressed: widget.enabled ? () => _handlePress(context) : null)); + } +} + abstract class _AttachUploadsButton extends StatelessWidget { const _AttachUploadsButton({required this.controller, required this.enabled}); @@ -1473,6 +1689,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { _AttachFileButton(controller: controller, enabled: composeButtonsEnabled), _AttachMediaButton(controller: controller, enabled: composeButtonsEnabled), _AttachFromCameraButton(controller: controller, enabled: composeButtonsEnabled), + _AddComposeCallUrlButton(controller: controller, enabled: composeButtonsEnabled, isVideoCall: true), ]; final topicInput = buildTopicInput(); diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 0e17f584ce..d16eb967b3 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -202,6 +202,9 @@ abstract final class ZulipIcons { /// The Zulip custom icon "unmute". static const IconData unmute = IconData(0xf13a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "video". + static const IconData video = IconData(0xf13b, fontFamily: "Zulip Icons"); + // END GENERATED ICON DATA } diff --git a/test/example_data.dart b/test/example_data.dart index c9beaef72f..6ea5b55b8f 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1352,22 +1352,28 @@ InitialSnapshot initialSnapshot({ Map? userStatuses, UserSettings? userSettings, List? userTopics, + bool? hasZoomToken, GroupSettingValue? realmCanDeleteAnyMessageGroup, GroupSettingValue? realmCanDeleteOwnMessageGroup, RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy, RealmWildcardMentionPolicy? realmWildcardMentionPolicy, bool? realmMandatoryTopics, String? realmName, + RealmVideoChatProvider? realmVideoChatProvider, + String? realmJitsiServerUrl, int? realmWaitingPeriodThreshold, int? realmMessageContentDeleteLimitSeconds, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, bool? realmEnableReadReceipts, Uri? realmIconUrl, + Map? realmAvailableVideoChatProviders, bool? realmPresenceDisabled, Map? realmDefaultExternalAccounts, + String? jitsiServerUrl, int? maxFileUploadSizeMib, Uri? serverEmojiDataUrl, + String? serverJitsiServerUrl, String? realmEmptyTopicDisplayName, List? realmUsers, List? realmNonActiveUsers, @@ -1412,23 +1418,29 @@ InitialSnapshot initialSnapshot({ userSettings: userSettings ?? _userSettings(), userTopics: userTopics ?? [], // no default; allow `null` to simulate servers without this + hasZoomToken: hasZoomToken ?? false, realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup, realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone, realmMandatoryTopics: realmMandatoryTopics ?? true, realmName: realmName ?? 'Example Zulip organization', + realmVideoChatProvider: realmVideoChatProvider ?? RealmVideoChatProvider.jitsiMeet, + realmJitsiServerUrl: realmJitsiServerUrl, realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0, realmMessageContentDeleteLimitSeconds: realmMessageContentDeleteLimitSeconds, realmAllowMessageEditing: realmAllowMessageEditing ?? true, realmMessageContentEditLimitSeconds: realmMessageContentEditLimitSeconds, realmEnableReadReceipts: realmEnableReadReceipts ?? true, realmIconUrl: realmIconUrl ?? _realmIcon, + realmAvailableVideoChatProviders: realmAvailableVideoChatProviders ?? {}, realmPresenceDisabled: realmPresenceDisabled ?? false, realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {}, + jitsiServerUrl: jitsiServerUrl ?? 'https://meet.jit.si', maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25, serverEmojiDataUrl: serverEmojiDataUrl ?? realmUrl.replace(path: '/static/emoji.json'), + serverJitsiServerUrl: serverJitsiServerUrl, realmEmptyTopicDisplayName: realmEmptyTopicDisplayName ?? defaultRealmEmptyTopicDisplayName, realmUsers: realmUsers ?? [selfUser], realmNonActiveUsers: realmNonActiveUsers ?? [], diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index a847120691..121eb018aa 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -62,7 +62,13 @@ void main() { List? streams, List subscriptions = const [], List? messages, + bool? hasZoomToken, bool? mandatoryTopics, + RealmVideoChatProvider? realmVideoChatProvider, + String? realmJitsiServerUrl, + Map? realmAvailableVideoChatProviders, + String? jitsiServerUrl, + String? serverJitsiServerUrl, int? zulipFeatureLevel, int? maxTopicLength, }) async { @@ -90,6 +96,8 @@ void main() { subscriptions: subscriptions, zulipFeatureLevel: zulipFeatureLevel, realmMandatoryTopics: mandatoryTopics, + realmVideoChatProvider: realmVideoChatProvider, + jitsiServerUrl: jitsiServerUrl, realmAllowMessageEditing: true, realmMessageContentEditLimitSeconds: null, maxTopicLength: maxTopicLength, @@ -1053,6 +1061,44 @@ void main() { }); }); + group('compose call button', () { + Future prepare(WidgetTester tester, { + String? jitsiServerUrl, + RealmVideoChatProvider? realmVideoChatProvider, + bool? hasZoomToken, + }) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + final channel = eg.stream(); + final narrow = ChannelNarrow(channel.streamId); + await prepareComposeBox(tester, + narrow: narrow, + streams: [channel], + realmVideoChatProvider : realmVideoChatProvider, + jitsiServerUrl : jitsiServerUrl, + hasZoomToken: hasZoomToken, + ); + + await enterTopic(tester, narrow: narrow, topic: 'some topic'); + await tester.pump(); + } + + group('add video call link', () { + testWidgets('jitsi success', (tester) async { + await prepare(tester); + connection.prepare(); + + await tester.tap(find.byIcon(ZulipIcons.video)); + await tester.pump(); + check(controller!.content.text) + ..startsWith('[Join video call.](https://meet.jit.si') + ..endsWith('#config.startWithVideoMuted=false)\n\n'); + }); + + }); + }); + group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( @@ -1332,6 +1378,7 @@ void main() { check(attachButtonFinder(ZulipIcons.attach_file).evaluate().length).equals(areShown ? 1 : 0); check(attachButtonFinder(ZulipIcons.image).evaluate().length).equals(areShown ? 1 : 0); check(attachButtonFinder(ZulipIcons.camera).evaluate().length).equals(areShown ? 1 : 0); + check(attachButtonFinder(ZulipIcons.video).evaluate().length).equals(areShown ? 1 : 0); } void checkBannerWithLabel(String label, {required bool isShown}) {