[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-04-25 18:58:06 +02:00
committed by Saúl Ibarra Corretgé
parent 4e36127dc7
commit f64c13d4b7
24 changed files with 1559 additions and 185 deletions
@@ -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);
}
}
@@ -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;
}
}
@@ -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}.
@@ -46,4 +46,8 @@ public abstract class JitsiMeetViewAdapter implements JitsiMeetViewListener {
@Override
public void onLoadConfigError(Map<String, Object> data) {
}
@Override
public void launchNativeInvite(InviteSearchController inviteSearchController) {
}
}
@@ -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.