Compare commits
39 Commits
3456
...
android-sd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c506ae5fd | ||
|
|
54e8d2c1a8 | ||
|
|
d27a94b3b5 | ||
|
|
e1710eaa38 | ||
|
|
f4467206d7 | ||
|
|
97e8b31cee | ||
|
|
55218de779 | ||
|
|
714e0e045d | ||
|
|
0bc369afb4 | ||
|
|
f71ec55170 | ||
|
|
760885437a | ||
|
|
f77976b742 | ||
|
|
9e95e7cd97 | ||
|
|
9d94257e79 | ||
|
|
13cfd61c83 | ||
|
|
fa818bc386 | ||
|
|
a73a642c64 | ||
|
|
94b3f6410d | ||
|
|
3d30f6e9cd | ||
|
|
40c16f0bac | ||
|
|
a1db63a8c2 | ||
|
|
59a9c2d947 | ||
|
|
fc897b9bac | ||
|
|
96f013c549 | ||
|
|
742905e05a | ||
|
|
bde44a94e8 | ||
|
|
1786bfadce | ||
|
|
b2e840636a | ||
|
|
ddaa22048f | ||
|
|
3e77890387 | ||
|
|
1e39c12963 | ||
|
|
243fdba80f | ||
|
|
08c4933c1b | ||
|
|
d5e0dea469 | ||
|
|
033aa0dd6e | ||
|
|
803870ef8f | ||
|
|
bf67a4a675 | ||
|
|
ee2036a2a7 | ||
|
|
4c3ed190f3 |
BIN
android/app/src/main/res/drawable-hdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 659 B |
BIN
android/app/src/main/res/drawable-mdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 379 B |
BIN
android/app/src/main/res/drawable-xhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 960 B |
BIN
android/app/src/main/res/drawable-xxhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
@@ -18,4 +18,4 @@
|
||||
# org.gradle.parallel=true
|
||||
|
||||
appVersion=19.2.0
|
||||
sdkVersion=2.1.0
|
||||
sdkVersion=2.2.2
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
|
||||
<uses-feature
|
||||
android:glEsVersion="0x00020000"
|
||||
@@ -44,6 +45,8 @@
|
||||
<action android:name="android.telecom.ConnectionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service android:name="org.jitsi.meet.sdk.JitsiMeetOngoingConferenceService" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -29,6 +29,7 @@ import com.facebook.react.bridge.ReadableMap;
|
||||
import com.rnimmersive.RNImmersiveModule;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -77,6 +78,15 @@ public abstract class BaseReactView<ListenerT>
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all registered React views.
|
||||
*
|
||||
* @return An {@link ArrayList} containing all views currently held by React.
|
||||
*/
|
||||
static ArrayList<BaseReactView> getViews() {
|
||||
return new ArrayList<>(views);
|
||||
}
|
||||
|
||||
/**
|
||||
* The unique identifier of this {@code BaseReactView} within the process
|
||||
* for the purposes of {@link ExternalAPIModule}. The name scope was
|
||||
|
||||
@@ -63,6 +63,16 @@ public class ConnectionService extends android.telecom.ConnectionService {
|
||||
static private final HashMap<String, Promise> startCallPromises
|
||||
= new HashMap<>();
|
||||
|
||||
/**
|
||||
* Aborts all ongoing connections. This is a last resort mechanism which forces all resources to
|
||||
* be freed on the system in case of fatal error.
|
||||
*/
|
||||
static void abortConnections() {
|
||||
for (ConnectionImpl connection: getConnections()) {
|
||||
connection.onAbort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@link ConnectionImpl} to the list.
|
||||
*
|
||||
|
||||
@@ -67,12 +67,16 @@ class ExternalAPIModule
|
||||
*/
|
||||
@ReactMethod
|
||||
public void sendEvent(String name, ReadableMap data, String scope) {
|
||||
// Keep track of the current ongoing conference.
|
||||
OngoingConferenceTracker.getInstance().onExternalAPIEvent(name, data);
|
||||
|
||||
// The JavaScript App needs to provide uniquely identifying information
|
||||
// to the native ExternalAPI module so that the latter may match the
|
||||
// former to the native BaseReactView which hosts it.
|
||||
BaseReactView view = BaseReactView.findViewByExternalAPIScope(scope);
|
||||
|
||||
if (view != null) {
|
||||
Log.d(TAG, "Sending event: " + name + " with data: " + data);
|
||||
try {
|
||||
view.onExternalAPIEvent(name, data);
|
||||
} catch(Exception e) {
|
||||
|
||||
@@ -36,6 +36,15 @@ public class JitsiMeet {
|
||||
defaultConferenceOptions = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current conference URL as a string.
|
||||
*
|
||||
* @return the current conference URL.
|
||||
*/
|
||||
public static String getCurrentConference() {
|
||||
return OngoingConferenceTracker.getInstance().getCurrentConference();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the default conference options as a {@link Bundle}.
|
||||
*
|
||||
|
||||
@@ -38,8 +38,8 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
|
||||
protected static final String TAG = JitsiMeetActivity.class.getSimpleName();
|
||||
|
||||
public static final String ACTION_JITSI_MEET_CONFERENCE = "org.jitsi.meet.CONFERENCE";
|
||||
public static final String JITSI_MEET_CONFERENCE_OPTIONS = "JitsiMeetConferenceOptions";
|
||||
private static final String ACTION_JITSI_MEET_CONFERENCE = "org.jitsi.meet.CONFERENCE";
|
||||
private static final String JITSI_MEET_CONFERENCE_OPTIONS = "JitsiMeetConferenceOptions";
|
||||
|
||||
// Helpers for starting the activity
|
||||
//
|
||||
@@ -71,6 +71,24 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
// Here we are trying to handle the following corner case: an application using the SDK
|
||||
// is using this Activity for displaying meetings, but there is another "main" Activity
|
||||
// with other content. If this Activity is "swiped out" from the recent list we will get
|
||||
// Activity#onDestroy() called without warning. At this point we can try to leave the
|
||||
// current meeting, but when our view is detached from React the JS <-> Native bridge won't
|
||||
// be operational so the external API won't be able to notify the native side that the
|
||||
// conference terminated. Thus, try our best to clean up.
|
||||
leave();
|
||||
if (AudioModeModule.useConnectionService()) {
|
||||
ConnectionService.abortConnections();
|
||||
}
|
||||
JitsiMeetOngoingConferenceService.abort(this);
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finish() {
|
||||
leave();
|
||||
@@ -155,6 +173,8 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
|
||||
JitsiMeetConferenceOptions options;
|
||||
|
||||
if ((options = getConferenceOptions(intent)) != null) {
|
||||
@@ -189,6 +209,8 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
@Override
|
||||
public void onConferenceJoined(Map<String, Object> data) {
|
||||
Log.d(TAG, "Conference joined: " + data);
|
||||
// Launch the service for the ongoing notification.
|
||||
JitsiMeetOngoingConferenceService.launch(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -341,7 +341,7 @@ public class JitsiMeetConferenceOptions implements Parcelable {
|
||||
dest.writeString(token);
|
||||
dest.writeBundle(colorScheme);
|
||||
dest.writeBundle(featureFlags);
|
||||
dest.writeBundle(userInfo.asBundle());
|
||||
dest.writeBundle(userInfo != null ? userInfo.asBundle() : new Bundle());
|
||||
dest.writeByte((byte) (audioMuted == null ? 0 : audioMuted ? 1 : 2));
|
||||
dest.writeByte((byte) (audioOnly == null ? 0 : audioOnly ? 1 : 2));
|
||||
dest.writeByte((byte) (videoMuted == null ? 0 : videoMuted ? 1 : 2));
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright @ 2019-present 8x8, Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.Service;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
|
||||
/**
|
||||
* This class implements an Android {@link Service}, a foreground one specifically, and it's
|
||||
* responsible for presenting an ongoing notification when a conference is in progress.
|
||||
* The service will help keep the app running while in the background.
|
||||
*
|
||||
* See: https://developer.android.com/guide/components/services
|
||||
*/
|
||||
public class JitsiMeetOngoingConferenceService extends Service
|
||||
implements OngoingConferenceTracker.OngoingConferenceListener {
|
||||
private static final String TAG = JitsiMeetOngoingConferenceService.class.getSimpleName();
|
||||
|
||||
static final class Actions {
|
||||
static final String START = TAG + ":START";
|
||||
static final String HANGUP = TAG + ":HANGUP";
|
||||
}
|
||||
|
||||
static void launch(Context context) {
|
||||
OngoingNotification.createOngoingConferenceNotificationChannel();
|
||||
|
||||
Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class);
|
||||
intent.setAction(Actions.START);
|
||||
|
||||
ComponentName componentName = context.startService(intent);
|
||||
if (componentName == null) {
|
||||
Log.w(TAG, "Ongoing conference service not started");
|
||||
}
|
||||
}
|
||||
|
||||
static void abort(Context context) {
|
||||
Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class);
|
||||
context.stopService(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
OngoingConferenceTracker.getInstance().addListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
OngoingConferenceTracker.getInstance().removeListener(this);
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
final String action = intent.getAction();
|
||||
if (action.equals(Actions.START)) {
|
||||
Notification notification = OngoingNotification.buildOngoingConferenceNotification();
|
||||
startForeground(OngoingNotification.NOTIFICATION_ID, notification);
|
||||
Log.i(TAG, "Service started");
|
||||
} else if (action.equals(Actions.HANGUP)) {
|
||||
Log.i(TAG, "Hangup requested");
|
||||
// Abort all ongoing calls
|
||||
if (AudioModeModule.useConnectionService()) {
|
||||
ConnectionService.abortConnections();
|
||||
}
|
||||
stopSelf();
|
||||
} else {
|
||||
Log.w(TAG, "Unknown action received: " + action);
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCurrentConferenceChanged(String conferenceUrl) {
|
||||
if (conferenceUrl == null) {
|
||||
stopSelf();
|
||||
Log.i(TAG, "Service stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright @ 2018-present 8x8, Inc.
|
||||
* Copyright @ 2017-2018 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.
|
||||
*/
|
||||
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
class JitsiMeetUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {
|
||||
private final Thread.UncaughtExceptionHandler defaultUncaughtExceptionHandler;
|
||||
|
||||
public static void register() {
|
||||
Thread.UncaughtExceptionHandler defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
|
||||
|
||||
JitsiMeetUncaughtExceptionHandler uncaughtExceptionHandler
|
||||
= new JitsiMeetUncaughtExceptionHandler(defaultUncaughtExceptionHandler);
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler(uncaughtExceptionHandler);
|
||||
}
|
||||
|
||||
private JitsiMeetUncaughtExceptionHandler(Thread.UncaughtExceptionHandler defaultUncaughtExceptionHandler) {
|
||||
this.defaultUncaughtExceptionHandler = defaultUncaughtExceptionHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uncaughtException(Thread t, Throwable e) {
|
||||
Log.e(this.getClass().getSimpleName(), "FATAL ERROR", e);
|
||||
|
||||
// Abort all ConnectionService ongoing calls
|
||||
if (AudioModeModule.useConnectionService()) {
|
||||
ConnectionService.abortConnections();
|
||||
}
|
||||
|
||||
if (defaultUncaughtExceptionHandler != null) {
|
||||
defaultUncaughtExceptionHandler.uncaughtException(t, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
public class JitsiMeetView extends BaseReactView<JitsiMeetViewListener> {
|
||||
public class JitsiMeetView extends BaseReactView<JitsiMeetViewListener>
|
||||
implements OngoingConferenceTracker.OngoingConferenceListener {
|
||||
|
||||
/**
|
||||
* The {@code Method}s of {@code JitsiMeetViewListener} by event name i.e.
|
||||
@@ -106,6 +107,14 @@ public class JitsiMeetView extends BaseReactView<JitsiMeetViewListener> {
|
||||
if (!(context instanceof JitsiMeetActivityInterface)) {
|
||||
throw new RuntimeException("Enclosing Activity must implement JitsiMeetActivityInterface");
|
||||
}
|
||||
|
||||
OngoingConferenceTracker.getInstance().addListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
OngoingConferenceTracker.getInstance().removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,27 +182,17 @@ public class JitsiMeetView extends BaseReactView<JitsiMeetViewListener> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal processing for the URL of the current conference set on the
|
||||
* associated {@link JitsiMeetView}.
|
||||
*
|
||||
* @param eventName the name of the external API event to be processed
|
||||
* @param eventData the details/specifics of the event to process determined
|
||||
* by/associated with the specified {@code eventName}.
|
||||
* Handler for {@link OngoingConferenceTracker} events.
|
||||
* @param conferenceUrl
|
||||
*/
|
||||
private void maybeSetViewURL(String eventName, ReadableMap eventData) {
|
||||
String url = eventData.getString("url");
|
||||
|
||||
switch(eventName) {
|
||||
case "CONFERENCE_WILL_JOIN":
|
||||
this.url = url;
|
||||
break;
|
||||
|
||||
case "CONFERENCE_TERMINATED":
|
||||
if (url != null && url.equals(this.url)) {
|
||||
this.url = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@Override
|
||||
public void onCurrentConferenceChanged(String conferenceUrl) {
|
||||
// This property was introduced in order to address
|
||||
// an exception in the Picture-in-Picture functionality which arose
|
||||
// because of delays related to bridging between JavaScript and Java. To
|
||||
// reduce these delays do not wait for the call to be transferred to the
|
||||
// UI thread.
|
||||
this.url = conferenceUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,13 +204,6 @@ public class JitsiMeetView extends BaseReactView<JitsiMeetViewListener> {
|
||||
*/
|
||||
@Override
|
||||
protected void onExternalAPIEvent(String name, ReadableMap data) {
|
||||
// XXX The JitsiMeetView property URL was introduced in order to address
|
||||
// an exception in the Picture-in-Picture functionality which arose
|
||||
// because of delays related to bridging between JavaScript and Java. To
|
||||
// reduce these delays do not wait for the call to be transferred to the
|
||||
// UI thread.
|
||||
maybeSetViewURL(name, data);
|
||||
|
||||
onExternalAPIEvent(LISTENER_METHODS, name, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright @ 2019-present 8x8, Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import com.facebook.react.bridge.ReadableMap;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
|
||||
|
||||
/**
|
||||
* Helper class to keep track of what the current conference is.
|
||||
*/
|
||||
class OngoingConferenceTracker {
|
||||
private static final OngoingConferenceTracker instance = new OngoingConferenceTracker();
|
||||
|
||||
private static final String CONFERENCE_WILL_JOIN = "CONFERENCE_WILL_JOIN";
|
||||
private static final String CONFERENCE_TERMINATED = "CONFERENCE_TERMINATED";
|
||||
|
||||
private final Collection<OngoingConferenceListener> listeners =
|
||||
Collections.synchronizedSet(new HashSet<OngoingConferenceListener>());
|
||||
private String currentConference;
|
||||
|
||||
public OngoingConferenceTracker() {
|
||||
}
|
||||
|
||||
public static OngoingConferenceTracker getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current active conference URL.
|
||||
*
|
||||
* @return - The current conference URL as a String.
|
||||
*/
|
||||
synchronized String getCurrentConference() {
|
||||
return currentConference;
|
||||
}
|
||||
|
||||
synchronized void onExternalAPIEvent(String name, ReadableMap data) {
|
||||
if (!data.hasKey("url")) {
|
||||
return;
|
||||
}
|
||||
|
||||
String url = data.getString("url");
|
||||
if (url == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch(name) {
|
||||
case CONFERENCE_WILL_JOIN:
|
||||
currentConference = url;
|
||||
updateListeners();
|
||||
break;
|
||||
|
||||
case CONFERENCE_TERMINATED:
|
||||
if (url.equals(currentConference)) {
|
||||
currentConference = null;
|
||||
updateListeners();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void addListener(OngoingConferenceListener listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
void removeListener(OngoingConferenceListener listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
private void updateListeners() {
|
||||
synchronized (listeners) {
|
||||
for (OngoingConferenceListener listener : listeners) {
|
||||
listener.onCurrentConferenceChanged(currentConference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface OngoingConferenceListener {
|
||||
void onCurrentConferenceChanged(String conferenceUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright @ 2019-present 8x8, Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.support.v4.app.NotificationCompat;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
|
||||
/**
|
||||
* Helper class for creating the ongoing notification which is used with
|
||||
* {@link JitsiMeetOngoingConferenceService}. It allows the user to easily get back to the app
|
||||
* and to hangup from within the notification itself.
|
||||
*/
|
||||
class OngoingNotification {
|
||||
private static final String TAG = OngoingNotification.class.getSimpleName();
|
||||
|
||||
private static final String CHANNEL_ID = "JitsiNotificationChannel";
|
||||
private static final String CHANNEL_NAME = "Ongoing Conference Notifications";
|
||||
|
||||
static final int NOTIFICATION_ID = new Random().nextInt(99999) + 10000;
|
||||
|
||||
|
||||
static void createOngoingConferenceNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
|
||||
Context context = ReactInstanceManagerHolder.getCurrentActivity();
|
||||
if (context == null) {
|
||||
Log.w(TAG, "Cannot create notification channel: no current context");
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationManager notificationManager
|
||||
= (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
NotificationChannel channel
|
||||
= notificationManager.getNotificationChannel(CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
// The channel was already created, no need to do it again.
|
||||
return;
|
||||
}
|
||||
|
||||
channel = new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT);
|
||||
channel.enableLights(false);
|
||||
channel.enableVibration(false);
|
||||
channel.setShowBadge(false);
|
||||
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
static Notification buildOngoingConferenceNotification() {
|
||||
Context context = ReactInstanceManagerHolder.getCurrentActivity();
|
||||
if (context == null) {
|
||||
Log.w(TAG, "Cannot create notification: no current context");
|
||||
return null;
|
||||
}
|
||||
|
||||
Intent notificationIntent = new Intent(context, context.getClass());
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notificationIntent, 0);
|
||||
|
||||
NotificationCompat.Builder builder;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
builder = new NotificationCompat.Builder(context, CHANNEL_ID);
|
||||
} else {
|
||||
builder = new NotificationCompat.Builder(context);
|
||||
}
|
||||
|
||||
builder
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setContentTitle(context.getString(R.string.ongoing_notification_title))
|
||||
.setContentText(context.getString(R.string.ongoing_notification_text))
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setUsesChronometer(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSmallIcon(context.getResources().getIdentifier("ic_notification", "drawable", context.getPackageName()));
|
||||
|
||||
// Add a "hang-up" action only if we are using ConnectionService.
|
||||
if (AudioModeModule.useConnectionService()) {
|
||||
Intent hangupIntent = new Intent(context, JitsiMeetOngoingConferenceService.class);
|
||||
hangupIntent.setAction(JitsiMeetOngoingConferenceService.Actions.HANGUP);
|
||||
PendingIntent hangupPendingIntent
|
||||
= PendingIntent.getService(context, 0, hangupIntent, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
NotificationCompat.Action hangupAction = new NotificationCompat.Action(0, "Hang up", hangupPendingIntent);
|
||||
|
||||
builder.addAction(hangupAction);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Application;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
@@ -120,6 +121,18 @@ class ReactInstanceManagerHolder {
|
||||
? reactContext.getNativeModule(nativeModuleClass) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current {@link Activity} linked to React Native.
|
||||
*
|
||||
* @return An activity attached to React Native.
|
||||
*/
|
||||
static Activity getCurrentActivity() {
|
||||
ReactContext reactContext
|
||||
= reactInstanceManager != null
|
||||
? reactInstanceManager.getCurrentReactContext() : null;
|
||||
return reactContext != null ? reactContext.getCurrentActivity() : null;
|
||||
}
|
||||
|
||||
static ReactInstanceManager getReactInstanceManager() {
|
||||
return reactInstanceManager;
|
||||
}
|
||||
@@ -182,5 +195,8 @@ class ReactInstanceManagerHolder {
|
||||
if (devSettings != null) {
|
||||
devSettings.setBundleDeltasEnabled(false);
|
||||
}
|
||||
|
||||
// Register our uncaught exception handler.
|
||||
JitsiMeetUncaughtExceptionHandler.register();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">Jitsi Meet SDK</string>
|
||||
<string name="dropbox_app_key"></string>
|
||||
<string name="ongoing_notification_title">Ongoing meeting</string>
|
||||
<string name="ongoing_notification_text">You are currently in a meeting. Tap to return to it.</string>
|
||||
</resources>
|
||||
|
||||
@@ -79,7 +79,6 @@ import {
|
||||
import { showNotification } from './react/features/notifications';
|
||||
import {
|
||||
dominantSpeakerChanged,
|
||||
getAvatarURLByParticipantId,
|
||||
getLocalParticipant,
|
||||
getNormalizedDisplayName,
|
||||
getParticipantById,
|
||||
@@ -1999,6 +1998,8 @@ export default {
|
||||
this.localAudio.dispose();
|
||||
this.localAudio = null;
|
||||
}
|
||||
|
||||
APP.API.notifySuspendDetected();
|
||||
});
|
||||
|
||||
APP.UI.addListener(UIEvents.AUDIO_MUTED, muted => {
|
||||
@@ -2278,18 +2279,6 @@ export default {
|
||||
= APP.store.getState()['features/base/settings'].displayName;
|
||||
|
||||
APP.UI.changeDisplayName('localVideoContainer', displayName);
|
||||
APP.API.notifyConferenceJoined(
|
||||
this.roomName,
|
||||
this._room.myUserId(),
|
||||
{
|
||||
displayName,
|
||||
formattedDisplayName: appendSuffix(
|
||||
displayName,
|
||||
interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME),
|
||||
avatarURL: getAvatarURLByParticipantId(
|
||||
APP.store.getState(), this._room.myUserId())
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2753,14 +2742,6 @@ export default {
|
||||
displayName: formattedNickname
|
||||
}));
|
||||
|
||||
APP.API.notifyDisplayNameChanged(id, {
|
||||
displayName: formattedNickname,
|
||||
formattedDisplayName:
|
||||
appendSuffix(
|
||||
formattedNickname,
|
||||
interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME)
|
||||
});
|
||||
|
||||
if (room) {
|
||||
APP.UI.changeDisplayName(id, formattedNickname);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-enlarge:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-signal_cellular_0:before {
|
||||
content: "\e901";
|
||||
}
|
||||
|
||||
@@ -437,6 +437,8 @@ The listener will receive an object with the following structure:
|
||||
}
|
||||
```
|
||||
|
||||
* **suspendDetected** - event notifications about detecting suspend event in host computer.
|
||||
|
||||
You can also add multiple event listeners by using `addEventListeners`.
|
||||
This method requires one argument of type Object. The object argument must
|
||||
have the names of the events as keys and the listeners of the events as values.
|
||||
|
||||
BIN
fonts/jitsi.eot
@@ -37,6 +37,7 @@
|
||||
<glyph unicode="" glyph-name="signal_cellular_2" d="M86 86l852 852v-852h-852z" />
|
||||
<glyph unicode="" glyph-name="share-doc" d="M554 640h236l-236 234v-234zM682 426v86h-340v-86h340zM682 256v86h-340v-86h340zM598 938l256-256v-512c0-46-40-84-86-84h-512c-46 0-86 38-86 84l2 684c0 46 38 84 84 84h342z" />
|
||||
<glyph unicode="" glyph-name="ninja" d="M330.667 469.333c-0.427 14.933 6.4 29.44 17.92 39.253 32-6.827 61.867-20.053 88.747-39.253 0-29.013-23.893-52.907-53.333-52.907s-52.907 23.467-53.333 52.907zM586.667 469.333c26.88 18.773 56.747 32 88.747 38.827 11.52-9.813 18.347-24.32 17.92-38.827 0-29.867-23.893-53.76-53.333-53.76s-53.333 23.893-53.333 53.76v0zM512 640c-118.187 1.707-234.667-27.733-338.347-85.333l-2.987-42.667c0-52.48 12.373-104.107 35.84-151.040 101.12 15.36 203.093 23.040 305.493 23.040s204.373-7.68 305.493-23.040c23.467 46.933 35.84 98.56 35.84 151.040l-2.987 42.667c-103.68 57.6-220.16 87.040-338.347 85.333zM512 938.667c235.641 0 426.667-191.025 426.667-426.667s-191.025-426.667-426.667-426.667c-235.641 0-426.667 191.025-426.667 426.667s191.025 426.667 426.667 426.667z" />
|
||||
<glyph unicode="" glyph-name="enlarge" d="M896 212v600h-768v-600h768zM896 896q34 0 60-26t26-60v-596q0-34-26-60t-60-26h-768q-34 0-60 26t-26 60v596q0 34 26 60t60 26h768zM598 342l-86-108-86 108h172zM256 598v-172l-106 86zM768 598l106-86-106-86v172zM512 790l86-108h-172z" />
|
||||
<glyph unicode="" glyph-name="full-screen" d="M598 810h212v-212h-84v128h-128v84zM726 298v128h84v-212h-212v84h128zM214 598v212h212v-84h-128v-128h-84zM298 426v-128h128v-84h-212v212h84z" />
|
||||
<glyph unicode="" glyph-name="exit-full-screen" d="M682 682h128v-84h-212v212h84v-128zM598 214v212h212v-84h-128v-128h-84zM342 682v128h84v-212h-212v84h128zM214 342v84h212v-212h-84v128h-128z" />
|
||||
<glyph unicode="" glyph-name="security" d="M768 170v428h-512v-428h512zM768 682c46 0 86-38 86-84v-428c0-46-40-84-86-84h-512c-46 0-86 38-86 84v428c0 46 40 84 86 84h388v86c0 72-60 132-132 132s-132-60-132-132h-82c0 118 96 214 214 214s214-96 214-214v-86h42zM512 298c-46 0-86 40-86 86s40 86 86 86 86-40 86-86-40-86-86-86z" />
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
BIN
fonts/jitsi.ttf
BIN
fonts/jitsi.woff
@@ -93,12 +93,15 @@
|
||||
+ "font-size:small;"
|
||||
+ "cursor: pointer'>" + showMoreText + "</a>"
|
||||
+ " "
|
||||
+ "<a href='" + href + "' style='"
|
||||
+ "<a id ='reloadLink' style='"
|
||||
+ "text-decoration: underline;"
|
||||
+ "font-size:small;"
|
||||
+ "'>reload now</a>"
|
||||
+ "</div>";
|
||||
|
||||
var reloadLink = document.getElementById('reloadLink');
|
||||
reloadLink.setAttribute('href', href);
|
||||
|
||||
var showMoreElem = document.getElementById("showMore");
|
||||
showMoreElem.addEventListener('click', function () {
|
||||
var moreInfoElem
|
||||
|
||||
@@ -167,7 +167,27 @@ var interfaceConfig = {
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
RECENT_LIST_ENABLED: true
|
||||
RECENT_LIST_ENABLED: true,
|
||||
|
||||
// Names of browsers which should show a warning stating the current browser
|
||||
// has a suboptimal experience. Browsers which are not listed as optimal or
|
||||
// unsupported are considered suboptimal. Valid values are:
|
||||
// chrome, chromium, edge, electron, firefox, nwjs, opera, safari
|
||||
OPTIMAL_BROWSERS: [ 'chrome', 'chromium', 'firefox', 'nwjs', 'electron' ],
|
||||
|
||||
// Browsers, in addition to those which do not fully support WebRTC, that
|
||||
// are not supported and should show the unsupported browser page.
|
||||
UNSUPPORTED_BROWSERS: [],
|
||||
|
||||
/**
|
||||
* A UX mode where the last screen share participant is automatically
|
||||
* pinned. Valid values are the string "remote-only" so remote participants
|
||||
* get pinned but not local, otherwise any truthy value for all participants,
|
||||
* and any falsy value to disable the feature.
|
||||
*
|
||||
* Note: this mode is experimental and subject to breakage.
|
||||
*/
|
||||
AUTO_PIN_LATEST_SCREEN_SHARE: 'remote-only'
|
||||
|
||||
/**
|
||||
* How many columns the tile view can expand to. The respected range is
|
||||
@@ -195,12 +215,6 @@ var interfaceConfig = {
|
||||
*/
|
||||
// ANDROID_APP_PACKAGE: 'org.jitsi.meet',
|
||||
|
||||
/**
|
||||
* A UX mode where the last screen share participant is automatically
|
||||
* pinned. Note: this mode is experimental and subject to breakage.
|
||||
*/
|
||||
// AUTO_PIN_LATEST_SCREEN_SHARE: false,
|
||||
|
||||
/**
|
||||
* Override the behavior of some notifications to remain displayed until
|
||||
* explicitly dismissed through a user action. The value is how long, in
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.1.0</string>
|
||||
<string>2.2.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
|
||||
@@ -472,6 +472,9 @@
|
||||
"focus": "Conference focus",
|
||||
"focusFail": "__component__ not available - retry in __ms__ sec",
|
||||
"grantedTo": "Moderator rights granted to __to__!",
|
||||
"invitedOneMember": "__name__ has been invited",
|
||||
"invitedThreePlusMembers": "__name__ and __count__ others have been invited",
|
||||
"invitedTwoMembers": "__first__ and __second__ have been invited",
|
||||
"kickParticipant": "__kicked__ was kicked by __kicker__",
|
||||
"me": "Me",
|
||||
"moderator": "Moderator rights granted!",
|
||||
@@ -614,7 +617,7 @@
|
||||
"accessibilityLabel": {
|
||||
"audioOnly": "Toggle audio only",
|
||||
"audioRoute": "Select the sound device",
|
||||
"callQuality": "Manage call quality",
|
||||
"callQuality": "Manage video quality",
|
||||
"cc": "Toggle subtitles",
|
||||
"chat": "Toggle chat window",
|
||||
"document": "Toggle shared document",
|
||||
@@ -638,6 +641,7 @@
|
||||
"shareRoom": "Invite someone",
|
||||
"shareYourScreen": "Toggle screenshare",
|
||||
"shortcuts": "Toggle shortcuts",
|
||||
"show": "Show on stage",
|
||||
"speakerStats": "Toggle speaker statistics",
|
||||
"tileView": "Toggle tile view",
|
||||
"toggleCamera": "Toggle camera",
|
||||
@@ -649,7 +653,7 @@
|
||||
"audioOnlyOn": "Enable audio only mode",
|
||||
"audioRoute": "Select the sound device",
|
||||
"authenticate": "Authenticate",
|
||||
"callQuality": "Manage call quality",
|
||||
"callQuality": "Manage video quality",
|
||||
"cameraDisabled": "Camera is not available",
|
||||
"chat": "Open / Close chat",
|
||||
"closeChat": "Close chat",
|
||||
@@ -735,7 +739,7 @@
|
||||
"videoStatus": {
|
||||
"audioOnly": "AUD",
|
||||
"audioOnlyExpanded": "You are in audio only mode. This mode saves bandwidth but you won't see videos of others.",
|
||||
"callQuality": "Call Quality",
|
||||
"callQuality": "Video Quality",
|
||||
"hd": "HD",
|
||||
"hdTooltip": "Viewing high definition video",
|
||||
"highDefinition": "High definition",
|
||||
@@ -748,7 +752,7 @@
|
||||
"onlyAudioAvailable": "Only audio is available",
|
||||
"onlyAudioSupported": "We only support audio in this browser.",
|
||||
"p2pEnabled": "Peer to Peer Enabled",
|
||||
"p2pVideoQualityDescription": "In peer to peer mode, received call quality can only be toggled between high and audio only. Other settings will not be honored until peer to peer is exited.",
|
||||
"p2pVideoQualityDescription": "In peer to peer mode, received video quality can only be toggled between high and audio only. Other settings will not be honored until peer to peer is exited.",
|
||||
"qualityButtonTip": "Change received video quality",
|
||||
"recHighDefinitionOnly": "Will prefer high definition.",
|
||||
"sd": "SD",
|
||||
@@ -763,6 +767,7 @@
|
||||
"mute": "Member is muted",
|
||||
"muted": "Muted",
|
||||
"remoteControl": "Remote control",
|
||||
"show": "Show on stage",
|
||||
"videomute": "Member has stopped the camera"
|
||||
},
|
||||
"welcomepage": {
|
||||
@@ -786,6 +791,7 @@
|
||||
"recentList": "Recent",
|
||||
"recentListDelete": "Delete",
|
||||
"recentListEmpty": "Your recent list is currently empty. Chat with your team and you will find all your recent meetings here.",
|
||||
"reducedUIText": "Welcome to __app__!",
|
||||
"roomname": "Enter room name",
|
||||
"roomnameHint": "Enter the name or URL of the room you want to join. You may make a name up, just let the people you are meeting know it so that they enter the same name.",
|
||||
"sendFeedback": "Send feedback",
|
||||
|
||||
@@ -509,6 +509,15 @@ class API {
|
||||
this._sendEvent({ name: 'video-ready-to-close' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify external application (if API is enabled) that a suspend event in host computer.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
notifySuspendDetected() {
|
||||
this._sendEvent({ name: 'suspend-detected' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify external application (if API is enabled) for audio muted status
|
||||
* changed.
|
||||
|
||||
2
modules/API/external/external_api.js
vendored
@@ -73,6 +73,7 @@ const events = {
|
||||
'video-mute-status-changed': 'videoMuteStatusChanged',
|
||||
'screen-sharing-status-changed': 'screenSharingStatusChanged',
|
||||
'subject-change': 'subjectChange',
|
||||
'suspend-detected': 'suspendDetected',
|
||||
'tile-view-changed': 'tileViewChanged'
|
||||
};
|
||||
|
||||
@@ -537,6 +538,7 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
|
||||
* {{
|
||||
* on: on //whether screen sharing is on
|
||||
* }}
|
||||
* {@code suspendDetected} - receives event notifications about detecting suspend event in host computer.
|
||||
* {@code readyToClose} - all hangup operations are completed and Jitsi Meet
|
||||
* is ready to be disposed.
|
||||
* @returns {void}
|
||||
|
||||
@@ -637,7 +637,7 @@ const VideoLayout = {
|
||||
*/
|
||||
onVideoMute(id, value) {
|
||||
if (APP.conference.isLocalId(id)) {
|
||||
localVideoThumbnail.setVideoMutedView(value);
|
||||
localVideoThumbnail && localVideoThumbnail.setVideoMutedView(value);
|
||||
} else {
|
||||
const remoteVideo = remoteVideos[id];
|
||||
|
||||
@@ -985,7 +985,7 @@ const VideoLayout = {
|
||||
} else if (currentId) {
|
||||
const currentSmallVideo = this.getSmallVideo(currentId);
|
||||
|
||||
currentSmallVideo.updateView();
|
||||
currentSmallVideo && currentSmallVideo.updateView();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
4
package-lock.json
generated
@@ -8946,8 +8946,8 @@
|
||||
}
|
||||
},
|
||||
"lib-jitsi-meet": {
|
||||
"version": "github:jitsi/lib-jitsi-meet#ee1854c1b12a229f70f351acaebe6686f38f6a85",
|
||||
"from": "github:jitsi/lib-jitsi-meet#ee1854c1b12a229f70f351acaebe6686f38f6a85",
|
||||
"version": "github:jitsi/lib-jitsi-meet#c0af82a215d0893f1999df299cfdfcbc9ce9e72a",
|
||||
"from": "github:jitsi/lib-jitsi-meet#c0af82a215d0893f1999df299cfdfcbc9ce9e72a",
|
||||
"requires": {
|
||||
"@jitsi/sdp-interop": "0.1.14",
|
||||
"@jitsi/sdp-simulcast": "0.2.1",
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"js-utils": "github:jitsi/js-utils#73a67a7a60d52f8e895f50939c8fcbd1f20fe7b5",
|
||||
"jsrsasign": "8.0.12",
|
||||
"jwt-decode": "2.2.0",
|
||||
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#ee1854c1b12a229f70f351acaebe6686f38f6a85",
|
||||
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#c0af82a215d0893f1999df299cfdfcbc9ce9e72a",
|
||||
"libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d",
|
||||
"lodash": "4.17.11",
|
||||
"moment": "2.19.4",
|
||||
|
||||
@@ -213,7 +213,8 @@ function _conferenceJoined(state, { conference }) {
|
||||
// i.e. password-protected is private to lib-jitsi-meet. However, the
|
||||
// library does not fire LOCK_STATE_CHANGED upon joining a JitsiConference
|
||||
// with a password.
|
||||
const locked = conference.room.locked ? LOCKED_REMOTELY : undefined;
|
||||
// FIXME Technically JitsiConference.room is a private field.
|
||||
const locked = conference.room && conference.room.locked ? LOCKED_REMOTELY : undefined;
|
||||
|
||||
return assign(state, {
|
||||
authRequired: undefined,
|
||||
|
||||
@@ -3,15 +3,74 @@
|
||||
import JitsiMeetJS from '../lib-jitsi-meet';
|
||||
import { Platform } from '../react';
|
||||
|
||||
import { isBlacklistedEnvironment } from './isBlacklistedEnvironment';
|
||||
const { browser } = JitsiMeetJS.util;
|
||||
|
||||
const DEFAULT_OPTIMAL_BROWSERS = [
|
||||
'chrome',
|
||||
'electron',
|
||||
'firefox',
|
||||
'nwjs'
|
||||
];
|
||||
|
||||
const DEFAULT_UNSUPPORTED_BROWSERS = [];
|
||||
|
||||
const browserNameToCheck = {
|
||||
chrome: browser.isChrome.bind(browser),
|
||||
chromium: browser.isChromiumBased.bind(browser),
|
||||
edge: browser.isEdge.bind(browser),
|
||||
electron: browser.isElectron.bind(browser),
|
||||
firefox: browser.isFirefox.bind(browser),
|
||||
nwjs: browser.isNWJS.bind(browser),
|
||||
opera: browser.isOpera.bind(browser),
|
||||
safari: browser.isSafari.bind(browser)
|
||||
};
|
||||
|
||||
declare var interfaceConfig: Object;
|
||||
|
||||
/**
|
||||
* Returns whether or not jitsi is optimized and targeted for the provided
|
||||
* browser name.
|
||||
*
|
||||
* @param {string} browserName - The name of the browser to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isBrowsersOptimal(browserName: string) {
|
||||
return (interfaceConfig.OPTIMAL_BROWSERS || DEFAULT_OPTIMAL_BROWSERS)
|
||||
.includes(browserName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the current browser or the list of passed in browsers
|
||||
* is considered suboptimal. Suboptimal means it is a supported browser but has
|
||||
* not been explicitly listed as being optimal, possibly due to functionality
|
||||
* issues.
|
||||
*
|
||||
* @param {Array<string>} [browsers] - A list of browser names to check. Will
|
||||
* default to a whitelist.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSuboptimalBrowser() {
|
||||
const optimalBrowsers
|
||||
= interfaceConfig.OPTIMAL_BROWSERS || DEFAULT_OPTIMAL_BROWSERS;
|
||||
|
||||
return !_isCurrentBrowserInList(optimalBrowsers) && isSupportedBrowser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the current browser should allow the app to display.
|
||||
* A supported browser is assumed to be able to support WebRtc.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSupportedBrowser() {
|
||||
if (navigator.product === 'ReactNative' || isBlacklistedEnvironment()) {
|
||||
if (navigator.product === 'ReactNative') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blacklists apply to desktop browsers only right now.
|
||||
if (!_isMobileBrowser() && _isCurrentBrowserInList(
|
||||
interfaceConfig.UNSUPPORTED_BROWSERS || DEFAULT_UNSUPPORTED_BROWSERS
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -19,7 +78,32 @@ export function isSupportedBrowser() {
|
||||
// - the WelcomePage is mobile ready;
|
||||
// - if the URL points to a conference then deep-linking will take
|
||||
// care of it.
|
||||
return Platform.OS === 'android'
|
||||
|| Platform.OS === 'ios'
|
||||
|| JitsiMeetJS.isWebRtcSupported();
|
||||
return _isMobileBrowser() || JitsiMeetJS.isWebRtcSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs various browser checks to know if the current browser is found within
|
||||
* the list.
|
||||
*
|
||||
* @param {Array<string>} list - Browser names to check. The names should be
|
||||
* keys in {@link browserNameToCheck}.
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isCurrentBrowserInList(list) {
|
||||
return Boolean(list.find(browserName => {
|
||||
const checkFunction = browserNameToCheck[browserName];
|
||||
|
||||
return checkFunction ? checkFunction.call(browser) : false;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the current environment is a mobile device.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isMobileBrowser() {
|
||||
return Platform.OS === 'android' || Platform.OS === 'ios';
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Returns whether or not the current browser is supported for showing meeting
|
||||
* based on any custom overrides. This file should be overridden with branding
|
||||
* as needed to fit deployment needs.
|
||||
*
|
||||
* @returns {boolean} True the browser is unsupported due to being blacklisted
|
||||
* by the logic within this function.
|
||||
*/
|
||||
export function isBlacklistedEnvironment() {
|
||||
return false;
|
||||
}
|
||||
@@ -118,13 +118,15 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
case PARTICIPANT_JOINED: {
|
||||
_maybePlaySounds(store, action);
|
||||
|
||||
const result = _participantJoinedOrUpdated(store, next, action);
|
||||
|
||||
const { participant: p } = action;
|
||||
|
||||
if (!p.local) {
|
||||
store.dispatch(showParticipantJoinedNotification(getParticipantDisplayName(store.getState, p.id)));
|
||||
}
|
||||
|
||||
return _participantJoinedOrUpdated(store, next, action);
|
||||
return result;
|
||||
}
|
||||
|
||||
case PARTICIPANT_LEFT:
|
||||
|
||||
@@ -96,10 +96,7 @@ function _addOrRemoveAudioElement(state, action) {
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const actionName
|
||||
= isAddAction ? '_ADD_AUDIO_ELEMENT' : '_REMOVE_AUDIO_ELEMENT';
|
||||
|
||||
logger.error(`${actionName}: no sound for id: ${soundId}`);
|
||||
logger.warn(`${action.type}: no sound for id: ${soundId}`);
|
||||
}
|
||||
|
||||
return nextState;
|
||||
|
||||
@@ -111,13 +111,6 @@ type Props = AbstractProps & {
|
||||
*/
|
||||
_toolboxVisible: boolean,
|
||||
|
||||
/**
|
||||
* The indicator which determines whether the Toolbox is always visible.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_toolboxAlwaysVisible: boolean,
|
||||
|
||||
/**
|
||||
* The redux {@code dispatch} function.
|
||||
*/
|
||||
@@ -298,10 +291,6 @@ class Conference extends AbstractConference<Props, *> {
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
if (this.props._toolboxAlwaysVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setToolboxVisible(!this.props._toolboxVisible);
|
||||
}
|
||||
|
||||
@@ -407,7 +396,7 @@ function _mapStateToProps(state) {
|
||||
leaving
|
||||
} = state['features/base/conference'];
|
||||
const { reducedUI } = state['features/base/responsive-ui'];
|
||||
const { alwaysVisible, visible } = state['features/toolbox'];
|
||||
const { visible } = state['features/toolbox'];
|
||||
|
||||
// XXX There is a window of time between the successful establishment of the
|
||||
// XMPP connection and the subsequent commencement of joining the MUC during
|
||||
@@ -484,15 +473,7 @@ function _mapStateToProps(state) {
|
||||
* @private
|
||||
* @type {boolean}
|
||||
*/
|
||||
_toolboxVisible: visible,
|
||||
|
||||
/**
|
||||
* The indicator which determines whether the Toolbox is always visible.
|
||||
*
|
||||
* @private
|
||||
* @type {boolean}
|
||||
*/
|
||||
_toolboxAlwaysVisible: alwaysVisible
|
||||
_toolboxVisible: visible
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { translateToHTML } from '../base/i18n';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { isSuboptimalBrowser } from '../base/environment';
|
||||
import { toState } from '../base/redux';
|
||||
|
||||
import { getName } from '../app';
|
||||
@@ -17,16 +17,7 @@ import { getOverlayToRender } from '../overlay';
|
||||
* @returns {void}
|
||||
*/
|
||||
export function maybeShowSuboptimalExperienceNotification(dispatch, t) {
|
||||
if (!browser.isChrome()
|
||||
&& !browser.isFirefox()
|
||||
&& !browser.isNWJS()
|
||||
&& !browser.isElectron()
|
||||
|
||||
// Adding react native to the list of recommended browsers is not
|
||||
// necessary for now because the function won't be executed at all
|
||||
// in this case but I'm adding it for completeness.
|
||||
&& !browser.isReactNative()
|
||||
) {
|
||||
if (isSuboptimalBrowser()) {
|
||||
dispatch(
|
||||
showWarningNotification(
|
||||
{
|
||||
|
||||
@@ -5,12 +5,11 @@ import {
|
||||
CONFERENCE_JOINED,
|
||||
KICKED_OUT,
|
||||
VIDEO_QUALITY_LEVELS,
|
||||
conferenceFailed,
|
||||
conferenceLeft,
|
||||
getCurrentConference,
|
||||
setPreferredReceiverVideoQuality
|
||||
} from '../base/conference';
|
||||
import { hideDialog, isDialogOpen } from '../base/dialog';
|
||||
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
|
||||
import { pinParticipant } from '../base/participants';
|
||||
import { SET_REDUCED_UI } from '../base/responsive-ui';
|
||||
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
|
||||
@@ -46,8 +45,7 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
dispatch(notifyKickedOut(
|
||||
action.participant,
|
||||
() => {
|
||||
dispatch(
|
||||
conferenceFailed(action.conference, JitsiConferenceEvents.KICKED));
|
||||
dispatch(conferenceLeft(action.conference));
|
||||
dispatch(appNavigate(undefined));
|
||||
}
|
||||
));
|
||||
|
||||
@@ -56,7 +56,7 @@ class DisplayNamePrompt extends AbstractDisplayNamePrompt<State> {
|
||||
render() {
|
||||
return (
|
||||
<Dialog
|
||||
isModal = { true }
|
||||
isModal = { false }
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = 'dialog.displayNameRequired'
|
||||
width = 'small'>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @flow
|
||||
|
||||
/**
|
||||
* Appends a suffix to the display name.
|
||||
*
|
||||
@@ -5,7 +7,7 @@
|
||||
* @param {string} suffix - Suffix that will be appended.
|
||||
* @returns {string} The formatted display name.
|
||||
*/
|
||||
export function appendSuffix(displayName, suffix) {
|
||||
export function appendSuffix(displayName: string, suffix: string) {
|
||||
return `${displayName || suffix || ''}${
|
||||
displayName && suffix && displayName !== suffix ? ` (${suffix})` : ''}`;
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
import './middleware';
|
||||
import './subscriber';
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
// @flow
|
||||
|
||||
import { CONFERENCE_FAILED } from '../base/conference';
|
||||
import { CONFERENCE_FAILED, CONFERENCE_JOINED } from '../base/conference';
|
||||
import { NOTIFY_CAMERA_ERROR, NOTIFY_MIC_ERROR } from '../base/devices';
|
||||
import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
|
||||
import {
|
||||
getAvatarURLByParticipantId,
|
||||
getLocalParticipant
|
||||
} from '../base/participants';
|
||||
import { MiddlewareRegistry } from '../base/redux';
|
||||
import { appendSuffix } from '../display-name';
|
||||
import { SUBMIT_FEEDBACK } from '../feedback';
|
||||
import { SET_FILMSTRIP_VISIBLE } from '../filmstrip';
|
||||
|
||||
declare var APP: Object;
|
||||
declare var interfaceConfig: Object;
|
||||
|
||||
/**
|
||||
* The middleware of the feature {@code external-api}.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register((/* store */) => next => action => {
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case CONFERENCE_FAILED: {
|
||||
if (action.conference
|
||||
@@ -22,6 +32,27 @@ MiddlewareRegistry.register((/* store */) => next => action => {
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_JOINED: {
|
||||
const state = store.getState();
|
||||
const { room } = state['features/base/conference'];
|
||||
const { name, id } = getLocalParticipant(state);
|
||||
|
||||
APP.API.notifyConferenceJoined(
|
||||
room,
|
||||
id,
|
||||
{
|
||||
displayName: name,
|
||||
formattedDisplayName: appendSuffix(
|
||||
name,
|
||||
interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME
|
||||
),
|
||||
avatarURL: getAvatarURLByParticipantId(state, id)
|
||||
}
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case NOTIFY_CAMERA_ERROR:
|
||||
if (action.error) {
|
||||
APP.API.notifyOnCameraError(
|
||||
@@ -34,7 +65,15 @@ MiddlewareRegistry.register((/* store */) => next => action => {
|
||||
APP.API.notifyOnMicError(action.error.name, action.error.message);
|
||||
}
|
||||
break;
|
||||
|
||||
case SET_FILMSTRIP_VISIBLE:
|
||||
APP.API.notifyFilmstripDisplayChanged(action.visible);
|
||||
break;
|
||||
|
||||
case SUBMIT_FEEDBACK:
|
||||
APP.API.notifyFeedbackSubmitted();
|
||||
break;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
return result;
|
||||
});
|
||||
|
||||
39
react/features/external-api/subscriber.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
|
||||
import { getLocalParticipant } from '../base/participants';
|
||||
import { StateListenerRegistry } from '../base/redux';
|
||||
import { appendSuffix } from '../display-name';
|
||||
import { shouldDisplayTileView } from '../video-layout';
|
||||
|
||||
declare var APP: Object;
|
||||
declare var interfaceConfig: Object;
|
||||
|
||||
/**
|
||||
* StateListenerRegistry provides a reliable way of detecting changes to
|
||||
* preferred layout state and dispatching additional actions.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => shouldDisplayTileView(state),
|
||||
/* listener */ displayTileView => {
|
||||
APP.API.notifyTileViewChanged(displayTileView);
|
||||
});
|
||||
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/settings'].displayName,
|
||||
/* listener */ (displayName, store) => {
|
||||
const localParticipant = getLocalParticipant(store.getState());
|
||||
|
||||
// Initial setting of the display name occurs happens on app
|
||||
// initialization, before the local participant is ready. The initial
|
||||
// settings is not desired to be fired anyways, only changes.
|
||||
if (localParticipant) {
|
||||
const { id } = localParticipant;
|
||||
|
||||
APP.API.notifyDisplayNameChanged(id, {
|
||||
displayName,
|
||||
formattedDisplayName: appendSuffix(
|
||||
displayName,
|
||||
interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME)
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -2,5 +2,4 @@ export * from './actions';
|
||||
export * from './actionTypes';
|
||||
export * from './components';
|
||||
|
||||
import './middleware';
|
||||
import './reducer';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/* @flow */
|
||||
|
||||
import { MiddlewareRegistry } from '../base/redux';
|
||||
|
||||
import { SUBMIT_FEEDBACK } from './actionTypes';
|
||||
|
||||
declare var APP: Object;
|
||||
|
||||
/**
|
||||
* Implements the middleware of the feature feedback.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case SUBMIT_FEEDBACK:
|
||||
if (typeof APP === 'object') {
|
||||
APP.API.notifyFeedbackSubmitted();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
PARTICIPANT_ROLE,
|
||||
ParticipantView,
|
||||
isEveryoneModerator,
|
||||
isLocalParticipantModerator,
|
||||
pinParticipant
|
||||
} from '../../../base/participants';
|
||||
import { Container } from '../../../base/react';
|
||||
@@ -21,6 +20,7 @@ import { getTrackByMediaTypeAndParticipant } from '../../../base/tracks';
|
||||
import { ConnectionIndicator } from '../../../connection-indicator';
|
||||
import { DisplayNameLabel } from '../../../display-name';
|
||||
import { RemoteVideoMenu } from '../../../remote-video-menu';
|
||||
import { toggleToolboxVisible } from '../../../toolbox';
|
||||
|
||||
import AudioMutedIndicator from './AudioMutedIndicator';
|
||||
import DominantSpeakerIndicator from './DominantSpeakerIndicator';
|
||||
@@ -44,11 +44,6 @@ type Props = {
|
||||
*/
|
||||
_isEveryoneModerator: boolean,
|
||||
|
||||
/**
|
||||
* True if the local participant is a moderator.
|
||||
*/
|
||||
_isModerator: boolean,
|
||||
|
||||
/**
|
||||
* The Redux representation of the state "features/large-video".
|
||||
*/
|
||||
@@ -74,12 +69,6 @@ type Props = {
|
||||
*/
|
||||
_videoTrack: Object,
|
||||
|
||||
/**
|
||||
* If true, tapping on the thumbnail will not pin the participant to large
|
||||
* video. By default tapping does pin the participant.
|
||||
*/
|
||||
disablePin?: boolean,
|
||||
|
||||
/**
|
||||
* If true, there will be no color overlay (tint) on the thumbnail
|
||||
* indicating the participant associated with the thumbnail is displayed on
|
||||
@@ -105,7 +94,12 @@ type Props = {
|
||||
/**
|
||||
* Optional styling to add or override on the Thumbnail component root.
|
||||
*/
|
||||
styleOverrides?: Object
|
||||
styleOverrides?: Object,
|
||||
|
||||
/**
|
||||
* If true, it tells the thumbnail that it needs to behave differently. E.g. react differently to a single tap.
|
||||
*/
|
||||
tileView?: boolean
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,16 +118,15 @@ class Thumbnail extends Component<Props> {
|
||||
const {
|
||||
_audioTrack: audioTrack,
|
||||
_isEveryoneModerator,
|
||||
_isModerator,
|
||||
_largeVideo: largeVideo,
|
||||
_onClick,
|
||||
_onShowRemoteVideoMenu,
|
||||
_styles,
|
||||
_videoTrack: videoTrack,
|
||||
disablePin,
|
||||
disableTint,
|
||||
participant,
|
||||
renderDisplayName
|
||||
renderDisplayName,
|
||||
tileView
|
||||
} = this.props;
|
||||
|
||||
// We don't render audio in any of the following:
|
||||
@@ -148,17 +141,14 @@ class Thumbnail extends Component<Props> {
|
||||
const participantInLargeVideo
|
||||
= participantId === largeVideo.participantId;
|
||||
const videoMuted = !videoTrack || videoTrack.muted;
|
||||
const showRemoteVideoMenu = _isModerator && !participant.local;
|
||||
|
||||
return (
|
||||
<Container
|
||||
onClick = { disablePin ? undefined : _onClick }
|
||||
onLongPress = {
|
||||
showRemoteVideoMenu
|
||||
? _onShowRemoteVideoMenu : undefined }
|
||||
onClick = { _onClick }
|
||||
onLongPress = { participant.local ? undefined : _onShowRemoteVideoMenu }
|
||||
style = { [
|
||||
styles.thumbnail,
|
||||
participant.pinned && !disablePin
|
||||
participant.pinned && !tileView
|
||||
? _styles.thumbnailPinned : null,
|
||||
this.props.styleOverrides || null
|
||||
] }
|
||||
@@ -234,10 +224,13 @@ function _mapDispatchToProps(dispatch: Function, ownProps): Object {
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
const { participant } = ownProps;
|
||||
const { participant, tileView } = ownProps;
|
||||
|
||||
dispatch(
|
||||
pinParticipant(participant.pinned ? null : participant.id));
|
||||
if (tileView) {
|
||||
dispatch(toggleToolboxVisible());
|
||||
} else {
|
||||
dispatch(pinParticipant(participant.pinned ? null : participant.id));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -262,7 +255,6 @@ function _mapDispatchToProps(dispatch: Function, ownProps): Object {
|
||||
* @param {Props} ownProps - Properties of component.
|
||||
* @returns {{
|
||||
* _audioTrack: Track,
|
||||
* _isModerator: boolean,
|
||||
* _largeVideo: Object,
|
||||
* _styles: StyleType,
|
||||
* _videoTrack: Track
|
||||
@@ -283,7 +275,6 @@ function _mapStateToProps(state, ownProps) {
|
||||
return {
|
||||
_audioTrack: audioTrack,
|
||||
_isEveryoneModerator: isEveryoneModerator(state),
|
||||
_isModerator: isLocalParticipantModerator(state),
|
||||
_largeVideo: largeVideo,
|
||||
_styles: ColorSchemeRegistry.get(state, 'Thumbnail'),
|
||||
_videoTrack: videoTrack
|
||||
|
||||
@@ -298,12 +298,12 @@ class TileView extends Component<Props, State> {
|
||||
return this._getSortedParticipants()
|
||||
.map(participant => (
|
||||
<Thumbnail
|
||||
disablePin = { true }
|
||||
disableTint = { true }
|
||||
key = { participant.id }
|
||||
participant = { participant }
|
||||
renderDisplayName = { true }
|
||||
styleOverrides = { styleOverrides } />));
|
||||
styleOverrides = { styleOverrides }
|
||||
tileView = { true } />));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
isAddPeopleEnabled,
|
||||
isDialOutEnabled
|
||||
} from '../../functions';
|
||||
import {
|
||||
NOTIFICATION_TIMEOUT,
|
||||
showNotification
|
||||
} from '../../../notifications';
|
||||
|
||||
const logger = require('jitsi-meet-logger').getLogger(__filename);
|
||||
|
||||
@@ -21,6 +25,11 @@ export type Props = {
|
||||
*/
|
||||
_addPeopleEnabled: boolean,
|
||||
|
||||
/**
|
||||
* Whether or not call flows are enabled.
|
||||
*/
|
||||
_callFlowsEnabled: boolean,
|
||||
|
||||
/**
|
||||
* The URL for validating if a phone number can be called.
|
||||
*/
|
||||
@@ -115,7 +124,7 @@ export default class AbstractAddPeopleDialog<P: Props, S: State>
|
||||
addToCallInProgress: true
|
||||
});
|
||||
|
||||
const { dispatch } = this.props;
|
||||
const { _callFlowsEnabled, dispatch } = this.props;
|
||||
|
||||
return dispatch(invite(invitees))
|
||||
.then(invitesLeftToSend => {
|
||||
@@ -140,6 +149,39 @@ export default class AbstractAddPeopleDialog<P: Props, S: State>
|
||||
this.setState({
|
||||
addToCallError: true
|
||||
});
|
||||
} else if (!_callFlowsEnabled) {
|
||||
const invitedCount = invitees.length;
|
||||
let notificationProps;
|
||||
|
||||
if (invitedCount >= 3) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
name: invitees[0].name,
|
||||
count: invitedCount - 1
|
||||
},
|
||||
titleKey: 'notify.invitedThreePlusMembers'
|
||||
};
|
||||
} else if (invitedCount === 2) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
first: invitees[0].name,
|
||||
second: invitees[1].name
|
||||
},
|
||||
titleKey: 'notify.invitedTwoMembers'
|
||||
};
|
||||
} else if (invitedCount) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
name: invitees[0].name
|
||||
},
|
||||
titleKey: 'notify.invitedOneMember'
|
||||
};
|
||||
}
|
||||
|
||||
if (notificationProps) {
|
||||
dispatch(
|
||||
showNotification(notificationProps, NOTIFICATION_TIMEOUT));
|
||||
}
|
||||
}
|
||||
|
||||
return invitesLeftToSend;
|
||||
@@ -206,6 +248,7 @@ export default class AbstractAddPeopleDialog<P: Props, S: State>
|
||||
*/
|
||||
export function _mapStateToProps(state: Object) {
|
||||
const {
|
||||
callFlowsEnabled,
|
||||
dialOutAuthUrl,
|
||||
peopleSearchQueryTypes,
|
||||
peopleSearchUrl
|
||||
@@ -213,6 +256,7 @@ export function _mapStateToProps(state: Object) {
|
||||
|
||||
return {
|
||||
_addPeopleEnabled: isAddPeopleEnabled(state),
|
||||
_callFlowsEnabled: callFlowsEnabled,
|
||||
_dialOutAuthUrl: dialOutAuthUrl,
|
||||
_dialOutEnabled: isDialOutEnabled(state),
|
||||
_jwt: state['features/base/jwt'].jwt,
|
||||
|
||||
@@ -212,7 +212,7 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
|
||||
* @returns {string}
|
||||
*/
|
||||
_keyExtractor(item) {
|
||||
return item.type === 'user' ? item.user_id : item.number;
|
||||
return item.type === 'user' ? item.id || item.user_id : item.number;
|
||||
}
|
||||
|
||||
_onCloseAddPeopleDialog: () => void
|
||||
@@ -315,8 +315,9 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
|
||||
&& !inviteItems.find(
|
||||
_.matchesProperty('number', result.number));
|
||||
case 'user':
|
||||
return result.user_id && !inviteItems.find(
|
||||
_.matchesProperty('user_id', result.user_id));
|
||||
return !inviteItems.find(
|
||||
(result.user_id && _.matchesProperty('id', result.id))
|
||||
|| (result.user_id && _.matchesProperty('user_id', result.user_id)));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -367,10 +368,12 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
|
||||
break;
|
||||
case 'user':
|
||||
selected
|
||||
= inviteItems.find(_.matchesProperty('user_id', item.user_id));
|
||||
= item.id
|
||||
? inviteItems.find(_.matchesProperty('id', item.id))
|
||||
: inviteItems.find(_.matchesProperty('user_id', item.user_id));
|
||||
renderableItem = {
|
||||
avatar: item.avatar,
|
||||
key: item.user_id,
|
||||
key: item.id || item.user_id,
|
||||
title: item.name
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -254,10 +254,10 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
|
||||
if (invitesLeftToSend.length) {
|
||||
const unsentInviteIDs
|
||||
= invitesLeftToSend.map(invitee =>
|
||||
invitee.id || invitee.number);
|
||||
invitee.id || invitee.user_id || invitee.number);
|
||||
const itemsToSelect
|
||||
= inviteItems.filter(({ item }) =>
|
||||
unsentInviteIDs.includes(item.id || item.number));
|
||||
unsentInviteIDs.includes(item.id || item.user_id || item.number));
|
||||
|
||||
if (this._multiselect) {
|
||||
this._multiselect.setSelectedItems(itemsToSelect);
|
||||
@@ -296,7 +296,7 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
|
||||
size = 'xsmall'
|
||||
src = { user.avatar } />
|
||||
},
|
||||
value: user.id
|
||||
value: user.id || user.user_id
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { translate, translateToHTML } from '../../../base/i18n';
|
||||
import { translate } from '../../../base/i18n';
|
||||
|
||||
import AbstractSuspendedOverlay from './AbstractSuspendedOverlay';
|
||||
import OverlayFrame from './OverlayFrame';
|
||||
@@ -31,11 +31,6 @@ class SuspendedOverlay extends AbstractSuspendedOverlay {
|
||||
className = 'inlay__title'>
|
||||
{ t('suspendedoverlay.title') }
|
||||
</h3>
|
||||
<span className = 'inlay__text'>
|
||||
{
|
||||
translateToHTML(t, 'suspendedoverlay.title')
|
||||
}
|
||||
</span>
|
||||
<ReloadButton textKey = 'suspendedoverlay.rejoinKeyTitle' />
|
||||
</div>
|
||||
</OverlayFrame>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// @flow
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
|
||||
import AbstractKickButton from '../AbstractKickButton';
|
||||
|
||||
/**
|
||||
* We don't need any further implementation for this on mobile, but we keep it
|
||||
* here for clarity and consistency with web. Once web uses the
|
||||
* {@code AbstractButton} base class, we can remove all these and just use
|
||||
* the {@code AbstractKickButton} as {@KickButton}.
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {Props}
|
||||
*/
|
||||
export default translate(connect()(AbstractKickButton));
|
||||
function _mapStateToProps(state) {
|
||||
return {
|
||||
visible: isLocalParticipantModerator(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AbstractKickButton));
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
// @flow
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
|
||||
import AbstractMuteButton, { _mapStateToProps } from '../AbstractMuteButton';
|
||||
import AbstractMuteButton, { _mapStateToProps as _abstractMapStateToProps } from '../AbstractMuteButton';
|
||||
|
||||
/**
|
||||
* We don't need any further implementation for this on mobile, but we keep it
|
||||
* here for clarity and consistency with web. Once web uses the
|
||||
* {@code AbstractButton} base class, we can remove all these and just use
|
||||
* the {@code AbstractMuteButton} as {@MuteButton}.
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - Properties of component.
|
||||
* @returns {Props}
|
||||
*/
|
||||
function _mapStateToProps(state, ownProps) {
|
||||
return {
|
||||
..._abstractMapStateToProps(state, ownProps),
|
||||
visible: isLocalParticipantModerator(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AbstractMuteButton));
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @flow
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { pinParticipant } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox';
|
||||
|
||||
export type Props = AbstractButtonProps & {
|
||||
|
||||
/**
|
||||
* True if tile view is currently enabled.
|
||||
*/
|
||||
_tileViewEnabled: boolean,
|
||||
|
||||
/**
|
||||
* The redux {@code dispatch} function.
|
||||
*/
|
||||
dispatch: Function,
|
||||
|
||||
/**
|
||||
* The ID of the participant that this button is supposed to pin.
|
||||
*/
|
||||
participantID: string,
|
||||
|
||||
/**
|
||||
* The function to be used to translate i18n labels.
|
||||
*/
|
||||
t: Function
|
||||
};
|
||||
|
||||
/**
|
||||
* A remote video menu button which pins a participant and exist the tile view.
|
||||
*/
|
||||
class PinButton extends AbstractButton<Props, *> {
|
||||
accessibilityLabel = 'toolbar.accessibilityLabel.show';
|
||||
iconName = 'icon-enlarge';
|
||||
label = 'videothumbnail.show';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and kicks the participant.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_handleClick() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
// Pin participant, it will automatically exit the tile view
|
||||
dispatch(pinParticipant(this.props.participantID));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {Props}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
return {
|
||||
visible: state['features/video-layout'].tileViewEnabled
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(PinButton));
|
||||
@@ -19,6 +19,7 @@ import { hideRemoteVideoMenu } from '../../actions';
|
||||
|
||||
import KickButton from './KickButton';
|
||||
import MuteButton from './MuteButton';
|
||||
import PinButton from './PinButton';
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
@@ -94,6 +95,7 @@ class RemoteVideoMenu extends Component<Props> {
|
||||
</View>
|
||||
<MuteButton { ...buttonProps } />
|
||||
<KickButton { ...buttonProps } />
|
||||
<PinButton { ...buttonProps } />
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,3 +102,12 @@ export const SET_TOOLBOX_TIMEOUT_MS = 'SET_TOOLBOX_TIMEOUT_MS';
|
||||
* }
|
||||
*/
|
||||
export const SET_TOOLBOX_VISIBLE = 'SET_TOOLBOX_VISIBLE';
|
||||
|
||||
/**
|
||||
* The type of the redux action which toggles the toolbox visibility regardless of it's current state.
|
||||
*
|
||||
* {
|
||||
* type: TOGGLE_TOOLBOX_VISIBLE
|
||||
* }
|
||||
*/
|
||||
export const TOGGLE_TOOLBOX_VISIBLE = 'TOGGLE_TOOLBOX_VISIBLE';
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
SET_TOOLBOX_ENABLED,
|
||||
SET_TOOLBOX_TIMEOUT,
|
||||
SET_TOOLBOX_TIMEOUT_MS,
|
||||
SET_TOOLBOX_VISIBLE
|
||||
SET_TOOLBOX_VISIBLE,
|
||||
TOGGLE_TOOLBOX_VISIBLE
|
||||
} from './actionTypes';
|
||||
|
||||
|
||||
@@ -144,3 +145,16 @@ export function setToolboxVisible(visible: boolean): Object {
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to toggle the toolbox visibility.
|
||||
*
|
||||
* @returns {{
|
||||
* type: TOGGLE_TOOLBOX_VISIBLE
|
||||
* }}
|
||||
*/
|
||||
export function toggleToolboxVisible() {
|
||||
return {
|
||||
type: TOGGLE_TOOLBOX_VISIBLE
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
SET_TOOLBOX_ENABLED,
|
||||
SET_TOOLBOX_TIMEOUT,
|
||||
SET_TOOLBOX_TIMEOUT_MS,
|
||||
SET_TOOLBOX_VISIBLE
|
||||
SET_TOOLBOX_VISIBLE,
|
||||
TOGGLE_TOOLBOX_VISIBLE
|
||||
} from './actionTypes';
|
||||
|
||||
declare var interfaceConfig: Object;
|
||||
@@ -165,7 +166,10 @@ ReducerRegistry.register(
|
||||
};
|
||||
|
||||
case SET_TOOLBOX_VISIBLE:
|
||||
return set(state, 'visible', action.visible);
|
||||
return set(state, 'visible', state.alwaysVisible || action.visible);
|
||||
|
||||
case TOGGLE_TOOLBOX_VISIBLE:
|
||||
return set(state, 'visible', state.alwaysVisible || !state.visible);
|
||||
}
|
||||
|
||||
return state;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { isBrowsersOptimal } from '../../base/environment';
|
||||
import { translate } from '../../base/i18n';
|
||||
|
||||
import { CHROME, FIREFOX } from './browserLinks';
|
||||
@@ -47,14 +48,26 @@ class UnsupportedDesktopBrowser extends Component<Props> {
|
||||
Please try again with the latest version of
|
||||
<a
|
||||
className = { `${_SNS}__link` }
|
||||
href = { CHROME } >Chrome</a> and
|
||||
<a
|
||||
className = { `${_SNS}__link` }
|
||||
href = { FIREFOX }>Firefox</a>
|
||||
href = { CHROME } >Chrome</a>
|
||||
{
|
||||
this._showFirefox() && <>and <a
|
||||
className = { `${_SNS}__link` }
|
||||
href = { FIREFOX }>Firefox</a></>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not a link to download Firefox is displayed.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_showFirefox() {
|
||||
return isBrowsersOptimal('firefox');
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(UnsupportedDesktopBrowser);
|
||||
|
||||
@@ -75,7 +75,6 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
|
||||
case SET_FILMSTRIP_VISIBLE:
|
||||
VideoLayout.resizeVideoArea(true, false);
|
||||
APP.API.notifyFilmstripDisplayChanged(action.visible);
|
||||
break;
|
||||
|
||||
case TRACK_ADDED:
|
||||
|
||||
@@ -36,10 +36,6 @@ StateListenerRegistry.register(
|
||||
_updateAutoPinnedParticipant(store);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof APP === 'object') {
|
||||
APP.API.notifyTileViewChanged(displayTileView);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -59,7 +55,13 @@ StateListenerRegistry.register(
|
||||
= store.getState()['features/video-layout'].screenShares || [];
|
||||
const knownSharingParticipantIds = tracks.reduce((acc, track) => {
|
||||
if (track.mediaType === 'video' && track.videoType === 'desktop') {
|
||||
acc.push(track.participantId);
|
||||
const skipTrack
|
||||
= interfaceConfig.AUTO_PIN_LATEST_SCREEN_SHARE === 'remote-only'
|
||||
&& track.local;
|
||||
|
||||
if (!skipTrack) {
|
||||
acc.push(track.participantId);
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import { getName } from '../../app';
|
||||
|
||||
import { ColorSchemeRegistry } from '../../base/color-scheme';
|
||||
import { translate } from '../../base/i18n';
|
||||
import { Icon } from '../../base/font-icons';
|
||||
@@ -23,11 +25,12 @@ import {
|
||||
import { DialInSummary } from '../../invite';
|
||||
import { SettingsView } from '../../settings';
|
||||
|
||||
import { setSideBarVisible } from '../actions';
|
||||
|
||||
import {
|
||||
AbstractWelcomePage,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from './AbstractWelcomePage';
|
||||
import { setSideBarVisible } from '../actions';
|
||||
import LocalVideoTrackUnderlay from './LocalVideoTrackUnderlay';
|
||||
import styles, { PLACEHOLDER_TEXT_COLOR } from './styles';
|
||||
import VideoSwitch from './VideoSwitch';
|
||||
@@ -95,52 +98,13 @@ class WelcomePage extends AbstractWelcomePage {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const roomnameAccLabel = 'welcomepage.accessibilityLabel.roomname';
|
||||
const { _headerStyles, t } = this.props;
|
||||
const { _reducedUI } = this.props;
|
||||
|
||||
return (
|
||||
<LocalVideoTrackUnderlay style = { styles.welcomePage }>
|
||||
<View style = { _headerStyles.page }>
|
||||
<Header style = { styles.header }>
|
||||
<TouchableOpacity onPress = { this._onShowSideBar } >
|
||||
<Icon
|
||||
name = 'menu'
|
||||
style = { _headerStyles.headerButtonIcon } />
|
||||
</TouchableOpacity>
|
||||
<VideoSwitch />
|
||||
</Header>
|
||||
<SafeAreaView style = { styles.roomContainer } >
|
||||
<View style = { styles.joinControls } >
|
||||
<TextInput
|
||||
accessibilityLabel = { t(roomnameAccLabel) }
|
||||
autoCapitalize = 'none'
|
||||
autoComplete = 'off'
|
||||
autoCorrect = { false }
|
||||
autoFocus = { false }
|
||||
onBlur = { this._onFieldBlur }
|
||||
onChangeText = { this._onRoomChange }
|
||||
onFocus = { this._onFieldFocus }
|
||||
onSubmitEditing = { this._onJoin }
|
||||
placeholder = { t('welcomepage.roomname') }
|
||||
placeholderTextColor = {
|
||||
PLACEHOLDER_TEXT_COLOR
|
||||
}
|
||||
returnKeyType = { 'go' }
|
||||
style = { styles.textInput }
|
||||
underlineColorAndroid = 'transparent'
|
||||
value = { this.state.room } />
|
||||
{
|
||||
this._renderHintBox()
|
||||
}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
<WelcomePageLists disabled = { this.state._fieldFocused } />
|
||||
<SettingsView />
|
||||
<DialInSummary />
|
||||
</View>
|
||||
<WelcomePageSideBar />
|
||||
</LocalVideoTrackUnderlay>
|
||||
);
|
||||
if (_reducedUI) {
|
||||
return this._renderReducedUI();
|
||||
}
|
||||
|
||||
return this._renderFullUI();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,20 +236,92 @@ class WelcomePage extends AbstractWelcomePage {
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the full welcome page.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderFullUI() {
|
||||
const roomnameAccLabel = 'welcomepage.accessibilityLabel.roomname';
|
||||
const { _headerStyles, t } = this.props;
|
||||
|
||||
return (
|
||||
<LocalVideoTrackUnderlay style = { styles.welcomePage }>
|
||||
<View style = { _headerStyles.page }>
|
||||
<Header style = { styles.header }>
|
||||
<TouchableOpacity onPress = { this._onShowSideBar } >
|
||||
<Icon
|
||||
name = 'menu'
|
||||
style = { _headerStyles.headerButtonIcon } />
|
||||
</TouchableOpacity>
|
||||
<VideoSwitch />
|
||||
</Header>
|
||||
<SafeAreaView style = { styles.roomContainer } >
|
||||
<View style = { styles.joinControls } >
|
||||
<TextInput
|
||||
accessibilityLabel = { t(roomnameAccLabel) }
|
||||
autoCapitalize = 'none'
|
||||
autoComplete = 'off'
|
||||
autoCorrect = { false }
|
||||
autoFocus = { false }
|
||||
onBlur = { this._onFieldBlur }
|
||||
onChangeText = { this._onRoomChange }
|
||||
onFocus = { this._onFieldFocus }
|
||||
onSubmitEditing = { this._onJoin }
|
||||
placeholder = { t('welcomepage.roomname') }
|
||||
placeholderTextColor = {
|
||||
PLACEHOLDER_TEXT_COLOR
|
||||
}
|
||||
returnKeyType = { 'go' }
|
||||
style = { styles.textInput }
|
||||
underlineColorAndroid = 'transparent'
|
||||
value = { this.state.room } />
|
||||
{
|
||||
this._renderHintBox()
|
||||
}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
<WelcomePageLists disabled = { this.state._fieldFocused } />
|
||||
<SettingsView />
|
||||
<DialInSummary />
|
||||
</View>
|
||||
<WelcomePageSideBar />
|
||||
</LocalVideoTrackUnderlay>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a "reduced" version of the welcome page.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderReducedUI() {
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.reducedUIContainer }>
|
||||
<Text style = { styles.reducedUIText }>
|
||||
{ t('welcomepage.reducedUIText', { app: getName() }) }
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {{
|
||||
* _headerStyles: Object
|
||||
* }}
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
const { reducedUI } = state['features/base/responsive-ui'];
|
||||
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
_headerStyles: ColorSchemeRegistry.get(state, 'Header')
|
||||
_headerStyles: ColorSchemeRegistry.get(state, 'Header'),
|
||||
_reducedUI: reducedUI
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,21 @@ export default createStyleSheet({
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
/**
|
||||
* The styles for reduced UI mode.
|
||||
*/
|
||||
reducedUIContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: ColorPalette.blue,
|
||||
flex: 1,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
reducedUIText: {
|
||||
color: TEXT_COLOR,
|
||||
fontSize: 12
|
||||
},
|
||||
|
||||
/**
|
||||
* Container for room name input box and 'join' button.
|
||||
*/
|
||||
|
||||