mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-01-05 14:22:28 +00:00
Compare commits
12 Commits
4640
...
saghul-pat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e51cf15b1 | ||
|
|
2f7ff37472 | ||
|
|
c752ea13f1 | ||
|
|
5ef60c3a7d | ||
|
|
1196ede961 | ||
|
|
12877c7fce | ||
|
|
c6bb600d4c | ||
|
|
845e23a947 | ||
|
|
55ebb60f85 | ||
|
|
8d3d94f568 | ||
|
|
be24772e57 | ||
|
|
a807f804a9 |
@@ -38,7 +38,7 @@ import java.lang.reflect.Method;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* The one and only Activity that the Jitsi Meet app needs. The
|
||||
@@ -183,8 +183,8 @@ public class MainActivity extends JitsiMeetActivity {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConferenceTerminated(Map<String, Object> data) {
|
||||
Log.d(TAG, "Conference terminated: " + data);
|
||||
protected void onConferenceTerminated(HashMap<String, Object> extraData) {
|
||||
Log.d(TAG, "Conference terminated: " + extraData);
|
||||
}
|
||||
|
||||
// Activity lifecycle method overrides
|
||||
|
||||
@@ -36,6 +36,7 @@ dependencies {
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0'
|
||||
implementation 'androidx.fragment:fragment:1.2.5'
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
|
||||
//noinspection GradleDynamicVersion
|
||||
|
||||
@@ -99,6 +99,7 @@ public abstract class BaseReactView<ListenerT>
|
||||
* The listener (e.g. {@link JitsiMeetViewListener}) instance for reporting
|
||||
* events occurring in Jitsi Meet.
|
||||
*/
|
||||
@Deprecated
|
||||
private ListenerT listener;
|
||||
|
||||
/**
|
||||
@@ -167,6 +168,7 @@ public abstract class BaseReactView<ListenerT>
|
||||
*
|
||||
* @return The listener set on this {@code BaseReactView}.
|
||||
*/
|
||||
@Deprecated
|
||||
public ListenerT getListener() {
|
||||
return listener;
|
||||
}
|
||||
@@ -179,8 +181,10 @@ public abstract class BaseReactView<ListenerT>
|
||||
* @param data - The details of the event associated with/specific to the
|
||||
* specified {@code name}.
|
||||
*/
|
||||
@Deprecated
|
||||
protected abstract void onExternalAPIEvent(String name, ReadableMap data);
|
||||
|
||||
@Deprecated
|
||||
protected void onExternalAPIEvent(
|
||||
Map<String, Method> listenerMethods,
|
||||
String name, ReadableMap data) {
|
||||
@@ -215,6 +219,7 @@ public abstract class BaseReactView<ListenerT>
|
||||
*
|
||||
* @param listener The listener to set on this {@code BaseReactView}.
|
||||
*/
|
||||
@Deprecated
|
||||
public void setListener(ListenerT listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.facebook.react.bridge.WritableNativeMap;
|
||||
|
||||
import org.jitsi.meet.sdk.log.JitsiMeetLogger;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* Wraps the name and extra data for events that were broadcasted locally.
|
||||
*/
|
||||
public class BroadcastAction {
|
||||
private static final String TAG = BroadcastAction.class.getSimpleName();
|
||||
|
||||
private final Type type;
|
||||
private final HashMap<String, Object> data;
|
||||
|
||||
public BroadcastAction(Intent intent) {
|
||||
this.type = Type.buildTypeFromAction(intent.getAction());
|
||||
this.data = buildDataFromBundle(intent.getExtras());
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
public HashMap<String, Object> getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
public WritableNativeMap getDataAsWritableNativeMap() {
|
||||
WritableNativeMap nativeMap = new WritableNativeMap();
|
||||
|
||||
for (String key : this.data.keySet()) {
|
||||
try {
|
||||
// TODO add support for different types of objects
|
||||
nativeMap.putString(key, this.data.get(key).toString());
|
||||
} catch (Exception e) {
|
||||
JitsiMeetLogger.w(TAG + " invalid extra data in event", e);
|
||||
}
|
||||
}
|
||||
|
||||
return nativeMap;
|
||||
}
|
||||
|
||||
private static HashMap<String, Object> buildDataFromBundle(Bundle bundle) {
|
||||
HashMap<String, Object> map = new HashMap<>();
|
||||
|
||||
if (bundle != null) {
|
||||
for (String key : bundle.keySet()) {
|
||||
map.put(key, bundle.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
enum Type {
|
||||
SET_AUDIO_MUTED("org.jitsi.meet.SET_AUDIO_MUTED"),
|
||||
HANG_UP("org.jitsi.meet.HANG_UP");
|
||||
|
||||
private final String action;
|
||||
|
||||
Type(String action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
private static Type buildTypeFromAction(String action) {
|
||||
for (Type type : Type.values()) {
|
||||
if (type.action.equalsIgnoreCase(action)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import com.facebook.react.bridge.ReadableMap;
|
||||
|
||||
/**
|
||||
* Class used to emit events through the LocalBroadcastManager, called when events
|
||||
* from JS occurred. Takes an action name from JS, builds and broadcasts the {@link BroadcastEvent}
|
||||
*/
|
||||
public class BroadcastEmitter {
|
||||
private final LocalBroadcastManager localBroadcastManager;
|
||||
|
||||
public BroadcastEmitter(Context context) {
|
||||
localBroadcastManager = LocalBroadcastManager.getInstance(context);
|
||||
}
|
||||
|
||||
public void sendBroadcast(String name, ReadableMap data) {
|
||||
BroadcastEvent event = new BroadcastEvent(name, data);
|
||||
|
||||
Intent intent = event.buildIntent();
|
||||
|
||||
if (intent != null) {
|
||||
localBroadcastManager.sendBroadcast(intent);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEvent.java
Normal file
130
android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEvent.java
Normal file
@@ -0,0 +1,130 @@
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.facebook.react.bridge.ReadableMap;
|
||||
|
||||
import org.jitsi.meet.sdk.log.JitsiMeetLogger;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* Wraps the name and extra data for the events that occur on the JS side and are
|
||||
* to be broadcasted.
|
||||
*/
|
||||
public class BroadcastEvent {
|
||||
|
||||
private static final String TAG = BroadcastEvent.class.getSimpleName();
|
||||
|
||||
private final Type type;
|
||||
private final HashMap<String, Object> data;
|
||||
|
||||
public BroadcastEvent(String name, ReadableMap data) {
|
||||
this.type = Type.buildTypeFromName(name);
|
||||
this.data = data.toHashMap();
|
||||
}
|
||||
|
||||
public BroadcastEvent(Intent intent) {
|
||||
this.type = Type.buildTypeFromAction(intent.getAction());
|
||||
this.data = buildDataFromBundle(intent.getExtras());
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
public HashMap<String, Object> getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
public Intent buildIntent() {
|
||||
if (type != null && type.action != null) {
|
||||
Intent intent = new Intent(type.action);
|
||||
|
||||
for (String key : this.data.keySet()) {
|
||||
try {
|
||||
intent.putExtra(key, this.data.get(key).toString());
|
||||
} catch (Exception e) {
|
||||
JitsiMeetLogger.w(TAG + " invalid extra data in event", e);
|
||||
}
|
||||
}
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static HashMap<String, Object> buildDataFromBundle(Bundle bundle) {
|
||||
if (bundle != null) {
|
||||
try {
|
||||
HashMap<String, Object> map = new HashMap<>();
|
||||
|
||||
for (String key : bundle.keySet()) {
|
||||
map.put(key, bundle.get(key));
|
||||
}
|
||||
|
||||
return map;
|
||||
} catch (Exception e) {
|
||||
JitsiMeetLogger.w(TAG + " invalid extra data", e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
CONFERENCE_JOINED("org.jitsi.meet.CONFERENCE_JOINED"),
|
||||
CONFERENCE_TERMINATED("org.jitsi.meet.CONFERENCE_TERMINATED"),
|
||||
CONFERENCE_WILL_JOIN("org.jitsi.meet.CONFERENCE_WILL_JOIN"),
|
||||
AUDIO_MUTED_CHANGED("org.jitsi.meet.AUDIO_MUTED_CHANGED"),
|
||||
PARTICIPANT_JOINED("org.jitsi.meet.PARTICIPANT_JOINED"),
|
||||
PARTICIPANT_LEFT("org.jitsi.meet.PARTICIPANT_LEFT");
|
||||
|
||||
private static final String CONFERENCE_WILL_JOIN_NAME = "CONFERENCE_WILL_JOIN";
|
||||
private static final String CONFERENCE_JOINED_NAME = "CONFERENCE_JOINED";
|
||||
private static final String CONFERENCE_TERMINATED_NAME = "CONFERENCE_TERMINATED";
|
||||
private static final String AUDIO_MUTED_CHANGED_NAME = "AUDIO_MUTED_CHANGED";
|
||||
private static final String PARTICIPANT_JOINED_NAME = "PARTICIPANT_JOINED";
|
||||
private static final String PARTICIPANT_LEFT_NAME = "PARTICIPANT_LEFT";
|
||||
|
||||
private final String action;
|
||||
|
||||
Type(String action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
private static Type buildTypeFromAction(String action) {
|
||||
for (Type type : Type.values()) {
|
||||
if (type.action.equalsIgnoreCase(action)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type buildTypeFromName(String name) {
|
||||
switch (name) {
|
||||
case CONFERENCE_WILL_JOIN_NAME:
|
||||
return CONFERENCE_WILL_JOIN;
|
||||
case CONFERENCE_JOINED_NAME:
|
||||
return CONFERENCE_JOINED;
|
||||
case CONFERENCE_TERMINATED_NAME:
|
||||
return CONFERENCE_TERMINATED;
|
||||
case AUDIO_MUTED_CHANGED_NAME:
|
||||
return AUDIO_MUTED_CHANGED;
|
||||
case PARTICIPANT_JOINED_NAME:
|
||||
return PARTICIPANT_JOINED;
|
||||
case PARTICIPANT_LEFT_NAME:
|
||||
return PARTICIPANT_LEFT;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
public class BroadcastIntentHelper {
|
||||
public static Intent buildSetAudioMutedIntent(boolean muted) {
|
||||
Intent intent = new Intent(BroadcastAction.Type.SET_AUDIO_MUTED.getAction());
|
||||
intent.putExtra("muted", muted);
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static Intent buildHangUpIntent() {
|
||||
return new Intent(BroadcastAction.Type.HANG_UP.getAction());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
/**
|
||||
* Listens for {@link BroadcastAction}s on LocalBroadcastManager. When one occurs,
|
||||
* it emits it to JS.
|
||||
*/
|
||||
public class BroadcastReceiver extends android.content.BroadcastReceiver {
|
||||
|
||||
public BroadcastReceiver(Context context) {
|
||||
LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(context);
|
||||
|
||||
IntentFilter intentFilter = new IntentFilter();
|
||||
intentFilter.addAction(BroadcastAction.Type.SET_AUDIO_MUTED.getAction());
|
||||
intentFilter.addAction(BroadcastAction.Type.HANG_UP.getAction());
|
||||
|
||||
localBroadcastManager.registerReceiver(this, intentFilter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
BroadcastAction action = new BroadcastAction(intent);
|
||||
String actionName = action.getType().getAction();
|
||||
|
||||
ReactInstanceManagerHolder.emitEvent(actionName, action.getDataAsWritableNativeMap());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright @ 2017-present Atlassian Pty Ltd
|
||||
* Copyright @ 2017-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.
|
||||
@@ -24,6 +24,9 @@ import com.facebook.react.module.annotations.ReactModule;
|
||||
|
||||
import org.jitsi.meet.sdk.log.JitsiMeetLogger;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Module implementing an API for sending events from JavaScript to native code.
|
||||
*/
|
||||
@@ -35,6 +38,9 @@ class ExternalAPIModule
|
||||
|
||||
private static final String TAG = NAME;
|
||||
|
||||
private final BroadcastEmitter broadcastEmitter;
|
||||
private final BroadcastReceiver broadcastReceiver;
|
||||
|
||||
/**
|
||||
* Initializes a new module instance. There shall be a single instance of
|
||||
* this module throughout the lifetime of the app.
|
||||
@@ -44,6 +50,9 @@ class ExternalAPIModule
|
||||
*/
|
||||
public ExternalAPIModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
|
||||
broadcastEmitter = new BroadcastEmitter(reactContext);
|
||||
broadcastReceiver = new BroadcastReceiver(reactContext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +65,22 @@ class ExternalAPIModule
|
||||
return NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a mapping with the constants this module is exporting.
|
||||
*
|
||||
* @return a {@link Map} mapping the constants to be exported with their
|
||||
* values.
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> getConstants() {
|
||||
Map<String, Object> constants = new HashMap<>();
|
||||
|
||||
constants.put("SET_AUDIO_MUTED", BroadcastAction.Type.SET_AUDIO_MUTED.getAction());
|
||||
constants.put("HANG_UP", BroadcastAction.Type.HANG_UP.getAction());
|
||||
|
||||
return constants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an event that occurred on the JavaScript side of the SDK to
|
||||
* the specified {@link BaseReactView}'s listener.
|
||||
@@ -79,7 +104,8 @@ class ExternalAPIModule
|
||||
JitsiMeetLogger.d(TAG + " Sending event: " + name + " with data: " + data);
|
||||
try {
|
||||
view.onExternalAPIEvent(name, data);
|
||||
} catch(Exception e) {
|
||||
broadcastEmitter.sendBroadcast(name, data);
|
||||
} catch (Exception e) {
|
||||
JitsiMeetLogger.e(e, TAG + " onExternalAPIEvent: error sending event");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,33 +16,41 @@
|
||||
|
||||
package org.jitsi.meet.sdk;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import com.facebook.react.modules.core.PermissionListener;
|
||||
|
||||
import org.jitsi.meet.sdk.log.JitsiMeetLogger;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* A base activity for SDK users to embed. It uses {@link JitsiMeetFragment} to do the heavy
|
||||
* lifting and wires the remaining Activity lifecycle methods so it works out of the box.
|
||||
*/
|
||||
public class JitsiMeetActivity extends FragmentActivity
|
||||
implements JitsiMeetActivityInterface, JitsiMeetViewListener {
|
||||
implements JitsiMeetActivityInterface {
|
||||
|
||||
protected static final String TAG = JitsiMeetActivity.class.getSimpleName();
|
||||
|
||||
private static final String ACTION_JITSI_MEET_CONFERENCE = "org.jitsi.meet.CONFERENCE";
|
||||
private static final String JITSI_MEET_CONFERENCE_OPTIONS = "JitsiMeetConferenceOptions";
|
||||
|
||||
private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
onBroadcastReceived(intent);
|
||||
}
|
||||
};
|
||||
// Helpers for starting the activity
|
||||
//
|
||||
|
||||
@@ -68,8 +76,7 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
|
||||
setContentView(R.layout.activity_jitsi_meet);
|
||||
|
||||
// Listen for conference events.
|
||||
getJitsiView().setListener(this);
|
||||
registerForBroadcastMessages();
|
||||
|
||||
if (!extraInitialize()) {
|
||||
initialize();
|
||||
@@ -91,6 +98,8 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
}
|
||||
JitsiMeetOngoingConferenceService.abort(this);
|
||||
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver);
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@@ -113,8 +122,8 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
public void join(@Nullable String url) {
|
||||
JitsiMeetConferenceOptions options
|
||||
= new JitsiMeetConferenceOptions.Builder()
|
||||
.setRoom(url)
|
||||
.build();
|
||||
.setRoom(url)
|
||||
.build();
|
||||
join(options);
|
||||
}
|
||||
|
||||
@@ -138,7 +147,8 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable JitsiMeetConferenceOptions getConferenceOptions(Intent intent) {
|
||||
private @Nullable
|
||||
JitsiMeetConferenceOptions getConferenceOptions(Intent intent) {
|
||||
String action = intent.getAction();
|
||||
|
||||
if (Intent.ACTION_VIEW.equals(action)) {
|
||||
@@ -157,7 +167,7 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
* Helper function called during activity initialization. If {@code true} is returned, the
|
||||
* initialization is delayed and the {@link JitsiMeetActivity#initialize()} method is not
|
||||
* called. In this case, it's up to the subclass to call the initialize method when ready.
|
||||
*
|
||||
* <p>
|
||||
* This is mainly required so we do some extra initialization in the Jitsi Meet app.
|
||||
*
|
||||
* @return {@code true} if the initialization will be delayed, {@code false} otherwise.
|
||||
@@ -172,6 +182,37 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
join(getConferenceOptions(getIntent()));
|
||||
}
|
||||
|
||||
protected void onConferenceJoined(HashMap<String, Object> extraData) {
|
||||
JitsiMeetLogger.i("Conference joined: " + extraData);
|
||||
// Launch the service for the ongoing notification.
|
||||
JitsiMeetOngoingConferenceService.launch(this);
|
||||
}
|
||||
|
||||
protected void onConferenceTerminated(HashMap<String, Object> extraData) {
|
||||
JitsiMeetLogger.i("Conference terminated: " + extraData);
|
||||
finish();
|
||||
}
|
||||
|
||||
protected void onConferenceWillJoin(HashMap<String, Object> extraData) {
|
||||
JitsiMeetLogger.i("Conference will join: " + extraData);
|
||||
}
|
||||
|
||||
protected void onParticipantJoined(HashMap<String, Object> extraData) {
|
||||
try {
|
||||
JitsiMeetLogger.i("Participant joined: ", extraData);
|
||||
} catch (Exception e) {
|
||||
JitsiMeetLogger.w("Invalid participant joined extraData", e);
|
||||
}
|
||||
}
|
||||
|
||||
protected void onParticipantLeft(HashMap<String, Object> extraData) {
|
||||
try {
|
||||
JitsiMeetLogger.i("Participant left: ", extraData);
|
||||
} catch (Exception e) {
|
||||
JitsiMeetLogger.w("Invalid participant left extraData", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Activity lifecycle methods
|
||||
//
|
||||
|
||||
@@ -223,24 +264,38 @@ public class JitsiMeetActivity extends FragmentActivity
|
||||
JitsiMeetActivityDelegate.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
// JitsiMeetViewListener
|
||||
//
|
||||
private void registerForBroadcastMessages() {
|
||||
IntentFilter intentFilter = new IntentFilter();
|
||||
intentFilter.addAction(BroadcastEvent.Type.CONFERENCE_JOINED.getAction());
|
||||
intentFilter.addAction(BroadcastEvent.Type.CONFERENCE_WILL_JOIN.getAction());
|
||||
intentFilter.addAction(BroadcastEvent.Type.CONFERENCE_TERMINATED.getAction());
|
||||
intentFilter.addAction(BroadcastEvent.Type.PARTICIPANT_JOINED.getAction());
|
||||
intentFilter.addAction(BroadcastEvent.Type.PARTICIPANT_LEFT.getAction());
|
||||
|
||||
@Override
|
||||
public void onConferenceJoined(Map<String, Object> data) {
|
||||
JitsiMeetLogger.i("Conference joined: " + data);
|
||||
// Launch the service for the ongoing notification.
|
||||
JitsiMeetOngoingConferenceService.launch(this);
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, intentFilter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConferenceTerminated(Map<String, Object> data) {
|
||||
JitsiMeetLogger.i("Conference terminated: " + data);
|
||||
finish();
|
||||
}
|
||||
private void onBroadcastReceived(Intent intent) {
|
||||
if (intent != null) {
|
||||
BroadcastEvent event = new BroadcastEvent(intent);
|
||||
|
||||
@Override
|
||||
public void onConferenceWillJoin(Map<String, Object> data) {
|
||||
JitsiMeetLogger.i("Conference will join: " + data);
|
||||
switch (event.getType()) {
|
||||
case CONFERENCE_JOINED:
|
||||
onConferenceJoined(event.getData());
|
||||
break;
|
||||
case CONFERENCE_WILL_JOIN:
|
||||
onConferenceWillJoin(event.getData());
|
||||
break;
|
||||
case CONFERENCE_TERMINATED:
|
||||
onConferenceTerminated(event.getData());
|
||||
break;
|
||||
case PARTICIPANT_JOINED:
|
||||
onParticipantJoined(event.getData());
|
||||
break;
|
||||
case PARTICIPANT_LEFT:
|
||||
onParticipantLeft(event.getData());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ import android.app.Service;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import org.jitsi.meet.sdk.log.JitsiMeetLogger;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import org.jitsi.meet.sdk.log.JitsiMeetLogger;
|
||||
|
||||
/**
|
||||
* This class implements an Android {@link Service}, a foreground one specifically, and it's
|
||||
@@ -35,19 +37,18 @@ import org.jitsi.meet.sdk.log.JitsiMeetLogger;
|
||||
* See: https://developer.android.com/guide/components/services
|
||||
*/
|
||||
public class JitsiMeetOngoingConferenceService extends Service
|
||||
implements OngoingConferenceTracker.OngoingConferenceListener {
|
||||
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";
|
||||
}
|
||||
private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver();
|
||||
|
||||
private boolean isAudioMuted;
|
||||
|
||||
static void launch(Context context) {
|
||||
OngoingNotification.createOngoingConferenceNotificationChannel();
|
||||
|
||||
Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class);
|
||||
intent.setAction(Actions.START);
|
||||
intent.setAction(Action.START.getName());
|
||||
|
||||
ComponentName componentName;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -70,11 +71,16 @@ public class JitsiMeetOngoingConferenceService extends Service
|
||||
super.onCreate();
|
||||
|
||||
OngoingConferenceTracker.getInstance().addListener(this);
|
||||
|
||||
IntentFilter intentFilter = new IntentFilter();
|
||||
intentFilter.addAction(BroadcastEvent.Type.AUDIO_MUTED_CHANGED.getAction());
|
||||
LocalBroadcastManager.getInstance(getApplicationContext()).registerReceiver(broadcastReceiver, intentFilter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
OngoingConferenceTracker.getInstance().removeListener(this);
|
||||
LocalBroadcastManager.getInstance(getApplicationContext()).unregisterReceiver(broadcastReceiver);
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -86,26 +92,37 @@ public class JitsiMeetOngoingConferenceService extends Service
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
final String action = intent.getAction();
|
||||
if (Actions.START.equals(action)) {
|
||||
Notification notification = OngoingNotification.buildOngoingConferenceNotification();
|
||||
if (notification == null) {
|
||||
final String actionName = intent.getAction();
|
||||
final Action action = Action.fromName(actionName);
|
||||
|
||||
switch (action) {
|
||||
case UNMUTE:
|
||||
case MUTE:
|
||||
Intent muteBroadcastIntent = BroadcastIntentHelper.buildSetAudioMutedIntent(action == Action.MUTE);
|
||||
LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(muteBroadcastIntent);
|
||||
break;
|
||||
case START:
|
||||
Notification notification = OngoingNotification.buildOngoingConferenceNotification(isAudioMuted);
|
||||
if (notification == null) {
|
||||
stopSelf();
|
||||
JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null");
|
||||
} else {
|
||||
startForeground(OngoingNotification.NOTIFICATION_ID, notification);
|
||||
JitsiMeetLogger.i(TAG + " Service started");
|
||||
}
|
||||
break;
|
||||
case HANGUP:
|
||||
JitsiMeetLogger.i(TAG + " Hangup requested");
|
||||
|
||||
Intent hangupBroadcastIntent = BroadcastIntentHelper.buildHangUpIntent();
|
||||
LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(hangupBroadcastIntent);
|
||||
|
||||
stopSelf();
|
||||
JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null");
|
||||
} else {
|
||||
startForeground(OngoingNotification.NOTIFICATION_ID, notification);
|
||||
JitsiMeetLogger.i(TAG + " Service started");
|
||||
}
|
||||
} else if (Actions.HANGUP.equals(action)) {
|
||||
JitsiMeetLogger.i(TAG + " Hangup requested");
|
||||
// Abort all ongoing calls
|
||||
if (AudioModeModule.useConnectionService()) {
|
||||
ConnectionService.abortConnections();
|
||||
}
|
||||
stopSelf();
|
||||
} else {
|
||||
JitsiMeetLogger.w(TAG + " Unknown action received: " + action);
|
||||
stopSelf();
|
||||
break;
|
||||
default:
|
||||
JitsiMeetLogger.w(TAG + " Unknown action received: " + action);
|
||||
stopSelf();
|
||||
break;
|
||||
}
|
||||
|
||||
return START_NOT_STICKY;
|
||||
@@ -118,4 +135,46 @@ public class JitsiMeetOngoingConferenceService extends Service
|
||||
JitsiMeetLogger.i(TAG + "Service stopped");
|
||||
}
|
||||
}
|
||||
|
||||
public enum Action {
|
||||
START(TAG + ":START"),
|
||||
HANGUP(TAG + ":HANGUP"),
|
||||
MUTE(TAG + ":MUTE"),
|
||||
UNMUTE(TAG + ":UNMUTE");
|
||||
|
||||
private final String name;
|
||||
|
||||
Action(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public static Action fromName(String name) {
|
||||
for (Action action : Action.values()) {
|
||||
if (action.name.equalsIgnoreCase(name)) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
private class BroadcastReceiver extends android.content.BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
isAudioMuted = Boolean.parseBoolean(intent.getStringExtra("muted"));
|
||||
Notification notification = OngoingNotification.buildOngoingConferenceNotification(isAudioMuted);
|
||||
if (notification == null) {
|
||||
stopSelf();
|
||||
JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null");
|
||||
} else {
|
||||
startForeground(OngoingNotification.NOTIFICATION_ID, notification);
|
||||
JitsiMeetLogger.i(TAG + " Service started");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ public class JitsiMeetView extends BaseReactView<JitsiMeetViewListener>
|
||||
* by/associated with the specified {@code name}.
|
||||
*/
|
||||
@Override
|
||||
@Deprecated
|
||||
protected void onExternalAPIEvent(String name, ReadableMap data) {
|
||||
onExternalAPIEvent(LISTENER_METHODS, name, data);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.util.Map;
|
||||
/**
|
||||
* Interface for listening to events coming from Jitsi Meet.
|
||||
*/
|
||||
@Deprecated
|
||||
public interface JitsiMeetViewListener {
|
||||
/**
|
||||
* Called when a conference was joined.
|
||||
|
||||
@@ -32,6 +32,7 @@ import java.util.regex.Pattern;
|
||||
* Utility methods for helping with transforming {@link ExternalAPIModule}
|
||||
* events into listener methods. Used with descendants of {@link BaseReactView}.
|
||||
*/
|
||||
@Deprecated
|
||||
public final class ListenerUtils {
|
||||
/**
|
||||
* Extracts the methods defined in a listener and creates a mapping of this
|
||||
|
||||
@@ -23,13 +23,13 @@ import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import org.jitsi.meet.sdk.log.JitsiMeetLogger;
|
||||
|
||||
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
|
||||
@@ -43,7 +43,6 @@ class OngoingNotification {
|
||||
|
||||
static final int NOTIFICATION_ID = new Random().nextInt(99999) + 10000;
|
||||
|
||||
|
||||
static void createOngoingConferenceNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
@@ -56,7 +55,7 @@ class OngoingNotification {
|
||||
}
|
||||
|
||||
NotificationManager notificationManager
|
||||
= (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
= (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
NotificationChannel channel
|
||||
= notificationManager.getNotificationChannel(CHANNEL_ID);
|
||||
@@ -73,7 +72,7 @@ class OngoingNotification {
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
static Notification buildOngoingConferenceNotification() {
|
||||
static Notification buildOngoingConferenceNotification(boolean isMuted) {
|
||||
Context context = ReactInstanceManagerHolder.getCurrentActivity();
|
||||
if (context == null) {
|
||||
JitsiMeetLogger.w(TAG + " Cannot create notification: no current context");
|
||||
@@ -83,12 +82,7 @@ class OngoingNotification {
|
||||
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);
|
||||
}
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID);
|
||||
|
||||
builder
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
@@ -99,21 +93,27 @@ class OngoingNotification {
|
||||
.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);
|
||||
NotificationCompat.Action hangupAction = createAction(context, JitsiMeetOngoingConferenceService.Action.HANGUP, "Hang up");
|
||||
|
||||
builder.addAction(hangupAction);
|
||||
}
|
||||
JitsiMeetOngoingConferenceService.Action toggleAudioAction = isMuted
|
||||
? JitsiMeetOngoingConferenceService.Action.UNMUTE : JitsiMeetOngoingConferenceService.Action.MUTE;
|
||||
String toggleAudioTitle = isMuted ? "Unmute" : "Mute";
|
||||
NotificationCompat.Action audioAction = createAction(context, toggleAudioAction, toggleAudioTitle);
|
||||
|
||||
builder.addAction(hangupAction);
|
||||
builder.addAction(audioAction);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static NotificationCompat.Action createAction(Context context, JitsiMeetOngoingConferenceService.Action action, String title) {
|
||||
Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class);
|
||||
intent.setAction(action.getName());
|
||||
PendingIntent pendingIntent
|
||||
= PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
return new NotificationCompat.Action(0, title, pendingIntent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2382,7 +2382,9 @@ export default {
|
||||
_onConferenceJoined() {
|
||||
APP.UI.initConference();
|
||||
|
||||
APP.keyboardshortcut.init();
|
||||
if (!config.disableShortcuts) {
|
||||
APP.keyboardshortcut.init();
|
||||
}
|
||||
|
||||
APP.store.dispatch(conferenceJoined(room));
|
||||
},
|
||||
|
||||
@@ -336,6 +336,9 @@ var config = {
|
||||
// will be joined when no room is specified.
|
||||
enableWelcomePage: true,
|
||||
|
||||
// Disable app shortcuts that are registered upon joining a conference
|
||||
// disableShortcuts: false,
|
||||
|
||||
// Disable initial browser getUserMedia requests.
|
||||
// This is useful for scenarios where users might want to start a conference for screensharing only
|
||||
// disableInitialGUM: false,
|
||||
|
||||
@@ -48,3 +48,19 @@
|
||||
.toolbox-button-wth-dialog .eYJELv {
|
||||
max-height: initial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override @atlaskit/InlineDialog styling for the overflowmenu so it displays
|
||||
* a scrollable list of elements at small screen widths.
|
||||
*/
|
||||
.sc-eNQAEJ {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep overflow menu within screen vertical bounds and make it scrollable.
|
||||
*/
|
||||
.toolbox-button-wth-dialog .sc-ckVGcZ.fdAqDG > :first-child {
|
||||
max-height: calc(100vh - #{$newToolbarSizeWithPadding} - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
124
css/_drawer.scss
Normal file
124
css/_drawer.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
.drawer-portal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: $drawerZ;
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
padding: 12px 16px;
|
||||
max-height: 50vh;
|
||||
background: #242528;
|
||||
border-radius: 16px 16px 0 0;
|
||||
overflow-y: auto;
|
||||
|
||||
&.expanded {
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.drawer-toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $overflowMenuItemHoverBG;
|
||||
}
|
||||
|
||||
svg, path {
|
||||
fill: #b8c7e0;
|
||||
}
|
||||
}
|
||||
|
||||
.popupmenu {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupmenu__item {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
&#{&} .overflow-menu {
|
||||
margin: auto;
|
||||
font-size: 1.2em;
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
|
||||
.overflow-menu-item {
|
||||
box-sizing: border-box;
|
||||
height: 48px;
|
||||
padding: 12px 16px;
|
||||
|
||||
align-items: center;
|
||||
color: $overflowMenuItemColor;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $overflowMenuItemHoverBG;
|
||||
color: $overflowMenuItemHoverColor;
|
||||
}
|
||||
|
||||
&.unclickable {
|
||||
cursor: default;
|
||||
}
|
||||
&.unclickable:hover {
|
||||
background: inherit;
|
||||
}
|
||||
&.disabled {
|
||||
cursor: initial;
|
||||
color: #3b475c;
|
||||
}
|
||||
}
|
||||
|
||||
.beta-tag {
|
||||
background: $overflowMenuItemColor;
|
||||
border-radius: 2px;
|
||||
color: $overflowMenuBG;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-left: 8px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.overflow-menu-item-icon {
|
||||
margin-right: 10px;
|
||||
|
||||
i {
|
||||
display: inline;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
i:hover {
|
||||
background-color: initial;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 24px;
|
||||
max-height: 24px;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: #B8C7E0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-text {
|
||||
max-width: 150px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ $poweredByZ: 100;
|
||||
$ringingZ: 300;
|
||||
$sideToolbarContainerZ: 300;
|
||||
$toolbarZ: 350;
|
||||
$drawerZ: 351;
|
||||
$tooltipsZ: 401;
|
||||
$dropdownMaskZ: 900;
|
||||
$dropdownZ: 901;
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
#remoteConnectionMessage {
|
||||
#remoteConnectionMessage,
|
||||
.watermark {
|
||||
z-index: $filmstripVideosZ + 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,5 +103,6 @@ $flagsImagePath: "../images/";
|
||||
@import 'e2ee';
|
||||
@import 'responsive';
|
||||
@import 'connection-status';
|
||||
@import 'drawer';
|
||||
|
||||
/* Modules END */
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dial-in-number {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.dial-in-numbers-list {
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -135,7 +135,6 @@
|
||||
.dial-in-copy {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 21px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,22 @@
|
||||
#if 0
|
||||
- (void)enterPictureInPicture:(NSDictionary *)data {
|
||||
[self _onJitsiMeetViewDelegateEvent:@"ENTER_PICTURE_IN_PICTURE" withData:data];
|
||||
|
||||
}
|
||||
#endif
|
||||
|
||||
- (void)participantJoined:(NSDictionary *)data {
|
||||
NSLog(@"%@%@", @"Participant joined: ", data[@"participantId"]);
|
||||
}
|
||||
|
||||
- (void)participantLeft:(NSDictionary *)data {
|
||||
NSLog(@"%@%@", @"Participant left: ", data[@"participantId"]);
|
||||
}
|
||||
|
||||
- (void)audioMutedChanged:(NSDictionary *)data {
|
||||
NSLog(@"%@%@", @"Audio muted changed: ", data[@"muted"]);
|
||||
}
|
||||
|
||||
#pragma mark - Helpers
|
||||
|
||||
- (void)terminate {
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
C69EFA0E209A0F660027712B /* JMCallKitListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69EFA0B209A0F660027712B /* JMCallKitListener.swift */; };
|
||||
C6A34261204EF76800E062DD /* DragGestureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A3425E204EF76800E062DD /* DragGestureController.swift */; };
|
||||
C6CC49AF207412CF000DFA42 /* PiPViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6CC49AE207412CF000DFA42 /* PiPViewCoordinator.swift */; };
|
||||
C81E9AB925AC5AD800B134D9 /* ExternalAPI.h in Headers */ = {isa = PBXBuildFile; fileRef = C81E9AB825AC5AD800B134D9 /* ExternalAPI.h */; };
|
||||
C8AFD27F2462C613000293D2 /* InfoPlistUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = C8AFD27D2462C613000293D2 /* InfoPlistUtil.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
C8AFD2802462C613000293D2 /* InfoPlistUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = C8AFD27E2462C613000293D2 /* InfoPlistUtil.m */; };
|
||||
DE438CDA2350934700DD541D /* JavaScriptSandbox.m in Sources */ = {isa = PBXBuildFile; fileRef = DE438CD82350934700DD541D /* JavaScriptSandbox.m */; };
|
||||
@@ -105,6 +106,7 @@
|
||||
C6A3425E204EF76800E062DD /* DragGestureController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DragGestureController.swift; sourceTree = "<group>"; };
|
||||
C6CC49AE207412CF000DFA42 /* PiPViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiPViewCoordinator.swift; sourceTree = "<group>"; };
|
||||
C6F99C13204DB63D0001F710 /* JitsiMeetView+Private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "JitsiMeetView+Private.h"; sourceTree = "<group>"; };
|
||||
C81E9AB825AC5AD800B134D9 /* ExternalAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExternalAPI.h; sourceTree = "<group>"; };
|
||||
C8AFD27D2462C613000293D2 /* InfoPlistUtil.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = InfoPlistUtil.h; sourceTree = "<group>"; };
|
||||
C8AFD27E2462C613000293D2 /* InfoPlistUtil.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InfoPlistUtil.m; sourceTree = "<group>"; };
|
||||
DE438CD82350934700DD541D /* JavaScriptSandbox.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = JavaScriptSandbox.m; sourceTree = "<group>"; };
|
||||
@@ -228,6 +230,7 @@
|
||||
0B93EF7D1EC9DDCD0030D24D /* RCTBridgeWrapper.m */,
|
||||
C8AFD27D2462C613000293D2 /* InfoPlistUtil.h */,
|
||||
C8AFD27E2462C613000293D2 /* InfoPlistUtil.m */,
|
||||
C81E9AB825AC5AD800B134D9 /* ExternalAPI.h */,
|
||||
);
|
||||
path = src;
|
||||
sourceTree = "<group>";
|
||||
@@ -301,6 +304,7 @@
|
||||
DE81A2D42316AC4D00AE1940 /* JitsiMeetLogger.h in Headers */,
|
||||
DE65AACA2317FFCD00290BEC /* LogUtils.h in Headers */,
|
||||
DEAD3226220C497000E93636 /* JitsiMeetConferenceOptions.h in Headers */,
|
||||
C81E9AB925AC5AD800B134D9 /* ExternalAPI.h in Headers */,
|
||||
C8AFD27F2462C613000293D2 /* InfoPlistUtil.h in Headers */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright @ 2017-present Atlassian Pty Ltd
|
||||
* Copyright @ 2017-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.
|
||||
|
||||
24
ios/sdk/src/ExternalAPI.h
Normal file
24
ios/sdk/src/ExternalAPI.h
Normal file
@@ -0,0 +1,24 @@
|
||||
/* Copyright @ 2021-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.
|
||||
*/
|
||||
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
|
||||
@interface ExternalAPI : RCTEventEmitter<RCTBridgeModule>
|
||||
|
||||
- (void)sendHangUp;
|
||||
- (void)sendSetAudioMuted: (BOOL)muted;
|
||||
|
||||
@end
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright @ 2017-present Atlassian Pty Ltd
|
||||
* Copyright @ 2017-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.
|
||||
@@ -14,17 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
#import "ExternalAPI.h"
|
||||
#import "JitsiMeetView+Private.h"
|
||||
|
||||
@interface ExternalAPI : NSObject<RCTBridgeModule>
|
||||
@end
|
||||
// Events
|
||||
static NSString * const hangUpEvent = @"org.jitsi.meet.HANG_UP";
|
||||
static NSString * const setAudioMutedEvent = @"org.jitsi.meet.SET_AUDIO_MUTED";
|
||||
|
||||
@implementation ExternalAPI
|
||||
|
||||
RCT_EXPORT_MODULE();
|
||||
|
||||
- (NSDictionary *)constantsToExport {
|
||||
return @{
|
||||
@"HANG_UP": hangUpEvent,
|
||||
@"SET_AUDIO_MUTED" : setAudioMutedEvent
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure all methods in this module are invoked on the main/UI thread.
|
||||
*/
|
||||
@@ -32,6 +39,14 @@ RCT_EXPORT_MODULE();
|
||||
return dispatch_get_main_queue();
|
||||
}
|
||||
|
||||
+ (BOOL)requiresMainQueueSetup {
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)supportedEvents {
|
||||
return @[ hangUpEvent, setAudioMutedEvent ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an event that occurred on JavaScript to the view's delegate.
|
||||
*
|
||||
@@ -87,4 +102,14 @@ RCT_EXPORT_METHOD(sendEvent:(NSString *)name
|
||||
return methodName;
|
||||
}
|
||||
|
||||
- (void)sendHangUp {
|
||||
[self sendEventWithName:hangUpEvent body:nil];
|
||||
}
|
||||
|
||||
- (void)sendSetAudioMuted: (BOOL)muted {
|
||||
NSDictionary *data = @{ @"muted": [NSNumber numberWithBool:muted]};
|
||||
|
||||
[self sendEventWithName:setAudioMutedEvent body:data];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -37,4 +37,8 @@
|
||||
*/
|
||||
- (void)leave;
|
||||
|
||||
- (void)hangUp;
|
||||
|
||||
- (void)setAudioMuted:(BOOL)muted;
|
||||
|
||||
@end
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
#include <mach/mach_time.h>
|
||||
|
||||
#import "ExternalAPI.h"
|
||||
#import "JitsiMeet+Private.h"
|
||||
#import "JitsiMeetConferenceOptions+Private.h"
|
||||
#import "JitsiMeetView+Private.h"
|
||||
@@ -49,7 +50,6 @@ static NSString *const PiPEnabledFeatureFlag = @"pip.enabled";
|
||||
* identifiers within the process).
|
||||
*/
|
||||
static NSMapTable<NSString *, JitsiMeetView *> *views;
|
||||
|
||||
/**
|
||||
* This gets called automagically when the program starts.
|
||||
*/
|
||||
@@ -115,6 +115,16 @@ static void initializeViewsMap() {
|
||||
[self setProps:@{}];
|
||||
}
|
||||
|
||||
- (void)hangUp {
|
||||
RCTBridge *bridge = [[JitsiMeet sharedInstance] getReactBridge];
|
||||
[[bridge moduleForClass:ExternalAPI.class] sendHangUp];
|
||||
}
|
||||
|
||||
- (void)setAudioMuted:(BOOL)muted {
|
||||
RCTBridge *bridge = [[JitsiMeet sharedInstance] getReactBridge];
|
||||
[[bridge moduleForClass:ExternalAPI.class] sendSetAudioMuted:muted];
|
||||
}
|
||||
|
||||
#pragma mark Private methods
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright @ 2017-present Atlassian Pty Ltd
|
||||
* Copyright @ 2017-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.
|
||||
@@ -55,4 +55,24 @@
|
||||
*/
|
||||
- (void)enterPictureInPicture:(NSDictionary *)data;
|
||||
|
||||
/**
|
||||
* Called when a participant has joined the conference.
|
||||
*
|
||||
* The `data` dictionary contains a `participantId` key with the id of the participant that has joined.
|
||||
*/
|
||||
- (void)participantJoined:(NSDictionary *)data;
|
||||
|
||||
/**
|
||||
* Called when a participant has left the conference.
|
||||
*
|
||||
* The `data` dictionary contains a `participantId` key with the id of the participant that has left.
|
||||
*/
|
||||
- (void)participantLeft:(NSDictionary *)data;
|
||||
|
||||
/**
|
||||
* Called when audioMuted state changed.
|
||||
*
|
||||
* The `data` dictionary contains a `muted` key with state of the audioMuted for the localParticipant.
|
||||
*/
|
||||
- (void)audioMutedChanged:(NSDictionary *)data;
|
||||
@end
|
||||
|
||||
@@ -592,8 +592,8 @@ class API {
|
||||
* @returns {void}
|
||||
*/
|
||||
notifyReceivedChatMessage(
|
||||
{ body, id, nick, ts }: {
|
||||
body: *, id: string, nick: string, ts: *
|
||||
{ body, id, nick, privateMessage, ts }: {
|
||||
body: *, id: string, nick: string, privateMessage: boolean, ts: *
|
||||
} = {}) {
|
||||
if (APP.conference.isLocalId(id)) {
|
||||
return;
|
||||
@@ -604,6 +604,7 @@ class API {
|
||||
from: id,
|
||||
message: body,
|
||||
nick,
|
||||
privateMessage,
|
||||
stamp: ts
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ export default [
|
||||
'disableRemoteControl',
|
||||
'disableRemoteMute',
|
||||
'disableRtx',
|
||||
'disableShortcuts',
|
||||
'disableSimulcast',
|
||||
'disableThirdPartyRequests',
|
||||
'disableTileView',
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import InlineDialog from '@atlaskit/inline-dialog';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Drawer, DrawerPortal } from '../../../toolbox/components/web';
|
||||
|
||||
/**
|
||||
* A map of dialog positions, relative to trigger, to css classes used to
|
||||
* manipulate elements for handling mouse events.
|
||||
@@ -66,6 +68,11 @@ type Props = {
|
||||
*/
|
||||
onPopoverOpen: Function,
|
||||
|
||||
/**
|
||||
* Whether to display the Popover as a drawer.
|
||||
*/
|
||||
overflowDrawer: boolean,
|
||||
|
||||
/**
|
||||
* From which side of the dialog trigger the dialog should display. The
|
||||
* value will be passed to {@code InlineDialog}.
|
||||
@@ -101,6 +108,11 @@ class Popover extends Component<Props, State> {
|
||||
id: ''
|
||||
};
|
||||
|
||||
/**
|
||||
* Reference to the Popover that is meant to open as a drawer.
|
||||
*/
|
||||
_drawerContainerRef: Object;
|
||||
|
||||
/**
|
||||
* Initializes a new {@code Popover} instance.
|
||||
*
|
||||
@@ -117,6 +129,51 @@ class Popover extends Component<Props, State> {
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._onHideDialog = this._onHideDialog.bind(this);
|
||||
this._onShowDialog = this._onShowDialog.bind(this);
|
||||
this._drawerContainerRef = React.createRef();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up an event listener to open a drawer when clicking, rather than entering the
|
||||
* overflow area.
|
||||
*
|
||||
* TODO: This should be done by setting an {@code onClick} handler on the div, but for some
|
||||
* reason that doesn't seem to work whatsoever.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentDidMount() {
|
||||
if (this._drawerContainerRef && this._drawerContainerRef.current) {
|
||||
this._drawerContainerRef.current.addEventListener('click', this._onShowDialog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the listener set up in the {@code componentDidMount} method.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
if (this._drawerContainerRef && this._drawerContainerRef.current) {
|
||||
this._drawerContainerRef.current.removeEventListener('click', this._onShowDialog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React Component's componentDidUpdate.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.overflowDrawer !== this.props.overflowDrawer) {
|
||||
// Make sure the listeners are set up when resizing the screen past the drawer threshold.
|
||||
if (this.props.overflowDrawer) {
|
||||
this.componentDidMount();
|
||||
} else {
|
||||
this.componentWillUnmount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,17 +183,37 @@ class Popover extends Component<Props, State> {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { children, className, content, id, overflowDrawer, position } = this.props;
|
||||
|
||||
if (overflowDrawer) {
|
||||
return (
|
||||
<div
|
||||
className = { className }
|
||||
id = { id }
|
||||
ref = { this._drawerContainerRef }>
|
||||
{ children }
|
||||
<DrawerPortal>
|
||||
<Drawer
|
||||
isOpen = { this.state.showDialog }
|
||||
onClose = { this._onHideDialog }>
|
||||
{ content }
|
||||
</Drawer>
|
||||
</DrawerPortal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { this.props.className }
|
||||
id = { this.props.id }
|
||||
className = { className }
|
||||
id = { id }
|
||||
onMouseEnter = { this._onShowDialog }
|
||||
onMouseLeave = { this._onHideDialog }>
|
||||
<InlineDialog
|
||||
content = { this._renderContent() }
|
||||
isOpen = { this.state.showDialog }
|
||||
position = { this.props.position }>
|
||||
{ this.props.children }
|
||||
position = { position }>
|
||||
{ children }
|
||||
</InlineDialog>
|
||||
</div>
|
||||
);
|
||||
@@ -160,10 +237,12 @@ class Popover extends Component<Props, State> {
|
||||
* Displays the {@code InlineDialog} and calls any registered onPopoverOpen
|
||||
* callbacks.
|
||||
*
|
||||
* @param {MouseEvent} event - The mouse event to intercept.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onShowDialog() {
|
||||
_onShowDialog(event) {
|
||||
event.stopPropagation();
|
||||
if (!this.props.disablePopover) {
|
||||
this.setState({ showDialog: true });
|
||||
|
||||
|
||||
@@ -4,12 +4,10 @@ import { jitsiLocalStorage } from '@jitsi/js-utils';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { APP_WILL_MOUNT } from '../app/actionTypes';
|
||||
import { browser } from '../lib-jitsi-meet';
|
||||
import { PersistenceRegistry, ReducerRegistry } from '../redux';
|
||||
import { assignIfDefined } from '../util';
|
||||
|
||||
import { SETTINGS_UPDATED } from './actionTypes';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* The default/initial redux state of the feature {@code base/settings}.
|
||||
@@ -75,41 +73,10 @@ ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => {
|
||||
return state;
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieves the legacy profile values regardless of it's being in pre or
|
||||
* post-flattening format.
|
||||
*
|
||||
* FIXME: Let's remove this after a predefined time (e.g. By July 2018) to avoid
|
||||
* garbage in the source.
|
||||
*
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _getLegacyProfile() {
|
||||
let persistedProfile = jitsiLocalStorage.getItem('features/base/profile');
|
||||
|
||||
if (persistedProfile) {
|
||||
try {
|
||||
persistedProfile = JSON.parse(persistedProfile);
|
||||
|
||||
if (persistedProfile && typeof persistedProfile === 'object') {
|
||||
const preFlattenedProfile = persistedProfile.profile;
|
||||
|
||||
return preFlattenedProfile || persistedProfile;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Error parsing persisted legacy profile', e);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inits the settings object based on what information we have available.
|
||||
* Info taken into consideration:
|
||||
* - Old Settings.js style data
|
||||
* - Things that we stored in profile earlier but belong here.
|
||||
* - Old Settings.js style data.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} featureState - The current state of the feature.
|
||||
@@ -138,28 +105,5 @@ function _initSettings(featureState) {
|
||||
email
|
||||
}, settings);
|
||||
|
||||
if (!browser.isReactNative()) {
|
||||
// Browser only
|
||||
const localFlipX = JSON.parse(jitsiLocalStorage.getItem('localFlipX') || 'true');
|
||||
const cameraDeviceId = jitsiLocalStorage.getItem('cameraDeviceId') || '';
|
||||
const micDeviceId = jitsiLocalStorage.getItem('micDeviceId') || '';
|
||||
|
||||
// Currently audio output device change is supported only in Chrome and
|
||||
// default output always has 'default' device ID
|
||||
const audioOutputDeviceId = jitsiLocalStorage.getItem('audioOutputDeviceId') || 'default';
|
||||
|
||||
settings = assignIfDefined({
|
||||
audioOutputDeviceId,
|
||||
cameraDeviceId,
|
||||
localFlipX,
|
||||
micDeviceId
|
||||
}, settings);
|
||||
}
|
||||
|
||||
// Things we stored in profile earlier
|
||||
const legacyProfile = _getLegacyProfile();
|
||||
|
||||
settings = assignIfDefined(legacyProfile, settings);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default class AbstractAudioMuteButton<P: Props, S: *>
|
||||
* Helper function to perform the actual setting of the audio mute / unmute
|
||||
* action.
|
||||
*
|
||||
* @param {boolean} audioMuted - Whether video should be muted or not.
|
||||
* @param {boolean} audioMuted - Whether audio should be muted or not.
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
@@ -247,6 +247,7 @@ function _handleReceivedMessage({ dispatch, getState }, { id, message, privateMe
|
||||
body: message,
|
||||
id,
|
||||
nick: displayName,
|
||||
privateMessage,
|
||||
ts: timestamp
|
||||
});
|
||||
|
||||
|
||||
@@ -9,3 +9,8 @@ export const FILMSTRIP_SIZE = 90;
|
||||
* The aspect ratio of a tile in tile view.
|
||||
*/
|
||||
export const TILE_ASPECT_RATIO = 16 / 9;
|
||||
|
||||
/**
|
||||
* Width below which the overflow menu(s) will be displayed as drawer(s).
|
||||
*/
|
||||
export const DISPLAY_DRAWER_THRESHOLD = 512;
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import Filmstrip from '../../../modules/UI/videolayout/Filmstrip';
|
||||
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
|
||||
import { StateListenerRegistry, equals } from '../base/redux';
|
||||
import { setOverflowDrawer } from '../toolbox/actions.web';
|
||||
import { getCurrentLayout, getTileViewGridDimensions, shouldDisplayTileView, LAYOUTS } from '../video-layout';
|
||||
|
||||
import { setHorizontalViewDimensions, setTileViewDimensions } from './actions.web';
|
||||
import { DISPLAY_DRAWER_THRESHOLD } from './constants';
|
||||
|
||||
/**
|
||||
* Listens for changes in the number of participants to calculate the dimensions of the tile view grid and the tiles.
|
||||
@@ -123,3 +125,12 @@ StateListenerRegistry.register(
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Listens for changes in the client width to determine whether the overflow menu(s) should be displayed as drawers.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/responsive-ui'].clientWidth < DISPLAY_DRAWER_THRESHOLD,
|
||||
/* listener */ (widthBelowThreshold, store) => {
|
||||
store.dispatch(setOverflowDrawer(widthBelowThreshold));
|
||||
});
|
||||
|
||||
@@ -79,25 +79,27 @@ class DialInNumber extends Component<Props> {
|
||||
|
||||
return (
|
||||
<div className = 'dial-in-number'>
|
||||
<span className = 'phone-number'>
|
||||
<span className = 'info-label'>
|
||||
{ t('info.dialInNumber') }
|
||||
<div>
|
||||
<span className = 'phone-number'>
|
||||
<span className = 'info-label'>
|
||||
{ t('info.dialInNumber') }
|
||||
</span>
|
||||
<span className = 'spacer'> </span>
|
||||
<span className = 'info-value'>
|
||||
{ phoneNumber }
|
||||
</span>
|
||||
</span>
|
||||
<span className = 'spacer'> </span>
|
||||
<span className = 'info-value'>
|
||||
{ phoneNumber }
|
||||
<span className = 'conference-id'>
|
||||
<span className = 'info-label'>
|
||||
{ t('info.dialInConferenceID') }
|
||||
</span>
|
||||
<span className = 'spacer'> </span>
|
||||
<span className = 'info-value'>
|
||||
{ `${_formatConferenceIDPin(conferenceID)}#` }
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className = 'spacer'> </span>
|
||||
<span className = 'conference-id'>
|
||||
<span className = 'info-label'>
|
||||
{ t('info.dialInConferenceID') }
|
||||
</span>
|
||||
<span className = 'spacer'> </span>
|
||||
<span className = 'info-value'>
|
||||
{ `${_formatConferenceIDPin(conferenceID)}#` }
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
className = 'dial-in-copy'
|
||||
onClick = { this._onCopyText }>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { i18next } from '../base/i18n';
|
||||
import { isLocalParticipantModerator } from '../base/participants';
|
||||
import { toState } from '../base/redux';
|
||||
import { doGetJSON, parseURIString } from '../base/util';
|
||||
import { isVpaasMeeting } from '../billing-counter/functions';
|
||||
|
||||
import logger from './logger';
|
||||
|
||||
@@ -352,7 +353,7 @@ export function invitePeopleAndChatRooms( // eslint-disable-line max-params
|
||||
export function isAddPeopleEnabled(state: Object): boolean {
|
||||
const { peopleSearchUrl } = state['features/base/config'];
|
||||
|
||||
return state['features/base/jwt'].jwt && Boolean(peopleSearchUrl);
|
||||
return state['features/base/jwt'].jwt && Boolean(peopleSearchUrl) && !isVpaasMeeting(state);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// @flow
|
||||
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
|
||||
import { appNavigate } from '../../app/actions';
|
||||
import { APP_WILL_MOUNT } from '../../base/app/actionTypes';
|
||||
import {
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_JOINED,
|
||||
@@ -18,7 +22,10 @@ import {
|
||||
JITSI_CONNECTION_URL_KEY,
|
||||
getURLWithoutParams
|
||||
} from '../../base/connection';
|
||||
import { SET_AUDIO_MUTED } from '../../base/media/actionTypes';
|
||||
import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../../base/participants';
|
||||
import { MiddlewareRegistry } from '../../base/redux';
|
||||
import { muteLocal } from '../../remote-video-menu/actions';
|
||||
import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture';
|
||||
|
||||
import { sendEvent } from './functions';
|
||||
@@ -29,6 +36,9 @@ import { sendEvent } from './functions';
|
||||
*/
|
||||
const CONFERENCE_TERMINATED = 'CONFERENCE_TERMINATED';
|
||||
|
||||
const { ExternalAPI } = NativeModules;
|
||||
const eventEmitter = new NativeEventEmitter(ExternalAPI);
|
||||
|
||||
/**
|
||||
* Middleware that captures Redux actions and uses the ExternalAPI module to
|
||||
* turn them into native events so the application knows about them.
|
||||
@@ -41,6 +51,9 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
const { type } = action;
|
||||
|
||||
switch (type) {
|
||||
case APP_WILL_MOUNT:
|
||||
_registerForNativeEvents(store.dispatch);
|
||||
break;
|
||||
case CONFERENCE_FAILED: {
|
||||
const { error, ...data } = action;
|
||||
|
||||
@@ -111,14 +124,56 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
break;
|
||||
}
|
||||
|
||||
case PARTICIPANT_JOINED:
|
||||
case PARTICIPANT_LEFT: {
|
||||
const { participant } = action;
|
||||
|
||||
sendEvent(
|
||||
store,
|
||||
action.type,
|
||||
/* data */ {
|
||||
isLocal: participant.local,
|
||||
email: participant.email,
|
||||
name: participant.name,
|
||||
participantId: participant.id
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case SET_ROOM:
|
||||
_maybeTriggerEarlyConferenceWillJoin(store, action);
|
||||
break;
|
||||
|
||||
case SET_AUDIO_MUTED:
|
||||
sendEvent(
|
||||
store,
|
||||
'AUDIO_MUTED_CHANGED',
|
||||
/* data */ {
|
||||
muted: action.muted
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Registers for events sent from the native side via NativeEventEmitter.
|
||||
*
|
||||
* @param {Dispatch} dispatch - The Redux dispatch function.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _registerForNativeEvents(dispatch) {
|
||||
eventEmitter.addListener(ExternalAPI.HANG_UP, () => {
|
||||
dispatch(appNavigate(undefined));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.SET_AUDIO_MUTED, ({ muted }) => {
|
||||
dispatch(muteLocal(muted === 'true'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@code String} representation of a specific error {@code Object}.
|
||||
*
|
||||
|
||||
@@ -60,6 +60,11 @@ type Props = {
|
||||
*/
|
||||
_menuPosition: string,
|
||||
|
||||
/**
|
||||
* Whether to display the Popover as a drawer.
|
||||
*/
|
||||
_overflowDrawer: boolean,
|
||||
|
||||
/**
|
||||
* The current state of the participant's remote control session.
|
||||
*/
|
||||
@@ -122,6 +127,7 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
|
||||
return (
|
||||
<Popover
|
||||
content = { content }
|
||||
overflowDrawer = { this.props._overflowDrawer }
|
||||
position = { this.props._menuPosition }>
|
||||
<span
|
||||
className = 'popover-trigger remote-video-menu-trigger'>
|
||||
@@ -237,14 +243,7 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - The own props of the component.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _isAudioMuted: boolean,
|
||||
* _isModerator: boolean,
|
||||
* _disableKick: boolean,
|
||||
* _disableRemoteMute: boolean,
|
||||
* _menuPosition: string,
|
||||
* _remoteControlState: number
|
||||
* }}
|
||||
* @returns {Props}
|
||||
*/
|
||||
function _mapStateToProps(state, ownProps) {
|
||||
const { participantID } = ownProps;
|
||||
@@ -259,6 +258,7 @@ function _mapStateToProps(state, ownProps) {
|
||||
const { active, controller } = state['features/remote-control'];
|
||||
const { requestedParticipant, controlled } = controller;
|
||||
const activeParticipant = requestedParticipant || controlled;
|
||||
const { overflowDrawer } = state['features/toolbox'];
|
||||
|
||||
if (_supportsRemoteControl
|
||||
&& ((!active && !_isRemoteControlSessionActive) || activeParticipant === participantID)) {
|
||||
@@ -291,7 +291,8 @@ function _mapStateToProps(state, ownProps) {
|
||||
_disableKick: Boolean(disableKick),
|
||||
_disableRemoteMute: Boolean(disableRemoteMute),
|
||||
_remoteControlState,
|
||||
_menuPosition
|
||||
_menuPosition,
|
||||
_overflowDrawer: overflowDrawer
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ export const FULL_SCREEN_CHANGED = 'FULL_SCREEN_CHANGED';
|
||||
*/
|
||||
export const SET_FULL_SCREEN = 'SET_FULL_SCREEN';
|
||||
|
||||
/**
|
||||
* The type of the redux action that toggles whether the overflow menu(s) should be shown as drawers.
|
||||
*/
|
||||
export const SET_OVERFLOW_DRAWER = 'SET_OVERFLOW_DRAWER';
|
||||
|
||||
/**
|
||||
* The type of the (redux) action which shows/hides the OverflowMenu.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { Dispatch } from 'redux';
|
||||
|
||||
import {
|
||||
FULL_SCREEN_CHANGED,
|
||||
SET_FULL_SCREEN
|
||||
SET_FULL_SCREEN,
|
||||
SET_OVERFLOW_DRAWER
|
||||
} from './actionTypes';
|
||||
import {
|
||||
clearToolboxTimeout,
|
||||
@@ -143,3 +144,19 @@ export function showToolbox(timeout: number = 0): Object {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals a request to display overflow as drawer.
|
||||
*
|
||||
* @param {boolean} displayAsDrawer - True to display overflow as drawer, false to preserve original behaviour.
|
||||
* @returns {{
|
||||
* type: SET_OVERFLOW_DRAWER,
|
||||
* displayAsDrawer: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setOverflowDrawer(displayAsDrawer: boolean) {
|
||||
return {
|
||||
type: SET_OVERFLOW_DRAWER,
|
||||
displayAsDrawer
|
||||
};
|
||||
}
|
||||
|
||||
90
react/features/toolbox/components/web/Drawer.js
Normal file
90
react/features/toolbox/components/web/Drawer.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// @flow
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon, IconArrowUp, IconArrowDown } from '../../../base/icons';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Whether the drawer should have a button that expands its size or not.
|
||||
*/
|
||||
canExpand: ?boolean,
|
||||
|
||||
/**
|
||||
* The component(s) to be displayed within the drawer menu.
|
||||
*/
|
||||
children: React$Node,
|
||||
|
||||
/**
|
||||
Whether the drawer should be shown or not.
|
||||
*/
|
||||
isOpen: boolean,
|
||||
|
||||
/**
|
||||
Function that hides the drawer.
|
||||
*/
|
||||
onClose: Function
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that displays the mobile friendly drawer on web.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function Drawer({
|
||||
canExpand,
|
||||
children,
|
||||
isOpen,
|
||||
onClose }: Props) {
|
||||
const [ expanded, setExpanded ] = useState(false);
|
||||
const drawerRef: Object = useRef(null);
|
||||
|
||||
/**
|
||||
* Closes the drawer when clicking outside of it.
|
||||
*
|
||||
* @param {Event} event - Mouse down event object.
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (drawerRef.current && !drawerRef.current.contains(event.target)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousedown', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}, [ drawerRef ]);
|
||||
|
||||
/**
|
||||
* Toggles the menu state between expanded/collapsed.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
|
||||
return (
|
||||
isOpen ? (
|
||||
<div
|
||||
className = { `drawer-menu${expanded ? ' expanded' : ''}` }
|
||||
ref = { drawerRef }>
|
||||
{canExpand && (
|
||||
<div
|
||||
className = 'drawer-toggle'
|
||||
onClick = { toggleExpanded }>
|
||||
<Icon src = { expanded ? IconArrowDown : IconArrowUp } />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
export default Drawer;
|
||||
47
react/features/toolbox/components/web/DrawerPortal.js
Normal file
47
react/features/toolbox/components/web/DrawerPortal.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// @flow
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* The component(s) to be displayed within the drawer portal.
|
||||
*/
|
||||
children: React$Node
|
||||
};
|
||||
|
||||
/**
|
||||
* Component meant to render a drawer at the bottom of the screen,
|
||||
* by creating a portal containing the component's children.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function DrawerPortal({ children }: Props) {
|
||||
const [ portalTarget ] = useState(() => {
|
||||
const portalDiv = document.createElement('div');
|
||||
|
||||
portalDiv.className = 'drawer-portal';
|
||||
|
||||
return portalDiv;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (document.body) {
|
||||
document.body.appendChild(portalTarget);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (document.body) {
|
||||
document.body.removeChild(portalTarget);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
children,
|
||||
portalTarget
|
||||
);
|
||||
}
|
||||
|
||||
export default DrawerPortal;
|
||||
@@ -6,7 +6,10 @@ import React, { Component } from 'react';
|
||||
import { createToolbarEvent, sendAnalytics } from '../../../analytics';
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { IconMenuThumb } from '../../../base/icons';
|
||||
import { connect } from '../../../base/redux';
|
||||
|
||||
import Drawer from './Drawer';
|
||||
import DrawerPortal from './DrawerPortal';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
|
||||
/**
|
||||
@@ -29,6 +32,11 @@ type Props = {
|
||||
*/
|
||||
onVisibilityChange: Function,
|
||||
|
||||
/**
|
||||
* Whether to display the OverflowMenu as a drawer.
|
||||
*/
|
||||
overflowDrawer: boolean,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
@@ -63,27 +71,58 @@ class OverflowMenuButton extends Component<Props> {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { children, isOpen, t } = this.props;
|
||||
const { children, isOpen, overflowDrawer } = this.props;
|
||||
|
||||
return (
|
||||
<div className = 'toolbox-button-wth-dialog'>
|
||||
<InlineDialog
|
||||
content = { children }
|
||||
isOpen = { isOpen }
|
||||
onClose = { this._onCloseDialog }
|
||||
position = { 'top right' }>
|
||||
<ToolbarButton
|
||||
accessibilityLabel =
|
||||
{ t('toolbar.accessibilityLabel.moreActions') }
|
||||
icon = { IconMenuThumb }
|
||||
onClick = { this._onToggleDialogVisibility }
|
||||
toggled = { isOpen }
|
||||
tooltip = { t('toolbar.moreActions') } />
|
||||
</InlineDialog>
|
||||
{
|
||||
overflowDrawer ? (
|
||||
<>
|
||||
{this._renderToolbarButton()}
|
||||
<DrawerPortal>
|
||||
<Drawer
|
||||
canExpand = { true }
|
||||
isOpen = { isOpen }
|
||||
onClose = { this._onCloseDialog }>
|
||||
{children}
|
||||
</Drawer>
|
||||
</DrawerPortal>
|
||||
</>
|
||||
) : (
|
||||
<InlineDialog
|
||||
content = { children }
|
||||
isOpen = { isOpen }
|
||||
onClose = { this._onCloseDialog }
|
||||
position = { 'top right' }>
|
||||
{this._renderToolbarButton()}
|
||||
</InlineDialog>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_renderToolbarButton: () => React$Node;
|
||||
|
||||
/**
|
||||
* Renders the actual toolbar overflow menu button.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderToolbarButton() {
|
||||
const { isOpen, t } = this.props;
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
accessibilityLabel =
|
||||
{ t('toolbar.accessibilityLabel.moreActions') }
|
||||
icon = { IconMenuThumb }
|
||||
onClick = { this._onToggleDialogVisibility }
|
||||
toggled = { isOpen }
|
||||
tooltip = { t('toolbar.moreActions') } />
|
||||
);
|
||||
}
|
||||
|
||||
_onCloseDialog: () => void;
|
||||
|
||||
/**
|
||||
@@ -113,4 +152,19 @@ class OverflowMenuButton extends Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(OverflowMenuButton);
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code OverflowMenuButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {Props}
|
||||
*/
|
||||
function mapStateToProps(state) {
|
||||
const { overflowDrawer } = state['features/toolbox'];
|
||||
|
||||
return {
|
||||
overflowDrawer
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(OverflowMenuButton));
|
||||
|
||||
@@ -1030,12 +1030,14 @@ class Toolbox extends Component<Props, State> {
|
||||
key = 'fullscreen'
|
||||
onClick = { this._onToolbarToggleFullScreen }
|
||||
text = { _fullScreen ? t('toolbar.exitFullScreen') : t('toolbar.enterFullScreen') } />,
|
||||
<LiveStreamButton
|
||||
key = 'livestreaming'
|
||||
showLabel = { true } />,
|
||||
<RecordButton
|
||||
key = 'record'
|
||||
showLabel = { true } />,
|
||||
this._shouldShowButton('livestreaming')
|
||||
&& <LiveStreamButton
|
||||
key = 'livestreaming'
|
||||
showLabel = { true } />,
|
||||
this._shouldShowButton('recording')
|
||||
&& <RecordButton
|
||||
key = 'record'
|
||||
showLabel = { true } />,
|
||||
this._shouldShowButton('sharedvideo')
|
||||
&& <OverflowMenuItem
|
||||
accessibilityLabel = { t('toolbar.accessibilityLabel.sharedvideo') }
|
||||
@@ -1047,18 +1049,19 @@ class Toolbox extends Component<Props, State> {
|
||||
&& <SharedDocumentButton
|
||||
key = 'etherpad'
|
||||
showLabel = { true } />,
|
||||
<VideoBlurButton
|
||||
key = 'videobackgroundblur'
|
||||
showLabel = { true }
|
||||
visible = { this._shouldShowButton('videobackgroundblur') && !_screensharing } />,
|
||||
<SettingsButton
|
||||
key = 'settings'
|
||||
showLabel = { true }
|
||||
visible = { this._shouldShowButton('settings') } />,
|
||||
<MuteEveryoneButton
|
||||
key = 'mute-everyone'
|
||||
showLabel = { true }
|
||||
visible = { this._shouldShowButton('mute-everyone') } />,
|
||||
this._shouldShowButton('videobackgroundblur')
|
||||
&& <VideoBlurButton
|
||||
key = 'videobackgroundblur'
|
||||
showLabel = { true }
|
||||
visible = { !_screensharing } />,
|
||||
this._shouldShowButton('settings')
|
||||
&& <SettingsButton
|
||||
key = 'settings'
|
||||
showLabel = { true } />,
|
||||
this._shouldShowButton('mute-everyone')
|
||||
&& <MuteEveryoneButton
|
||||
key = 'mute-everyone'
|
||||
showLabel = { true } />,
|
||||
this._shouldShowButton('stats')
|
||||
&& <OverflowMenuItem
|
||||
accessibilityLabel = { t('toolbar.accessibilityLabel.speakerStats') }
|
||||
@@ -1224,7 +1227,7 @@ class Toolbox extends Component<Props, State> {
|
||||
t
|
||||
} = this.props;
|
||||
const overflowMenuContent = this._renderOverflowMenuContent();
|
||||
const overflowHasItems = Boolean(overflowMenuContent.length);
|
||||
const overflowHasItems = overflowMenuContent.some(item => Boolean(item));
|
||||
const toolbarAccLabel = 'toolbar.accessibilityLabel.moreActionsMenu';
|
||||
const buttonsLeft = [];
|
||||
const buttonsRight = [];
|
||||
|
||||
@@ -2,3 +2,5 @@ export { default as AudioSettingsButton } from './AudioSettingsButton';
|
||||
export { default as VideoSettingsButton } from './VideoSettingsButton';
|
||||
export { default as ToolbarButton } from './ToolbarButton';
|
||||
export { default as Toolbox } from './Toolbox';
|
||||
export { default as Drawer } from './Drawer';
|
||||
export { default as DrawerPortal } from './DrawerPortal';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ReducerRegistry, set } from '../base/redux';
|
||||
import {
|
||||
CLEAR_TOOLBOX_TIMEOUT,
|
||||
FULL_SCREEN_CHANGED,
|
||||
SET_OVERFLOW_DRAWER,
|
||||
SET_OVERFLOW_MENU_VISIBLE,
|
||||
SET_TOOLBAR_HOVERED,
|
||||
SET_TOOLBOX_ALWAYS_VISIBLE,
|
||||
@@ -25,6 +26,7 @@ declare var interfaceConfig: Object;
|
||||
* alwaysVisible: boolean,
|
||||
* enabled: boolean,
|
||||
* hovered: boolean,
|
||||
* overflowDrawer: boolean,
|
||||
* overflowMenuVisible: boolean,
|
||||
* timeoutID: number,
|
||||
* timeoutMS: number,
|
||||
@@ -79,6 +81,13 @@ function _getInitialState() {
|
||||
*/
|
||||
hovered: false,
|
||||
|
||||
/**
|
||||
* The indicator which determines whether the overflow menu(s) are to be displayed as drawers.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
overflowDrawer: false,
|
||||
|
||||
/**
|
||||
* The indicator which determines whether the OverflowMenu is visible.
|
||||
*
|
||||
@@ -103,7 +112,7 @@ function _getInitialState() {
|
||||
timeoutMS,
|
||||
|
||||
/**
|
||||
* The indicator which determines whether the Toolbox is visible.
|
||||
* The indicator that determines whether the Toolbox is visible.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
@@ -127,6 +136,12 @@ ReducerRegistry.register(
|
||||
fullScreen: action.fullScreen
|
||||
};
|
||||
|
||||
case SET_OVERFLOW_DRAWER:
|
||||
return {
|
||||
...state,
|
||||
overflowDrawer: action.displayAsDrawer
|
||||
};
|
||||
|
||||
case SET_OVERFLOW_MENU_VISIBLE:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<!--#include virtual="head.html" -->
|
||||
<!--#include virtual="/head.html" -->
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="content-type" content="text/html;charset=utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!--#include virtual="base.html" -->
|
||||
<!--#include virtual="/base.html" -->
|
||||
|
||||
<link rel="apple-touch-icon" href="images/apple-touch-icon.png">
|
||||
<!--#include virtual="title.html" -->
|
||||
<!--#include virtual="/title.html" -->
|
||||
</head>
|
||||
<style>
|
||||
body,
|
||||
@@ -61,4 +61,4 @@
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user