Compare commits

...

29 Commits

Author SHA1 Message Date
paweldomas
c2191e3a28 callkit with base/session 2018-08-07 12:20:38 -05:00
paweldomas
72e3e8593d feat(base/session): store 'room' in the session
Stores name of the conference room in the session when it's being
created.
2018-08-07 12:20:38 -05:00
paweldomas
67a8b4915d feat(base/session): add SESSION_CONFIGURED event
The SESSION_CONFIGURED event is fired once the config has been set,
after either being loaded or restored from the storage.
2018-08-07 12:20:38 -05:00
paweldomas
468d4a7150 ref(mobile/external-api): use base/session 2018-08-07 12:20:38 -05:00
paweldomas
2a01e29fec feat: add features/base/session 2018-08-07 12:20:38 -05:00
paweldomas
90a64d30dc ref(base/config): keep 'locationURL' after SET_CONFIG
This is required for the session feature to be able to tell what's
the latest URL the app is working with.
2018-08-07 12:20:38 -05:00
paweldomas
31905d4f63 debug actions 2018-08-07 12:20:38 -05:00
Nik
7e1d97665a fix: only access nested json values when corrent payload type (#3352) 2018-08-07 09:03:31 -07:00
Zoltan Bettenbuk
b978851a0f [RN] Fix streaming on mobile (#3351) 2018-08-06 17:30:32 -07:00
Nik
ef49817eaf fix: add a timer which automatically clears subtitles (#3349) 2018-08-06 14:30:50 -07:00
virtuacoplenny
cac8888b37 feat(welcome-page): be able to open settings dialog (#3327)
* feat(welcome-page): be able to open settings dialog

- Create a getter for getting a settings tab's props so the device
  selection tab can get updated available devices.
- Be able to call a function from a tab after it has mounted. This is
  used for device selection to essentially call enumerateDevices on
  the welcome page so the device selectors are populated.
- Remove event UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED. Instead directly call
  setAudioOutputDeviceId where possible.
- Fix initialization of the audioOutputDeviceId in settings by defaulting
  the audio output device to the one set in settings.

* squash: updateAvailableDevices -> getAvailableDevices, add comment for propsUpdateFunction
2018-08-06 10:24:59 -05:00
Praveen Gupta
81853d971a [WEB] Show final translated speech to text results as subtitles (#3276)
* Shows final translated speech to text results as subtitles

* Use conference from redux state and removes addTranscriptMessage
2018-08-06 11:24:37 +02:00
Lyubo Marinov
b9c5ed3b03 Fixes typo, comment 2018-08-05 17:18:14 -05:00
Lyubo Marinov
0892e0b644 Remove duplication 2018-08-05 17:04:19 -05:00
Bettenbuk Zoltan
b41bf22be7 Replace console with logger 2018-08-05 17:04:19 -05:00
Saúl Ibarra Corretgé
a1cc9bce91 [RN] Drop no longer needed polyfills
They were required only on Android because of its old JSC version. With the JSC
version bump they are no longer required.
2018-08-05 17:04:19 -05:00
Saúl Ibarra Corretgé
8d3cecad86 [Android] Update JSC version
The JSC version used by React Native is about 3 years old, and doesn't implement
things like Symbol or Typed Arrays, which require polyfills. These polyfills are
sometimes a los less performant, as is the case for Typed Arrays.

Bumping an updated JSC version makes both platforms consistent when it comes to
the JavaScript platform.
2018-08-05 17:04:16 -05:00
hristoterezov
bd8559fad6 fix(invite): IFrame api when invalid invitees are passed. 2018-08-03 12:42:38 -05:00
hristoterezov
fb75180632 ref(RecentList): Improvements after review. 2018-08-03 11:25:03 -05:00
Ritwik Heda
046b06e436 added recent list 2018-08-03 11:25:03 -05:00
Дамян Минков
af7c69a1aa Moves google-api in its own feature. (#3339)
* Moves google-api in its own feature.

* Stores the profile email in redux.
2018-08-02 14:56:36 -07:00
Saúl Ibarra Corretgé
7ad0639f7a [RN] Fix setting audio mode for audio-only calls
When a call is tarted in audio only mode due to the switch on the welcome page,
the wrong audio mode was chosen.
2018-08-01 22:12:08 +02:00
paweldomas
54a1853e60 fix(ios/Podfile.lock): bump SDWebImage/Core version 2018-07-31 14:07:17 -05:00
Saúl Ibarra Corretgé
27021ea271 [RN] Replace cached image implementation
Use react-native-fastimage, which uses 2 full-native image impleentations using
well known and mature (native) libraries.

This gets us rid of 2 libraries which were observerd as a source of bugs and
created trouble with dependencies: react-native-fetch-blob and
react-native-img-cache. They are also no longer well maintained.
2018-07-31 14:07:17 -05:00
Saúl Ibarra Corretgé
f5a667ad9e feat(Avatar): simplified code 2018-07-31 14:07:17 -05:00
paweldomas
2b9ce40533 feat(travis): bump image version 2018-07-31 12:54:01 -05:00
paweldomas
d3dd833f21 fix(ios/travis-ci) try pod update
With the fastimage lib Travis complains about:

CocoaPods could not find compatible versions for pod "SDWebImage/Core"
2018-07-31 12:54:01 -05:00
Neil Brown
1cc372868b Update quick-install.md
Tweaks for clarity.
2018-07-31 11:35:18 +02:00
bgrozev
a6956c7c34 Commit from translate.jitsi.org by user bgrozev.: 447 of 447 strings translated (0 fuzzy). 2018-07-30 14:27:03 -05:00
111 changed files with 3204 additions and 1776 deletions

View File

@@ -1,4 +1,4 @@
osx_image: xcode9.3
osx_image: xcode9.4
language: objective-c
script:
- "./ios/travis-ci/build-ipa.sh"

View File

@@ -68,3 +68,9 @@
-dontwarn java.nio.file.*
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn okio.**
# FastImage
-keep public class com.dylanvann.fastimage.* {*;}
-keep public class com.dylanvann.fastimage.** {*;}

View File

@@ -18,6 +18,7 @@ allprojects {
repositories {
google()
jcenter()
maven { url "$rootDir/../node_modules/jsc-android/dist" }
// React Native (JS, Obj-C sources, Android binaries) is installed from
// npm.
maven { url "$rootDir/../node_modules/react-native/android" }
@@ -34,6 +35,12 @@ allprojects {
def version = new groovy.json.JsonSlurper().parseText(file.text).version
details.useVersion version
}
if (details.requested.group == 'org.webkit'
&& details.requested.name == 'android-jsc') {
def file = new File("$rootDir/../node_modules/jsc-android/package.json")
def version = new groovy.json.JsonSlurper().parseText(file.text).version
details.useVersion "r${version.tokenize('.')[0]}"
}
}
}
}
@@ -146,7 +153,7 @@ allprojects {
ext {
buildToolsVersion = "26.0.2"
compileSdkVersion = 26
minSdkVersion = 16
minSdkVersion = 21
targetSdkVersion = 26
// The Maven artifact groupdId of the third-party react-native modules which

View File

@@ -25,7 +25,7 @@ dependencies {
compile 'com.facebook.react:react-native:+'
compile project(':react-native-background-timer')
compile project(':react-native-fetch-blob')
compile project(':react-native-fast-image')
compile project(':react-native-immersive')
compile project(':react-native-keep-awake')
compile project(':react-native-linear-gradient')

View File

@@ -122,12 +122,12 @@ class ReactInstanceManagerHolder {
.addPackage(new com.BV.LinearGradient.LinearGradientPackage())
.addPackage(new com.calendarevents.CalendarEventsPackage())
.addPackage(new com.corbt.keepawake.KCKeepAwakePackage())
.addPackage(new com.dylanvann.fastimage.FastImageViewPackage())
.addPackage(new com.facebook.react.shell.MainReactPackage())
.addPackage(new com.i18n.reactnativei18n.ReactNativeI18n())
.addPackage(new com.oblador.vectoricons.VectorIconsPackage())
.addPackage(new com.ocetnik.timer.BackgroundTimerPackage())
.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() {

View File

@@ -3,8 +3,8 @@ rootProject.name = 'jitsi-meet'
include ':app', ':sdk'
include ':react-native-background-timer'
project(':react-native-background-timer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-background-timer/android')
include ':react-native-fetch-blob'
project(':react-native-fetch-blob').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fetch-blob/android')
include ':react-native-fast-image'
project(':react-native-fast-image').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fast-image/android')
include ':react-native-immersive'
project(':react-native-immersive').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-immersive/android')
include ':react-native-keep-awake'

View File

@@ -35,6 +35,7 @@ import {
conferenceJoined,
conferenceLeft,
conferenceWillJoin,
conferenceWillLeave,
dataChannelOpened,
EMAIL_COMMAND,
lockStateChanged,
@@ -44,6 +45,7 @@ import {
setDesktopSharingEnabled
} from './react/features/base/conference';
import {
getAvailableDevices,
setAudioOutputDeviceId,
updateDeviceList
} from './react/features/base/devices';
@@ -372,6 +374,8 @@ class ConferenceConnector {
case JitsiConferenceErrors.FOCUS_LEFT:
case JitsiConferenceErrors.VIDEOBRIDGE_NOT_AVAILABLE:
APP.store.dispatch(conferenceWillLeave(room));
// FIXME the conference should be stopped by the library and not by
// the app. Both the errors above are unrecoverable from the library
// perspective.
@@ -468,6 +472,7 @@ function _connectionFailedHandler(error) {
JitsiConnectionEvents.CONNECTION_FAILED,
_connectionFailedHandler);
if (room) {
APP.store.dispatch(conferenceWillLeave(room));
room.leave();
}
}
@@ -2125,20 +2130,6 @@ export default {
}
);
APP.UI.addListener(
UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED,
audioOutputDeviceId => {
sendAnalytics(createDeviceChangedEvent('audio', 'output'));
setAudioOutputDeviceId(audioOutputDeviceId, APP.store.dispatch)
.then(() => logger.log('changed audio output device'))
.catch(err => {
logger.warn('Failed to change audio output device. '
+ 'Default or previously set audio output device '
+ 'will be used instead.', err);
});
}
);
APP.UI.addListener(UIEvents.TOGGLE_AUDIO_ONLY, audioOnly => {
// FIXME On web video track is stored both in redux and in
@@ -2310,39 +2301,43 @@ export default {
/**
* Inits list of current devices and event listener for device change.
* @private
* @returns {Promise}
*/
_initDeviceList() {
const { mediaDevices } = JitsiMeetJS;
if (mediaDevices.isDeviceListAvailable()
&& mediaDevices.isDeviceChangeAvailable()) {
mediaDevices.enumerateDevices(devices => {
// Ugly way to synchronize real device IDs with local storage
// and settings menu. This is a workaround until
// getConstraints() method will be implemented in browsers.
const { dispatch } = APP.store;
if (this.localAudio) {
dispatch(updateSettings({
micDeviceId: this.localAudio.getDeviceId()
}));
}
if (this.localVideo) {
dispatch(updateSettings({
cameraDeviceId: this.localVideo.getDeviceId()
}));
}
APP.store.dispatch(updateDeviceList(devices));
APP.UI.onAvailableDevicesChanged(devices);
});
this.deviceChangeListener = devices =>
window.setTimeout(() => this._onDeviceListChanged(devices), 0);
mediaDevices.addEventListener(
JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED,
this.deviceChangeListener);
const { dispatch } = APP.store;
return dispatch(getAvailableDevices())
.then(devices => {
// Ugly way to synchronize real device IDs with local
// storage and settings menu. This is a workaround until
// getConstraints() method will be implemented in browsers.
if (this.localAudio) {
dispatch(updateSettings({
micDeviceId: this.localAudio.getDeviceId()
}));
}
if (this.localVideo) {
dispatch(updateSettings({
cameraDeviceId: this.localVideo.getDeviceId()
}));
}
APP.UI.onAvailableDevicesChanged(devices);
});
}
return Promise.resolve();
},
/**
@@ -2493,13 +2488,24 @@ export default {
// before all operations are done.
Promise.all([
requestFeedbackPromise,
room.leave().then(disconnect, disconnect)
this.leaveRoomAndDisconnect()
]).then(values => {
APP.API.notifyReadyToClose();
maybeRedirectToWelcomePage(values[0]);
});
},
/**
* Leaves the room and calls JitsiConnection.disconnect.
*
* @returns {Promise}
*/
leaveRoomAndDisconnect() {
APP.store.dispatch(conferenceWillLeave(room));
return room.leave().then(disconnect, disconnect);
},
/**
* Changes the email for the local user
* @param email {string} the new email

View File

@@ -0,0 +1,43 @@
%navigate-section-list-text {
width: 100%;
font-size: 14px;
line-height: 20px;
color: $welcomePageTitleColor;
text-align: left;
font-family: 'open_sanslight', Helvetica, sans-serif;
}
%navigate-section-list-tile-text {
@extend %navigate-section-list-text;
overflow: hidden;
text-overflow: ellipsis;
float: left;
}
.navigate-section-list-tile {
height: 90px;
width: 260px;
border-radius: 4px;
background-color: #1754A9;
margin-right: 8px;
padding: 16px;
display: inline-block;
box-sizing: border-box;
cursor: pointer;
}
.navigate-section-tile-body {
@extend %navigate-section-list-tile-text;
font-weight: normal;
}
.navigate-section-tile-title {
@extend %navigate-section-list-tile-text;
font-weight: bold;
}
.navigate-section-section-header {
@extend %navigate-section-list-text;
font-weight: bold;
margin-bottom: 16px;
}
.navigate-section-list {
position: relative;
margin-top: 36px;
margin-bottom: 36px;
}

View File

@@ -78,4 +78,5 @@
@import 'modals/invite/add-people';
@import 'deep-linking/main';
@import 'transcription-subtitles';
@import 'navigate_section_list';
/* Modules END */

View File

@@ -4,7 +4,11 @@ This document describes the required steps for a quick Jitsi Meet installation o
Debian Wheezy and other older systems may require additional things to be done. Specifically for Wheezy, [libc needs to be updated](http://lists.jitsi.org/pipermail/users/2015-September/010064.html).
N.B.: All commands are supposed to be run by root. If you are logged in as a regular user with sudo rights, please prepend ___sudo___ to each of the commands.
N.B.:
a.) All commands are supposed to be run by root. If you are logged in as a regular user with sudo rights, please prepend ___sudo___ to each of the commands.
b.) You only need to do this if you want to ___host your own Jitsi server___. If you just want to have a video conference with someone, use https://meet.jit.si instead.
## Basic Jitsi Meet install
@@ -59,6 +63,8 @@ org.ice4j.ice.harvest.NAT_HARVESTER_PUBLIC_ADDRESS=<Public.IP.Address>
See [the documenation of ice4j](https://github.com/jitsi/ice4j/blob/master/doc/configuration.md)
for details.
By default, anyone who has access to your jitsi instance will be able to start a conferencee: if your server is open to the world, anyone can have a chat with anyone else. If you want to limit the ability to start a conference to registered users, set up a "secure domain". Follow the instructions at https://github.com/jitsi/jicofo#secure-domain.
### Open a conference
Launch a web browser (Chrome, Chromium or latest Opera) and enter in the URL bar the hostname (or IP address) you used in the previous step.

View File

@@ -1,26 +1,2 @@
// The type field of react-native application loader's React Element is created
// as number and not Symbol, because it's not been defined by the polyfill yet.
// We import the application renderer, before Symbol is defined, in order to use
// number types as well. Otherwise this will result in the invariant exception,
// because fiber thingy will not recognise root react-native component as React
// Element, but as an Object.
//
// See node_modules/react-native/Libraries/polyfills/babelHelpers.js
// :babelHelpers.createRawReactElement - that's where first react-native element
// is created (super early - it's the app loader).
//
// See node_modules/react-native/Libraries/Renderer/ReactNativeFiber-dev.js
// and look for REACT_ELEMENT_TYPE definition - it's defined later when Symbol
// has been defined and type will not match.
//
// As an alternative solution we could stop using/polyfilling Symbols and
// replace with classpath string constants or some kind of a wrapper around
// that.
import 'react-native/Libraries/ReactNative/renderApplication';
// Android doesn't provide Symbol
import 'es6-symbol/implement';
import './react/index.native';

View File

@@ -163,7 +163,14 @@ var interfaceConfig = {
*
* @type {boolean}
*/
VIDEO_QUALITY_LABEL_DISABLED: false
VIDEO_QUALITY_LABEL_DISABLED: false,
/**
* If true, will display recent list
*
* @type {boolean}
*/
RECENT_LIST_ENABLED: true
/**
* Specify custom URL for downloading android mobile app.

View File

@@ -28,8 +28,8 @@ target 'JitsiMeet' do
pod 'react-native-background-timer',
:path => '../node_modules/react-native-background-timer'
pod 'react-native-fetch-blob',
:path => '../node_modules/react-native-fetch-blob'
pod 'react-native-fast-image',
:path => '../node_modules/react-native-fast-image'
pod 'react-native-keep-awake',
:path => '../node_modules/react-native-keep-awake'
pod 'react-native-locale-detector',

View File

@@ -1,6 +1,7 @@
PODS:
- boost-for-react-native (1.63.0)
- DoubleConversion (1.1.5)
- FLAnimatedImage (1.0.12)
- Folly (2016.09.26.00):
- boost-for-react-native
- DoubleConversion
@@ -12,8 +13,11 @@ PODS:
- React
- react-native-calendar-events (1.6.0):
- React
- react-native-fetch-blob (0.10.6):
- React/Core
- react-native-fast-image (4.0.14):
- FLAnimatedImage
- React
- SDWebImage/Core
- SDWebImage/GIF
- react-native-keep-awake (2.0.6):
- React
- react-native-locale-detector (1.0.0):
@@ -68,6 +72,10 @@ PODS:
- React/Core
- RNVectorIcons (4.4.2):
- React
- SDWebImage/Core (4.4.2)
- SDWebImage/GIF (4.4.2):
- FLAnimatedImage (~> 1.0)
- SDWebImage/Core
- yoga (0.55.4.React)
DEPENDENCIES:
@@ -76,7 +84,7 @@ DEPENDENCIES:
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- 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-fast-image (from `../node_modules/react-native-fast-image`)
- react-native-keep-awake (from `../node_modules/react-native-keep-awake`)
- react-native-locale-detector (from `../node_modules/react-native-locale-detector`)
- react-native-webrtc (from `../node_modules/react-native-webrtc`)
@@ -98,6 +106,8 @@ DEPENDENCIES:
SPEC REPOS:
https://github.com/cocoapods/specs.git:
- boost-for-react-native
- FLAnimatedImage
- SDWebImage
EXTERNAL SOURCES:
DoubleConversion:
@@ -112,8 +122,8 @@ EXTERNAL SOURCES:
: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-fast-image:
:path: "../node_modules/react-native-fast-image"
react-native-keep-awake:
:path: "../node_modules/react-native-keep-awake"
react-native-locale-detector:
@@ -132,20 +142,22 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost-for-react-native: 39c7adb57c4e60d6c5479dd8623128eb5b3f0f2c
DoubleConversion: e22e0762848812a87afd67ffda3998d9ef29170c
FLAnimatedImage: 4a0b56255d9b05f18b6dd7ee06871be5d3b89e31
Folly: 211775e49d8da0ca658aebc8eab89d642935755c
glog: 1de0bb937dccdc981596d3b5825ebfb765017ded
React: aa2040dbb6f317b95314968021bd2888816e03d5
react-native-background-timer: 63dcbf37dbcf294b5c6c071afcdc661fa06a7594
react-native-calendar-events: fe6fbc8ed337a7423c98f2c9012b25f20444de09
react-native-fetch-blob: 63394b1d7b0781547b3e4463b3195790177b1222
react-native-fast-image: cba3d9bf9c2cf8ddb643d887a686c53a5dd90a2c
react-native-keep-awake: 0de4bd66de0c23178107dce0c2fcc3354b2a8e94
react-native-locale-detector: d1b2c6fe5abb56e3a1efb6c2d6f308c05c4251f1
react-native-webrtc: 31b6d3f1e3e2ce373aa43fd682b04367250f807d
ReactNativePermissions: 9f2d9c45c98800795e6c2ed330e25d11a66a8169
RNSound: b360b3862d3118ed1c74bb9825696b5957686ac4
RNVectorIcons: c0dbfbf6068fefa240c37b0f71bd03b45dddac44
SDWebImage: 624d6e296c69b244bcede364c72ae0430ac14681
yoga: a23273df0088bf7f2bb7e5d7b00044ea57a2a54a
PODFILE CHECKSUM: e24d0131e937934fbe4d1f0b7ad5947ee0192f58
PODFILE CHECKSUM: 1d5c8382f73d9540fac68d93b32e1d3b58d069ee
COCOAPODS: 1.5.3

View File

@@ -130,6 +130,7 @@ cp "${CERT_DIR}/dev-profile.mobileprovision" ~/Library/MobileDevice/Provisionin
npm install
cd ios
pod update
pod install
cd ..

View File

@@ -1,24 +1,19 @@
{
"contactlist": "",
"contactlist_plural": "",
"passwordSetRemotely": "Configurado por outro membro",
"connectionsettings": "Configurações de conexão",
"contactlist_plural": "__count__ Membros",
"passwordSetRemotely": "configurado por outro membro",
"poweredby": "distribuído por",
"feedback": {
"average": "Média",
"bad": "Ruim",
"good": "Boa",
"rateExperience": "Por favor, avalie sua experiência na reunião.",
"veryBad": "Muito ruim",
"veryGood": "Muito boa"
},
"inviteUrlDefaultMsg": "Sua conferência está sendo criada...",
"me": "eu",
"speaker": "Orador",
"raisedHand": "Gostaria de falar",
"defaultNickname": "ex. João Pedro",
"defaultLink": "ex.: __url__",
"callingName": "__name__",
"audioDevices": {
"bluetooth": "Bluetooth",
"headphones": "Fones de ouvido",
"phone": "Celular",
"speaker": "Orador"
},
"audioOnly": {
"audioOnly": "Somente áudio",
"featureToggleDisabled": "A alternância de __feature__ é desativada enquanto estiver no modo somente de áudio"
@@ -27,6 +22,7 @@
"react-nativeGrantPermissions": "Selecione <b><i>Permitir</i></b> quando seu navegador perguntar pelas permissões.",
"chromeGrantPermissions": "Selecione <b><i>Permitir</i></b> quando seu navegador perguntar pelas permissões.",
"androidGrantPermissions": "Selecione <b><i>Permitir</i></b> quando seu navegador perguntar pelas permissões.",
"electronGrantPermissions": "Dê as permissões para usar sua câmera e microfone",
"firefoxGrantPermissions": "Selecione <b><i>Compartilhar Dispositivos Selecionados</i></b> quando seu navegador perguntar pelas permissões.",
"operaGrantPermissions": "Selecione <b><i>Permitir</i></b> quando seu navegador perguntar pelas permissões.",
"iexplorerGrantPermissions": "Selecione <b><i>OK</i></b> quando seu navegador perguntar pelas permissões.",
@@ -50,46 +46,20 @@
"showSpeakerStats": "Exibir estatísticas do alto falante"
},
"welcomepage": {
"disable": "Não exibir esta página novamente",
"feature1": {
"content": "Não precisa baixar nada. __app__ funciona diretamente no seu navegador. Basta compartilhar a URL da sua conferência com outros para começar.",
"title": "Simples de usar"
},
"feature2": {
"content": "Conferências de vídeo de multipartes funcionam a partir de 128 kbps. Compartilhamento de tela e conferências apenas com áudio são possíveis com muito menos.",
"title": "Largura de banda baixa"
},
"feature3": {
"content": "__app__ é licenciado sob a Licença Apache. Você é livre para baixar, usar, modificar e compartilhar ela com a mesma licença.",
"title": "Código aberto"
},
"feature4": {
"content": "Não há restrições artificiais sobre o número de usuários ou membros da conferência. O poder do servidor e a largura de banda são os únicos fatores limitantes.",
"title": "Usuários ilimitados"
},
"feature5": {
"content": "É fácil compartilhar sua tela com outros. __app__ é ideal para apresentações online, leituras, e sessões de suporte técnico.",
"title": "Compartilhamento de tela"
},
"feature6": {
"content": "Precisa alguma privacidade? Salas de conferência do __app__ podem ser seguras com uma senha para excluir visitantes indesejados e prevenir interrupções.",
"title": "Salas seguras"
},
"feature7": {
"content": "__app_ disponibiliza o Etherpad, um editor de texto colaborativo em tempo real, que é ótimo para reuniões rápidas, escrevendo artigos, e mais.",
"title": "Notas compartilhadas"
},
"feature8": {
"content": "Aprenda sobre seus usuários através de integração fácil com o Piwik, Google Analytics, e outros sistemas de monitoramento e estatísticas.",
"title": "Estatísticas de uso"
"appDescription": "Vá em frente, converse por vídeo com toda a equipe. De fato, convide todos que você conhece. __app__ é uma solução de videoconferência totalmente criptografada e 100% de código aberto que você pode usar todos os dias, a cada dia, gratuitamente — sem necessidade de conta.",
"audioVideoSwitch": {
"audio": "Voz",
"video": "Vídeo"
},
"calendar": "Calendário",
"go": "IR",
"join": "Entrar",
"privacy": "Política de Privacidade",
"roomname": "Digite o nome da sala",
"roomnamePlaceHolder": "Nome da sala",
"roomnameHint": "Digite o nome ou a URL da sala que você deseja entrar. Você pode digitar um nome, e apenas deixe para as pessoas que você quer se reunir digitem o mesmo nome.",
"sendFeedback": "Enviar comentários",
"terms": "Termos"
"terms": "Termos",
"title": "Vídeo conferência mais segura, mais flexível e completamente livre "
},
"startupoverlay": {
"policyText": " ",
@@ -103,22 +73,28 @@
"toolbar": {
"addPeople": "Adicionar pessoas à sua chamada",
"audioonly": "Ativar / desativar modo somente áudio (economiza banda)",
"callQuality": "Gerenciar qualidade da chamada",
"enterFullScreen": "Ver em tela cheia",
"exitFullScreen": "Sair da tela cheia",
"feedback": "Deixar feedback",
"moreActions": "Mais ações",
"mute": "Mudo / Não mudo",
"videomute": "Iniciar ou parar a câmera",
"authenticate": "Autenticar",
"lock": "Travar ou destravar a sala",
"invite": "Compartilhar o link",
"chat": "Abrir ou fechar o bate-papo",
"etherpad": "Abrir ou fechar o documento compartilhado",
"documentOpen": "Abrir documento compartilhado",
"documentClose": "Fechar documento compartilhado",
"sharedvideo": "Compartilhar um vídeo do YouTube",
"sharescreen": "Iniciar ou parar o compartilhamento de tela",
"sharescreen": "Compartilhamento de tela",
"stopSharedVideo": "Parar vídeo do YouTube",
"fullscreen": "Entrar ou sair da tela cheia",
"sip": "Chamar número SIP",
"Settings": "Configurações",
"hangup": "Sair",
"login": "Iniciar sessão",
"logout": "Encerrar sessão",
"dialpad": "Abrir ou fechar teclado de discagem",
"sharedVideoMutedPopup": "Seu vídeo compartilhado foi silenciado para que você possa conversar com os outros membros.",
"micMutedPopup": "Seu microfone foi silenciado para que você aproveite plenamente seu vídeo compartilhado.",
"talkWhileMutedPopup": "Tentando falar? Você está em mudo.",
@@ -127,19 +103,9 @@
"micDisabled": "O microfone não está disponível",
"filmstrip": "Mostrar / ocultar vídeos",
"profile": "Editar seu perfil",
"raiseHand": "Erguer o baixar sua mão"
},
"unsupportedBrowser": {
"appInstalled": "ou se você já tenha isso<br /> <strong>então</strong>",
"appNotInstalled": "Você precisa do <strong>__app__</strong> para começar uma conversa no seu celular",
"downloadApp": "Baixe o Aplicativo",
"joinConversation": "Entrar na conversa",
"startConference": "Comece uma conferência"
},
"bottomtoolbar": {
"chat": "Abrir / fechar bate-papo",
"filmstrip": "Mostrar / ocultar vídeos",
"contactlist": "Veja e convide membros"
"raiseHand": "Erguer o baixar sua mão",
"shortcuts": "Ver atalhos",
"speakerStats": "Estatísticas do Apresentador"
},
"chat": {
"nickname": {
@@ -174,7 +140,7 @@
"moderator": "Moderador",
"videomute": "O membro parou a câmera",
"mute": "O membro está em silêncio",
"kick": "Chutar fora",
"kick": "Expulsar",
"muted": "Mudo",
"domute": "Mudo",
"flip": "Inverter",
@@ -199,7 +165,7 @@
"remoteaddress_plural": "Endereços remotos:",
"transport": "Transporte:",
"bandwidth": "Largura de banda estimada:",
"na": "Volte aqui para informações de conexão uma vez que a conferência inicie",
"na": "Volte aqui para informações de conexão após iniciar a conferência",
"turn": " (virar)",
"quality": {
"good": "Boa",
@@ -213,7 +179,9 @@
"notify": {
"disconnected": "desconectado",
"moderator": "Direitos de moderador concedidos!",
"connected": "conectado",
"connectedOneMember": "__name__ conectado",
"connectedTwoMembers": "__first__ e __second__ conectados",
"connectedThreePlusMembers": "__name__ e __count__ outros conectados",
"somebody": "Alguém",
"me": "Eu",
"focus": "Foco da conferência",
@@ -222,42 +190,42 @@
"grantedToUnknown": "Direitos de moderador concedido para $t(notify.somebody)!",
"muted": "Você iniciou uma conversa em mudo.",
"mutedTitle": "Você está mudo!",
"raisedHand": "Gostaria de falar."
"raisedHand": "Gostaria de falar.",
"suboptimalExperienceTitle": "Alerta do navegador",
"suboptimalExperienceDescription": "Eer ... temos medo de que sua experiência com o __appName__ não seja tão boa aqui. Estamos procurando maneiras de melhorar isso, mas até lá tente usar um dos <a href='static/recommendedBrowsers.html' target='_blank'> navegadores totalmente compatíveis</a>."
},
"dialog": {
"add": "Adicionar",
"allow": "Permitir",
"kickMessage": "Ouch! Você o chutou para fora da reunião!",
"popupErrorTitle": "",
"popupError": "",
"kickMessage": "Ouch! Você foi expulso da reunião!",
"popupErrorTitle": "Popup bloqueado",
"popupError": "Seu navegador está bloqueando janelas popup deste site. Habilite os popups nas configurações de segurança no seu navegador e tente novamente.",
"passwordErrorTitle": "Erro na senha",
"passwordError": "Esta conversa está protegida atualmente por uma senha. Somente o dono da conferência pode definir a senha.",
"passwordError2": "Esta conversa não está protegida por senha atualmente. Somente o dono da conferência pode definir a senha.",
"connectError": "Oops! Alguma coisa está errada e nós não pudemos conectar à conferência.",
"connectErrorWithMsg": "Oops! Alguma coisa está errada e não podemos conectar à conferência: __msg__",
"incorrectPassword": "",
"incorrectPassword": "Usuário ou senha incorretos",
"connecting": "Conectando",
"copy": "Copiar",
"contactSupport": "",
"contactSupport": "Contate o suporte",
"error": "Erro",
"createPassword": "Criar uma senha",
"detectext": "Erro enquanto tenta detectar a extensão de compartilhamento de tela.",
"detectext": "Erro ao detectar a extensão de compartilhamento de tela.",
"failedpermissions": "Falha ao obter permissões para usar o microfone e/ou câmera local.",
"conferenceReloadTitle": "Infelizmente, algo deu errado.",
"conferenceReloadMsg": "Estamos tentando consertar isto. Reconectando em __seconds__ segundos...",
"conferenceDisconnectTitle": "Você foi desconectado.",
"conferenceDisconnectMsg": "Você pode querer verificar sua conexão de rede. Reconectando em __seconds__ segundos ...",
"dismiss": "",
"rejoinNow": "Voltar agora",
"maxUsersLimitReachedTitle": "",
"maxUsersLimitReached": "",
"dismiss": "Dispensar",
"rejoinNow": "Reconectar agora",
"maxUsersLimitReachedTitle": "Limite máximo de membros alcançado",
"maxUsersLimitReached": "O limite para o máximo do número de membros foi alcançado. A conferência está cheia. Contate o dono da reunião ou tente de novo depois!",
"lockTitle": "Bloqueio falhou",
"lockMessage": "Falha ao travar a conferência.",
"warning": "Atenção",
"passwordNotSupportedTitle": "",
"passwordNotSupported": "",
"passwordNotSupportedTitle": "Senha não suportada",
"passwordNotSupported": "Configuração de senha para a reunião não é suportada.",
"internalErrorTitle": "Erro interno",
"internalError": "",
"internalError": "Oops! Alguma coisa está errada. O seguinte erro ocorreu: __error__",
"unableToSwitch": "Impossível trocar o fluxo de vídeo.",
"SLDFailure": "Oops! Alguma coisa está errada e nós falhamos em silenciar! (Falha do SLD)",
"SRDFailure": "Oops! Alguma coisa está errada e nós falhamos em parar o vídeo! (Falha do SRD)",
@@ -274,8 +242,8 @@
"shareVideoLinkError": "Por favor, forneça um link do youtube correto.",
"removeSharedVideoTitle": "Remover vídeo compartilhado",
"removeSharedVideoMsg": "Deseja remover seu vídeo compartilhado?",
"alreadySharedVideoMsg": "",
"alreadySharedVideoTitle": "",
"alreadySharedVideoMsg": "Outro membro já está compartilhando um vídeo. Esta conferência permite apenas um vídeo compartilhado por vez.",
"alreadySharedVideoTitle": "Somente um vídeo compartilhado é permitido por vez",
"WaitingForHost": "Esperando o hospedeiro...",
"WaitForHostMsg": "A conferência <b>__room__</b> não foi iniciada. Se você é o hospedeiro, então autentique-se. Caso contrário, aguarde o hospedeiro chegar.",
"IamHost": "Eu sou o hospedeiro",
@@ -284,20 +252,16 @@
"retry": "Tentar novamente",
"logoutTitle": "Encerrar sessão",
"logoutQuestion": "Deseja encerrar a sessão e finalizar a conferência?",
"sessTerminated": "",
"sessTerminated": "Chamada terminada",
"hungUp": "Você desconectou",
"joinAgain": "Entrar novamente",
"Share": "Compartilhar",
"Save": "Salvar",
"recording": "Gravando",
"recordingToken": "Digite o token de gravação",
"passwordCheck": "Deseja remover a senha?",
"passwordMsg": "Definir uma senha para trancar sua sala",
"shareLink": "Compartilhar o link para a chamada",
"yourPassword": "Digite a nova senha",
"Back": "Voltar",
"serviceUnavailable": "Serviço indisponível",
"gracefulShutdown": "Nosso serviço está desligado para manutenção. Por favor, tente mais tarde.",
"gracefulShutdown": "O sistema está em manutenção. Por favor tente novamente mais tarde.",
"Yes": "Sim",
"reservationError": "Erro de sistema de reserva",
"reservationErrorMsg": "Código do erro: __code__, mensagem: __msg__",
@@ -306,42 +270,40 @@
"token": "token",
"tokenAuthFailedTitle": "Falha de autenticação",
"tokenAuthFailed": "Desculpe, você não está autorizado a entrar nesta chamada.",
"displayNameRequired": "Mostrar o nome é requerido",
"displayNameRequired": "Nome de exibição requerido",
"enterDisplayName": "Digite seu nome de exibição",
"extensionRequired": "Extensão requerida:",
"firefoxExtensionPrompt": "Você precisa instalar uma extensão do Firefox para compartilhar a tela. Tente novamente depois que você <a href='__url__'>pegá-lo aqui</a>!",
"feedbackHelp": "Seu retorno nos ajudará a melhorar nossa experiência de vídeo.",
"feedbackQuestion": "Nos conte sobre sua chamada!",
"thankYou": "Obrigado por usar o __appName__!",
"sorryFeedback": "Lamentamos escutar isso. Gostaria de nos contar mais?",
"sorryFeedback": "Sentimos muito pelos transtornos. Poderia nos das mais detalhes?",
"liveStreaming": "Transmissão ao Vivo",
"streamKey": "Nome/chave do fluxo",
"startLiveStreaming": "Iniciar transmissão ao vivo",
"streamKey": "Chave para transmissão ao vivo",
"startLiveStreaming": "Ir ao vivo agora",
"startRecording": "Parar gravação",
"stopStreamingWarning": "Tem certeza que deseja parar a transmissão ao vivo?",
"stopRecordingWarning": "Tem certeza que deseja parar a gravação?",
"stopLiveStreaming": "Parar a transmissão ao vivo",
"stopRecording": "Parar a gravação",
"doNotShowWarningAgain": "Não exibir este aviso novamente",
"doNotShowMessageAgain": "Não mostre esta mensagem novamente",
"permissionDenied": "Permissão Negada",
"screenSharingFailedToInstall": "",
"screenSharingFailedToInstallTitle": "",
"screenSharingPermissionDeniedError": "",
"micErrorPresent": "Ocorreu um erro conectando seu microfone.",
"cameraErrorPresent": "Ocorreu um erro conectando sua câmera.",
"screenSharingFailedToInstall": "Oops! Falhou a instalação da extensão de compartilhamento de tela.",
"screenSharingFailedToInstallTitle": "A extensão de compartilhamento de tela falhou ao instalar",
"screenSharingFirefoxPermissionDeniedError": "Algo deu errado enquanto estávamos tentando compartilhar sua tela. Por favor, certifique-se de que você nos deu permissão para fazê-lo. ",
"screenSharingFirefoxPermissionDeniedTitle": "Opa! Não foi possível iniciar o compartilhamento de tela.",
"screenSharingPermissionDeniedError": "Oops! Alguma coisa está errada com suas permissões de compartilhamento de tela. Recarregue e tente de novo.",
"cameraUnsupportedResolutionError": "Sua câmera não suporta a resolução de vídeo requerida.",
"cameraUnknownError": "Não pode usar a câmera por uma razão desconhecida.",
"cameraPermissionDeniedError": "Você não tem permissão para usar sua câmera. Você ainda pode entrar na conferência, mas os outros não verão você. Use o botão da câmera na barra de endereço para fixar isto.",
"cameraPermissionDeniedError": "Não foi permitido acessar a sua câmera. Você ainda pode entrar na conferência, mas sem exibir o seu vídeo. Clique no botão da câmera para tentar reparar.",
"cameraNotFoundError": "A câmera não foi encontrada.",
"cameraConstraintFailedError": "Sua câmera não satisfaz algumas condições necessárias.",
"micUnknownError": "Não pode usar o microfone por uma razão desconhecida.",
"micPermissionDeniedError": "Você não tem permissão para usar seu microfone. Você ainda pode entrar na conferência, mas os outros não ouvirão você. Use o botão da câmera na barra de endereço para fixar isto.",
"micPermissionDeniedError": "Não foi permitido acessar o seu microfone. Você ainda pode entrar na conferência, mas sem enviar áudio. Clique no botão do microfone para tentar reparar.",
"micNotFoundError": "O microfone não foi encontrado.",
"micConstraintFailedError": "Seu microfone não satisfaz algumas condições necessárias.",
"micNotSendingDataTitle": "",
"micNotSendingData": "",
"cameraNotSendingDataTitle": "",
"cameraNotSendingData": "",
"micNotSendingDataTitle": "Incapaz de acessar o microfone",
"micNotSendingData": "Estamos incapazes de acessar seu microfone. Selecione outro dispositivo do menu de configurações ou tente recarregar a aplicação.",
"cameraNotSendingDataTitle": "Incapaz de acessar a câmera",
"cameraNotSendingData": "Estamos incapazes de acessar sua câmera. Verifique se outra aplicação está usando este dispositivo, selecione outro dispositivo do menu de configurações ou recarregue a aplicação.",
"goToStore": "Vá para a loja virtual",
"externalInstallationTitle": "Extensão requerida",
"externalInstallationMsg": "Você precisa instalar nossa extensão de compartilhamento de tela.",
@@ -351,7 +313,7 @@
"muteParticipantBody": "Você não está habilitado para tirar o mudo deles, mas eles podem tirar o mudo deles mesmos a qualquer tempo.",
"muteParticipantButton": "Mudo",
"remoteControlTitle": "Conexão de área de trabalho remota",
"remoteControlRequestMessage": "Permitirá __user__ controlar remotamente sua área de trabalho?",
"remoteControlRequestMessage": "Deseja permitir que __user__ controle remotamente sua área de trabalho?",
"remoteControlShareScreenWarning": "Note que se você pressionar \"Permitir\" você vai compartilhar sua tela!",
"remoteControlDeniedMessage": "__user__ rejeitou sua requisição de controle remoto!",
"remoteControlAllowedMessage": "__user__ aceitou sua requisição de controle remoto!",
@@ -404,30 +366,50 @@
"ATTACHED": "Anexado"
},
"recording": {
"busy": "",
"busyTitle": "",
"busy": "Estamos trabalhando para liberar recursos de gravação. Tente novamente em alguns minutos.",
"busyTitle": "Todas as gravações estão atualmente ocupadas",
"buttonTooltip": "Iniciar / parar gravação",
"error": "A gravação falhou. Tente novamente.",
"failedToStart": "Falha ao iniciar a gravação",
"off": "Gravação parada",
"on": "Gravando",
"pending": "Aguardando um participante para iniciar a gravação...",
"unavailable": "",
"unavailableTitle": ""
"serviceName": "Serviço de gravação",
"unavailable": "Oops! O __serviceName__ está indisponível. Estamos trabalhando para resolver o problema. Por favor, tente mais tarde.",
"unavailableTitle": "Gravação indisponível"
},
"liveStreaming": {
"busy": "",
"busyTitle": "",
"buttonTooltip": "",
"error": "",
"failedToStart": "",
"off": "",
"busy": "Estamos trabalhando para liberar os recursos de transmissão. Tente novamente em alguns minutos.",
"busyTitle": "Todas as transmissões estão atualmente ocupadas",
"buttonTooltip": "Iniciar / Parar transmissão ao vivo",
"changeSignIn": "Alternar contas.",
"choose": "Escolha uma transmissão ao vivo",
"chooseCTA": "Escolha uma opção de transmissão. Você está conectado atualmente como __email__.",
"enterStreamKey": "Insira sua chave de transmissão ao vivo do YouTube aqui.",
"error": "Falha na transmissão ao vivo. Tente de novo.",
"errorAPI": "Ocorreu um erro ao acessar suas transmissões do YouTube. Por favor tente logar novamente.",
"failedToStart": "Falha ao iniciar a transmissão ao vivo",
"off": "Transmissão ao vivo encerrada",
"on": "Transmissão ao Vivo",
"pending": "Iniciando Transmissão ao Vivo...",
"streamIdRequired": "",
"streamIdHelp": "Aonde eu encontro isto?",
"unavailable": "",
"unavailableTitle": ""
"serviceName": "Serviço de Transmissão ao Vivo",
"signIn": "Faça login no Google",
"signInCTA": "Faça login ou insira sua chave de transmissão ao vivo do YouTube.",
"start": "Iniciar uma transmissão ao vivo",
"streamIdHelp": "O que é isso?",
"unavailableTitle": "Transmissão ao vivo indisponível"
},
"videoSIPGW": {
"busy": "Estamos trabalhando para liberar recursos. Por favor, tente novamente em alguns minutos.",
"busyTitle": "O serviço da sala está ocupado",
"errorInvite": "A conferência ainda não foi estabelecida. Por favor, tente mais tarde.",
"errorInviteTitle": "Erro no convite da sala",
"errorAlreadyInvited": "__displayName__ já convidado",
"errorInviteFailedTitle": "Convite para __displayName__ falhou",
"errorInviteFailed": "Estamos trabalhando para resolver o problema. Por favor, tente mais tarde.",
"pending": "__displayName__ foi convidado",
"serviceName": "Serviço da sala",
"unavailableTitle": "Serviço da sala indisponível"
},
"speakerStats": {
"hours": "__count__h",
@@ -444,46 +426,47 @@
"selectADevice": "Selecione um dispositivo",
"testAudio": "Testar o som"
},
"invite": {
"addPassword": "Adicionar uma senha",
"callNumber": "Ligar para __number__",
"enterID": "Digite o ID da Conferência: __conferenceID__ seguido de # em um telefone para participar",
"howToDialIn": "Para participar, use um dos números a seguir e o ID da conferência",
"hidePassword": "Esconder a senha",
"inviteTo": "Convidar pessoas para __conferenceName__",
"invitedYouTo": "__userName__ o convidou para a conferência __inviteURL__",
"invitePeople": "",
"locked": "Esta chamada está travada. Novos participantes precisam ter o link e digitar a senha para entrar.",
"showPassword": "Mostrar senha",
"unlocked": "Esta chamada está destravada. Qualquer novo participante com o link pode participar."
},
"videoStatus": {
"callQuality": "Qualidade da Chamada",
"hd": "HD",
"hdTooltip": "Ver vídeo em alta definição",
"highDefinition": "Alta definição (HD)",
"labelTooltipVideo": "Qualidade do vídeo atual",
"labelTooltipAudioOnly": "Modo somente de áudio habilitado",
"labelTooiltipNoVideo": "Sem vídeo",
"labelTooltipVideo": "Qualidade do vídeo atual",
"ld": "LD",
"ldTooltip": "Ver vídeo em baixa definição",
"lowDefinition": "Baixa definição (LD)",
"onlyAudioAvailable": "Somente áudio disponível",
"onlyAudioSupported": "Suportamos somente áudio neste navegador.",
"p2pEnabled": "Ponto-a-ponto habilitada",
"p2pVideoQualityDescription": "Em modo ponto-a-ponto, qualidade de chamadas recebidas podem somente ser modificada entre alta definição e áudio somente. Outras configurações não serão honradas até sair do ponto-a-ponto.",
"recHighDefinitionOnly": "Preferência para alta definição",
"sd": "SD",
"sdTooltip": "Ver vídeo em definição padrão",
"standardDefinition": "Definição padrão",
"qualityButtonTip": "Trocar a qualidade de vídeo recebido"
},
"dialOut": {
"dial": "Discar",
"dialOut": "",
"statusMessage": "está agora __status__",
"enterPhone": "Digite o número do telefone",
"phoneNotAllowed": "Oh, ainda não temos suporte para esse destino! Desculpe!"
"statusMessage": "está agora __status__"
},
"addPeople": {
"add": "Adicionar",
"add": "Convidar",
"countryNotSupported": "Ainda não suportamos este destino.",
"countryReminder": "Ligando fora dos EUA? Por favor, certifique-se de começar com o código do país!",
"disabled": "Você não pode convidar pessoas.",
"invite": "Convidar",
"loading": "Procurando por pessoas e números de telefone",
"loadingNumber": "Validando o número de telefone",
"loadingPeople": "Procurando por pessoas para convidar",
"noResults": "Nenhum resultado de busca correspondente",
"searchPlaceholder": "Encontrar por pessoas e salas para adicionar",
"title": "Adicionar pessoas à sua chamada",
"noValidNumbers": "Por favor, digite um número de telefone",
"notAvailable": "Você não pode convidar pessoas.",
"searchNumbers": "Adicionar números de telefones",
"searchPeople": "Pesquisar pessoas",
"searchPeopleAndNumbers": "Pesquisar por pessoas ou adicionar seus números de telefone",
"telephone": "Telefone: __number__",
"title": "Convide pessoas para sua reunião",
"failedToAdd": "Falha ao adicionar membros."
},
"inlineDialogFailure": {
@@ -493,15 +476,80 @@
"supportMsg": "Se isso continuar acontecendo, chegar a"
},
"deviceError": {
"cameraError": "",
"microphoneError": "",
"cameraError": "Falha ao acessar sua câmera",
"microphoneError": "Falha ao acessar seu microfone",
"cameraPermission": "Erro ao obter permissão para a câmera",
"microphonePermission": "Erro ao obter permissão para o microfone"
},
"feedback": {
"average": "Média",
"bad": "Ruim",
"good": "Boa",
"detailsLabel": "Nos conte mais sobre isso.",
"rateExperience": "Avalie sua experiência na reunião",
"veryBad": "Muito ruim",
"veryGood": "Muito boa"
},
"info": {
"copy": "Copiar link",
"invite": "Convidar em __app__",
"title": "Informações de acesso à chamada",
"addPassword": "Adicionar uma senha",
"cancelPassword": "Cancelar senha",
"conferenceURL": "Link:",
"country": "País",
"dialANumber": "Para participar da sua reunião, disque um desses números e insira o PIN: __conferenceID__#",
"dialInNumber": "Discar:",
"dialInConferenceID": "PIN:",
"dialInNotSupported": "Desculpe, a discagem não é atualmente suportada.",
"genericError": "Oops, alguma coisa deu errado.",
"inviteLiveStream": "Para ver a transmissão ao vivo da reunião, clique no link: __url__",
"invitePhone": "Para participar por telefone, disque __number__ e insira este PIN: __conferenceID__#",
"invitePhoneAlternatives": "Para ver mais números de telefone, clique neste link: __url__",
"inviteURL": "Para se juntar à reunião, clique neste link: __url__",
"liveStreamURL": "Transmissão ao vivo:",
"moreNumbers": "Mais números",
"noNumbers": "Sem números de discagem.",
"noPassword": "Nenhum",
"noRoom": "Nenhuma sala foi especificada para entrar.",
"numbers": "Números de discagem",
"password": "Senha:",
"title": "Compartilhar",
"tooltip": "Obtenha informações de acesso sobre a reunião"
},
"settingsView": {
"alertOk": "OK",
"alertTitle": "Atenção",
"alertURLText": "A URL digitada do servidor é inválida",
"conferenceSection": "Conferência",
"displayName": "Nome de exibição",
"email": "E-mail",
"header": "Configurações",
"profileSection": "Perfil",
"serverURL": "URL do servidor",
"startWithAudioMuted": "Iniciar sem áudio",
"startWithVideoMuted": "Iniciar sem vídeo"
},
"calendarSync": {
"later": "Depois",
"next": "Recebendo",
"nextMeeting": "próxima reunião",
"now": "Agora",
"permissionButton": "Abrir configurações",
"permissionMessage": "Permissão do calendário é requerida para listar suas reuniões na aplicação."
},
"recentList": {
"today": "Hoje",
"yesterday": "Ontem",
"earlier": "Mais cedo"
},
"sectionList": {
"pullToRefresh": "Puxe para atualizar"
},
"deepLinking": {
"title": "Iniciando sua reunião no __app__...",
"description": "Nada acontece? Estamos tentando iniciar sua reunião no aplicativo desktop __app__. Tente novamente ou inicie ele na aplicação web __app__.",
"tryAgainButton": "Tente novamente no desktop",
"launchWebButton": "Iniciar na web",
"appNotInstalled": "Você precisa do aplicativo móvel __app__ para participar da reunião no seu telefone.",
"downloadApp": "Baixe o Aplicativo",
"openApp": "Continue na aplicação"
}
}

View File

@@ -626,9 +626,7 @@
"permissionMessage": "The Calendar permission is required to see your meetings in the app."
},
"recentList": {
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier"
"joinPastMeeting": "Join A Past Meeting"
},
"sectionList": {
"pullToRefresh": "Pull to refresh"
@@ -656,6 +654,11 @@
"ignored": "Ignored",
"expired": "Expired"
},
"dateUtils": {
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier"
},
"incomingCall": {
"answer": "Answer",
"audioCallTitle": "Incoming call",

View File

@@ -1,7 +1,6 @@
/* eslint-disable no-unused-vars, no-var */
// Logging configuration
// XXX When making any changes to this file make sure to also update it's React
// version at ./react/features/base/logging/reducer.js !!!
var loggingConfig = {
// default log level for the app and lib-jitsi-meet
defaultLogLevel: 'trace',
@@ -11,9 +10,17 @@ var loggingConfig = {
// The following are too verbose in their logging with the
// {@link #defaultLogLevel}:
'modules/RTC/TraceablePeerConnection.js': 'info',
'modules/statistics/CallStats.js': 'info',
'modules/xmpp/strophe.util.js': 'log',
'modules/RTC/TraceablePeerConnection.js': 'info'
'modules/xmpp/strophe.util.js': 'log'
};
/* eslint-enable no-unused-vars, no-var */
// XXX Web/React server-includes logging_config.js into index.html.
// Mobile/react-native requires it in react/features/base/logging. For the
// purposes of the latter, (try to) export loggingConfig. The following
// detection of a module system is inspired by webpack.
typeof module === 'object'
&& typeof exports === 'object'
&& (module.exports = loggingConfig);

View File

@@ -112,11 +112,21 @@ function initCommands() {
const { name } = request;
switch (name) {
case 'invite':
case 'invite': // eslint-disable-line no-case-declarations
const { invitees } = request;
if (!Array.isArray(invitees) || invitees.length === 0) {
callback({
error: new Error('Unexpected format of invitees')
});
break;
}
// The store should be already available because API.init is called
// on appWillMount action.
APP.store.dispatch(
invite(request.invitees, true))
invite(invitees, true))
.then(failedInvitees => {
let error;
let result;

View File

@@ -238,7 +238,9 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
}
})
});
this.invite(invitees);
if (Array.isArray(invitees) && invitees.length > 0) {
this.invite(invitees);
}
this._isLargeVideoVisible = true;
this._numberOfParticipants = 0;
this._participants = {};
@@ -597,6 +599,10 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
* @returns {Promise} - Resolves on success and rejects on failure.
*/
invite(invitees) {
if (!Array.isArray(invitees) || invitees.length === 0) {
return Promise.reject(new TypeError('Invalid Argument'));
}
return this._transport.sendRequest({
name: 'invite',
invitees

52
package-lock.json generated
View File

@@ -5573,11 +5573,6 @@
"randomfill": "^1.0.3"
}
},
"crypto-js": {
"version": "3.1.9-1",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz",
"integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg="
},
"css-color-list": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/css-color-list/-/css-color-list-0.0.1.tgz",
@@ -5732,6 +5727,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz",
"integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=",
"dev": true,
"requires": {
"es5-ext": "^0.10.9"
}
@@ -6236,6 +6232,7 @@
"version": "0.10.39",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.39.tgz",
"integrity": "sha512-AlaXZhPHl0po/uxMx1tyrlt1O86M6D5iVaDH8UgLfgek4kXTX6vzsRfJQWC2Ku+aG8pkw1XWzh9eTkwfVrsD5g==",
"dev": true,
"requires": {
"es6-iterator": "~2.0.3",
"es6-symbol": "~3.1.1"
@@ -6245,6 +6242,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
"dev": true,
"requires": {
"d": "1",
"es5-ext": "^0.10.35",
@@ -6282,6 +6280,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz",
"integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=",
"dev": true,
"requires": {
"d": "1",
"es5-ext": "~0.10.14"
@@ -9524,6 +9523,11 @@
"dev": true,
"optional": true
},
"jsc-android": {
"version": "224109.1.0",
"resolved": "https://registry.npmjs.org/jsc-android/-/jsc-android-224109.1.0.tgz",
"integrity": "sha512-mhALFynePc/wJsUt9BJuH13mSK/dGWtBO/pcYwVV1I0A7iduyqy3fSoAt1b0yI+/B3TzlGyue/gqjPxsqG1HRQ=="
},
"jsesc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz",
@@ -10507,6 +10511,11 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.19.4.tgz",
"integrity": "sha512-1xFTAknSLfc47DIxHDUbnJWC+UwgWxATmymaxIPQpmMh7LBm7ZbwVEsuushqwL2GYZU0jie4xO+TK44hJPjNSQ=="
},
"moment-duration-format": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/moment-duration-format/-/moment-duration-format-2.2.2.tgz",
"integrity": "sha1-uVdhLeJgFsmtnrYIfAVFc+USd3k="
},
"morgan": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.0.tgz",
@@ -12715,35 +12724,12 @@
"jssha": "^2.2.0"
}
},
"react-native-fetch-blob": {
"version": "github:joltup/react-native-fetch-blob#1f9a1761aea4e37bd672bd0d233f3adf0e113a11",
"from": "github:joltup/react-native-fetch-blob#1f9a1761aea4e37bd672bd0d233f3adf0e113a11",
"react-native-fast-image": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-4.0.14.tgz",
"integrity": "sha512-MeRgL70JxoY/hn8ZRGBsDED9SGvTEeznneL//fWZyLaG0CM+w2CH4QXAMvADnIvu2RFd8WQWNii6c6VOpVe4Tg==",
"requires": {
"base-64": "0.1.0",
"glob": "7.0.6"
},
"dependencies": {
"glob": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.0.6.tgz",
"integrity": "sha1-IRuvr0nlJbjNkyYNFKsTYVKz9Xo=",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.2",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"react-native-img-cache": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/react-native-img-cache/-/react-native-img-cache-1.5.2.tgz",
"integrity": "sha1-6HG4MJk3t/mSbgmwFuTk5nWKOfo=",
"requires": {
"crypto-js": "^3.1.9-1"
"prop-types": "^15.5.10"
}
},
"react-native-immersive": {

View File

@@ -36,8 +36,6 @@
"@atlaskit/tooltip": "9.1.1",
"@webcomponents/url": "0.7.1",
"autosize": "1.18.13",
"es6-iterator": "2.0.3",
"es6-symbol": "3.1.1",
"i18next": "8.4.3",
"i18next-browser-languagedetector": "2.0.0",
"i18next-xhr-backend": "1.4.2",
@@ -47,10 +45,12 @@
"jquery-contextmenu": "2.4.5",
"jquery-i18next": "1.2.0",
"js-md5": "0.6.1",
"jsc-android": "224109.1.0",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#2be752fc88ff71e454c6b9178b21a33b59c53f41",
"lodash": "4.17.4",
"moment": "2.19.4",
"moment-duration-format": "2.2.2",
"postis": "2.2.0",
"prop-types": "15.6.0",
"react": "16.3.1",
@@ -60,8 +60,7 @@
"react-native-background-timer": "2.0.0",
"react-native-calendar-events": "github:jitsi/react-native-calendar-events#cad37355f36d17587d84af72b0095e8cc5fd3df9",
"react-native-callstats": "3.52.0",
"react-native-fetch-blob": "github:joltup/react-native-fetch-blob#1f9a1761aea4e37bd672bd0d233f3adf0e113a11",
"react-native-img-cache": "1.5.2",
"react-native-fast-image": "4.0.14",
"react-native-immersive": "1.1.0",
"react-native-keep-awake": "2.0.6",
"react-native-linear-gradient": "2.4.0",

View File

@@ -4,6 +4,7 @@ import { AbstractAudioMuteButton } from '../base/toolbox';
import type { AbstractButtonProps as Props } from '../base/toolbox';
const { api } = window.alwaysOnTop;
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The type of the React {@code Component} state of {@link AudioMuteButton}.
@@ -68,7 +69,7 @@ export default class AudioMuteButton
audioAvailable,
audioMuted
}))
.catch(console.error);
.catch(logger.error);
}
/**

View File

@@ -4,6 +4,7 @@ import { AbstractVideoMuteButton } from '../base/toolbox';
import type { AbstractButtonProps as Props } from '../base/toolbox';
const { api } = window.alwaysOnTop;
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The type of the React {@code Component} state of {@link VideoMuteButton}.
@@ -68,7 +69,7 @@ export default class VideoMuteButton
videoAvailable,
videoMuted
}))
.catch(console.error);
.catch(logger.error);
}
/**

View File

@@ -11,6 +11,7 @@ import {
AspectRatioDetector,
ReducedUIDetector
} from '../../base/responsive-ui';
import '../../google-api';
import '../../mobile/audio-mode';
import '../../mobile/background';
import '../../mobile/callkit';
@@ -26,6 +27,8 @@ import type { Props as AbstractAppProps } from './AbstractApp';
declare var __DEV__;
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The type of React {@code Component} props of {@link App}.
*/
@@ -196,7 +199,7 @@ function _handleException(error, fatal) {
// an unhandled JavascriptException for an unhandled JavaScript error.
// This will effectively kill the app. In accord with the Web, do not
// kill the app.
console.error(error);
logger.error(error);
} else {
// Forward to the next globalHandler of ErrorUtils.
const { next } = _handleException;

View File

@@ -11,6 +11,8 @@ import {
JITSI_CONFERENCE_URL_KEY
} from './constants';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Attach a set of local tracks to a conference.
*
@@ -172,7 +174,7 @@ export function _removeLocalTracksFromConference(
function _reportError(msg, err) {
// TODO This is a good point to call some global error handler when we have
// one.
console.error(msg, err);
logger.error(msg, err);
}
/**

View File

@@ -24,12 +24,14 @@ import { TRACK_ADDED, TRACK_REMOVED } from '../tracks';
import {
conferenceFailed,
conferenceLeft,
conferenceWillLeave,
createConference,
setLastN
} from './actions';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_WILL_LEAVE,
DATA_CHANNEL_OPENED,
SET_AUDIO_ONLY,
SET_LASTN,
@@ -46,6 +48,11 @@ const logger = require('jitsi-meet-logger').getLogger(__filename);
declare var APP: Object;
/**
* Handler for before unload event.
*/
let beforeUnloadHandler;
/**
* Implements the middleware of the feature base/conference.
*
@@ -66,6 +73,10 @@ MiddlewareRegistry.register(store => next => action => {
case CONNECTION_FAILED:
return _connectionFailed(store, next, action);
case CONFERENCE_WILL_LEAVE:
_conferenceWillLeave();
break;
case DATA_CHANNEL_OPENED:
return _syncReceiveVideoQuality(store, next, action);
@@ -135,6 +146,11 @@ function _conferenceFailed(store, next, action) {
// conference is handled by /conference.js and appropriate failure handlers
// are set there.
if (typeof APP !== 'undefined') {
if (typeof beforeUnloadHandler !== 'undefined') {
window.removeEventListener('beforeunload', beforeUnloadHandler);
beforeUnloadHandler = undefined;
}
return result;
}
@@ -175,6 +191,16 @@ function _conferenceJoined({ dispatch, getState }, next, action) {
// and the LastN value needs to be synchronized here.
audioOnly && conference.getLastN() !== 0 && dispatch(setLastN(0));
// FIXME: Very dirty solution. This will work on web only.
// When the user closes the window or quits the browser, lib-jitsi-meet
// handles the process of leaving the conference. This is temporary solution
// that should cover the described use case as part of the effort to
// implement the conferenceWillLeave action for web.
beforeUnloadHandler = () => {
dispatch(conferenceWillLeave(conference));
};
window.addEventListener('beforeunload', beforeUnloadHandler);
return result;
}
@@ -227,6 +253,11 @@ function _connectionFailed({ dispatch, getState }, next, action) {
const result = next(action);
if (typeof beforeUnloadHandler !== 'undefined') {
window.removeEventListener('beforeunload', beforeUnloadHandler);
beforeUnloadHandler = undefined;
}
// FIXME: Workaround for the web version. Currently, the creation of the
// conference is handled by /conference.js and appropriate failure handlers
// are set there.
@@ -266,6 +297,21 @@ function _connectionFailed({ dispatch, getState }, next, action) {
return result;
}
/**
* Notifies the feature base/conference that the action
* {@code CONFERENCE_WILL_LEAVE} is being dispatched within a specific redux
* store.
*
* @private
* @returns {void}
*/
function _conferenceWillLeave() {
if (typeof beforeUnloadHandler !== 'undefined') {
window.removeEventListener('beforeunload', beforeUnloadHandler);
beforeUnloadHandler = undefined;
}
}
/**
* Returns whether or not a CONNECTION_FAILED action is for a possible split
* brain error. A split brain error occurs when at least two users join a
@@ -447,7 +493,7 @@ function _setLastN({ getState }, next, action) {
try {
conference.setLastN(action.lastN);
} catch (err) {
console.error(`Failed to set lastN: ${err}`);
logger.error(`Failed to set lastN: ${err}`);
}
}

View File

@@ -37,6 +37,9 @@ const INITIAL_RN_STATE = {
// fastest to merely disable them.
disableAudioLevels: true,
// FIXME flow complains about missing 'locationURL' missing in _setConfig
locationURL: undefined,
p2p: {
disableH264: false,
preferH264: true
@@ -126,8 +129,10 @@ function _setConfig(state, { config }) {
const newState = _.merge(
{},
config,
{ error: undefined },
config, {
error: undefined,
locationURL: state.locationURL
},
// The config of _getInitialState() is meant to override the config
// downloaded from the Jitsi Meet deployment because the former contains

View File

@@ -9,17 +9,6 @@
*/
export const SET_AUDIO_INPUT_DEVICE = Symbol('SET_AUDIO_INPUT_DEVICE');
/**
* The type of Redux action which signals that the currently used audio
* output device should be changed.
*
* {
* type: SET_AUDIO_OUTPUT_DEVICE,
* deviceId: string,
* }
*/
export const SET_AUDIO_OUTPUT_DEVICE = Symbol('SET_AUDIO_OUTPUT_DEVICE');
/**
* The type of Redux action which signals that the currently used video
* input device should be changed.

View File

@@ -1,10 +1,34 @@
import JitsiMeetJS from '../lib-jitsi-meet';
import {
SET_AUDIO_INPUT_DEVICE,
SET_AUDIO_OUTPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST
} from './actionTypes';
/**
* Queries for connected A/V input and output devices and updates the redux
* state of known devices.
*
* @returns {Function}
*/
export function getAvailableDevices() {
return dispatch => new Promise(resolve => {
const { mediaDevices } = JitsiMeetJS;
if (mediaDevices.isDeviceListAvailable()
&& mediaDevices.isDeviceChangeAvailable()) {
mediaDevices.enumerateDevices(devices => {
dispatch(updateDeviceList(devices));
resolve(devices);
});
} else {
resolve([]);
}
});
}
/**
* Signals to update the currently used audio input device.
*
@@ -21,22 +45,6 @@ export function setAudioInputDevice(deviceId) {
};
}
/**
* Signals to update the currently used audio output device.
*
* @param {string} deviceId - The id of the new audio ouput device.
* @returns {{
* type: SET_AUDIO_OUTPUT_DEVICE,
* deviceId: string
* }}
*/
export function setAudioOutputDevice(deviceId) {
return {
type: SET_AUDIO_OUTPUT_DEVICE,
deviceId
};
}
/**
* Signals to update the currently used video input device.
*

View File

@@ -6,7 +6,6 @@ import { MiddlewareRegistry } from '../redux';
import {
SET_AUDIO_INPUT_DEVICE,
SET_AUDIO_OUTPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE
} from './actionTypes';
@@ -22,9 +21,6 @@ MiddlewareRegistry.register(store => next => action => {
case SET_AUDIO_INPUT_DEVICE:
APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId);
break;
case SET_AUDIO_OUTPUT_DEVICE:
APP.UI.emitEvent(UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED, action.deviceId);
break;
case SET_VIDEO_INPUT_DEVICE:
APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId);
break;

View File

@@ -1,6 +1,5 @@
import {
SET_AUDIO_INPUT_DEVICE,
SET_AUDIO_OUTPUT_DEVICE,
SET_VIDEO_INPUT_DEVICE,
UPDATE_DEVICE_LIST
} from './actionTypes';
@@ -40,7 +39,6 @@ ReducerRegistry.register(
// now.
case SET_AUDIO_INPUT_DEVICE:
case SET_VIDEO_INPUT_DEVICE:
case SET_AUDIO_OUTPUT_DEVICE:
default:
return state;
}

View File

@@ -103,6 +103,28 @@ class DialogWithTabs extends Component<Props, State> {
);
}
/**
* Gets the props to pass into the tab component.
*
* @param {number} tabId - The index of the tab configuration within
* {@link this.state.tabStates}.
* @returns {Object}
*/
_getTabProps(tabId) {
const { tabs } = this.props;
const { tabStates } = this.state;
const tabConfiguration = tabs[tabId];
const currentTabState = tabStates[tabId];
if (tabConfiguration.propsUpdateFunction) {
return tabConfiguration.propsUpdateFunction(
currentTabState,
tabConfiguration.props);
}
return { ...currentTabState };
}
/**
* Renders the tabs from the tab information passed on props.
*
@@ -155,10 +177,11 @@ class DialogWithTabs extends Component<Props, State> {
<div className = { styles }>
<TabComponent
closeDialog = { closeDialog }
mountCallback = { this.props.tabs[tabId].onMount }
onTabStateChange
= { this._onTabStateChange }
tabId = { tabId }
{ ...this.state.tabStates[tabId] } />
{ ...this._getTabProps(tabId) } />
</div>);
}

View File

@@ -4,6 +4,9 @@ import moment from 'moment';
import i18next from './i18next';
// allows for moment durations to be formatted
import 'moment-duration-format';
// 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.
@@ -55,8 +58,19 @@ export function getLocalizedDurationFormatter(duration: number) {
// states v2.19 so maybe locale on moment's duration was introduced in
// between?
//
// If the conference is under an hour long we want to display it without
// showing the hour and we want to include the hour if the conference is
// more than an hour long
// $FlowFixMe
return moment.duration(duration).locale(_getSupportedLocale());
if (moment.duration(duration).format('h') !== '0') {
// $FlowFixMe
return moment.duration(duration).format('h:mm:ss');
}
// $FlowFixMe
return moment.duration(duration).format('mm:ss', { trim: false });
}
/**

View File

@@ -10,6 +10,8 @@ declare var APP: Object;
const JitsiConferenceErrors = JitsiMeetJS.errors.conference;
const JitsiConnectionErrors = JitsiMeetJS.errors.connection;
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Creates a {@link JitsiLocalTrack} model from the given device id.
*
@@ -133,7 +135,7 @@ export function loadConfig(
return config;
})
.catch(err => {
console.error(`Failed to load config from ${url}`, err);
logger.error(`Failed to load config from ${url}`, err);
throw err;
});

View File

@@ -3,6 +3,8 @@
import { NativeModules } from 'react-native';
import { RTCPeerConnection, RTCSessionDescription } from 'react-native-webrtc';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/* eslint-disable no-unused-vars */
// Address families.
@@ -140,7 +142,7 @@ _RTCPeerConnection.prototype.setRemoteDescription = function(
* @returns {void}
*/
function _LOGE(...args) {
console && console.error && console.error(...args);
logger.error(...args);
}
/**

View File

@@ -1,4 +1,3 @@
import Iterator from 'es6-iterator';
import BackgroundTimer from 'react-native-background-timer';
import '@webcomponents/url'; // Polyfill for URL constructor
@@ -114,16 +113,13 @@ function _visitNode(node, callback) {
global.addEventListener = () => {};
}
// Array.prototype[@@iterator]
// removeEventListener
//
// Required by:
// - for...of statement use(s) in lib-jitsi-meet
const arrayPrototype = Array.prototype;
if (typeof arrayPrototype['@@iterator'] === 'undefined') {
arrayPrototype['@@iterator'] = function() {
return new Iterator(this);
};
// - features/base/conference/middleware
if (typeof global.removeEventListener === 'undefined') {
// eslint-disable-next-line no-empty-function
global.removeEventListener = () => {};
}
// document

View File

@@ -7,23 +7,12 @@ import { SET_LOGGING_CONFIG } from './actionTypes';
/**
* The default/initial redux state of the feature base/logging.
*
* XXX When making any changes to the DEFAULT_STATE make sure to also update
* logging_config.js file located in the root directory of this project !!!
*
* @type {{
* config: Object
* }}
*/
const DEFAULT_STATE = {
config: {
defaultLogLevel: 'trace',
// The following are too verbose in their logging with the
// {@link #defaultLogLevel}:
'modules/statistics/CallStats.js': 'info',
'modules/xmpp/strophe.util.js': 'log',
'modules/RTC/TraceablePeerConnection.js': 'info'
}
config: require('../../../../logging_config.js')
};
ReducerRegistry.register(

View File

@@ -19,7 +19,7 @@ class VideoTrack extends AbstractVideoTrack {
static propTypes = AbstractVideoTrack.propTypes
/**
* Renders video element with animation.
* Renders the video element for the associated video track.
*
* @override
* @returns {ReactElement}

View File

@@ -229,7 +229,7 @@ class VideoTransform extends Component<Props, State> {
onLayout = { this._onLayout }
pointerEvents = 'box-only'
style = { [
styles.videoTransformedViewContaier,
styles.videoTransformedViewContainer,
style
] }
{ ...this.gestureHandlers.panHandlers }>

View File

@@ -17,7 +17,7 @@ export default StyleSheet.create({
* that can be visible on special occasions, such as during device rotate
* animation, or PiP mode.
*/
videoTransformedViewContaier: {
videoTransformedViewContainer: {
overflow: 'hidden'
},

View File

@@ -1,10 +1,9 @@
// @flow
import React, { Component } from 'react';
import React, { Component, Fragment } from 'react';
import { Image, View } from 'react-native';
import FastImage from 'react-native-fast-image';
import { CachedImage, ImageCache } from '../../../mobile/image-cache';
import { Platform } from '../../react';
import { ColorPalette } from '../../styles';
import styles from './styles';
@@ -46,7 +45,8 @@ type Props = {
*/
type State = {
backgroundColor: string,
source: number | { uri: string }
source: ?{ uri: string },
useDefaultAvatar: boolean
};
/**
@@ -68,6 +68,9 @@ export default class Avatar extends Component<Props, State> {
constructor(props: Props) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onAvatarLoaded = this._onAvatarLoaded.bind(this);
// Fork (in Facebook/React speak) the prop uri because Image will
// receive it through a source object. Additionally, other props may be
// forked as well.
@@ -94,18 +97,8 @@ export default class Avatar extends Component<Props, State> {
if (prevURI !== nextURI || assignState) {
const nextState = {
backgroundColor: this._getBackgroundColor(nextProps),
/**
* The source of the {@link Image} which is the actual
* representation of this {@link Avatar}. The state
* {@code source} was explicitly introduced in order to reduce
* unnecessary renders.
*
* @type {{
* uri: string
* }}
*/
source: _DEFAULT_SOURCE
source: undefined,
useDefaultAvatar: true
};
if (assignState) {
@@ -130,7 +123,14 @@ export default class Avatar extends Component<Props, State> {
// an image retrieval action.
if (nextURI && !nextURI.startsWith('#')) {
const nextSource = { uri: nextURI };
const observer = () => {
if (assignState) {
// eslint-disable-next-line react/no-direct-mutation-state
this.state = {
...this.state,
source: nextSource
};
} else {
this._unmounted || this.setState((prevState, props) => {
if (props.uri === nextURI
&& (!prevState.source
@@ -140,22 +140,6 @@ export default class Avatar extends Component<Props, State> {
return {};
});
};
// Wait for the source/URI to load.
if (ImageCache) {
ImageCache.get().on(
nextSource,
observer,
/* immutable */ true);
} else if (assignState) {
// eslint-disable-next-line react/no-direct-mutation-state
this.state = {
...this.state,
source: nextSource
};
} else {
observer();
}
}
}
@@ -186,135 +170,132 @@ export default class Avatar extends Component<Props, State> {
*/
_getBackgroundColor({ uri }) {
if (!uri) {
// @lyubomir: I'm leaving @saghul's implementation which picks up a
// random color bellow so that we have it in the source code in
// case we decide to use it in the future. However, I think at the
// time of this writing that the randomness reduces the
// predictability which React is supposed to bring to our app.
return ColorPalette.white;
}
let hash = 0;
if (typeof uri === 'string') {
/* eslint-disable no-bitwise */
/* eslint-disable no-bitwise */
for (let i = 0; i < uri.length; i++) {
hash = uri.charCodeAt(i) + ((hash << 5) - hash);
hash |= 0; // Convert to 32-bit integer
}
/* eslint-enable no-bitwise */
} else {
// @saghul: If we have no URI yet, we have no data to hash from. So
// use a random value.
hash = Math.floor(Math.random() * 360);
for (let i = 0; i < uri.length; i++) {
hash = uri.charCodeAt(i) + ((hash << 5) - hash);
hash |= 0; // Convert to 32-bit integer
}
/* eslint-enable no-bitwise */
return `hsl(${hash % 360}, 100%, 75%)`;
}
/**
* Helper which computes the style for the {@code Image} / {@code FastImage}
* component.
*
* @private
* @returns {Object}
*/
_getImageStyle() {
const { size } = this.props;
return {
...styles.avatar,
borderRadius: size / 2,
height: size,
width: size
};
}
_onAvatarLoaded: () => void;
/**
* Handler called when the remote image was loaded. When this happens we
* show that instead of the default locally generated one.
*
* @private
* @returns {void}
*/
_onAvatarLoaded() {
this._unmounted || this.setState({ useDefaultAvatar: false });
}
/**
* Renders a default, locally generated avatar image.
*
* @private
* @returns {ReactElement}
*/
_renderDefaultAvatar() {
// When using a local image, react-native-fastimage falls back to a
// regular Image, so we need to wrap it in a view to make it round.
// https://github.com/facebook/react-native/issues/3198
const { backgroundColor, useDefaultAvatar } = this.state;
const imageStyle = this._getImageStyle();
const viewStyle = {
...imageStyle,
backgroundColor,
display: useDefaultAvatar ? 'flex' : 'none',
// FIXME @lyubomir: Without the opacity bellow I feel like the
// avatar colors are too strong. Besides, we use opacity for the
// ToolbarButtons. That's where I copied the value from and we
// may want to think about "standardizing" the opacity in the
// app in a way similar to ColorPalette.
opacity: 0.1,
overflow: 'hidden'
};
return (
<View style = { viewStyle }>
<Image
// The Image adds a fade effect without asking, so lets
// explicitly disable it. More info here:
// https://github.com/facebook/react-native/issues/10194
fadeDuration = { 0 }
resizeMode = 'contain'
source = { _DEFAULT_SOURCE }
style = { imageStyle } />
</View>
);
}
/**
* Renders an avatar using a remote image.
*
* @private
* @returns {ReactElement}
*/
_renderAvatar() {
const { source, useDefaultAvatar } = this.state;
const style = {
...this._getImageStyle(),
display: useDefaultAvatar ? 'none' : 'flex'
};
return (
<FastImage
onLoad = { this._onAvatarLoaded }
resizeMode = 'contain'
source = { source }
style = { style } />
);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
render() {
// Propagate all props of this Avatar but the ones consumed by this
// Avatar to the Image it renders.
const {
/* eslint-disable no-unused-vars */
const { source, useDefaultAvatar } = this.state;
// The following are forked in state:
uri: forked0,
/* eslint-enable no-unused-vars */
size,
...props
} = this.props;
const {
backgroundColor,
source
} = this.state;
// Compute the base style
const borderRadius = size / 2;
const style = {
...styles.avatar,
// XXX Workaround for Android: for radii < 80 the border radius
// doesn't work properly, but applying a radius twice as big seems
// to do the trick.
borderRadius:
Platform.OS === 'android' && borderRadius < 80
? size * 2
: borderRadius,
height: size,
width: size
};
// If we're rendering the _DEFAULT_SOURCE, then we want to do some
// additional fu like having automagical colors generated per
// participant, transparency to make the intermediate state while
// downloading the remote image a little less "in your face", etc.
let styleWithBackgroundColor;
if (source === _DEFAULT_SOURCE && backgroundColor) {
styleWithBackgroundColor = {
...style,
backgroundColor,
// FIXME @lyubomir: Without the opacity bellow I feel like the
// avatar colors are too strong. Besides, we use opacity for the
// ToolbarButtons. That's where I copied the value from and we
// may want to think about "standardizing" the opacity in the
// app in a way similar to ColorPalette.
opacity: 0.1,
overflow: 'hidden'
};
}
// If we're styling with backgroundColor, we need to wrap the Image in a
// View because of a bug in React Native for Android:
// https://github.com/facebook/react-native/issues/3198
let imageStyle;
let viewStyle;
if (styleWithBackgroundColor) {
if (Platform.OS === 'android') {
imageStyle = style;
viewStyle = styleWithBackgroundColor;
} else {
imageStyle = styleWithBackgroundColor;
}
} else {
imageStyle = style;
}
let element
= React.createElement(
// XXX CachedImage removed support for images which clearly do
// not need caching.
typeof source === 'number' ? Image : CachedImage,
{
...props,
// The Image adds a fade effect without asking, so lets
// explicitly disable it. More info here:
// https://github.com/facebook/react-native/issues/10194
fadeDuration: 0,
resizeMode: 'contain',
source,
style: imageStyle
});
if (viewStyle) {
element = React.createElement(View, { style: viewStyle }, element);
}
return element;
return (
<Fragment>
{ source && this._renderAvatar() }
{ useDefaultAvatar && this._renderDefaultAvatar() }
</Fragment>
);
}
}

View File

@@ -2,6 +2,7 @@
import React, { Component } from 'react';
import { Text, View } from 'react-native';
import FastImage from 'react-native-fast-image';
import { connect } from 'react-redux';
import { translate } from '../../i18n';
@@ -11,7 +12,6 @@ import {
shouldRenderVideoTrack,
VideoTrack
} from '../../media';
import { prefetch } from '../../../mobile/image-cache';
import { Container, TintedView } from '../../react';
import { TestHint } from '../../testing/components';
import { getTrackByMediaTypeAndParticipant } from '../../tracks';
@@ -303,7 +303,8 @@ function _mapStateToProps(state, ownProps) {
// ParticipantView knows before Avatar that an avatar URL will be used
// so it's advisable to prefetch here.
avatar && prefetch({ uri: avatar });
avatar && !avatar.startsWith('#')
&& FastImage.preload([ { uri: avatar } ]);
}
return {

View File

@@ -0,0 +1,75 @@
// @flow
import type { ComponentType, Element } from 'react';
/**
* Item data for <tt>NavigateSectionList</tt>.
*/
export type Item = {
/**
* the color base of the avatar
*/
colorBase: string,
/**
* Item title
*/
title: string,
/**
* Item url
*/
url: string,
/**
* lines[0] - date
* lines[1] - duration
* lines[2] - server name
*/
lines: Array<string>
}
/**
* web implementation of section data for NavigateSectionList
*/
export type Section = {
/**
* section title
*/
title: string,
/**
* unique key for the section
*/
key?: string,
/**
* Array of items in the section
*/
data: $ReadOnlyArray<Item>,
/**
* Optional properties added only to fix some flow errors thrown by React
* SectionList
*/
ItemSeparatorComponent?: ?ComponentType<any>,
keyExtractor?: (item: Object) => string,
renderItem?: ?(info: Object) => ?Element<any>
}
/**
* native implementation of section data for NavigateSectionList
*
* When react-native's SectionList component parses through an array of sections
* it passes the section nested within the section property of another object
* to the renderSection method (on web for our own implementation of SectionList
* this nesting is not implemented as there is no need for nesting)
*/
export type SetionListSection = {
section: Section
}

View File

@@ -0,0 +1,223 @@
// @flow
import React, { Component } from 'react';
// TODO: Maybe try to make all NavigateSectionList components to work for both
// mobile and web, and move them to NavigateSectionList component.
import {
NavigateSectionListEmptyComponent,
NavigateSectionListItem,
NavigateSectionListSectionHeader,
SectionList
} from './_';
import type { Section } from '../Types';
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,
/**
* Function to override the rendered default empty list component.
*/
renderListEmptyComponent: Function,
/**
* An array of sections
*/
sections: Array<Section>
};
/**
* 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.
*/
class NavigateSectionList extends Component<Props> {
/**
* Creates an empty section object.
*
* @param {string} title - The title of the section.
* @param {string} key - The key of the section. It must be unique.
* @private
* @returns {Object}
*/
static createSection(title: string, key: string) {
return {
data: [],
key,
title
};
}
/**
* Constructor of the NavigateSectionList component.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
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._renderListEmptyComponent
= this._renderListEmptyComponent.bind(this);
this._renderSectionHeader = this._renderSectionHeader.bind(this);
}
/**
* Implements React's Component.render.
* 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 {
renderListEmptyComponent = this._renderListEmptyComponent,
sections
} = this.props;
return (
<SectionList
ListEmptyComponent = { renderListEmptyComponent }
keyExtractor = { this._getItemKey }
onItemClick = { this.props.onPress }
onRefresh = { this._onRefresh }
refreshing = { false }
renderItem = { this._renderItem }
renderSectionHeader = { this._renderSectionHeader }
sections = { sections } />
);
}
_getItemKey: (Object, number) => string;
/**
* Generates a unique id to every item.
*
* @param {Object} item - The item.
* @param {number} index - The item index.
* @private
* @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.
*
* @param {string} url - The URL of the item to navigate to.
* @private
* @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.
*
* @param {Object} listItem - The item to render.
* @param {string} key - The item needed for rendering using map on web.
* @private
* @returns {Component}
*/
_renderItem(listItem, key: string = '') {
const { item } = listItem;
const { url } = item;
// XXX The value of title cannot be undefined; otherwise, react-native
// will throw a TypeError: Cannot read property of undefined. While it's
// difficult to get an undefined title and very likely requires the
// execution of incorrect source code, it is undesirable to break the
// whole app because of an undefined string.
if (typeof item.title === 'undefined') {
return null;
}
return (
<NavigateSectionListItem
item = { item }
key = { key }
onPress = { this._onPress(url) } />
);
}
_renderListEmptyComponent: () => Object;
/**
* Renders a component to display when the list is empty.
*
* @param {Object} section - The section being rendered.
* @private
* @returns {React$Node}
*/
_renderListEmptyComponent() {
const { onRefresh } = this.props;
if (typeof onRefresh === 'function') {
return (
<NavigateSectionListEmptyComponent />
);
}
return null;
}
_renderSectionHeader: Object => Object;
/**
* Renders a section header.
*
* @param {Object} section - The section being rendered.
* @private
* @returns {React$Node}
*/
_renderSectionHeader(section) {
return (
<NavigateSectionListSectionHeader
section = { section } />
);
}
}
export default NavigateSectionList;

View File

@@ -1 +1,2 @@
export * from './_';
export { default as NavigateSectionList } from './NavigateSectionList';

View File

@@ -34,6 +34,7 @@ export default class Container extends AbstractContainer {
accessible,
onClick,
touchFeedback = onClick,
underlayColor,
visible = true,
...props
} = this.props;
@@ -62,7 +63,8 @@ export default class Container extends AbstractContainer {
{
accessibilityLabel,
accessible,
onPress: onClick
onPress: onClick,
underlayColor
},
element);
}

View File

@@ -1,346 +0,0 @@
// @flow
import React, { Component } from 'react';
import {
SafeAreaView,
SectionList,
Text,
TouchableHighlight,
View
} from 'react-native';
import { Icon } from '../../../font-icons';
import { translate } from '../../../i18n';
import styles, { UNDERLAY_COLOR } from './styles';
type Props = {
/**
* Indicates if the list is disabled or not.
*/
disabled: boolean,
/**
* The translate function.
*/
t: Function,
/**
* 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,
/**
* Function to override the rendered default empty list component.
*/
renderListEmptyComponent: 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.
*/
class NavigateSectionList extends Component<Props> {
/**
* Creates an empty section object.
*
* @param {string} title - The title of the section.
* @param {string} key - The key of the section. It must be unique.
* @private
* @returns {Object}
*/
static createSection(title, key) {
return {
data: [],
key,
title
};
}
/**
* 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._renderListEmptyComponent
= this._renderListEmptyComponent.bind(this);
this._renderSection = this._renderSection.bind(this);
}
/**
* Implements React's Component.render.
* 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 {
renderListEmptyComponent = this._renderListEmptyComponent,
sections
} = this.props;
return (
<SafeAreaView
style = { styles.container } >
<SectionList
ListEmptyComponent = { renderListEmptyComponent }
keyExtractor = { this._getItemKey }
onRefresh = { this._onRefresh }
refreshing = { false }
renderItem = { this._renderItem }
renderSectionHeader = { this._renderSection }
sections = { sections }
style = { styles.list } />
</SafeAreaView>
);
}
_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.
*
* @param {Object} item - The item.
* @param {number} index - The item index.
* @private
* @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.
*
* @param {string} url - The URL of the item to navigate to.
* @private
* @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.
*
* @param {Object} listItem - The item to render.
* @private
* @returns {Component}
*/
_renderItem(listItem) {
const { item: { colorBase, lines, title, url } } = listItem;
// XXX The value of title cannot be undefined; otherwise, react-native
// will throw a TypeError: Cannot read property of undefined. While it's
// difficult to get an undefined title and very likely requires the
// execution of incorrect source code, it is undesirable to break the
// whole app because of an undefined string.
if (typeof title === 'undefined') {
return null;
}
return (
<TouchableHighlight
onPress = { this._onPress(url) }
underlayColor = { UNDERLAY_COLOR }>
<View style = { styles.listItem }>
<View style = { styles.avatarContainer } >
<View
style = { [
styles.avatar,
this._getAvatarColor(colorBase)
] } >
<Text style = { styles.avatarContent }>
{ title.substr(0, 1).toUpperCase() }
</Text>
</View>
</View>
<View style = { styles.listItemDetails }>
<Text
numberOfLines = { 1 }
style = { [
styles.listItemText,
styles.listItemTitle
] }>
{ title }
</Text>
{ this._renderItemLines(lines) }
</View>
</View>
</TouchableHighlight>
);
}
_renderItemLine: (string, number) => React$Node;
/**
* Renders a single line from the additional lines.
*
* @param {string} line - The line text.
* @param {number} index - The index of the line.
* @private
* @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.
*
* @param {Array<string>} lines - The lines to render.
* @private
* @returns {Array<React$Node>}
*/
_renderItemLines(lines) {
return lines && lines.length ? lines.map(this._renderItemLine) : null;
}
_renderListEmptyComponent: () => Object;
/**
* Renders a component to display when the list is empty.
*
* @param {Object} section - The section being rendered.
* @private
* @returns {React$Node}
*/
_renderListEmptyComponent() {
const { t, onRefresh } = this.props;
if (typeof onRefresh === 'function') {
return (
<View style = { styles.pullToRefresh }>
<Text style = { styles.pullToRefreshText }>
{ t('sectionList.pullToRefresh') }
</Text>
<Icon
name = 'menu-down'
style = { styles.pullToRefreshIcon } />
</View>
);
}
return null;
}
_renderSection: Object => Object;
/**
* Renders a section title.
*
* @param {Object} section - The section being rendered.
* @private
* @returns {React$Node}
*/
_renderSection(section) {
return (
<View style = { styles.listSection }>
<Text style = { styles.listSectionText }>
{ section.section.title }
</Text>
</View>
);
}
}
export default translate(NavigateSectionList);

View File

@@ -0,0 +1,48 @@
// @flow
import React, { Component } from 'react';
import { Text, View } from 'react-native';
import { Icon } from '../../../font-icons';
import { translate } from '../../../i18n';
import styles from './styles';
type Props = {
/**
* The translate function.
*/
t: Function,
};
/**
* Implements a React Native {@link Component} that is to be displayed when the
* list is empty
*
* @extends Component
*/
class NavigateSectionListEmptyComponent extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { t } = this.props;
return (
<View style = { styles.pullToRefresh }>
<Text style = { styles.pullToRefreshText }>
{ t('sectionList.pullToRefresh') }
</Text>
<Icon
name = 'menu-down'
style = { styles.pullToRefreshIcon } />
</View>
);
}
}
export default translate(NavigateSectionListEmptyComponent);

View File

@@ -0,0 +1,141 @@
// @flow
import React, { Component } from 'react';
import Container from './Container';
import Text from './Text';
import styles, { UNDERLAY_COLOR } from './styles';
import type { Item } from '../../Types';
type Props = {
/**
* item containing data to be rendered
*/
item: Item,
/**
* Function to be invoked when an Item is pressed. The Item's URL is passed.
*/
onPress: Function
}
/**
* Implements a React/Native {@link Component} that renders the Navigate Section
* List Item
*
* @extends Component
*/
export default class NavigateSectionListItem extends Component<Props> {
/**
* Constructor of the NavigateSectionList component.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._getAvatarColor = this._getAvatarColor.bind(this);
this._renderItemLine = this._renderItemLine.bind(this);
this._renderItemLines = this._renderItemLines.bind(this);
}
_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}`];
}
_renderItemLine: (string, number) => React$Node;
/**
* Renders a single line from the additional lines.
*
* @param {string} line - The line text.
* @param {number} index - The index of the line.
* @private
* @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.
*
* @param {Array<string>} lines - The lines to render.
* @private
* @returns {Array<React$Node>}
*/
_renderItemLines(lines) {
return lines && lines.length ? lines.map(this._renderItemLine) : null;
}
/**
* Renders the content of this component.
*
* @returns {ReactElement}
*/
render() {
const { colorBase, lines, title } = this.props.item;
const avatarStyles = {
...styles.avatar,
...this._getAvatarColor(colorBase)
};
return (
<Container
onClick = { this.props.onPress }
style = { styles.listItem }
underlayColor = { UNDERLAY_COLOR }>
<Container style = { styles.avatarContainer }>
<Container style = { avatarStyles }>
<Text style = { styles.avatarContent }>
{title.substr(0, 1).toUpperCase()}
</Text>
</Container>
</Container>
<Container style = { styles.listItemDetails }>
<Text
numberOfLines = { 1 }
style = {{
...styles.listItemText,
...styles.listItemTitle
}}>
{title}
</Text>
{this._renderItemLines(lines)}
</Container>
</Container>
);
}
}

View File

@@ -0,0 +1,41 @@
// @flow
import React, { Component } from 'react';
import Container from './Container';
import styles from './styles';
import Text from './Text';
import type { SetionListSection } from '../../Types';
type Props = {
/**
* A section containing the data to be rendered
*/
section: SetionListSection
}
/**
* Implements a React/Native {@link Component} that renders the section header
* of the list
*
* @extends Component
*/
export default class NavigateSectionListSectionHeader extends Component<Props> {
/**
* Renders the content of this component.
*
* @returns {ReactElement}
*/
render() {
const { section } = this.props.section;
return (
<Container style = { styles.listSection }>
<Text style = { styles.listSectionText }>
{ section.title }
</Text>
</Container>
);
}
}

View File

@@ -0,0 +1,91 @@
// @flow
import React, { Component } from 'react';
import {
SafeAreaView,
SectionList as ReactNativeSectionList
} from 'react-native';
import styles from './styles';
import type { Section } from '../../Types';
/**
* The type of the React {@code Component} props of {@link SectionList}
*/
type Props = {
/**
* Rendered when the list is empty. Can be a React Component Class, a render
* function, or a rendered element.
*/
ListEmptyComponent: Object,
/**
*
* Used to extract a unique key for a given item at the specified index.
* Key is used for caching and as the react key to track item re-ordering.
*/
keyExtractor: Function,
/**
*
* Functions that defines what happens when the list is pulled for refresh
*/
onRefresh: Function,
/**
*
* A boolean that is set true while waiting for new data from a refresh.
*/
refreshing: ?boolean,
/**
*
* Default renderer for every item in every section.
*/
renderItem: Function,
/**
*
* A component rendered at the top of each section. These stick to the top
* of the ScrollView by default on iOS.
*/
renderSectionHeader: Object,
/**
* An array of sections
*/
sections: Array<Section>
};
/**
* Implements a React Native {@link Component} that wraps the React Native
* SectionList component in a SafeAreaView so that it renders the sectionlist
* within the safe area of the device
*
* @extends Component
*/
export default class SectionList extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<SafeAreaView
style = { styles.container } >
<ReactNativeSectionList
ListEmptyComponent = { this.props.ListEmptyComponent }
keyExtractor = { this.props.keyExtractor }
onRefresh = { this.props.onRefresh }
refreshing = { this.props.refreshing }
renderItem = { this.props.renderItem }
renderSectionHeader = { this.props.renderSectionHeader }
sections = { this.props.sections }
style = { styles.list } />
</SafeAreaView>
);
}
}

View File

@@ -1,10 +1,16 @@
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 NavigateSectionListEmptyComponent } from
'./NavigateSectionListEmptyComponent';
export { default as NavigateSectionListItem }
from './NavigateSectionListItem';
export { default as NavigateSectionListSectionHeader }
from './NavigateSectionListSectionHeader';
export { default as PagedList } from './PagedList';
export { default as Pressable } from './Pressable';
export { default as SideBar } from './SideBar';
export { default as Text } from './Text';
export { default as SectionList } from './SectionList';
export { default as TintedView } from './TintedView';

View File

@@ -0,0 +1,74 @@
// @flow
import React, { Component } from 'react';
import Container from './Container';
import Text from './Text';
import type { Item } from '../../Types';
type Props = {
/**
* Function to be invoked when an item is pressed. The item's URL is passed.
*/
onPress: Function,
/**
* A item containing data to be rendered
*/
item: Item
}
/**
* Implements a React/Web {@link Component} for displaying an item in a
* NavigateSectionList
*
* @extends Component
*/
export default class NavigateSectionListItem extends Component<Props> {
/**
* Renders the content of this component.
*
* @returns {ReactElement}
*/
render() {
const { lines, title } = this.props.item;
const { onPress } = this.props;
/**
* Initiliazes the date and duration of the conference to the an empty
* string in case for some reason there is an error where the item data
* lines doesn't contain one or both of those values (even though this
* unlikely the app shouldn't break because of it)
* @type {string}
*/
let date = '';
let duration = '';
if (lines[0]) {
date = lines[0];
}
if (lines[1]) {
duration = lines[1];
}
return (
<Container
className = 'navigate-section-list-tile'
onClick = { onPress }>
<Text
className = 'navigate-section-tile-title'>
{ title }
</Text>
<Text
className = 'navigate-section-tile-body'>
{ date }
</Text>
<Text
className = 'navigate-section-tile-body'>
{ duration }
</Text>
</Container>
);
}
}

View File

@@ -0,0 +1,35 @@
// @flow
import React, { Component } from 'react';
import Text from './Text';
import type { Section } from '../../Types';
type Props = {
/**
* A section containing the data to be rendered
*/
section: Section
}
/**
* Implements a React/Web {@link Component} that renders the section header of
* the list
*
* @extends Component
*/
export default class NavigateSectionListSectionHeader extends Component<Props> {
/**
* Renders the content of this component.
*
* @returns {ReactElement}
*/
render() {
return (
<Text className = 'navigate-section-section-header'>
{ this.props.section.title }
</Text>
);
}
}

View File

@@ -0,0 +1,92 @@
// @flow
import React, { Component } from 'react';
import Container from './Container';
import type { Section } from '../../Types';
type Props = {
/**
* Used to extract a unique key for a given item at the specified index.
* Key is used for caching and as the react key to track item re-ordering.
*/
keyExtractor: Function,
/**
* Returns a React component that renders each Item in the list
*/
renderItem: Function,
/**
* Returns a React component that renders the header for every section
*/
renderSectionHeader: Function,
/**
* An array of sections
*/
sections: Array<Section>,
/**
* defines what happens when an item in the section list is clicked
*/
onItemClick: Function
};
/**
* Implements a React/Web {@link Component} for displaying a list with
* sections similar to React Native's {@code SectionList} in order to
* faciliate cross-platform source code.
*
* @extends Component
*/
export default class SectionList extends Component<Props> {
/**
* Renders the content of this component.
*
* @returns {React.ReactNode}
*/
render() {
const {
renderSectionHeader,
renderItem,
sections,
keyExtractor
} = this.props;
/**
* If there are no recent items we dont want to display anything
*/
if (sections) {
return (
/* eslint-disable no-extra-parens */
<Container
className = 'navigate-section-list'>
{
sections.map((section, sectionIndex) => (
<Container
key = { sectionIndex }>
{ renderSectionHeader(section) }
{ section.data
.map((item, listIndex) => {
const listItem = {
item
};
return renderItem(listItem,
keyExtractor(section,
listIndex));
}) }
</Container>
)
)
}
</Container>
/* eslint-enable no-extra-parens */
);
}
return null;
}
}

View File

@@ -1,4 +1,11 @@
export { default as Container } from './Container';
export { default as MultiSelectAutocomplete } from './MultiSelectAutocomplete';
export { default as NavigateSectionListEmptyComponent } from
'./NavigateSectionListEmptyComponent';
export { default as NavigateSectionListItem } from
'./NavigateSectionListItem';
export { default as NavigateSectionListSectionHeader }
from './NavigateSectionListSectionHeader';
export { default as SectionList } from './SectionList';
export { default as Text } from './Text';
export { default as Watermarks } from './Watermarks';

View File

@@ -1,2 +1,3 @@
export * from './components';
export { default as Platform } from './Platform';
export * from './Types';

View File

@@ -0,0 +1,14 @@
/**
* FIXME.
*
* {
* type: SET_SESSION,
* session: {
* url: {string},
* state: {string},
* ...data
* }
* }
* @public
*/
export const SET_SESSION = Symbol('SET_SESSION');

View File

@@ -0,0 +1,16 @@
import { SET_SESSION } from './actionTypes';
/**
* FIXME.
*
* @param {string} session - FIXME.
* @returns {{
* type: SET_SESSION
* }}
*/
export function setSession(session) {
return {
type: SET_SESSION,
session
};
}

View File

@@ -0,0 +1,12 @@
export const SESSION_CONFIGURED = Symbol('SESSION_CONFIGURED');
export const SESSION_ENDED = Symbol('SESSION_ENDED');
export const SESSION_FAILED = Symbol('SESSION_FAILED');
export const SESSION_STARTED = Symbol('SESSION_STARTED');
export const SESSION_WILL_END = Symbol('SESSION_WILL_END');
export const SESSION_WILL_START = Symbol('SESSION_WILL_START');

View File

@@ -0,0 +1,36 @@
// @flow
import { toState } from '../redux';
import { toURLString } from '../util';
/**
* FIXME.
*
* @param {Function|Object} stateful - FIXME.
* @param {string} url - FIXME.
* @returns {*}
*/
export function getSession(stateful: Function | Object, url: string): ?Object {
const state = toState(stateful);
const session = state['features/base/session'].get(url);
if (!session) {
console.info(`SESSION NOT FOUND FOR URL: ${url}`);
}
return session;
}
/**
* FIXME.
*
* @param {Function | Object} stateful - FIXME.
* @returns {Object}
*/
export function getCurrentSession(stateful: Function | Object): ?Object {
const state = toState(stateful);
const { locationURL } = state['features/base/config'];
return getSession(state, toURLString(locationURL));
}

View File

@@ -0,0 +1,7 @@
export * from './actions';
export * from './actionTypes';
export * from './constants';
export * from './functions';
import './middleware';
import './reducer';

View File

@@ -0,0 +1,349 @@
// @flow
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_LEFT,
CONFERENCE_WILL_JOIN,
CONFERENCE_WILL_LEAVE,
JITSI_CONFERENCE_URL_KEY,
isRoomValid
} from '../../base/conference';
import {
CONNECTION_DISCONNECTED,
CONNECTION_FAILED,
CONNECTION_WILL_CONNECT
} from '../../base/connection';
import {
MiddlewareRegistry,
toState
} from '../../base/redux';
import { parseURIString, toURLString } from '../../base/util';
import {
SESSION_CONFIGURED,
SESSION_ENDED,
SESSION_FAILED,
SESSION_STARTED,
SESSION_WILL_END,
SESSION_WILL_START
} from './constants';
import { setSession } from './actions';
import { CONFIG_WILL_LOAD, LOAD_CONFIG_ERROR, SET_CONFIG } from '../config';
import { getCurrentSession, getSession } from './functions';
/**
* Middleware that maintains conference sessions. The features spans across
* three features strictly related to the conference lifecycle.
* The first one is the base/config which configures the session. It's
* 'locationURL' state is used to tell what's the current conference URL the app
* is working with. The session starts as soon as {@link CONFIG_WILL_LOAD} event
* arrives. The {@code locationURL} instance is stored in the session to
* associate the load config request with the session and be able to distinguish
* between the current and outdated load config request failures. After the
* config is loaded the lifecycle moves to the base/connection feature which
* creates a {@code JitsiConnection} and tries to connect to the server. On
* {@code CONNECTION_WILL_CONNECT} the connection instance is stored in the
* session and used later to filter the events similar to what's done for
* the load config requests. The base/conference feature adds the last part to
* the session's lifecycle. A {@code JitsiConference} instance is stored in the
* session on the {@code CONFERENCE_WILL_JOIN} action. A session is considered
* alive as long as either connection or conference is available and
* operational.
*
* @param {Store} store - Redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const result = next(action);
const { type } = action;
switch (type) {
case CONFERENCE_WILL_JOIN: {
const { conference } = action;
const { locationURL } = store.getState()['features/base/connection'];
const url = toURLString(locationURL);
const session = getSession(store, url);
if (session) {
store.dispatch(setSession({
url: session.url,
conference
}));
} else {
console.info(`IGNORED WILL_JOIN FOR: ${url}`);
}
break;
}
case CONFERENCE_JOINED: {
const { conference } = action;
const session = findSessionForConference(store, conference);
const state = session && session.state;
if (state === SESSION_CONFIGURED) {
store.dispatch(
setSession({
// Flow complains that the session can be undefined, but it
// can't if the state is defined.
// $FlowExpectedError
url: session.url,
state: SESSION_STARTED
}));
} else {
// eslint-disable-next-line max-len
console.info(`IGNORED CONF JOINED FOR: ${toURLString(conference[JITSI_CONFERENCE_URL_KEY])}`);
}
break;
}
case CONFERENCE_LEFT:
case CONFERENCE_FAILED: {
const { conference, error } = action;
const session = findSessionForConference(store, conference);
// FIXME update comments
// XXX Certain CONFERENCE_FAILED errors are recoverable i.e. they have
// prevented the user from joining a specific conference but the app may
// be able to eventually join the conference. For example, the app will
// ask the user for a password upon
// JitsiConferenceErrors.PASSWORD_REQUIRED and will retry joining the
// conference afterwards. Such errors are to not reach the native
// counterpart of the External API (or at least not in the
// fatality/finality semantics attributed to
// conferenceFailed:/onConferenceFailed).
if (session) {
if (!error || isGameOver(store, session, error)) {
if (session.connection) {
store.dispatch(
setSession({
url: session.url,
conference: undefined
}));
} else {
store.dispatch(
setSession({
url: session.url,
state: error ? SESSION_FAILED : SESSION_ENDED,
error
}));
}
}
} else {
// eslint-disable-next-line max-len
console.info(`IGNORED FAILED/LEFT for ${toURLString(conference[JITSI_CONFERENCE_URL_KEY])}`, error);
}
break;
}
// NOTE WILL_JOIN is fired on SET_ROOM
// case CONFERENCE_WILL_JOIN:
case CONFERENCE_WILL_LEAVE: {
const { conference } = action;
const url = toURLString(conference[JITSI_CONFERENCE_URL_KEY]);
const session = findSessionForConference(store, conference);
const state = session && session.state;
if (state && state !== SESSION_WILL_END) {
store.dispatch(
setSession({
// Flow complains that the session can be undefined, but it
// can't if the state is defined.
// $FlowExpectedError
url: session.url,
state: SESSION_WILL_END
}));
} else {
console.info(`IGNORED WILL LEAVE FOR ${url}`);
}
break;
}
case CONNECTION_WILL_CONNECT: {
const { connection } = action;
const { locationURL } = store.getState()['features/base/connection'];
const url = toURLString(locationURL);
const session = getSession(store, url);
if (session) {
store.dispatch(
setSession({
url: session.url,
connection,
conference: undefined // Detach from the old conference
}));
} else {
console.info(`IGNORED CONNECTION_WILL_CONNECT FOR: ${url}`);
}
break;
}
case CONNECTION_DISCONNECTED:
case CONNECTION_FAILED: {
const { connection, error } = action;
const session = findSessionForConnection(store, connection);
if (session) {
// Remove connection from the session, but wait for
// the conference to be removed as well.
if (!error || isGameOver(store, session, error)) {
if (session.conference) {
store.dispatch(
setSession({
url: session.url,
connection: undefined
}));
} else {
store.dispatch(
setSession({
url: session.url,
state: error ? SESSION_FAILED : SESSION_ENDED,
error
}));
}
}
} else {
console.info('Ignored DISCONNECTED/FAILED for connection');
}
break;
}
case SET_CONFIG: {
// XXX SET_CONFIG IS ALWAYS RELEVANT
const { locationURL } = store.getState()['features/base/config'];
const url = toURLString(locationURL);
const session = getSession(store, url);
const state = session && session.state;
if (state === SESSION_WILL_START) {
store.dispatch(
setSession({
url,
state: SESSION_CONFIGURED
}));
}
break;
}
case CONFIG_WILL_LOAD: {
const { locationURL } = action;
const url = toURLString(locationURL);
const session = getSession(store, url);
// The back and forth to string conversion is here, because there's no
// guarantee that the locationURL will be the exact custom structure
// which contains the room property.
let { room } = parseURIString(url);
// Validate the room
room = isRoomValid(room) ? room : undefined;
if (room && !session) {
store.dispatch(
setSession({
url,
state: SESSION_WILL_START,
locationURL,
room
}));
} else if (room && session) {
// Update to the new locationURL instance
store.dispatch(
setSession({
url,
locationURL
}));
} else {
console.info(`IGNORED CFG WILL LOAD FOR ${url}`);
}
break;
}
case LOAD_CONFIG_ERROR: {
const { error, locationURL } = action;
const url = toURLString(locationURL);
const session = getSession(store, url);
if (session && session.locationURL === locationURL) {
if (isGameOver(store, session, error)) {
store.dispatch(
setSession({
url,
state: SESSION_FAILED,
error
}));
}
} else {
console.info(`IGNORED LOAD_CONFIG_ERROR FOR: ${url}`);
}
break;
}
}
return result;
});
/**
* FIXME A session is to be terminated either when the recoverable flag is
* explicitly set to {@code false} or if the error arrives for a session which
* is no longer current (the app has started working with another session).
* This can happen when a conference which is being disconnected fails in which
* case the session needs to be ended even if the flag is not {@code false}
* because we know that there's no fatal error handling. This is kind of
* a contract between the fatal error feature and the session which probably
* indicates that the fatal error detection and handling should be incorporated
* into the session feature.
*
* @param {Object | Function} stateful - FIXME.
* @param {Object} session - FIXME.
* @param {Object} error - FIXME.
* @returns {boolean}
*/
function isGameOver(stateful, session, error) {
return getCurrentSession(stateful) !== session
|| error.recoverable === false;
}
/**
* FIXME.
*
* @param {Object | Function} stateful - FIXME.
* @param {JitsiConnection} connection - FIXME.
* @returns {Object|undefined}
*/
function findSessionForConnection(stateful, connection) {
const state = toState(stateful);
for (const session of state['features/base/session'].values()) {
if (session.connection === connection) {
return session;
}
}
console.info('Session not found for a connection');
return undefined;
}
/**
* FIXME.
*
* @param {Object | Function} stateful - FIXME.
* @param {JitsiConference} conference - FIXME.
* @returns {Object|undefined}
*/
function findSessionForConference(stateful, conference) {
const state = toState(stateful);
for (const session of state['features/base/session'].values()) {
if (session.conference === conference) {
return session;
}
}
console.info('Session not found for a conference');
return undefined;
}

View File

@@ -0,0 +1,71 @@
// @flow
import { assign, ReducerRegistry } from '../../base/redux';
import { getSymbolDescription } from '../util';
import { SET_SESSION } from './actionTypes';
import {
SESSION_FAILED,
SESSION_ENDED,
SESSION_WILL_START
} from './constants';
ReducerRegistry.register('features/base/session',
(state = new Map(), action) => {
switch (action.type) {
case SET_SESSION:
return _setSession(state, action);
}
return state;
});
/**
* FIXME.
*
* @param {Object} featureState - FIXME.
* @param {Object} action - FIXME.
* @returns {Map<any, any>} - FIXME.
* @private
*/
function _setSession(featureState, action) {
const { url, state, ...data } = action.session;
const session = featureState.get(url);
const nextState = new Map(featureState);
// Drop the whole action if the url is not defined
if (!url) {
console.error('SET SESSION - NO URL');
return nextState;
}
if (session) {
if (state === SESSION_ENDED || state === SESSION_FAILED) {
nextState.delete(url);
} else {
nextState.set(
url,
assign(session, {
url,
state: state ? state : session.state,
...data
}));
}
} else if (state === SESSION_WILL_START) {
nextState.set(
url, {
url,
state,
...data
});
}
console.info(
'SESSION STATE REDUCED: ',
new Map(nextState),
url,
state && getSymbolDescription(state),
action.session.error);
return nextState;
}

View File

@@ -138,7 +138,7 @@ function _initSettings(featureState) {
if (settings.audioOutputDeviceId
!== JitsiMeetJS.mediaDevices.getAudioOutputDevice()) {
JitsiMeetJS.mediaDevices.setAudioOutputDevice(
audioOutputDeviceId
settings.audioOutputDeviceId
).catch(ex => {
logger.warn('Failed to set audio output device from local '
+ 'storage. Default audio output device will be used'

View File

@@ -20,6 +20,15 @@ const throttledPersistState
state => PersistenceRegistry.persistState(state),
PERSIST_STATE_DELAY);
// Web only code.
// We need the <tt>if</tt> beacuse it appears that on mobile the polyfill is not
// executed yet.
if (typeof window.addEventListener === 'function') {
window.addEventListener('unload', () => {
throttledPersistState.flush();
});
}
/**
* A master MiddleWare to selectively persist state. Please use the
* {@link persisterconfig.json} to set which subtrees of the redux state should

View File

@@ -233,7 +233,7 @@ export function setTrackMuted(track, muted) {
// Track might be already disposed so ignore such an error.
if (error.name !== JitsiTrackErrors.TRACK_IS_DISPOSED) {
// FIXME Emit mute failed, so that the app can show error dialog.
console.error(`set track ${f} failed`, error);
logger.error(`set track ${f} failed`, error);
}
});
}

View File

@@ -1,5 +1,7 @@
// @flow
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The app linking scheme.
* TODO: This should be read from the manifest files later.
@@ -174,7 +176,7 @@ function _objectToURLParamsArray(obj = {}) {
params.push(
`${key}=${encodeURIComponent(JSON.stringify(obj[key]))}`);
} catch (e) {
console.warn(`Error encoding ${key}: ${e}`);
logger.warn(`Error encoding ${key}: ${e}`);
}
}

View File

@@ -4,18 +4,22 @@ import {
Transport
} from '../../../modules/transport';
import { createDeviceChangedEvent, sendAnalytics } from '../analytics';
import {
getAudioOutputDeviceId,
setAudioInputDevice,
setAudioOutputDevice,
setAudioOutputDeviceId,
setVideoInputDevice
} from '../base/devices';
import { i18next } from '../base/i18n';
import JitsiMeetJS from '../base/lib-jitsi-meet';
import { updateSettings } from '../base/settings';
import { SET_DEVICE_SELECTION_POPUP_DATA } from './actionTypes';
import { getDeviceSelectionDialogProps } from './functions';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Opens a popup window with the device selection dialog in it.
*
@@ -115,23 +119,22 @@ function _processRequest(dispatch, getState, request, responseCallback) { // esl
responseCallback(getState()['features/base/devices']);
break;
case 'setDevice': {
let action;
const { device } = request;
switch (device.kind) {
case 'audioinput':
action = setAudioInputDevice;
dispatch(setAudioInputDevice(device.id));
break;
case 'audiooutput':
action = setAudioOutputDevice;
setAudioOutputDeviceId(device.id, dispatch);
break;
case 'videoinput':
action = setVideoInputDevice;
dispatch(setVideoInputDevice(device.id));
break;
default:
}
dispatch(action(device.id));
responseCallback(true);
break;
}
@@ -179,6 +182,10 @@ export function submitDeviceSelectionTab(newState) {
if (newState.selectedVideoInputId
&& newState.selectedVideoInputId
!== currentState.selectedVideoInputId) {
dispatch(updateSettings({
cameraDeviceId: newState.selectedVideoInputId
}));
dispatch(
setVideoInputDevice(newState.selectedVideoInputId));
}
@@ -186,6 +193,10 @@ export function submitDeviceSelectionTab(newState) {
if (newState.selectedAudioInputId
&& newState.selectedAudioInputId
!== currentState.selectedAudioInputId) {
dispatch(updateSettings({
micDeviceId: newState.selectedAudioInputId
}));
dispatch(
setAudioInputDevice(newState.selectedAudioInputId));
}
@@ -193,8 +204,19 @@ export function submitDeviceSelectionTab(newState) {
if (newState.selectedAudioOutputId
&& newState.selectedAudioOutputId
!== currentState.selectedAudioOutputId) {
dispatch(
setAudioOutputDevice(newState.selectedAudioOutputId));
sendAnalytics(createDeviceChangedEvent('audio', 'output'));
setAudioOutputDeviceId(
newState.selectedAudioOutputId,
dispatch)
.then(() => logger.log('changed audio output device'))
.catch(err => {
logger.warn(
'Failed to change audio output device.',
'Default or previously set audio output device will',
' be used instead.',
err);
});
}
};
}

View File

@@ -12,6 +12,8 @@ import AudioOutputPreview from './AudioOutputPreview';
import DeviceSelector from './DeviceSelector';
import VideoInputPreview from './VideoInputPreview';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The type of the React {@code Component} props of {@link DeviceSelection}.
*/
@@ -64,6 +66,12 @@ export type Props = {
*/
hideAudioOutputSelect: boolean,
/**
* An optional callback to invoke after the component has completed its
* mount logic.
*/
mountCallback?: Function,
/**
* The id of the audio input device to preview.
*/
@@ -134,8 +142,12 @@ class DeviceSelection extends AbstractDialogTab<Props, State> {
* @inheritdoc
*/
componentDidMount() {
this._createAudioInputTrack(this.props.selectedAudioInputId);
this._createVideoInputTrack(this.props.selectedVideoInputId);
Promise.all([
this._createAudioInputTrack(this.props.selectedAudioInputId),
this._createVideoInputTrack(this.props.selectedVideoInputId)
])
.catch(err => logger.warn('Failed to initialize preview tracks', err))
.then(() => this.props.mountCallback && this.props.mountCallback());
}
/**
@@ -212,7 +224,7 @@ class DeviceSelection extends AbstractDialogTab<Props, State> {
* @returns {void}
*/
_createAudioInputTrack(deviceId) {
this._disposeAudioInputPreview()
return this._disposeAudioInputPreview()
.then(() => createLocalTrack('audio', deviceId))
.then(jitsiLocalTrack => {
this.setState({
@@ -234,7 +246,7 @@ class DeviceSelection extends AbstractDialogTab<Props, State> {
* @returns {void}
*/
_createVideoInputTrack(deviceId) {
this._disposeVideoInputPreview()
return this._disposeVideoInputPreview()
.then(() => createLocalTrack('video', deviceId))
.then(jitsiLocalTrack => {
if (!jitsiLocalTrack) {

View File

@@ -0,0 +1,19 @@
/**
* The type of Redux action which changes Google API state.
*
* {
* type: SET_GOOGLE_API_STATE
* }
* @public
*/
export const SET_GOOGLE_API_STATE = Symbol('SET_GOOGLE_API_STATE');
/**
* The type of Redux action which changes Google API profile state.
*
* {
* type: SET_GOOGLE_API_PROFILE
* }
* @public
*/
export const SET_GOOGLE_API_PROFILE = Symbol('SET_GOOGLE_API_PROFILE');

View File

@@ -0,0 +1,139 @@
/* @flow */
import {
SET_GOOGLE_API_PROFILE,
SET_GOOGLE_API_STATE
} from './actionTypes';
import { GOOGLE_API_STATES } from './constants';
import googleApi from './googleApi';
/**
* Loads Google API.
*
* @param {string} clientId - The client ID to be used with the API library.
* @returns {Function}
*/
export function loadGoogleAPI(clientId: string) {
return (dispatch: Dispatch<*>) =>
googleApi.get()
.then(() => googleApi.initializeClient(clientId))
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.LOADED }))
.then(() => googleApi.isSignedIn())
.then(isSignedIn => {
if (isSignedIn) {
dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN });
}
});
}
/**
* Prompts the participant to sign in to the Google API Client Library.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function signIn() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}));
}
/**
* Updates the profile data that is currently used.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function updateProfile() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}))
.then(() => googleApi.getCurrentUserProfile())
.then(profile => dispatch({
type: SET_GOOGLE_API_PROFILE,
profileEmail: profile.getEmail()
}));
}
/**
* Executes a request for a list of all YouTube broadcasts associated with
* user currently signed in to the Google API Client Library.
*
* @returns {function(): (Promise<*>|Promise<any[] | never>)}
*/
export function requestAvailableYouTubeBroadcasts() {
return () =>
googleApi.requestAvailableYouTubeBroadcasts()
.then(response => {
// Takes in a list of broadcasts from the YouTube API,
// removes dupes, removes broadcasts that cannot get a stream key,
// and parses the broadcasts into flat objects.
const broadcasts = response.result.items;
const parsedBroadcasts = {};
for (let i = 0; i < broadcasts.length; i++) {
const broadcast = broadcasts[i];
const boundStreamID = broadcast.contentDetails.boundStreamId;
if (boundStreamID && !parsedBroadcasts[boundStreamID]) {
parsedBroadcasts[boundStreamID] = {
boundStreamID,
id: broadcast.id,
status: broadcast.status.lifeCycleStatus,
title: broadcast.snippet.title
};
}
}
return Object.values(parsedBroadcasts);
});
}
/**
* Fetches the stream key for a YouTube broadcast and updates the internal
* state to display the associated stream key as being entered.
*
* @param {string} boundStreamID - The bound stream ID associated with the
* broadcast from which to get the stream key.
* @returns {function(): (Promise<*>|Promise<{
* streamKey: (*|string),
* selectedBoundStreamID: *} | never>)}
*/
export function requestLiveStreamsForYouTubeBroadcast(boundStreamID: string) {
return () =>
googleApi.requestLiveStreamsForYouTubeBroadcast(boundStreamID)
.then(response => {
const broadcasts = response.result.items;
const streamName = broadcasts
&& broadcasts[0]
&& broadcasts[0].cdn.ingestionInfo.streamName;
const streamKey = streamName || '';
return {
streamKey,
selectedBoundStreamID: boundStreamID
};
});
}
/**
* Forces the Google web client application to prompt for a sign in, such as
* when changing account, and will then fetch available YouTube broadcasts.
*
* @returns {function(): (Promise<*>|Promise<{
* streamKey: (*|string),
* selectedBoundStreamID: *} | never>)}
*/
export function showAccountSelection() {
return () =>
googleApi.showAccountSelection();
}

View File

@@ -0,0 +1,33 @@
// @flow
/**
* The Google API scopes to request access to for streaming.
*
* @type {Array<string>}
*/
export const GOOGLE_API_SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly'
];
/**
* An enumeration of the different states the Google API can be in.
*
* @private
* @type {Object}
*/
export const GOOGLE_API_STATES = {
/**
* The state in which the Google API still needs to be loaded.
*/
NEEDS_LOADING: 0,
/**
* The state in which the Google API is loaded and ready for use.
*/
LOADED: 1,
/**
* The state in which a user has been logged in through the Google API.
*/
SIGNED_IN: 2
};

View File

@@ -0,0 +1,5 @@
export { GOOGLE_API_STATES } from './constants';
export * from './googleApi';
export * from './actions';
import './reducer';

View File

@@ -0,0 +1,40 @@
// @flow
import { ReducerRegistry } from '../base/redux';
import {
SET_GOOGLE_API_PROFILE,
SET_GOOGLE_API_STATE
} from './actionTypes';
import { GOOGLE_API_STATES } from './constants';
/**
* The default state is the Google API needs loading.
*
* @type {{googleAPIState: number}}
*/
const DEFAULT_STATE = {
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
profileEmail: ''
};
/**
* Reduces the Redux actions of the feature features/google-api.
*/
ReducerRegistry.register('features/google-api',
(state = DEFAULT_STATE, action) => {
switch (action.type) {
case SET_GOOGLE_API_STATE:
return {
...state,
googleAPIState: action.googleAPIState
};
case SET_GOOGLE_API_PROFILE:
return {
...state,
profileEmail: action.profileEmail
};
}
return state;
});

View File

@@ -1,4 +1,4 @@
/* @flow */
// @flow
import { NativeModules } from 'react-native';
@@ -11,6 +11,9 @@ import {
} from '../../base/conference';
import { MiddlewareRegistry } from '../../base/redux';
const { AudioMode } = NativeModules;
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Middleware that captures conference actions and sets the correct audio mode
* based on the type of conference. Audio-only conferences don't use the speaker
@@ -20,7 +23,7 @@ import { MiddlewareRegistry } from '../../base/redux';
* @returns {Function}
*/
MiddlewareRegistry.register(({ getState }) => next => action => {
const AudioMode = NativeModules.AudioMode;
const result = next(action);
if (AudioMode) {
let mode;
@@ -43,13 +46,13 @@ MiddlewareRegistry.register(({ getState }) => next => action => {
*/
case CONFERENCE_JOINED:
case SET_AUDIO_ONLY: {
if (getState()['features/base/conference'].conference
|| action.conference) {
mode
= action.audioOnly
? AudioMode.AUDIO_CALL
: AudioMode.VIDEO_CALL;
}
const { audioOnly, conference }
= getState()['features/base/conference'];
conference
&& (mode = audioOnly
? AudioMode.AUDIO_CALL
: AudioMode.VIDEO_CALL);
break;
}
}
@@ -57,10 +60,10 @@ MiddlewareRegistry.register(({ getState }) => next => action => {
if (typeof mode !== 'undefined') {
AudioMode.setMode(mode)
.catch(err =>
console.error(
logger.error(
`Failed to set audio mode ${String(mode)}: ${err}`));
}
}
return next(action);
return result;
});

View File

@@ -5,14 +5,7 @@ import uuid from 'uuid';
import { createTrackMutedEvent, sendAnalytics } from '../../analytics';
import { appNavigate, getName } from '../../app';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app';
import {
CONFERENCE_FAILED,
CONFERENCE_LEFT,
CONFERENCE_WILL_JOIN,
CONFERENCE_JOINED,
SET_AUDIO_ONLY,
getCurrentConference
} from '../../base/conference';
import { SET_AUDIO_ONLY } from '../../base/conference';
import { getInviteURL } from '../../base/connection';
import {
MEDIA_TYPE,
@@ -20,6 +13,16 @@ import {
setAudioMuted
} from '../../base/media';
import { MiddlewareRegistry } from '../../base/redux';
import {
SESSION_CONFIGURED,
SESSION_ENDED,
SESSION_FAILED,
SESSION_STARTED,
SET_SESSION,
getCurrentSession,
getSession,
setSession
} from '../../base/session';
import {
TRACK_ADDED,
TRACK_REMOVED,
@@ -51,21 +54,12 @@ CallKit && MiddlewareRegistry.register(store => next => action => {
});
break;
case CONFERENCE_FAILED:
return _conferenceFailed(store, next, action);
case CONFERENCE_JOINED:
return _conferenceJoined(store, next, action);
case CONFERENCE_LEFT:
return _conferenceLeft(store, next, action);
case CONFERENCE_WILL_JOIN:
return _conferenceWillJoin(store, next, action);
case SET_AUDIO_ONLY:
return _setAudioOnly(store, next, action);
case SET_SESSION:
return _setSession(store, next, action);
case TRACK_ADDED:
case TRACK_REMOVED:
case TRACK_UPDATED:
@@ -127,6 +121,35 @@ function _appWillMount({ dispatch, getState }, next, action) {
return result;
}
/**
* FIXME.
*
* @param {Store}store - FIXME.
* @param {Dispatch} next - FIXME.
* @param {Action} action - FIXME.
* @returns {*} The value returned by {@code next(action)}.
* @private
*/
function _setSession(store, next, action) {
const { state } = action.session;
switch (state) {
case SESSION_CONFIGURED:
return _sessionConfigured(store, next, action);
case SESSION_ENDED:
return _sessionEnded(store, next, action);
case SESSION_FAILED:
return _sessionFailed(store, next, action);
case SESSION_STARTED:
return _sessionJoined(store, next, action);
}
return next(action);
}
/**
* Notifies the feature callkit that the action {@link CONFERENCE_FAILED} is
* being dispatched within a specific redux {@code store}.
@@ -140,21 +163,14 @@ function _appWillMount({ dispatch, getState }, next, action) {
* @private
* @returns {*} The value returned by {@code next(action)}.
*/
function _conferenceFailed(store, next, action) {
const result = next(action);
function _sessionFailed(store, next, action) {
const callUUID = _getCallUUIDForSessionAction(store, action);
// XXX Certain CONFERENCE_FAILED errors are recoverable i.e. they have
// prevented the user from joining a specific conference but the app may be
// able to eventually join the conference.
if (!action.error.recoverable) {
const { callUUID } = action.conference;
if (callUUID) {
CallKit.reportCallFailed(callUUID);
}
if (callUUID) {
CallKit.reportCallFailed(callUUID);
}
return result;
return next(action);
}
/**
@@ -170,16 +186,14 @@ function _conferenceFailed(store, next, action) {
* @private
* @returns {*} The value returned by {@code next(action)}.
*/
function _conferenceJoined(store, next, action) {
const result = next(action);
const { callUUID } = action.conference;
function _sessionJoined(store, next, action) {
const callUUID = _getCallUUIDForSessionAction(store, action);
if (callUUID) {
CallKit.reportConnectedOutgoingCall(callUUID);
}
return result;
return next(action);
}
/**
@@ -195,16 +209,34 @@ function _conferenceJoined(store, next, action) {
* @private
* @returns {*} The value returned by {@code next(action)}.
*/
function _conferenceLeft(store, next, action) {
const result = next(action);
const { callUUID } = action.conference;
function _sessionEnded(store, next, action) {
const callUUID = _getCallUUIDForSessionAction(store, action);
if (callUUID) {
CallKit.endCall(callUUID);
}
return result;
return next(action);
}
/**
* FIXME.
*
* @param {Store} store - FIXME.
* @param {Object} action - FIXME.
* @returns {string|undefined}
* @private
*/
function _getCallUUIDForSessionAction(store, action) {
const url = action.session.url;
const session = getSession(store, url);
const callUUID = session && session.callkit && session.callkit.callUUID;
if (!callUUID) {
console.info(`CALLKIT SESSION NOT FOUND FOR URL: ${url}`);
}
return callUUID;
}
/**
@@ -220,27 +252,32 @@ function _conferenceLeft(store, next, action) {
* @private
* @returns {*} The value returned by {@code next(action)}.
*/
function _conferenceWillJoin({ getState }, next, action) {
const result = next(action);
const { conference } = action;
function _sessionConfigured({ getState }, next, action) {
const state = getState();
const { callHandle, callUUID } = state['features/base/config'];
const { callHandle, callUUID: _callUUID } = state['features/base/config'];
const url = getInviteURL(state);
const handle = callHandle || url.toString();
const hasVideo = !isVideoMutedByAudioOnly(state);
// When assigning the call UUID, do so in upper case, since iOS will return
// it upper cased.
conference.callUUID = (callUUID || uuid.v4()).toUpperCase();
const callUUID = (_callUUID || uuid.v4()).toUpperCase();
CallKit.startCall(conference.callUUID, handle, hasVideo)
// Store the callUUID in the session
action.session.callkit = {
callUUID
};
CallKit.startCall(callUUID, handle, hasVideo)
.then(() => {
const session = getSession(getState(), action.session.url);
const { callee } = state['features/base/jwt'];
const displayName
= state['features/base/config'].callDisplayName
|| (callee && callee.name)
|| state['features/base/conference'].room;
|| (session && session.room);
console.info(`CALLKIT WILL USE NAME: ${displayName}`);
const muted
= isLocalTrackMuted(
@@ -248,11 +285,11 @@ function _conferenceWillJoin({ getState }, next, action) {
MEDIA_TYPE.AUDIO);
// eslint-disable-next-line object-property-newline
CallKit.updateCall(conference.callUUID, { displayName, hasVideo });
CallKit.setMuted(conference.callUUID, muted);
CallKit.updateCall(callUUID, { displayName, hasVideo });
CallKit.setMuted(callUUID, muted);
});
return result;
return next(action);
}
/**
@@ -263,18 +300,47 @@ function _conferenceWillJoin({ getState }, next, action) {
* @returns {void}
*/
function _onPerformEndCallAction({ callUUID }) {
const { dispatch, getState } = this; // eslint-disable-line no-invalid-this
const conference = getCurrentConference(getState);
const { dispatch } = this; // eslint-disable-line no-invalid-this
// eslint-disable-next-line max-len
const session = _findSessionForCallUUID(this, callUUID); // eslint-disable-line no-invalid-this
if (conference && conference.callUUID === callUUID) {
if (session) {
// We arrive here when a call is ended by the system, for example, when
// another incoming call is received and the user selects "End &
// Accept".
delete conference.callUUID;
dispatch(
setSession({
url: session.url,
callkit: undefined
}));
dispatch(appNavigate(undefined));
}
}
/**
* FIXME.
*
* @param {Store} getState - FIXME.
* @param {string} callUUID - FIXME.
* @returns {Object|null}
* @private
*/
function _findSessionForCallUUID({ getState }, callUUID) {
const sessions = getState()['features/base/session'];
for (const session of sessions.values()) {
const _callUUID = session.callkit && session.callkit.callUUID;
if (callUUID === _callUUID) {
return session;
}
}
console.info(`SESSION NOT FOUND FOR CALL ID: ${callUUID}`);
return null;
}
/**
* Handles CallKit's event {@code performSetMutedCallAction}.
*
@@ -283,10 +349,11 @@ function _onPerformEndCallAction({ callUUID }) {
* @returns {void}
*/
function _onPerformSetMutedCallAction({ callUUID, muted }) {
const { dispatch, getState } = this; // eslint-disable-line no-invalid-this
const conference = getCurrentConference(getState);
const { dispatch } = this; // eslint-disable-line no-invalid-this
// eslint-disable-next-line max-len
const session = _findSessionForCallUUID(this, callUUID); // eslint-disable-line no-invalid-this
if (conference && conference.callUUID === callUUID) {
if (session) {
muted = Boolean(muted); // eslint-disable-line no-param-reassign
sendAnalytics(createTrackMutedEvent('audio', 'callkit', muted));
dispatch(setAudioMuted(muted, /* ensureTrack */ true));
@@ -316,11 +383,11 @@ function _onPerformSetMutedCallAction({ callUUID, muted }) {
function _setAudioOnly({ getState }, next, action) {
const result = next(action);
const state = getState();
const conference = getCurrentConference(state);
const session = getCurrentSession(state);
if (conference && conference.callUUID) {
if (session && session.callUUID) {
CallKit.updateCall(
conference.callUUID,
session.callUUID,
{ hasVideo: !action.audioOnly });
}
@@ -369,20 +436,25 @@ function _syncTrackState({ getState }, next, action) {
const result = next(action);
const { jitsiTrack } = action.track;
const state = getState();
const conference = getCurrentConference(state);
if (jitsiTrack.isLocal() && conference && conference.callUUID) {
// It could go over all sessions here, but even if we'd support simultaneous
// sessions / putting on hold, probably only the active session would be
// holding the tracks.
const session = getCurrentSession(state);
const callUUID = session && session.callkit && session.callkit.callUUID;
if (jitsiTrack.isLocal() && callUUID) {
switch (jitsiTrack.getType()) {
case 'audio': {
const tracks = state['features/base/tracks'];
const muted = isLocalTrackMuted(tracks, MEDIA_TYPE.AUDIO);
CallKit.setMuted(conference.callUUID, muted);
CallKit.setMuted(callUUID, muted);
break;
}
case 'video': {
CallKit.updateCall(
conference.callUUID,
callUUID,
{ hasVideo: !isVideoMutedByAudioOnly(state) });
break;
}

View File

@@ -1,22 +1,56 @@
// @flow
import { NativeModules } from 'react-native';
import { getAppProp } from '../../app';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_LEFT,
CONFERENCE_WILL_JOIN,
CONFERENCE_WILL_LEAVE,
JITSI_CONFERENCE_URL_KEY,
SET_ROOM,
forEachConference,
isRoomValid
CONFERENCE_WILL_LEAVE
} from '../../base/conference';
import { LOAD_CONFIG_ERROR } from '../../base/config';
import { CONNECTION_FAILED } from '../../base/connection';
import { MiddlewareRegistry } from '../../base/redux';
import { getSymbolDescription, toURLString } from '../../base/util';
import {
SESSION_ENDED,
SESSION_FAILED,
SESSION_STARTED,
SESSION_WILL_END,
SESSION_WILL_START,
SET_SESSION
} from '../../base/session';
import { getSymbolDescription } from '../../base/util';
import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture';
/**
* FIXME.
*
* @param {Symbol} state - FIXME.
* @returns {string}
* @private
*/
function _stateToApiEventName(state) {
switch (state) {
case SESSION_WILL_START:
return getSymbolDescription(CONFERENCE_WILL_JOIN);
case SESSION_STARTED:
return getSymbolDescription(CONFERENCE_JOINED);
case SESSION_WILL_END:
return getSymbolDescription(CONFERENCE_WILL_LEAVE);
case SESSION_ENDED:
return getSymbolDescription(CONFERENCE_LEFT);
case SESSION_FAILED:
return getSymbolDescription(CONFERENCE_FAILED);
default:
return undefined;
}
}
import { sendEvent } from './functions';
/**
@@ -27,63 +61,19 @@ import { sendEvent } from './functions';
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
action.type && console.info(`ACTION ${getSymbolDescription(action.type)}`);
const result = next(action);
const { type } = action;
switch (type) {
case CONFERENCE_FAILED: {
const { error, ...data } = action;
// XXX Certain CONFERENCE_FAILED errors are recoverable i.e. they have
// prevented the user from joining a specific conference but the app may
// be able to eventually join the conference. For example, the app will
// ask the user for a password upon
// JitsiConferenceErrors.PASSWORD_REQUIRED and will retry joining the
// conference afterwards. Such errors are to not reach the native
// counterpart of the External API (or at least not in the
// fatality/finality semantics attributed to
// conferenceFailed:/onConferenceFailed).
if (!error.recoverable) {
_sendConferenceEvent(store, /* action */ {
error: _toErrorString(error),
...data
});
}
break;
}
case CONFERENCE_JOINED:
case CONFERENCE_LEFT:
case CONFERENCE_WILL_JOIN:
case CONFERENCE_WILL_LEAVE:
_sendConferenceEvent(store, action);
break;
case CONNECTION_FAILED:
!action.error.recoverable
&& _sendConferenceFailedOnConnectionError(store, action);
case SET_SESSION:
_setSession(store, action);
break;
case ENTER_PICTURE_IN_PICTURE:
sendEvent(store, getSymbolDescription(type), /* data */ {});
break;
case LOAD_CONFIG_ERROR: {
const { error, locationURL } = action;
sendEvent(
store,
getSymbolDescription(type),
/* data */ {
error: _toErrorString(error),
url: toURLString(locationURL)
});
break;
}
case SET_ROOM:
_maybeTriggerEarlyConferenceWillJoin(store, action);
break;
}
return result;
@@ -110,145 +100,47 @@ function _toErrorString(
}
/**
* If {@link SET_ROOM} action happens for a valid conference room this method
* will emit an early {@link CONFERENCE_WILL_JOIN} event to let the external API
* know that a conference is being joined. Before that happens a connection must
* be created and only then base/conference feature would emit
* {@link CONFERENCE_WILL_JOIN}. That is fine for the Jitsi Meet app, because
* that's the a conference instance gets created, but it's too late for
* the external API to learn that. The latter {@link CONFERENCE_WILL_JOIN} is
* swallowed in {@link _swallowEvent}.
*
* @param {Store} store - The redux store.
* @param {Action} action - The redux action.
* @returns {void}
*/
function _maybeTriggerEarlyConferenceWillJoin(store, action) {
const { locationURL } = store.getState()['features/base/connection'];
const { room } = action;
isRoomValid(room) && locationURL && sendEvent(
store,
getSymbolDescription(CONFERENCE_WILL_JOIN),
/* data */ {
url: toURLString(locationURL)
});
}
/**
* Sends an event to the native counterpart of the External API for a specific
* conference-related redux action.
*
* @param {Store} store - The redux store.
* @param {Action} action - The redux action.
* @returns {void}
*/
function _sendConferenceEvent(
store: Object,
action: {
conference: Object,
type: Symbol,
url: ?string
}) {
const { conference, type, ...data } = action;
// For these (redux) actions, conference identifies a JitsiConference
// instance. The external API cannot transport such an object so we have to
// transport an "equivalent".
if (conference) {
data.url = toURLString(conference[JITSI_CONFERENCE_URL_KEY]);
}
_swallowEvent(store, action, data)
|| sendEvent(store, getSymbolDescription(type), data);
}
/**
* Sends {@link CONFERENCE_FAILED} event when the {@link CONNECTION_FAILED}
* occurs. It should be done only if the connection fails before the conference
* instance is created. Otherwise the eventual failure event is supposed to be
* emitted by the base/conference feature.
*
* @param {Store} store - The redux store.
* @param {Action} action - The redux action.
* @returns {void}
*/
function _sendConferenceFailedOnConnectionError(store, action) {
const { locationURL } = store.getState()['features/base/connection'];
const { connection } = action;
locationURL
&& forEachConference(
store,
// If there's any conference in the base/conference state then the
// base/conference feature is supposed to emit a failure.
conference => conference.getConnection() !== connection)
&& sendEvent(
store,
getSymbolDescription(CONFERENCE_FAILED),
/* data */ {
url: toURLString(locationURL),
error: action.error.name
});
}
/**
* Determines whether to not send a {@code CONFERENCE_LEFT} event to the native
* counterpart of the External API.
* Sends a specific event to the native counterpart of the External API. Native
* apps may listen to such events via the mechanisms provided by the (native)
* mobile Jitsi Meet SDK.
*
* @param {Object} store - The redux store.
* @param {Action} action - The redux action which is causing the sending of the
* event.
* @param {string} name - The name of the event to send.
* @param {Object} data - The details/specifics of the event to send determined
* by/associated with the specified {@code action}.
* @returns {boolean} If the specified event is to not be sent, {@code true};
* otherwise, {@code false}.
* by/associated with the specified {@code name}.
* @private
* @returns {void}
*/
function _swallowConferenceLeft({ getState }, action, { url }) {
// XXX Internally, we work with JitsiConference instances. Externally
// though, we deal with URL strings. The relation between the two is many to
// one so it's technically and practically possible (by externally loading
// the same URL string multiple times) to try to send CONFERENCE_LEFT
// externally for a URL string which identifies a JitsiConference that the
// app is internally legitimately working with.
let swallowConferenceLeft = false;
function _sendEvent(store: Object, name: string, data: Object) {
// The JavaScript App needs to provide uniquely identifying information to
// the native ExternalAPI module so that the latter may match the former to
// the native JitsiMeetView which hosts it.
const externalAPIScope = getAppProp(store, 'externalAPIScope');
url
&& forEachConference(getState, (conference, conferenceURL) => {
if (conferenceURL && conferenceURL.toString() === url) {
swallowConferenceLeft = true;
}
console.info(
`EXT EVENT ${name} URL: ${data.url} DATA: ${JSON.stringify(data)}`);
return !swallowConferenceLeft;
});
return swallowConferenceLeft;
externalAPIScope
&& NativeModules.ExternalAPI.sendEvent(name, data, externalAPIScope);
}
/**
* Determines whether to not send a specific event to the native counterpart of
* the External API.
* FIXME.
*
* @param {Object} store - The redux store.
* @param {Action} action - The redux action which is causing the sending of the
* event.
* @param {Object} data - The details/specifics of the event to send determined
* by/associated with the specified {@code action}.
* @returns {boolean} If the specified event is to not be sent, {@code true};
* otherwise, {@code false}.
* @param {Store} store - FIXME.
* @param {Action} action - FIXME.
* @returns {void}
* @private
*/
function _swallowEvent(store, action, data) {
switch (action.type) {
case CONFERENCE_LEFT:
return _swallowConferenceLeft(store, action, data);
case CONFERENCE_WILL_JOIN:
// CONFERENCE_WILL_JOIN is dispatched to the external API on SET_ROOM,
// before the connection is created, so we need to swallow the original
// one emitted by base/conference.
return true;
function _setSession(store, action) {
const { error, state, url } = action.session;
const apiEventName = _stateToApiEventName(state);
default:
return false;
}
apiEventName && _sendEvent(
store,
apiEventName,
/* data */ {
url,
error: error && _toErrorString(error)
});
}

View File

@@ -1,31 +0,0 @@
import { ImageCache } from './';
/**
* Notifies about the successful download of an {@code Image} source. The name
* is inspired by {@code Image}. The downloaded {@code Image} source is not
* available because (1) I do not know how to get it from {@link ImageCache} and
* (2) we do not need it bellow. The function was explicitly introduced to cut
* down on unnecessary {@code ImageCache} {@code observer} instances.
*
* @private
* @returns {void}
*/
function _onLoad() {
// ImageCache requires an observer; otherwise, we do not need it because we
// merely want to initiate the download and do not care what happens with it
// afterwards.
}
/**
* Initiates the retrieval of a specific {@code Image} source (if it has not
* been initiated already). Due to limitations of {@link ImageCache}, the source
* may have at most one {@code uri}. The name is inspired by {@code Image}.
*
* @param {Object} source - The {@code Image} source with preferably exactly
* one {@code uri}.
* @public
* @returns {void}
*/
export function prefetch(source) {
ImageCache && ImageCache.get().on(source, _onLoad, /* immutable */ true);
}

View File

@@ -1,4 +0,0 @@
export * from './functions';
export * from './react-native-img-cache';
import './middleware';

View File

@@ -1,91 +0,0 @@
/* @flow */
import { APP_WILL_MOUNT } from '../../base/app';
import {
getAvatarURL,
getLocalParticipant,
getParticipantById,
PARTICIPANT_ID_CHANGED,
PARTICIPANT_JOINED,
PARTICIPANT_UPDATED
} from '../../base/participants';
import { MiddlewareRegistry } from '../../base/redux';
import { ImageCache, prefetch } from './';
/**
* The indicator which determines whether avatar URLs are to be prefetched in
* the middleware here. Unless/until the implementation starts observing the
* redux store instead of the respective redux actions, the value should very
* likely be {@code false} because the middleware here is pretty much the last
* to get a chance to figure out that an avatar URL may be used. Besides, it is
* somewhat uninformed to download just about anything that may eventually be
* used or not.
*
* @private
* @type {boolean}
*/
const _PREFETCH_AVATAR_URLS = false;
/**
* Middleware which captures app startup and conference actions in order to
* clear the image cache.
*
* @returns {Function}
*/
MiddlewareRegistry.register(({ getState }) => next => action => {
switch (action.type) {
case APP_WILL_MOUNT:
// XXX CONFERENCE_FAILED/LEFT are no longer used here because they
// are tricky to get right as detectors of the moments in time at which
// CachedImage is not used. Anyway, if ImageCache is to be cleared from
// time to time, SET_LOCATION_URL is a much easier detector of such
// opportune times. Fixes at least one 100%-reproducible case of
// "TypeError: Cannot read property handlers of undefined." Anyway, in
// order to reduce the re-downloading of the same avatars, eventually we
// decided to not clear during the runtime of the app (other that at the
// beginning that is).
ImageCache && ImageCache.get().clear();
break;
case PARTICIPANT_ID_CHANGED:
case PARTICIPANT_JOINED:
case PARTICIPANT_UPDATED: {
if (!_PREFETCH_AVATAR_URLS) {
break;
}
const result = next(action);
// Initiate the downloads of participants' avatars as soon as possible.
// 1. Figure out the participant (instance).
let { participant } = action;
if (participant) {
if (participant.id) {
participant = getParticipantById(getState, participant.id);
} else if (participant.local) {
participant = getLocalParticipant(getState);
} else {
participant = undefined;
}
} else if (action.oldValue && action.newValue) {
participant = getParticipantById(getState, action.newValue);
}
if (participant) {
// 2. Get the participant's avatar URL.
const uri = getAvatarURL(participant);
if (uri) {
// 3. Initiate the download of the participant's avatar.
prefetch({ uri });
}
}
return result;
}
}
return next(action);
});

View File

@@ -1 +0,0 @@
export * from './react-native-img-cache.yes';

View File

@@ -1 +0,0 @@
export * from './react-native-img-cache.yes';

View File

@@ -1,16 +0,0 @@
// XXX The third-party react-native modules react-native-fetch-blob utilizes the
// same HTTP library as react-native i.e. okhttp. Unfortunately, that means that
// the versions of okhttp on which react-native and react-native-fetch-blob
// depend may have incompatible APIs. Such an incompatibility will be made
// apparent at compile time and the developer doing the compilation may choose
// to not compile react-native-fetch-blob's source code.
// XXX The choice between the use of react-native-img-cache could've been done
// at runtime based on whether NativeModules.RNFetchBlob is defined if only
// react-native-fetch-blob would've completely protected itself. At the time of
// this writing its source code appears to be attempting to protect itself from
// missing native binaries but that protection is incomplete and there's a
// TypeError.
import { Image } from 'react-native';
export { Image as CachedImage, undefined as ImageCache };

View File

@@ -1 +0,0 @@
export { CachedImage, ImageCache } from 'react-native-img-cache';

View File

@@ -7,6 +7,8 @@ import { Platform } from '../../base/react';
import { ENTER_PICTURE_IN_PICTURE } from './actionTypes';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Enters (or rather initiates entering) picture-in-picture.
* Helper function to enter PiP mode. This is triggered by user request
@@ -34,7 +36,7 @@ export function enterPictureInPicture() {
p.then(
() => dispatch({ type: ENTER_PICTURE_IN_PICTURE }),
e => console.warn(`Error entering PiP mode: ${e}`));
e => logger.warn(`Error entering PiP mode: ${e}`));
}
};
}

View File

@@ -1,3 +1,9 @@
import {
SESSION_FAILED,
getCurrentSession,
setSession
} from '../base/session';
import {
MEDIA_PERMISSION_PROMPT_VISIBILITY_CHANGED,
SET_FATAL_ERROR,
@@ -57,3 +63,37 @@ export function setFatalError(fatalError) {
fatalError
};
}
/**
* FIXME naming is not quite accurate - came from the previous method which was
* reemitting the action. I feel that this part needs more discussion. Changing
* it back to emitting the original action which caused the fatal error will
* also require changes to how it's being detected (currently through the state
* listener, but we'd have to go back to the middleware way).
*
* @returns {Function}
*/
export function reemitFatalError() {
return (dispatch, getState) => {
const state = getState();
const { fatalError } = state['features/overlay'];
if (fatalError) {
const session = getCurrentSession(state);
if (session) {
dispatch(
setSession({
url: session.url,
state: SESSION_FAILED,
error: fatalError
}));
} else {
console.info('No current session!');
}
dispatch(setFatalError(undefined));
} else {
console.info('NO FATAL ERROR');
}
};
}

View File

@@ -8,7 +8,7 @@ import { LoadingIndicator } from '../../base/react';
import AbstractPageReloadOverlay, { abstractMapStateToProps }
from './AbstractPageReloadOverlay';
import { setFatalError } from '../actions';
import { reemitFatalError } from '../actions';
import OverlayFrame from './OverlayFrame';
import { pageReloadOverlay as styles } from './styles';
@@ -41,7 +41,7 @@ class PageReloadOverlay extends AbstractPageReloadOverlay {
*/
_onCancel() {
clearInterval(this._interval);
this.props.dispatch(setFatalError(undefined));
this.props.dispatch(reemitFatalError());
this.props.dispatch(appNavigate(undefined));
}

View File

@@ -0,0 +1,112 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { appNavigate, getDefaultURL } from '../../app';
import { translate } from '../../base/i18n';
import { NavigateSectionList } from '../../base/react';
import type { Section } from '../../base/react';
import { isRecentListEnabled, toDisplayableList } from '../functions';
/**
* 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<Section>
};
/**
* The cross platform container rendering the list of the recently joined rooms.
*
*/
class RecentList extends Component<Props> {
/**
* Initializes a new {@code RecentList} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onPress = this._onPress.bind(this);
}
/**
* Implements the React Components's render method.
*
* @inheritdoc
*/
render() {
if (!isRecentListEnabled()) {
return null;
}
const { disabled, t, _defaultServerURL, _recentList } = this.props;
const recentList = toDisplayableList(_recentList, t, _defaultServerURL);
return (
<NavigateSectionList
disabled = { disabled }
onPress = { this._onPress }
sections = { recentList } />
);
}
_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));
}
}
/**
* Maps redux state to component props.
*
* @param {Object} state - The redux state.
* @returns {{
* _defaultServerURL: string,
* _recentList: Array
* }}
*/
export function _mapStateToProps(state: Object) {
return {
_defaultServerURL: getDefaultURL(state),
_recentList: state['features/recent-list']
};
}
export default translate(connect(_mapStateToProps)(RecentList));

View File

@@ -1,234 +0,0 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { appNavigate, getDefaultURL } from '../../app';
import {
getLocalizedDateFormatter,
getLocalizedDurationFormatter,
translate
} from '../../base/i18n';
import { NavigateSectionList } from '../../base/react';
import { parseURIString } from '../../base/util';
/**
* 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.
*
*/
class RecentList extends Component<Props> {
/**
* Initializes a new {@code RecentList} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
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 the React Components's render method.
*
* @inheritdoc
*/
render() {
const { disabled } = this.props;
return (
<NavigateSectionList
disabled = { disabled }
onPress = { this._onPress }
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));
}
_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.
*
* @private
* @returns {Array<Object>}
*/
_toDisplayableList() {
const { _recentList, t } = this.props;
const { createSection } = NavigateSectionList;
const todaySection = createSection(t('recentList.today'), 'today');
const yesterdaySection
= createSection(t('recentList.yesterday'), 'yesterday');
const earlierSection
= 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;
/**
* Generates a date string for the item.
*
* @private
* @param {number} itemDate - The item's timestamp.
* @returns {string}
*/
_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;
}
}
/**
* Maps redux state to component props.
*
* @param {Object} state - The redux state.
* @returns {{
* _defaultServerURL: string,
* _recentList: Array
* }}
*/
export function _mapStateToProps(state: Object) {
return {
_defaultServerURL: getDefaultURL(state),
_recentList: state['features/recent-list']
};
}
export default translate(connect(_mapStateToProps)(RecentList));

View File

@@ -0,0 +1,76 @@
import {
getLocalizedDateFormatter,
getLocalizedDurationFormatter
} from '../base/i18n';
import { parseURIString } from '../base/util';
/**
* Creates a displayable list item of a recent list entry.
*
* @private
* @param {Object} item - The recent list entry.
* @param {string} defaultServerURL - The default server URL.
* @param {Function} t - The translate function.
* @returns {Object}
*/
export function toDisplayableItem(item, defaultServerURL, t) {
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: [
_toDateString(item.date, t),
_toDurationString(item.duration),
serverName
],
title: location.room,
url: item.conference
};
}
/**
* Generates a duration string for the item.
*
* @private
* @param {number} duration - The item's duration.
* @returns {string}
*/
export function _toDurationString(duration) {
if (duration) {
return getLocalizedDurationFormatter(duration);
}
return null;
}
/**
* Generates a date string for the item.
*
* @private
* @param {number} itemDate - The item's timestamp.
* @param {Function} t - The translate function.
* @returns {string}
*/
export function _toDateString(itemDate, t) {
const m = getLocalizedDateFormatter(itemDate);
const date = new Date(itemDate);
const dateInMs = date.getTime();
const now = new Date();
const todayInMs = (new Date()).setHours(0, 0, 0, 0);
const yesterdayInMs = todayInMs - 86400000; // 1 day = 86400000ms
if (dateInMs >= todayInMs) {
return m.fromNow();
} else if (dateInMs >= yesterdayInMs) {
return t('dateUtils.yesterday');
} else if (date.getFullYear() !== now.getFullYear()) {
// We only want to include the year in the date if its not the current
// year.
return m.format('ddd, MMMM DD h:mm A, gggg');
}
return m.format('ddd, MMMM DD h:mm A');
}

View File

@@ -0,0 +1,71 @@
import { NavigateSectionList } from '../base/react';
import { toDisplayableItem } from './functions.any';
/**
* Transforms the history list to a displayable list
* with sections.
*
* @private
* @param {Array<Object>} recentList - The recent list form the redux store.
* @param {Function} t - The translate function.
* @param {string} defaultServerURL - The default server URL.
* @returns {Array<Object>}
*/
export function toDisplayableList(recentList, t, defaultServerURL) {
const { createSection } = NavigateSectionList;
const todaySection = createSection(t('dateUtils.today'), 'today');
const yesterdaySection
= createSection(t('dateUtils.yesterday'), 'yesterday');
const earlierSection
= createSection(t('dateUtils.earlier'), 'earlier');
const today = new Date();
const todayString = today.toDateString();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayString = yesterday.toDateString();
for (const item of recentList) {
const itemDateString = new Date(item.date).toDateString();
const displayableItem = toDisplayableItem(item, defaultServerURL, t);
if (itemDateString === todayString) {
todaySection.data.push(displayableItem);
} else if (itemDateString === yesterdayString) {
yesterdaySection.data.push(displayableItem);
} else {
earlierSection.data.push(displayableItem);
}
}
const displayableList = [];
// the recent list in the redux store has the latest date in the last index
// therefore all the sectionLists' data that was created by parsing through
// the recent list is in reverse order and must be reversed for the most
// item to show first
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;
}
/**
* Returns <tt>true</tt> if recent list is enabled and <tt>false</tt> otherwise.
*
* @returns {boolean} <tt>true</tt> if recent list is enabled and <tt>false</tt>
* otherwise.
*/
export function isRecentListEnabled() {
return true;
}

View File

@@ -0,0 +1,47 @@
/* global interfaceConfig */
import { NavigateSectionList } from '../base/react';
import { toDisplayableItem } from './functions.any';
/**
* Transforms the history list to a displayable list
* with sections.
*
* @private
* @param {Array<Object>} recentList - The recent list form the redux store.
* @param {Function} t - The translate function.
* @param {string} defaultServerURL - The default server URL.
* @returns {Array<Object>}
*/
export function toDisplayableList(recentList, t, defaultServerURL) {
const { createSection } = NavigateSectionList;
const section
= createSection(t('recentList.joinPastMeeting'), 'joinPastMeeting');
// We only want the last three conferences we were in for web.
for (const item of recentList.slice(-3)) {
const displayableItem = toDisplayableItem(item, defaultServerURL, t);
section.data.push(displayableItem);
}
const displayableList = [];
if (section.data.length) {
section.data.reverse();
displayableList.push(section);
}
return displayableList;
}
/**
* Returns <tt>true</tt> if recent list is enabled and <tt>false</tt> otherwise.
*
* @returns {boolean} <tt>true</tt> if recent list is enabled and <tt>false</tt>
* otherwise.
*/
export function isRecentListEnabled() {
return interfaceConfig.RECENT_LIST_ENABLED;
}

View File

@@ -1,12 +1,19 @@
// @flow
import { APP_WILL_MOUNT } from '../base/app';
import { CONFERENCE_WILL_LEAVE, SET_ROOM } from '../base/conference';
import {
CONFERENCE_WILL_LEAVE,
SET_ROOM,
JITSI_CONFERENCE_URL_KEY
} from '../base/conference';
import { addKnownDomains } from '../base/known-domains';
import { MiddlewareRegistry } from '../base/redux';
import { parseURIString } from '../base/util';
import { _storeCurrentConference, _updateConferenceDuration } from './actions';
import { isRecentListEnabled } from './functions';
declare var APP: Object;
/**
* Middleware that captures joined rooms so they can be saved into
@@ -16,15 +23,17 @@ import { _storeCurrentConference, _updateConferenceDuration } from './actions';
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT:
return _appWillMount(store, next, action);
if (isRecentListEnabled()) {
switch (action.type) {
case APP_WILL_MOUNT:
return _appWillMount(store, next, action);
case CONFERENCE_WILL_LEAVE:
return _conferenceWillLeave(store, next, action);
case CONFERENCE_WILL_LEAVE:
return _conferenceWillLeave(store, next, action);
case SET_ROOM:
return _setRoom(store, next, action);
case SET_ROOM:
return _setRoom(store, next, action);
}
}
return next(action);
@@ -77,9 +86,27 @@ function _appWillMount({ dispatch, getState }, next, action) {
* @returns {*} The result returned by {@code next(action)}.
*/
function _conferenceWillLeave({ dispatch, getState }, next, action) {
let locationURL;
/**
* FIXME:
* It is better to use action.conference[JITSI_CONFERENCE_URL_KEY]
* in order to make sure we get the url the conference is leaving
* from (i.e. the room we are leaving from) because if the order of events
* is different, we cannot be guranteed that the location URL in base
* connection is the url we are leaving from... not the one we are going to
* (the latter happens on mobile -- if we use the web implementation);
* however, the conference object on web does not have
* JITSI_CONFERENCE_URL_KEY so we cannot call it and must use the other way
*/
if (typeof APP === 'undefined') {
locationURL = action.conference[JITSI_CONFERENCE_URL_KEY];
} else {
locationURL = getState()['features/base/connection'].locationURL;
}
dispatch(
_updateConferenceDuration(
getState()['features/base/connection'].locationURL));
locationURL));
return next(action);
}

View File

@@ -1,5 +1,4 @@
// @flow
import { APP_WILL_MOUNT } from '../base/app';
import { getURLWithoutParamsNormalized } from '../base/connection';
import { ReducerRegistry } from '../base/redux';
@@ -9,6 +8,7 @@ import {
_STORE_CURRENT_CONFERENCE,
_UPDATE_CONFERENCE_DURATION
} from './actionTypes';
import { isRecentListEnabled } from './functions';
const logger = require('jitsi-meet-logger').getLogger(__filename);
@@ -50,19 +50,21 @@ PersistenceRegistry.register(STORE_NAME);
ReducerRegistry.register(
STORE_NAME,
(state = _getLegacyRecentRoomList(), action) => {
switch (action.type) {
case APP_WILL_MOUNT:
return _appWillMount(state);
if (isRecentListEnabled()) {
switch (action.type) {
case APP_WILL_MOUNT:
return _appWillMount(state);
case _STORE_CURRENT_CONFERENCE:
return _storeCurrentConference(state, action);
case _STORE_CURRENT_CONFERENCE:
return _storeCurrentConference(state, action);
case _UPDATE_CONFERENCE_DURATION:
return _updateConferenceDuration(state, action);
default:
return state;
case _UPDATE_CONFERENCE_DURATION:
return _updateConferenceDuration(state, action);
default:
return state;
}
}
return state;
});
/**

View File

@@ -26,6 +26,18 @@ export type Props = {
*/
_googleApiApplicationClientID: string,
/**
* The current state of interactions with the Google API. Determines what
* Google related UI should display.
*/
_googleAPIState: number,
/**
* The email of the user currently logged in to the Google web client
* application.
*/
_googleProfileEmail: string,
/**
* The live stream key that was used before.
*/
@@ -60,18 +72,6 @@ export type State = {
*/
errorType: ?string,
/**
* The current state of interactions with the Google API. Determines what
* Google related UI should display.
*/
googleAPIState: number,
/**
* The email of the user currently logged in to the Google web client
* application.
*/
googleProfileEmail: string,
/**
* The boundStreamID of the broadcast currently selected in the broadcast
* dropdown.
@@ -84,36 +84,6 @@ export type State = {
streamKey: string
};
/**
* An enumeration of the different states the Google API can be in while
* interacting with {@code StartLiveStreamDialog}.
*
* @private
* @type {Object}
*/
export const GOOGLE_API_STATES = {
/**
* The state in which the Google API still needs to be loaded.
*/
NEEDS_LOADING: 0,
/**
* The state in which the Google API is loaded and ready for use.
*/
LOADED: 1,
/**
* The state in which a user has been logged in through the Google API.
*/
SIGNED_IN: 2,
/**
* The state in which the Google API encountered an error either loading
* or with an API request.
*/
ERROR: 3
};
/**
* Implements an abstract class for the StartLiveStreamDialog on both platforms.
*
@@ -136,8 +106,6 @@ export default class AbstractStartLiveStreamDialog
this.state = {
broadcasts: undefined,
errorType: undefined,
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
googleProfileEmail: '',
selectedBoundStreamID: undefined,
streamKey: ''
};
@@ -331,6 +299,8 @@ export function _mapStateToProps(state: Object) {
_conference: state['features/base/conference'].conference,
_googleApiApplicationClientID:
state['features/base/config'].googleApiApplicationClientID,
_googleAPIState: state['features/google-api'].googleAPIState,
_googleProfileEmail: state['features/google-api'].profileEmail,
_streamKey: state['features/recording'].streamKey
};
}

View File

@@ -6,19 +6,24 @@ import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import googleApi from '../../googleApi';
import {
updateProfile,
GOOGLE_API_STATES,
loadGoogleAPI,
requestAvailableYouTubeBroadcasts,
requestLiveStreamsForYouTubeBroadcast,
showAccountSelection,
signIn
} from '../../../google-api';
import AbstractStartLiveStreamDialog, {
_mapStateToProps,
GOOGLE_API_STATES,
type Props
} from './AbstractStartLiveStreamDialog';
import BroadcastsDropdown from './BroadcastsDropdown';
import GoogleSignInButton from './GoogleSignInButton';
import StreamKeyForm from './StreamKeyForm';
declare var interfaceConfig: Object;
/**
* A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference.
@@ -40,6 +45,7 @@ class StartLiveStreamDialog
// Bind event handlers so they are only bound once per instance.
this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this);
this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this);
this._onGoogleSignIn = this._onGoogleSignIn.bind(this);
this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this);
this._onYouTubeBroadcastIDSelected
= this._onYouTubeBroadcastIDSelected.bind(this);
@@ -58,23 +64,23 @@ class StartLiveStreamDialog
* @returns {Promise}
*/
_onInitializeGoogleApi() {
return googleApi.get()
.then(() => googleApi.initializeClient(
this.props._googleApiApplicationClientID))
.then(() => this._setStateIfMounted({
googleAPIState: GOOGLE_API_STATES.LOADED
}))
.then(() => googleApi.isSignedIn())
.then(isSignedIn => {
if (isSignedIn) {
return this._onGetYouTubeBroadcasts();
}
})
.catch(() => {
this._setStateIfMounted({
googleAPIState: GOOGLE_API_STATES.ERROR
});
});
this.props.dispatch(
loadGoogleAPI(this.props._googleApiApplicationClientID))
.catch(response => this._parseErrorFromResponse(response));
}
/**
* Automatically selects the input field's value after starting to edit the
* display name.
*
* @inheritdoc
* @returns {void}
*/
componentDidUpdate(previousProps) {
if (previousProps._googleAPIState === GOOGLE_API_STATES.LOADED
&& this.props._googleAPIState === GOOGLE_API_STATES.SIGNED_IN) {
this._onGetYouTubeBroadcasts();
}
}
_onGetYouTubeBroadcasts: () => Promise<*>;
@@ -84,42 +90,39 @@ class StartLiveStreamDialog
* list of the user's YouTube broadcasts.
*
* @private
* @returns {Promise}
* @returns {void}
*/
_onGetYouTubeBroadcasts() {
return googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => googleApi.getCurrentUserProfile())
.then(profile => {
this._setStateIfMounted({
googleProfileEmail: profile.getEmail(),
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
});
})
.then(() => googleApi.requestAvailableYouTubeBroadcasts())
.then(response => {
const broadcasts = this._parseBroadcasts(response.result.items);
this.props.dispatch(updateProfile())
.catch(response => this._parseErrorFromResponse(response));
this.props.dispatch(requestAvailableYouTubeBroadcasts())
.then(broadcasts => {
this._setStateIfMounted({
broadcasts
});
if (broadcasts.length === 1 && !this.state.streamKey) {
if (broadcasts.length === 1) {
const broadcast = broadcasts[0];
this._onYouTubeBroadcastIDSelected(broadcast.boundStreamID);
}
})
.catch(response => {
// Only show an error if an external request was made with the
// Google api. Do not error if the login in canceled.
if (response && response.result) {
this._setStateIfMounted({
errorType: this._parseErrorFromResponse(response),
googleAPIState: GOOGLE_API_STATES.ERROR
});
}
});
.catch(response => this._parseErrorFromResponse(response));
}
_onGoogleSignIn: () => Object;
/**
* Forces the Google web client application to prompt for a sign in, such as
* when changing account, and will then fetch available YouTube broadcasts.
*
* @private
* @returns {Promise}
*/
_onGoogleSignIn() {
this.props.dispatch(signIn())
.catch(response => this._parseErrorFromResponse(response));
}
_onRequestGoogleSignIn: () => Object;
@@ -132,8 +135,14 @@ class StartLiveStreamDialog
* @returns {Promise}
*/
_onRequestGoogleSignIn() {
return googleApi.showAccountSelection()
.then(() => this._setStateIfMounted({ broadcasts: undefined }))
// when there is an error we show the google sign-in button.
// once we click it we want to clear the error from the state
this.props.dispatch(showAccountSelection())
.then(() =>
this._setStateIfMounted({
broadcasts: undefined,
errorType: undefined
}))
.then(() => this._onGetYouTubeBroadcasts());
}
@@ -151,55 +160,20 @@ class StartLiveStreamDialog
* @returns {Promise}
*/
_onYouTubeBroadcastIDSelected(boundStreamID) {
return googleApi.requestLiveStreamsForYouTubeBroadcast(boundStreamID)
.then(response => {
const broadcasts = response.result.items;
const streamName = broadcasts
&& broadcasts[0]
&& broadcasts[0].cdn.ingestionInfo.streamName;
const streamKey = streamName || '';
this.props.dispatch(
requestLiveStreamsForYouTubeBroadcast(boundStreamID))
.then(({ streamKey, selectedBoundStreamID }) =>
this._setStateIfMounted({
streamKey,
selectedBoundStreamID: boundStreamID
});
});
}
selectedBoundStreamID
}));
_parseBroadcasts: (Array<Object>) => Array<Object>;
/**
* Takes in a list of broadcasts from the YouTube API, removes dupes,
* removes broadcasts that cannot get a stream key, and parses the
* broadcasts into flat objects.
*
* @param {Array} broadcasts - Broadcast descriptions as obtained from
* calling the YouTube API.
* @private
* @returns {Array} An array of objects describing each unique broadcast.
*/
_parseBroadcasts(broadcasts) {
const parsedBroadcasts = {};
for (let i = 0; i < broadcasts.length; i++) {
const broadcast = broadcasts[i];
const boundStreamID = broadcast.contentDetails.boundStreamId;
if (boundStreamID && !parsedBroadcasts[boundStreamID]) {
parsedBroadcasts[boundStreamID] = {
boundStreamID,
id: broadcast.id,
status: broadcast.status.lifeCycleStatus,
title: broadcast.snippet.title
};
}
}
return Object.values(parsedBroadcasts);
}
/**
* Searches in a Google API error response for the error type.
* Only show an error if an external request was made with the Google api.
* Do not error if the login in canceled.
* And searches in a Google API error response for the error type.
*
* @param {Object} response - The Google API response that may contain an
* error.
@@ -207,12 +181,19 @@ class StartLiveStreamDialog
* @returns {string|null}
*/
_parseErrorFromResponse(response) {
if (!response || !response.result) {
return;
}
const result = response.result;
const error = result.error;
const errors = error && error.errors;
const firstError = errors && errors[0];
return (firstError && firstError.reason) || null;
this._setStateIfMounted({
errorType: (firstError && firstError.reason) || null
});
}
_renderDialogContent: () => React$Component<*>
@@ -243,20 +224,22 @@ class StartLiveStreamDialog
* @returns {ReactElement}
*/
_renderYouTubePanel() {
const { t } = this.props;
const {
t,
_googleProfileEmail
} = this.props;
const {
broadcasts,
googleProfileEmail,
selectedBoundStreamID
} = this.state;
let googleContent, helpText;
switch (this.state.googleAPIState) {
switch (this.props._googleAPIState) {
case GOOGLE_API_STATES.LOADED:
googleContent = ( // eslint-disable-line no-extra-parens
<GoogleSignInButton
onClick = { this._onGetYouTubeBroadcasts }
onClick = { this._onGoogleSignIn }
text = { t('liveStreaming.signIn') } />
);
helpText = t('liveStreaming.signInCTA');
@@ -279,7 +262,7 @@ class StartLiveStreamDialog
helpText = ( // eslint-disable-line no-extra-parens
<div>
{ `${t('liveStreaming.chooseCTA',
{ email: googleProfileEmail })} ` }
{ email: _googleProfileEmail })} ` }
<a onClick = { this._onRequestGoogleSignIn }>
{ t('liveStreaming.changeSignIn') }
</a>
@@ -288,16 +271,6 @@ class StartLiveStreamDialog
break;
case GOOGLE_API_STATES.ERROR:
googleContent = ( // eslint-disable-line no-extra-parens
<GoogleSignInButton
onClick = { this._onRequestGoogleSignIn }
text = { t('liveStreaming.signIn') } />
);
helpText = this._getGoogleErrorMessageToDisplay();
break;
case GOOGLE_API_STATES.NEEDS_LOADING:
default:
googleContent = ( // eslint-disable-line no-extra-parens
@@ -309,6 +282,15 @@ class StartLiveStreamDialog
break;
}
if (this.state.errorType !== undefined) {
googleContent = ( // eslint-disable-line no-extra-parens
<GoogleSignInButton
onClick = { this._onRequestGoogleSignIn }
text = { t('liveStreaming.signIn') } />
);
helpText = this._getGoogleErrorMessageToDisplay();
}
return (
<div className = 'google-panel'>
<div className = 'live-stream-cta'>
@@ -336,7 +318,7 @@ class StartLiveStreamDialog
case 'liveStreamingNotEnabled':
text = this.props.t(
'liveStreaming.errorLiveStreamNotEnabled',
{ email: this.state.googleProfileEmail });
{ email: this.props._googleProfileEmail });
break;
default:
text = this.props.t('liveStreaming.errorAPI');

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