Compare commits
33 Commits
2619
...
bgrozev-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc272fbd1 | ||
|
|
2334eb9967 | ||
|
|
04bd4a9038 | ||
|
|
eb8f34cee8 | ||
|
|
b9379f5996 | ||
|
|
40d7d0c9cb | ||
|
|
357f173e85 | ||
|
|
7da26042b3 | ||
|
|
c86c7beb24 | ||
|
|
1020a54a33 | ||
|
|
c84abd543e | ||
|
|
4b17c6f015 | ||
|
|
cb973b61aa | ||
|
|
b096622995 | ||
|
|
ae0bf876a8 | ||
|
|
bba480f329 | ||
|
|
4dbcaf851f | ||
|
|
04dff9059b | ||
|
|
26cd2f17f6 | ||
|
|
60e03e3dec | ||
|
|
bfb45ed0e8 | ||
|
|
e325199075 | ||
|
|
4e4713c3e2 | ||
|
|
ff8386e931 | ||
|
|
8f520086e5 | ||
|
|
5cde674eff | ||
|
|
c018252eee | ||
|
|
c8cab1560c | ||
|
|
d218abfd97 | ||
|
|
9e0fee6c7d | ||
|
|
5dca9e08f4 | ||
|
|
d3a1f7d4f7 | ||
|
|
80bdf908ca |
13
README.md
@@ -90,6 +90,19 @@ cd jitsi-meet
|
||||
npm unlink lib-jitsi-meet
|
||||
npm install
|
||||
```
|
||||
## Running with webpack-dev-server for development
|
||||
|
||||
Use it at the CLI, type
|
||||
```
|
||||
node_modules/.bin/webpack-dev-server
|
||||
```
|
||||
|
||||
By default the backend deployment used is `beta.meet.jit.si`, you can point the Jitsi-Meet app at a different backend by using a proxy server. To do this set the WEBPACK_DEV_SERVER_PROXY_TARGET variable, type
|
||||
```
|
||||
WEBPACK_DEV_SERVER_PROXY_TARGET=https://your-example-server.com node_modules/.bin/webpack-dev-server
|
||||
```
|
||||
|
||||
The app should be running at https://localhost:8080/
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -43,16 +43,6 @@ Add the Maven repository
|
||||
`https://github.com/jitsi/jitsi-maven-repository/raw/master/releases` and the
|
||||
dependency `org.jitsi.react:jitsi-meet-sdk:1.9.0` into your `build.gradle`.
|
||||
|
||||
Add Java 1.8 compatibility support to your project by adding the following lines
|
||||
into your `build.gradle` file:
|
||||
|
||||
```
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Jitsi Meet SDK is an Android library which embodies the whole Jitsi Meet
|
||||
@@ -412,6 +402,7 @@ rules file:
|
||||
# WebRTC
|
||||
|
||||
-keep class org.webrtc.** { *; }
|
||||
-dontwarn org.chromium.build.BuildHooksAndroid
|
||||
|
||||
# Jisti Meet SDK
|
||||
|
||||
|
||||
@@ -31,10 +31,6 @@ android {
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -23,6 +23,8 @@ import org.jitsi.meet.sdk.JitsiMeetActivity;
|
||||
import org.jitsi.meet.sdk.JitsiMeetView;
|
||||
import org.jitsi.meet.sdk.JitsiMeetViewListener;
|
||||
|
||||
import com.calendarevents.CalendarEventsPackage;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -103,4 +105,10 @@ public class MainActivity extends JitsiMeetActivity {
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
CalendarEventsPackage.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ dependencies {
|
||||
compile project(':react-native-immersive')
|
||||
compile project(':react-native-keep-awake')
|
||||
compile project(':react-native-locale-detector')
|
||||
compile project(':react-native-sound')
|
||||
compile project(':react-native-vector-icons')
|
||||
compile project(':react-native-webrtc')
|
||||
compile project(':react-native-calendar-events')
|
||||
}
|
||||
|
||||
// Build process helpers
|
||||
|
||||
@@ -25,6 +25,7 @@ import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.util.Log;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
@@ -53,6 +54,12 @@ public class JitsiMeetView extends FrameLayout {
|
||||
*/
|
||||
private static final int BACKGROUND_COLOR = 0xFF111111;
|
||||
|
||||
/**
|
||||
* The {@link Log} tag which identifies the source of the log messages of
|
||||
* {@code JitsiMeetView}.
|
||||
*/
|
||||
private final static String TAG = JitsiMeetView.class.getSimpleName();
|
||||
|
||||
/**
|
||||
* React Native bridge. The instance manager allows embedding applications
|
||||
* to create multiple root views off the same JavaScript bundle.
|
||||
@@ -107,6 +114,7 @@ public class JitsiMeetView extends FrameLayout {
|
||||
.setApplication(application)
|
||||
.setBundleAssetName("index.android.bundle")
|
||||
.setJSMainModulePath("index.android")
|
||||
.addPackage(new com.calendarevents.CalendarEventsPackage())
|
||||
.addPackage(new com.corbt.keepawake.KCKeepAwakePackage())
|
||||
.addPackage(new com.facebook.react.shell.MainReactPackage())
|
||||
.addPackage(new com.i18n.reactnativei18n.ReactNativeI18n())
|
||||
@@ -115,6 +123,7 @@ public class JitsiMeetView extends FrameLayout {
|
||||
.addPackage(new com.oney.WebRTCModule.WebRTCModulePackage())
|
||||
.addPackage(new com.RNFetchBlob.RNFetchBlobPackage())
|
||||
.addPackage(new com.rnimmersive.RNImmersivePackage())
|
||||
.addPackage(new com.zmxv.RNSound.RNSoundPackage())
|
||||
.addPackage(new ReactPackageAdapter() {
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(
|
||||
@@ -266,13 +275,15 @@ public class JitsiMeetView extends FrameLayout {
|
||||
* @param params {@code WritableMap} optional ancillary data for the event.
|
||||
*/
|
||||
private static void sendEvent(
|
||||
String eventName, @Nullable WritableMap params) {
|
||||
String eventName,
|
||||
@Nullable WritableMap params) {
|
||||
if (reactInstanceManager != null) {
|
||||
ReactContext reactContext
|
||||
= reactInstanceManager.getCurrentReactContext();
|
||||
if (reactContext != null) {
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
|
||||
.getJSModule(
|
||||
DeviceEventManagerModule.RCTDeviceEventEmitter.class)
|
||||
.emit(eventName, params);
|
||||
}
|
||||
}
|
||||
@@ -498,10 +509,29 @@ public class JitsiMeetView extends FrameLayout {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
|
||||
// https://github.com/mockingbot/react-native-immersive#restore-immersive-state
|
||||
|
||||
// FIXME The singleton pattern employed by RNImmersiveModule is not
|
||||
// advisable because a react-native mobule is consumable only after its
|
||||
// BaseJavaModule#initialize() has completed and here we have no
|
||||
// knowledge of whether the precondition is really met.
|
||||
RNImmersiveModule immersive = RNImmersiveModule.getInstance();
|
||||
|
||||
if (hasFocus && immersive != null) {
|
||||
immersive.emitImmersiveStateChangeEvent();
|
||||
try {
|
||||
immersive.emitImmersiveStateChangeEvent();
|
||||
} catch (RuntimeException re) {
|
||||
// FIXME I don't know how to check myself whether
|
||||
// BaseJavaModule#initialize() has been invoked and thus
|
||||
// RNImmersiveModule is consumable. A safe workaround is to
|
||||
// swallow the failure because the whole full-screen/immersive
|
||||
// functionality is brittle anyway, akin to the icing on the
|
||||
// cake, and has been working without onWindowFocusChanged for a
|
||||
// very long time.
|
||||
Log.e(
|
||||
TAG,
|
||||
"RNImmersiveModule#emitImmersiveStateChangeEvent() failed!",
|
||||
re);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ include ':react-native-keep-awake'
|
||||
project(':react-native-keep-awake').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-keep-awake/android')
|
||||
include ':react-native-locale-detector'
|
||||
project(':react-native-locale-detector').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-locale-detector/android')
|
||||
include ':react-native-sound'
|
||||
project(':react-native-sound').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sound/android')
|
||||
include ':react-native-vector-icons'
|
||||
project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android')
|
||||
include ':react-native-webrtc'
|
||||
project(':react-native-webrtc').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-webrtc/android')
|
||||
include ':react-native-calendar-events'
|
||||
project(':react-native-calendar-events').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-calendar-events/android')
|
||||
|
||||
@@ -754,7 +754,7 @@ export default {
|
||||
track.mute();
|
||||
}
|
||||
});
|
||||
logger.log('initialized with %s local tracks', tracks.length);
|
||||
logger.log(`initialized with ${tracks.length} local tracks`);
|
||||
this._localTracksInitialized = true;
|
||||
con.addEventListener(
|
||||
JitsiConnectionEvents.CONNECTION_FAILED,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* The dialog content element.
|
||||
*/
|
||||
.dial-out-content {
|
||||
margin-top: 5px;
|
||||
|
||||
/**
|
||||
* Wrap the contents in flex so items can be aligned on the same line.
|
||||
*/
|
||||
.form-control {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/**
|
||||
* The style of the flag icon.
|
||||
*/
|
||||
.dial-out-flag-icon {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
/**
|
||||
* The style of the dial code element.
|
||||
*/
|
||||
.dial-out-code {
|
||||
margin-bottom: 0;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dial-out dialog error element.
|
||||
*/
|
||||
.dial-out-error {
|
||||
color: $errorColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The style of the dial input element.
|
||||
*/
|
||||
.dial-out-input {
|
||||
display: inline-block;
|
||||
flex: 1;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-styling the default dropdown inside the dial-out-content.
|
||||
*/
|
||||
.dropdown {
|
||||
position: relative;
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-styling the default form-control inside the dial-out-content.
|
||||
*/
|
||||
.form-control {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
padding-left: 16px;
|
||||
|
||||
&:read-only {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-trigger-icon {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
.flag-icon-background {
|
||||
background-size: contain;
|
||||
background-position: 50%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.flag-icon {
|
||||
background-size: contain;
|
||||
background-position: 50%;
|
||||
background-repeat: no-repeat;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 1.33333333em;
|
||||
line-height: 1em;
|
||||
}
|
||||
.flag-icon:before {
|
||||
content: "\00a0";
|
||||
}
|
||||
.flag-icon-au {
|
||||
background-image: url(../images/countries/au.svg);
|
||||
}
|
||||
.flag-icon-ca {
|
||||
background-image: url(../images/countries/ca.svg);
|
||||
}
|
||||
.flag-icon-de {
|
||||
background-image: url(../images/countries/de.svg);
|
||||
}
|
||||
.flag-icon-gb {
|
||||
background-image: url(../images/countries/gb.svg);
|
||||
}
|
||||
.flag-icon-fr {
|
||||
background-image: url(../images/countries/fr.svg);
|
||||
}
|
||||
.flag-icon-us {
|
||||
background-image: url(../images/countries/us.svg);
|
||||
}
|
||||
@@ -36,9 +36,15 @@
|
||||
.icon-navigate_before:before {
|
||||
content: "\e408";
|
||||
}
|
||||
.icon-navigate_next:before {
|
||||
content: "\e409";
|
||||
}
|
||||
.icon-public:before {
|
||||
content: "\e80b";
|
||||
}
|
||||
.icon-restore:before {
|
||||
content: "\e8b3";
|
||||
}
|
||||
.icon-timer:before {
|
||||
content: "\e425";
|
||||
}
|
||||
|
||||
@@ -28,11 +28,8 @@
|
||||
@import 'font-awesome';
|
||||
/* Fonts END */
|
||||
|
||||
@import 'flag-icon';
|
||||
|
||||
/* Modules BEGIN */
|
||||
|
||||
@import 'dial-out';
|
||||
@import 'aui_reset';
|
||||
@import 'base';
|
||||
@import 'utils';
|
||||
|
||||
@@ -11,17 +11,11 @@
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Styles the loading element in the MultiSelectAutocomplete.
|
||||
*/
|
||||
.autocomplete-loading {
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
min-width: 260px;
|
||||
padding: 20px;
|
||||
.add-telephone-icon {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
background-color: #fff;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
padding: 35px 0;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
|
||||
@@ -14,7 +13,7 @@
|
||||
color: $unsupportedBrowserTextColor;
|
||||
margin: auto;
|
||||
max-width: 40em;
|
||||
padding-bottom: 40px;
|
||||
padding: 35px 0 40px 0;
|
||||
text-align: center;
|
||||
width: 75%;
|
||||
|
||||
|
||||
7
debian/jitsi-meet-prosody.postinst
vendored
@@ -125,8 +125,11 @@ case "$1" in
|
||||
# echo for using all default values
|
||||
echo | prosodyctl cert generate $JICOFO_AUTH_DOMAIN
|
||||
|
||||
ln -sf /var/lib/prosody/$JICOFO_AUTH_DOMAIN.key /etc/prosody/certs/$JICOFO_AUTH_DOMAIN.key
|
||||
ln -sf /var/lib/prosody/$JICOFO_AUTH_DOMAIN.crt /etc/prosody/certs/$JICOFO_AUTH_DOMAIN.crt
|
||||
AUTH_KEY_FILE="/etc/prosody/certs/$JICOFO_AUTH_DOMAIN.key"
|
||||
AUTH_CRT_FILE="/etc/prosody/certs/$JICOFO_AUTH_DOMAIN.crt"
|
||||
|
||||
ln -sf /var/lib/prosody/$JICOFO_AUTH_DOMAIN.key $AUTH_KEY_FILE
|
||||
ln -sf /var/lib/prosody/$JICOFO_AUTH_DOMAIN.crt $AUTH_CRT_FILE
|
||||
ln -sf /var/lib/prosody/$JICOFO_AUTH_DOMAIN.crt /usr/local/share/ca-certificates/$JICOFO_AUTH_DOMAIN.crt
|
||||
|
||||
update-ca-certificates
|
||||
|
||||
BIN
fonts/jitsi.eot
@@ -12,6 +12,7 @@
|
||||
<glyph unicode="" glyph-name="bluetooth" d="M550 328l-80 82v-162zM470 776v-162l80 82zM670 696l-184-184 184-184-244-242h-42v324l-196-196-60 60 238 238-238 238 60 60 196-196v324h42zM834 738c40-64 62-142 62-222 0-84-24-160-66-226l-50 50c26 52 42 110 42 172s-16 120-42 172zM608 512l98 98c12-30 20-64 20-98s-8-70-20-100z" />
|
||||
<glyph unicode="" glyph-name="headset" d="M512 982c212 0 384-172 384-384v-300c0-70-58-128-128-128h-128v342h170v86c0 166-132 298-298 298s-298-132-298-298v-86h170v-342h-128c-70 0-128 58-128 128v300c0 212 172 384 384 384z" />
|
||||
<glyph unicode="" glyph-name="navigate_before" d="M658 708l-196-196 196-196-60-60-256 256 256 256z" />
|
||||
<glyph unicode="" glyph-name="navigate_next" d="M426 768l256-256-256-256-60 60 196 196-196 196z" />
|
||||
<glyph unicode="" glyph-name="timer" d="M512 170c166 0 298 134 298 300s-132 298-298 298-298-132-298-298 132-300 298-300zM812 708c52-66 84-148 84-238 0-212-172-384-384-384s-384 172-384 384 172 384 384 384c90 0 174-34 240-86l60 62c22-18 42-38 60-60zM470 426v256h84v-256h-84zM640 982v-86h-256v86h256z" />
|
||||
<glyph unicode="" glyph-name="arrow_back" d="M854 554v-84h-520l238-240-60-60-342 342 342 342 60-60-238-240h520z" />
|
||||
<glyph unicode="" glyph-name="menu" d="M128 768h768v-86h-768v86zM128 470v84h768v-84h-768zM128 256v86h768v-86h-768z" />
|
||||
@@ -22,6 +23,7 @@
|
||||
<glyph unicode="" glyph-name="event_note" d="M598 426v-84h-300v84h300zM810 214v468h-596v-468h596zM810 896c46 0 86-40 86-86v-596c0-46-40-86-86-86h-596c-48 0-86 40-86 86v596c0 46 38 86 86 86h42v86h86v-86h340v86h86v-86h42zM726 598v-86h-428v86h428z" />
|
||||
<glyph unicode="" glyph-name="phone-talk" d="M640 512c0 70-58 128-128 128v86c118 0 214-96 214-214h-86zM810 512c0 166-132 298-298 298v86c212 0 384-172 384-384h-86zM854 362c24 0 42-18 42-42v-150c0-24-18-42-42-42-400 0-726 326-726 726 0 24 18 42 42 42h150c24 0 42-18 42-42 0-54 8-104 24-152 4-14 2-32-10-44l-94-94c62-122 162-220 282-282l94 94c12 12 30 14 44 10 48-16 98-24 152-24z" />
|
||||
<glyph unicode="" glyph-name="public" d="M764 282c56 60 90 142 90 230 0 142-88 266-214 316v-18c0-46-40-84-86-84h-84v-86c0-24-20-42-44-42h-84v-86h256c24 0 42-18 42-42v-128h42c38 0 70-26 82-60zM470 174v82c-46 0-86 40-86 86v42l-204 204c-6-24-10-50-10-76 0-174 132-318 300-338zM512 938c236 0 426-190 426-426s-190-426-426-426-426 190-426 426 190 426 426 426z" />
|
||||
<glyph unicode="" glyph-name="restore" d="M512 682h64v-180l150-90-32-52-182 110v212zM554 896c212 0 384-172 384-384s-172-384-384-384c-106 0-200 42-270 112l60 62c54-54 128-88 210-88 166 0 300 132 300 298s-134 298-300 298-298-132-298-298h128l-172-172-4 6-166 166h128c0 212 172 384 384 384z" />
|
||||
<glyph unicode="" glyph-name="avatar" d="M512 204c106 0 200 56 256 138-2 84-172 132-256 132-86 0-254-48-256-132 56-82 150-138 256-138zM512 810c-70 0-128-58-128-128s58-128 128-128 128 58 128 128-58 128-128 128zM512 938c236 0 426-190 426-426s-190-426-426-426-426 190-426 426 190 426 426 426z" />
|
||||
<glyph unicode="" glyph-name="download" d="M726 470h-128v170h-172v-170h-128l214-214zM826 596c110-8 198-100 198-212 0-118-96-214-214-214h-554c-142 0-256 114-256 256 0 132 100 240 228 254 54 102 160 174 284 174 156 0 284-110 314-258z" />
|
||||
<glyph unicode="" glyph-name="mic-camera-combined" d="M756.704 628.138l267.296 202.213v-635.075l-267.296 202.213v-191.923c0-12.085-11.296-21.863-25.216-21.863h-706.272c-13.92 0-25.216 9.777-25.216 21.863v612.25c0 12.085 11.296 21.863 25.216 21.863h706.272c13.92 0 25.216-9.777 25.216-21.863v-189.679zM371.338 376.228c47.817 0 86.529 40.232 86.529 89.811v184.835c0 49.651-38.713 89.883-86.529 89.883-47.788 0-86.515-40.232-86.515-89.883v-184.835c0-49.579 38.756-89.811 86.515-89.811v0zM356.754 314.070v-32.78h33.718v33.412c73.858 9.606 131.235 73.73 131.235 151.351v88.232h-30.636v-88.232c0-67.57-53.696-122.534-119.734-122.534-66.024 0-119.691 54.964-119.691 122.534v88.232h-30.636v-88.232c0-79.215 59.674-144.502 135.744-151.969v-0.014z" />
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
BIN
fonts/jitsi.ttf
BIN
fonts/jitsi.woff
@@ -1,6 +1,60 @@
|
||||
{
|
||||
"IcoMoonType": "selection",
|
||||
"icons": [
|
||||
{
|
||||
"icon": {
|
||||
"paths": [
|
||||
"M512 342h64v180l150 90-32 52-182-110v-212zM554 128c212 0 384 172 384 384s-172 384-384 384c-106 0-200-42-270-112l60-62c54 54 128 88 210 88 166 0 300-132 300-298s-134-298-300-298-298 132-298 298h128l-172 172-4-6-166-166h128c0-212 172-384 384-384z"
|
||||
],
|
||||
"attrs": [],
|
||||
"isMulticolor": false,
|
||||
"isMulticolor2": false,
|
||||
"tags": [
|
||||
"restore"
|
||||
],
|
||||
"defaultCode": 59571,
|
||||
"grid": 24
|
||||
},
|
||||
"attrs": [],
|
||||
"properties": {
|
||||
"ligatures": "history, restore",
|
||||
"id": 385,
|
||||
"order": 930,
|
||||
"prevSize": 24,
|
||||
"code": 59571,
|
||||
"name": "restore"
|
||||
},
|
||||
"setIdx": 0,
|
||||
"setId": 2,
|
||||
"iconIdx": 385
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
"paths": [
|
||||
"M426 256l256 256-256 256-60-60 196-196-196-196z"
|
||||
],
|
||||
"attrs": [],
|
||||
"isMulticolor": false,
|
||||
"isMulticolor2": false,
|
||||
"tags": [
|
||||
"navigate_next"
|
||||
],
|
||||
"defaultCode": 58377,
|
||||
"grid": 24
|
||||
},
|
||||
"attrs": [],
|
||||
"properties": {
|
||||
"ligatures": "chevron_right, navigate_next",
|
||||
"id": 153,
|
||||
"order": 927,
|
||||
"prevSize": 24,
|
||||
"code": 58377,
|
||||
"name": "navigate_next"
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 0
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
"paths": [
|
||||
@@ -24,9 +78,9 @@
|
||||
"code": 58834,
|
||||
"name": "menu"
|
||||
},
|
||||
"setIdx": 0,
|
||||
"setId": 2,
|
||||
"iconIdx": 489
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 1
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -53,7 +107,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 0
|
||||
"iconIdx": 2
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -80,7 +134,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 1
|
||||
"iconIdx": 3
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -107,7 +161,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 2
|
||||
"iconIdx": 4
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -134,7 +188,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 3
|
||||
"iconIdx": 5
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -154,14 +208,14 @@
|
||||
"properties": {
|
||||
"ligatures": "timer",
|
||||
"id": 760,
|
||||
"order": 916,
|
||||
"order": 928,
|
||||
"prevSize": 24,
|
||||
"code": 58405,
|
||||
"name": "timer"
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 4
|
||||
"iconIdx": 6
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -188,7 +242,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 5
|
||||
"iconIdx": 7
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -215,7 +269,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 6
|
||||
"iconIdx": 8
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -242,7 +296,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 7
|
||||
"iconIdx": 9
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -269,7 +323,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 8
|
||||
"iconIdx": 10
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -298,7 +352,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 9
|
||||
"iconIdx": 11
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -325,7 +379,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 10
|
||||
"iconIdx": 12
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -352,7 +406,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 11
|
||||
"iconIdx": 13
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -381,7 +435,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 12
|
||||
"iconIdx": 14
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -410,7 +464,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 13
|
||||
"iconIdx": 15
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -439,7 +493,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 14
|
||||
"iconIdx": 16
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -468,7 +522,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 15
|
||||
"iconIdx": 17
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -497,7 +551,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 16
|
||||
"iconIdx": 18
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -523,7 +577,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 17
|
||||
"iconIdx": 19
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -549,7 +603,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 18
|
||||
"iconIdx": 20
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -575,7 +629,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 19
|
||||
"iconIdx": 21
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -601,7 +655,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 20
|
||||
"iconIdx": 22
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -627,7 +681,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 21
|
||||
"iconIdx": 23
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -653,7 +707,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 22
|
||||
"iconIdx": 24
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -679,7 +733,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 23
|
||||
"iconIdx": 25
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -705,7 +759,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 24
|
||||
"iconIdx": 26
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -731,7 +785,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 25
|
||||
"iconIdx": 27
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -757,7 +811,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 26
|
||||
"iconIdx": 28
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -783,7 +837,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 27
|
||||
"iconIdx": 29
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -809,7 +863,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 28
|
||||
"iconIdx": 30
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -835,7 +889,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 29
|
||||
"iconIdx": 31
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -861,7 +915,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 30
|
||||
"iconIdx": 32
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -887,7 +941,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 31
|
||||
"iconIdx": 33
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -913,7 +967,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 32
|
||||
"iconIdx": 34
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -939,7 +993,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 33
|
||||
"iconIdx": 35
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -965,7 +1019,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 34
|
||||
"iconIdx": 36
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -991,7 +1045,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 35
|
||||
"iconIdx": 37
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1017,7 +1071,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 36
|
||||
"iconIdx": 38
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1043,7 +1097,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 37
|
||||
"iconIdx": 39
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1069,7 +1123,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 38
|
||||
"iconIdx": 40
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1095,7 +1149,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 39
|
||||
"iconIdx": 41
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1121,7 +1175,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 40
|
||||
"iconIdx": 42
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1147,7 +1201,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 41
|
||||
"iconIdx": 43
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1173,7 +1227,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 42
|
||||
"iconIdx": 44
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1199,7 +1253,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 43
|
||||
"iconIdx": 45
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1225,7 +1279,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 44
|
||||
"iconIdx": 46
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1251,7 +1305,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 45
|
||||
"iconIdx": 47
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1280,7 +1334,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 46
|
||||
"iconIdx": 48
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1310,7 +1364,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 47
|
||||
"iconIdx": 49
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1340,7 +1394,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 48
|
||||
"iconIdx": 50
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1366,7 +1420,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 49
|
||||
"iconIdx": 51
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1392,7 +1446,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 50
|
||||
"iconIdx": 52
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1418,7 +1472,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 51
|
||||
"iconIdx": 53
|
||||
}
|
||||
],
|
||||
"height": 1024,
|
||||
|
||||
BIN
images/calendar@2x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
images/calendar@3x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="480" width="640" viewBox="0 0 640 480">
|
||||
<g stroke-width="1pt">
|
||||
<path fill="#006" d="M0 0h640v480H0z"/>
|
||||
<path d="M0 0v27.95L307.037 250h38.647v-27.95L38.647 0H0zm345.684 0v27.95L38.647 250H0v-27.95L307.037 0h38.647z" fill="#fff"/>
|
||||
<path d="M144.035 0v250h57.614V0h-57.615zM0 83.333v83.333h345.684V83.333H0z" fill="#fff"/>
|
||||
<path d="M0 100v50h345.684v-50H0zM155.558 0v250h34.568V0h-34.568zM0 250l115.228-83.334h25.765L25.765 250H0zM0 0l115.228 83.333H89.463L0 18.633V0zm204.69 83.333L319.92 0h25.764L230.456 83.333H204.69zM345.685 250l-115.228-83.334h25.765l89.464 64.7V250z" fill="#c00"/>
|
||||
<path d="M299.762 392.523l-43.653 3.795 6.013 43.406-30.187-31.764-30.186 31.764 6.014-43.406-43.653-3.795 37.68-22.364-24.244-36.495 40.97 15.514 13.42-41.713 13.42 41.712 40.97-15.515-24.242 36.494m224.444 62.372l-10.537-15.854 17.81 6.742 5.824-18.125 5.825 18.126 17.807-6.742-10.537 15.854 16.37 9.718-18.965 1.65 2.616 18.85-13.116-13.793-13.117 13.794 2.616-18.85-18.964-1.65m16.368-291.815l-10.537-15.856 17.81 6.742 5.824-18.122 5.825 18.12 17.807-6.74-10.537 15.855 16.37 9.717-18.965 1.65 2.616 18.85-13.116-13.793-13.117 13.794 2.616-18.85-18.964-1.65m-89.418 104.883l-10.537-15.853 17.808 6.742 5.825-18.125 5.825 18.125 17.808-6.742-10.536 15.853 16.37 9.72-18.965 1.65 2.615 18.85-13.117-13.795-13.117 13.795 2.617-18.85-18.964-1.65m216.212-37.929l-10.558-15.854 17.822 6.742 5.782-18.125 5.854 18.125 17.772-6.742-10.508 15.854 16.362 9.718-18.97 1.65 2.608 18.85-13.118-13.793-13.117 13.793 2.61-18.85-18.936-1.65m-22.251 73.394l-10.367 6.425 2.914-11.84-9.316-7.863 12.165-.896 4.605-11.29 4.606 11.29 12.165.897-9.317 7.863 2.912 11.84" fill-rule="evenodd" fill="#fff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="480" width="640" viewBox="0 0 640 480">
|
||||
<g transform="translate(74.118) scale(.9375)">
|
||||
<path fill="#fff" d="M81.137 0h362.276v512H81.137z"/>
|
||||
<path fill="#bf0a30" d="M-100 0H81.138v512H-100zm543.413 0H624.55v512H443.414zM135.31 247.41l-14.067 4.808 65.456 57.446c4.95 14.764-1.72 19.116-5.97 26.86l71.06-9.02-1.85 71.512 14.718-.423-3.21-70.918 71.13 8.432c-4.402-9.297-8.32-14.233-4.247-29.098l65.414-54.426-11.447-4.144c-9.36-7.222 4.044-34.784 6.066-52.178 0 0-38.195 13.135-40.698 6.262l-9.727-18.685-34.747 38.17c-3.796.91-5.413-.6-6.304-3.808l16.053-79.766-25.42 14.297c-2.128.91-4.256.125-5.658-2.355l-24.45-49.06-25.21 50.95c-1.9 1.826-3.803 2.037-5.382.796l-24.204-13.578 14.53 79.143c-1.156 3.14-3.924 4.025-7.18 2.324l-33.216-37.737c-4.345 6.962-7.29 18.336-13.033 20.885-5.744 2.387-24.98-4.823-37.873-7.637 4.404 15.895 18.176 42.302 9.46 50.957z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 934 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="480" width="640" viewBox="0 0 640 480">
|
||||
<path fill="#ffce00" d="M0 320h640v160.002H0z"/>
|
||||
<path d="M0 0h640v160H0z"/>
|
||||
<path fill="#d00" d="M0 160h640v160H0z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 220 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="480" width="640" viewBox="0 0 640 480">
|
||||
<g fill-rule="evenodd" stroke-width="1pt">
|
||||
<path fill="#fff" d="M0 0h640v480H0z"/>
|
||||
<path fill="#00267f" d="M0 0h213.337v480H0z"/>
|
||||
<path fill="#f31830" d="M426.662 0H640v480H426.662z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 301 B |
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="480" width="640" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill-opacity=".67" d="M-85.333 0h682.67v512h-682.67z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#a)" transform="translate(80) scale(.94)">
|
||||
<g stroke-width="1pt">
|
||||
<path fill="#006" d="M-256 0H768.02v512.01H-256z"/>
|
||||
<path d="M-256 0v57.244l909.535 454.768H768.02V454.77L-141.515 0H-256zM768.02 0v57.243L-141.515 512.01H-256v-57.243L653.535 0H768.02z" fill="#fff"/>
|
||||
<path d="M170.675 0v512.01h170.67V0h-170.67zM-256 170.67v170.67H768.02V170.67H-256z" fill="#fff"/>
|
||||
<path d="M-256 204.804v102.402H768.02V204.804H-256zM204.81 0v512.01h102.4V0h-102.4zM-256 512.01L85.34 341.34h76.324l-341.34 170.67H-256zM-256 0L85.34 170.67H9.016L-256 38.164V0zm606.356 170.67L691.696 0h76.324L426.68 170.67h-76.324zM768.02 512.01L426.68 341.34h76.324L768.02 473.848v38.162z" fill="#c00"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 956 B |
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="480" width="640" viewBox="0 0 640 480">
|
||||
<g fill-rule="evenodd" transform="scale(.9375)">
|
||||
<g stroke-width="1pt">
|
||||
<path d="M0 0h972.81v39.385H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0z" fill="#bd3d44"/>
|
||||
<path d="M0 39.385h972.81V78.77H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0zm0 78.77h972.81v39.385H0z" fill="#fff"/>
|
||||
</g>
|
||||
<path fill="#192f5d" d="M0 0h389.12v275.69H0z"/>
|
||||
<g fill="#fff">
|
||||
<path d="M32.427 11.8l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.853 0l3.541 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735H93.74zm64.856 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.269 6.734 3.54-10.896-9.269-6.735h11.458zm64.852 0l3.54 10.896h11.457l-9.269 6.735 3.54 10.896-9.268-6.734-9.27 6.734 3.541-10.896-9.27-6.735h11.458zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.27 6.734 3.542-10.896-9.27-6.735h11.458zM64.855 39.37l3.54 10.896h11.458L70.583 57l3.542 10.897-9.27-6.734-9.269 6.734L59.126 57l-9.269-6.734h11.458zm64.852 0l3.54 10.896h11.457L135.435 57l3.54 10.897-9.268-6.734-9.27 6.734L123.978 57l-9.27-6.734h11.458zm64.855 0l3.54 10.896h11.458L200.29 57l3.541 10.897-9.27-6.734-9.268 6.734L188.833 57l-9.269-6.734h11.457zm64.855 0l3.54 10.896h11.458L265.145 57l3.541 10.897-9.269-6.734-9.27 6.734L253.69 57l-9.27-6.734h11.458zm64.852 0l3.54 10.896h11.457L329.997 57l3.54 10.897-9.268-6.734-9.27 6.734L318.54 57l-9.27-6.734h11.458zM32.427 66.939l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.853 0l3.541 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735H93.74zm64.856 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.269 6.734 3.54-10.896-9.269-6.735h11.458zm64.852 0l3.54 10.896h11.457l-9.269 6.735 3.54 10.896-9.268-6.734-9.27 6.734 3.541-10.896-9.27-6.735h11.458zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.27 6.734 3.542-10.896-9.27-6.735h11.458zM64.855 94.508l3.54 10.897h11.458l-9.27 6.734 3.542 10.897-9.27-6.734-9.269 6.734 3.54-10.897-9.269-6.734h11.458zm64.852 0l3.54 10.897h11.457l-9.269 6.734 3.54 10.897-9.268-6.734-9.27 6.734 3.541-10.897-9.27-6.734h11.458zm64.855 0l3.54 10.897h11.458l-9.27 6.734 3.541 10.897-9.27-6.734-9.268 6.734 3.54-10.897-9.269-6.734h11.457zm64.855 0l3.54 10.897h11.458l-9.27 6.734 3.541 10.897-9.269-6.734-9.27 6.734 3.542-10.897-9.27-6.734h11.458zm64.852 0l3.54 10.897h11.457l-9.269 6.734 3.54 10.897-9.268-6.734-9.27 6.734 3.541-10.897-9.27-6.734h11.458zM32.427 122.078l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.853 0l3.541 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735H93.74zm64.856 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.269 6.734 3.54-10.896-9.269-6.735h11.458zm64.852 0l3.54 10.896h11.457l-9.269 6.735 3.54 10.896-9.268-6.734-9.27 6.734 3.541-10.896-9.27-6.735h11.458zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.27 6.734 3.542-10.896-9.27-6.735h11.458zM64.855 149.647l3.54 10.897h11.458l-9.27 6.734 3.542 10.897-9.27-6.734-9.269 6.734 3.54-10.897-9.269-6.734h11.458zm64.852 0l3.54 10.897h11.457l-9.269 6.734 3.54 10.897-9.268-6.734-9.27 6.734 3.541-10.897-9.27-6.734h11.458zm64.855 0l3.54 10.897h11.458l-9.27 6.734 3.541 10.897-9.27-6.734-9.268 6.734 3.54-10.897-9.269-6.734h11.457zm64.855 0l3.54 10.897h11.458l-9.27 6.734 3.541 10.897-9.269-6.734-9.27 6.734 3.542-10.897-9.27-6.734h11.458zm64.852 0l3.54 10.897h11.457l-9.269 6.734 3.54 10.897-9.268-6.734-9.27 6.734 3.541-10.897-9.27-6.734h11.458z"/>
|
||||
<g>
|
||||
<path d="M32.427 177.217l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.853 0l3.541 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735H93.74zm64.856 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.269 6.734 3.54-10.896-9.269-6.735h11.458zm64.852 0l3.54 10.896h11.457l-9.269 6.735 3.54 10.896-9.268-6.734-9.27 6.734 3.541-10.896-9.27-6.735h11.458zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.27 6.734 3.542-10.896-9.27-6.735h11.458zM64.855 204.786l3.54 10.897h11.458l-9.27 6.734 3.542 10.897-9.27-6.734-9.269 6.734 3.54-10.897-9.269-6.734h11.458zm64.852 0l3.54 10.897h11.457l-9.269 6.734 3.54 10.897-9.268-6.734-9.27 6.734 3.541-10.897-9.27-6.734h11.458zm64.855 0l3.54 10.897h11.458l-9.27 6.734 3.541 10.897-9.27-6.734-9.268 6.734 3.54-10.897-9.269-6.734h11.457zm64.855 0l3.54 10.897h11.458l-9.27 6.734 3.541 10.897-9.269-6.734-9.27 6.734 3.542-10.897-9.27-6.734h11.458zm64.852 0l3.54 10.897h11.457l-9.269 6.734 3.54 10.897-9.268-6.734-9.27 6.734 3.541-10.897-9.27-6.734h11.458z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M32.427 232.356l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.853 0l3.541 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735H93.74zm64.856 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.269 6.734 3.54-10.896-9.269-6.735h11.458zm64.852 0l3.54 10.896h11.457l-9.269 6.735 3.54 10.896-9.268-6.734-9.27 6.734 3.541-10.896-9.27-6.735h11.458zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.27-6.734-9.268 6.734 3.54-10.896-9.269-6.735h11.457zm64.855 0l3.54 10.896h11.458l-9.27 6.735 3.541 10.896-9.269-6.734-9.27 6.734 3.542-10.896-9.27-6.735h11.458z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -28,7 +28,10 @@ target 'JitsiMeet' do
|
||||
pod 'react-native-locale-detector',
|
||||
:path => '../node_modules/react-native-locale-detector'
|
||||
pod 'react-native-webrtc', :path => '../node_modules/react-native-webrtc'
|
||||
pod 'RNSound', :path => '../node_modules/react-native-sound'
|
||||
pod 'RNVectorIcons', :path => '../node_modules/react-native-vector-icons'
|
||||
pod 'react-native-calendar-events',
|
||||
:path => '../node_modules/react-native-calendar-events'
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
|
||||
@@ -3,6 +3,8 @@ PODS:
|
||||
- React/Core (= 0.51.0)
|
||||
- react-native-background-timer (2.0.0):
|
||||
- React
|
||||
- react-native-calendar-events (1.4.3):
|
||||
- React
|
||||
- react-native-fetch-blob (0.10.6):
|
||||
- React/Core
|
||||
- react-native-keep-awake (2.0.6):
|
||||
@@ -41,12 +43,18 @@ PODS:
|
||||
- React/Core
|
||||
- React/fishhook
|
||||
- React/RCTBlob
|
||||
- RNSound (0.10.4):
|
||||
- React/Core
|
||||
- RNSound/Core (= 0.10.4)
|
||||
- RNSound/Core (0.10.4):
|
||||
- React/Core
|
||||
- RNVectorIcons (4.4.2):
|
||||
- React
|
||||
- yoga (0.51.0.React)
|
||||
|
||||
DEPENDENCIES:
|
||||
- react-native-background-timer (from `../node_modules/react-native-background-timer`)
|
||||
- react-native-calendar-events (from `../node_modules/react-native-calendar-events`)
|
||||
- react-native-fetch-blob (from `../node_modules/react-native-fetch-blob`)
|
||||
- react-native-keep-awake (from `../node_modules/react-native-keep-awake`)
|
||||
- react-native-locale-detector (from `../node_modules/react-native-locale-detector`)
|
||||
@@ -61,6 +69,7 @@ DEPENDENCIES:
|
||||
- React/RCTNetwork (from `../node_modules/react-native`)
|
||||
- React/RCTText (from `../node_modules/react-native`)
|
||||
- React/RCTWebSocket (from `../node_modules/react-native`)
|
||||
- RNSound (from `../node_modules/react-native-sound`)
|
||||
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
|
||||
- yoga (from `../node_modules/react-native/ReactCommon/yoga`)
|
||||
|
||||
@@ -69,6 +78,8 @@ EXTERNAL SOURCES:
|
||||
:path: ../node_modules/react-native
|
||||
react-native-background-timer:
|
||||
:path: ../node_modules/react-native-background-timer
|
||||
react-native-calendar-events:
|
||||
:path: ../node_modules/react-native-calendar-events
|
||||
react-native-fetch-blob:
|
||||
:path: ../node_modules/react-native-fetch-blob
|
||||
react-native-keep-awake:
|
||||
@@ -77,6 +88,8 @@ EXTERNAL SOURCES:
|
||||
:path: ../node_modules/react-native-locale-detector
|
||||
react-native-webrtc:
|
||||
:path: ../node_modules/react-native-webrtc
|
||||
RNSound:
|
||||
:path: ../node_modules/react-native-sound
|
||||
RNVectorIcons:
|
||||
:path: ../node_modules/react-native-vector-icons
|
||||
yoga:
|
||||
@@ -85,13 +98,15 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
React: 541ba768b9855e10cdc76f55427a5cd0653ca806
|
||||
react-native-background-timer: 63dcbf37dbcf294b5c6c071afcdc661fa06a7594
|
||||
react-native-calendar-events: fe6fbc8ed337a7423c98f2c9012b25f20444de09
|
||||
react-native-fetch-blob: 63394b1d7b0781547b3e4463b3195790177b1222
|
||||
react-native-keep-awake: 0de4bd66de0c23178107dce0c2fcc3354b2a8e94
|
||||
react-native-locale-detector: d1b2c6fe5abb56e3a1efb6c2d6f308c05c4251f1
|
||||
react-native-webrtc: bc044ca9530fc802e7533f247aa08fe1b6bf8dc5
|
||||
RNSound: d0818fe2435254fe30540fae48a429c5ffb72e09
|
||||
RNVectorIcons: c0dbfbf6068fefa240c37b0f71bd03b45dddac44
|
||||
yoga: 17521bbb0dd54a47c0b3ac43253e78cdac7488e0
|
||||
|
||||
PODFILE CHECKSUM: fabd6b6c27f8e1849f0668db3f403bf536ac8903
|
||||
PODFILE CHECKSUM: 4a5a310403b99b9c2d619e0b18da89bf0fe5858c
|
||||
|
||||
COCOAPODS: 1.4.0
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>Displays the user's meetings in the app.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Participate in conferences with video.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"audio": "Voice",
|
||||
"video": "Video"
|
||||
},
|
||||
"calendar": "Calendar",
|
||||
"go": "GO",
|
||||
"join": "JOIN",
|
||||
"privacy": "Privacy",
|
||||
@@ -452,17 +453,24 @@
|
||||
"qualityButtonTip": "Change received video quality"
|
||||
},
|
||||
"dialOut": {
|
||||
"dial": "Dial",
|
||||
"dialOut": "Call a number",
|
||||
"statusMessage": "is now __status__",
|
||||
"enterPhone": "Enter phone number",
|
||||
"phoneNotAllowed": "Oh, we don't support that destination yet! Sorry!"
|
||||
"statusMessage": "is now __status__"
|
||||
},
|
||||
"addPeople": {
|
||||
"add": "Add",
|
||||
"countryNotSupported": "We do not support this destination yet.",
|
||||
"countryReminder": "Calling outside the US? Please make sure you start with the country code!",
|
||||
"disabled": "You can't invite people.",
|
||||
"invite": "Invite",
|
||||
"loading": "Searching for people and phone numbers",
|
||||
"loadingNumber": "Validating phone number",
|
||||
"loadingPeople": "Searching for people to invite",
|
||||
"noResults": "No matching search results",
|
||||
"searchPlaceholder": "Search for people and rooms to add",
|
||||
"title": "Add people to your call",
|
||||
"noValidNumbers": "Please enter a phone number",
|
||||
"searchNumbers": "Enter a phone number to invite",
|
||||
"searchPeople": "Enter a name to invite",
|
||||
"searchPeopleAndNumbers": "Enter a name or phone number to invite",
|
||||
"telephone": "Telephone: __number__",
|
||||
"title": "Invite people to your meeting",
|
||||
"failedToAdd": "Failed to add members"
|
||||
},
|
||||
"inlineDialogFailure": {
|
||||
@@ -495,11 +503,13 @@
|
||||
"dialInConferenceID": "PIN: __conferenceID__#",
|
||||
"dialInNotSupported": "Sorry, dialing in is currently not suppported.",
|
||||
"genericError": "Whoops, something went wrong.",
|
||||
"invitePhone": "To join by phone, dial __number__ and enter this PIN: __pin__#",
|
||||
"invitePhone": "To join by phone, dial __number__ and enter this PIN: __conferenceID__#",
|
||||
"invitePhoneAlternatives": "To view more phone numbers, click this link: __url__",
|
||||
"inviteURL": "To join the video meeting, click this link: __url__",
|
||||
"moreNumbers": "More numbers",
|
||||
"noNumbers": "No dial-in numbers.",
|
||||
"noPassword": "None",
|
||||
"noRoom": "No room was specified to dial-in into.",
|
||||
"numbers": "Dial-in Numbers",
|
||||
"password": "Password:",
|
||||
"title": "Call info",
|
||||
@@ -517,5 +527,16 @@
|
||||
"serverURL": "Server URL",
|
||||
"startWithAudioMuted": "Start with audio muted",
|
||||
"startWithVideoMuted": "Start with video muted"
|
||||
},
|
||||
"calendarSync": {
|
||||
"later": "Later",
|
||||
"next": "Upcoming",
|
||||
"nextMeeting": "next meeting",
|
||||
"now": "Now"
|
||||
},
|
||||
"recentList": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"earlier": "Earlier"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,11 +503,6 @@ UI.addUser = function(user) {
|
||||
APP.store.dispatch(showParticipantJoinedNotification(displayName));
|
||||
}
|
||||
|
||||
if (!config.startAudioMuted
|
||||
|| config.startAudioMuted > APP.conference.membersCount) {
|
||||
UIUtil.playSoundNotification('userJoined');
|
||||
}
|
||||
|
||||
// Add Peer's container
|
||||
VideoLayout.addParticipantContainer(user);
|
||||
|
||||
@@ -529,11 +524,6 @@ UI.removeUser = function(id, displayName) {
|
||||
messageHandler.participantNotification(
|
||||
displayName, 'notify.somebody', 'disconnected', 'notify.disconnected');
|
||||
|
||||
if (!config.startAudioMuted
|
||||
|| config.startAudioMuted > APP.conference.membersCount) {
|
||||
UIUtil.playSoundNotification('userLeft');
|
||||
}
|
||||
|
||||
VideoLayout.removeParticipantContainer(id);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ const htmlStr = `
|
||||
</div>
|
||||
|
||||
<div id="chatconversation"></div>
|
||||
<audio id="chatNotification" src="sounds/incomingMessage.wav"
|
||||
preload="auto"></audio>
|
||||
<textarea id="usermsg" autofocus
|
||||
data-i18n="[placeholder]chat.messagebox"></textarea>
|
||||
<div id="smileysarea">
|
||||
@@ -285,7 +283,6 @@ const Chat = {
|
||||
|
||||
if (!Chat.isVisible()) {
|
||||
unreadMessages++;
|
||||
UIUtil.playSoundNotification('chatNotification');
|
||||
updateVisualNotification();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +66,6 @@ const UIUtil = {
|
||||
return el.clientHeight + 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* Plays the sound given by id.
|
||||
*
|
||||
* @param id the identifier of the audio element.
|
||||
*/
|
||||
playSoundNotification(id) {
|
||||
document.getElementById(id).play();
|
||||
},
|
||||
|
||||
/**
|
||||
* Escapes the given text.
|
||||
*/
|
||||
|
||||
@@ -687,7 +687,11 @@ export class VideoContainer extends LargeContainer {
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateBackground() {
|
||||
if (browser.isFirefox() || browser.isTemasysPluginUsed()) {
|
||||
// Do not the background display on browsers that might experience
|
||||
// performance issues from the presence of the background.
|
||||
if (browser.isFirefox()
|
||||
|| browser.isSafariWithWebrtc()
|
||||
|| browser.isTemasysPluginUsed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
124
package-lock.json
generated
@@ -3754,14 +3754,14 @@
|
||||
}
|
||||
},
|
||||
"duplexify": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.5.3.tgz",
|
||||
"integrity": "sha512-g8ID9OroF9hKt2POf8YLayy+9594PzmM3scI00/uBXocX3TWNgoB67hjzkFe9ITAbQOne/lLdBxHXvYUM4ZgGA==",
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.5.4.tgz",
|
||||
"integrity": "sha512-JzYSLYMhoVVBe8+mbHQ4KgpvHpm0DZpJuL8PY93Vyv1fW7jYJ90LoXa1di/CVbJM+TgMs91rbDapE/RNIfnJsA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"end-of-stream": "1.4.1",
|
||||
"inherits": "2.0.3",
|
||||
"readable-stream": "2.3.4",
|
||||
"readable-stream": "2.3.5",
|
||||
"stream-shift": "1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -3772,9 +3772,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.4.tgz",
|
||||
"integrity": "sha512-vuYxeWYM+fde14+rajzqgeohAI7YoJcHE7kXDAc4Nk0EbuKnJfqtY9YtRkLo/tqkuF7MsBQRhPnPeyjYITp3ZQ==",
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.5.tgz",
|
||||
"integrity": "sha512-tK0yDhrkygt/knjowCUiWP9YdV7c5R+8cR0r/kt9ZhBU906Fs6RpQJCEilamRJj1Nx2rWI6LkW9gKqjTkshhEw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"core-util-is": "1.0.2",
|
||||
@@ -4982,7 +4982,7 @@
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"inherits": "2.0.3",
|
||||
"readable-stream": "2.3.4"
|
||||
"readable-stream": "2.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"isarray": {
|
||||
@@ -4992,9 +4992,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.4.tgz",
|
||||
"integrity": "sha512-vuYxeWYM+fde14+rajzqgeohAI7YoJcHE7kXDAc4Nk0EbuKnJfqtY9YtRkLo/tqkuF7MsBQRhPnPeyjYITp3ZQ==",
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.5.tgz",
|
||||
"integrity": "sha512-tK0yDhrkygt/knjowCUiWP9YdV7c5R+8cR0r/kt9ZhBU906Fs6RpQJCEilamRJj1Nx2rWI6LkW9gKqjTkshhEw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"core-util-is": "1.0.2",
|
||||
@@ -5069,7 +5069,7 @@
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"inherits": "2.0.3",
|
||||
"readable-stream": "2.3.4"
|
||||
"readable-stream": "2.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"isarray": {
|
||||
@@ -5079,9 +5079,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.4.tgz",
|
||||
"integrity": "sha512-vuYxeWYM+fde14+rajzqgeohAI7YoJcHE7kXDAc4Nk0EbuKnJfqtY9YtRkLo/tqkuF7MsBQRhPnPeyjYITp3ZQ==",
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.5.tgz",
|
||||
"integrity": "sha512-tK0yDhrkygt/knjowCUiWP9YdV7c5R+8cR0r/kt9ZhBU906Fs6RpQJCEilamRJj1Nx2rWI6LkW9gKqjTkshhEw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"core-util-is": "1.0.2",
|
||||
@@ -7010,6 +7010,19 @@
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
|
||||
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
|
||||
},
|
||||
"js-utils": {
|
||||
"version": "github:jitsi/js-utils#d4b78721b754a15c2588b7b0c621a9fff6fa1363",
|
||||
"requires": {
|
||||
"bowser": "1.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"bowser": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.1.tgz",
|
||||
"integrity": "sha512-UXti1JB6oK8hO983AImunnV6j/fqAEeDlPXh99zhsP5g32oLbxJJ6qcOaUesR+tqqhnUVQHlRJyD0dfiV0Hxaw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.7.0.tgz",
|
||||
@@ -7207,7 +7220,7 @@
|
||||
}
|
||||
},
|
||||
"lib-jitsi-meet": {
|
||||
"version": "github:jitsi/lib-jitsi-meet#d5389ace55686b96f1526c32fda26faf0d9a672f",
|
||||
"version": "github:jitsi/lib-jitsi-meet#410ad5e76d33b1422ce34f83719eda7dfd584e22",
|
||||
"requires": {
|
||||
"async": "0.9.0",
|
||||
"current-executing-script": "0.1.3",
|
||||
@@ -7221,19 +7234,6 @@
|
||||
"strophejs-plugin-disco": "0.0.2",
|
||||
"webrtc-adapter": "github:webrtc/adapter#1eec19782b4058d186341263e7d049cea3e3290a",
|
||||
"yaeti": "1.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"bowser": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.1.tgz",
|
||||
"integrity": "sha512-UXti1JB6oK8hO983AImunnV6j/fqAEeDlPXh99zhsP5g32oLbxJJ6qcOaUesR+tqqhnUVQHlRJyD0dfiV0Hxaw=="
|
||||
},
|
||||
"js-utils": {
|
||||
"version": "github:jitsi/js-utils#d4b78721b754a15c2588b7b0c621a9fff6fa1363",
|
||||
"requires": {
|
||||
"bowser": "1.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"load-json-file": {
|
||||
@@ -7929,7 +7929,7 @@
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"concat-stream": "1.6.0",
|
||||
"duplexify": "3.5.3",
|
||||
"duplexify": "3.5.4",
|
||||
"end-of-stream": "1.4.1",
|
||||
"flush-write-stream": "1.0.2",
|
||||
"from2": "2.3.0",
|
||||
@@ -8623,7 +8623,7 @@
|
||||
"requires": {
|
||||
"cyclist": "0.2.2",
|
||||
"inherits": "2.0.3",
|
||||
"readable-stream": "2.3.4"
|
||||
"readable-stream": "2.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"isarray": {
|
||||
@@ -8633,9 +8633,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.4.tgz",
|
||||
"integrity": "sha512-vuYxeWYM+fde14+rajzqgeohAI7YoJcHE7kXDAc4Nk0EbuKnJfqtY9YtRkLo/tqkuF7MsBQRhPnPeyjYITp3ZQ==",
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.5.tgz",
|
||||
"integrity": "sha512-tK0yDhrkygt/knjowCUiWP9YdV7c5R+8cR0r/kt9ZhBU906Fs6RpQJCEilamRJj1Nx2rWI6LkW9gKqjTkshhEw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"core-util-is": "1.0.2",
|
||||
@@ -9552,7 +9552,7 @@
|
||||
"integrity": "sha512-2kmNR9ry+Pf45opRVirpNuIFotsxUGLaYqxIwuR77AYrYRMuFCz9eryHBS52L360O+NcR383CL4QYlMKPq4zYA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"duplexify": "3.5.3",
|
||||
"duplexify": "3.5.4",
|
||||
"inherits": "2.0.3",
|
||||
"pump": "2.0.1"
|
||||
}
|
||||
@@ -9845,6 +9845,11 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-background-timer/-/react-native-background-timer-2.0.0.tgz",
|
||||
"integrity": "sha512-vLNJIedXQZN4p3ChFsAgVHacnJqQMnLl+wBsnZuliRkmsjEHo8kQOA9fnLih/OoiDi1O3eHQvXC5L8f+RYiKgw=="
|
||||
},
|
||||
"react-native-calendar-events": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-calendar-events/-/react-native-calendar-events-1.4.3.tgz",
|
||||
"integrity": "sha1-KYBOi0TWlG5pq1ogkC2USe0xXEc="
|
||||
},
|
||||
"react-native-callstats": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-callstats/-/react-native-callstats-3.27.0.tgz",
|
||||
@@ -9905,6 +9910,11 @@
|
||||
"resolved": "https://registry.npmjs.org/react-native-prompt/-/react-native-prompt-1.0.0.tgz",
|
||||
"integrity": "sha1-QeDsKqfdjxLzo+6Dr51jxLZw+KE="
|
||||
},
|
||||
"react-native-sound": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.10.4.tgz",
|
||||
"integrity": "sha512-V9v4CjKgv8ekQRLOJSoKA7pxJ03F4Ih3T/RfMIlMWLktz7v/O4sdJPjRBLOzZRqAnr9FWTLbSk1ZCjioXh3mjQ=="
|
||||
},
|
||||
"react-native-vector-icons": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-4.4.2.tgz",
|
||||
@@ -9938,7 +9948,7 @@
|
||||
}
|
||||
},
|
||||
"react-native-webrtc": {
|
||||
"version": "github:jitsi/react-native-webrtc#806435b41fa152a8239ebeb7d002d1c6e979be86",
|
||||
"version": "github:jitsi/react-native-webrtc#626818af40384356617f70366133317b6a475171",
|
||||
"requires": {
|
||||
"base64-js": "1.2.3",
|
||||
"event-target-shim": "1.1.1",
|
||||
@@ -10724,6 +10734,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"serialize-javascript": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.4.0.tgz",
|
||||
"integrity": "sha1-fJWFFNtqwkQ6irwGLcn3iGp/YAU=",
|
||||
"dev": true
|
||||
},
|
||||
"serve-favicon": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.3.2.tgz",
|
||||
@@ -11664,20 +11680,48 @@
|
||||
"optional": true
|
||||
},
|
||||
"uglifyjs-webpack-plugin": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.1.2.tgz",
|
||||
"integrity": "sha512-k07cmJTj+8vZMSc3BaQ9uW7qVl2MqDts4ti4KaNACXEcXSw2vQM2S8olSk/CODxvcSFGvUHzNSqA8JQlhgUJPw==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.2.2.tgz",
|
||||
"integrity": "sha512-CG/NvzXfemUAm5Y4Guh5eEaJYHtkG7kKNpXEJHp9QpxsFVB5/qKvYWoMaq4sa99ccZ0hM3MK8vQV9XPZB4357A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cacache": "10.0.4",
|
||||
"find-cache-dir": "1.0.0",
|
||||
"schema-utils": "0.3.0",
|
||||
"schema-utils": "0.4.5",
|
||||
"serialize-javascript": "1.4.0",
|
||||
"source-map": "0.6.1",
|
||||
"uglify-es": "3.3.9",
|
||||
"webpack-sources": "1.1.0",
|
||||
"worker-farm": "1.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.2.1.tgz",
|
||||
"integrity": "sha1-KKarxJOiq+D7TIUHrK7bQ/pVBnE=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "1.0.0",
|
||||
"fast-json-stable-stringify": "2.0.0",
|
||||
"json-schema-traverse": "0.3.1"
|
||||
}
|
||||
},
|
||||
"ajv-keywords": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.1.0.tgz",
|
||||
"integrity": "sha1-rCsnk5xUPpXSwG5/f1wnvkqlQ74=",
|
||||
"dev": true
|
||||
},
|
||||
"schema-utils": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.5.tgz",
|
||||
"integrity": "sha512-yYrjb9TX2k/J1Y5UNy3KYdZq10xhYcF8nMpAW6o3hy6Q8WSIEf9lJHG/ePnOBfziPM3fvQwfOwa13U/Fh8qTfA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ajv": "6.2.1",
|
||||
"ajv-keywords": "3.1.0"
|
||||
}
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"jquery-i18next": "1.2.0",
|
||||
"js-md5": "0.6.1",
|
||||
"jwt-decode": "2.2.0",
|
||||
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#d5389ace55686b96f1526c32fda26faf0d9a672f",
|
||||
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#410ad5e76d33b1422ce34f83719eda7dfd584e22",
|
||||
"lodash": "4.17.4",
|
||||
"moment": "2.19.4",
|
||||
"nuclear-js": "1.4.0",
|
||||
@@ -55,6 +55,7 @@
|
||||
"react-i18next": "4.8.0",
|
||||
"react-native": "0.51.0",
|
||||
"react-native-background-timer": "2.0.0",
|
||||
"react-native-calendar-events": "1.4.3",
|
||||
"react-native-callstats": "3.27.0",
|
||||
"react-native-fetch-blob": "0.10.8",
|
||||
"react-native-img-cache": "1.5.2",
|
||||
@@ -62,8 +63,9 @@
|
||||
"react-native-keep-awake": "2.0.6",
|
||||
"react-native-locale-detector": "github:jitsi/react-native-locale-detector#cc76092fc4335488a28a9529c8b50afae2c3ecdc",
|
||||
"react-native-prompt": "1.0.0",
|
||||
"react-native-sound": "0.10.4",
|
||||
"react-native-vector-icons": "4.4.2",
|
||||
"react-native-webrtc": "github:jitsi/react-native-webrtc#806435b41fa152a8239ebeb7d002d1c6e979be86",
|
||||
"react-native-webrtc": "github:jitsi/react-native-webrtc#626818af40384356617f70366133317b6a475171",
|
||||
"react-redux": "5.0.6",
|
||||
"redux": "3.7.2",
|
||||
"redux-thunk": "2.2.0",
|
||||
@@ -99,7 +101,7 @@
|
||||
"precommit-hook": "3.0.0",
|
||||
"string-replace-loader": "1.3.0",
|
||||
"style-loader": "0.19.0",
|
||||
"uglifyjs-webpack-plugin": "1.1.2",
|
||||
"uglifyjs-webpack-plugin": "1.2.2",
|
||||
"whatwg-fetch": "2.0.3",
|
||||
"webpack": "3.9.1",
|
||||
"webpack-dev-server": "2.9.5"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import '../../base/profile';
|
||||
import { Fragment, RouteRegistry } from '../../base/react';
|
||||
import { MiddlewareRegistry, ReducerRegistry } from '../../base/redux';
|
||||
import { SoundCollection } from '../../base/sounds';
|
||||
import { PersistenceRegistry } from '../../base/storage';
|
||||
import { toURLString } from '../../base/util';
|
||||
import { OverlayContainer } from '../../overlay';
|
||||
@@ -274,6 +275,7 @@ export class AbstractApp extends Component {
|
||||
<Provider store = { this._getStore() }>
|
||||
<Fragment>
|
||||
{ this._createElement(component) }
|
||||
<SoundCollection />
|
||||
<OverlayContainer />
|
||||
</Fragment>
|
||||
</Provider>
|
||||
@@ -501,7 +503,7 @@ export class AbstractApp extends Component {
|
||||
/**
|
||||
* Navigates this {@code AbstractApp} to (i.e. opens) a specific URL.
|
||||
*
|
||||
* @param {string|Object} url - The URL to navigate this {@code AbstractApp}
|
||||
* @param {Object|string} url - The URL to navigate this {@code AbstractApp}
|
||||
* to (i.e. the URL to open).
|
||||
* @protected
|
||||
* @returns {void}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
|
||||
import '../../base/responsive-ui';
|
||||
import { getLocationContextRoot } from '../../base/util';
|
||||
import '../../chat';
|
||||
import '../../room-lock';
|
||||
|
||||
import { AbstractApp } from './AbstractApp';
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
{
|
||||
"IcoMoonType": "selection",
|
||||
"icons": [
|
||||
{
|
||||
"icon": {
|
||||
"paths": [
|
||||
"M512 342h64v180l150 90-32 52-182-110v-212zM554 128c212 0 384 172 384 384s-172 384-384 384c-106 0-200-42-270-112l60-62c54 54 128 88 210 88 166 0 300-132 300-298s-134-298-300-298-298 132-298 298h128l-172 172-4-6-166-166h128c0-212 172-384 384-384z"
|
||||
],
|
||||
"attrs": [],
|
||||
"isMulticolor": false,
|
||||
"isMulticolor2": false,
|
||||
"tags": [
|
||||
"restore"
|
||||
],
|
||||
"defaultCode": 59571,
|
||||
"grid": 24
|
||||
},
|
||||
"attrs": [],
|
||||
"properties": {
|
||||
"ligatures": "history, restore",
|
||||
"id": 385,
|
||||
"order": 930,
|
||||
"prevSize": 24,
|
||||
"code": 59571,
|
||||
"name": "restore"
|
||||
},
|
||||
"setIdx": 0,
|
||||
"setId": 2,
|
||||
"iconIdx": 385
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
"paths": [
|
||||
"M426 256l256 256-256 256-60-60 196-196-196-196z"
|
||||
],
|
||||
"attrs": [],
|
||||
"isMulticolor": false,
|
||||
"isMulticolor2": false,
|
||||
"tags": [
|
||||
"navigate_next"
|
||||
],
|
||||
"defaultCode": 58377,
|
||||
"grid": 24
|
||||
},
|
||||
"attrs": [],
|
||||
"properties": {
|
||||
"ligatures": "chevron_right, navigate_next",
|
||||
"id": 153,
|
||||
"order": 927,
|
||||
"prevSize": 24,
|
||||
"code": 58377,
|
||||
"name": "navigate_next"
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 0
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
"paths": [
|
||||
@@ -24,9 +78,9 @@
|
||||
"code": 58834,
|
||||
"name": "menu"
|
||||
},
|
||||
"setIdx": 0,
|
||||
"setId": 2,
|
||||
"iconIdx": 489
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 1
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -53,7 +107,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 0
|
||||
"iconIdx": 2
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -80,7 +134,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 1
|
||||
"iconIdx": 3
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -107,7 +161,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 2
|
||||
"iconIdx": 4
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -134,7 +188,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 3
|
||||
"iconIdx": 5
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -154,14 +208,14 @@
|
||||
"properties": {
|
||||
"ligatures": "timer",
|
||||
"id": 760,
|
||||
"order": 916,
|
||||
"order": 928,
|
||||
"prevSize": 24,
|
||||
"code": 58405,
|
||||
"name": "timer"
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 4
|
||||
"iconIdx": 6
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -188,7 +242,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 5
|
||||
"iconIdx": 7
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -215,7 +269,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 6
|
||||
"iconIdx": 8
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -242,7 +296,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 7
|
||||
"iconIdx": 9
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -269,7 +323,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 8
|
||||
"iconIdx": 10
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -298,7 +352,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 9
|
||||
"iconIdx": 11
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -325,7 +379,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 10
|
||||
"iconIdx": 12
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -352,7 +406,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 11
|
||||
"iconIdx": 13
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -381,7 +435,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 12
|
||||
"iconIdx": 14
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -410,7 +464,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 13
|
||||
"iconIdx": 15
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -439,7 +493,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 14
|
||||
"iconIdx": 16
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -468,7 +522,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 15
|
||||
"iconIdx": 17
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -497,7 +551,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 16
|
||||
"iconIdx": 18
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -523,7 +577,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 17
|
||||
"iconIdx": 19
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -549,7 +603,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 18
|
||||
"iconIdx": 20
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -575,7 +629,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 19
|
||||
"iconIdx": 21
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -601,7 +655,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 20
|
||||
"iconIdx": 22
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -627,7 +681,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 21
|
||||
"iconIdx": 23
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -653,7 +707,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 22
|
||||
"iconIdx": 24
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -679,7 +733,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 23
|
||||
"iconIdx": 25
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -705,7 +759,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 24
|
||||
"iconIdx": 26
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -731,7 +785,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 25
|
||||
"iconIdx": 27
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -757,7 +811,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 26
|
||||
"iconIdx": 28
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -783,7 +837,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 27
|
||||
"iconIdx": 29
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -809,7 +863,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 28
|
||||
"iconIdx": 30
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -835,7 +889,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 29
|
||||
"iconIdx": 31
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -861,7 +915,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 30
|
||||
"iconIdx": 32
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -887,7 +941,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 31
|
||||
"iconIdx": 33
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -913,7 +967,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 32
|
||||
"iconIdx": 34
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -939,7 +993,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 33
|
||||
"iconIdx": 35
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -965,7 +1019,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 34
|
||||
"iconIdx": 36
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -991,7 +1045,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 35
|
||||
"iconIdx": 37
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1017,7 +1071,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 36
|
||||
"iconIdx": 38
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1043,7 +1097,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 37
|
||||
"iconIdx": 39
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1069,7 +1123,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 38
|
||||
"iconIdx": 40
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1095,7 +1149,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 39
|
||||
"iconIdx": 41
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1121,7 +1175,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 40
|
||||
"iconIdx": 42
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1147,7 +1201,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 41
|
||||
"iconIdx": 43
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1173,7 +1227,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 42
|
||||
"iconIdx": 44
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1199,7 +1253,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 43
|
||||
"iconIdx": 45
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1225,7 +1279,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 44
|
||||
"iconIdx": 46
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1251,7 +1305,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 45
|
||||
"iconIdx": 47
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1280,7 +1334,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 46
|
||||
"iconIdx": 48
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1310,7 +1364,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 47
|
||||
"iconIdx": 49
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1340,7 +1394,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 48
|
||||
"iconIdx": 50
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1366,7 +1420,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 49
|
||||
"iconIdx": 51
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1392,7 +1446,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 50
|
||||
"iconIdx": 52
|
||||
},
|
||||
{
|
||||
"icon": {
|
||||
@@ -1418,7 +1472,7 @@
|
||||
},
|
||||
"setIdx": 1,
|
||||
"setId": 1,
|
||||
"iconIdx": 51
|
||||
"iconIdx": 53
|
||||
}
|
||||
],
|
||||
"height": 1024,
|
||||
|
||||
82
react/features/base/i18n/dateUtil.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
import i18next from './i18next';
|
||||
|
||||
// MomentJS uses static language bundle loading, so in order to support dynamic
|
||||
// language selection in the app we need to load all bundles that we support in
|
||||
// the app.
|
||||
require('moment/locale/bg');
|
||||
require('moment/locale/de');
|
||||
require('moment/locale/eo');
|
||||
require('moment/locale/es');
|
||||
require('moment/locale/fr');
|
||||
require('moment/locale/hy-am');
|
||||
require('moment/locale/it');
|
||||
require('moment/locale/nb');
|
||||
|
||||
// OC is not available. Please submit OC translation to the MomentJS project.
|
||||
|
||||
require('moment/locale/pl');
|
||||
require('moment/locale/pt');
|
||||
require('moment/locale/pt-br');
|
||||
require('moment/locale/ru');
|
||||
require('moment/locale/sk');
|
||||
require('moment/locale/sl');
|
||||
require('moment/locale/sv');
|
||||
require('moment/locale/tr');
|
||||
require('moment/locale/zh-cn');
|
||||
|
||||
/**
|
||||
* Returns a localized date formatter initialized with a specific {@code Date}
|
||||
* or timestamp ({@code number}).
|
||||
*
|
||||
* @private
|
||||
* @param {Date | number} dateOrTimeStamp - The date or unix timestamp (ms)
|
||||
* to format.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getLocalizedDateFormatter(dateOrTimeStamp: Date | number) {
|
||||
return moment(dateOrTimeStamp).locale(_getSupportedLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a localized duration formatter initialized with a
|
||||
* specific duration ({@code number}).
|
||||
*
|
||||
* @private
|
||||
* @param {number} duration - The duration (ms)
|
||||
* to format.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getLocalizedDurationFormatter(duration: number) {
|
||||
return moment.duration(duration).locale(_getSupportedLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* A lenient locale matcher to match language and dialect if possible.
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getSupportedLocale() {
|
||||
const i18nLocale = i18next.language;
|
||||
let supportedLocale;
|
||||
|
||||
if (i18nLocale) {
|
||||
const localeRegexp = new RegExp('^([a-z]{2,2})(-)*([a-z]{2,2})*$');
|
||||
const localeResult = localeRegexp.exec(i18nLocale.toLowerCase());
|
||||
|
||||
if (localeResult) {
|
||||
const currentLocaleRegexp
|
||||
= new RegExp(
|
||||
`^${localeResult[1]}(-)*${`(${localeResult[3]})*` || ''}`);
|
||||
|
||||
supportedLocale
|
||||
= moment.locales().find(lang => currentLocaleRegexp.exec(lang));
|
||||
}
|
||||
}
|
||||
|
||||
return supportedLocale || 'en';
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
export * from './dateUtil';
|
||||
export * from './functions';
|
||||
|
||||
// TODO Eventually (e.g. when the non-React Web app is rewritten into React), it
|
||||
|
||||
@@ -254,7 +254,7 @@ class CalleeInfo extends Component<Props, State> {
|
||||
if (this.state.renderAudio && this.state.ringing) {
|
||||
return (
|
||||
<Audio
|
||||
ref = { this._setAudio }
|
||||
setRef = { this._setAudio }
|
||||
src = './sounds/ring.ogg' />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,6 @@ function _makePromiseAware(
|
||||
afterCallbacks: number) {
|
||||
return function(...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if (args.length <= beforeCallbacks + afterCallbacks) {
|
||||
args.splice(beforeCallbacks, 0, resolve, reject);
|
||||
}
|
||||
|
||||
@@ -1,116 +1,121 @@
|
||||
// @flow
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Component } from 'react';
|
||||
|
||||
/**
|
||||
* Describes audio element interface used in the base/media feature for audio
|
||||
* playback.
|
||||
*/
|
||||
export type AudioElement = {
|
||||
pause: () => void,
|
||||
play: () => void,
|
||||
setSinkId?: string => void
|
||||
};
|
||||
|
||||
/**
|
||||
* {@code AbstractAudio} component's property types.
|
||||
*/
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* A callback which will be called with {@code AbstractAudio} instance once
|
||||
* the audio element is loaded.
|
||||
*/
|
||||
setRef?: ?AudioElement => void,
|
||||
|
||||
/**
|
||||
* The URL of a media resource to use in the element.
|
||||
*
|
||||
* NOTE on react-native sound files are imported through 'require' and then
|
||||
* passed as the 'src' parameter which means their type will be 'any'.
|
||||
*
|
||||
* @type {Object | string}
|
||||
*/
|
||||
src: Object | string,
|
||||
stream: Object
|
||||
}
|
||||
|
||||
/**
|
||||
* The React {@link Component} which is similar to Web's
|
||||
* {@code HTMLAudioElement}.
|
||||
*/
|
||||
export default class AbstractAudio extends Component<*> {
|
||||
export default class AbstractAudio extends Component<Props> {
|
||||
/**
|
||||
* The (reference to the) {@link ReactElement} which actually implements
|
||||
* this {@code AbstractAudio}.
|
||||
* The {@link AudioElement} instance which implements the audio playback
|
||||
* functionality.
|
||||
*/
|
||||
_ref: ?Object;
|
||||
|
||||
_setRef: Function;
|
||||
|
||||
/**
|
||||
* {@code AbstractAudio} component's property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* The URL of a media resource to use in the element.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
src: PropTypes.string,
|
||||
stream: PropTypes.object
|
||||
};
|
||||
_audioElementImpl: ?AudioElement;
|
||||
|
||||
/**
|
||||
* Initializes a new {@code AbstractAudio} instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* @param {Props} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: Object) {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._setRef = this._setRef.bind(this);
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this.setAudioElementImpl = this.setAudioElementImpl.bind(this);
|
||||
}
|
||||
|
||||
pause: () => void;
|
||||
|
||||
/**
|
||||
* Attempts to pause the playback of the media.
|
||||
*
|
||||
* @public
|
||||
* @returns {void}
|
||||
*/
|
||||
pause() {
|
||||
this._ref && typeof this._ref.pause === 'function' && this._ref.pause();
|
||||
pause(): void {
|
||||
this._audioElementImpl && this._audioElementImpl.pause();
|
||||
}
|
||||
|
||||
play: () => void;
|
||||
|
||||
/**
|
||||
* Attempts to being the playback of the media.
|
||||
*
|
||||
* @public
|
||||
* @returns {void}
|
||||
*/
|
||||
play() {
|
||||
this._ref && typeof this._ref.play === 'function' && this._ref.play();
|
||||
play(): void {
|
||||
this._audioElementImpl && this._audioElementImpl.play();
|
||||
}
|
||||
|
||||
setAudioElementImpl: ?AudioElement => void;
|
||||
|
||||
/**
|
||||
* Renders this {@code AbstractAudio} as a React {@link Component} of a
|
||||
* specific type.
|
||||
* Set the (reference to the) {@link AudioElement} object which implements
|
||||
* the audio playback functionality.
|
||||
*
|
||||
* @param {string|ReactClass} type - The type of the React {@code Component}
|
||||
* which is to be rendered.
|
||||
* @param {Object|undefined} props - The read-only React {@code Component}
|
||||
* properties, if any, to render. If {@code undefined}, the props of this
|
||||
* instance will be rendered.
|
||||
* @param {AudioElement} element - The {@link AudioElement} instance
|
||||
* which implements the audio playback functionality.
|
||||
* @protected
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_render(type, props) {
|
||||
const {
|
||||
children,
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
// The following properties are consumed by React itself so they are
|
||||
// to not be propagated.
|
||||
ref,
|
||||
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
...filteredProps
|
||||
} = props || this.props;
|
||||
|
||||
return (
|
||||
React.createElement(
|
||||
type,
|
||||
{
|
||||
...filteredProps,
|
||||
ref: this._setRef
|
||||
},
|
||||
children));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the (reference to the) {@link ReactElement} which actually implements
|
||||
* this {@code AbstractAudio}.
|
||||
*
|
||||
* @param {Object} ref - The (reference to the) {@code ReactElement} which
|
||||
* actually implements this {@code AbstractAudio}.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setRef(ref) {
|
||||
this._ref = ref;
|
||||
setAudioElementImpl(element: ?AudioElement): void {
|
||||
this._audioElementImpl = element;
|
||||
|
||||
// setRef
|
||||
const { setRef } = this.props;
|
||||
|
||||
// $FlowFixMe
|
||||
typeof setRef === 'function' && setRef(element ? this : null);
|
||||
}
|
||||
|
||||
setSinkId: string => void;
|
||||
|
||||
/**
|
||||
* Sets the sink ID (output device ID) on the underlying audio element.
|
||||
* NOTE: Currently, implemented only on Web.
|
||||
*
|
||||
* @param {string} sinkId - The sink ID (output device ID).
|
||||
* @returns {void}
|
||||
*/
|
||||
setSinkId(sinkId: string): void {
|
||||
this._audioElementImpl
|
||||
&& typeof this._audioElementImpl.setSinkId === 'function'
|
||||
&& this._audioElementImpl.setSinkId(sinkId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,66 @@
|
||||
/* @flow */
|
||||
|
||||
import Sound from 'react-native-sound';
|
||||
|
||||
import AbstractAudio from '../AbstractAudio';
|
||||
|
||||
const logger = require('jitsi-meet-logger').getLogger(__filename);
|
||||
|
||||
/**
|
||||
* The React Native/mobile {@link Component} which is similar to Web's
|
||||
* {@code HTMLAudioElement} and wraps around react-native-webrtc's
|
||||
* {@link RTCView}.
|
||||
*/
|
||||
export default class Audio extends AbstractAudio {
|
||||
|
||||
/**
|
||||
* {@code Audio} component's property types.
|
||||
*
|
||||
* @static
|
||||
* Reference to 'react-native-sound} {@link Sound} instance.
|
||||
*/
|
||||
static propTypes = AbstractAudio.propTypes;
|
||||
_sound: Sound
|
||||
|
||||
/**
|
||||
* A callback passed to the 'react-native-sound''s {@link Sound} instance,
|
||||
* called when loading sound is finished.
|
||||
*
|
||||
* @param {Object} error - The error object passed by
|
||||
* the 'react-native-sound' library.
|
||||
* @returns {void}
|
||||
* @private
|
||||
*/
|
||||
_soundLoadedCallback(error) {
|
||||
if (error) {
|
||||
logger.error('Failed to load sound', error);
|
||||
} else {
|
||||
this.setAudioElementImpl(this._sound);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Will load the sound, after the component did mount.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
componentDidMount() {
|
||||
this._sound
|
||||
= this.props.src
|
||||
? new Sound(
|
||||
this.props.src,
|
||||
this._soundLoadedCallback.bind(this))
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will dispose sound resources (if any) when component is about to unmount.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
if (this._sound) {
|
||||
this.setAudioElementImpl(null);
|
||||
this._sound.release();
|
||||
this._sound = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* @flow */
|
||||
// @flow
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import AbstractAudio from '../AbstractAudio';
|
||||
import type { AudioElement } from '../AbstractAudio';
|
||||
|
||||
/**
|
||||
* The React/Web {@link Component} which is similar to and wraps around
|
||||
@@ -8,11 +11,28 @@ import AbstractAudio from '../AbstractAudio';
|
||||
*/
|
||||
export default class Audio extends AbstractAudio {
|
||||
/**
|
||||
* {@code Audio} component's property types.
|
||||
*
|
||||
* @static
|
||||
* Set to <code>true</code> when the whole file is loaded.
|
||||
*/
|
||||
static propTypes = AbstractAudio.propTypes;
|
||||
_audioFileLoaded: boolean;
|
||||
|
||||
/**
|
||||
* Reference to the HTML audio element, stored until the file is ready.
|
||||
*/
|
||||
_ref: ?AudioElement;
|
||||
|
||||
/**
|
||||
* Creates new <code>Audio</code> element instance with given props.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: Object) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._onCanPlayThrough = this._onCanPlayThrough.bind(this);
|
||||
this._setRef = this._setRef.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
@@ -21,6 +41,67 @@ export default class Audio extends AbstractAudio {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
return super._render('audio');
|
||||
return (
|
||||
<audio
|
||||
onCanPlayThrough = { this._onCanPlayThrough }
|
||||
preload = 'auto'
|
||||
|
||||
// $FlowFixMe
|
||||
ref = { this._setRef }
|
||||
src = { this.props.src } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If audio element reference has been set and the file has been
|
||||
* loaded then {@link setAudioElementImpl} will be called to eventually add
|
||||
* the audio to the Redux store.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_maybeSetAudioElementImpl() {
|
||||
if (this._ref && this._audioFileLoaded) {
|
||||
this.setAudioElementImpl(this._ref);
|
||||
}
|
||||
}
|
||||
|
||||
_onCanPlayThrough: () => void;
|
||||
|
||||
/**
|
||||
* Called when 'canplaythrough' event is triggered on the audio element,
|
||||
* which means that the whole file has been loaded.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onCanPlayThrough() {
|
||||
this._audioFileLoaded = true;
|
||||
this._maybeSetAudioElementImpl();
|
||||
}
|
||||
|
||||
_setRef: (?AudioElement) => void;
|
||||
|
||||
/**
|
||||
* Sets the reference to the HTML audio element.
|
||||
*
|
||||
* @param {HTMLAudioElement} audioElement - The HTML audio element instance.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setRef(audioElement: ?AudioElement) {
|
||||
this._ref = audioElement;
|
||||
|
||||
if (audioElement) {
|
||||
this._maybeSetAudioElementImpl();
|
||||
} else {
|
||||
// AbstractAudioElement is supposed to trigger "removeAudio" only if
|
||||
// it was previously added, so it's safe to just call it.
|
||||
this.setAudioElementImpl(null);
|
||||
|
||||
// Reset the loaded flag, as the audio element is being removed from
|
||||
// the DOM tree.
|
||||
this._audioFileLoaded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,20 @@ export const LOCAL_PARTICIPANT_DEFAULT_ID = 'local';
|
||||
*/
|
||||
export const MAX_DISPLAY_NAME_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when new remote participant joins
|
||||
* the room.
|
||||
* @type {string}
|
||||
*/
|
||||
export const PARTICIPANT_JOINED_SOUND_ID = 'PARTICIPANT_JOINED_SOUND';
|
||||
|
||||
/**
|
||||
* The identifier of the sound to be played when remote participant leaves
|
||||
* the room.
|
||||
* @type {string}
|
||||
*/
|
||||
export const PARTICIPANT_LEFT_SOUND_ID = 'PARTICIPANT_LEFT_SOUND';
|
||||
|
||||
/**
|
||||
* The set of possible XMPP MUC roles for conference participants.
|
||||
*
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import UIEvents from '../../../../service/UI/UIEvents';
|
||||
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../app';
|
||||
import {
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_LEFT
|
||||
} from '../conference';
|
||||
import { MiddlewareRegistry } from '../redux';
|
||||
import { playSound, registerSound, unregisterSound } from '../sounds';
|
||||
|
||||
import { localParticipantIdChanged } from './actions';
|
||||
import {
|
||||
@@ -14,13 +16,23 @@ import {
|
||||
MUTE_REMOTE_PARTICIPANT,
|
||||
PARTICIPANT_DISPLAY_NAME_CHANGED,
|
||||
PARTICIPANT_JOINED,
|
||||
PARTICIPANT_LEFT,
|
||||
PARTICIPANT_UPDATED
|
||||
} from './actionTypes';
|
||||
import { LOCAL_PARTICIPANT_DEFAULT_ID } from './constants';
|
||||
import {
|
||||
LOCAL_PARTICIPANT_DEFAULT_ID,
|
||||
PARTICIPANT_JOINED_SOUND_ID,
|
||||
PARTICIPANT_LEFT_SOUND_ID
|
||||
} from './constants';
|
||||
import {
|
||||
getAvatarURLByParticipantId,
|
||||
getLocalParticipant
|
||||
getLocalParticipant,
|
||||
getParticipantCount
|
||||
} from './functions';
|
||||
import {
|
||||
PARTICIPANT_JOINED_SRC,
|
||||
PARTICIPANT_LEFT_SRC
|
||||
} from './sounds';
|
||||
|
||||
declare var APP: Object;
|
||||
|
||||
@@ -34,7 +46,18 @@ declare var APP: Object;
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const { conference } = store.getState()['features/base/conference'];
|
||||
|
||||
if (action.type === PARTICIPANT_JOINED
|
||||
|| action.type === PARTICIPANT_LEFT) {
|
||||
_maybePlaySounds(store, action);
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case APP_WILL_MOUNT:
|
||||
_registerSounds(store);
|
||||
break;
|
||||
case APP_WILL_UNMOUNT:
|
||||
_unregisterSounds(store);
|
||||
break;
|
||||
case CONFERENCE_JOINED:
|
||||
store.dispatch(localParticipantIdChanged(action.conference.myUserId()));
|
||||
break;
|
||||
@@ -100,3 +123,59 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Plays sounds when participants join/leave conference.
|
||||
*
|
||||
* @param {Store} store - The Redux store.
|
||||
* @param {Action} action - The Redux action. Should be either
|
||||
* {@link PARTICIPANT_JOINED} or {@link PARTICIPANT_LEFT}.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _maybePlaySounds({ getState, dispatch }, action) {
|
||||
const state = getState();
|
||||
const { startAudioMuted } = state['features/base/config'];
|
||||
|
||||
// We're not playing sounds for local participant
|
||||
// nor when the user is joining past the "startAudioMuted" limit.
|
||||
// The intention there was to not play user joined notification in big
|
||||
// conferences where 100th person is joining.
|
||||
if (!action.participant.local
|
||||
&& (!startAudioMuted
|
||||
|| getParticipantCount(state) < startAudioMuted)) {
|
||||
if (action.type === PARTICIPANT_JOINED) {
|
||||
dispatch(playSound(PARTICIPANT_JOINED_SOUND_ID));
|
||||
} else if (action.type === PARTICIPANT_LEFT) {
|
||||
dispatch(playSound(PARTICIPANT_LEFT_SOUND_ID));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers sounds related with the participants feature.
|
||||
*
|
||||
* @param {Store} store - The Redux store.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _registerSounds({ dispatch }) {
|
||||
dispatch(
|
||||
registerSound(PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_JOINED_SRC));
|
||||
dispatch(
|
||||
registerSound(PARTICIPANT_LEFT_SOUND_ID, PARTICIPANT_LEFT_SRC));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters sounds related with the participants feature.
|
||||
*
|
||||
* @param {Store} store - The Redux store.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _unregisterSounds({ dispatch }) {
|
||||
dispatch(
|
||||
unregisterSound(PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_JOINED_SRC));
|
||||
dispatch(
|
||||
unregisterSound(PARTICIPANT_LEFT_SOUND_ID, PARTICIPANT_LEFT_SRC));
|
||||
}
|
||||
|
||||
13
react/features/base/participants/sounds.native.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Points to the sound file which will be played when new participant joins
|
||||
* the conference.
|
||||
*/
|
||||
export const PARTICIPANT_JOINED_SRC
|
||||
= require('../../../../sounds/joined.wav');
|
||||
|
||||
/**
|
||||
* Points to the sound file which will be played when any participant leaves
|
||||
* the conference.
|
||||
*/
|
||||
export const PARTICIPANT_LEFT_SRC
|
||||
= require('../../../../sounds/left.wav');
|
||||
11
react/features/base/participants/sounds.web.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Points to the sound file which will be played when new participant joins
|
||||
* the conference.
|
||||
*/
|
||||
export const PARTICIPANT_JOINED_SRC = 'sounds/joined.wav';
|
||||
|
||||
/**
|
||||
* Points to the sound file which will be played when any participant leaves
|
||||
* the conference.
|
||||
*/
|
||||
export const PARTICIPANT_LEFT_SRC = 'sounds/left.wav';
|
||||
@@ -0,0 +1,295 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
SafeAreaView,
|
||||
SectionList,
|
||||
Text,
|
||||
TouchableHighlight,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import styles, { UNDERLAY_COLOR } from './styles';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Indicates if the list is disabled or not.
|
||||
*/
|
||||
disabled: boolean,
|
||||
|
||||
/**
|
||||
* Function to be invoked when an item is pressed. The item's URL is passed.
|
||||
*/
|
||||
onPress: Function,
|
||||
|
||||
/**
|
||||
* Function to be invoked when pull-to-refresh is performed.
|
||||
*/
|
||||
onRefresh: Function,
|
||||
|
||||
/**
|
||||
* Sections to be rendered in the following format:
|
||||
*
|
||||
* [
|
||||
* {
|
||||
* title: string, <- section title
|
||||
* key: string, <- unique key for the section
|
||||
* data: [ <- Array of items in the section
|
||||
* {
|
||||
* colorBase: string, <- the color base of the avatar
|
||||
* title: string, <- item title
|
||||
* url: string, <- item url
|
||||
* lines: Array<string> <- additional lines to be rendered
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
sections: Array<Object>
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a general section list to display items that have a URL
|
||||
* property and navigates to (probably) meetings, such as the recent list
|
||||
* or the meeting list components.
|
||||
*/
|
||||
export default class NavigateSectionList extends Component<Props> {
|
||||
/**
|
||||
* Constructor of the NavigateSectionList component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this._getAvatarColor = this._getAvatarColor.bind(this);
|
||||
this._getItemKey = this._getItemKey.bind(this);
|
||||
this._onPress = this._onPress.bind(this);
|
||||
this._onRefresh = this._onRefresh.bind(this);
|
||||
this._renderItem = this._renderItem.bind(this);
|
||||
this._renderItemLine = this._renderItemLine.bind(this);
|
||||
this._renderItemLines = this._renderItemLines.bind(this);
|
||||
this._renderSection = this._renderSection.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's Component.render function.
|
||||
* Note: we don't use the refreshing value yet, because refreshing of these
|
||||
* lists is super quick, no need to complicate the code - yet.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { sections } = this.props;
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style = { styles.container } >
|
||||
<SectionList
|
||||
keyExtractor = { this._getItemKey }
|
||||
onRefresh = { this._onRefresh }
|
||||
refreshing = { false }
|
||||
renderItem = { this._renderItem }
|
||||
renderSectionHeader = { this._renderSection }
|
||||
sections = { sections }
|
||||
style = { styles.list } />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty section object.
|
||||
*
|
||||
* @private
|
||||
* @param {string} title - The title of the section.
|
||||
* @param {string} key - The key of the section. It must be unique.
|
||||
* @returns {Object}
|
||||
*/
|
||||
static createSection(title, key) {
|
||||
return {
|
||||
data: [],
|
||||
key,
|
||||
title
|
||||
};
|
||||
}
|
||||
|
||||
_getAvatarColor: string => Object
|
||||
|
||||
/**
|
||||
* Returns a style (color) based on the string that determines the
|
||||
* color of the avatar.
|
||||
*
|
||||
* @param {string} colorBase - The string that is the base of the color.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getAvatarColor(colorBase) {
|
||||
if (!colorBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let nameHash = 0;
|
||||
|
||||
for (let i = 0; i < colorBase.length; i++) {
|
||||
nameHash += colorBase.codePointAt(i);
|
||||
}
|
||||
|
||||
return styles[`avatarColor${(nameHash % 5) + 1}`];
|
||||
}
|
||||
|
||||
_getItemKey: (Object, number) => string;
|
||||
|
||||
/**
|
||||
* Generates a unique id to every item.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item - The item.
|
||||
* @param {number} index - The item index.
|
||||
* @returns {string}
|
||||
*/
|
||||
_getItemKey(item, index) {
|
||||
return `${index}-${item.key}`;
|
||||
}
|
||||
|
||||
_onPress: string => Function
|
||||
|
||||
/**
|
||||
* Returns a function that is used in the onPress callback of the items.
|
||||
*
|
||||
* @private
|
||||
* @param {string} url - The URL of the item to navigate to.
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onPress(url) {
|
||||
return () => {
|
||||
const { disabled, onPress } = this.props;
|
||||
|
||||
!disabled && url && typeof onPress === 'function' && onPress(url);
|
||||
};
|
||||
}
|
||||
|
||||
_onRefresh: () => void
|
||||
|
||||
/**
|
||||
* Invokes the onRefresh callback if present.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onRefresh() {
|
||||
const { onRefresh } = this.props;
|
||||
|
||||
if (typeof onRefresh === 'function') {
|
||||
onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
_renderItem: Object => Object;
|
||||
|
||||
/**
|
||||
* Renders a single item in the list.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} listItem - The item to render.
|
||||
* @returns {Component}
|
||||
*/
|
||||
_renderItem(listItem) {
|
||||
const { item } = listItem;
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
onPress = { this._onPress(item.url) }
|
||||
underlayColor = { UNDERLAY_COLOR }>
|
||||
<View style = { styles.listItem }>
|
||||
<View style = { styles.avatarContainer } >
|
||||
<View
|
||||
style = { [
|
||||
styles.avatar,
|
||||
this._getAvatarColor(item.colorBase)
|
||||
] } >
|
||||
<Text style = { styles.avatarContent }>
|
||||
{ item.title.substr(0, 1).toUpperCase() }
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style = { styles.listItemDetails }>
|
||||
<Text
|
||||
numberOfLines = { 1 }
|
||||
style = { [
|
||||
styles.listItemText,
|
||||
styles.listItemTitle
|
||||
] }>
|
||||
{ item.title }
|
||||
</Text>
|
||||
{
|
||||
this._renderItemLines(item.lines)
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
_renderItemLine: (string, number) => React$Node;
|
||||
|
||||
/**
|
||||
* Renders a single line from the additional lines.
|
||||
*
|
||||
* @private
|
||||
* @param {string} line - The line text.
|
||||
* @param {number} index - The index of the line.
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
_renderItemLine(line, index) {
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
key = { index }
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.listItemText }>
|
||||
{ line }
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
_renderItemLines: (Array<string>) => Array<React$Node>;
|
||||
|
||||
/**
|
||||
* Renders the additional item lines, if any.
|
||||
*
|
||||
* @private
|
||||
* @param {Array<string>} lines - The lines to render.
|
||||
* @returns {Array<React$Node>}
|
||||
*/
|
||||
_renderItemLines(lines) {
|
||||
if (lines && lines.length) {
|
||||
return lines.map((line, index) =>
|
||||
this._renderItemLine(line, index)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_renderSection: Object => Object
|
||||
|
||||
/**
|
||||
* Renders a section title.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} section - The section being rendered.
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
_renderSection(section) {
|
||||
return (
|
||||
<View style = { styles.listSection }>
|
||||
<Text style = { styles.listSectionText }>
|
||||
{ section.section.title }
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as Container } from './Container';
|
||||
export { default as Header } from './Header';
|
||||
export { default as NavigateSectionList } from './NavigateSectionList';
|
||||
export { default as Link } from './Link';
|
||||
export { default as LoadingIndicator } from './LoadingIndicator';
|
||||
export { default as SideBar } from './SideBar';
|
||||
|
||||
@@ -4,20 +4,20 @@ import {
|
||||
createStyleSheet
|
||||
} from '../../../styles';
|
||||
|
||||
const AVATAR_OPACITY = 0.4;
|
||||
const AVATAR_SIZE = 65;
|
||||
const HEADER_COLOR = ColorPalette.blue;
|
||||
|
||||
// Header height is from iOS guidelines. Also, this looks good.
|
||||
const HEADER_HEIGHT = 44;
|
||||
const OVERLAY_FONT_COLOR = 'rgba(255, 255, 255, 0.6)';
|
||||
|
||||
export const HEADER_PADDING = BoxModel.padding;
|
||||
export const STATUSBAR_COLOR = ColorPalette.blueHighlight;
|
||||
export const SIDEBAR_WIDTH = 250;
|
||||
export const UNDERLAY_COLOR = 'rgba(255, 255, 255, 0.2)';
|
||||
|
||||
/**
|
||||
* The styles of the generic React {@code Components} of the app.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
|
||||
const HEADER_STYLES = {
|
||||
/**
|
||||
* Platform specific header button (e.g. back, menu...etc).
|
||||
*/
|
||||
@@ -68,8 +68,124 @@ export default createStyleSheet({
|
||||
height: HEADER_HEIGHT,
|
||||
justifyContent: 'flex-start',
|
||||
padding: HEADER_PADDING
|
||||
}
|
||||
};
|
||||
|
||||
const SECTION_LIST_STYLES = {
|
||||
/**
|
||||
* The style of the actual avatar.
|
||||
*/
|
||||
avatar: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: `rgba(23, 160, 219, ${AVATAR_OPACITY})`,
|
||||
borderRadius: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
justifyContent: 'center',
|
||||
width: AVATAR_SIZE
|
||||
},
|
||||
|
||||
/**
|
||||
* List of styles of the avatar of a remote meeting (not the default
|
||||
* server). The number of colors are limited because they should match
|
||||
* nicely.
|
||||
*/
|
||||
avatarColor1: {
|
||||
backgroundColor: `rgba(232, 105, 156, ${AVATAR_OPACITY})`
|
||||
},
|
||||
|
||||
avatarColor2: {
|
||||
backgroundColor: `rgba(255, 198, 115, ${AVATAR_OPACITY})`
|
||||
},
|
||||
|
||||
avatarColor3: {
|
||||
backgroundColor: `rgba(128, 128, 255, ${AVATAR_OPACITY})`
|
||||
},
|
||||
|
||||
avatarColor4: {
|
||||
backgroundColor: `rgba(105, 232, 194, ${AVATAR_OPACITY})`
|
||||
},
|
||||
|
||||
avatarColor5: {
|
||||
backgroundColor: `rgba(234, 255, 128, ${AVATAR_OPACITY})`
|
||||
},
|
||||
|
||||
/**
|
||||
* The style of the avatar container that makes the avatar rounded.
|
||||
*/
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
padding: 5
|
||||
},
|
||||
|
||||
/**
|
||||
* Simple {@code Text} content of the avatar (the actual initials).
|
||||
*/
|
||||
avatarContent: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||
color: OVERLAY_FONT_COLOR,
|
||||
fontSize: 32,
|
||||
fontWeight: '100',
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* The top level container style of the list.
|
||||
*/
|
||||
container: {
|
||||
flex: 1
|
||||
},
|
||||
|
||||
list: {
|
||||
flex: 1,
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
listItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 5
|
||||
},
|
||||
|
||||
listItemDetails: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
paddingHorizontal: 5
|
||||
},
|
||||
|
||||
listItemText: {
|
||||
color: OVERLAY_FONT_COLOR,
|
||||
fontSize: 14
|
||||
},
|
||||
|
||||
listItemTitle: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16
|
||||
},
|
||||
|
||||
listSection: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
padding: 5
|
||||
},
|
||||
|
||||
listSectionText: {
|
||||
color: OVERLAY_FONT_COLOR,
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal'
|
||||
},
|
||||
|
||||
touchableView: {
|
||||
flexDirection: 'row'
|
||||
}
|
||||
};
|
||||
|
||||
const SIDEBAR_STYLES = {
|
||||
/**
|
||||
* The topmost container of the side bar.
|
||||
*/
|
||||
@@ -105,4 +221,14 @@ export default createStyleSheet({
|
||||
sideMenuShadowTouchable: {
|
||||
flex: 1
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The styles of the React {@code Components} of the generic components
|
||||
* in the app.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
...HEADER_STYLES,
|
||||
...SECTION_LIST_STYLES,
|
||||
...SIDEBAR_STYLES
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MultiSelectStateless } from '@atlaskit/multi-select';
|
||||
import AKInlineDialog from '@atlaskit/inline-dialog';
|
||||
import Spinner from '@atlaskit/spinner';
|
||||
import _debounce from 'lodash/debounce';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
@@ -28,11 +27,22 @@ class MultiSelectAutocomplete extends Component {
|
||||
*/
|
||||
isDisabled: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Text to display while a query is executing.
|
||||
*/
|
||||
loadingMessage: PropTypes.string,
|
||||
|
||||
/**
|
||||
* The text to show when no matches are found.
|
||||
*/
|
||||
noMatchesFound: PropTypes.string,
|
||||
|
||||
/**
|
||||
* The function called immediately before a selection has been actually
|
||||
* selected. Provides an opportunity to do any formatting.
|
||||
*/
|
||||
onItemSelected: PropTypes.func,
|
||||
|
||||
/**
|
||||
* The function called when the selection changes.
|
||||
*/
|
||||
@@ -113,14 +123,14 @@ class MultiSelectAutocomplete extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the selected items.
|
||||
* Sets the items to display as selected.
|
||||
*
|
||||
* @param {Array<Object>} selectedItems - The list of items to display as
|
||||
* having been selected.
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
this.setState({
|
||||
selectedItems: []
|
||||
});
|
||||
setSelectedItems(selectedItems = []) {
|
||||
this.setState({ selectedItems });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,8 +150,10 @@ class MultiSelectAutocomplete extends Component {
|
||||
<MultiSelectStateless
|
||||
filterValue = { this.state.filterValue }
|
||||
isDisabled = { isDisabled }
|
||||
isLoading = { this.state.loading }
|
||||
isOpen = { this.state.isOpen }
|
||||
items = { this.state.items }
|
||||
loadingMessage = { this.props.loadingMessage }
|
||||
noMatchesFound = { noMatchesFound }
|
||||
onFilterChange = { this._onFilterChange }
|
||||
onRemoved = { this._onSelectionChange }
|
||||
@@ -150,7 +162,6 @@ class MultiSelectAutocomplete extends Component {
|
||||
selectedItems = { this.state.selectedItems }
|
||||
shouldFitContainer = { shouldFitContainer }
|
||||
shouldFocus = { shouldFocus } />
|
||||
{ this._renderLoadingIndicator() }
|
||||
{ this._renderError() }
|
||||
</div>
|
||||
);
|
||||
@@ -169,7 +180,8 @@ class MultiSelectAutocomplete extends Component {
|
||||
error: this.state.error && Boolean(filterValue),
|
||||
filterValue,
|
||||
isOpen: Boolean(this.state.items.length) && Boolean(filterValue),
|
||||
items: filterValue ? this.state.items : []
|
||||
items: filterValue ? this.state.items : [],
|
||||
loading: Boolean(filterValue)
|
||||
});
|
||||
if (filterValue) {
|
||||
this._sendQuery(filterValue);
|
||||
@@ -201,7 +213,7 @@ class MultiSelectAutocomplete extends Component {
|
||||
if (existing) {
|
||||
selectedItems = selectedItems.filter(k => k !== existing);
|
||||
} else {
|
||||
selectedItems.push(item);
|
||||
selectedItems.push(this.props.onItemSelected(item));
|
||||
}
|
||||
this.setState({
|
||||
isOpen: false,
|
||||
@@ -236,33 +248,6 @@ class MultiSelectAutocomplete extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the loading indicator.
|
||||
*
|
||||
* @returns {ReactElement|null}
|
||||
*/
|
||||
_renderLoadingIndicator() {
|
||||
if (!(this.state.loading
|
||||
&& !this.state.items.length
|
||||
&& this.state.filterValue.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = ( // eslint-disable-line no-extra-parens
|
||||
<div className = 'autocomplete-loading'>
|
||||
<Spinner
|
||||
isCompleting = { false }
|
||||
size = 'medium' />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AKInlineDialog
|
||||
content = { content }
|
||||
isOpen = { true } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a query to the resourceClient.
|
||||
*
|
||||
@@ -275,7 +260,6 @@ class MultiSelectAutocomplete extends Component {
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
error: false
|
||||
});
|
||||
|
||||
@@ -288,7 +272,6 @@ class MultiSelectAutocomplete extends Component {
|
||||
.then(results => {
|
||||
if (this.state.filterValue !== filterValue) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: false
|
||||
});
|
||||
|
||||
|
||||
54
react/features/base/sounds/actionTypes.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The type of a feature/internal/protected (redux) action to add an audio
|
||||
* element to the sounds collection state.
|
||||
*
|
||||
* {
|
||||
* type: _ADD_AUDIO_ELEMENT,
|
||||
* ref: AudioElement,
|
||||
* soundId: string
|
||||
* }
|
||||
*/
|
||||
export const _ADD_AUDIO_ELEMENT = Symbol('_ADD_AUDIO_ELEMENT');
|
||||
|
||||
/**
|
||||
* The type of feature/internal/protected (redux) action to remove an audio
|
||||
* element for given sound identifier from the sounds collection state.
|
||||
*
|
||||
* {
|
||||
* type: _REMOVE_AUDIO_ELEMENT,
|
||||
* soundId: string
|
||||
* }
|
||||
*/
|
||||
export const _REMOVE_AUDIO_ELEMENT = Symbol('_REMOVE_AUDIO_ELEMENT');
|
||||
|
||||
/**
|
||||
* The type of (redux) action to play a sound from the sounds collection.
|
||||
*
|
||||
* {
|
||||
* type: PLAY_SOUND,
|
||||
* soundId: string
|
||||
* }
|
||||
*/
|
||||
export const PLAY_SOUND = Symbol('PLAY_SOUND');
|
||||
|
||||
/**
|
||||
* The type of (redux) action to register a new sound with the sounds
|
||||
* collection.
|
||||
*
|
||||
* {
|
||||
* type: REGISTER_SOUND,
|
||||
* soundId: string
|
||||
* }
|
||||
*/
|
||||
export const REGISTER_SOUND = Symbol('REGISTER_SOUND');
|
||||
|
||||
/**
|
||||
* The type of (redux) action to unregister an existing sound from the sounds
|
||||
* collection.
|
||||
*
|
||||
* {
|
||||
* type: UNREGISTER_SOUND,
|
||||
* soundId: string
|
||||
* }
|
||||
*/
|
||||
export const UNREGISTER_SOUND = Symbol('UNREGISTER_SOUND');
|
||||
118
react/features/base/sounds/actions.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// @flow
|
||||
|
||||
import type { AudioElement } from '../media';
|
||||
|
||||
import {
|
||||
_ADD_AUDIO_ELEMENT,
|
||||
_REMOVE_AUDIO_ELEMENT,
|
||||
PLAY_SOUND,
|
||||
REGISTER_SOUND,
|
||||
UNREGISTER_SOUND
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Adds {@link AudioElement} instance to the base/sounds feature state for the
|
||||
* {@link Sound} instance identified by the given id. After this action the
|
||||
* sound can be played by dispatching the {@link PLAY_SOUND} action.
|
||||
*
|
||||
* @param {string} soundId - The sound identifier for which the audio element
|
||||
* will be stored.
|
||||
* @param {AudioElement} audioElement - The audio element which implements the
|
||||
* audio playback functionality and which is backed by the sound resource
|
||||
* corresponding to the {@link Sound} with the given id.
|
||||
* @protected
|
||||
* @returns {{
|
||||
* type: PLAY_SOUND,
|
||||
* audioElement: AudioElement,
|
||||
* soundId: string
|
||||
* }}
|
||||
*/
|
||||
export function _addAudioElement(soundId: string, audioElement: AudioElement) {
|
||||
return {
|
||||
type: _ADD_AUDIO_ELEMENT,
|
||||
audioElement,
|
||||
soundId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The opposite of {@link _addAudioElement} which removes {@link AudioElement}
|
||||
* for given sound from base/sounds state. It means that the audio resource has
|
||||
* been disposed and the sound can no longer be played.
|
||||
*
|
||||
* @param {string} soundId - The {@link Sound} instance identifier for which the
|
||||
* audio element is being removed.
|
||||
* @protected
|
||||
* @returns {{
|
||||
* type: _REMOVE_AUDIO_ELEMENT,
|
||||
* soundId: string
|
||||
* }}
|
||||
*/
|
||||
export function _removeAudioElement(soundId: string) {
|
||||
return {
|
||||
type: _REMOVE_AUDIO_ELEMENT,
|
||||
soundId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts playback of the sound identified by the given sound id. The action
|
||||
* will have effect only if the audio resource has been loaded already.
|
||||
*
|
||||
* @param {string} soundId - The id of the sound to be played (the same one
|
||||
* which was used in {@link registerSound} to register the sound).
|
||||
* @returns {{
|
||||
* type: PLAY_SOUND,
|
||||
* soundId: string
|
||||
* }}
|
||||
*/
|
||||
export function playSound(soundId: string): Object {
|
||||
return {
|
||||
type: PLAY_SOUND,
|
||||
soundId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new sound for given id and a source object which can be either a
|
||||
* path or a raw object depending on the platform (native vs web). It will make
|
||||
* the {@link SoundCollection} render extra HTMLAudioElement which will make it
|
||||
* available for playback through the {@link playSound} action.
|
||||
*
|
||||
* @param {string} soundId - The global identifier which identify the sound
|
||||
* created for given source object.
|
||||
* @param {Object|string} src - Either path to an audio file or a raw object
|
||||
* which specifies the audio resource that will be associated with the given
|
||||
* {@code soundId}.
|
||||
* @returns {{
|
||||
* type: REGISTER_SOUND,
|
||||
* soundId: string,
|
||||
* src: (Object | string)
|
||||
* }}
|
||||
*/
|
||||
export function registerSound(soundId: string, src: Object | string): Object {
|
||||
return {
|
||||
type: REGISTER_SOUND,
|
||||
soundId,
|
||||
src
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the sound identified by the given id. It will make the
|
||||
* {@link SoundCollection} component stop rendering the corresponding
|
||||
* {@code HTMLAudioElement} which then should result in the audio resource
|
||||
* disposal.
|
||||
*
|
||||
* @param {string} soundId - The identifier of the {@link Sound} to be removed.
|
||||
* @returns {{
|
||||
* type: UNREGISTER_SOUND,
|
||||
* soundId: string
|
||||
* }}
|
||||
*/
|
||||
export function unregisterSound(soundId: string): Object {
|
||||
return {
|
||||
type: UNREGISTER_SOUND,
|
||||
soundId
|
||||
};
|
||||
}
|
||||
160
react/features/base/sounds/components/SoundCollection.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// @flow
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Audio } from '../../media';
|
||||
import type { AudioElement } from '../../media';
|
||||
import { Fragment } from '../../react';
|
||||
|
||||
import { _addAudioElement, _removeAudioElement } from '../actions';
|
||||
import type { Sound } from '../reducer';
|
||||
|
||||
/**
|
||||
* {@link SoundCollection}'s properties.
|
||||
*/
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Dispatches {@link _ADD_AUDIO_ELEMENT} Redux action which will store the
|
||||
* {@link AudioElement} for a sound in the Redux store.
|
||||
*/
|
||||
_addAudioElement: Function,
|
||||
|
||||
/**
|
||||
* Dispatches {@link _REMOVE_AUDIO_ELEMENT} Redux action which will remove
|
||||
* the sound's {@link AudioElement} from the Redux store.
|
||||
*/
|
||||
_removeAudioElement: Function,
|
||||
|
||||
/**
|
||||
* It's the 'base/sounds' reducer's state mapped to a property. It's used to
|
||||
* render audio elements for every registered sound.
|
||||
*/
|
||||
_sounds: Map<string, Sound>
|
||||
}
|
||||
|
||||
/**
|
||||
* Collections of all global sounds used by the app for playing audio
|
||||
* notifications in response to various events. It renders <code>Audio</code>
|
||||
* element for each sound registered in the base/sounds feature. When the audio
|
||||
* resource is loaded it will emit add/remove audio element actions which will
|
||||
* attach the element to the corresponding {@link Sound} instance in the Redux
|
||||
* state. When that happens the sound can be played using the {@link playSound}
|
||||
* action.
|
||||
*/
|
||||
class SoundCollection extends Component<Props> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
let key = 0;
|
||||
const sounds = [];
|
||||
|
||||
for (const [ soundId, sound ] of this.props._sounds.entries()) {
|
||||
sounds.push(
|
||||
React.createElement(
|
||||
Audio, {
|
||||
key,
|
||||
setRef: this._setRef.bind(this, soundId),
|
||||
src: sound.src
|
||||
}));
|
||||
key += 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{
|
||||
sounds
|
||||
}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the (reference to the) {@link AudioElement} object which implements
|
||||
* the audio playback functionality.
|
||||
*
|
||||
* @param {string} soundId - The sound Id for the audio element for which
|
||||
* the callback is being executed.
|
||||
* @param {AudioElement} element - The {@link AudioElement} instance
|
||||
* which implements the audio playback functionality.
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_setRef(soundId: string, element: ?AudioElement) {
|
||||
if (element) {
|
||||
this.props._addAudioElement(soundId, element);
|
||||
} else {
|
||||
this.props._removeAudioElement(soundId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to {@code SoundCollection}'s props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _sounds: Map<string, Sound>
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
return {
|
||||
_sounds: state['features/base/sounds']
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps dispatching of some actions to React component props.
|
||||
*
|
||||
* @param {Function} dispatch - Redux action dispatcher.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _addAudioElement: void,
|
||||
* _removeAudioElement: void
|
||||
* }}
|
||||
*/
|
||||
export function _mapDispatchToProps(dispatch: Function) {
|
||||
return {
|
||||
/**
|
||||
* Dispatches action to store the {@link AudioElement} for
|
||||
* a {@link Sound} identified by given <tt>soundId</tt> in the Redux
|
||||
* store, so that the playback can be controlled through the Redux
|
||||
* actions.
|
||||
*
|
||||
* @param {string} soundId - A global identifier which will be used to
|
||||
* identify the {@link Sound} instance for which an audio element will
|
||||
* be added.
|
||||
* @param {AudioElement} audioElement - The {@link AudioElement}
|
||||
* instance that will be stored in the Redux state of the base/sounds
|
||||
* feature, as part of the {@link Sound} object. At that point the sound
|
||||
* will be ready for playback.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_addAudioElement(soundId: string, audioElement: AudioElement) {
|
||||
dispatch(_addAudioElement(soundId, audioElement));
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispatches action to remove {@link AudioElement} from the Redux
|
||||
* store for specific {@link Sound}, because it is no longer part of
|
||||
* the DOM tree and the audio resource will be released.
|
||||
*
|
||||
* @param {string} soundId - The id of the {@link Sound} instance for
|
||||
* which an {@link AudioElement} will be removed from the Redux store.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_removeAudioElement(soundId: string) {
|
||||
dispatch(_removeAudioElement(soundId));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(_mapStateToProps, _mapDispatchToProps)(SoundCollection);
|
||||
1
react/features/base/sounds/components/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SoundCollection } from './SoundCollection';
|
||||
6
react/features/base/sounds/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './actions';
|
||||
export * from './actionTypes';
|
||||
export * from './components';
|
||||
|
||||
import './middleware';
|
||||
import './reducer';
|
||||
46
react/features/base/sounds/middleware.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// @flow
|
||||
|
||||
import { MiddlewareRegistry } from '../redux';
|
||||
|
||||
import { PLAY_SOUND } from './actionTypes';
|
||||
|
||||
const logger = require('jitsi-meet-logger').getLogger(__filename);
|
||||
|
||||
/**
|
||||
* Implements the entry point of the middleware of the feature base/media.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case PLAY_SOUND:
|
||||
_playSound(store, action.soundId);
|
||||
break;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Plays sound from audio element registered in the Redux store.
|
||||
*
|
||||
* @param {Store} store - The Redux store instance.
|
||||
* @param {string} soundId - Audio element identifier.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _playSound({ getState }, soundId) {
|
||||
const sounds = getState()['features/base/sounds'];
|
||||
const sound = sounds.get(soundId);
|
||||
|
||||
if (sound) {
|
||||
if (sound.audioElement) {
|
||||
sound.audioElement.play();
|
||||
} else {
|
||||
logger.warn(`PLAY_SOUND: sound not loaded yet for id: ${soundId}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`PLAY_SOUND: no sound found for id: ${soundId}`);
|
||||
}
|
||||
}
|
||||
140
react/features/base/sounds/reducer.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// @flow
|
||||
|
||||
import type { AudioElement } from '../media';
|
||||
import { assign, ReducerRegistry } from '../redux';
|
||||
|
||||
import {
|
||||
_ADD_AUDIO_ELEMENT,
|
||||
_REMOVE_AUDIO_ELEMENT,
|
||||
REGISTER_SOUND,
|
||||
UNREGISTER_SOUND
|
||||
} from './actionTypes';
|
||||
|
||||
const logger = require('jitsi-meet-logger').getLogger(__filename);
|
||||
|
||||
/**
|
||||
* The structure use by this reducer to describe a sound.
|
||||
*/
|
||||
export type Sound = {
|
||||
|
||||
/**
|
||||
* The HTMLAudioElement which implements the audio playback functionality.
|
||||
* Becomes available once the sound resource gets loaded and the sound can
|
||||
* not be played until that happens.
|
||||
*/
|
||||
audioElement?: AudioElement,
|
||||
|
||||
/**
|
||||
* This field describes the source of the audio resource to be played. It
|
||||
* can be either a path to the file or an object depending on the platform
|
||||
* (native vs web).
|
||||
*/
|
||||
src: Object | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial/default state of the feature {@code base/sounds}. It is a {@code Map}
|
||||
* of globally stored sounds.
|
||||
*
|
||||
* @type {Map<string, Sound>}
|
||||
*/
|
||||
const DEFAULT_STATE = new Map();
|
||||
|
||||
/**
|
||||
* The base/sounds feature's reducer.
|
||||
*/
|
||||
ReducerRegistry.register(
|
||||
'features/base/sounds',
|
||||
(state = DEFAULT_STATE, action) => {
|
||||
switch (action.type) {
|
||||
case _ADD_AUDIO_ELEMENT:
|
||||
case _REMOVE_AUDIO_ELEMENT:
|
||||
return _addOrRemoveAudioElement(state, action);
|
||||
|
||||
case REGISTER_SOUND:
|
||||
return _registerSound(state, action);
|
||||
|
||||
case UNREGISTER_SOUND:
|
||||
return _unregisterSound(state, action);
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds or removes {@link AudioElement} associated with a {@link Sound}.
|
||||
*
|
||||
* @param {Map<string, Sound>} state - The current Redux state of this feature.
|
||||
* @param {_ADD_AUDIO_ELEMENT | _REMOVE_AUDIO_ELEMENT} action - The action to be
|
||||
* handled.
|
||||
* @private
|
||||
* @returns {Map<string, Sound>}
|
||||
*/
|
||||
function _addOrRemoveAudioElement(state, action) {
|
||||
const isAddAction = action.type === _ADD_AUDIO_ELEMENT;
|
||||
const nextState = new Map(state);
|
||||
const { soundId } = action;
|
||||
|
||||
const sound = nextState.get(soundId);
|
||||
|
||||
if (sound) {
|
||||
if (isAddAction) {
|
||||
nextState.set(soundId,
|
||||
assign(sound, {
|
||||
audioElement: action.audioElement
|
||||
}));
|
||||
} else {
|
||||
nextState.set(soundId,
|
||||
assign(sound, {
|
||||
audioElement: undefined
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const actionName
|
||||
= isAddAction ? '_ADD_AUDIO_ELEMENT' : '_REMOVE_AUDIO_ELEMENT';
|
||||
|
||||
logger.error(`${actionName}: no sound for id: ${soundId}`);
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new {@link Sound} for given id and source. It will make
|
||||
* the {@link SoundCollection} component render HTMLAudioElement for given
|
||||
* source making it available for playback through the redux actions.
|
||||
*
|
||||
* @param {Map<string, Sound>} state - The current Redux state of the sounds
|
||||
* features.
|
||||
* @param {REGISTER_SOUND} action - The register sound action.
|
||||
* @private
|
||||
* @returns {Map<string, Sound>}
|
||||
*/
|
||||
function _registerSound(state, action) {
|
||||
const nextState = new Map(state);
|
||||
|
||||
nextState.set(action.soundId, {
|
||||
src: action.src
|
||||
});
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a {@link Sound} which will make the {@link SoundCollection}
|
||||
* component stop rendering the corresponding HTMLAudioElement. This will
|
||||
* result further in the audio resource disposal.
|
||||
*
|
||||
* @param {Map<string, Sound>} state - The current Redux state of this feature.
|
||||
* @param {UNREGISTER_SOUND} action - The unregister sound action.
|
||||
* @private
|
||||
* @returns {Map<string, Sound>}
|
||||
*/
|
||||
function _unregisterSound(state, action) {
|
||||
const nextState = new Map(state);
|
||||
|
||||
nextState.delete(action.soundId);
|
||||
|
||||
return nextState;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
// @flow
|
||||
|
||||
/**
|
||||
* The app linking scheme.
|
||||
* TODO: This should be read from the manifest files later.
|
||||
*/
|
||||
export const APP_LINK_SCHEME = 'org.jitsi.meet:';
|
||||
|
||||
/**
|
||||
* The {@link RegExp} pattern of the authority of a URI.
|
||||
*
|
||||
@@ -19,10 +25,14 @@ const _URI_PATH_PATTERN = '([^?#]*)';
|
||||
/**
|
||||
* The {@link RegExp} pattern of the protocol of a URI.
|
||||
*
|
||||
* @private
|
||||
* FIXME: The URL class exposed by JavaScript will not include the colon in
|
||||
* the protocol field. Also in other places (at the time of this writing:
|
||||
* the UnsupportedMobileBrowser.js) the APP_LINK_SCHEME does not include
|
||||
* the double dots, so things are inconsistent.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const _URI_PROTOCOL_PATTERN = '([a-z][a-z0-9\\.\\+-]*:)';
|
||||
export const URI_PROTOCOL_PATTERN = '([a-z][a-z0-9\\.\\+-]*:)';
|
||||
|
||||
/**
|
||||
* Fixes the hier-part of a specific URI (string) so that the URI is well-known.
|
||||
@@ -41,7 +51,7 @@ function _fixURIStringHierPart(uri) {
|
||||
// hipchat.com
|
||||
let regex
|
||||
= new RegExp(
|
||||
`^${_URI_PROTOCOL_PATTERN}//hipchat\\.com/video/call/`,
|
||||
`^${URI_PROTOCOL_PATTERN}//hipchat\\.com/video/call/`,
|
||||
'gi');
|
||||
let match: Array<string> | null = regex.exec(uri);
|
||||
|
||||
@@ -49,7 +59,7 @@ function _fixURIStringHierPart(uri) {
|
||||
// enso.me
|
||||
regex
|
||||
= new RegExp(
|
||||
`^${_URI_PROTOCOL_PATTERN}//enso\\.me/(?:call|meeting)/`,
|
||||
`^${URI_PROTOCOL_PATTERN}//enso\\.me/(?:call|meeting)/`,
|
||||
'gi');
|
||||
match = regex.exec(uri);
|
||||
}
|
||||
@@ -81,7 +91,7 @@ function _fixURIStringHierPart(uri) {
|
||||
* @returns {string}
|
||||
*/
|
||||
function _fixURIStringScheme(uri: string) {
|
||||
const regex = new RegExp(`^${_URI_PROTOCOL_PATTERN}+`, 'gi');
|
||||
const regex = new RegExp(`^${URI_PROTOCOL_PATTERN}+`, 'gi');
|
||||
const match: Array<string> | null = regex.exec(uri);
|
||||
|
||||
if (match) {
|
||||
@@ -185,7 +195,7 @@ export function parseStandardURIString(str: string) {
|
||||
str = str.replace(/\s/g, '');
|
||||
|
||||
// protocol
|
||||
regex = new RegExp(`^${_URI_PROTOCOL_PATTERN}`, 'gi');
|
||||
regex = new RegExp(`^${URI_PROTOCOL_PATTERN}`, 'gi');
|
||||
match = regex.exec(str);
|
||||
if (match) {
|
||||
obj.protocol = match[1].toLowerCase();
|
||||
@@ -327,12 +337,12 @@ function _standardURIToString(thiz: ?Object) {
|
||||
* the one accepted by the constructor of Web's ExternalAPI is supported on both
|
||||
* mobile/React Native and Web/React.
|
||||
*
|
||||
* @param {string|Object} obj - The URL to return a {@code String}
|
||||
* @param {Object|string} obj - The URL to return a {@code String}
|
||||
* representation of.
|
||||
* @returns {string} - A {@code String} representation of the specified
|
||||
* {@code obj} which is supposed to represent a URL.
|
||||
*/
|
||||
export function toURLString(obj: ?(string | Object)): ?string {
|
||||
export function toURLString(obj: ?(Object | string)): ?string {
|
||||
let str;
|
||||
|
||||
switch (typeof obj) {
|
||||
@@ -395,7 +405,7 @@ export function urlObjectToString(o: Object): ?string {
|
||||
// XXX The value of domain in supposed to be host/hostname
|
||||
// and, optionally, pathname. Make sure it is not taken for
|
||||
// a pathname only.
|
||||
_fixURIStringScheme(`org.jitsi.meet://${domain}`));
|
||||
_fixURIStringScheme(`${APP_LINK_SCHEME}//${domain}`));
|
||||
|
||||
// authority
|
||||
if (host) {
|
||||
|
||||
17
react/features/calendar-sync/actionTypes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// @flow
|
||||
|
||||
/**
|
||||
* Action to update the current calendar entry list in the store.
|
||||
*/
|
||||
export const NEW_CALENDAR_ENTRY_LIST = Symbol('NEW_CALENDAR_ENTRY_LIST');
|
||||
|
||||
/**
|
||||
* Action to add a new known domain to the list.
|
||||
*/
|
||||
export const NEW_KNOWN_DOMAIN = Symbol('NEW_KNOWN_DOMAIN');
|
||||
|
||||
/**
|
||||
* Action to refresh (re-fetch) the entry list.
|
||||
*/
|
||||
export const REFRESH_CALENDAR_ENTRY_LIST
|
||||
= Symbol('REFRESH_CALENDAR_ENTRY_LIST');
|
||||
51
react/features/calendar-sync/actions.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// @flow
|
||||
import {
|
||||
NEW_CALENDAR_ENTRY_LIST,
|
||||
NEW_KNOWN_DOMAIN,
|
||||
REFRESH_CALENDAR_ENTRY_LIST
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Sends an action to add a new known domain if not present yet.
|
||||
*
|
||||
* @param {string} domainName - The new domain.
|
||||
* @returns {{
|
||||
* type: NEW_KNOWN_DOMAIN,
|
||||
* domainName: string
|
||||
* }}
|
||||
*/
|
||||
export function maybeAddNewKnownDomain(domainName: string) {
|
||||
return {
|
||||
type: NEW_KNOWN_DOMAIN,
|
||||
domainName
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an action to refresh the entry list (fetches new data).
|
||||
*
|
||||
* @returns {{
|
||||
* type: REFRESH_CALENDAR_ENTRY_LIST
|
||||
* }}
|
||||
*/
|
||||
export function refreshCalendarEntryList() {
|
||||
return {
|
||||
type: REFRESH_CALENDAR_ENTRY_LIST
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an action to update the current calendar list in redux.
|
||||
*
|
||||
* @param {Array<Object>} events - The new list.
|
||||
* @returns {{
|
||||
* type: NEW_CALENDAR_ENTRY_LIST,
|
||||
* events: Array<Object>
|
||||
* }}
|
||||
*/
|
||||
export function updateCalendarEntryList(events: Array<Object>) {
|
||||
return {
|
||||
type: NEW_CALENDAR_ENTRY_LIST,
|
||||
events
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { appNavigate } from '../../app';
|
||||
import { getURLWithoutParams } from '../../base/connection';
|
||||
import { getLocalizedDateFormatter, translate } from '../../base/i18n';
|
||||
import { Icon } from '../../base/font-icons';
|
||||
import { ASPECT_RATIO_NARROW } from '../../base/responsive-ui';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
const ALERT_MILLISECONDS = 5 * 60 * 1000;
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: Function,
|
||||
|
||||
/**
|
||||
* The current aspect ratio of the screen.
|
||||
*/
|
||||
_aspectRatio: Symbol,
|
||||
|
||||
/**
|
||||
* The URL of the current conference without params.
|
||||
*/
|
||||
_currentConferenceURL: string,
|
||||
|
||||
/**
|
||||
* The calendar event list.
|
||||
*/
|
||||
_eventList: Array<Object>,
|
||||
|
||||
/**
|
||||
* The translate function.
|
||||
*/
|
||||
t: Function
|
||||
};
|
||||
|
||||
type State = {
|
||||
|
||||
/**
|
||||
* The event object to display the notification for.
|
||||
*/
|
||||
event?: Object
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to display a permanent badge-like notification on the
|
||||
* conference screen when another meeting is about to start.
|
||||
*/
|
||||
class ConferenceNotification extends Component<Props, State> {
|
||||
updateIntervalId: number;
|
||||
|
||||
/**
|
||||
* Constructor of the ConferenceNotification component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
event: undefined
|
||||
};
|
||||
|
||||
this._getNotificationContentStyle
|
||||
= this._getNotificationContentStyle.bind(this);
|
||||
this._getNotificationPosition
|
||||
= this._getNotificationPosition.bind(this);
|
||||
this._maybeDisplayNotification
|
||||
= this._maybeDisplayNotification.bind(this);
|
||||
this._onGoToNext = this._onGoToNext.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React Component's componentDidMount.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentDidMount() {
|
||||
this.updateIntervalId = setInterval(
|
||||
this._maybeDisplayNotification,
|
||||
10 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React Component's componentWillUnmount.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.updateIntervalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the React Components's render method.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { event } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
if (event) {
|
||||
return (
|
||||
<View
|
||||
style = { [
|
||||
styles.notificationContainer,
|
||||
this._getNotificationPosition()
|
||||
] } >
|
||||
<View
|
||||
style = { this._getNotificationContentStyle() }>
|
||||
<TouchableOpacity
|
||||
onPress = { this._onGoToNext } >
|
||||
<View style = { styles.touchableView }>
|
||||
<View
|
||||
style = {
|
||||
styles.notificationTextContainer
|
||||
}>
|
||||
<Text style = { styles.notificationText }>
|
||||
{ t('calendarSync.nextMeeting') }
|
||||
</Text>
|
||||
<Text style = { styles.notificationText }>
|
||||
{
|
||||
getLocalizedDateFormatter(
|
||||
event.startDate
|
||||
).fromNow()
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style = {
|
||||
styles.notificationIconContainer
|
||||
}>
|
||||
<Icon
|
||||
name = 'navigate_next'
|
||||
style = { styles.notificationIcon } />
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_getNotificationContentStyle: () => Array<Object>
|
||||
|
||||
/**
|
||||
* Decides the color of the notification and some additional
|
||||
* styles based on notificationPosition.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
_getNotificationContentStyle() {
|
||||
const { event } = this.state;
|
||||
const { _aspectRatio } = this.props;
|
||||
const now = Date.now();
|
||||
const style = [
|
||||
styles.notificationContent
|
||||
];
|
||||
|
||||
if (event && event.startDate < now && event.endDate > now) {
|
||||
style.push(styles.notificationContentPast);
|
||||
} else {
|
||||
style.push(styles.notificationContentNext);
|
||||
}
|
||||
|
||||
if (_aspectRatio === ASPECT_RATIO_NARROW) {
|
||||
style.push(styles.notificationContentSide);
|
||||
} else {
|
||||
style.push(styles.notificationContentTop);
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
_getNotificationPosition: () => Object;
|
||||
|
||||
/**
|
||||
* Decides the position of the notification.
|
||||
*
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getNotificationPosition() {
|
||||
const { _aspectRatio } = this.props;
|
||||
|
||||
if (_aspectRatio === ASPECT_RATIO_NARROW) {
|
||||
return styles.notificationContainerSide;
|
||||
}
|
||||
|
||||
return styles.notificationContainerTop;
|
||||
}
|
||||
|
||||
_maybeDisplayNotification: () => void;
|
||||
|
||||
/**
|
||||
* Periodically checks if there is an event in the calendar for which we
|
||||
* need to show a notification.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_maybeDisplayNotification() {
|
||||
const { _currentConferenceURL, _eventList } = this.props;
|
||||
let eventToShow;
|
||||
|
||||
if (_eventList && _eventList.length) {
|
||||
const now = Date.now();
|
||||
|
||||
for (const event of _eventList) {
|
||||
if (event.url !== _currentConferenceURL) {
|
||||
if ((!eventToShow
|
||||
&& event.startDate > now
|
||||
&& event.startDate < now + ALERT_MILLISECONDS)
|
||||
|| (event.startDate < now && event.endDate > now)
|
||||
) {
|
||||
eventToShow = event;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
event: eventToShow
|
||||
});
|
||||
}
|
||||
|
||||
_onGoToNext: () => void;
|
||||
|
||||
/**
|
||||
* Opens the meeting URL that the notification shows.
|
||||
*
|
||||
* @private
|
||||
* @param {string} url - The URL to open.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onGoToNext() {
|
||||
const { event } = this.state;
|
||||
|
||||
if (event && event.url) {
|
||||
this.props.dispatch(appNavigate(event.url));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {{
|
||||
* _aspectRatio: Symbol,
|
||||
* _currentConferenceURL: string,
|
||||
* _eventList: Array
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: Object) {
|
||||
const { locationURL } = state['features/base/connection'];
|
||||
|
||||
return {
|
||||
_aspectRatio: state['features/base/responsive-ui'].aspectRatio,
|
||||
_currentConferenceURL:
|
||||
locationURL ? getURLWithoutParams(locationURL)._url : '',
|
||||
_eventList: state['features/calendar-sync'].events
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(ConferenceNotification));
|
||||
245
react/features/calendar-sync/components/MeetingList.native.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { refreshCalendarEntryList } from '../actions';
|
||||
|
||||
import { appNavigate } from '../../app';
|
||||
import { getLocalizedDateFormatter, translate } from '../../base/i18n';
|
||||
import { NavigateSectionList } from '../../base/react';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Indicates if the list is disabled or not.
|
||||
*/
|
||||
disabled: boolean,
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: Function,
|
||||
|
||||
/**
|
||||
* Tells the component if it's being displayed at the moment, or not.
|
||||
* Note: as an example, on Android it can happen that the component
|
||||
* is rendered but not displayed, because components like ViewPagerAndroid
|
||||
* render their children even if they are not visible at the moment.
|
||||
*/
|
||||
displayed: boolean,
|
||||
|
||||
/**
|
||||
* The calendar event list.
|
||||
*/
|
||||
_eventList: Array<Object>,
|
||||
|
||||
/**
|
||||
* The translate function.
|
||||
*/
|
||||
t: Function
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to display a list of events from the (mobile) user's calendar.
|
||||
*/
|
||||
class MeetingList extends Component<Props> {
|
||||
_initialLoaded: boolean
|
||||
|
||||
/**
|
||||
* Default values for the component's props.
|
||||
*/
|
||||
static defaultProps = {
|
||||
_eventList: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor of the MeetingList component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._onPress = this._onPress.bind(this);
|
||||
this._onRefresh = this._onRefresh.bind(this);
|
||||
this._toDisplayableItem = this._toDisplayableItem.bind(this);
|
||||
this._toDisplayableList = this._toDisplayableList.bind(this);
|
||||
this._toDateString = this._toDateString.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React Component's componentWillReceiveProps function.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentWillReceiveProps(newProps) {
|
||||
// This is a conditional logic to refresh the calendar entries (thus
|
||||
// to request access to calendar) on component first receives a
|
||||
// displayed=true prop - to avoid requesting calendar access on
|
||||
// app start.
|
||||
if (!this._initialLoaded
|
||||
&& newProps.displayed
|
||||
&& !this.props.displayed) {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
this._initialLoaded = true;
|
||||
dispatch(refreshCalendarEntryList());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the React Components's render method.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { disabled } = this.props;
|
||||
|
||||
return (
|
||||
<NavigateSectionList
|
||||
disabled = { disabled }
|
||||
onPress = { this._onPress }
|
||||
onRefresh = { this._onRefresh }
|
||||
sections = { this._toDisplayableList() } />
|
||||
);
|
||||
}
|
||||
|
||||
_onPress: string => Function
|
||||
|
||||
/**
|
||||
* Handles the list's navigate action.
|
||||
*
|
||||
* @private
|
||||
* @param {string} url - The url string to navigate to.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onPress(url) {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(appNavigate(url));
|
||||
}
|
||||
|
||||
_onRefresh: () => void
|
||||
|
||||
/**
|
||||
* Callback to execute when the list is doing a pull-to-refresh.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onRefresh() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(refreshCalendarEntryList());
|
||||
}
|
||||
|
||||
_toDisplayableItem: Object => Object
|
||||
|
||||
/**
|
||||
* Creates a displayable object from an event.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} event - The calendar event.
|
||||
* @returns {Object}
|
||||
*/
|
||||
_toDisplayableItem(event) {
|
||||
return {
|
||||
key: `${event.id}-${event.startDate}`,
|
||||
lines: [
|
||||
event.url,
|
||||
this._toDateString(event)
|
||||
],
|
||||
title: event.title,
|
||||
url: event.url
|
||||
};
|
||||
}
|
||||
|
||||
_toDisplayableList: () => Array<Object>
|
||||
|
||||
/**
|
||||
* Transforms the event list to a displayable list
|
||||
* with sections.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
_toDisplayableList() {
|
||||
const { _eventList, t } = this.props;
|
||||
const now = Date.now();
|
||||
const nowSection = NavigateSectionList.createSection(
|
||||
t('calendarSync.now'),
|
||||
'now'
|
||||
);
|
||||
const nextSection = NavigateSectionList.createSection(
|
||||
t('calendarSync.next'),
|
||||
'next'
|
||||
);
|
||||
const laterSection = NavigateSectionList.createSection(
|
||||
t('calendarSync.later'),
|
||||
'later'
|
||||
);
|
||||
|
||||
for (const event of _eventList) {
|
||||
const displayableEvent = this._toDisplayableItem(event);
|
||||
|
||||
if (event.startDate < now && event.endDate > now) {
|
||||
nowSection.data.push(displayableEvent);
|
||||
} else if (event.startDate > now) {
|
||||
if (nextSection.data.length
|
||||
&& nextSection.data[0].startDate !== event.startDate) {
|
||||
laterSection.data.push(displayableEvent);
|
||||
} else {
|
||||
nextSection.data.push(displayableEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sectionList = [];
|
||||
|
||||
for (const section of [
|
||||
nowSection,
|
||||
nextSection,
|
||||
laterSection
|
||||
]) {
|
||||
if (section.data.length) {
|
||||
sectionList.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
return sectionList;
|
||||
}
|
||||
|
||||
_toDateString: Object => string
|
||||
|
||||
/**
|
||||
* Generates a date (interval) string for a given event.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} event - The event.
|
||||
* @returns {string}
|
||||
*/
|
||||
_toDateString(event) {
|
||||
/* eslint-disable max-len */
|
||||
const startDateTime = getLocalizedDateFormatter(event.startDate).format('lll');
|
||||
const endTime = getLocalizedDateFormatter(event.endDate).format('LT');
|
||||
|
||||
return `${startDateTime} - ${endTime}`;
|
||||
/* eslint-enable max-len */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {{
|
||||
* _eventList: Array
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: Object) {
|
||||
return {
|
||||
_eventList: state['features/calendar-sync'].events
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(MeetingList));
|
||||
2
react/features/calendar-sync/components/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as MeetingList } from './MeetingList';
|
||||
export { default as ConferenceNotification } from './ConferenceNotification';
|
||||
126
react/features/calendar-sync/components/styles.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { createStyleSheet } from '../../base/styles';
|
||||
|
||||
const NOTIFICATION_SIZE = 55;
|
||||
|
||||
/**
|
||||
* The styles of the React {@code Component}s of the feature meeting-list i.e.
|
||||
* {@code MeetingList}.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
|
||||
/**
|
||||
* The top level container of the notification.
|
||||
*/
|
||||
notificationContainer: {
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute'
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the container when the notification is displayed
|
||||
* on the side (narrow view).
|
||||
*/
|
||||
notificationContainerSide: {
|
||||
top: 100
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the container when the notification is displayed
|
||||
* on the top (wide view).
|
||||
*/
|
||||
notificationContainerTop: {
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
|
||||
/**
|
||||
* The top level container of the notification.
|
||||
*/
|
||||
notificationContent: {
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
height: NOTIFICATION_SIZE,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 10
|
||||
},
|
||||
|
||||
/**
|
||||
* Color for upcoming meeting notification.
|
||||
*/
|
||||
notificationContentNext: {
|
||||
backgroundColor: '#eeb231'
|
||||
},
|
||||
|
||||
/**
|
||||
* Color for already ongoing meeting notifications.
|
||||
*/
|
||||
notificationContentPast: {
|
||||
backgroundColor: 'red'
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the content when the notification is displayed
|
||||
* on the side (narrow view).
|
||||
*/
|
||||
notificationContentSide: {
|
||||
borderBottomRightRadius: NOTIFICATION_SIZE,
|
||||
borderTopRightRadius: NOTIFICATION_SIZE
|
||||
},
|
||||
|
||||
/**
|
||||
* Additional style for the content when the notification is displayed
|
||||
* on the top (wide view).
|
||||
*/
|
||||
notificationContentTop: {
|
||||
borderBottomLeftRadius: NOTIFICATION_SIZE / 2,
|
||||
borderBottomRightRadius: NOTIFICATION_SIZE / 2,
|
||||
paddingHorizontal: 20
|
||||
},
|
||||
|
||||
/**
|
||||
* The icon of the notification.
|
||||
*/
|
||||
notificationIcon: {
|
||||
color: 'white',
|
||||
fontSize: 25
|
||||
},
|
||||
|
||||
/**
|
||||
* The container that contains the icon.
|
||||
*/
|
||||
notificationIconContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: NOTIFICATION_SIZE,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* A single line of text of the notification.
|
||||
*/
|
||||
notificationText: {
|
||||
color: 'white',
|
||||
fontSize: 13
|
||||
},
|
||||
|
||||
/**
|
||||
* The container for all the lines if the norification.
|
||||
*/
|
||||
notificationTextContainer: {
|
||||
flexDirection: 'column',
|
||||
height: NOTIFICATION_SIZE,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* The touchable component.
|
||||
*/
|
||||
touchableView: {
|
||||
flexDirection: 'row'
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './actions';
|
||||
export * from './components';
|
||||
|
||||
import './middleware';
|
||||
import './reducer';
|
||||
211
react/features/calendar-sync/middleware.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// @flow
|
||||
import Logger from 'jitsi-meet-logger';
|
||||
import RNCalendarEvents from 'react-native-calendar-events';
|
||||
|
||||
import { SET_ROOM } from '../base/conference';
|
||||
import { MiddlewareRegistry } from '../base/redux';
|
||||
import { APP_LINK_SCHEME, parseURIString } from '../base/util';
|
||||
|
||||
import { APP_WILL_MOUNT } from '../app';
|
||||
|
||||
import { maybeAddNewKnownDomain, updateCalendarEntryList } from './actions';
|
||||
import { REFRESH_CALENDAR_ENTRY_LIST } from './actionTypes';
|
||||
|
||||
const FETCH_END_DAYS = 10;
|
||||
const FETCH_START_DAYS = -1;
|
||||
const MAX_LIST_LENGTH = 10;
|
||||
const logger = Logger.getLogger(__filename);
|
||||
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case APP_WILL_MOUNT:
|
||||
_ensureDefaultServer(store);
|
||||
_fetchCalendarEntries(store, false);
|
||||
break;
|
||||
case REFRESH_CALENDAR_ENTRY_LIST:
|
||||
_fetchCalendarEntries(store, true);
|
||||
break;
|
||||
case SET_ROOM:
|
||||
_parseAndAddDomain(store);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures calendar access if possible and resolves the promise if it's granted.
|
||||
*
|
||||
* @private
|
||||
* @param {boolean} promptForPermission - Flag to tell the app if it should
|
||||
* prompt for a calendar permission if it wasn't granted yet.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function _ensureCalendarAccess(promptForPermission) {
|
||||
return new Promise((resolve, reject) => {
|
||||
RNCalendarEvents.authorizationStatus()
|
||||
.then(status => {
|
||||
if (status === 'authorized') {
|
||||
resolve();
|
||||
} else if (promptForPermission) {
|
||||
RNCalendarEvents.authorizeEventStore()
|
||||
.then(result => {
|
||||
if (result === 'authorized') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(result);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
} else {
|
||||
reject(status);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures presence of the default server in the known domains list.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} store - The redux store.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function _ensureDefaultServer(store) {
|
||||
const state = store.getState();
|
||||
const defaultURL = parseURIString(
|
||||
state['features/app'].app._getDefaultURL()
|
||||
);
|
||||
|
||||
store.dispatch(maybeAddNewKnownDomain(defaultURL.host));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's calendar and updates the stored entries if need be.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} store - The redux store.
|
||||
* @param {boolean} promptForPermission - Flag to tell the app if it should
|
||||
* prompt for a calendar permission if it wasn't granted yet.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _fetchCalendarEntries(store, promptForPermission) {
|
||||
_ensureCalendarAccess(promptForPermission)
|
||||
.then(() => {
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
|
||||
startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
|
||||
endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
|
||||
|
||||
RNCalendarEvents.fetchAllEvents(
|
||||
startDate.getTime(),
|
||||
endDate.getTime(),
|
||||
[]
|
||||
)
|
||||
.then(events => {
|
||||
const { knownDomains } = store.getState()['features/calendar-sync'];
|
||||
const eventList = [];
|
||||
|
||||
if (events && events.length) {
|
||||
for (const event of events) {
|
||||
const jitsiURL = _getURLFromEvent(event, knownDomains);
|
||||
const now = Date.now();
|
||||
|
||||
if (jitsiURL) {
|
||||
const eventStartDate = Date.parse(event.startDate);
|
||||
const eventEndDate = Date.parse(event.endDate);
|
||||
|
||||
if (isNaN(eventStartDate) || isNaN(eventEndDate)) {
|
||||
logger.warn(
|
||||
'Skipping calendar event due to invalid dates',
|
||||
event.title,
|
||||
event.startDate,
|
||||
event.endDate
|
||||
);
|
||||
} else if (eventEndDate > now) {
|
||||
eventList.push({
|
||||
endDate: eventEndDate,
|
||||
id: event.id,
|
||||
startDate: eventStartDate,
|
||||
title: event.title,
|
||||
url: jitsiURL
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store.dispatch(updateCalendarEntryList(eventList.sort((a, b) =>
|
||||
a.startDate - b.startDate
|
||||
).slice(0, MAX_LIST_LENGTH)));
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Error fetching calendar.', error);
|
||||
});
|
||||
})
|
||||
.catch(reason => {
|
||||
logger.error('Error accessing calendar.', reason);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retreives a jitsi URL from an event if present.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} event - The event to parse.
|
||||
* @param {Array<string>} knownDomains - The known domain names.
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
function _getURLFromEvent(event, knownDomains) {
|
||||
const linkTerminatorPattern = '[^\\s<>$]';
|
||||
/* eslint-disable max-len */
|
||||
const urlRegExp
|
||||
= new RegExp(`http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`, 'gi');
|
||||
/* eslint-enable max-len */
|
||||
const schemeRegExp
|
||||
= new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi');
|
||||
const fieldsToSearch = [
|
||||
event.title,
|
||||
event.url,
|
||||
event.location,
|
||||
event.notes,
|
||||
event.description
|
||||
];
|
||||
let matchArray;
|
||||
|
||||
for (const field of fieldsToSearch) {
|
||||
if (typeof field === 'string') {
|
||||
if (
|
||||
(matchArray
|
||||
= urlRegExp.exec(field) || schemeRegExp.exec(field))
|
||||
!== null
|
||||
) {
|
||||
return matchArray[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retreives the domain name of a room upon join and stores it
|
||||
* in the known domain list, if not present yet.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} store - The redux store.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function _parseAndAddDomain(store) {
|
||||
const { locationURL } = store.getState()['features/base/connection'];
|
||||
|
||||
store.dispatch(maybeAddNewKnownDomain(locationURL.host));
|
||||
}
|
||||
69
react/features/calendar-sync/reducer.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// @flow
|
||||
|
||||
import { ReducerRegistry } from '../base/redux';
|
||||
import { PersistenceRegistry } from '../base/storage';
|
||||
|
||||
import { NEW_CALENDAR_ENTRY_LIST, NEW_KNOWN_DOMAIN } from './actionTypes';
|
||||
|
||||
/**
|
||||
* ZB: this is an object, as further data is to come here, like:
|
||||
* - known domain list
|
||||
*/
|
||||
const DEFAULT_STATE = {
|
||||
events: [],
|
||||
knownDomains: []
|
||||
};
|
||||
|
||||
const MAX_DOMAIN_LIST_SIZE = 10;
|
||||
|
||||
const STORE_NAME = 'features/calendar-sync';
|
||||
|
||||
PersistenceRegistry.register(STORE_NAME, {
|
||||
knownDomains: true
|
||||
});
|
||||
|
||||
ReducerRegistry.register(
|
||||
STORE_NAME,
|
||||
(state = DEFAULT_STATE, action) => {
|
||||
switch (action.type) {
|
||||
case NEW_CALENDAR_ENTRY_LIST:
|
||||
return {
|
||||
...state,
|
||||
events: action.events
|
||||
};
|
||||
|
||||
case NEW_KNOWN_DOMAIN:
|
||||
return _maybeAddNewDomain(state, action);
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds a new domain to the known domain list if not present yet.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} action - The redux action.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _maybeAddNewDomain(state, action) {
|
||||
let { domainName } = action;
|
||||
const { knownDomains } = state;
|
||||
|
||||
if (domainName && domainName.length) {
|
||||
domainName = domainName.toLowerCase();
|
||||
if (knownDomains.indexOf(domainName) === -1) {
|
||||
knownDomains.push(domainName);
|
||||
|
||||
// Ensure the list doesn't exceed a/the maximum size.
|
||||
knownDomains.splice(0, knownDomains.length - MAX_DOMAIN_LIST_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
knownDomains
|
||||
};
|
||||
}
|
||||
7
react/features/chat/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The audio ID of the audio element for which the {@link playAudio} action is
|
||||
* triggered when new chat message is received.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const INCOMING_MSG_SOUND_ID = 'INCOMING_MSG_SOUND';
|
||||
3
react/features/chat/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './constants';
|
||||
|
||||
import './middleware';
|
||||
67
react/features/chat/middleware.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// @flow
|
||||
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
|
||||
import { CONFERENCE_JOINED } from '../base/conference';
|
||||
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
|
||||
import { MiddlewareRegistry } from '../base/redux';
|
||||
import { playSound, registerSound, unregisterSound } from '../base/sounds';
|
||||
|
||||
import { INCOMING_MSG_SOUND_ID } from './constants';
|
||||
import { INCOMING_MSG_SOUND_SRC } from './sounds';
|
||||
|
||||
declare var APP: Object;
|
||||
|
||||
/**
|
||||
* Implements the middleware of the chat feature.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case APP_WILL_MOUNT:
|
||||
// Register the chat message sound on Web only because there's no chat
|
||||
// on mobile.
|
||||
typeof APP === 'undefined'
|
||||
|| store.dispatch(
|
||||
registerSound(INCOMING_MSG_SOUND_ID, INCOMING_MSG_SOUND_SRC));
|
||||
break;
|
||||
|
||||
case APP_WILL_UNMOUNT:
|
||||
// Unregister the chat message sound on Web because it's registered
|
||||
// there only.
|
||||
typeof APP === 'undefined'
|
||||
|| store.dispatch(unregisterSound(INCOMING_MSG_SOUND_ID));
|
||||
break;
|
||||
|
||||
case CONFERENCE_JOINED:
|
||||
typeof APP === 'undefined'
|
||||
|| _addChatMsgListener(action.conference, store);
|
||||
break;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Registers listener for {@link JitsiConferenceEvents.MESSAGE_RECEIVED} which
|
||||
* will play a sound on the event, given that the chat is not currently visible.
|
||||
*
|
||||
* @param {JitsiConference} conference - The conference instance on which the
|
||||
* new event listener will be registered.
|
||||
* @param {Dispatch} next - The redux dispatch function to dispatch the
|
||||
* specified action to the specified store.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _addChatMsgListener(conference, { dispatch }) {
|
||||
// XXX Currently, there's no need to remove the listener, because the
|
||||
// JitsiConference instance cannot be reused. Hence, the listener will be
|
||||
// gone with the JitsiConference instance.
|
||||
conference.on(
|
||||
JitsiConferenceEvents.MESSAGE_RECEIVED,
|
||||
() => {
|
||||
APP.UI.isChatVisible()
|
||||
|| dispatch(playSound(INCOMING_MSG_SOUND_ID));
|
||||
});
|
||||
}
|
||||
6
react/features/chat/sounds.web.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* The audio source for the incoming chat message sound.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
export const INCOMING_MSG_SOUND_SRC = 'sounds/incomingMessage.wav';
|
||||
@@ -12,6 +12,7 @@ import { DialogContainer } from '../../base/dialog';
|
||||
import { CalleeInfoContainer } from '../../base/jwt';
|
||||
import { Container, LoadingIndicator, TintedView } from '../../base/react';
|
||||
import { createDesiredLocalTracks } from '../../base/tracks';
|
||||
import { ConferenceNotification } from '../../calendar-sync';
|
||||
import { Filmstrip } from '../../filmstrip';
|
||||
import { LargeVideo } from '../../large-video';
|
||||
import { setToolboxVisible, Toolbox } from '../../toolbox';
|
||||
@@ -233,6 +234,8 @@ class Conference extends Component<Props> {
|
||||
<Filmstrip />
|
||||
</View>
|
||||
|
||||
<ConferenceNotification />
|
||||
|
||||
{/*
|
||||
* The dialogs are in the topmost stacking layers.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
import { Audio } from '../../base/media';
|
||||
|
||||
const TEST_SOUND_PATH = 'sounds/ring.wav';
|
||||
|
||||
@@ -77,9 +78,8 @@ class AudioOutputPreview extends Component {
|
||||
<a onClick = { this._onClick }>
|
||||
{ this.props.t('deviceSelection.testAudio') }
|
||||
</a>
|
||||
<audio
|
||||
preload = 'auto'
|
||||
ref = { this._setAudioElement }
|
||||
<Audio
|
||||
setRef = { this._setAudioElement }
|
||||
src = { TEST_SOUND_PATH } />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* The type of the action which signals a check for a dial-out phone number has
|
||||
* succeeded.
|
||||
*
|
||||
* {
|
||||
* type: PHONE_NUMBER_CHECKED,
|
||||
* response: Object
|
||||
* }
|
||||
*/
|
||||
export const PHONE_NUMBER_CHECKED
|
||||
= Symbol('PHONE_NUMBER_CHECKED');
|
||||
|
||||
/**
|
||||
* The type of the action which signals a cancel of the dial-out operation.
|
||||
*
|
||||
* {
|
||||
* type: DIAL_OUT_CANCELED,
|
||||
* response: Object
|
||||
* }
|
||||
*/
|
||||
export const DIAL_OUT_CANCELED
|
||||
= Symbol('DIAL_OUT_CANCELED');
|
||||
|
||||
/**
|
||||
* The type of the action which signals a request for dial-out country codes has
|
||||
* succeeded.
|
||||
*
|
||||
* {
|
||||
* type: DIAL_OUT_CODES_UPDATED,
|
||||
* response: Object
|
||||
* }
|
||||
*/
|
||||
export const DIAL_OUT_CODES_UPDATED
|
||||
= Symbol('DIAL_OUT_CODES_UPDATED');
|
||||
|
||||
/**
|
||||
* The type of the action which signals a failure in some of dial-out service
|
||||
* requests.
|
||||
*
|
||||
* {
|
||||
* type: DIAL_OUT_SERVICE_FAILED,
|
||||
* response: Object
|
||||
* }
|
||||
*/
|
||||
export const DIAL_OUT_SERVICE_FAILED
|
||||
= Symbol('DIAL_OUT_SERVICE_FAILED');
|
||||
@@ -1,102 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import {
|
||||
DIAL_OUT_CANCELED,
|
||||
DIAL_OUT_CODES_UPDATED,
|
||||
DIAL_OUT_SERVICE_FAILED,
|
||||
PHONE_NUMBER_CHECKED
|
||||
} from './actionTypes';
|
||||
|
||||
declare var $: Function;
|
||||
declare var config: Object;
|
||||
|
||||
/**
|
||||
* Dials the given number.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function cancel() {
|
||||
return {
|
||||
type: DIAL_OUT_CANCELED
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dials the given number.
|
||||
*
|
||||
* @param {string} dialNumber - The number to dial.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function dial(dialNumber: string) {
|
||||
return (dispatch: Dispatch<*>, getState: Function) => {
|
||||
const { conference } = getState()['features/base/conference'];
|
||||
|
||||
conference.dial(dialNumber);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an ajax request for dial-out country codes.
|
||||
*
|
||||
* @param {string} dialNumber - The dial number to check for validity.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function checkDialNumber(dialNumber: string) {
|
||||
return (dispatch: Dispatch<*>, getState: Function) => {
|
||||
const { dialOutAuthUrl } = getState()['features/base/config'];
|
||||
|
||||
if (!dialOutAuthUrl) {
|
||||
// no auth url, let's say it is valid
|
||||
const response = {};
|
||||
|
||||
response.allow = true;
|
||||
dispatch({
|
||||
type: PHONE_NUMBER_CHECKED,
|
||||
response
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fullUrl = `${dialOutAuthUrl}?phone=${dialNumber}`;
|
||||
|
||||
$.getJSON(fullUrl)
|
||||
.then(response =>
|
||||
dispatch({
|
||||
type: PHONE_NUMBER_CHECKED,
|
||||
response
|
||||
}))
|
||||
.catch(error =>
|
||||
dispatch({
|
||||
type: DIAL_OUT_SERVICE_FAILED,
|
||||
error
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an ajax request for dial-out country codes.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function updateDialOutCodes() {
|
||||
return (dispatch: Dispatch<*>, getState: Function) => {
|
||||
const { dialOutCodesUrl } = getState()['features/base/config'];
|
||||
|
||||
if (!dialOutCodesUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.getJSON(dialOutCodesUrl)
|
||||
.then(response =>
|
||||
dispatch({
|
||||
type: DIAL_OUT_CODES_UPDATED,
|
||||
response
|
||||
}))
|
||||
.catch(error =>
|
||||
dispatch({
|
||||
type: DIAL_OUT_SERVICE_FAILED,
|
||||
error
|
||||
}));
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} to render a country flag icon.
|
||||
*/
|
||||
export default class CountryIcon extends Component {
|
||||
/**
|
||||
* {@code CountryIcon}'s property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* The css style class name.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
|
||||
/**
|
||||
* The 2-letter country code.
|
||||
*/
|
||||
countryCode: PropTypes.string
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const iconClassName
|
||||
= `flag-icon flag-icon-${
|
||||
this.props.countryCode} flag-icon-squared ${
|
||||
this.props.className}`;
|
||||
|
||||
return <span className = { iconClassName } />;
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
import { Dialog } from '../../base/dialog';
|
||||
|
||||
import { cancel, checkDialNumber, dial } from '../actions';
|
||||
import DialOutNumbersForm from './DialOutNumbersForm';
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} which allows the user to dial out from
|
||||
* the conference.
|
||||
*/
|
||||
class DialOutDialog extends Component {
|
||||
/**
|
||||
* {@code DialOutDialog} component's property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* The redux state representing the list of dial-out codes.
|
||||
*/
|
||||
_dialOutCodes: PropTypes.array,
|
||||
|
||||
/**
|
||||
* Property indicating if a dial number is allowed.
|
||||
*/
|
||||
_isDialNumberAllowed: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* The function performing the cancel action.
|
||||
*/
|
||||
cancel: PropTypes.func,
|
||||
|
||||
/**
|
||||
* The function performing the phone number validity check.
|
||||
*/
|
||||
checkDialNumber: PropTypes.func,
|
||||
|
||||
/**
|
||||
* The function performing the dial action.
|
||||
*/
|
||||
dial: PropTypes.func,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: PropTypes.func
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a new {@code DialOutNumbersForm} instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
/**
|
||||
* The number to dial.
|
||||
*/
|
||||
dialNumber: '',
|
||||
|
||||
/**
|
||||
* Indicates if the dial input is currently empty.
|
||||
*/
|
||||
isDialInputEmpty: true
|
||||
};
|
||||
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._onDialNumberChange = this._onDialNumberChange.bind(this);
|
||||
this._onCancel = this._onCancel.bind(this);
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { _isDialNumberAllowed } = this.props;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
okDisabled = { this.state.isDialInputEmpty
|
||||
|| !_isDialNumberAllowed }
|
||||
okTitleKey = 'dialOut.dial'
|
||||
onCancel = { this._onCancel }
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = 'dialOut.dialOut'
|
||||
width = 'small'>
|
||||
{ this._renderContent() }
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the dial number in a way to remove all non digital characters
|
||||
* from it (including spaces, brackets, dash, dot, etc.).
|
||||
*
|
||||
* @param {string} dialNumber - The phone number to format.
|
||||
* @private
|
||||
* @returns {string} - The formatted phone number.
|
||||
*/
|
||||
_formatDialNumber(dialNumber) {
|
||||
return dialNumber.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dialog content.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
* @private
|
||||
*/
|
||||
_renderContent() {
|
||||
const { _isDialNumberAllowed } = this.props;
|
||||
|
||||
return (
|
||||
<div className = 'dial-out-content'>
|
||||
{ _isDialNumberAllowed ? '' : this._renderErrorMessage() }
|
||||
<DialOutNumbersForm
|
||||
onChange = { this._onDialNumberChange } />
|
||||
</div>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the error message to display if the dial phone number is not
|
||||
* allowed.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
* @private
|
||||
*/
|
||||
_renderErrorMessage() {
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<div className = 'dial-out-error'>
|
||||
{ t('dialOut.phoneNotAllowed') }
|
||||
</div>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the dial out.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} - Returns true to indicate that the dialog should be
|
||||
* closed.
|
||||
*/
|
||||
_onCancel() {
|
||||
this.props.cancel();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dials the number.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} - Returns true to indicate that the dialog should be
|
||||
* closed.
|
||||
*/
|
||||
_onSubmit() {
|
||||
if (this.props._isDialNumberAllowed) {
|
||||
this.props.dial(this.state.dialNumber);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dialNumber and check for validity.
|
||||
*
|
||||
* @param {string} dialCode - The dial code value.
|
||||
* @param {string} dialInput - The dial input value.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onDialNumberChange(dialCode, dialInput) {
|
||||
let formattedDialInput, formattedNumber;
|
||||
|
||||
// if there are no dial out codes it is possible they are disabled
|
||||
// so we get the input as is, it can be just a sip address
|
||||
if (this.props._dialOutCodes) {
|
||||
// We remove all starting zeros from the dial input before attaching
|
||||
// it to the country code.
|
||||
formattedDialInput = dialInput.replace(/^(0+)/, '');
|
||||
|
||||
const dialNumber = `${dialCode}${formattedDialInput}`;
|
||||
|
||||
formattedNumber = this._formatDialNumber(dialNumber);
|
||||
|
||||
this.props.checkDialNumber(formattedNumber);
|
||||
} else {
|
||||
formattedNumber = formattedDialInput = dialInput;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
dialNumber: formattedNumber,
|
||||
isDialInputEmpty: !formattedDialInput
|
||||
|| formattedDialInput.length === 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated
|
||||
* {@code DialOutDialog}'s props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _isDialNumberAllowed: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
const { dialOutCodes, isDialNumberAllowed } = state['features/dial-out'];
|
||||
|
||||
return {
|
||||
/**
|
||||
* List of dial-out codes.
|
||||
*
|
||||
* @private
|
||||
* @type {array}
|
||||
*/
|
||||
_dialOutCodes: dialOutCodes,
|
||||
|
||||
/**
|
||||
* Property indicating if a dial number is allowed.
|
||||
*
|
||||
* @private
|
||||
* @type {boolean}
|
||||
*/
|
||||
_isDialNumberAllowed: isDialNumberAllowed
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(
|
||||
connect(_mapStateToProps, {
|
||||
cancel,
|
||||
checkDialNumber,
|
||||
dial
|
||||
})(DialOutDialog));
|
||||
@@ -1,369 +0,0 @@
|
||||
import { DropdownMenuStateless as DropdownMenu } from '@atlaskit/dropdown-menu';
|
||||
import { FieldTextStateless as TextField } from '@atlaskit/field-text';
|
||||
import ChevronDownIcon from '@atlaskit/icon/glyph/chevron-down';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
|
||||
import { updateDialOutCodes } from '../actions';
|
||||
import CountryIcon from './CountryIcon';
|
||||
|
||||
/**
|
||||
* The default value of the country if the fetch service is unavailable.
|
||||
*
|
||||
* @type {{
|
||||
* code: string,
|
||||
* dialCode: string,
|
||||
* name: string
|
||||
* }}
|
||||
*/
|
||||
const DEFAULT_COUNTRY = {
|
||||
code: 'US',
|
||||
dialCode: '+1',
|
||||
name: 'United States'
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} responsible for fetching and displaying dial-out
|
||||
* country codes, as well as dialing a phone number.
|
||||
*
|
||||
* @extends Component
|
||||
*/
|
||||
class DialOutNumbersForm extends Component {
|
||||
/**
|
||||
* {@code DialOutNumbersForm}'s property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* The redux state representing the list of dial-out codes.
|
||||
*/
|
||||
_dialOutCodes: PropTypes.array,
|
||||
|
||||
/**
|
||||
* The function called on every dial input change.
|
||||
*/
|
||||
onChange: PropTypes.func,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: PropTypes.func,
|
||||
|
||||
/**
|
||||
* Invoked to send an ajax request for dial-out codes.
|
||||
*/
|
||||
updateDialOutCodes: PropTypes.func
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a new {@code DialOutNumbersForm} instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
dialInput: '',
|
||||
|
||||
/**
|
||||
* Whether or not the dropdown should be open.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
isDropdownOpen: false,
|
||||
|
||||
/**
|
||||
* The selected country.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
selectedCountry: DEFAULT_COUNTRY
|
||||
};
|
||||
|
||||
/**
|
||||
* The internal reference to the DOM/HTML element backing the React
|
||||
* {@code Component} text input.
|
||||
*
|
||||
* @private
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
this._dialInputElem = null;
|
||||
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._onDropdownTriggerInputChange
|
||||
= this._onDropdownTriggerInputChange.bind(this);
|
||||
this._onInputChange = this._onInputChange.bind(this);
|
||||
this._onOpenChange = this._onOpenChange.bind(this);
|
||||
this._onSelect = this._onSelect.bind(this);
|
||||
this._setDialInputElement = this._setDialInputElement.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a request for dial out codes if not already present in the
|
||||
* redux store. If dial out codes are present, sets a default code to
|
||||
* display in the dropdown trigger.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentDidMount() {
|
||||
const dialOutCodes = this.props._dialOutCodes;
|
||||
|
||||
if (dialOutCodes) {
|
||||
this._setDefaultCode(dialOutCodes);
|
||||
} else {
|
||||
this.props.updateDialOutCodes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors for dial out code updates and sets a default code to display in
|
||||
* the dropdown trigger if not already set.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!this.state.selectedCountry && nextProps._dialOutCodes) {
|
||||
this._setDefaultCode(nextProps._dialOutCodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { t, _dialOutCodes } = this.props;
|
||||
|
||||
return (
|
||||
<div className = 'form-control'>
|
||||
{ _dialOutCodes ? this._createDropdownMenu(
|
||||
this._formatCountryCodes(_dialOutCodes)) : null }
|
||||
<div className = 'dial-out-input'>
|
||||
<TextField
|
||||
autoFocus = { true }
|
||||
isLabelHidden = { true }
|
||||
label = { 'dial-out-input-field' }
|
||||
onChange = { this._onInputChange }
|
||||
placeholder = { t('dialOut.enterPhone') }
|
||||
ref = { this._setDialInputElement }
|
||||
shouldFitContainer = { true }
|
||||
value = { this.state.dialInput } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@code DropdownMenu} instance.
|
||||
*
|
||||
* @param {Array} items - The content to display within the dropdown.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_createDropdownMenu(items) {
|
||||
const { code, dialCode } = this.state.selectedCountry;
|
||||
|
||||
return (
|
||||
<div className = 'dropdown-container'>
|
||||
<DropdownMenu
|
||||
isOpen = { this.state.isDropdownOpen }
|
||||
items = { [ { items } ] }
|
||||
onItemActivated = { this._onSelect }
|
||||
onOpenChange = { this._onOpenChange }
|
||||
shouldFitContainer = { false }>
|
||||
{ this._createDropdownTrigger(dialCode, code) }
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a React {@code Component} with a readonly HTMLInputElement as a
|
||||
* trigger for displaying the dropdown menu. The {@code Component} will also
|
||||
* display the currently selected number.
|
||||
*
|
||||
* @param {string} dialCode - The +xx dial code.
|
||||
* @param {string} countryCode - The country 2 letter code.
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_createDropdownTrigger(dialCode, countryCode) {
|
||||
return (
|
||||
<div className = 'dropdown'>
|
||||
<CountryIcon
|
||||
className = 'dial-out-flag-icon'
|
||||
countryCode = { `${countryCode}` } />
|
||||
{ /**
|
||||
* FIXME Replace TextField with AtlasKit Button when an issue
|
||||
* with icons shrinking due to button text is fixed.
|
||||
*/ }
|
||||
<TextField
|
||||
className = 'input-control dial-out-code'
|
||||
isLabelHidden = { true }
|
||||
isReadOnly = { true }
|
||||
label = 'dial-out-code'
|
||||
onChange = { this._onDropdownTriggerInputChange }
|
||||
type = 'text'
|
||||
value = { dialCode || '' } />
|
||||
<span className = 'dropdown-trigger-icon'>
|
||||
<ChevronDownIcon
|
||||
label = 'expand'
|
||||
size = 'small' />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the passed in numbers object into an array of objects that can
|
||||
* be parsed by {@code DropdownMenu}.
|
||||
*
|
||||
* @param {Object} countryCodes - The list of country codes.
|
||||
* @private
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
_formatCountryCodes(countryCodes) {
|
||||
return countryCodes.map(country => {
|
||||
const countryIcon
|
||||
= <CountryIcon countryCode = { `${country.code}` } />;
|
||||
const countryElement
|
||||
= <span>{countryIcon} { country.name }</span>;
|
||||
|
||||
return {
|
||||
content: `${country.dialCode}`,
|
||||
country,
|
||||
elemBefore: countryElement
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dialNumber when changes to the dial text or code happen.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onDialNumberChange() {
|
||||
const { dialCode } = this.state.selectedCountry;
|
||||
|
||||
this.props.onChange(dialCode, this.state.dialInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a no-op function used to stub out TextField's onChange in order
|
||||
* to prevent TextField from printing prop type validation errors. TextField
|
||||
* is used as a trigger for the dropdown in {@code DialOutNumbersForm} to
|
||||
* get the desired AtlasKit input look for the UI.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onDropdownTriggerInputChange() {
|
||||
// Intentionally left empty.
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dialInput state when the input changes.
|
||||
*
|
||||
* @param {Object} e - The event notifying us of the change.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onInputChange(e) {
|
||||
this.setState({
|
||||
dialInput: e.target.value
|
||||
}, () => {
|
||||
this._onDialNumberChange();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the internal state to either open or close the dropdown. If the
|
||||
* dropdown is disabled, the state will always be set to false.
|
||||
*
|
||||
* @param {Object} dropdownEvent - The even returned from clicking on the
|
||||
* dropdown trigger.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOpenChange(dropdownEvent) {
|
||||
this.setState({
|
||||
isDropdownOpen: dropdownEvent.isOpen
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the internal state of the currently selected country code.
|
||||
*
|
||||
* @param {Object} selection - Event from choosing an dropdown option.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSelect(selection) {
|
||||
this.setState({
|
||||
isDropdownOpen: false,
|
||||
selectedCountry: selection.item.country
|
||||
}, () => {
|
||||
this._onDialNumberChange();
|
||||
|
||||
this._dialInputElem.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the internal state of the currently selected number by defaulting
|
||||
* to the first available number.
|
||||
*
|
||||
* @param {Object} countryCodes - The list of country codes to choose from
|
||||
* for setting a default code.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setDefaultCode(countryCodes) {
|
||||
this.setState({
|
||||
selectedCountry: countryCodes[0]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the internal reference to the DOM/HTML element backing the React
|
||||
* {@code Component} dial input.
|
||||
*
|
||||
* @param {HTMLInputElement} input - The DOM/HTML element for this
|
||||
* {@code Component}'s text input.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setDialInputElement(input) {
|
||||
this._dialInputElem = input;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated
|
||||
* {@code DialOutNumbersForm}'s props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _dialOutCodes: Object
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
const { dialOutCodes } = state['features/dial-out'];
|
||||
|
||||
return {
|
||||
_dialOutCodes: dialOutCodes
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(
|
||||
connect(_mapStateToProps, { updateDialOutCodes })(DialOutNumbersForm));
|
||||
@@ -1 +0,0 @@
|
||||
export { default as DialOutDialog } from './DialOutDialog';
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
ReducerRegistry
|
||||
} from '../base/redux';
|
||||
|
||||
import {
|
||||
DIAL_OUT_CANCELED,
|
||||
DIAL_OUT_CODES_UPDATED,
|
||||
DIAL_OUT_SERVICE_FAILED,
|
||||
PHONE_NUMBER_CHECKED
|
||||
} from './actionTypes';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
dialOutCodes: null,
|
||||
error: null,
|
||||
isDialNumberAllowed: true
|
||||
};
|
||||
|
||||
ReducerRegistry.register(
|
||||
'features/dial-out',
|
||||
(state = DEFAULT_STATE, action) => {
|
||||
switch (action.type) {
|
||||
case DIAL_OUT_CANCELED: {
|
||||
// if we have already downloaded codes fill them in default state
|
||||
// to skip another ajax query
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
dialOutCodes: state.dialOutCodes
|
||||
};
|
||||
}
|
||||
case DIAL_OUT_CODES_UPDATED: {
|
||||
return {
|
||||
...state,
|
||||
error: null,
|
||||
dialOutCodes: action.response
|
||||
};
|
||||
}
|
||||
case DIAL_OUT_SERVICE_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
error: action.error
|
||||
};
|
||||
}
|
||||
case PHONE_NUMBER_CHECKED: {
|
||||
return {
|
||||
...state,
|
||||
error: null,
|
||||
isDialNumberAllowed: action.response.allow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getLocalParticipant, PARTICIPANT_ROLE } from '../../base/participants';
|
||||
import { InviteButton } from '../../invite';
|
||||
import { Toolbox } from '../../toolbox';
|
||||
|
||||
@@ -43,6 +44,18 @@ class Filmstrip extends Component<*> {
|
||||
*/
|
||||
_hovered: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Whether or not the feature to directly invite people into the
|
||||
* conference is available.
|
||||
*/
|
||||
_isAddToCallAvailable: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Whether or not the feature to dial out to number to join the
|
||||
* conference is available.
|
||||
*/
|
||||
_isDialOutAvailable: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Whether or not the remote videos should be visible. Will toggle
|
||||
* a class for hiding the videos.
|
||||
@@ -93,6 +106,14 @@ class Filmstrip extends Component<*> {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const {
|
||||
_hideInviteButton,
|
||||
_isAddToCallAvailable,
|
||||
_isDialOutAvailable,
|
||||
_remoteVideosVisible,
|
||||
filmstripOnly
|
||||
} = this.props;
|
||||
|
||||
/**
|
||||
* Note: Appending of {@code RemoteVideo} views is handled through
|
||||
* VideoLayout. The views do not get blown away on render() because
|
||||
@@ -102,12 +123,12 @@ class Filmstrip extends Component<*> {
|
||||
* modified, then the views will get blown away.
|
||||
*/
|
||||
|
||||
const filmstripClassNames = `filmstrip ${this.props._remoteVideosVisible
|
||||
? '' : 'hide-videos'}`;
|
||||
const filmstripClassNames = `filmstrip ${_remoteVideosVisible ? ''
|
||||
: 'hide-videos'}`;
|
||||
|
||||
return (
|
||||
<div className = { filmstripClassNames }>
|
||||
{ this.props.filmstripOnly ? <Toolbox /> : null }
|
||||
{ filmstripOnly ? <Toolbox /> : null }
|
||||
<div
|
||||
className = 'filmstrip__videos'
|
||||
id = 'remoteVideos'>
|
||||
@@ -116,9 +137,11 @@ class Filmstrip extends Component<*> {
|
||||
id = 'filmstripLocalVideo'
|
||||
onMouseOut = { this._onMouseOut }
|
||||
onMouseOver = { this._onMouseOver }>
|
||||
{ this.props.filmstripOnly
|
||||
|| this.props._hideInviteButton
|
||||
? null : <InviteButton /> }
|
||||
{ filmstripOnly || _hideInviteButton
|
||||
? null
|
||||
: <InviteButton
|
||||
enableAddPeople = { _isAddToCallAvailable }
|
||||
enableDialOut = { _isDialOutAvailable } /> }
|
||||
<div id = 'filmstripLocalVideoThumbnail' />
|
||||
</div>
|
||||
<div
|
||||
@@ -135,14 +158,6 @@ class Filmstrip extends Component<*> {
|
||||
onMouseOut = { this._onMouseOut }
|
||||
onMouseOver = { this._onMouseOver } />
|
||||
</div>
|
||||
<audio
|
||||
id = 'userJoined'
|
||||
preload = 'auto'
|
||||
src = 'sounds/joined.wav' />
|
||||
<audio
|
||||
id = 'userLeft'
|
||||
preload = 'auto'
|
||||
src = 'sounds/left.wav' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -192,15 +207,34 @@ class Filmstrip extends Component<*> {
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _hovered: boolean,
|
||||
* _hideInviteButton: boolean,
|
||||
* _hovered: boolean,
|
||||
* _isAddToCallAvailable: boolean,
|
||||
* _isDialOutAvailable: boolean,
|
||||
* _remoteVideosVisible: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
const { conference } = state['features/base/conference'];
|
||||
const {
|
||||
enableUserRolesBasedOnToken,
|
||||
iAmRecorder
|
||||
} = state['features/base/config'];
|
||||
const { isGuest } = state['features/base/jwt'];
|
||||
const { hovered } = state['features/filmstrip'];
|
||||
|
||||
const isAddToCallAvailable = !isGuest;
|
||||
const isDialOutAvailable
|
||||
= getLocalParticipant(state).role === PARTICIPANT_ROLE.MODERATOR
|
||||
&& conference && conference.isSIPCallingSupported()
|
||||
&& (!enableUserRolesBasedOnToken || !isGuest);
|
||||
|
||||
return {
|
||||
_hovered: state['features/filmstrip'].hovered,
|
||||
_hideInviteButton: state['features/base/config'].iAmRecorder,
|
||||
_hideInviteButton: iAmRecorder
|
||||
|| (!isAddToCallAvailable && !isDialOutAvailable),
|
||||
_hovered: hovered,
|
||||
_isAddToCallAvailable: isAddToCallAvailable,
|
||||
_isDialOutAvailable: isDialOutAvailable,
|
||||
_remoteVideosVisible: shouldRemoteVideosBeVisible(state)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,12 +11,21 @@ import { getInviteURL } from '../../base/connection';
|
||||
import { Dialog, hideDialog } from '../../base/dialog';
|
||||
import { translate } from '../../base/i18n';
|
||||
import { MultiSelectAutocomplete } from '../../base/react';
|
||||
|
||||
import { invitePeopleAndChatRooms, searchDirectory } from '../functions';
|
||||
import { inviteVideoRooms } from '../../videosipgw';
|
||||
|
||||
import {
|
||||
checkDialNumber,
|
||||
invitePeopleAndChatRooms,
|
||||
searchDirectory
|
||||
} from '../functions';
|
||||
|
||||
const logger = require('jitsi-meet-logger').getLogger(__filename);
|
||||
|
||||
declare var interfaceConfig: Object;
|
||||
|
||||
const isPhoneNumberRegex
|
||||
= new RegExp(interfaceConfig.PHONE_NUMBER_REGEX || '^[0-9+()-\\s]*$');
|
||||
|
||||
/**
|
||||
* The dialog that allows to invite people to the call.
|
||||
*/
|
||||
@@ -33,6 +42,11 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
*/
|
||||
_conference: PropTypes.object,
|
||||
|
||||
/**
|
||||
* The URL for validating if a phone number can be called.
|
||||
*/
|
||||
_dialOutAuthUrl: PropTypes.string,
|
||||
|
||||
/**
|
||||
* The URL pointing to the service allowing for people invite.
|
||||
*/
|
||||
@@ -58,6 +72,16 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
*/
|
||||
_peopleSearchUrl: PropTypes.string,
|
||||
|
||||
/**
|
||||
* Whether or not to show Add People functionality.
|
||||
*/
|
||||
enableAddPeople: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Whether or not to show Dial Out functionality.
|
||||
*/
|
||||
enableDialOut: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* The function closing the dialog.
|
||||
*/
|
||||
@@ -76,33 +100,7 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
|
||||
_multiselect = null;
|
||||
|
||||
_resourceClient = {
|
||||
makeQuery: text => {
|
||||
const {
|
||||
_jwt,
|
||||
_peopleSearchQueryTypes,
|
||||
_peopleSearchUrl
|
||||
} = this.props; // eslint-disable-line no-invalid-this
|
||||
|
||||
return (
|
||||
searchDirectory(
|
||||
_peopleSearchUrl,
|
||||
_jwt,
|
||||
text,
|
||||
_peopleSearchQueryTypes));
|
||||
},
|
||||
|
||||
parseResults: response => response.map(user => {
|
||||
return {
|
||||
content: user.name,
|
||||
elemBefore: <Avatar
|
||||
size = 'medium'
|
||||
src = { user.avatar } />,
|
||||
item: user,
|
||||
value: user.id
|
||||
};
|
||||
})
|
||||
};
|
||||
_resourceClient: Object;
|
||||
|
||||
state = {
|
||||
/**
|
||||
@@ -116,6 +114,12 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
*/
|
||||
addToCallInProgress: false,
|
||||
|
||||
|
||||
// FIXME: Remove usage of Immutable. {@code MultiSelectAutocomplete}
|
||||
// will default to having its internal implementation use a plain array
|
||||
// if no {@link defaultValue} is passed in. As such is the case, this
|
||||
// instance of Immutable.List gets overridden with an array on the first
|
||||
// search.
|
||||
/**
|
||||
* The list of invite items.
|
||||
*/
|
||||
@@ -133,9 +137,17 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._isAddDisabled = this._isAddDisabled.bind(this);
|
||||
this._onItemSelected = this._onItemSelected.bind(this);
|
||||
this._onSelectionChange = this._onSelectionChange.bind(this);
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
this._parseQueryResults = this._parseQueryResults.bind(this);
|
||||
this._query = this._query.bind(this);
|
||||
this._setMultiSelectElement = this._setMultiSelectElement.bind(this);
|
||||
|
||||
this._resourceClient = {
|
||||
makeQuery: this._query,
|
||||
parseResults: this._parseQueryResults
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +165,7 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
&& !this.state.addToCallInProgress
|
||||
&& !this.state.addToCallError
|
||||
&& this._multiselect) {
|
||||
this._multiselect.clear();
|
||||
this._multiselect.setSelectedItems([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,18 +175,69 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { enableAddPeople, enableDialOut, t } = this.props;
|
||||
let isMultiSelectDisabled = this.state.addToCallInProgress || false;
|
||||
let placeholder;
|
||||
let loadingMessage;
|
||||
let noMatches;
|
||||
|
||||
if (enableAddPeople && enableDialOut) {
|
||||
loadingMessage = 'addPeople.loading';
|
||||
noMatches = 'addPeople.noResults';
|
||||
placeholder = 'addPeople.searchPeopleAndNumbers';
|
||||
} else if (enableAddPeople) {
|
||||
loadingMessage = 'addPeople.loadingPeople';
|
||||
noMatches = 'addPeople.noResults';
|
||||
placeholder = 'addPeople.searchPeople';
|
||||
} else if (enableDialOut) {
|
||||
loadingMessage = 'addPeople.loadingNumber';
|
||||
noMatches = 'addPeople.noValidNumbers';
|
||||
placeholder = 'addPeople.searchNumbers';
|
||||
} else {
|
||||
isMultiSelectDisabled = true;
|
||||
noMatches = 'addPeople.noResults';
|
||||
placeholder = 'addPeople.disabled';
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
okDisabled = { this._isAddDisabled() }
|
||||
okTitleKey = 'addPeople.add'
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = 'addPeople.title'
|
||||
width = 'small'>
|
||||
{ this._renderUserInputForm() }
|
||||
width = 'medium'>
|
||||
<div className = 'add-people-form-wrap'>
|
||||
{ this._renderErrorMessage() }
|
||||
<MultiSelectAutocomplete
|
||||
isDisabled = { isMultiSelectDisabled }
|
||||
loadingMessage = { t(loadingMessage) }
|
||||
noMatchesFound = { t(noMatches) }
|
||||
onItemSelected = { this._onItemSelected }
|
||||
onSelectionChange = { this._onSelectionChange }
|
||||
placeholder = { t(placeholder) }
|
||||
ref = { this._setMultiSelectElement }
|
||||
resourceClient = { this._resourceClient }
|
||||
shouldFitContainer = { true }
|
||||
shouldFocus = { true } />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
_getDigitsOnly: (string) => string;
|
||||
|
||||
/**
|
||||
* Removes all non-numeric characters from a string.
|
||||
*
|
||||
* @param {string} text - The string from which to remove all characters
|
||||
* except numbers.
|
||||
* @private
|
||||
* @returns {string} A string with only numbers.
|
||||
*/
|
||||
_getDigitsOnly(text = '') {
|
||||
return text.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
_isAddDisabled: () => boolean;
|
||||
|
||||
/**
|
||||
@@ -189,6 +252,45 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
|| this.state.addToCallInProgress;
|
||||
}
|
||||
|
||||
_isMaybeAPhoneNumber: (string) => boolean;
|
||||
|
||||
/**
|
||||
* Checks whether a string looks like it could be for a phone number.
|
||||
*
|
||||
* @param {string} text - The text to check whether or not it could be a
|
||||
* phone number.
|
||||
* @private
|
||||
* @returns {boolean} True if the string looks like it could be a phone
|
||||
* number.
|
||||
*/
|
||||
_isMaybeAPhoneNumber(text) {
|
||||
if (!isPhoneNumberRegex.test(text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const digits = this._getDigitsOnly(text);
|
||||
|
||||
return Boolean(digits.length);
|
||||
}
|
||||
|
||||
_onItemSelected: (Object) => Object;
|
||||
|
||||
/**
|
||||
* Callback invoked when a selection has been made but before it has been
|
||||
* set as selected.
|
||||
*
|
||||
* @param {Object} item - The item that has just been selected.
|
||||
* @private
|
||||
* @returns {Object} The item to display as selected in the input.
|
||||
*/
|
||||
_onItemSelected(item) {
|
||||
if (item.item.type === 'phone') {
|
||||
item.content = item.item.number;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
_onSelectionChange: (Map<*, *>) => void;
|
||||
|
||||
/**
|
||||
@@ -199,55 +301,279 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSelectionChange(selectedItems) {
|
||||
const selectedIds = selectedItems.map(o => o.item);
|
||||
|
||||
this.setState({
|
||||
inviteItems: selectedIds
|
||||
inviteItems: selectedItems
|
||||
});
|
||||
}
|
||||
|
||||
_onSubmit: () => void;
|
||||
|
||||
/**
|
||||
* Handles the submit button action.
|
||||
* Invite people and numbers to the conference. The logic works by inviting
|
||||
* numbers, people/rooms, and videosipgw in parallel. All invitees are
|
||||
* stored in an array. As each invite succeeds, the invitee is removed
|
||||
* from the array. After all invites finish, close the modal if there are
|
||||
* no invites left to send. If any are left, that means an invite failed
|
||||
* and an error state should display.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSubmit() {
|
||||
if (!this._isAddDisabled()) {
|
||||
this.setState({
|
||||
addToCallInProgress: true
|
||||
if (this._isAddDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
addToCallInProgress: true
|
||||
});
|
||||
|
||||
let allInvitePromises = [];
|
||||
let invitesLeftToSend = [
|
||||
...this.state.inviteItems
|
||||
];
|
||||
|
||||
// First create all promises for dialing out.
|
||||
if (this.props.enableDialOut && this.props._conference) {
|
||||
const phoneNumbers = invitesLeftToSend.filter(
|
||||
({ item }) => item.type === 'phone');
|
||||
|
||||
// For each number, dial out. On success, remove the number from
|
||||
// {@link invitesLeftToSend}.
|
||||
const phoneInvitePromises = phoneNumbers.map(number => {
|
||||
const numberToInvite = this._getDigitsOnly(number.item.number);
|
||||
|
||||
return this.props._conference.dial(numberToInvite)
|
||||
.then(() => {
|
||||
invitesLeftToSend
|
||||
= invitesLeftToSend.filter(invite =>
|
||||
invite !== number);
|
||||
})
|
||||
.catch(error => logger.error(
|
||||
'Error inviting phone number:', error));
|
||||
|
||||
});
|
||||
|
||||
const vrooms = this.state.inviteItems.filter(
|
||||
i => i.type === 'videosipgw');
|
||||
allInvitePromises = allInvitePromises.concat(phoneInvitePromises);
|
||||
}
|
||||
|
||||
if (this.props.enableAddPeople) {
|
||||
const usersAndRooms = invitesLeftToSend.filter(i =>
|
||||
i.item.type === 'user' || i.item.type === 'room')
|
||||
.map(i => i.item);
|
||||
|
||||
if (usersAndRooms.length) {
|
||||
// Send a request to invite all the rooms and users. On success,
|
||||
// filter all rooms and users from {@link invitesLeftToSend}.
|
||||
const peopleInvitePromise = invitePeopleAndChatRooms(
|
||||
this.props._inviteServiceUrl,
|
||||
this.props._inviteUrl,
|
||||
this.props._jwt,
|
||||
usersAndRooms)
|
||||
.then(() => {
|
||||
invitesLeftToSend = invitesLeftToSend.filter(i =>
|
||||
i.item.type !== 'user' && i.item.type !== 'room');
|
||||
})
|
||||
.catch(error => logger.error(
|
||||
'Error inviting people:', error));
|
||||
|
||||
allInvitePromises.push(peopleInvitePromise);
|
||||
}
|
||||
|
||||
// Sipgw calls are fire and forget. Invite them to the conference
|
||||
// then immediately remove them from {@link invitesLeftToSend}.
|
||||
const vrooms = invitesLeftToSend.filter(i =>
|
||||
i.item.type === 'videosipgw')
|
||||
.map(i => i.item);
|
||||
|
||||
this.props._conference
|
||||
&& vrooms.length > 0
|
||||
&& this.props.inviteVideoRooms(this.props._conference, vrooms);
|
||||
&& this.props.inviteVideoRooms(
|
||||
this.props._conference, vrooms);
|
||||
|
||||
invitePeopleAndChatRooms(
|
||||
this.props._inviteServiceUrl,
|
||||
this.props._inviteUrl,
|
||||
this.props._jwt,
|
||||
this.state.inviteItems.filter(
|
||||
i => i.type === 'user' || i.type === 'room'))
|
||||
.then(
|
||||
/* onFulfilled */ () => {
|
||||
this.setState({
|
||||
addToCallInProgress: false
|
||||
});
|
||||
invitesLeftToSend = invitesLeftToSend.filter(i =>
|
||||
i.item.type !== 'videosipgw');
|
||||
}
|
||||
|
||||
Promise.all(allInvitePromises)
|
||||
.then(() => {
|
||||
// If any invites are left that means something failed to send
|
||||
// so treat it as an error.
|
||||
if (invitesLeftToSend.length) {
|
||||
logger.error(`${invitesLeftToSend.length} invites failed`);
|
||||
|
||||
this.props.hideDialog();
|
||||
},
|
||||
/* onRejected */ () => {
|
||||
this.setState({
|
||||
addToCallInProgress: false,
|
||||
addToCallError: true
|
||||
});
|
||||
|
||||
if (this._multiselect) {
|
||||
this._multiselect.setSelectedItems(invitesLeftToSend);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
addToCallInProgress: false
|
||||
});
|
||||
|
||||
this.props.hideDialog();
|
||||
});
|
||||
}
|
||||
|
||||
_parseQueryResults: (Array<Object>, string) => Array<Object>;
|
||||
|
||||
/**
|
||||
* Processes results from requesting available numbers and people by munging
|
||||
* each result into a format {@code MultiSelectAutocomplete} can use for
|
||||
* display.
|
||||
*
|
||||
* @param {Array} response - The response object from the server for the
|
||||
* query.
|
||||
* @private
|
||||
* @returns {Object[]} Configuration objects for items to display in the
|
||||
* search autocomplete.
|
||||
*/
|
||||
_parseQueryResults(response = []) {
|
||||
const { t } = this.props;
|
||||
const users = response.filter(item => item.type !== 'phone');
|
||||
const userDisplayItems = users.map(user => {
|
||||
return {
|
||||
content: user.name,
|
||||
elemBefore: <Avatar
|
||||
size = 'medium'
|
||||
src = { user.avatar } />,
|
||||
item: user,
|
||||
tag: {
|
||||
elemBefore: <Avatar
|
||||
size = 'xsmall'
|
||||
src = { user.avatar } />
|
||||
},
|
||||
value: user.id
|
||||
};
|
||||
});
|
||||
|
||||
const numbers = response.filter(item => item.type === 'phone');
|
||||
const telephoneIcon = this._renderTelephoneIcon();
|
||||
|
||||
const numberDisplayItems = numbers.map(number => {
|
||||
const numberNotAllowedMessage
|
||||
= number.allowed ? '' : t('addPeople.countryNotSupported');
|
||||
const countryCodeReminder = number.showCountryCodeReminder
|
||||
? t('addPeople.countryReminder') : '';
|
||||
const description
|
||||
= `${numberNotAllowedMessage} ${countryCodeReminder}`.trim();
|
||||
|
||||
return {
|
||||
filterValues: [
|
||||
number.originalEntry,
|
||||
number.number
|
||||
],
|
||||
content: t('addPeople.telephone', { number: number.number }),
|
||||
description,
|
||||
isDisabled: !number.allowed,
|
||||
elemBefore: telephoneIcon,
|
||||
item: number,
|
||||
tag: {
|
||||
elemBefore: telephoneIcon
|
||||
},
|
||||
value: number.number
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...userDisplayItems,
|
||||
...numberDisplayItems
|
||||
];
|
||||
}
|
||||
|
||||
_query: (string) => Promise<Array<Object>>;
|
||||
|
||||
/**
|
||||
* Performs a people and phone number search request.
|
||||
*
|
||||
* @param {string} query - The search text.
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_query(query = '') {
|
||||
const text = query.trim();
|
||||
const {
|
||||
_dialOutAuthUrl,
|
||||
_jwt,
|
||||
_peopleSearchQueryTypes,
|
||||
_peopleSearchUrl
|
||||
} = this.props;
|
||||
|
||||
let peopleSearchPromise;
|
||||
|
||||
if (this.props.enableAddPeople) {
|
||||
peopleSearchPromise = searchDirectory(
|
||||
_peopleSearchUrl,
|
||||
_jwt,
|
||||
text,
|
||||
_peopleSearchQueryTypes);
|
||||
} else {
|
||||
peopleSearchPromise = Promise.resolve([]);
|
||||
}
|
||||
|
||||
|
||||
const hasCountryCode = text.startsWith('+');
|
||||
let phoneNumberPromise;
|
||||
|
||||
if (this.props.enableDialOut && this._isMaybeAPhoneNumber(text)) {
|
||||
let numberToVerify = text;
|
||||
|
||||
// When the number to verify does not start with a +, we assume no
|
||||
// proper country code has been entered. In such a case, prepend 1
|
||||
// for the country code. The service currently takes care of
|
||||
// prepending the +.
|
||||
if (!hasCountryCode && !text.startsWith('1')) {
|
||||
numberToVerify = `1${numberToVerify}`;
|
||||
}
|
||||
|
||||
// The validation service works properly when the query is digits
|
||||
// only so ensure only digits get sent.
|
||||
numberToVerify = this._getDigitsOnly(numberToVerify);
|
||||
|
||||
phoneNumberPromise
|
||||
= checkDialNumber(numberToVerify, _dialOutAuthUrl);
|
||||
} else {
|
||||
phoneNumberPromise = Promise.resolve({});
|
||||
}
|
||||
|
||||
return Promise.all([ peopleSearchPromise, phoneNumberPromise ])
|
||||
.then(([ peopleResults, phoneResults ]) => {
|
||||
const results = [
|
||||
...peopleResults
|
||||
];
|
||||
|
||||
/**
|
||||
* This check for phone results is for the day the call to
|
||||
* searching people might return phone results as well. When
|
||||
* that day comes this check will make it so the server checks
|
||||
* are honored and the local appending of the number is not
|
||||
* done. The local appending of the phone number can then be
|
||||
* cleaned up when convenient.
|
||||
*/
|
||||
const hasPhoneResult = peopleResults.find(
|
||||
result => result.type === 'phone');
|
||||
|
||||
if (!hasPhoneResult
|
||||
&& typeof phoneResults.allow === 'boolean') {
|
||||
results.push({
|
||||
allowed: phoneResults.allow,
|
||||
country: phoneResults.country,
|
||||
type: 'phone',
|
||||
number: phoneResults.phone,
|
||||
originalEntry: text,
|
||||
showCountryCodeReminder: !hasCountryCode
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,28 +620,16 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the input form.
|
||||
* Renders a telephone icon.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderUserInputForm() {
|
||||
const { t } = this.props;
|
||||
|
||||
_renderTelephoneIcon() {
|
||||
return (
|
||||
<div className = 'add-people-form-wrap'>
|
||||
{ this._renderErrorMessage() }
|
||||
<MultiSelectAutocomplete
|
||||
isDisabled
|
||||
= { this.state.addToCallInProgress || false }
|
||||
noMatchesFound = { t('addPeople.noResults') }
|
||||
onSelectionChange = { this._onSelectionChange }
|
||||
placeholder = { t('addPeople.searchPlaceholder') }
|
||||
ref = { this._setMultiSelectElement }
|
||||
resourceClient = { this._resourceClient }
|
||||
shouldFitContainer = { true }
|
||||
shouldFocus = { true } />
|
||||
</div>
|
||||
<span className = 'add-telephone-icon'>
|
||||
<i className = 'icon-telephone' />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,13 +655,19 @@ class AddPeopleDialog extends Component<*, *> {
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _conference: Object,
|
||||
* _dialOutAuthUrl: string,
|
||||
* _inviteServiceUrl: string,
|
||||
* _inviteUrl: string,
|
||||
* _jwt: string,
|
||||
* _peopleSearchQueryTypes: Array<string>,
|
||||
* _peopleSearchUrl: string
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
const { conference } = state['features/base/conference'];
|
||||
const {
|
||||
dialOutAuthUrl,
|
||||
inviteServiceUrl,
|
||||
peopleSearchQueryTypes,
|
||||
peopleSearchUrl
|
||||
@@ -355,6 +675,7 @@ function _mapStateToProps(state) {
|
||||
|
||||
return {
|
||||
_conference: conference,
|
||||
_dialOutAuthUrl: dialOutAuthUrl,
|
||||
_inviteServiceUrl: inviteServiceUrl,
|
||||
_inviteUrl: getInviteURL(state),
|
||||
_jwt: state['features/base/jwt'].jwt,
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
/* global interfaceConfig */
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Button from '@atlaskit/button';
|
||||
import DropdownMenu from '@atlaskit/dropdown-menu';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
import { getLocalParticipant, PARTICIPANT_ROLE } from '../../base/participants';
|
||||
|
||||
import { openDialog } from '../../base/dialog';
|
||||
import { translate } from '../../base/i18n';
|
||||
import { AddPeopleDialog } from '.';
|
||||
import { DialOutDialog } from '../../dial-out';
|
||||
import { isInviteOptionEnabled } from '../functions';
|
||||
|
||||
const DIAL_OUT_OPTION = 'dialout';
|
||||
const ADD_TO_CALL_OPTION = 'addtocall';
|
||||
|
||||
/**
|
||||
* The button that provides different invite options.
|
||||
@@ -27,20 +17,20 @@ class InviteButton extends Component {
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* Invoked to open {@code AddPeopleDialog}.
|
||||
*/
|
||||
dispatch: PropTypes.func,
|
||||
|
||||
/**
|
||||
* Indicates if the "Add to call" feature is available.
|
||||
*/
|
||||
_isAddToCallAvailable: PropTypes.bool,
|
||||
enableAddPeople: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Indicates if the "Dial out" feature is available.
|
||||
*/
|
||||
_isDialOutAvailable: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* The function opening the dialog.
|
||||
*/
|
||||
openDialog: PropTypes.func,
|
||||
enableDialOut: PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
@@ -57,26 +47,8 @@ class InviteButton extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._onInviteOptionSelected = this._onInviteOptionSelected.bind(this);
|
||||
this._updateInviteItems = this._updateInviteItems.bind(this);
|
||||
|
||||
this._updateInviteItems(this.props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentWillReceiveProps()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @param {Object} nextProps - The read-only props which this Component will
|
||||
* receive.
|
||||
* @returns {void}
|
||||
*/
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props._isDialOutAvailable !== nextProps._isDialOutAvailable
|
||||
|| this.props._isAddToCallAvailable
|
||||
!== nextProps._isAddToCallAvailable) {
|
||||
this._updateInviteItems(nextProps);
|
||||
}
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,144 +57,31 @@ class InviteButton extends Component {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
// HACK ALERT: Normally children should not be controlling their own
|
||||
// visibility; parents should control that. However, this component is
|
||||
// in a transitionary state while the Invite Dialog is being redone.
|
||||
// This hack will go away when the Invite Dialog is back.
|
||||
if (!this.state.buttonOption) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { VERTICAL_FILMSTRIP } = interfaceConfig;
|
||||
|
||||
return (
|
||||
<div className = 'filmstrip__invite'>
|
||||
<div className = 'invite-button-group'>
|
||||
<Button
|
||||
// eslint-disable-next-line react/jsx-handler-names
|
||||
onClick = { this.state.buttonOption.action }
|
||||
onClick = { this._onClick }
|
||||
shouldFitContainer = { true }>
|
||||
{ this.state.buttonOption.content }
|
||||
{ this.props.t('addPeople.invite') }
|
||||
</Button>
|
||||
{ this.state.inviteOptions[0].items.length
|
||||
? <DropdownMenu
|
||||
items = { this.state.inviteOptions }
|
||||
onItemActivated = { this._onInviteOptionSelected }
|
||||
position = { VERTICAL_FILMSTRIP
|
||||
? 'bottom right'
|
||||
: 'top right' }
|
||||
shouldFlip = { true }
|
||||
triggerType = 'button' />
|
||||
: null }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles selection of the invite options.
|
||||
* Opens {@code AddPeopleDialog}.
|
||||
*
|
||||
* @param { Object } option - The invite option that has been selected from
|
||||
* the dropdown menu.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onInviteOptionSelected(option) {
|
||||
this.state.inviteOptions[0].items.forEach(item => {
|
||||
if (item.content === option.item.content) {
|
||||
item.action();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the invite items list depending on the availability of the
|
||||
* features.
|
||||
*
|
||||
* @param {Object} props - The read-only properties of the component.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateInviteItems(props) {
|
||||
const { INVITE_OPTIONS = [] } = interfaceConfig;
|
||||
const validOptions = INVITE_OPTIONS.filter(option =>
|
||||
(option === DIAL_OUT_OPTION && props._isDialOutAvailable)
|
||||
|| (option === ADD_TO_CALL_OPTION && props._isAddToCallAvailable));
|
||||
|
||||
/* eslint-disable array-callback-return */
|
||||
|
||||
const inviteItems = validOptions.map(option => {
|
||||
switch (option) {
|
||||
case DIAL_OUT_OPTION:
|
||||
return {
|
||||
content: this.props.t('dialOut.dialOut'),
|
||||
action: () => this.props.openDialog(DialOutDialog)
|
||||
};
|
||||
case ADD_TO_CALL_OPTION:
|
||||
return {
|
||||
content: interfaceConfig.ADD_PEOPLE_APP_NAME,
|
||||
action: () => this.props.openDialog(AddPeopleDialog)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/* eslint-enable array-callback-return */
|
||||
|
||||
const buttonOption = inviteItems[0];
|
||||
const dropdownOptions = inviteItems.splice(1, inviteItems.length);
|
||||
|
||||
const nextState = {
|
||||
/**
|
||||
* The configuration for how the invite button should display and
|
||||
* behave on click.
|
||||
*/
|
||||
buttonOption,
|
||||
|
||||
/**
|
||||
* The list of invite options in the dropdown.
|
||||
*/
|
||||
inviteOptions: [
|
||||
{
|
||||
items: dropdownOptions
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (this.state) {
|
||||
this.setState(nextState);
|
||||
} else {
|
||||
// eslint-disable-next-line react/no-direct-mutation-state
|
||||
this.state = nextState;
|
||||
}
|
||||
_onClick() {
|
||||
this.props.dispatch(openDialog(AddPeopleDialog, {
|
||||
enableAddPeople: this.props.enableAddPeople,
|
||||
enableDialOut: this.props.enableDialOut
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated {@code InviteButton}'s
|
||||
* props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _isAddToCallAvailable: boolean,
|
||||
* _isDialOutAvailable: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
const { conference } = state['features/base/conference'];
|
||||
const { enableUserRolesBasedOnToken } = state['features/base/config'];
|
||||
const { isGuest } = state['features/base/jwt'];
|
||||
|
||||
return {
|
||||
_isAddToCallAvailable:
|
||||
!isGuest && isInviteOptionEnabled(ADD_TO_CALL_OPTION),
|
||||
_isDialOutAvailable:
|
||||
getLocalParticipant(state).role === PARTICIPANT_ROLE.MODERATOR
|
||||
&& conference && conference.isSIPCallingSupported()
|
||||
&& isInviteOptionEnabled(DIAL_OUT_OPTION)
|
||||
&& (!enableUserRolesBasedOnToken || !isGuest)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps, { openDialog })(
|
||||
InviteButton));
|
||||
export default translate(connect()(InviteButton));
|
||||
|
||||
@@ -7,15 +7,20 @@ import { i18next } from '../../../base/i18n';
|
||||
|
||||
import { DialInSummary } from '../dial-in-summary';
|
||||
|
||||
import NoRoomError from './NoRoomError';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const params = parseURLParams(window.location, true, 'search');
|
||||
const { room } = params;
|
||||
|
||||
ReactDOM.render(
|
||||
<I18nextProvider i18n = { i18next }>
|
||||
<DialInSummary
|
||||
className = 'dial-in-page'
|
||||
clickableNumbers = { false }
|
||||
room = { params.room } />
|
||||
{ room
|
||||
? <DialInSummary
|
||||
className = 'dial-in-page'
|
||||
clickableNumbers = { false }
|
||||
room = { params.room } />
|
||||
: <NoRoomError className = 'dial-in-page' /> }
|
||||
</I18nextProvider>,
|
||||
document.getElementById('react')
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
|
||||
/**
|
||||
* Displays an error message stating no room name was specified to fetch dial-in
|
||||
* numbers for.
|
||||
*
|
||||
* @extends Component
|
||||
*/
|
||||
class NoRoomError extends Component {
|
||||
/**
|
||||
* {@code NoRoomError} component's property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* Additional CSS classnames to append to the root of the component.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: PropTypes.func
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<div className = { this.props.className } >
|
||||
<div>{ t('info.noNumbers') }</div>
|
||||
<div>{ t('info.noRoom') }</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(NoRoomError);
|
||||
@@ -273,13 +273,7 @@ class InfoDialog extends Component {
|
||||
= encodeURIComponent(this.props._conferenceName);
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
|
||||
// More than two parts implies the path consists of more than the first
|
||||
// forward slash and the meeting name. If that is the case, drop the
|
||||
// last segment of the path, which we assume is the meeting name. This
|
||||
// is necessary when is hosted on a url with a path.
|
||||
if (pathParts.length > 2) {
|
||||
pathParts.length = pathParts.length - 1;
|
||||
}
|
||||
pathParts.length = pathParts.length - 1;
|
||||
|
||||
const newPath = pathParts.reduce((accumulator, currentValue) => {
|
||||
if (currentValue) {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function searchDirectory( // eslint-disable-line max-params
|
||||
jwt: string,
|
||||
text: string,
|
||||
queryTypes: Array<string> = [ 'conferenceRooms', 'user', 'room' ]
|
||||
): Promise<void> {
|
||||
): Promise<Array<Object>> {
|
||||
const queryTypesString = JSON.stringify(queryTypes);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -86,3 +86,31 @@ export function searchDirectory( // eslint-disable-line max-params
|
||||
.catch((jqxhr, textStatus, error) => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an ajax request to check if the phone number can be called.
|
||||
*
|
||||
* @param {string} dialNumber - The dial number to check for validity.
|
||||
* @param {string} dialOutAuthUrl - The endpoint to use for checking validity.
|
||||
* @returns {Promise} - The promise created by the request.
|
||||
*/
|
||||
export function checkDialNumber(
|
||||
dialNumber: string, dialOutAuthUrl: string): Promise<Object> {
|
||||
if (!dialOutAuthUrl) {
|
||||
// no auth url, let's say it is valid
|
||||
const response = {
|
||||
allow: true,
|
||||
phone: `+${dialNumber}`
|
||||
};
|
||||
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
|
||||
const fullUrl = `${dialOutAuthUrl}?phone=${dialNumber}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
$.getJSON(fullUrl)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import { Component } from 'react';
|
||||
|
||||
import { appNavigate } from '../../app';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link AbstractRecentList}
|
||||
*/
|
||||
type Props = {
|
||||
_defaultURL: string,
|
||||
|
||||
_recentList: Array<Object>,
|
||||
|
||||
/**
|
||||
* The redux store's {@code dispatch} function.
|
||||
*/
|
||||
dispatch: Dispatch<*>,
|
||||
|
||||
/**
|
||||
* Whether {@code AbstractRecentList} is enabled.
|
||||
*/
|
||||
enabled: boolean
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} which represents the list of conferences
|
||||
* recently joined, similar to how a list of last dialed numbers list would do
|
||||
* on a mobile device.
|
||||
*
|
||||
* @extends Component
|
||||
*/
|
||||
export default class AbstractRecentList extends Component<Props> {
|
||||
|
||||
/**
|
||||
* Joins the selected room.
|
||||
*
|
||||
* @param {string} room - The selected room.
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onJoin(room) {
|
||||
const { dispatch, enabled } = this.props;
|
||||
|
||||
enabled && room && dispatch(appNavigate(room));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bound onPress action for the list item.
|
||||
*
|
||||
* @param {string} room - The selected room.
|
||||
* @protected
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onSelect(room) {
|
||||
return this._onJoin.bind(this, room);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state into {@code AbstractRecentList}'s React
|
||||
* {@code Component} props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {{
|
||||
* _defaultURL: string,
|
||||
* _recentList: Array
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: Object) {
|
||||
return {
|
||||
_defaultURL: state['features/app'].app._getDefaultURL(),
|
||||
_recentList: state['features/recent-list']
|
||||
};
|
||||
}
|
||||
@@ -1,202 +1,240 @@
|
||||
import React from 'react';
|
||||
import { ListView, Text, TouchableHighlight, View } from 'react-native';
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Icon } from '../../base/font-icons';
|
||||
import { appNavigate } from '../../app';
|
||||
import {
|
||||
getLocalizedDateFormatter,
|
||||
getLocalizedDurationFormatter,
|
||||
translate
|
||||
} from '../../base/i18n';
|
||||
import { NavigateSectionList } from '../../base/react';
|
||||
import { parseURIString } from '../../base/util';
|
||||
|
||||
import AbstractRecentList, { _mapStateToProps } from './AbstractRecentList';
|
||||
import { getRecentRooms } from '../functions';
|
||||
import styles, { UNDERLAY_COLOR } from './styles';
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link RecentList}
|
||||
*/
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Renders the list disabled.
|
||||
*/
|
||||
disabled: boolean,
|
||||
|
||||
/**
|
||||
* The redux store's {@code dispatch} function.
|
||||
*/
|
||||
dispatch: Dispatch<*>,
|
||||
|
||||
/**
|
||||
* The translate function.
|
||||
*/
|
||||
t: Function,
|
||||
|
||||
/**
|
||||
* The default server URL.
|
||||
*/
|
||||
_defaultServerURL: string,
|
||||
|
||||
/**
|
||||
* The recent list from the Redux store.
|
||||
*/
|
||||
_recentList: Array<Object>
|
||||
};
|
||||
|
||||
/**
|
||||
* The native container rendering the list of the recently joined rooms.
|
||||
*
|
||||
* @extends AbstractRecentList
|
||||
*/
|
||||
class RecentList extends AbstractRecentList {
|
||||
/**
|
||||
* The datasource wrapper to be used for the display.
|
||||
*/
|
||||
dataSource = new ListView.DataSource({
|
||||
rowHasChanged: (r1, r2) =>
|
||||
r1.conference !== r2.conference
|
||||
&& r1.dateTimeStamp !== r2.dateTimeStamp
|
||||
});
|
||||
|
||||
class RecentList extends Component<Props> {
|
||||
/**
|
||||
* Initializes a new {@code RecentList} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props) {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._getAvatarStyle = this._getAvatarStyle.bind(this);
|
||||
this._onSelect = this._onSelect.bind(this);
|
||||
this._renderConfDuration = this._renderConfDuration.bind(this);
|
||||
this._renderRow = this._renderRow.bind(this);
|
||||
this._renderServerInfo = this._renderServerInfo.bind(this);
|
||||
this._onPress = this._onPress.bind(this);
|
||||
this._toDateString = this._toDateString.bind(this);
|
||||
this._toDurationString = this._toDurationString.bind(this);
|
||||
this._toDisplayableItem = this._toDisplayableItem.bind(this);
|
||||
this._toDisplayableList = this._toDisplayableList.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}. Renders a list of recently
|
||||
* joined rooms.
|
||||
* Implements the React Components's render method.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { enabled, _recentList } = this.props;
|
||||
|
||||
if (!_recentList) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const listViewDataSource
|
||||
= this.dataSource.cloneWithRows(getRecentRooms(_recentList));
|
||||
const { disabled } = this.props;
|
||||
|
||||
return (
|
||||
<View
|
||||
style = { [
|
||||
styles.container,
|
||||
enabled ? null : styles.containerDisabled
|
||||
] }>
|
||||
<ListView
|
||||
dataSource = { listViewDataSource }
|
||||
enableEmptySections = { true }
|
||||
renderRow = { this._renderRow } />
|
||||
</View>
|
||||
<NavigateSectionList
|
||||
disabled = { disabled }
|
||||
onPress = { this._onPress }
|
||||
sections = { this._toDisplayableList() } />
|
||||
);
|
||||
}
|
||||
|
||||
_onPress: string => Function
|
||||
|
||||
/**
|
||||
* Assembles the style array of the avatar based on if the conference was
|
||||
* hosted on the default Jitsi Meet deployment or on a non-default one
|
||||
* (based on current app setting).
|
||||
* Handles the list's navigate action.
|
||||
*
|
||||
* @private
|
||||
* @param {string} url - The url string to navigate to.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onPress(url) {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(appNavigate(url));
|
||||
}
|
||||
|
||||
_toDisplayableItem: Object => Object
|
||||
|
||||
/**
|
||||
* Creates a displayable list item of a recent list entry.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item - The recent list entry.
|
||||
* @returns {Object}
|
||||
*/
|
||||
_toDisplayableItem(item) {
|
||||
const { _defaultServerURL } = this.props;
|
||||
const location = parseURIString(item.conference);
|
||||
const baseURL = `${location.protocol}//${location.host}`;
|
||||
const serverName = baseURL === _defaultServerURL ? null : location.host;
|
||||
|
||||
return {
|
||||
colorBase: serverName,
|
||||
key: `key-${item.conference}-${item.date}`,
|
||||
lines: [
|
||||
this._toDateString(item.date),
|
||||
this._toDurationString(item.duration),
|
||||
serverName
|
||||
],
|
||||
title: location.room,
|
||||
url: item.conference
|
||||
};
|
||||
}
|
||||
|
||||
_toDisplayableList: () => Array<Object>
|
||||
|
||||
/**
|
||||
* Transforms the history list to a displayable list
|
||||
* with sections.
|
||||
*
|
||||
* @param {Object} recentListEntry - The recent list entry being rendered.
|
||||
* @private
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
_getAvatarStyle({ baseURL, serverName }) {
|
||||
const avatarStyles = [ styles.avatar ];
|
||||
|
||||
if (baseURL !== this.props._defaultURL) {
|
||||
avatarStyles.push(this._getColorForServerName(serverName));
|
||||
}
|
||||
|
||||
return avatarStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a style (color) based on the server name, so then the same server
|
||||
* will always be rendered with the same avatar color.
|
||||
*
|
||||
* @param {string} serverName - The recent list entry being rendered.
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getColorForServerName(serverName) {
|
||||
let nameHash = 0;
|
||||
|
||||
for (let i = 0; i < serverName.length; i++) {
|
||||
nameHash += serverName.codePointAt(i);
|
||||
}
|
||||
|
||||
return styles[`avatarRemoteServer${(nameHash % 5) + 1}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the conference duration if available.
|
||||
*
|
||||
* @param {Object} recentListEntry - The recent list entry being rendered.
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderConfDuration({ durationString }) {
|
||||
if (durationString) {
|
||||
return (
|
||||
<View style = { styles.infoWithIcon } >
|
||||
<Icon
|
||||
name = 'timer'
|
||||
style = { styles.inlineIcon } />
|
||||
<Text style = { styles.confLength }>
|
||||
{ durationString }
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list of recently joined rooms.
|
||||
*
|
||||
* @param {Object} data - The row data to be rendered.
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderRow(data) {
|
||||
return (
|
||||
<TouchableHighlight
|
||||
onPress = { this._onSelect(data.conference) }
|
||||
underlayColor = { UNDERLAY_COLOR } >
|
||||
<View style = { styles.row } >
|
||||
<View style = { styles.avatarContainer } >
|
||||
<View style = { this._getAvatarStyle(data) } >
|
||||
<Text style = { styles.avatarContent }>
|
||||
{ data.initials }
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style = { styles.detailsContainer } >
|
||||
<Text
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.roomName }>
|
||||
{ data.room }
|
||||
</Text>
|
||||
<View style = { styles.infoWithIcon } >
|
||||
<Icon
|
||||
name = 'event_note'
|
||||
style = { styles.inlineIcon } />
|
||||
<Text style = { styles.date }>
|
||||
{ data.dateString }
|
||||
</Text>
|
||||
</View>
|
||||
{ this._renderConfDuration(data) }
|
||||
{ this._renderServerInfo(data) }
|
||||
</View>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
_toDisplayableList() {
|
||||
const { _recentList, t } = this.props;
|
||||
const todaySection = NavigateSectionList.createSection(
|
||||
t('recentList.today'),
|
||||
'today'
|
||||
);
|
||||
const yesterdaySection = NavigateSectionList.createSection(
|
||||
t('recentList.yesterday'),
|
||||
'yesterday'
|
||||
);
|
||||
const earlierSection = NavigateSectionList.createSection(
|
||||
t('recentList.earlier'),
|
||||
'earlier'
|
||||
);
|
||||
const today = new Date().toDateString();
|
||||
const yesterdayDate = new Date();
|
||||
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
|
||||
const yesterday = yesterdayDate.toDateString();
|
||||
|
||||
for (const item of _recentList) {
|
||||
const itemDay = new Date(item.date).toDateString();
|
||||
const displayableItem = this._toDisplayableItem(item);
|
||||
|
||||
if (itemDay === today) {
|
||||
todaySection.data.push(displayableItem);
|
||||
} else if (itemDay === yesterday) {
|
||||
yesterdaySection.data.push(displayableItem);
|
||||
} else {
|
||||
earlierSection.data.push(displayableItem);
|
||||
}
|
||||
}
|
||||
|
||||
const displayableList = [];
|
||||
|
||||
if (todaySection.data.length) {
|
||||
todaySection.data.reverse();
|
||||
displayableList.push(todaySection);
|
||||
}
|
||||
if (yesterdaySection.data.length) {
|
||||
yesterdaySection.data.reverse();
|
||||
displayableList.push(yesterdaySection);
|
||||
}
|
||||
if (earlierSection.data.length) {
|
||||
earlierSection.data.reverse();
|
||||
displayableList.push(earlierSection);
|
||||
}
|
||||
|
||||
return displayableList;
|
||||
}
|
||||
|
||||
_toDateString: number => string
|
||||
|
||||
/**
|
||||
* Renders the server info component based on whether the entry was on a
|
||||
* different server.
|
||||
* Generates a date string for the item.
|
||||
*
|
||||
* @param {Object} recentListEntry - The recent list entry being rendered.
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
* @param {number} itemDate - The item's timestamp.
|
||||
* @returns {string}
|
||||
*/
|
||||
_renderServerInfo({ baseURL, serverName }) {
|
||||
if (baseURL !== this.props._defaultURL) {
|
||||
return (
|
||||
<View style = { styles.infoWithIcon } >
|
||||
<Icon
|
||||
name = 'public'
|
||||
style = { styles.inlineIcon } />
|
||||
<Text style = { styles.serverName }>
|
||||
{ serverName }
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
_toDateString(itemDate) {
|
||||
const date = new Date(itemDate);
|
||||
const m = getLocalizedDateFormatter(itemDate);
|
||||
|
||||
if (date.toDateString() === new Date().toDateString()) {
|
||||
// The date is today, we use fromNow format.
|
||||
return m.fromNow();
|
||||
}
|
||||
|
||||
return m.format('lll');
|
||||
}
|
||||
|
||||
_toDurationString: number => string
|
||||
|
||||
/**
|
||||
* Generates a duration string for the item.
|
||||
*
|
||||
* @private
|
||||
* @param {number} duration - The item's duration.
|
||||
* @returns {string}
|
||||
*/
|
||||
_toDurationString(duration) {
|
||||
if (duration) {
|
||||
return getLocalizedDurationFormatter(duration).humanize();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(_mapStateToProps)(RecentList);
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {{
|
||||
* _defaultServerURL: string,
|
||||
* _recentList: Array
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: Object) {
|
||||
return {
|
||||
_defaultServerURL: state['features/app'].app._getDefaultURL(),
|
||||
_recentList: state['features/recent-list']
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(RecentList));
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
// MomentJS uses static language bundle loading, so in order to support dynamic
|
||||
// language selection in the app we need to load all bundles that we support in
|
||||
// the app.
|
||||
// FIXME: If we decide to support MomentJS in other features as well we may need
|
||||
// to move this import and the lenient matcher to the i18n feature.
|
||||
require('moment/locale/bg');
|
||||
require('moment/locale/de');
|
||||
require('moment/locale/eo');
|
||||
require('moment/locale/es');
|
||||
require('moment/locale/fr');
|
||||
require('moment/locale/hy-am');
|
||||
require('moment/locale/it');
|
||||
require('moment/locale/nb');
|
||||
|
||||
// OC is not available. Please submit OC translation to the MomentJS project.
|
||||
require('moment/locale/pl');
|
||||
require('moment/locale/pt');
|
||||
require('moment/locale/pt-br');
|
||||
require('moment/locale/ru');
|
||||
require('moment/locale/sk');
|
||||
require('moment/locale/sl');
|
||||
require('moment/locale/sv');
|
||||
require('moment/locale/tr');
|
||||
require('moment/locale/zh-cn');
|
||||
|
||||
import { i18next } from '../base/i18n';
|
||||
import { parseURIString } from '../base/util';
|
||||
|
||||
/**
|
||||
* Retrieves the recent room list and generates all the data needed to be
|
||||
* displayed.
|
||||
*
|
||||
* @param {Array<Object>} list - The stored recent list retrieved from redux.
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getRecentRooms(list: Array<Object>): Array<Object> {
|
||||
const recentRoomDS = [];
|
||||
|
||||
if (list.length) {
|
||||
// We init the locale on every list render, so then it changes
|
||||
// immediately if a language change happens in the app.
|
||||
const locale = _getSupportedLocale();
|
||||
|
||||
for (const e of list) {
|
||||
const uri = parseURIString(e.conference);
|
||||
|
||||
if (uri && uri.room && uri.hostname) {
|
||||
const duration
|
||||
= e.duration || /* legacy */ e.conferenceDuration || 0;
|
||||
|
||||
recentRoomDS.push({
|
||||
baseURL: `${uri.protocol}//${uri.host}`,
|
||||
conference: e.conference,
|
||||
dateString: _getDateString(e.date, locale),
|
||||
dateTimeStamp: e.date,
|
||||
duration,
|
||||
durationString: _getDurationString(duration, locale),
|
||||
initials: _getInitials(uri.room),
|
||||
room: uri.room,
|
||||
serverName: uri.hostname
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return recentRoomDS.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a well formatted date string to be displayed in the list.
|
||||
*
|
||||
* @param {number} dateTimeStamp - The UTC timestamp to be converted to String.
|
||||
* @param {string} locale - The locale to init the formatter with. Note: This
|
||||
* locale must be supported by the formatter so ensure this prerequisite before
|
||||
* invoking the function.
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getDateString(dateTimeStamp: number, locale: string) {
|
||||
const date = new Date(dateTimeStamp);
|
||||
const m = _getLocalizedFormatter(date, locale);
|
||||
|
||||
if (date.toDateString() === new Date().toDateString()) {
|
||||
// The date is today, we use fromNow format.
|
||||
return m.fromNow();
|
||||
}
|
||||
|
||||
return m.format('lll');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a well formatted duration string to be displayed as the conference
|
||||
* length.
|
||||
*
|
||||
* @param {number} duration - The duration in MS.
|
||||
* @param {string} locale - The locale to init the formatter with. Note: This
|
||||
* locale must be supported by the formatter so ensure this prerequisite before
|
||||
* invoking the function.
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getDurationString(duration: number, locale: string) {
|
||||
return _getLocalizedFormatter(duration, locale).humanize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the initials supposed to be used based on the room name.
|
||||
*
|
||||
* @param {string} room - The room name.
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getInitials(room: string) {
|
||||
return room && room.charAt(0) ? room.charAt(0).toUpperCase() : '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a localized date formatter initialized with a specific {@code Date}
|
||||
* or duration ({@code number}).
|
||||
*
|
||||
* @private
|
||||
* @param {Date|number} dateOrDuration - The date or duration to format.
|
||||
* @param {string} locale - The locale to init the formatter with. Note: The
|
||||
* specified locale must be supported by the formatter so ensure the
|
||||
* prerequisite is met before invoking the function.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _getLocalizedFormatter(dateOrDuration: Date | number, locale: string) {
|
||||
const m
|
||||
= typeof dateOrDuration === 'number'
|
||||
? moment.duration(dateOrDuration)
|
||||
: moment(dateOrDuration);
|
||||
|
||||
return m.locale(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lenient locale matcher to match language and dialect if possible.
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getSupportedLocale() {
|
||||
const i18nLocale = i18next.language;
|
||||
let supportedLocale;
|
||||
|
||||
if (i18nLocale) {
|
||||
const localeRegexp = new RegExp('^([a-z]{2,2})(-)*([a-z]{2,2})*$');
|
||||
const localeResult = localeRegexp.exec(i18nLocale.toLowerCase());
|
||||
|
||||
if (localeResult) {
|
||||
const currentLocaleRegexp
|
||||
= new RegExp(
|
||||
`^${localeResult[1]}(-)*${`(${localeResult[3]})*` || ''}`);
|
||||
|
||||
supportedLocale
|
||||
= moment.locales().find(lang => currentLocaleRegexp.exec(lang));
|
||||
}
|
||||
}
|
||||
|
||||
return supportedLocale || 'en';
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { connect } from 'react-redux';
|
||||
|
||||
import { translate, translateToHTML } from '../../base/i18n';
|
||||
import { Platform } from '../../base/react';
|
||||
import { URI_PROTOCOL_PATTERN } from '../../base/util';
|
||||
import { DialInSummary } from '../../invite';
|
||||
import HideNotificationBarStyle from './HideNotificationBarStyle';
|
||||
|
||||
@@ -82,7 +83,11 @@ class UnsupportedMobileBrowser extends Component<*, *> {
|
||||
// appears to be a link with an app-specific scheme, not a Universal
|
||||
// Link.
|
||||
const appScheme = interfaceConfig.MOBILE_APP_SCHEME || 'org.jitsi.meet';
|
||||
const joinURL = `${appScheme}:${window.location.href}`;
|
||||
|
||||
// Replace the protocol part with the app scheme.
|
||||
const joinURL
|
||||
= window.location.href.replace(
|
||||
new RegExp(`^${URI_PROTOCOL_PATTERN}`), `${appScheme}:`);
|
||||
|
||||
this.setState({
|
||||
joinURL
|
||||
@@ -96,7 +101,7 @@ class UnsupportedMobileBrowser extends Component<*, *> {
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { _room, t } = this.props;
|
||||
|
||||
const openAppButtonClassName
|
||||
= `${_SNS}__button ${_SNS}__button_primary`;
|
||||
@@ -128,10 +133,12 @@ class UnsupportedMobileBrowser extends Component<*, *> {
|
||||
{ t(`${_TNS}.downloadApp`) }
|
||||
</button>
|
||||
</a>
|
||||
<DialInSummary
|
||||
className = 'unsupported-dial-in'
|
||||
clickableNumbers = { true }
|
||||
room = { this.props._room } />
|
||||
{ _room
|
||||
? <DialInSummary
|
||||
className = 'unsupported-dial-in'
|
||||
clickableNumbers = { true }
|
||||
room = { _room } />
|
||||
: null }
|
||||
</div>
|
||||
<HideNotificationBarStyle />
|
||||
</div>
|
||||
|
||||
48
react/features/welcome/components/AbstractPagedList.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// @flow
|
||||
|
||||
import { Component } from 'react';
|
||||
|
||||
/**
|
||||
* The page to be displayed on render.
|
||||
*/
|
||||
export const DEFAULT_PAGE = 0;
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Indicates if the list is disabled or not.
|
||||
*/
|
||||
disabled: boolean,
|
||||
|
||||
/**
|
||||
* The i18n translate function
|
||||
*/
|
||||
t: Function
|
||||
}
|
||||
|
||||
type State = {
|
||||
|
||||
/**
|
||||
* The currently selected page.
|
||||
*/
|
||||
pageIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class for the platform specific paged lists.
|
||||
*/
|
||||
export default class AbstractPagedList extends Component<Props, State> {
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
pageIndex: DEFAULT_PAGE
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
177
react/features/welcome/components/PagedList.android.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Text, TouchableOpacity, View, ViewPagerAndroid } from 'react-native';
|
||||
|
||||
import { Icon } from '../../base/font-icons';
|
||||
import { MeetingList } from '../../calendar-sync';
|
||||
import { RecentList } from '../../recent-list';
|
||||
|
||||
import AbstractPagedList, { DEFAULT_PAGE } from './AbstractPagedList';
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* A platform specific component to render a paged or tabbed list/view.
|
||||
*
|
||||
* @extends PagedList
|
||||
*/
|
||||
export default class PagedList extends AbstractPagedList {
|
||||
/**
|
||||
* A reference to the viewpager.
|
||||
*/
|
||||
_viewPager: Object;
|
||||
|
||||
/**
|
||||
* Constructor of the PagedList Component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._getIndicatorStyle = this._getIndicatorStyle.bind(this);
|
||||
this._onPageSelected = this._onPageSelected.bind(this);
|
||||
this._onSelectPage = this._onSelectPage.bind(this);
|
||||
this._setPagerReference = this._setPagerReference.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the paged list.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { disabled } = this.props;
|
||||
const { pageIndex } = this.state;
|
||||
|
||||
return (
|
||||
<View
|
||||
style = { [
|
||||
styles.pagedListContainer,
|
||||
disabled ? styles.pagedListContainerDisabled : null
|
||||
] }>
|
||||
<ViewPagerAndroid
|
||||
initialPage = { DEFAULT_PAGE }
|
||||
keyboardDismissMode = 'on-drag'
|
||||
onPageSelected = { this._onPageSelected }
|
||||
peekEnabled = { true }
|
||||
ref = { this._setPagerReference }
|
||||
style = { styles.pagedList }>
|
||||
<View key = { 0 }>
|
||||
<RecentList disabled = { disabled } />
|
||||
</View>
|
||||
<View key = { 1 }>
|
||||
<MeetingList
|
||||
disabled = { disabled }
|
||||
displayed = { pageIndex === 1 } />
|
||||
</View>
|
||||
</ViewPagerAndroid>
|
||||
<View style = { styles.pageIndicatorContainer }>
|
||||
<TouchableOpacity
|
||||
onPress = { this._onSelectPage(0) }
|
||||
style = { styles.pageIndicator } >
|
||||
<View style = { styles.pageIndicator }>
|
||||
<Icon
|
||||
name = 'restore'
|
||||
style = { [
|
||||
styles.pageIndicatorIcon,
|
||||
this._getIndicatorStyle(0)
|
||||
] } />
|
||||
<Text
|
||||
style = { [
|
||||
styles.pageIndicatorText,
|
||||
this._getIndicatorStyle(0)
|
||||
] }>
|
||||
History
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress = { this._onSelectPage(1) }
|
||||
style = { styles.pageIndicator } >
|
||||
<View style = { styles.pageIndicator }>
|
||||
<Icon
|
||||
name = 'event_note'
|
||||
style = { [
|
||||
styles.pageIndicatorIcon,
|
||||
this._getIndicatorStyle(1)
|
||||
] } />
|
||||
<Text
|
||||
style = { [
|
||||
styles.pageIndicatorText,
|
||||
this._getIndicatorStyle(1)
|
||||
] }>
|
||||
Calendar
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
_getIndicatorStyle: number => Object;
|
||||
|
||||
/**
|
||||
* Constructs the style of an indicator.
|
||||
*
|
||||
* @private
|
||||
* @param {number} indicatorIndex - The index of the indicator.
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getIndicatorStyle(indicatorIndex) {
|
||||
if (this.state.pageIndex === indicatorIndex) {
|
||||
return styles.pageIndicatorTextActive;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_onPageSelected: Object => void;
|
||||
|
||||
/**
|
||||
* Updates the index of the currently selected page.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} event - The native event of the callback.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onPageSelected({ nativeEvent: { position } }) {
|
||||
if (this.state.pageIndex !== position) {
|
||||
this.setState({
|
||||
pageIndex: position
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onSelectPage: number => Function
|
||||
|
||||
/**
|
||||
* Constructs a function to be used as a callback for the tab bar.
|
||||
*
|
||||
* @private
|
||||
* @param {number} pageIndex - The index of the page to activate via the
|
||||
* callback.
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onSelectPage(pageIndex) {
|
||||
return () => {
|
||||
this._viewPager.setPage(pageIndex);
|
||||
this.setState({
|
||||
pageIndex
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
_setPagerReference: Object => void
|
||||
|
||||
/**
|
||||
* Sets the pager's reference for direct modification.
|
||||
*
|
||||
* @private
|
||||
* @param {React@Node} component - The pager component.
|
||||
* @returns {void}
|
||||
*/
|
||||
_setPagerReference(component) {
|
||||
this._viewPager = component;
|
||||
}
|
||||
}
|
||||