[RN] add support for inviting participants during a call on mobile

* Button conditionally shown based on if the feature is enabled and available
* Hooks for launching the invite UI (delegates to the native layer)
* Hooks for using the search and dial out checks from the native layer (calls back into JS)
* Hooks for handling sending invites and passing any failures back to the native layer
* Android and iOS handling for those hooks

Author: Ryan Peck <rpeck@atlassian.com>
Author: Eric Brynsvold <ebrynsvold@atlassian.com>
This commit is contained in:
Ryan Peck 2018-03-27 19:49:22 -05:00 committed by Saúl Ibarra Corretgé
parent 4e36127dc7
commit f64c13d4b7
24 changed files with 1559 additions and 185 deletions

View File

@ -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<String, Object>());
}
@Override
public void onLoadConfigError(Map<String, Object> data) {
on("LOAD_CONFIG_ERROR", data);

View File

@ -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<String, ReadableMap> items = new HashMap<>();
/**
* Randomly generated UUID, used for identification in the InviteSearchModule
*/
private String uuid = UUID.randomUUID().toString();
private WeakReference<InviteSearchModule> 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<String> ids) {
WritableArray selectedItems = new WritableNativeArray();
for(int i=0; i<ids.size(); i++) {
if(items.containsKey(ids.get(i))) {
WritableNativeMap map = new WritableNativeMap();
map.merge(items.get(ids.get(i)));
selectedItems.pushMap(map);
} else {
// if the id doesn't exist in the map, we can't do anything, so just skip it
}
}
JitsiMeetView.submitSelectedItems(selectedItems, uuid);
}
/**
* Caches results received by the search into a local map for use
* later when the items are submitted. Submission requires the full
* map of information, but only the IDs are returned back to the delegate.
* Using this map means we don't have to send the whole map back to the delegate.
*
* @param results
* @param query
*/
void receivedResultsForQuery(ReadableArray results, String query) {
List<Map<String, Object>> jvmResults = new ArrayList<>();
// cache results for use in submission later
// convert to jvm array
for(int i=0; i<results.size(); i++) {
ReadableMap map = results.getMap(i);
if(map.hasKey("id")) {
items.put(map.getString("id"), map);
} else if(map.hasKey("type") && map.getString("type").equals("phone") && map.hasKey("number")) {
items.put(map.getString("number"), map);
} else {
Log.w("InviteSearchController", "Received result without id and that was not a phone number, so not adding it to suggestions: " + map);
}
jvmResults.add(map.toHashMap());
}
searchControllerDelegate.onReceiveResults(this, jvmResults, query);
}
/**
*
* @return the InviteSearchControllerDelegate for this controller, used to pass query
* results back to the native code that initiated the query.
*/
public InviteSearchControllerDelegate getSearchControllerDelegate() {
return searchControllerDelegate;
}
/**
* Sets the InviteSearchControllerDelegate for this controller, used to pass query results
* back to the native code that initiated the query.
*
* @param searchControllerDelegate
*/
public void setSearchControllerDelegate(InviteSearchControllerDelegate searchControllerDelegate) {
this.searchControllerDelegate = searchControllerDelegate;
}
/**
* Cancel the invitation flow and free memory allocated to the InviteSearchController. After
* calling this method, this object is invalid - a new InviteSearchController will be passed
* to the caller through launchNativeInvite.
*/
public void cancelSearch() {
InviteSearchModule parentModule = parentModuleRef.get();
if(parentModule != null) {
parentModule.removeSearchController(uuid);
}
}
/**
* @return the unique identifier for this InviteSearchController
*/
public String getUuid() {
return uuid;
}
public interface InviteSearchControllerDelegate {
/**
* Called when results are received for a query called through InviteSearchController.query()
*
* @param searchController
* @param results a List of Map<String, Object> 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<Map<String, Object>> 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<String, Object>} 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<Map<String, Object>> failedInviteItems);
}
}

View File

@ -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<String, InviteSearchController> 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<Map<String, Object>> jvmItems = new ArrayList<>();
for(int i=0; i<items.size(); i++) {
ReadableMap item = items.getMap(i);
jvmItems.add(item.toHashMap());
}
controller.getSearchControllerDelegate().inviteFailed(controller, jvmItems);
}
@ReactMethod
public void inviteSucceeded(String inviteSearchControllerScope) {
InviteSearchController controller = searchControllers.get(inviteSearchControllerScope);
if(controller == null) {
Log.w("InviteSearchModule", "Invite succeeded, but unable to find active controller to notify");
return;
}
controller.getSearchControllerDelegate().inviteSucceeded(controller);
searchControllers.remove(inviteSearchControllerScope);
}
void removeSearchController(String inviteSearchControllerUuid) {
searchControllers.remove(inviteSearchControllerUuid);
}
@Override
public String getName() {
return "InviteSearch";
}
private InviteSearchController createSearchController() {
InviteSearchController searchController = new InviteSearchController(this);
searchControllers.put(searchController.getUuid(), searchController);
return searchController;
}
}

View File

@ -32,7 +32,9 @@ import com.facebook.react.ReactRootView;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.common.LifecycleState;
import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler;
import com.facebook.react.modules.core.DeviceEventManagerModule;
@ -42,6 +44,7 @@ import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.WeakHashMap;
@ -75,6 +78,7 @@ public class JitsiMeetView extends FrameLayout {
new AppInfoModule(reactContext),
new AudioModeModule(reactContext),
new ExternalAPIModule(reactContext),
new InviteSearchModule(reactContext),
new PictureInPictureModule(reactContext),
new ProximityModule(reactContext),
new WiFiStatsModule(reactContext),
@ -268,15 +272,43 @@ public class JitsiMeetView extends FrameLayout {
sendEvent("onUserLeaveHint", null);
}
/**
* Starts a query for users to invite to the conference. Results will be
* returned through the {@link InviteSearchController.InviteSearchControllerDelegate#onReceiveResults(InviteSearchController, List, String)}
* method.
*
* @param query {@code String} to use for the query
*/
public static void onInviteQuery(String query, String inviteSearchControllerScope) {
WritableNativeMap params = new WritableNativeMap();
params.putString("query", query);
params.putString("inviteScope", inviteSearchControllerScope);
sendEvent("performQueryAction", params);
}
/**
* Sends JavaScript event to submit invitations to the given item ids
*
* @param selectedItems a WritableArray of WritableNativeMaps representing selected items.
* Each map representing a selected item should match the data passed
* back in the return from a query.
*/
public static void submitSelectedItems(WritableArray selectedItems, String inviteSearchControllerScope) {
WritableNativeMap params = new WritableNativeMap();
params.putArray("selectedItems", selectedItems);
params.putString("inviteScope", inviteSearchControllerScope);
sendEvent("performSubmitInviteAction", params);
}
/**
* Helper function to send an event to JavaScript.
*
* @param eventName {@code String} containing the event name.
* @param params {@code WritableMap} optional ancillary data for the event.
* @param data {@code Object} optional ancillary data for the event.
*/
private static void sendEvent(
String eventName,
@Nullable WritableMap params) {
@Nullable Object data) {
if (reactInstanceManager != null) {
ReactContext reactContext
= reactInstanceManager.getCurrentReactContext();
@ -284,11 +316,16 @@ public class JitsiMeetView extends FrameLayout {
reactContext
.getJSModule(
DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(eventName, params);
.emit(eventName, data);
}
}
}
/**
* Whether user invitation is enabled.
*/
private boolean addPeopleEnabled;
/**
* The default base {@code URL} used to join a conference when a partial URL
* (e.g. a room name only) is specified to {@link #loadURLString(String)} or
@ -296,6 +333,11 @@ public class JitsiMeetView extends FrameLayout {
*/
private URL defaultURL;
/**
* Whether the ability to add users by phone number is enabled.
*/
private boolean dialOutEnabled;
/**
* The unique identifier of this {@code JitsiMeetView} within the process
* for the purposes of {@link ExternalAPI}. The name scope was inspired by
@ -454,6 +496,9 @@ public class JitsiMeetView extends FrameLayout {
// welcomePageEnabled
props.putBoolean("welcomePageEnabled", welcomePageEnabled);
props.putBoolean("addPeopleEnabled", addPeopleEnabled);
props.putBoolean("dialOutEnabled", dialOutEnabled);
// XXX The method loadURLObject: is supposed to be imperative i.e.
// a second invocation with one and the same URL is expected to join
// the respective conference again if the first invocation was followed
@ -535,6 +580,18 @@ public class JitsiMeetView extends FrameLayout {
}
}
/**
* Sets whether the ability to add users to the call is enabled.
* If this is enabled, an add user button will appear on the {@link JitsiMeetView}.
* If enabled, and the user taps the add user button,
* {@link JitsiMeetViewListener#launchNativeInvite(Map)} will be called.
*
* @param addPeopleEnabled {@code true} to enable the add people button; otherwise, {@code false}
*/
public void setAddPeopleEnabled(boolean addPeopleEnabled) {
this.addPeopleEnabled = addPeopleEnabled;
}
/**
* Sets the default base {@code URL} used to join a conference when a
* partial URL (e.g. a room name only) is specified to
@ -548,6 +605,18 @@ public class JitsiMeetView extends FrameLayout {
this.defaultURL = defaultURL;
}
/**
* Sets whether the ability to add phone numbers to the call is enabled.
* Must be enabled along with {@link #setAddPeopleEnabled(boolean)} to
* be effective.
*
* @param dialOutEnabled {@code true} to enable the ability to add
* phone numbers to the call; otherwise, {@code false}
*/
public void setDialOutEnabled(boolean dialOutEnabled) {
this.dialOutEnabled = dialOutEnabled;
}
/**
* Sets a specific {@link JitsiMeetViewListener} on this
* {@code JitsiMeetView}.

View File

@ -46,4 +46,8 @@ public abstract class JitsiMeetViewAdapter implements JitsiMeetViewListener {
@Override
public void onLoadConfigError(Map<String, Object> data) {
}
@Override
public void launchNativeInvite(InviteSearchController inviteSearchController) {
}
}

View File

@ -59,6 +59,16 @@ public interface JitsiMeetViewListener {
*/
void onConferenceWillLeave(Map<String, Object> 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.

View File

@ -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 = "<group>"; };
75635B0820751D6D00F29C9F /* joined.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = joined.wav; path = ../../sounds/joined.wav; sourceTree = "<group>"; };
75635B0920751D6D00F29C9F /* left.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = left.wav; path = ../../sounds/left.wav; sourceTree = "<group>"; };
412BF89C206AA66F0053B9E5 /* InviteSearch.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InviteSearch.m; sourceTree = "<group>"; };
412BF89E206AA82F0053B9E5 /* InviteSearch.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = InviteSearch.h; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
@ -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 */,

View File

@ -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<NSDictionary*> * _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<NSDictionary *> * _Nonnull)items
fromSearchController:(InviteSearchController * _Nonnull)searchController;
@end
@interface InviteSearchController: NSObject
@property (nonatomic, nullable, weak) id<InviteSearchControllerDelegate> delegate;
- (void)performQuery:(NSString * _Nonnull)query;
- (void)cancelSearch;
- (void)submitSelectedItemIds:(NSArray<NSString *> * _Nonnull)ids;
@end

215
ios/sdk/src/InviteSearch.m Normal file
View File

@ -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 <React/RCTBridge.h>
#import <React/RCTEventEmitter.h>
#import <React/RCTUtils.h>
#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<NSDictionary*> * _Nonnull)results
forQuery:(NSString * _Nonnull)query;
- (void)inviteDidSucceed;
- (void)inviteDidFailForItems:(NSArray<NSDictionary *> *)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<NSString *> *)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<JitsiMeetViewDelegate> 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<NSDictionary *> *)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<NSDictionary *> * _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<NSString *> * _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<NSDictionary *> *)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<NSDictionary *> *)items {
if (!items) {
items = @[];
}
[self.delegate inviteDidFailForItems:items fromSearchController:self];
}
@end

View File

@ -16,3 +16,4 @@
#import <JitsiMeet/JitsiMeetView.h>
#import <JitsiMeet/JitsiMeetViewDelegate.h>
#import <JitsiMeet/InviteSearch.h>

View File

@ -21,10 +21,14 @@
@interface JitsiMeetView : UIView
@property (nonatomic, nullable, weak) id<JitsiMeetViewDelegate> delegate;
@property (nonatomic) BOOL addPeopleEnabled;
@property (copy, nonatomic, nullable) NSURL *defaultURL;
@property (nonatomic, nullable, weak) id<JitsiMeetViewDelegate> delegate;
@property (nonatomic) BOOL dialOutEnabled;
@property (nonatomic) BOOL pictureInPictureEnabled;
@property (nonatomic) BOOL welcomePageEnabled;

View File

@ -268,6 +268,8 @@ static NSMapTable<NSString *, JitsiMeetView *> *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);

View File

@ -14,6 +14,8 @@
* limitations under the License.
*/
@class InviteSearchController;
@protocol JitsiMeetViewDelegate <NSObject>
@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

View File

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

View File

@ -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);
}
/**

View File

@ -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<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
enableAddPeople,
enableDialOut,
onLaunchNativeInvite,
...props
} = this.props;
if (!enableAddPeople && !enableDialOut) {
return null;
}
return (
<ToolbarButton
iconName = { 'add' }
onClick = { onLaunchNativeInvite }
{ ...props } />
);
}
}
/**
* 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);

View File

@ -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<string> = [ 'conferenceRooms', 'user', 'room' ]
): Promise<Array<Object>> {
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<string>,
/**
* 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<Object>} The list of rooms that should be invited.
* @returns {void}
*/
inviteVideoRooms: (Object, Array<Object>) => 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<Object>} 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<Object>,
options: SendInvitesOptions
): Promise<Array<Object>> {
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);
}

View File

@ -1,5 +1,6 @@
export * from './actions';
export * from './components';
export * from './functions';
import './reducer';
import './middleware';

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export * from './actions';
export * from './actionTypes';
import './reducer';
import './middleware';

View File

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

View File

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

View File

@ -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<Props> {
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<Props> {
style = { style }
underlayColor = { underlayColor } />
{
_SHARE_ROOM_TOOLBAR_BUTTON
_SHARE_ROOM_TOOLBAR_BUTTON && !showInviteButton
&& <ToolbarButton
iconName = 'link'
iconStyle = { iconStyle }
@ -260,6 +281,15 @@ class Toolbox extends Component<Props> {
style = { style }
underlayColor = { underlayColor } />
}
{
showInviteButton
&& <InviteButton
enableAddPeople = { enableAddPeople }
enableDialOut = { enableDialOut }
iconStyle = { iconStyle }
style = { style }
underlayColor = { underlayColor } />
}
<EnterPictureInPictureToolbarButton
iconStyle = { iconStyle }
style = { style }
@ -388,6 +418,22 @@ function _mapStateToProps(state) {
*/
_audioOnly: Boolean(conference.audioOnly),
/**
* Whether or not the feature to directly invite people into the
* conference is available.
*
* @type {boolean}
*/
_enableAddPeople: isAddPeopleEnabled(state),
/**
* Whether or not the feature to dial out to number to join the
* conference is available.
*
* @type {boolean}
*/
_enableDialOut: isDialOutEnabled(state),
/**
* The indicator which determines whether the toolbox is enabled.
*