Compare commits

...

33 Commits

Author SHA1 Message Date
bgrozev
6cc272fbd1 log: Fixes a log message 2018-03-19 14:41:18 -05:00
Saumeya Katyal
2334eb9967 doc: Add dev server steps (#2610)
* doc: Add webpack-dev-server steps
2018-03-19 10:16:01 -05:00
Emil Ivov
04bd4a9038 Merge pull request #2617 from virtuacoplenny/lenny/info-dialog-again
fix(info): update copy text to find correct var
2018-03-15 12:16:20 -05:00
virtuacoplenny
eb8f34cee8 Merge pull request #2612 from jitsi/no_protocol_in_intent_uri
feat(UnsupportedMobileBrowser): do not include protocol in the intent
2018-03-15 10:01:31 -07:00
Leonard Kim
b9379f5996 fix(info): update copy text to find correct var 2018-03-15 09:42:39 -07:00
paweldomas
40d7d0c9cb feat(UnsupportedMobileBrowser): do not include protocol in the intent
Do not include the protocol part in the intent URL.
2018-03-14 17:44:32 -05:00
zbettenbuk
357f173e85 Remove obsolate PlatformElements.native.js 2018-03-13 18:04:17 -05:00
zbettenbuk
7da26042b3 Avoid asking for calendar permission on app start 2018-03-13 18:04:17 -05:00
zbettenbuk
c86c7beb24 Refactor i18n calendar formatter 2018-03-13 18:04:17 -05:00
zbettenbuk
1020a54a33 Add Android navigation bar 2018-03-13 18:04:17 -05:00
zbettenbuk
c84abd543e Add support for app link scheme 2018-03-13 18:04:16 -05:00
zbettenbuk
4b17c6f015 Add pull-to-refresh functionality 2018-03-13 18:04:16 -05:00
zbettenbuk
cb973b61aa Implement adaptive known domain list 2018-03-13 18:04:16 -05:00
zbettenbuk
b096622995 Unify recent and meeting lists 2018-03-13 18:04:16 -05:00
zbettenbuk
ae0bf876a8 Add conference notification 2018-03-13 18:04:16 -05:00
zbettenbuk
bba480f329 Add calendar-sync feature 2018-03-13 18:04:14 -05:00
paweldomas
4dbcaf851f flow(AbstractAudio): specific function types 2018-03-13 16:57:29 -05:00
paweldomas
04dff9059b ref(AudioOutputPreview): use Audio from base/media 2018-03-13 16:57:28 -05:00
paweldomas
26cd2f17f6 ref(chat): port incoming chat msg sound to react 2018-03-13 16:57:28 -05:00
paweldomas
60e03e3dec feat: add join/leave sounds on mobile
Adds base/sounds feature which allows other features to register a sound
source under specified id. A new SoundsCollection component will then
render corresponding HTMLAudioElement for each such sound. Once "setRef"
callback is called by the HTMLAudioElement, this element will be added
to the Redux store. When that happens sound can be played through the
new 'playSound' action which will call play() method on the stored
HTMLAudioElement instance.
2018-03-13 16:57:28 -05:00
virtuacoplenny
bfb45ed0e8 fix(large-video): do not try to show background on safari with webrtc (#2606)
The animation for toggling filmstrip visibility was lagging on
Safari. Even though the background video is set to hidden, it is
still causing issues. Setting the background to display none
instead does help but might interfere with animations. So instead
do the easy thing and re-use logic used for Firefox to not show
the background video.
2018-03-13 14:37:35 -07:00
virtuacoplenny
e325199075 fix(invite): prefix a + when faking the validation response (#2597)
Pre-existing logic made it so numbers were assumed as valid
if no validation url was specified. To be consistent with
the validation server, the faked number should include a
+ at the beginning.
2018-03-12 13:25:42 -07:00
virtuacoplenny
4e4713c3e2 feat(invite): be able to call numbers from the invite dialog (#2555)
* feat(invite): be able to call numbers from the invite dialog

The major changes:
- Remove DialOutDialog, its views, redux hooks, css, and images.
  Its main functionality has been moved into AddPeopleDialog.
- Modify the AppPeopleDialog styling a bit so it is wider.
- Add phone numbers to AddPeopleDialog search results. Phone
  numbers are validated in parallel with the request for people
  and then appended to the result. The validation includes
  an ajax to validate the number is recognized as dialable by
  the server. The trigger for the validation is essentially if
  the entered input is numbers only.
- AddPeopleDialog holds onto the full object representation of
  an item selected in MultiSelectAutocomplete. This is so
  selected items can be removed on successful invite, leaving
  only unsuccessful items.
- More granular error handling on invite so individual invitees
  can be removed from the selected items list.

* squash: change load state, new regex for numbers

* squash: change strings, auto prepend 1 if no country code, add reminders
2018-03-12 12:23:40 -07:00
Saúl Ibarra Corretgé
ff8386e931 debian: fix setting the auth domain certificates
In 94813bc0fd (diff-6e9552c9bd8e61c8f277c21220160234)
two local variables got removed (AUTH_KEY_FILE and AUTH_CRT_FILE), which are used by the sed command
below to configure the virtualhost for auth.
2018-03-11 16:05:14 -05:00
Leonard Kim
8f520086e5 fix(info): do not show dial in numbers without a room specified
For the static page an error message displays stating no room
was specified. On mobile for unsupported browsers, the dial in
info will not show.
2018-03-09 17:18:10 -06:00
Shuai Li
5cde674eff fix(android): webrtc progurd rule
The new libwebrtc.jar contains an extra unused class file, when proguard is enabled result in the following warning:

org.chromium.build.BuildHooksAndroidImpl: can't find superclass or interface org.chromium.build.BuildHooksAndroid
2018-03-09 12:29:49 -08:00
Lyubo Marinov
c018252eee [Android] Fix RuntimeException in RNImmersiveModule
java.lang.RuntimeException: Tried to access a JS module before the React instance was fully set up. Calls to ReactContext#getJSModule should only happen once initialize() has been called on your native module.
	at com.facebook.react.bridge.ReactContext.getJSModule(ReactContext.java:102)
	at com.rnimmersive.RNImmersiveModule.emitImmersiveStateChangeEvent(RNImmersiveModule.java:74)
	at org.jitsi.meet.sdk.JitsiMeetView.onWindowFocusChanged(JitsiMeetView.java:504)
	at android.view.View.dispatchWindowFocusChanged(View.java:10257)
	at android.view.ViewGroup.dispatchWindowFocusChanged(ViewGroup.java:1193)
	at android.view.ViewGroup.dispatchWindowFocusChanged(ViewGroup.java:1197)
	at android.view.ViewGroup.dispatchWindowFocusChanged(ViewGroup.java:1197)
	at android.view.ViewGroup.dispatchWindowFocusChanged(ViewGroup.java:1197)
	at android.view.ViewGroup.dispatchWindowFocusChanged(ViewGroup.java:1197)
	at android.view.ViewGroup.dispatchWindowFocusChanged(ViewGroup.java:1197)
	at android.view.ViewGroup.dispatchWindowFocusChanged(ViewGroup.java:1197)
	at android.view.ViewRootImpl$ViewRootHandler.handleMessage(ViewRootImpl.java:3602)
	at android.os.Handler.dispatchMessage(Handler.java:102)
	at android.os.Looper.loop(Looper.java:154)
	at android.app.ActivityThread.main(ActivityThread.java:6119)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776)
2018-03-09 13:50:07 -06:00
George Politis
c8cab1560c Merge pull request #2589 from jitsi/update-lib-jitsi-stats
Updates lib-jitsi-meet to d4b78721.
2018-03-09 12:44:53 -06:00
damencho
d218abfd97 Updates lib-jitsi-meet to d4b78721.
Implements the promised based getStats. Enables them for Safari and FF.
2018-03-09 12:03:01 -06:00
paweldomas
9e0fee6c7d fix(android): do not require java 8 target
Updates react-native-webrtc to get rid of Java 8 requirement for
the Android app.
2018-03-08 15:47:05 -06:00
damencho
5dca9e08f4 Bumps uglifyjs-webpack-plugin and its dependencies.
Solves a GitHub warning in lib-jitsi-meet about a vulnerability in a
uglifyjs-webpack-plugin dependency.
2018-03-08 15:12:15 -06:00
Lyubo Marinov
d3a1f7d4f7 [iOS] Fix uncaught NSInvalidArgumentException in RTCPeerConnection's createAnswer
WebRTC used to report createAnswer (and createOffer) NSError with key
"error". But now the key's called "NSLocalizedDescription".

Additionally, NSMutableDictionary doesn't accept nil.
2018-03-07 15:23:20 -06:00
Leonard Kim
80bdf908ca fix(info): always remove last part of path for meeting name 2018-03-06 15:22:27 -06:00
103 changed files with 4106 additions and 2093 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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')

View File

@@ -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,

View File

@@ -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%);
}
}

View File

@@ -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);
}

View File

@@ -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";
}

View File

@@ -28,11 +28,8 @@
@import 'font-awesome';
/* Fonts END */
@import 'flag-icon';
/* Modules BEGIN */
@import 'dial-out';
@import 'aui_reset';
@import 'base';
@import 'utils';

View File

@@ -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);
}
}
}
/**

View File

@@ -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%;

View File

@@ -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

Binary file not shown.

View File

@@ -12,6 +12,7 @@
<glyph unicode="&#xe1aa;" 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="&#xe310;" 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="&#xe408;" glyph-name="navigate_before" d="M658 708l-196-196 196-196-60-60-256 256 256 256z" />
<glyph unicode="&#xe409;" glyph-name="navigate_next" d="M426 768l256-256-256-256-60 60 196 196-196 196z" />
<glyph unicode="&#xe425;" 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="&#xe5c4;" glyph-name="arrow_back" d="M854 554v-84h-520l238-240-60-60-342 342 342 342 60-60-238-240h520z" />
<glyph unicode="&#xe5d2;" glyph-name="menu" d="M128 768h768v-86h-768v86zM128 470v84h768v-84h-768zM128 256v86h768v-86h-768z" />
@@ -22,6 +23,7 @@
<glyph unicode="&#xe616;" 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="&#xe61d;" 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="&#xe80b;" 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="&#xe8b3;" 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="&#xe901;" 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="&#xe902;" 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="&#xe903;" 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

Binary file not shown.

Binary file not shown.

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
images/calendar@3x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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|

View File

@@ -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

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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);
};

View File

@@ -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();
}
}

View File

@@ -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.
*/

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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}

View File

@@ -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';

View File

@@ -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,

View 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';
}

View File

@@ -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

View File

@@ -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' />
);
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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()}.

View File

@@ -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;
}
}
}

View File

@@ -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.
*

View File

@@ -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));
}

View 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');

View 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';

View File

@@ -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>
);
}
}

View File

@@ -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';

View File

@@ -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
});

View File

@@ -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
});

View 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');

View 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
};
}

View 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);

View File

@@ -0,0 +1 @@
export { default as SoundCollection } from './SoundCollection';

View File

@@ -0,0 +1,6 @@
export * from './actions';
export * from './actionTypes';
export * from './components';
import './middleware';
import './reducer';

View 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}`);
}
}

View 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;
}

View File

@@ -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) {

View 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');

View 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
};
}

View File

@@ -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));

View 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));

View File

@@ -0,0 +1,2 @@
export { default as MeetingList } from './MeetingList';
export { default as ConferenceNotification } from './ConferenceNotification';

View 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'
}
});

View File

@@ -1,4 +1,4 @@
export * from './actions';
export * from './components';
import './middleware';
import './reducer';

View 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));
}

View 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
};
}

View 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';

View File

@@ -0,0 +1,3 @@
export * from './constants';
import './middleware';

View 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));
});
}

View 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';

View File

@@ -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.
*/

View File

@@ -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>
);

View File

@@ -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');

View File

@@ -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
}));
};
}

View File

@@ -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 } />;
}
}

View File

@@ -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));

View File

@@ -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));

View File

@@ -1 +0,0 @@
export { default as DialOutDialog } from './DialOutDialog';

View File

@@ -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;
});

View File

@@ -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)
};
}

View File

@@ -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,

View File

@@ -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));

View File

@@ -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')
);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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);
});
}

View File

@@ -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']
};
}

View File

@@ -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));

View File

@@ -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';
}

View File

@@ -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>

View 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
};
}
}

View 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;
}
}

Some files were not shown because too many files have changed in this diff Show More