diff --git a/android/app/src/main/java/org/jitsi/meet/MainActivity.java b/android/app/src/main/java/org/jitsi/meet/MainActivity.java index 83952765f7..c84dc660cf 100644 --- a/android/app/src/main/java/org/jitsi/meet/MainActivity.java +++ b/android/app/src/main/java/org/jitsi/meet/MainActivity.java @@ -19,12 +19,14 @@ package org.jitsi.meet; import android.os.Bundle; import android.util.Log; +import org.jitsi.meet.sdk.InviteSearchController; import org.jitsi.meet.sdk.JitsiMeetActivity; import org.jitsi.meet.sdk.JitsiMeetView; import org.jitsi.meet.sdk.JitsiMeetViewListener; import com.calendarevents.CalendarEventsPackage; +import java.util.HashMap; import java.util.Map; /** @@ -84,6 +86,11 @@ public class MainActivity extends JitsiMeetActivity { on("CONFERENCE_WILL_LEAVE", data); } + @Override + public void launchNativeInvite(InviteSearchController inviteSearchController) { + on("LAUNCH_NATIVE_INVITE", new HashMap()); + } + @Override public void onLoadConfigError(Map data) { on("LOAD_CONFIG_ERROR", data); diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/InviteSearchController.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/InviteSearchController.java new file mode 100644 index 0000000000..969b016fb4 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/InviteSearchController.java @@ -0,0 +1,184 @@ +package org.jitsi.meet.sdk; + +import android.util.Log; + +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableNativeArray; +import com.facebook.react.bridge.WritableNativeMap; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Controller object used by native code to query and submit user selections for the user invitation flow. + */ +public class InviteSearchController { + + /** + * The InviteSearchControllerDelegate for this controller, used to pass query + * results back to the native code that initiated the query. + */ + private InviteSearchControllerDelegate searchControllerDelegate; + + /** + * Local cache of search query results. Used to re-hydrate the list + * of selected items based on their ids passed to submitSelectedItemIds + * in order to pass the full item maps back to the JitsiMeetView during submission. + */ + private Map items = new HashMap<>(); + + /** + * Randomly generated UUID, used for identification in the InviteSearchModule + */ + private String uuid = UUID.randomUUID().toString(); + + private WeakReference parentModuleRef; + + public InviteSearchController(InviteSearchModule module) { + parentModuleRef = new WeakReference<>(module); + } + + /** + * Start a search for entities to invite with the given query. + * Results will be returned through the associated InviteSearchControllerDelegate's + * onReceiveResults method. + * + * @param query + */ + public void performQuery(String query) { + JitsiMeetView.onInviteQuery(query, uuid); + } + + /** + * Send invites to selected users based on their item ids + * + * @param ids + */ + public void submitSelectedItemIds(List ids) { + WritableArray selectedItems = new WritableNativeArray(); + for(int i=0; i> jvmResults = new ArrayList<>(); + // cache results for use in submission later + // convert to jvm array + for(int i=0; i objects that represent items returned by the query. + * The object at key "type" describes the type of item: "user", "videosipgw" (conference room), or "phone". + * "user" types have properties at "id", "name", and "avatar" + * "videosipgw" types have properties at "id" and "name" + * "phone" types have properties at "number", "title", "and "subtitle" + * @param query the query that generated the given results + */ + void onReceiveResults(InviteSearchController searchController, List> results, String query); + + /** + * Called when the call to {@link InviteSearchController#submitSelectedItemIds(List)} completes successfully + * and invitations are sent to all given IDs. + * + * @param searchController the active {@link InviteSearchController} for this invite flow. This object will be + * cleaned up after the call to inviteSucceeded completes. + */ + void inviteSucceeded(InviteSearchController searchController); + + /** + * Called when the call to {@link InviteSearchController#submitSelectedItemIds(List)} completes, but the + * invitation fails for one or more of the selected items. + * + * @param searchController the active {@link InviteSearchController} for this invite flow. This object + * should be cleaned up by calling {@link InviteSearchController#cancelSearch()} if + * the user exits the invite flow. Otherwise, it can stay active if the user + * will attempt to invite + * @param failedInviteItems a {@code List} of {@code Map} dictionaries that represent the + * invitations that failed. The data type of the objects is identical to the results + * returned in onReceiveResuls. + */ + void inviteFailed(InviteSearchController searchController, List> failedInviteItems); + } +} \ No newline at end of file diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/InviteSearchModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/InviteSearchModule.java new file mode 100644 index 0000000000..4cfe2d28e8 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/InviteSearchModule.java @@ -0,0 +1,126 @@ +package org.jitsi.meet.sdk; + +import android.util.Log; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +/** + * Native module for Invite Search + */ +class InviteSearchModule extends ReactContextBaseJavaModule { + + /** + * Map of InviteSearchController objects passed to connected JitsiMeetView. + * A call to launchNativeInvite will create a new InviteSearchController and pass + * it back to the caller. On a successful invitation, the controller will be removed automatically. + * On a failed invitation, the caller has the option of calling InviteSearchController#cancelSearch() + * to remove the controller from this map. The controller should also be removed if the user cancels + * the invitation flow. + */ + private Map searchControllers = new HashMap<>(); + + public InviteSearchModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + /** + * Launch the native user invite flow + * + * @param externalAPIScope a string that represents a connection to a specific JitsiMeetView + */ + @ReactMethod + public void launchNativeInvite(String externalAPIScope) { + JitsiMeetView viewToLaunchInvite = JitsiMeetView.findViewByExternalAPIScope(externalAPIScope); + + if(viewToLaunchInvite == null) { + return; + } + + if(viewToLaunchInvite.getListener() == null) { + return; + } + + InviteSearchController controller = createSearchController(); + viewToLaunchInvite.getListener().launchNativeInvite(controller); + } + + /** + * Callback for results received from the JavaScript invite search call + * + * @param results the results in a ReadableArray of ReadableMap objects + * @param query the query associated with the search + * @param inviteSearchControllerScope a string that represents a connection to a specific InviteSearchController + */ + @ReactMethod + public void receivedResults(ReadableArray results, String query, String inviteSearchControllerScope) { + InviteSearchController controller = searchControllers.get(inviteSearchControllerScope); + + if(controller == null) { + Log.w("InviteSearchModule", "Received results, but unable to find active controller to send results back"); + return; + } + + controller.receivedResultsForQuery(results, query); + + } + + /** + * Callback for invitation failures + * + * @param items the items for which the invitation failed + * @param inviteSearchControllerScope a string that represents a connection to a specific InviteSearchController + */ + @ReactMethod + public void inviteFailedForItems(ReadableArray items, String inviteSearchControllerScope) { + InviteSearchController controller = searchControllers.get(inviteSearchControllerScope); + + if(controller == null) { + Log.w("InviteSearchModule", "Invite failed, but unable to find active controller to notify"); + return; + } + + ArrayList> jvmItems = new ArrayList<>(); + for(int i=0; i data) { } + + @Override + public void launchNativeInvite(InviteSearchController inviteSearchController) { + } } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetViewListener.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetViewListener.java index 52e48037ee..380ab75ed4 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetViewListener.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetViewListener.java @@ -59,6 +59,16 @@ public interface JitsiMeetViewListener { */ void onConferenceWillLeave(Map data); + /** + * Called when the add user button is tapped. + * + * @param inviteSearchController {@code InviteSearchController} scoped + * for this user invite flow. The {@code InviteSearchController} is used + * to start user queries and accepts an {@code InviteSearchControllerDelegate} + * for receiving user query responses. + */ + void launchNativeInvite(InviteSearchController inviteSearchController); + /** * Called when loading the main configuration file from the Jitsi Meet * deployment fails. diff --git a/ios/sdk/sdk.xcodeproj/project.pbxproj b/ios/sdk/sdk.xcodeproj/project.pbxproj index e5b4b731d0..5e0005fc13 100644 --- a/ios/sdk/sdk.xcodeproj/project.pbxproj +++ b/ios/sdk/sdk.xcodeproj/project.pbxproj @@ -29,6 +29,8 @@ 0F65EECE1D95DA94561BB47E /* libPods-JitsiMeet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 03F2ADC957FF109849B7FCA1 /* libPods-JitsiMeet.a */; }; 75635B0A20751D6D00F29C9F /* joined.wav in Resources */ = {isa = PBXBuildFile; fileRef = 75635B0820751D6D00F29C9F /* joined.wav */; }; 75635B0B20751D6D00F29C9F /* left.wav in Resources */ = {isa = PBXBuildFile; fileRef = 75635B0920751D6D00F29C9F /* left.wav */; }; + 412BF89D206AA66F0053B9E5 /* InviteSearch.m in Sources */ = {isa = PBXBuildFile; fileRef = 412BF89C206AA66F0053B9E5 /* InviteSearch.m */; }; + 412BF89F206ABAE40053B9E5 /* InviteSearch.h in Headers */ = {isa = PBXBuildFile; fileRef = 412BF89E206AA82F0053B9E5 /* InviteSearch.h */; settings = {ATTRIBUTES = (Public, ); }; }; C6245F5D2053091D0040BE68 /* image-resize@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = C6245F5B2053091D0040BE68 /* image-resize@2x.png */; }; C6245F5E2053091D0040BE68 /* image-resize@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = C6245F5C2053091D0040BE68 /* image-resize@3x.png */; }; C6A34261204EF76800E062DD /* DragGestureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A3425E204EF76800E062DD /* DragGestureController.swift */; }; @@ -62,6 +64,8 @@ 0BD906E91EC0C00300C8C18E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 75635B0820751D6D00F29C9F /* joined.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = joined.wav; path = ../../sounds/joined.wav; sourceTree = ""; }; 75635B0920751D6D00F29C9F /* left.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = left.wav; path = ../../sounds/left.wav; sourceTree = ""; }; + 412BF89C206AA66F0053B9E5 /* InviteSearch.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InviteSearch.m; sourceTree = ""; }; + 412BF89E206AA82F0053B9E5 /* InviteSearch.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = InviteSearch.h; sourceTree = ""; }; 98E09B5C73D9036B4ED252FC /* Pods-JitsiMeet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JitsiMeet.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-JitsiMeet/Pods-JitsiMeet.debug.xcconfig"; sourceTree = ""; }; 9C77CA3CC919B081F1A52982 /* Pods-JitsiMeet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JitsiMeet.release.xcconfig"; path = "../Pods/Target Support Files/Pods-JitsiMeet/Pods-JitsiMeet.release.xcconfig"; sourceTree = ""; }; C6245F5B2053091D0040BE68 /* image-resize@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "image-resize@2x.png"; path = "src/picture-in-picture/image-resize@2x.png"; sourceTree = ""; }; @@ -125,6 +129,8 @@ 0BCA495C1EC4B6C600B793EE /* AudioMode.m */, 0BB9AD7C1F60356D001C08DB /* AppInfo.m */, 0BB9AD7A1F5EC8F4001C08DB /* CallKit.m */, + 412BF89E206AA82F0053B9E5 /* InviteSearch.h */, + 412BF89C206AA66F0053B9E5 /* InviteSearch.m */, 0BA13D301EE83FF8007BEF7F /* ExternalAPI.m */, 0BD906E91EC0C00300C8C18E /* Info.plist */, 0B7C2CFC200F51D60060D076 /* LaunchOptions.m */, @@ -180,6 +186,7 @@ buildActionMask = 2147483647; files = ( C6F99C15204DB63E0001F710 /* JitsiMeetView+Private.h in Headers */, + 412BF89F206ABAE40053B9E5 /* InviteSearch.h in Headers */, 0B412F181EDEC65D00B1A0A6 /* JitsiMeetView.h in Headers */, 0B93EF7E1EC9DDCD0030D24D /* RCTBridgeWrapper.h in Headers */, 0B412F221EDEF6EA00B1A0A6 /* JitsiMeetViewDelegate.h in Headers */, @@ -347,6 +354,7 @@ C6CC49AF207412CF000DFA42 /* PiPViewCoordinator.swift in Sources */, 0BCA495F1EC4B6C600B793EE /* AudioMode.m in Sources */, 0B44A0191F902126009D1D64 /* MPVolumeViewManager.m in Sources */, + 412BF89D206AA66F0053B9E5 /* InviteSearch.m in Sources */, 0BCA49611EC4B6C600B793EE /* Proximity.m in Sources */, C6A34261204EF76800E062DD /* DragGestureController.swift in Sources */, 0B412F191EDEC65D00B1A0A6 /* JitsiMeetView.m in Sources */, diff --git a/ios/sdk/src/InviteSearch.h b/ios/sdk/src/InviteSearch.h new file mode 100644 index 0000000000..95f29a8d84 --- /dev/null +++ b/ios/sdk/src/InviteSearch.h @@ -0,0 +1,49 @@ +/* + * Copyright @ 2018-present Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@class InviteSearchController; + +@protocol InviteSearchControllerDelegate + +/** + * Called when an InviteSearchController has results for a query that was previously provided. + */ +- (void)inviteSearchController:(InviteSearchController * _Nonnull)controller + didReceiveResults:(NSArray * _Nonnull)results + forQuery:(NSString * _Nonnull)query; + +/** + * Called when all invitations were sent successfully. + */ +- (void)inviteDidSucceedForSearchController:(InviteSearchController * _Nonnull)searchController; + +/** + * Called when one or more invitations fails to send successfully. + */ +- (void)inviteDidFailForItems:(NSArray * _Nonnull)items + fromSearchController:(InviteSearchController * _Nonnull)searchController; + +@end + +@interface InviteSearchController: NSObject + +@property (nonatomic, nullable, weak) id delegate; + +- (void)performQuery:(NSString * _Nonnull)query; +- (void)cancelSearch; +- (void)submitSelectedItemIds:(NSArray * _Nonnull)ids; + +@end diff --git a/ios/sdk/src/InviteSearch.m b/ios/sdk/src/InviteSearch.m new file mode 100644 index 0000000000..74e768acac --- /dev/null +++ b/ios/sdk/src/InviteSearch.m @@ -0,0 +1,215 @@ +/* + * Copyright @ 2018-present Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import +#import +#import + +#import "JitsiMeetView+Private.h" + +#import "InviteSearch.h" + +// The events emitted/supported by InviteSearch: +static NSString * const InviteSearchPerformQueryAction = @"performQueryAction"; +static NSString * const InviteSearchPerformSubmitInviteAction = @"performSubmitInviteAction"; + + +@interface InviteSearch : RCTEventEmitter + +@end + + +@interface InviteSearchController () + +@property (nonatomic, readonly) NSString* _Nonnull identifier; +@property (nonatomic, strong) NSMutableDictionary* _Nonnull items; +@property (nonatomic, nullable, weak) InviteSearch* module; + +- (instancetype)initWithSearchModule:(InviteSearch *)module; + +- (void)didReceiveResults:(NSArray * _Nonnull)results + forQuery:(NSString * _Nonnull)query; + +- (void)inviteDidSucceed; + +- (void)inviteDidFailForItems:(NSArray *)items; + +@end + + +@implementation InviteSearch + +static NSMutableDictionary* searchControllers; + +RCT_EXTERN void RCTRegisterModule(Class); + ++ (void)load { + RCTRegisterModule(self); + + searchControllers = [[NSMutableDictionary alloc] init]; +} + ++ (NSString *)moduleName { + return @"InviteSearch"; +} + +- (NSArray *)supportedEvents { + return @[ + InviteSearchPerformQueryAction, + InviteSearchPerformSubmitInviteAction + ]; +} + +/** + * Calls the corresponding JitsiMeetView's delegate to request that the native + * invite search be presented. + * + * @param scope + */ +RCT_EXPORT_METHOD(launchNativeInvite:(NSString *)scope) { + // The JavaScript App needs to provide uniquely identifying information to + // the native module so that the latter may match the former to the native + // JitsiMeetView which hosts it. + JitsiMeetView *view = [JitsiMeetView viewForExternalAPIScope:scope]; + + if (!view) { + return; + } + + id delegate = view.delegate; + + if (!delegate) { + return; + } + + if ([delegate respondsToSelector:@selector(launchNativeInviteForSearchController:)]) { + InviteSearchController* searchController = [searchControllers objectForKey:scope]; + if (!searchController) { + searchController = [self makeInviteSearchController]; + } + + [delegate launchNativeInviteForSearchController:searchController]; + } +} + +RCT_EXPORT_METHOD(inviteSucceeded:(NSString *)inviteScope) { + InviteSearchController* searchController = [searchControllers objectForKey:inviteScope]; + + [searchController inviteDidSucceed]; + + [searchControllers removeObjectForKey:inviteScope]; +} + +RCT_EXPORT_METHOD(inviteFailedForItems:(NSArray *)items inviteScope:(NSString *)inviteScope) { + InviteSearchController* searchController = [searchControllers objectForKey:inviteScope]; + + [searchController inviteDidFailForItems:items]; +} + +RCT_EXPORT_METHOD(receivedResults:(NSArray *)results forQuery:(NSString *)query inviteScope:(NSString *)inviteScope) { + + InviteSearchController* searchController = [searchControllers objectForKey:inviteScope]; + + [searchController didReceiveResults:results forQuery:query]; +} + +- (InviteSearchController *)makeInviteSearchController { + InviteSearchController* searchController = [[InviteSearchController alloc] initWithSearchModule:self]; + + [searchControllers setObject:searchController forKey:searchController.identifier]; + + return searchController; +} + +- (void)performQuery:(NSString * _Nonnull)query inviteScope:(NSString * _Nonnull)inviteScope { + [self sendEventWithName:InviteSearchPerformQueryAction body:@{ @"query": query, @"inviteScope": inviteScope }]; +} + +- (void)cancelSearchForInviteScope:(NSString * _Nonnull)inviteScope { + [searchControllers removeObjectForKey:inviteScope]; +} + +- (void)submitSelectedItems:(NSArray * _Nonnull)items inviteScope:(NSString * _Nonnull)inviteScope { + [self sendEventWithName:InviteSearchPerformSubmitInviteAction body:@{ @"selectedItems": items, @"inviteScope": inviteScope }]; +} + +@end + + +@implementation InviteSearchController + +- (instancetype)initWithSearchModule:(InviteSearch *)module { + self = [super init]; + if (self) { + _identifier = [[NSUUID UUID] UUIDString]; + + self.items = [[NSMutableDictionary alloc] init]; + self.module = module; + } + return self; +} + +- (void)performQuery:(NSString *)query { + [self.module performQuery:query inviteScope:self.identifier]; +} + +- (void)cancelSearch { + [self.module cancelSearchForInviteScope:self.identifier]; +} + +- (void)submitSelectedItemIds:(NSArray * _Nonnull)ids { + NSMutableArray* items = [[NSMutableArray alloc] init]; + + for (NSString* itemId in ids) { + id item = [self.items objectForKey:itemId]; + + if (item) { + [items addObject:item]; + } + } + + [self.module submitSelectedItems:items inviteScope:self.identifier]; +} + +- (void)didReceiveResults:(NSArray *)results forQuery:(NSString *)query { + for (NSDictionary* item in results) { + NSString* itemId = item[@"id"]; + NSString* itemType = item[@"type"]; + if (itemId) { + [self.items setObject:item forKey:itemId]; + } else if (itemType != nil && [itemType isEqualToString: @"phone"]) { + NSString* number = item[@"number"]; + if (number) { + [self.items setObject:item forKey:number]; + } + } + } + + [self.delegate inviteSearchController:self didReceiveResults:results forQuery:query]; +} + +- (void)inviteDidSucceed { + [self.delegate inviteDidSucceedForSearchController:self]; +} + +- (void)inviteDidFailForItems:(NSArray *)items { + if (!items) { + items = @[]; + } + [self.delegate inviteDidFailForItems:items fromSearchController:self]; +} + +@end diff --git a/ios/sdk/src/JitsiMeet.h b/ios/sdk/src/JitsiMeet.h index e1b00906df..53829e7b29 100644 --- a/ios/sdk/src/JitsiMeet.h +++ b/ios/sdk/src/JitsiMeet.h @@ -16,3 +16,4 @@ #import #import +#import diff --git a/ios/sdk/src/JitsiMeetView.h b/ios/sdk/src/JitsiMeetView.h index 1248fd8ea6..043ea00a8f 100644 --- a/ios/sdk/src/JitsiMeetView.h +++ b/ios/sdk/src/JitsiMeetView.h @@ -21,10 +21,14 @@ @interface JitsiMeetView : UIView -@property (nonatomic, nullable, weak) id delegate; +@property (nonatomic) BOOL addPeopleEnabled; @property (copy, nonatomic, nullable) NSURL *defaultURL; +@property (nonatomic, nullable, weak) id delegate; + +@property (nonatomic) BOOL dialOutEnabled; + @property (nonatomic) BOOL pictureInPictureEnabled; @property (nonatomic) BOOL welcomePageEnabled; diff --git a/ios/sdk/src/JitsiMeetView.m b/ios/sdk/src/JitsiMeetView.m index 6af40e48d3..a026821e45 100644 --- a/ios/sdk/src/JitsiMeetView.m +++ b/ios/sdk/src/JitsiMeetView.m @@ -268,6 +268,8 @@ static NSMapTable *views; props[@"defaultURL"] = [self.defaultURL absoluteString]; } + props[@"addPeopleEnabled"] = @(self.addPeopleEnabled); + props[@"dialOutEnabled"] = @(self.dialOutEnabled); props[@"externalAPIScope"] = externalAPIScope; props[@"pictureInPictureEnabled"] = @(self.pictureInPictureEnabled); props[@"welcomePageEnabled"] = @(self.welcomePageEnabled); diff --git a/ios/sdk/src/JitsiMeetViewDelegate.h b/ios/sdk/src/JitsiMeetViewDelegate.h index f13529e95c..1b13a9119e 100644 --- a/ios/sdk/src/JitsiMeetViewDelegate.h +++ b/ios/sdk/src/JitsiMeetViewDelegate.h @@ -14,6 +14,8 @@ * limitations under the License. */ +@class InviteSearchController; + @protocol JitsiMeetViewDelegate @optional @@ -55,6 +57,15 @@ */ - (void)conferenceWillLeave:(NSDictionary *)data; + +/** + * Called when the invite button in the conference is tapped. + * + * The search controller provided can be used to query user search within the + * conference. + */ +- (void)launchNativeInviteForSearchController:(InviteSearchController *)searchController; + /** * Called when entering Picture-in-Picture is requested by the user. The app * should now activate its Picture-in-Picture implementation (and resize the diff --git a/react/features/app/components/App.native.js b/react/features/app/components/App.native.js index d633e0c4e4..198da4b31c 100644 --- a/react/features/app/components/App.native.js +++ b/react/features/app/components/App.native.js @@ -37,6 +37,10 @@ export class App extends AbstractApp { static propTypes = { ...AbstractApp.propTypes, + addPeopleEnabled: PropTypes.bool, + + dialOutEnabled: PropTypes.bool, + /** * Whether Picture-in-Picture is enabled. If {@code true}, a toolbar * button is rendered in the {@link Conference} view to afford entering diff --git a/react/features/invite/components/AddPeopleDialog.web.js b/react/features/invite/components/AddPeopleDialog.web.js index 091f541769..ad2910bfd9 100644 --- a/react/features/invite/components/AddPeopleDialog.web.js +++ b/react/features/invite/components/AddPeopleDialog.web.js @@ -14,18 +14,14 @@ import { MultiSelectAutocomplete } from '../../base/react'; import { inviteVideoRooms } from '../../videosipgw'; import { - checkDialNumber, - invitePeopleAndChatRooms, - searchDirectory + sendInvitesForItems, + getInviteResultsForQuery } from '../functions'; const logger = require('jitsi-meet-logger').getLogger(__filename); declare var interfaceConfig: Object; -const isPhoneNumberRegex - = new RegExp(interfaceConfig.PHONE_NUMBER_REGEX || '^[0-9+()-\\s]*$'); - /** * The dialog that allows to invite people to the call. */ @@ -240,20 +236,6 @@ class AddPeopleDialog extends Component<*, *> { ); } - _getDigitsOnly: (string) => string; - - /** - * Removes all non-numeric characters from a string. - * - * @param {string} text - The string from which to remove all characters - * except numbers. - * @private - * @returns {string} A string with only numbers. - */ - _getDigitsOnly(text = '') { - return text.replace(/\D/g, ''); - } - /** * Helper for determining how many of each type of user is being invited. * Used for logging and sending analytics related to invites. @@ -294,27 +276,6 @@ class AddPeopleDialog extends Component<*, *> { || this.state.addToCallInProgress; } - _isMaybeAPhoneNumber: (string) => boolean; - - /** - * Checks whether a string looks like it could be for a phone number. - * - * @param {string} text - The text to check whether or not it could be a - * phone number. - * @private - * @returns {boolean} True if the string looks like it could be a phone - * number. - */ - _isMaybeAPhoneNumber(text) { - if (!isPhoneNumberRegex.test(text)) { - return false; - } - - const digits = this._getDigitsOnly(text); - - return Boolean(digits.length); - } - _onItemSelected: (Object) => Object; /** @@ -379,75 +340,26 @@ class AddPeopleDialog extends Component<*, *> { addToCallInProgress: true }); - let allInvitePromises = []; - let invitesLeftToSend = [ - ...this.state.inviteItems - ]; + const { + _conference, + _inviteServiceUrl, + _inviteUrl, + _jwt + } = this.props; - // First create all promises for dialing out. - if (this.props.enableDialOut && this.props._conference) { - const phoneNumbers = invitesLeftToSend.filter( - ({ item }) => item.type === 'phone'); + const inviteItems = this.state.inviteItems; + const items = inviteItems.map(item => item.item); - // For each number, dial out. On success, remove the number from - // {@link invitesLeftToSend}. - const phoneInvitePromises = phoneNumbers.map(number => { - const numberToInvite = this._getDigitsOnly(number.item.number); + const options = { + conference: _conference, + inviteServiceUrl: _inviteServiceUrl, + inviteUrl: _inviteUrl, + inviteVideoRooms: this.props.inviteVideoRooms, + jwt: _jwt + }; - return this.props._conference.dial(numberToInvite) - .then(() => { - invitesLeftToSend - = invitesLeftToSend.filter(invite => - invite !== number); - }) - .catch(error => logger.error( - 'Error inviting phone number:', error)); - - }); - - allInvitePromises = allInvitePromises.concat(phoneInvitePromises); - } - - if (this.props.enableAddPeople) { - const usersAndRooms = invitesLeftToSend.filter(i => - i.item.type === 'user' || i.item.type === 'room') - .map(i => i.item); - - if (usersAndRooms.length) { - // Send a request to invite all the rooms and users. On success, - // filter all rooms and users from {@link invitesLeftToSend}. - const peopleInvitePromise = invitePeopleAndChatRooms( - this.props._inviteServiceUrl, - this.props._inviteUrl, - this.props._jwt, - usersAndRooms) - .then(() => { - invitesLeftToSend = invitesLeftToSend.filter(i => - i.item.type !== 'user' && i.item.type !== 'room'); - }) - .catch(error => logger.error( - 'Error inviting people:', error)); - - allInvitePromises.push(peopleInvitePromise); - } - - // Sipgw calls are fire and forget. Invite them to the conference - // then immediately remove them from {@link invitesLeftToSend}. - const vrooms = invitesLeftToSend.filter(i => - i.item.type === 'videosipgw') - .map(i => i.item); - - this.props._conference - && vrooms.length > 0 - && this.props.inviteVideoRooms( - this.props._conference, vrooms); - - invitesLeftToSend = invitesLeftToSend.filter(i => - i.item.type !== 'videosipgw'); - } - - Promise.all(allInvitePromises) - .then(() => { + sendInvitesForItems(items, options) + .then(invitesLeftToSend => { // If any invites are left that means something failed to send // so treat it as an error. if (invitesLeftToSend.length) { @@ -467,8 +379,18 @@ class AddPeopleDialog extends Component<*, *> { addToCallError: true }); + const unsentInviteIDs = invitesLeftToSend.map(invite => + invite.id || invite.number + ); + + const itemsToSelect = inviteItems.filter(invite => + unsentInviteIDs.includes( + invite.item.id || invite.item.number + ) + ); + if (this._multiselect) { - this._multiselect.setSelectedItems(invitesLeftToSend); + this._multiselect.setSelectedItems(itemsToSelect); } return; @@ -558,82 +480,25 @@ class AddPeopleDialog extends Component<*, *> { * @returns {Promise} */ _query(query = '') { - const text = query.trim(); const { + enableAddPeople, + enableDialOut, _dialOutAuthUrl, _jwt, _peopleSearchQueryTypes, _peopleSearchUrl } = this.props; - let peopleSearchPromise; + const options = { + dialOutAuthUrl: _dialOutAuthUrl, + enableAddPeople, + enableDialOut, + jwt: _jwt, + peopleSearchQueryTypes: _peopleSearchQueryTypes, + peopleSearchUrl: _peopleSearchUrl + }; - if (this.props.enableAddPeople && text) { - peopleSearchPromise = searchDirectory( - _peopleSearchUrl, - _jwt, - text, - _peopleSearchQueryTypes); - } else { - peopleSearchPromise = Promise.resolve([]); - } - - - const hasCountryCode = text.startsWith('+'); - let phoneNumberPromise; - - if (this.props.enableDialOut && this._isMaybeAPhoneNumber(text)) { - let numberToVerify = text; - - // When the number to verify does not start with a +, we assume no - // proper country code has been entered. In such a case, prepend 1 - // for the country code. The service currently takes care of - // prepending the +. - if (!hasCountryCode && !text.startsWith('1')) { - numberToVerify = `1${numberToVerify}`; - } - - // The validation service works properly when the query is digits - // only so ensure only digits get sent. - numberToVerify = this._getDigitsOnly(numberToVerify); - - phoneNumberPromise - = checkDialNumber(numberToVerify, _dialOutAuthUrl); - } else { - phoneNumberPromise = Promise.resolve({}); - } - - return Promise.all([ peopleSearchPromise, phoneNumberPromise ]) - .then(([ peopleResults, phoneResults ]) => { - const results = [ - ...peopleResults - ]; - - /** - * This check for phone results is for the day the call to - * searching people might return phone results as well. When - * that day comes this check will make it so the server checks - * are honored and the local appending of the number is not - * done. The local appending of the phone number can then be - * cleaned up when convenient. - */ - const hasPhoneResult = peopleResults.find( - result => result.type === 'phone'); - - if (!hasPhoneResult - && typeof phoneResults.allow === 'boolean') { - results.push({ - allowed: phoneResults.allow, - country: phoneResults.country, - type: 'phone', - number: phoneResults.phone, - originalEntry: text, - showCountryCodeReminder: !hasCountryCode - }); - } - - return results; - }); + return getInviteResultsForQuery(query, options); } /** diff --git a/react/features/invite/components/InviteButton.native.js b/react/features/invite/components/InviteButton.native.js new file mode 100644 index 0000000000..25a1f0a81b --- /dev/null +++ b/react/features/invite/components/InviteButton.native.js @@ -0,0 +1,91 @@ +// @flow + +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { launchNativeInvite } from '../../mobile/invite-search'; +import { ToolbarButton } from '../../toolbox'; + +/** + * The type of {@link EnterPictureInPictureToobarButton}'s React + * {@code Component} props. + */ +type Props = { + + /** + * Indicates if the "Add to call" feature is available. + */ + enableAddPeople: boolean, + + /** + * Indicates if the "Dial out" feature is available. + */ + enableDialOut: boolean, + + /** + * Launches native invite dialog. + * + * @protected + */ + onLaunchNativeInvite: Function, +}; + +/** + * Implements a {@link ToolbarButton} to enter Picture-in-Picture. + */ +class InviteButton extends Component { + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { + enableAddPeople, + enableDialOut, + onLaunchNativeInvite, + ...props + } = this.props; + + if (!enableAddPeople && !enableDialOut) { + return null; + } + + return ( + + ); + } +} + +/** + * Maps redux actions to {@link InviteButton}'s React + * {@code Component} props. + * + * @param {Function} dispatch - The redux action {@code dispatch} function. + * @returns {{ +* onLaunchNativeInvite + * }} + * @private + */ +function _mapDispatchToProps(dispatch) { + return { + + /** + * Launches native invite dialog. + * + * @private + * @returns {void} + * @type {Function} + */ + onLaunchNativeInvite() { + dispatch(launchNativeInvite()); + } + }; +} + +export default connect(undefined, _mapDispatchToProps)(InviteButton); diff --git a/react/features/invite/functions.js b/react/features/invite/functions.js index 8be2b821c8..d52bce50d8 100644 --- a/react/features/invite/functions.js +++ b/react/features/invite/functions.js @@ -1,5 +1,6 @@ // @flow +import { getLocalParticipant, PARTICIPANT_ROLE } from '../base/participants'; import { doGetJSON } from '../base/util'; declare var $: Function; @@ -50,7 +51,7 @@ export function getDialInNumbers(url: string): Promise<*> { * type items to invite. * @returns {Promise} - The promise created by the request. */ -export function invitePeopleAndChatRooms( // eslint-disable-line max-params +function invitePeopleAndChatRooms( // eslint-disable-line max-params inviteServiceUrl: string, inviteUrl: string, jwt: string, @@ -88,9 +89,10 @@ export function searchDirectory( // eslint-disable-line max-params text: string, queryTypes: Array = [ 'conferenceRooms', 'user', 'room' ] ): Promise> { - const queryTypesString = JSON.stringify(queryTypes); + const query = encodeURIComponent(text); + const queryTypesString = encodeURIComponent(JSON.stringify(queryTypes)); - return fetch(`${serviceUrl}?query=${encodeURIComponent(text)}&queryTypes=${ + return fetch(`${serviceUrl}?query=${query}&queryTypes=${ queryTypesString}&jwt=${jwt}`) .then(response => { const jsonify = response.json(); @@ -110,6 +112,21 @@ export function searchDirectory( // eslint-disable-line max-params }); } +/** + * RegExp to use to determine if some text might be a phone number. + * + * @returns {RegExp} + */ +function isPhoneNumberRegex(): RegExp { + let regexString = '^[0-9+()-\\s]*$'; + + if (typeof interfaceConfig !== 'undefined') { + regexString = interfaceConfig.PHONE_NUMBER_REGEX || regexString; + } + + return new RegExp(regexString); +} + /** * Sends an ajax request to check if the phone number can be called. * @@ -137,3 +154,315 @@ export function checkDialNumber( .catch(reject); }); } + +/** + * Removes all non-numeric characters from a string. + * + * @param {string} text - The string from which to remove all characters + * except numbers. + * @private + * @returns {string} A string with only numbers. + */ +function getDigitsOnly(text: string = ''): string { + return text.replace(/\D/g, ''); +} + +/** + * Type of the options to use when sending a search query. + */ +export type GetInviteResultsOptions = { + + /** + * The endpoint to use for checking phone number validity. + */ + dialOutAuthUrl: string, + + /** + * Whether or not to search for people. + */ + enableAddPeople: boolean, + + /** + * Whether or not to check phone numbers. + */ + enableDialOut: boolean, + + /** + * Array with the query types that will be executed - + * "conferenceRooms" | "user" | "room". + */ + peopleSearchQueryTypes: Array, + + /** + * The url to query for people. + */ + peopleSearchUrl: string, + + /** + * The jwt token to pass to the search service. + */ + jwt: string +}; + +/** + * Combines directory search with phone number validation to produce a single + * set of invite search results. + * + * @param {string} query - Text to search. + * @param {GetInviteResultsOptions} options - Options to use when searching. + * @returns {Promise<*>} + */ +export function getInviteResultsForQuery( + query: string, + options: GetInviteResultsOptions): Promise<*> { + const text = query.trim(); + + const { + dialOutAuthUrl, + enableAddPeople, + enableDialOut, + peopleSearchQueryTypes, + peopleSearchUrl, + jwt + } = options; + + let peopleSearchPromise; + + if (enableAddPeople && text) { + peopleSearchPromise = searchDirectory( + peopleSearchUrl, + jwt, + text, + peopleSearchQueryTypes); + } else { + peopleSearchPromise = Promise.resolve([]); + } + + + const hasCountryCode = text.startsWith('+'); + let phoneNumberPromise; + + if (enableDialOut && isMaybeAPhoneNumber(text)) { + let numberToVerify = text; + + // When the number to verify does not start with a +, we assume no + // proper country code has been entered. In such a case, prepend 1 + // for the country code. The service currently takes care of + // prepending the +. + if (!hasCountryCode && !text.startsWith('1')) { + numberToVerify = `1${numberToVerify}`; + } + + // The validation service works properly when the query is digits + // only so ensure only digits get sent. + numberToVerify = getDigitsOnly(numberToVerify); + + phoneNumberPromise + = checkDialNumber(numberToVerify, dialOutAuthUrl); + } else { + phoneNumberPromise = Promise.resolve({}); + } + + return Promise.all([ peopleSearchPromise, phoneNumberPromise ]) + .then(([ peopleResults, phoneResults ]) => { + const results = [ + ...peopleResults + ]; + + /** + * This check for phone results is for the day the call to + * searching people might return phone results as well. When + * that day comes this check will make it so the server checks + * are honored and the local appending of the number is not + * done. The local appending of the phone number can then be + * cleaned up when convenient. + */ + const hasPhoneResult = peopleResults.find( + result => result.type === 'phone'); + + if (!hasPhoneResult + && typeof phoneResults.allow === 'boolean') { + results.push({ + allowed: phoneResults.allow, + country: phoneResults.country, + type: 'phone', + number: phoneResults.phone, + originalEntry: text, + showCountryCodeReminder: !hasCountryCode + }); + } + + return results; + }); +} + +/** + * Checks whether a string looks like it could be for a phone number. + * + * @param {string} text - The text to check whether or not it could be a + * phone number. + * @private + * @returns {boolean} True if the string looks like it could be a phone + * number. + */ +function isMaybeAPhoneNumber(text: string): boolean { + if (!isPhoneNumberRegex().test(text)) { + return false; + } + + const digits = getDigitsOnly(text); + + return Boolean(digits.length); +} + +/** + * Type of the options to use when sending invites. + */ +export type SendInvitesOptions = { + + /** + * Conference object used to dial out. + */ + conference: Object, + + /** + * The URL to send invites through. + */ + inviteServiceUrl: string, + + /** + * The URL sent with each invite. + */ + inviteUrl: string, + + /** + * The function to use to invite video rooms. + * + * @param {Object} The conference to which the video rooms should be + * invited. + * @param {Array} The list of rooms that should be invited. + * @returns {void} + */ + inviteVideoRooms: (Object, Array) => void, + + /** + * The jwt token to pass to the invite service. + */ + jwt: string +}; + +/** + * Send invites for a list of items (may be a combination of users, rooms, phone + * numbers, and video rooms). + * + * @param {Array} invites - Items for which invites should be sent. + * @param {SendInvitesOptions} options - Options to use when sending the + * provided invites. + * @returns {Promise} Promise containing the list of invites that were not sent. + */ +export function sendInvitesForItems( + invites: Array, + options: SendInvitesOptions +): Promise> { + + const { + conference, + inviteServiceUrl, + inviteUrl, + inviteVideoRooms, + jwt + } = options; + + let allInvitePromises = []; + let invitesLeftToSend = [ ...invites ]; + + // First create all promises for dialing out. + if (conference) { + const phoneNumbers = invitesLeftToSend.filter( + item => item.type === 'phone'); + + // For each number, dial out. On success, remove the number from + // {@link invitesLeftToSend}. + const phoneInvitePromises = phoneNumbers.map(item => { + const numberToInvite = getDigitsOnly(item.number); + + return conference.dial(numberToInvite) + .then(() => { + invitesLeftToSend + = invitesLeftToSend.filter(invite => + invite !== item); + }) + .catch(error => logger.error( + 'Error inviting phone number:', error)); + + }); + + allInvitePromises = allInvitePromises.concat(phoneInvitePromises); + } + + const usersAndRooms = invitesLeftToSend.filter(item => + item.type === 'user' || item.type === 'room'); + + if (usersAndRooms.length) { + // Send a request to invite all the rooms and users. On success, + // filter all rooms and users from {@link invitesLeftToSend}. + const peopleInvitePromise = invitePeopleAndChatRooms( + inviteServiceUrl, + inviteUrl, + jwt, + usersAndRooms) + .then(() => { + invitesLeftToSend = invitesLeftToSend.filter(item => + item.type !== 'user' && item.type !== 'room'); + }) + .catch(error => logger.error( + 'Error inviting people:', error)); + + allInvitePromises.push(peopleInvitePromise); + } + + // Sipgw calls are fire and forget. Invite them to the conference + // then immediately remove them from {@link invitesLeftToSend}. + const vrooms = invitesLeftToSend.filter(item => + item.type === 'videosipgw'); + + conference + && vrooms.length > 0 + && inviteVideoRooms(conference, vrooms); + + invitesLeftToSend = invitesLeftToSend.filter(item => + item.type !== 'videosipgw'); + + return Promise.all(allInvitePromises) + .then(() => invitesLeftToSend); +} + +/** + * Determines if adding people is currently enabled. + * + * @param {boolean} state - Current state. + * @returns {boolean} Indication of whether adding people is currently enabled. + */ +export function isAddPeopleEnabled(state: Object): boolean { + const { app } = state['features/app']; + const { isGuest } = state['features/base/jwt']; + + return !isGuest && Boolean(app && app.props.addPeopleEnabled); +} + +/** + * Determines if dial out is currently enabled or not. + * + * @param {boolean} state - Current state. + * @returns {boolean} Indication of whether dial out is currently enabled. + */ +export function isDialOutEnabled(state: Object): boolean { + const { conference } = state['features/base/conference']; + const { isGuest } = state['features/base/jwt']; + const { enableUserRolesBasedOnToken } = state['features/base/config']; + const participant = getLocalParticipant(state); + + return participant && participant.role === PARTICIPANT_ROLE.MODERATOR + && conference && conference.isSIPCallingSupported() + && (!enableUserRolesBasedOnToken || !isGuest); +} diff --git a/react/features/invite/index.js b/react/features/invite/index.js index 085a8cab20..48450b4e0e 100644 --- a/react/features/invite/index.js +++ b/react/features/invite/index.js @@ -1,5 +1,6 @@ export * from './actions'; export * from './components'; +export * from './functions'; import './reducer'; import './middleware'; diff --git a/react/features/mobile/invite-search/actionTypes.js b/react/features/mobile/invite-search/actionTypes.js new file mode 100644 index 0000000000..236fc6b002 --- /dev/null +++ b/react/features/mobile/invite-search/actionTypes.js @@ -0,0 +1,46 @@ +/** + * The type of redux action to set InviteSearch's event subscriptions. + * + * { + * type: _SET_INVITE_SEARCH_SUBSCRIPTIONS, + * subscriptions: Array|undefined + * } + * + * @protected + */ +export const _SET_INVITE_SEARCH_SUBSCRIPTIONS + = Symbol('_SET_INVITE_SEARCH_SUBSCRIPTIONS'); + + +/** + * The type of the action which signals a request to launch the native invite + * dialog. + * + * { + * type: LAUNCH_NATIVE_INVITE + * } + */ +export const LAUNCH_NATIVE_INVITE = Symbol('LAUNCH_NATIVE_INVITE'); + +/** + * The type of the action which signals that native invites were sent + * successfully. + * + * { + * type: SEND_INVITE_SUCCESS, + * inviteScope: string + * } + */ +export const SEND_INVITE_SUCCESS = Symbol('SEND_INVITE_SUCCESS'); + +/** + * The type of the action which signals that native invites failed to send + * successfully. + * + * { + * type: SEND_INVITE_FAILURE, + * items: Array<*>, + * inviteScope: string + * } + */ +export const SEND_INVITE_FAILURE = Symbol('SEND_INVITE_FAILURE'); diff --git a/react/features/mobile/invite-search/actions.js b/react/features/mobile/invite-search/actions.js new file mode 100644 index 0000000000..f01daabdc6 --- /dev/null +++ b/react/features/mobile/invite-search/actions.js @@ -0,0 +1,50 @@ +// @flow + +import { + LAUNCH_NATIVE_INVITE, + SEND_INVITE_SUCCESS, + SEND_INVITE_FAILURE +} from './actionTypes'; + +/** + * Launches the native invite dialog. + * + * @returns {{ + * type: LAUNCH_NATIVE_INVITE + * }} + */ +export function launchNativeInvite() { + return { + type: LAUNCH_NATIVE_INVITE + }; +} + +/** + * Indicates that all native invites were sent successfully. + * + * @param {string} inviteScope - Scope identifier for the invite success. This + * is used to look up relevant information on the native side. + * @returns {void} + */ +export function sendInviteSuccess(inviteScope: string) { + return { + type: SEND_INVITE_SUCCESS, + inviteScope + }; +} + +/** + * Indicates that some native invites failed to send successfully. + * + * @param {Array<*>} items - Invite items that failed to send. + * @param {string} inviteScope - Scope identifier for the invite failure. This + * is used to look up relevant information on the native side. + * @returns {void} + */ +export function sendInviteFailure(items: Array<*>, inviteScope: string) { + return { + type: SEND_INVITE_FAILURE, + items, + inviteScope + }; +} diff --git a/react/features/mobile/invite-search/index.js b/react/features/mobile/invite-search/index.js new file mode 100644 index 0000000000..496fe99f8b --- /dev/null +++ b/react/features/mobile/invite-search/index.js @@ -0,0 +1,5 @@ +export * from './actions'; +export * from './actionTypes'; + +import './reducer'; +import './middleware'; diff --git a/react/features/mobile/invite-search/middleware.js b/react/features/mobile/invite-search/middleware.js new file mode 100644 index 0000000000..2cb6b31e69 --- /dev/null +++ b/react/features/mobile/invite-search/middleware.js @@ -0,0 +1,233 @@ +/* @flow */ + +import i18next from 'i18next'; +import { NativeModules, NativeEventEmitter } from 'react-native'; + +import { MiddlewareRegistry } from '../../base/redux'; +import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../app'; +import { getInviteURL } from '../../base/connection'; +import { + getInviteResultsForQuery, + isAddPeopleEnabled, + isDialOutEnabled, + sendInvitesForItems +} from '../../invite'; +import { inviteVideoRooms } from '../../videosipgw'; + +import { sendInviteSuccess, sendInviteFailure } from './actions'; +import { + _SET_INVITE_SEARCH_SUBSCRIPTIONS, + LAUNCH_NATIVE_INVITE, + SEND_INVITE_SUCCESS, + SEND_INVITE_FAILURE +} from './actionTypes'; + +/** + * Middleware that captures Redux actions and uses the InviteSearch module to + * turn them into native events so the application knows about them. + * + * @param {Store} store - Redux store. + * @returns {Function} + */ +MiddlewareRegistry.register(store => next => action => { + const result = next(action); + + switch (action.type) { + + case APP_WILL_MOUNT: + return _appWillMount(store, next, action); + + case APP_WILL_UNMOUNT: + store.dispatch({ + type: _SET_INVITE_SEARCH_SUBSCRIPTIONS, + subscriptions: undefined + }); + break; + + case LAUNCH_NATIVE_INVITE: + launchNativeInvite(store); + break; + + case SEND_INVITE_SUCCESS: + onSendInviteSuccess(action); + break; + + case SEND_INVITE_FAILURE: + onSendInviteFailure(action); + break; + } + + return result; +}); + +/** + * Notifies the feature jwt that the action {@link APP_WILL_MOUNT} is being + * dispatched within a specific redux {@code store}. + * + * @param {Store} store - The redux store in which the specified {@code action} + * is being dispatched. + * @param {Dispatch} next - The redux dispatch function to dispatch the + * specified {@code action} to the specified {@code store}. + * @param {Action} action - The redux action {@code APP_WILL_MOUNT} which is + * being dispatched in the specified {@code store}. + * @private + * @returns {*} + */ +function _appWillMount({ dispatch, getState }, next, action) { + const result = next(action); + + const emitter = new NativeEventEmitter(NativeModules.InviteSearch); + + const context = { + dispatch, + getState + }; + const subscriptions = [ + emitter.addListener( + 'performQueryAction', + _onPerformQueryAction, + context), + emitter.addListener( + 'performSubmitInviteAction', + _onPerformSubmitInviteAction, + context) + ]; + + dispatch({ + type: _SET_INVITE_SEARCH_SUBSCRIPTIONS, + subscriptions + }); + + return result; +} + +/** + * Sends a request to the native counterpart of InviteSearch to launch a native. + * invite search. + * + * @param {Object} store - The redux store. + * @private + * @returns {void} + */ +function launchNativeInvite(store: { getState: Function }) { + // The JavaScript App needs to provide uniquely identifying information + // to the native module so that the latter may match the former + // to the native JitsiMeetView which hosts it. + const { app } = store.getState()['features/app']; + + if (app) { + const { externalAPIScope } = app.props; + + if (externalAPIScope) { + NativeModules.InviteSearch.launchNativeInvite(externalAPIScope); + } + } +} + +/** + * Sends a notification to the native counterpart of InviteSearch that all + * invites were sent successfully. + * + * @param {Object} action - The redux action {@code SEND_INVITE_SUCCESS} which + * is being dispatched. + * @returns {void} + */ +function onSendInviteSuccess({ inviteScope }) { + NativeModules.InviteSearch.inviteSucceeded(inviteScope); +} + +/** + * Sends a notification to the native counterpart of InviteSearch that some + * invite items failed to send successfully. + * + * @param {Object} action - The redux action {@code SEND_INVITE_FAILURE} which + * is being dispatched. + * @returns {void} + */ +function onSendInviteFailure({ items, inviteScope }) { + NativeModules.InviteSearch.inviteFailedForItems(items, inviteScope); +} + +/** + * Handles InviteSearch's event {@code performQueryAction}. + * + * @param {Object} event - The details of the InviteSearch event + * {@code performQueryAction}. + * @returns {void} + */ +function _onPerformQueryAction({ query, inviteScope }) { + const { getState } = this; // eslint-disable-line no-invalid-this + + const state = getState(); + + const { + dialOutAuthUrl, + peopleSearchQueryTypes, + peopleSearchUrl + } = state['features/base/config']; + + const options = { + dialOutAuthUrl, + enableAddPeople: isAddPeopleEnabled(state), + enableDialOut: isDialOutEnabled(state), + jwt: state['features/base/jwt'].jwt, + peopleSearchQueryTypes, + peopleSearchUrl + }; + + getInviteResultsForQuery(query, options) + .catch(() => []) + .then(results => { + const translatedResults = results.map(result => { + if (result.type === 'phone') { + result.title = i18next.t('addPeople.telephone', { + number: result.number + }); + + if (result.showCountryCodeReminder) { + result.subtitle = i18next.t( + 'addPeople.countryReminder' + ); + } + } + + return result; + }).filter(result => result.type !== 'phone' || result.allowed); + + NativeModules.InviteSearch.receivedResults( + translatedResults, + query, + inviteScope); + }); +} + +/** + * Handles InviteSearch's event {@code performSubmitInviteAction}. + * + * @param {Object} event - The details of the InviteSearch event. + * @returns {void} + */ +function _onPerformSubmitInviteAction({ selectedItems, inviteScope }) { + const { dispatch, getState } = this; // eslint-disable-line no-invalid-this + const state = getState(); + const { conference } = state['features/base/conference']; + const { + inviteServiceUrl + } = state['features/base/config']; + const options = { + conference, + inviteServiceUrl, + inviteUrl: getInviteURL(state), + inviteVideoRooms, + jwt: state['features/base/jwt'].jwt + }; + + sendInvitesForItems(selectedItems, options) + .then(invitesLeftToSend => { + if (invitesLeftToSend.length) { + dispatch(sendInviteFailure(invitesLeftToSend, inviteScope)); + } else { + dispatch(sendInviteSuccess(inviteScope)); + } + }); +} diff --git a/react/features/mobile/invite-search/reducer.js b/react/features/mobile/invite-search/reducer.js new file mode 100644 index 0000000000..370ae3be56 --- /dev/null +++ b/react/features/mobile/invite-search/reducer.js @@ -0,0 +1,14 @@ +import { assign, ReducerRegistry } from '../../base/redux'; + +import { _SET_INVITE_SEARCH_SUBSCRIPTIONS } from './actionTypes'; + +ReducerRegistry.register( + 'features/invite-search', + (state = {}, action) => { + switch (action.type) { + case _SET_INVITE_SEARCH_SUBSCRIPTIONS: + return assign(state, 'subscriptions', action.subscriptions); + } + + return state; + }); diff --git a/react/features/toolbox/components/Toolbox.native.js b/react/features/toolbox/components/Toolbox.native.js index f11f38ec09..47926e84cf 100644 --- a/react/features/toolbox/components/Toolbox.native.js +++ b/react/features/toolbox/components/Toolbox.native.js @@ -14,6 +14,11 @@ import { isNarrowAspectRatio, makeAspectRatioAware } from '../../base/responsive-ui'; +import { + InviteButton, + isAddPeopleEnabled, + isDialOutEnabled +} from '../../invite'; import { EnterPictureInPictureToolbarButton } from '../../mobile/picture-in-picture'; @@ -39,7 +44,7 @@ import { AudioMuteButton, HangupButton, VideoMuteButton } from './buttons'; * @private * @type {boolean} */ -const _SHARE_ROOM_TOOLBAR_BUTTON = true; +const _SHARE_ROOM_TOOLBAR_BUTTON = false; /** * The type of {@link Toolbox}'s React {@code Component} props. @@ -56,6 +61,18 @@ type Props = { */ _audioOnly: boolean, + /** + * Whether or not the feature to directly invite people into the + * conference is available. + */ + _enableAddPeople: boolean, + + /** + * Whether or not the feature to dial out to number to join the + * conference is available. + */ + _enableDialOut: boolean, + /** * The indicator which determines whether the toolbox is enabled. */ @@ -212,9 +229,13 @@ class Toolbox extends Component { const underlayColor = 'transparent'; const { _audioOnly: audioOnly, + _enableAddPeople: enableAddPeople, + _enableDialOut: enableDialOut, _videoMuted: videoMuted } = this.props; + const showInviteButton = enableAddPeople || enableDialOut; + /* eslint-disable react/jsx-curly-spacing,react/jsx-handler-names */ return ( @@ -252,7 +273,7 @@ class Toolbox extends Component { style = { style } underlayColor = { underlayColor } /> { - _SHARE_ROOM_TOOLBAR_BUTTON + _SHARE_ROOM_TOOLBAR_BUTTON && !showInviteButton && { style = { style } underlayColor = { underlayColor } /> } + { + showInviteButton + && + }