[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:
committed by
Saúl Ibarra Corretgé
parent
4e36127dc7
commit
f64c13d4b7
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user