Compare commits

...

17 Commits

Author SHA1 Message Date
netaskd
8da0552541 microsoftCalendar: add showing calendar events in local timezone. 2019-07-02 21:20:00 +01:00
Hristo Terezov
7ce0def995 fix(mobile): After PR #4396 2019-07-02 10:36:37 -07:00
Hristo Terezov
48285e8a2d fix(conference): Don't use this._room.
this._room should be used only by jitsi-meet-torture and for test purposes. Also this._room is assigned later than room. This may cause some issues, for example conference.getMyUserId() may return undefined while the user id is already available beacuse this._room hasn't been assigned yet.
2019-07-02 07:38:57 -07:00
Hristo Terezov
21dcc41d31 ref(large-video): switch LargeVideo logic to react 2019-07-02 07:38:57 -07:00
Дамян Минков
625d268373 Room lock update (#4394)
* Adds a notification when remote lock happens.

* Updates translations.

Removes unused strings and extracts room password to separate translation, to be able to change it when deployment uses only digits.

* Formats the conference pin when showing it.

* Removes member from translation in favour of participant.

* Updates formatting of the pin.

* Adds a notification when password is remotely removed.
2019-07-02 14:14:58 +01:00
Дамян Минков
681782ed20 Adds back talk while muted notification. (#4392)
* Adds back talk while muted notification.

* Adds unmute button to the notification.
2019-07-02 12:59:25 +01:00
Saúl Ibarra Corretgé
1baa85b649 rn: hide invite button if the functionality is not available 2019-07-02 12:30:50 +02:00
Bettenbuk Zoltan
72137a2811 feat: initial based avatars 2019-07-01 23:59:16 +02:00
Leonard Kim
0734ce7ae3 feat(api): add notifications for kicked participants 2019-07-01 12:53:25 -07:00
Дамян Минков
2dc06c28e3 Adds option to be able to cancel locked rooms and leave. (#4391)
* Adds option to be able to cancel locked rooms and leave.

* Removes not needed operations when canceling password prompt.
2019-07-01 13:02:25 +01:00
paweldomas
5848669552 feat(analytics): time since last success in connection dropped
Update LJM to 9bcc2a26cc94683b8ed302418695a331b450df97 in order to bring
in the analytics update which will add a property indicating how much
time has passed since the last successful XMPP request came through.
2019-06-28 13:30:50 -05:00
Leonard Kim
c0376d238a ref(notifications): do not notify of local participant left
Join notifications are already supressed for the local
participant, so hide the left notification. For now
the notification is not being shown on mobile to keep
the same existing behavior and because a copy change
will be needed, but will be added once batching is
implemented.
2019-06-28 09:18:18 -07:00
Leonard Kim
979b773c3c ref(notifications): move join notification firing to notifications feature 2019-06-27 17:29:02 -07:00
Leonard Kim
3195a449ca ref(conference): web and native exercise same redux flow for kicked out 2019-06-27 09:34:05 -07:00
Bettenbuk Zoltan
d7483f07e3 feat: add possibility to clear invite search field 2019-06-27 18:17:37 +02:00
Дамян Минков
9c1b802997 Device selection now always shows the device that it managed to open. (#4384)
* Device selection now always shows the device that managed to open.

* Simplifies implementation, skips using state.
2019-06-27 17:04:47 +01:00
damencho
bb3a10b0fc Safe guard for removed parent node of the iframe. 2019-06-27 14:23:59 +01:00
73 changed files with 1494 additions and 1211 deletions

View File

@@ -23,7 +23,8 @@ import {
sendAnalytics
} from './react/features/analytics';
import {
redirectWithStoredParams,
maybeRedirectToWelcomePage,
redirectToStaticPage,
reloadWithStoredParams
} from './react/features/app';
@@ -43,6 +44,7 @@ import {
conferenceWillJoin,
conferenceWillLeave,
dataChannelOpened,
kickedOut,
lockStateChanged,
onStartMutedPolicyChanged,
p2pStatusChanged,
@@ -76,7 +78,10 @@ import {
setVideoAvailable,
setVideoMuted
} from './react/features/base/media';
import { showNotification } from './react/features/notifications';
import {
hideNotification,
showNotification
} from './react/features/notifications';
import {
dominantSpeakerChanged,
getLocalParticipant,
@@ -100,11 +105,7 @@ import {
trackAdded,
trackRemoved
} from './react/features/base/tracks';
import {
getLocationContextRoot,
getJitsiMeetGlobalNS
} from './react/features/base/util';
import { notifyKickedOut } from './react/features/conference';
import { getJitsiMeetGlobalNS } from './react/features/base/util';
import { addMessage } from './react/features/chat';
import { showDesktopPicker } from './react/features/desktop-picker';
import { appendSuffix } from './react/features/display-name';
@@ -211,77 +212,6 @@ function muteLocalVideo(muted) {
APP.store.dispatch(setVideoMuted(muted));
}
/**
* Check if the welcome page is enabled and redirects to it.
* If requested show a thank you dialog before that.
* If we have a close page enabled, redirect to it without
* showing any other dialog.
*
* @param {object} options used to decide which particular close page to show
* or if close page is disabled, whether we should show the thankyou dialog
* @param {boolean} options.showThankYou - whether we should
* show thank you dialog
* @param {boolean} options.feedbackSubmitted - whether feedback was submitted
*/
function maybeRedirectToWelcomePage(options) {
// if close page is enabled redirect to it, without further action
if (config.enableClosePage) {
const { isGuest } = APP.store.getState()['features/base/jwt'];
// save whether current user is guest or not, before navigating
// to close page
window.sessionStorage.setItem('guest', isGuest);
redirectToStaticPage(`static/${
options.feedbackSubmitted ? 'close.html' : 'close2.html'}`);
return;
}
// else: show thankYou dialog only if there is no feedback
if (options.showThankYou) {
APP.store.dispatch(showNotification({
titleArguments: { appName: interfaceConfig.APP_NAME },
titleKey: 'dialog.thankYou'
}));
}
// if Welcome page is enabled redirect to welcome page after 3 sec, if
// there is a thank you message to be shown, 0.5s otherwise.
if (config.enableWelcomePage) {
setTimeout(
() => {
APP.store.dispatch(redirectWithStoredParams('/'));
},
options.showThankYou ? 3000 : 500);
}
}
/**
* Assigns a specific pathname to window.location.pathname taking into account
* the context root of the Web app.
*
* @param {string} pathname - The pathname to assign to
* window.location.pathname. If the specified pathname is relative, the context
* root of the Web app will be prepended to the specified pathname before
* assigning it to window.location.pathname.
* @return {void}
*/
function redirectToStaticPage(pathname) {
const windowLocation = window.location;
let newPathname = pathname;
if (!newPathname.startsWith('/')) {
// A pathname equal to ./ specifies the current directory. It will be
// fine but pointless to include it because contextRoot is the current
// directory.
newPathname.startsWith('./')
&& (newPathname = newPathname.substring(2));
newPathname = getLocationContextRoot(windowLocation) + newPathname;
}
windowLocation.pathname = newPathname;
}
/**
* A queue for the async replaceLocalTrack action so that multiple audio
* replacements cannot happen simultaneously. This solves the issue where
@@ -347,7 +277,7 @@ class ConferenceConnector {
case JitsiConferenceErrors.NOT_ALLOWED_ERROR: {
// let's show some auth not allowed page
redirectToStaticPage('static/authError.html');
APP.store.dispatch(redirectToStaticPage('static/authError.html'));
break;
}
@@ -1018,17 +948,15 @@ export default {
* Returns the connection times stored in the library.
*/
getConnectionTimes() {
return this._room.getConnectionTimes();
return room.getConnectionTimes();
},
// used by torture currently
isJoined() {
return this._room
&& this._room.isJoined();
return room && room.isJoined();
},
getConnectionState() {
return this._room
&& this._room.getConnectionState();
return room && room.getConnectionState();
},
/**
@@ -1037,8 +965,7 @@ export default {
* P2P connection
*/
getP2PConnectionState() {
return this._room
&& this._room.getP2PConnectionState();
return room && room.getP2PConnectionState();
},
/**
@@ -1047,7 +974,7 @@ export default {
*/
_startP2P() {
try {
this._room && this._room.startP2PSession();
room && room.startP2PSession();
} catch (error) {
logger.error('Start P2P failed', error);
throw error;
@@ -1060,7 +987,7 @@ export default {
*/
_stopP2P() {
try {
this._room && this._room.stopP2PSession();
room && room.stopP2PSession();
} catch (error) {
logger.error('Stop P2P failed', error);
throw error;
@@ -1075,7 +1002,7 @@ export default {
* false otherwise.
*/
isConnectionInterrupted() {
return this._room.isConnectionInterrupted();
return room.isConnectionInterrupted();
},
/**
@@ -1136,7 +1063,7 @@ export default {
},
getMyUserId() {
return this._room && this._room.myUserId();
return room && room.myUserId();
},
/**
@@ -1159,7 +1086,7 @@ export default {
* least one track.
*/
getNumberOfParticipantsWithTracks() {
return this._room.getParticipants()
return room.getParticipants()
.filter(p => p.getTracks().length > 0)
.length;
},
@@ -1323,7 +1250,7 @@ export default {
this.localVideo = newStream;
this._setSharingScreen(newStream);
if (newStream) {
APP.UI.addLocalStream(newStream);
APP.UI.addLocalVideoStream(newStream);
}
this.setVideoMuteStatus(this.isLocalVideoMuted());
})
@@ -1374,9 +1301,6 @@ export default {
replaceLocalTrack(this.localAudio, newStream, room))
.then(() => {
this.localAudio = newStream;
if (newStream) {
APP.UI.addLocalStream(newStream);
}
this.setAudioMuteStatus(this.isLocalAudioMuted());
})
.then(resolve)
@@ -1781,11 +1705,6 @@ export default {
logger.log(`USER ${id} LEFT:`, user);
APP.API.notifyUserLeft(id);
APP.UI.messageHandler.participantNotification(
user.getDisplayName(),
'notify.somebody',
'disconnected',
'notify.disconnected');
APP.UI.onSharedVideoStop(id);
});
@@ -1854,14 +1773,29 @@ export default {
APP.UI.setAudioLevel(id, newLvl);
});
room.on(JitsiConferenceEvents.TRACK_MUTE_CHANGED, (_, participantThatMutedUs) => {
// we store the last start muted notification id that we showed,
// so we can hide it when unmuted mic is detected
let lastNotificationId;
room.on(JitsiConferenceEvents.TRACK_MUTE_CHANGED, (track, participantThatMutedUs) => {
if (participantThatMutedUs) {
APP.store.dispatch(participantMutedUs(participantThatMutedUs));
}
if (lastNotificationId && track.isAudioTrack() && track.isLocal() && !track.isMuted()) {
APP.store.dispatch(hideNotification(lastNotificationId));
lastNotificationId = undefined;
}
});
room.on(JitsiConferenceEvents.TALK_WHILE_MUTED, () => {
APP.UI.showToolbar(6000);
const action = APP.store.dispatch(showNotification({
titleKey: 'toolbar.talkWhileMutedPopup',
customActionNameKey: 'notify.unmute',
customActionHandler: muteLocalAudio.bind(this, false)
}));
lastNotificationId = action.uid;
});
room.on(JitsiConferenceEvents.SUBJECT_CHANGED,
subject => APP.store.dispatch(conferenceSubjectChanged(subject)));
@@ -1962,7 +1896,7 @@ export default {
room.on(JitsiConferenceEvents.KICKED, participant => {
APP.UI.hideStats();
APP.store.dispatch(notifyKickedOut(participant));
APP.store.dispatch(kickedOut(room, participant));
// FIXME close
});
@@ -2269,7 +2203,7 @@ export default {
if (config.requireDisplayName
&& !APP.conference.getLocalDisplayName()
&& !this._room.isHidden()) {
&& !room.isHidden()) {
APP.UI.promptDisplayName();
}
@@ -2601,7 +2535,7 @@ export default {
room = undefined;
APP.API.notifyReadyToClose();
maybeRedirectToWelcomePage(values[0]);
APP.store.dispatch(maybeRedirectToWelcomePage(values[0]));
});
},

14
css/_avatar.scss Normal file
View File

@@ -0,0 +1,14 @@
.avatar {
align-items: center;
background-color: #AAA;
display: flex;
border-radius: 50%;
color: rgba(255, 255, 255, 0.6);
font-weight: 100;
justify-content: center;
object-fit: cover;
}
.defaultAvatar {
opacity: 0.6
}

View File

@@ -493,7 +493,6 @@
}
#dominantSpeakerAvatarContainer,
#dominantSpeakerAvatar,
.dynamic-shadow {
width: 200px;
height: 200px;
@@ -503,14 +502,9 @@
top: 50px;
margin: auto;
position: relative;
border-radius: 100px;
overflow: hidden;
visibility: inherit;
}
#dominantSpeakerAvatar {
background-color: #000000;
object-fit: cover;
}
.dynamic-shadow {
border-radius: 50%;
@@ -524,7 +518,6 @@
.avatar-container {
@include maxSize(60px);
@include absoluteAligning();
border-radius: 50%;
display: flex;
justify-content: center;
height: 50%;

View File

@@ -86,5 +86,6 @@ $flagsImagePath: "../images/";
@import 'navigate_section_list';
@import 'third-party-branding/google';
@import 'third-party-branding/microsoft';
@import 'avatar';
/* Modules END */

View File

@@ -387,6 +387,19 @@ changes. The listener will receive an object with the following structure:
}
```
* **participantKickedOut** - event notifications about a participants being removed from the room. The listener will receive an object with the following structure:
```javascript
{
kicked: {
id: string, // the id of the participant removed from the room
local: boolean // whether or not the participant is the local particiapnt
},
kicker: {
id: string // the id of the participant who kicked out the other participant
}
}
```
* **participantLeft** - event notifications about participants that leave the room. The listener will receive an object with the following structure:
```javascript
{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -4,7 +4,7 @@
"countryNotSupported": "We do not support this destination yet.",
"countryReminder": "Calling outside the US? Please make sure you start with the country code!",
"disabled": "You can't invite people.",
"failedToAdd": "Failed to add members",
"failedToAdd": "Failed to add participants",
"footerText": "Dialing out is disabled.",
"invite": "Invite",
"loading": "Searching for people and phone numbers",
@@ -112,7 +112,6 @@
"transport_plural": "Transports:",
"turn": " (turn)"
},
"contactlist_plural": "__count__ Members",
"dateUtils": {
"earlier": "Earlier",
"today": "Today",
@@ -148,7 +147,7 @@
"liveStreaming": "Live Stream"
},
"allow": "Allow",
"alreadySharedVideoMsg": "Another member is already sharing a video. This conference allows only one shared video at a time.",
"alreadySharedVideoMsg": "Another participant is already sharing a video. This conference allows only one shared video at a time.",
"alreadySharedVideoTitle": "Only one shared video is allowed at a time",
"applicationWindow": "Application window",
"Back": "Back",
@@ -173,7 +172,6 @@
"connecting": "Connecting",
"contactSupport": "Contact support",
"copy": "Copy",
"currentPassword": "The current password is",
"defaultError": "There was some kind of error",
"detectext": "Error when trying to detect desktopsharing extension.",
"dismiss": "Dismiss",
@@ -200,18 +198,18 @@
"kickMessage": "You can contact __participantDisplayName__ for more details.",
"kickParticipantButton": "Kick",
"kickParticipantDialog": "Are you sure you want to kick this participant?",
"kickParticipantTitle": "Kick this member?",
"kickParticipantTitle": "Kick this participant?",
"kickTitle": "Ouch! __participantDisplayName__ kicked you out of the meeting",
"liveStreaming": "Live Streaming",
"liveStreamingDisabledForGuestTooltip": "Guests can't start live streaming.",
"liveStreamingDisabledTooltip": "Start live stream disabled.",
"lockMessage": "Failed to lock the conference.",
"lockRoom": "Add meeting password",
"lockRoom": "Add meeting $t(lockRoomPasswordUppercase)",
"lockTitle": "Lock failed",
"logoutQuestion": "Are you sure you want to logout and stop the conference?",
"logoutTitle": "Logout",
"maxUsersLimitReached": "The limit for maximum number of members has been reached. The conference is full. Please contact the meeting owner or try again later!",
"maxUsersLimitReachedTitle": "Maximum members limit reached",
"maxUsersLimitReached": "The limit for maximum number of participants has been reached. The conference is full. Please contact the meeting owner or try again later!",
"maxUsersLimitReachedTitle": "Maximum participants limit reached",
"micConstraintFailedError": "Your microphone does not satisfy some of the required constraints.",
"micNotFoundError": "Microphone was not found.",
"micNotSendingData": "Go to your computer's settings to unmute your mic and adjust its level",
@@ -221,17 +219,13 @@
"muteParticipantBody": "You won't be able to unmute them, but they can unmute themselves at any time.",
"muteParticipantButton": "Mute",
"muteParticipantDialog": "Are you sure you want to mute this participant? You won't be able to unmute them, but they can unmute themselves at any time.",
"muteParticipantTitle": "Mute this member?",
"muteParticipantTitle": "Mute this participant?",
"Ok": "Ok",
"oops": "Oops!",
"password": "Enter password",
"passwordError": "This conversation is currently protected by a password. Only the owner of the conference can set a password.",
"passwordError2": "This conversation isn't currently protected by a password. Only the owner of the conference can set a password.",
"passwordErrorTitle": "Password Error",
"passwordLabel": "Password",
"passwordNotSupported": "Setting a meeting password is not supported.",
"passwordNotSupportedTitle": "Password not supported",
"passwordRequired": "Password required",
"passwordLabel": "$t(lockRoomPasswordUppercase)",
"passwordNotSupported": "Setting a meeting $t(lockRoomPassword) is not supported.",
"passwordNotSupportedTitle": "$t(lockRoomPasswordUppercase) not supported",
"passwordRequired": "$t(lockRoomPasswordUppercase) required",
"permissionDenied": "Permission Denied",
"popupError": "Your browser is blocking pop-up windows from this site. Please enable pop-ups in your browser's security settings and try again.",
"popupErrorTitle": "Pop-up blocked",
@@ -248,7 +242,7 @@
"remoteControlStopMessage": "The remote control session ended!",
"remoteControlTitle": "Remote desktop control",
"Remove": "Remove",
"removePassword": "Remove password",
"removePassword": "Remove $t(lockRoomPassword)",
"removeSharedVideoMsg": "Are you sure you would like to remove your shared video?",
"removeSharedVideoTitle": "Remove shared video",
"reservationError": "Reservation system error",
@@ -286,7 +280,7 @@
"tokenAuthFailedTitle": "Authentication failed",
"transcribing": "Transcribing",
"unableToSwitch": "Unable to switch video stream.",
"unlockRoom": "Remove meeting password",
"unlockRoom": "Remove meeting $t(lockRoomPassword)",
"userPassword": "user password",
"WaitForHostMsg": "The conference <b>__room__</b> has not yet started. If you are the host then please authenticate. Otherwise, please wait for the host to arrive.",
"WaitForHostMsgWOk": "The conference <b>__room__</b> has not yet started. If you are the host then please press Ok to authenticate. Otherwise, please wait for the host to arrive.",
@@ -298,34 +292,6 @@
"dialOut": {
"statusMessage": "is now __status__"
},
"email": {
"and": "and",
"body": [
" Note that __appName__ is currently only supported by __supportedBrowsers__, so you need to be using one of these browsers.",
"",
"",
"",
"",
"",
"",
"",
"",
"Hey there, I%27d like to invite you to a __appName__ conference I%27ve just set up.",
"Please click on the following link in order to join the conference.",
"Talk to you in a sec!",
"__roomUrl__",
"__sharedKeyText__"
],
"sharedKey": [
"",
"",
"",
"",
"This conference is password-protected. Please use the following pin when joining:",
"__sharedKey__"
],
"subject": "Invitation to a __appName__ (__conferenceName__)"
},
"feedback": {
"average": "Average",
"bad": "Bad",
@@ -344,8 +310,8 @@
},
"info": {
"accessibilityLabel": "Show info",
"addPassword": "Add password",
"cancelPassword": "Cancel password",
"addPassword": "Add $t(lockRoomPassword)",
"cancelPassword": "Cancel $t(lockRoomPassword)",
"conferenceURL": "Link:",
"country": "Country",
"dialANumber": "To join your meeting, dial one of these numbers and then enter the pin.",
@@ -367,7 +333,7 @@
"noPassword": "None",
"noRoom": "No room was specified to dial-in into.",
"numbers": "Dial-in Numbers",
"password": "Password:",
"password": "$t(lockRoomPasswordUppercase):",
"title": "Share",
"tooltip": "Share link and dial-in info for this meeting",
"label": "Meeting info"
@@ -463,6 +429,8 @@
"stop": "Stop Recording",
"yes": "Yes"
},
"lockRoomPassword": "password",
"lockRoomPasswordUppercase": "Password",
"me": "me",
"notify": {
"connectedOneMember": "__name__ joined the meeting",
@@ -482,17 +450,20 @@
"mutedTitle": "You're muted!",
"mutedRemotelyTitle": "You have been muted by __participantDisplayName__!",
"mutedRemotelyDescription": "You can always unmute when you're ready to speak. Mute back when you're done to keep noise away from the meeting.",
"passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) removed by another participant",
"passwordSetRemotely": "$t(lockRoomPasswordUppercase) set by another participant",
"raisedHand": "__name__ would like to speak.",
"somebody": "Somebody",
"startSilentTitle": "You joined with no audio output!",
"startSilentDescription": "Rejoin the meeting to enable audio",
"suboptimalExperienceDescription": "Eer... we are afraid your experience with __appName__ isn't going to be that great here. We are looking for ways to improve this but, until then, please try using one of the <a href='static/recommendedBrowsers.html' target='_blank'>fully supported browsers</a>.",
"suboptimalExperienceTitle": "Browser Warning",
"unmute": "Unmute",
"newDeviceCameraTitle": "New camera detected",
"newDeviceAudioTitle": "New audio device detected",
"newDeviceAction": "Use"
},
"passwordSetRemotely": "set by another member",
"passwordSetRemotely": "set by another participant",
"passwordDigitsOnly": "Up to __number__ digits",
"poweredby": "powered by",
"presenceStatus": {
@@ -567,7 +538,6 @@
"more": "More",
"name": "Name",
"noDevice": "None",
"password": "SET PASSWORD",
"selectAudioOutput": "Audio output",
"selectCamera": "Camera",
"selectMic": "Microphone",
@@ -684,7 +654,7 @@
"raiseYourHand": "Raise your hand",
"Settings": "Settings",
"sharedvideo": "Share a YouTube video",
"sharedVideoMutedPopup": "Your shared video has been muted so that you can talk to the other members.",
"sharedVideoMutedPopup": "Your shared video has been muted so that you can talk to the other participants.",
"shareRoom": "Invite someone",
"shortcuts": "View shortcuts",
"sip": "Call SIP number",
@@ -764,11 +734,11 @@
"flip": "Flip",
"kick": "Kick out",
"moderator": "Moderator",
"mute": "Member is muted",
"mute": "Participant is muted",
"muted": "Muted",
"remoteControl": "Remote control",
"show": "Show on stage",
"videomute": "Member has stopped the camera"
"videomute": "Participant has stopped the camera"
},
"welcomepage": {
"accessibilityLabel": {

View File

@@ -658,6 +658,24 @@ class API {
});
}
/**
* Notify external application of a participant, remote or local, being
* removed from the conference by another participant.
*
* @param {string} kicked - The ID of the participant removed from the
* conference.
* @param {string} kicker - The ID of the participant that removed the
* other participant.
* @returns {void}
*/
notifyKickedOut(kicked: Object, kicker: Object) {
this._sendEvent({
name: 'participant-kicked-out',
kicked,
kicker
});
}
/**
* Notify external application of the current meeting requiring a password
* to join.

View File

@@ -63,6 +63,7 @@ const events = {
'mic-error': 'micError',
'outgoing-message': 'outgoingMessage',
'participant-joined': 'participantJoined',
'participant-kicked-out': 'participantKickedOut',
'participant-left': 'participantLeft',
'password-required': 'passwordRequired',
'proxy-connection-event': 'proxyConnectionEvent',
@@ -561,7 +562,7 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this.emit('_willDispose');
this._transport.dispose();
this.removeAllListeners();
if (this._frame) {
if (this._frame && this._frame.parentNode) {
this._frame.parentNode.removeChild(this._frame);
}
}

View File

@@ -229,22 +229,11 @@ UI.unbindEvents = () => {
};
/**
* Show local stream on UI.
* Show local video stream on UI.
* @param {JitsiTrack} track stream to show
*/
UI.addLocalStream = track => {
switch (track.getType()) {
case 'audio':
// Local audio is not rendered so no further action is needed at this
// point.
break;
case 'video':
VideoLayout.changeLocalVideo(track);
break;
default:
logger.error(`Unknown stream type: ${track.getType()}`);
break;
}
UI.addLocalVideoStream = track => {
VideoLayout.changeLocalVideo(track);
};
/**
@@ -510,8 +499,8 @@ UI.dockToolbar = dock => APP.store.dispatch(dockToolbox(dock));
* @param {string} avatarURL - The URL to avatar image to display.
* @returns {void}
*/
UI.refreshAvatarDisplay = function(id, avatarURL) {
VideoLayout.changeUserAvatar(id, avatarURL);
UI.refreshAvatarDisplay = function(id) {
VideoLayout.changeUserAvatar(id);
};
/**

View File

@@ -33,7 +33,7 @@ SharedVideoThumb.prototype.constructor = SharedVideoThumb;
SharedVideoThumb.prototype.setDeviceAvailabilityIcons = function() {};
// eslint-disable-next-line no-empty-function
SharedVideoThumb.prototype.avatarChanged = function() {};
SharedVideoThumb.prototype.initializeAvatar = function() {};
SharedVideoThumb.prototype.createContainer = function(spanId) {
const container = document.createElement('span');

View File

@@ -5,11 +5,8 @@ import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { Avatar } from '../../../react/features/base/avatar';
import { i18next } from '../../../react/features/base/i18n';
import {
Avatar,
getAvatarURLByParticipantId
} from '../../../react/features/base/participants';
import { PresenceLabel } from '../../../react/features/presence-status';
/* eslint-enable no-unused-vars */
@@ -214,8 +211,7 @@ export default class LargeVideoManager {
container.setStream(id, stream, videoType);
// change the avatar url on large
this.updateAvatar(
getAvatarURLByParticipantId(APP.store.getState(), id));
this.updateAvatar();
// If the user's connection is disrupted then the avatar will be
// displayed in case we have no video image cached. That is if
@@ -406,18 +402,16 @@ export default class LargeVideoManager {
/**
* Updates the src of the dominant speaker avatar
*/
updateAvatar(avatarUrl) {
if (avatarUrl) {
ReactDOM.render(
updateAvatar() {
ReactDOM.render(
<Provider store = { APP.store }>
<Avatar
id = "dominantSpeakerAvatar"
uri = { avatarUrl } />,
this._dominantSpeakerAvatarContainer
);
} else {
ReactDOM.unmountComponentAtNode(
this._dominantSpeakerAvatarContainer);
}
participantId = { this.id }
size = { 200 } />
</Provider>,
this._dominantSpeakerAvatarContainer
);
}
/**

View File

@@ -7,9 +7,6 @@ import { Provider } from 'react-redux';
import { JitsiTrackEvents } from '../../../react/features/base/lib-jitsi-meet';
import { VideoTrack } from '../../../react/features/base/media';
import {
getAvatarURLByParticipantId
} from '../../../react/features/base/participants';
import { updateSettings } from '../../../react/features/base/settings';
import { getLocalVideoTrack } from '../../../react/features/base/tracks';
import { shouldDisplayTileView } from '../../../react/features/video-layout';
@@ -55,8 +52,7 @@ function LocalVideo(VideoLayout, emitter, streamEndedCallback) {
// Initialize the avatar display with an avatar url selected from the redux
// state. Redux stores the local user with a hardcoded participant id of
// 'local' if no id has been assigned yet.
this.avatarChanged(
getAvatarURLByParticipantId(APP.store.getState(), this.id));
this.initializeAvatar();
this.addAudioLevelIndicator();
this.updateIndicators();

View File

@@ -10,9 +10,8 @@ import { Provider } from 'react-redux';
import { i18next } from '../../../react/features/base/i18n';
import { AudioLevelIndicator }
from '../../../react/features/audio-level-indicator';
import { Avatar as AvatarDisplay } from '../../../react/features/base/avatar';
import {
Avatar as AvatarDisplay,
getAvatarURLByParticipantId,
getPinnedParticipant,
pinParticipant
} from '../../../react/features/base/participants';
@@ -570,8 +569,7 @@ SmallVideo.prototype.updateView = function() {
if (!this.hasAvatar) {
if (this.id) {
// Init avatar
this.avatarChanged(
getAvatarURLByParticipantId(APP.store.getState(), this.id));
this.initializeAvatar();
} else {
logger.error('Unable to init avatar - no id', this);
@@ -609,19 +607,22 @@ SmallVideo.prototype.updateView = function() {
* Updates the react component displaying the avatar with the passed in avatar
* url.
*
* @param {string} avatarUrl - The uri to the avatar image.
* @returns {void}
*/
SmallVideo.prototype.avatarChanged = function(avatarUrl) {
SmallVideo.prototype.initializeAvatar = function() {
const thumbnail = this.$avatar().get(0);
this.hasAvatar = true;
if (thumbnail) {
// Maybe add a special case for local participant, as on init of
// LocalVideo.js the id is set to "local" but will get updated later.
ReactDOM.render(
<AvatarDisplay
className = 'userAvatar'
uri = { avatarUrl } />,
<Provider store = { APP.store }>
<AvatarDisplay
className = 'userAvatar'
participantId = { this.id } />
</Provider>,
thumbnail
);
}

View File

@@ -53,16 +53,6 @@ function onLocalFlipXChanged(val) {
}
}
/**
* Returns the redux representation of all known users.
*
* @private
* @returns {Array}
*/
function getAllParticipants() {
return APP.store.getState()['features/base/participants'];
}
/**
* Returns an array of all thumbnails in the filmstrip.
*
@@ -86,43 +76,6 @@ function getLocalParticipant() {
return getLocalParticipantFromStore(APP.store.getState());
}
/**
* Returns the user ID of the remote participant that is current the dominant
* speaker.
*
* @private
* @returns {string|null}
*/
function getCurrentRemoteDominantSpeakerID() {
const dominantSpeaker = getAllParticipants()
.find(participant => participant.dominantSpeaker);
if (dominantSpeaker) {
return dominantSpeaker.local ? null : dominantSpeaker.id;
}
return null;
}
/**
* Returns the corresponding resource id to the given peer container
* DOM element.
*
* @return the corresponding resource id to the given peer container
* DOM element
*/
function getPeerContainerResourceId(containerElement) {
if (localVideoThumbnail.container === containerElement) {
return localVideoThumbnail.id;
}
const i = containerElement.id.indexOf('participant_');
if (i >= 0) {
return containerElement.id.substring(i + 12);
}
}
const VideoLayout = {
init(emitter) {
eventEmitter = emitter;
@@ -208,10 +161,6 @@ const VideoLayout = {
* and setting them assume the id is already set.
*/
mucJoined() {
if (largeVideo && !largeVideo.id) {
this.updateLargeVideo(getLocalParticipant().id, true);
}
// FIXME: replace this call with a generic update call once SmallVideo
// only contains a ReactElement. Then remove this call once the
// Filmstrip is fully in React.
@@ -247,79 +196,6 @@ const VideoLayout = {
localVideoThumbnail.setVisible(visible);
},
/**
* Checks if removed video is currently displayed and tries to display
* another one instead.
* Uses focusedID if any or dominantSpeakerID if any,
* otherwise elects new video, in this order.
*/
_updateAfterThumbRemoved(id) {
// Always trigger an update if large video is empty.
if (!largeVideo
|| (this.getLargeVideoID() && !this.isCurrentlyOnLarge(id))) {
return;
}
const pinnedId = this.getPinnedId();
let newId;
if (pinnedId) {
newId = pinnedId;
} else if (getCurrentRemoteDominantSpeakerID()) {
newId = getCurrentRemoteDominantSpeakerID();
} else { // Otherwise select last visible video
newId = this.electLastVisibleVideo();
}
this.updateLargeVideo(newId);
},
electLastVisibleVideo() {
// pick the last visible video in the row
// if nobody else is left, this picks the local video
const remoteThumbs = Filmstrip.getThumbs(true).remoteThumbs;
let thumbs = remoteThumbs.filter('[id!="mixedstream"]');
const lastVisible = thumbs.filter(':visible:last');
if (lastVisible.length) {
const id = getPeerContainerResourceId(lastVisible[0]);
if (remoteVideos[id]) {
logger.info(`electLastVisibleVideo: ${id}`);
return id;
}
// The RemoteVideo was removed (but the DOM elements may still
// exist).
}
logger.info('Last visible video no longer exists');
thumbs = Filmstrip.getThumbs().remoteThumbs;
if (thumbs.length) {
const id = getPeerContainerResourceId(thumbs[0]);
if (remoteVideos[id]) {
logger.info(`electLastVisibleVideo: ${id}`);
return id;
}
// The RemoteVideo was removed (but the DOM elements may
// still exist).
}
// Go with local video
logger.info('Fallback to local video...');
const { id } = getLocalParticipant();
logger.info(`electLastVisibleVideo: ${id}`);
return id;
},
onRemoteStreamAdded(stream) {
const id = stream.getParticipantId();
const remoteVideo = remoteVideos[id];
@@ -423,23 +299,6 @@ const VideoLayout = {
getAllThumbnails().forEach(thumbnail =>
thumbnail.focus(pinnedParticipantID === thumbnail.getId()));
if (pinnedParticipantID) {
this.updateLargeVideo(pinnedParticipantID);
} else {
const currentDominantSpeakerID
= getCurrentRemoteDominantSpeakerID();
if (currentDominantSpeakerID) {
this.updateLargeVideo(currentDominantSpeakerID);
} else {
// if there is no currentDominantSpeakerID, it can also be
// that local participant is the dominant speaker
// we should act as a participant has left and was on large
// and we should choose somebody (electLastVisibleVideo)
this.updateLargeVideo(this.electLastVisibleVideo());
}
}
},
/**
@@ -473,19 +332,6 @@ const VideoLayout = {
this.updateMutedForNoTracks(id, 'audio');
this.updateMutedForNoTracks(id, 'video');
const remoteVideosCount = Object.keys(remoteVideos).length;
if (remoteVideosCount === 1) {
window.setTimeout(() => {
const updatedRemoteVideosCount
= Object.keys(remoteVideos).length;
if (updatedRemoteVideosCount === 1 && remoteVideos[id]) {
this._maybePlaceParticipantOnLargeVideo(id);
}
}, 3000);
}
},
/**
@@ -512,43 +358,14 @@ const VideoLayout = {
// FIXME: what does this do???
remoteVideoActive(videoElement, resourceJid) {
logger.info(`${resourceJid} video is now active`, videoElement);
VideoLayout.resizeThumbnails(
false, () => {
if (videoElement) {
$(videoElement).show();
}
});
this._maybePlaceParticipantOnLargeVideo(resourceJid);
},
/**
* Update the large video to the last added video only if there's no current
* dominant, focused speaker or update it to the current dominant speaker.
*
* @params {string} resourceJid - The id of the user to maybe display on
* large video.
* @returns {void}
*/
_maybePlaceParticipantOnLargeVideo(resourceJid) {
const pinnedId = this.getPinnedId();
if ((!pinnedId
&& !getCurrentRemoteDominantSpeakerID()
&& this.isLargeContainerTypeVisible(VIDEO_CONTAINER_TYPE))
|| pinnedId === resourceJid
|| (!pinnedId && resourceJid
&& getCurrentRemoteDominantSpeakerID() === resourceJid)
/* Playback started while we're on the stage - may need to update
video source with the new stream */
|| this.isCurrentlyOnLarge(resourceJid)) {
this.updateLargeVideo(resourceJid, true);
}
this._updateLargeVideoIfDisplayed(resourceJid, true);
},
/**
@@ -646,10 +463,8 @@ const VideoLayout = {
}
}
if (this.isCurrentlyOnLarge(id)) {
// large video will show avatar instead of muted stream
this.updateLargeVideo(id, true);
}
// large video will show avatar instead of muted stream
this._updateLargeVideoIfDisplayed(id, true);
},
/**
@@ -677,18 +492,6 @@ const VideoLayout = {
onDominantSpeakerChanged(id) {
getAllThumbnails().forEach(thumbnail =>
thumbnail.showDominantSpeakerIndicator(id === thumbnail.getId()));
if (!remoteVideos[id]) {
return;
}
// Local video will not have container found, but that's ok
// since we don't want to switch to local video.
if (!interfaceConfig.filmStripOnly && !this.getPinnedId()
&& !this.getCurrentlyOnLargeContainer().stayOnStage()) {
this.updateLargeVideo(id);
}
},
/**
@@ -758,9 +561,7 @@ const VideoLayout = {
if (remoteVideo) {
remoteVideo.updateView();
if (remoteVideo.isCurrentlyOnLargeVideo()) {
this.updateLargeVideo(id);
}
this._updateLargeVideoIfDisplayed(id);
}
},
@@ -809,7 +610,6 @@ const VideoLayout = {
}
VideoLayout.resizeThumbnails();
VideoLayout._updateAfterThumbRemoved(id);
},
onVideoTypeChanged(id, newVideoType) {
@@ -835,9 +635,7 @@ const VideoLayout = {
}
smallVideo.setVideoType(newVideoType);
if (this.isCurrentlyOnLarge(id)) {
this.updateLargeVideo(id, true);
}
this._updateLargeVideoIfDisplayed(id, true);
},
/**
@@ -880,7 +678,7 @@ const VideoLayout = {
const smallVideo = VideoLayout.getSmallVideo(id);
if (smallVideo) {
smallVideo.avatarChanged(avatarUrl);
smallVideo.initializeAvatar();
} else {
logger.warn(
`Missed avatar update - no small video yet for ${id}`

8
package-lock.json generated
View File

@@ -8738,8 +8738,8 @@
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
},
"js-utils": {
"version": "github:jitsi/js-utils#73a67a7a60d52f8e895f50939c8fcbd1f20fe7b5",
"from": "github:jitsi/js-utils#73a67a7a60d52f8e895f50939c8fcbd1f20fe7b5",
"version": "github:jitsi/js-utils#192b1c996e8c05530eb1f19e82a31069c3021e31",
"from": "github:jitsi/js-utils#192b1c996e8c05530eb1f19e82a31069c3021e31",
"requires": {
"bowser": "1.9.1",
"js-md5": "0.7.3"
@@ -8946,8 +8946,8 @@
}
},
"lib-jitsi-meet": {
"version": "github:jitsi/lib-jitsi-meet#c0af82a215d0893f1999df299cfdfcbc9ce9e72a",
"from": "github:jitsi/lib-jitsi-meet#c0af82a215d0893f1999df299cfdfcbc9ce9e72a",
"version": "github:jitsi/lib-jitsi-meet#9bcc2a26cc94683b8ed302418695a331b450df97",
"from": "github:jitsi/lib-jitsi-meet#9bcc2a26cc94683b8ed302418695a331b450df97",
"requires": {
"@jitsi/sdp-interop": "0.1.14",
"@jitsi/sdp-simulcast": "0.2.1",

View File

@@ -49,10 +49,10 @@
"jquery-contextmenu": "2.4.5",
"jquery-i18next": "1.2.0",
"js-md5": "0.6.1",
"js-utils": "github:jitsi/js-utils#73a67a7a60d52f8e895f50939c8fcbd1f20fe7b5",
"js-utils": "github:jitsi/js-utils#192b1c996e8c05530eb1f19e82a31069c3021e31",
"jsrsasign": "8.0.12",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#c0af82a215d0893f1999df299cfdfcbc9ce9e72a",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#9bcc2a26cc94683b8ed302418695a331b450df97",
"libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d",
"lodash": "4.17.11",
"moment": "2.19.4",
@@ -85,6 +85,7 @@
"redux-thunk": "2.2.0",
"styled-components": "3.4.9",
"uuid": "3.1.0",
"windows-iana": "^3.1.0",
"xmldom": "0.1.27"
},
"devDependencies": {

View File

@@ -13,10 +13,18 @@ import {
import { connect, disconnect, setLocationURL } from '../base/connection';
import { loadConfig } from '../base/lib-jitsi-meet';
import { createDesiredLocalTracks } from '../base/tracks';
import { parseURIString, toURLString } from '../base/util';
import {
getLocationContextRoot,
parseURIString,
toURLString
} from '../base/util';
import { showNotification } from '../notifications';
import { setFatalError } from '../overlay';
import { getDefaultURL } from './functions';
import {
getDefaultURL,
getName
} from './functions';
const logger = require('jitsi-meet-logger').getLogger(__filename);
@@ -136,6 +144,34 @@ export function redirectWithStoredParams(pathname: string) {
};
}
/**
* Assigns a specific pathname to window.location.pathname taking into account
* the context root of the Web app.
*
* @param {string} pathname - The pathname to assign to
* window.location.pathname. If the specified pathname is relative, the context
* root of the Web app will be prepended to the specified pathname before
* assigning it to window.location.pathname.
* @returns {Function}
*/
export function redirectToStaticPage(pathname: string) {
return () => {
const windowLocation = window.location;
let newPathname = pathname;
if (!newPathname.startsWith('/')) {
// A pathname equal to ./ specifies the current directory. It will be
// fine but pointless to include it because contextRoot is the current
// directory.
newPathname.startsWith('./')
&& (newPathname = newPathname.substring(2));
newPathname = getLocationContextRoot(windowLocation) + newPathname;
}
windowLocation.pathname = newPathname;
};
}
/**
* Reloads the page.
*
@@ -182,3 +218,58 @@ export function reloadWithStoredParams() {
}
};
}
/**
* Check if the welcome page is enabled and redirects to it.
* If requested show a thank you dialog before that.
* If we have a close page enabled, redirect to it without
* showing any other dialog.
*
* @param {Object} options - Used to decide which particular close page to show
* or if close page is disabled, whether we should show the thankyou dialog.
* @param {boolean} options.showThankYou - Whether we should
* show thank you dialog.
* @param {boolean} options.feedbackSubmitted - Whether feedback was submitted.
* @returns {Function}
*/
export function maybeRedirectToWelcomePage(options: Object = {}) {
return (dispatch: Dispatch<any>, getState: Function) => {
const {
enableClosePage
} = getState()['features/base/config'];
// if close page is enabled redirect to it, without further action
if (enableClosePage) {
const { isGuest } = getState()['features/base/jwt'];
// save whether current user is guest or not, before navigating
// to close page
window.sessionStorage.setItem('guest', isGuest);
dispatch(redirectToStaticPage(`static/${
options.feedbackSubmitted ? 'close.html' : 'close2.html'}`));
return;
}
// else: show thankYou dialog only if there is no feedback
if (options.showThankYou) {
dispatch(showNotification({
titleArguments: { appName: getName() },
titleKey: 'dialog.thankYou'
}));
}
// if Welcome page is enabled redirect to welcome page after 3 sec, if
// there is a thank you message to be shown, 0.5s otherwise.
if (getState()['features/base/config'].enableWelcomePage) {
setTimeout(
() => {
dispatch(redirectWithStoredParams('/'));
},
options.showThankYou ? 3000 : 500);
}
};
}

View File

@@ -0,0 +1,177 @@
// @flow
import { PureComponent } from 'react';
import { getParticipantById } from '../../participants';
import { getAvatarColor, getInitials } from '../functions';
export type Props = {
/**
* The string we base the initials on (this is generated from a list of precendences).
*/
_initialsBase: ?string,
/**
* An URL that we validated that it can be loaded.
*/
_loadableAvatarUrl: ?string,
/**
* A string to override the initials to generate a color of. This is handy if you don't want to make
* the background color match the string that the initials are generated from.
*/
colorBase?: string,
/**
* Display name of the entity to render an avatar for (if any). This is handy when we need
* an avatar for a non-participasnt entity (e.g. a recent list item).
*/
displayName?: string,
/**
* The ID of the participant to render an avatar for (if it's a participant avatar).
*/
participantId?: string,
/**
* The size of the avatar.
*/
size: number,
/**
* URI of the avatar, if any.
*/
uri: ?string,
}
type State = {
avatarFailed: boolean
}
export const DEFAULT_SIZE = 65;
/**
* Implements an abstract class to render avatars in the app.
*/
export default class AbstractAvatar<P: Props> extends PureComponent<P, State> {
/**
* Instantiates a new {@code Component}.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
this.state = {
avatarFailed: false
};
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
componentDidUpdate(prevProps: P) {
if (prevProps.uri !== this.props.uri) {
// URI changed, so we need to try to fetch it again.
// Eslint doesn't like this statement, but based on the React doc, it's safe if it's
// wrapped in a condition: https://reactjs.org/docs/react-component.html#componentdidupdate
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
avatarFailed: false
});
}
}
/**
* Implements {@code Componenr#render}.
*
* @inheritdoc
*/
render() {
const {
_initialsBase,
_loadableAvatarUrl,
colorBase,
uri
} = this.props;
const { avatarFailed } = this.state;
// _loadableAvatarUrl is validated that it can be loaded, but uri (if present) is not, so
// we still need to do a check for that. And an explicitly provided URI is higher priority than
// an avatar URL anyhow.
if ((uri && !avatarFailed) || _loadableAvatarUrl) {
return this._renderURLAvatar((!avatarFailed && uri) || _loadableAvatarUrl);
}
const _initials = getInitials(_initialsBase);
if (_initials) {
return this._renderInitialsAvatar(_initials, getAvatarColor(colorBase || _initialsBase));
}
return this._renderDefaultAvatar();
}
_onAvatarLoadError: () => void;
/**
* Callback to handle the error while loading of the avatar URI.
*
* @returns {void}
*/
_onAvatarLoadError() {
this.setState({
avatarFailed: true
});
}
/**
* Function to render the actual, platform specific default avatar component.
*
* @returns {React$Element<*>}
*/
_renderDefaultAvatar: () => React$Element<*>
/**
* Function to render the actual, platform specific initials-based avatar component.
*
* @param {string} initials - The initials to use.
* @param {string} color - The color to use.
* @returns {React$Element<*>}
*/
_renderInitialsAvatar: (string, string) => React$Element<*>
/**
* Function to render the actual, platform specific URL-based avatar component.
*
* @param {string} uri - The URI of the avatar.
* @returns {React$Element<*>}
*/
_renderURLAvatar: ?string => React$Element<*>
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Props} ownProps - The own props of the component.
* @returns {Props}
*/
export function _mapStateToProps(state: Object, ownProps: Props) {
const { displayName, participantId } = ownProps;
const _participant = participantId && getParticipantById(state, participantId);
const _initialsBase = (_participant && (_participant.name || _participant.email)) || displayName;
return {
_initialsBase,
_loadableAvatarUrl: _participant && _participant.loadableAvatarUrl
};
}

View File

@@ -0,0 +1,3 @@
// @flow
export * from './native';

View File

@@ -0,0 +1,3 @@
// @flow
export * from './web';

View File

@@ -0,0 +1,106 @@
// @flow
import React from 'react';
import { Image, Text, View } from 'react-native';
import { connect } from '../../../redux';
import { type StyleType } from '../../../styles';
import AbstractAvatar, {
_mapStateToProps,
type Props as AbstractProps,
DEFAULT_SIZE
} from '../AbstractAvatar';
import RemoteAvatar, { DEFAULT_AVATAR } from './RemoteAvatar';
import styles from './styles';
type Props = AbstractProps & {
/**
* External style of the component.
*/
style?: StyleType
}
/**
* Implements an avatar component that has 4 ways to render an avatar:
*
* - Based on an explicit avatar URI, if provided
* - Gravatar, if there is any
* - Based on initials generated from name or email
* - Default avatar icon, if any of the above fails
*/
class Avatar extends AbstractAvatar<Props> {
_onAvatarLoadError: () => void;
/**
* Implements {@code AbstractAvatar#_renderDefaultAvatar}.
*
* @inheritdoc
*/
_renderDefaultAvatar() {
return this._wrapAvatar(
<Image
source = { DEFAULT_AVATAR }
style = { [
styles.avatarContent(this.props.size || DEFAULT_SIZE),
styles.staticAvatar
] } />
);
}
/**
* Implements {@code AbstractAvatar#_renderGravatar}.
*
* @inheritdoc
*/
_renderInitialsAvatar(initials, color) {
return this._wrapAvatar(
<View
style = { [
styles.initialsContainer,
{
backgroundColor: color
}
] }>
<Text style = { styles.initialsText(this.props.size || DEFAULT_SIZE) }> { initials } </Text>
</View>
);
}
/**
* Implements {@code AbstractAvatar#_renderGravatar}.
*
* @inheritdoc
*/
_renderURLAvatar(uri) {
return this._wrapAvatar(
<RemoteAvatar
onError = { this._onAvatarLoadError }
size = { this.props.size || DEFAULT_SIZE }
uri = { uri } />
);
}
/**
* Wraps an avatar into a common wrapper.
*
* @param {React#Component} avatar - The avatar component.
* @returns {React#Component}
*/
_wrapAvatar(avatar) {
return (
<View
style = { [
styles.avatarContainer(this.props.size || DEFAULT_SIZE),
this.props.style
] }>
{ avatar }
</View>
);
}
}
export default connect(_mapStateToProps)(Avatar);

View File

@@ -0,0 +1,50 @@
// @flow
import React, { PureComponent } from 'react';
import { Image } from 'react-native';
import styles from './styles';
export const DEFAULT_AVATAR = require('../../../../../../images/avatar.png');
type Props = {
/**
* Callback for load errors.
*/
onError: Function,
/**
* Size of the avatar.
*/
size: number,
/**
* URI of the avatar to load.
*/
uri: string
};
/**
* Implements a private class that is used to fetch and render remote avatars based on an URI.
*/
export default class RemoteAvatar extends PureComponent<Props> {
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
render() {
const { onError, size, uri } = this.props;
return (
<Image
defaultSource = { DEFAULT_AVATAR }
onError = { onError }
resizeMode = 'cover'
source = {{ uri }}
style = { styles.avatarContent(size) } />
);
}
}

View File

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

View File

@@ -0,0 +1,47 @@
// @flow
import { ColorPalette } from '../../../styles';
/**
* The styles of the feature base/participants.
*/
export default {
avatarContainer: (size: number) => {
return {
alignItems: 'center',
borderRadius: size / 2,
height: size,
justifyContent: 'center',
overflow: 'hidden',
width: size
};
},
avatarContent: (size: number) => {
return {
height: size,
width: size
};
},
initialsContainer: {
alignItems: 'center',
alignSelf: 'stretch',
flex: 1,
justifyContent: 'center'
},
initialsText: (size: number) => {
return {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: size * 0.5,
fontWeight: '100'
};
},
staticAvatar: {
backgroundColor: ColorPalette.lightGrey,
opacity: 0.4
}
};

View File

@@ -0,0 +1,98 @@
// @flow
import React from 'react';
import { connect } from '../../../redux';
import AbstractAvatar, {
_mapStateToProps,
type Props as AbstractProps
} from '../AbstractAvatar';
type Props = AbstractProps & {
className?: string,
id: string
};
/**
* Implements an avatar as a React/Web {@link Component}.
*/
class Avatar extends AbstractAvatar<Props> {
/**
* Constructs a style object to be used on the avatars.
*
* @param {string?} color - The desired background color.
* @returns {Object}
*/
_getAvatarStyle(color) {
const { size } = this.props;
return {
backgroundColor: color || undefined,
fontSize: size ? size * 0.5 : '180%',
height: size || '100%',
width: size || '100%'
};
}
/**
* Constructs a list of class names required for the avatar component.
*
* @param {string} additional - Any additional class to add.
* @returns {string}
*/
_getAvatarClassName(additional) {
return `avatar ${additional || ''} ${this.props.className || ''}`;
}
_onAvatarLoadError: () => void;
/**
* Implements {@code AbstractAvatar#_renderDefaultAvatar}.
*
* @inheritdoc
*/
_renderDefaultAvatar() {
return (
<img
className = { this._getAvatarClassName('defaultAvatar') }
id = { this.props.id }
src = '/images/avatar.png'
style = { this._getAvatarStyle() } />
);
}
/**
* Implements {@code AbstractAvatar#_renderGravatar}.
*
* @inheritdoc
*/
_renderInitialsAvatar(initials, color) {
return (
<div
className = { this._getAvatarClassName() }
id = { this.props.id }
style = { this._getAvatarStyle(color) }>
{ initials }
</div>
);
}
/**
* Implements {@code AbstractAvatar#_renderGravatar}.
*
* @inheritdoc
*/
_renderURLAvatar(uri) {
return (
<img
className = { this._getAvatarClassName() }
id = { this.props.id }
onError = { this._onAvatarLoadError }
src = { uri }
style = { this._getAvatarStyle() } />
);
}
}
export default connect(_mapStateToProps)(Avatar);

View File

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

View File

@@ -0,0 +1,54 @@
// @flow
import _ from 'lodash';
const AVATAR_COLORS = [
'232, 105, 156',
'255, 198, 115',
'128, 128, 255',
'105, 232, 194',
'234, 255, 128'
];
const AVATAR_OPACITY = 0.4;
/**
* Generates the background color of an initials based avatar.
*
* @param {string?} initials - The initials of the avatar.
* @returns {string}
*/
export function getAvatarColor(initials: ?string) {
let colorIndex = 0;
if (initials) {
let nameHash = 0;
for (const s of initials) {
nameHash += s.codePointAt(0);
}
colorIndex = nameHash % AVATAR_COLORS.length;
}
return `rgba(${AVATAR_COLORS[colorIndex]}, ${AVATAR_OPACITY})`;
}
/**
* Generates initials for a simple string.
*
* @param {string?} s - The string to generate initials for.
* @returns {string?}
*/
export function getInitials(s: ?string) {
// We don't want to use the domain part of an email address, if it is one
const initialsBasis = _.split(s, '@')[0];
const words = _.words(initialsBasis);
let initials = '';
for (const w of words) {
(initials.length < 2) && (initials += w.substr(0, 1).toUpperCase());
}
return initials;
}

View File

@@ -0,0 +1,3 @@
// @flow
export * from './components';

View File

@@ -1,3 +1,5 @@
// @flow
/**
* Create an action for when dominant speaker changes.
*
@@ -65,6 +67,18 @@ export const PARTICIPANT_ID_CHANGED = 'PARTICIPANT_ID_CHANGED';
*/
export const PARTICIPANT_JOINED = 'PARTICIPANT_JOINED';
/**
* Action to signal that a participant has been removed from a conference by
* another participant.
*
* {
* type: PARTICIPANT_KICKED,
* kicked: Object,
* kicker: Object
* }
*/
export const PARTICIPANT_KICKED = 'PARTICIPANT_KICKED';
/**
* Action to handle case when participant lefts.
*
@@ -120,3 +134,17 @@ export const HIDDEN_PARTICIPANT_JOINED = 'HIDDEN_PARTICIPANT_JOINED';
* }
*/
export const HIDDEN_PARTICIPANT_LEFT = 'HIDDEN_PARTICIPANT_LEFT';
/**
* The type of Redux action which notifies the app that the loadable avatar URL has changed.
*
* {
* type: SET_LOADABLE_AVATAR_URL,
* participant: {
* id: string,
loadableAvatarUrl: string
* }
* }
*/
export const SET_LOADABLE_AVATAR_URL = 'SET_LOADABLE_AVATAR_URL';

View File

@@ -1,5 +1,3 @@
import throttle from 'lodash/throttle';
import { set } from '../redux';
import { NOTIFICATION_TIMEOUT, showNotification } from '../../notifications';
@@ -11,9 +9,11 @@ import {
MUTE_REMOTE_PARTICIPANT,
PARTICIPANT_ID_CHANGED,
PARTICIPANT_JOINED,
PARTICIPANT_KICKED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
PIN_PARTICIPANT
PIN_PARTICIPANT,
SET_LOADABLE_AVATAR_URL
} from './actionTypes';
import {
getLocalParticipant,
@@ -417,6 +417,12 @@ export function participantMutedUs(participant) {
export function participantKicked(kicker, kicked) {
return (dispatch, getState) => {
dispatch({
type: PARTICIPANT_KICKED,
kicked: kicked.getId(),
kicker: kicker.getId()
});
dispatch(showNotification({
titleArguments: {
kicked:
@@ -451,71 +457,24 @@ export function pinParticipant(id) {
}
/**
* An array of names of participants that have joined the conference. The array
* is replaced with an empty array as notifications are displayed.
* Creates an action which notifies the app that the loadable URL of the avatar of a participant got updated.
*
* @private
* @type {string[]}
*/
let joinedParticipantsNames = [];
/**
* A throttled internal function that takes the internal list of participant
* names, {@code joinedParticipantsNames}, and triggers the display of a
* notification informing of their joining.
*
* @private
* @type {Function}
*/
const _throttledNotifyParticipantConnected = throttle(dispatch => {
const joinedParticipantsCount = joinedParticipantsNames.length;
let notificationProps;
if (joinedParticipantsCount >= 3) {
notificationProps = {
titleArguments: {
name: joinedParticipantsNames[0],
count: joinedParticipantsCount - 1
},
titleKey: 'notify.connectedThreePlusMembers'
};
} else if (joinedParticipantsCount === 2) {
notificationProps = {
titleArguments: {
first: joinedParticipantsNames[0],
second: joinedParticipantsNames[1]
},
titleKey: 'notify.connectedTwoMembers'
};
} else if (joinedParticipantsCount) {
notificationProps = {
titleArguments: {
name: joinedParticipantsNames[0]
},
titleKey: 'notify.connectedOneMember'
};
}
if (notificationProps) {
dispatch(
showNotification(notificationProps, NOTIFICATION_TIMEOUT));
}
joinedParticipantsNames = [];
}, 500, { leading: false });
/**
* Queues the display of a notification of a participant having connected to
* the meeting. The notifications are batched so that quick consecutive
* connection events are shown in one notification.
*
* @param {string} displayName - The name of the participant that connected.
* @returns {Function}
*/
export function showParticipantJoinedNotification(displayName) {
joinedParticipantsNames.push(displayName);
return dispatch => _throttledNotifyParticipantConnected(dispatch);
* @param {string} participantId - The ID of the participant.
* @param {string} url - The new URL.
* @returns {{
* type: SET_LOADABLE_AVATAR_URL,
* participant: {
* id: string,
* loadableAvatarUrl: string
* }
* }}
*/
export function setLoadableAvatarUrl(participantId, url) {
return {
type: SET_LOADABLE_AVATAR_URL,
participant: {
id: participantId,
loadableAvatarUrl: url
}
};
}

View File

@@ -1,320 +0,0 @@
// @flow
import React, { Component, Fragment, PureComponent } from 'react';
import { Dimensions, Image, Platform, View } from 'react-native';
import FastImage, {
type CacheControls,
type Priorities
} from 'react-native-fast-image';
import { ColorPalette } from '../../styles';
import styles from './styles';
/**
* The default image/source to be used in case none is specified or the
* specified one fails to load.
*
* XXX The relative path to the default/stock (image) file is defined by the
* {@code const} {@code DEFAULT_AVATAR_RELATIVE_PATH}. Unfortunately, the
* packager of React Native cannot deal with it early enough for the following
* {@code require} to succeed at runtime. Anyway, be sure to synchronize the
* relative path on Web and mobile for the purposes of consistency.
*
* @private
* @type {string}
*/
const _DEFAULT_SOURCE = require('../../../../../images/avatar.png');
/**
* The type of the React {@link Component} props of {@link Avatar}.
*/
type Props = {
/**
* The size for the {@link Avatar}.
*/
size: number,
/**
* The URI of the {@link Avatar}.
*/
uri: string
};
/**
* The type of the React {@link Component} state of {@link Avatar}.
*/
type State = {
/**
* Background color for the locally generated avatar.
*/
backgroundColor: string,
/**
* Error indicator for non-local avatars.
*/
error: boolean,
/**
* Indicates if the non-local avatar was loaded or not.
*/
loaded: boolean,
/**
* Source for the non-local avatar.
*/
source: {
uri?: string,
headers?: Object,
priority?: Priorities,
cache?: CacheControls,
}
};
/**
* Implements a React Native/mobile {@link Component} wich renders the content
* of an Avatar.
*/
class AvatarContent extends Component<Props, State> {
/**
* Initializes a new Avatar instance.
*
* @param {Props} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props: Props) {
super(props);
// Set the image source. The logic for the character # below is as
// follows:
// - Technically, URI is supposed to start with a scheme and scheme
// cannot contain the character #.
// - Technically, the character # in a URI signals the start of the
// fragment/hash.
// - Technically, the fragment/hash does not imply a retrieval
// action.
// - Practically, the fragment/hash does not always mandate a
// retrieval action. For example, an HTML anchor with an href that
// starts with the character # does not cause a Web browser to
// initiate a retrieval action.
// So I'll use the character # at the start of URI to not initiate
// an image retrieval action.
const source = {};
if (props.uri && !props.uri.startsWith('#')) {
source.uri = props.uri;
}
this.state = {
backgroundColor: this._getBackgroundColor(props),
error: false,
loaded: false,
source
};
// Bind event handlers so they are only bound once per instance.
this._onAvatarLoaded = this._onAvatarLoaded.bind(this);
this._onAvatarLoadError = this._onAvatarLoadError.bind(this);
}
/**
* Computes if the default avatar (ie, locally generated) should be used
* or not.
*/
get useDefaultAvatar() {
const { error, loaded, source } = this.state;
return !source.uri || error || !loaded;
}
/**
* Computes a hash over the URI and returns a HSL background color. We use
* 75% as lightness, for nice pastel style colors.
*
* @param {Object} props - The read-only React {@code Component} props from
* which the background color is to be generated.
* @private
* @returns {string} - The HSL CSS property.
*/
_getBackgroundColor({ uri }) {
if (!uri) {
return ColorPalette.white;
}
let hash = 0;
/* 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 */
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 loading finishes. This doesn't
* necessarily mean the load was successful.
*
* @private
* @returns {void}
*/
_onAvatarLoaded() {
this.setState({ loaded: true });
}
_onAvatarLoadError: () => void;
/**
* Handler called when the remote image loading failed.
*
* @private
* @returns {void}
*/
_onAvatarLoadError() {
this.setState({ error: true });
}
/**
* 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 } = this.state;
const imageStyle = this._getImageStyle();
const viewStyle = {
...imageStyle,
backgroundColor,
// FIXME @lyubomir: Without the opacity below 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 } = this.state;
let extraStyle;
if (this.useDefaultAvatar) {
// On Android, the image loading indicators don't work unless the
// Glide image is actually created, so we cannot use display: none.
// Instead, render it off-screen, which does the trick.
if (Platform.OS === 'android') {
const windowDimensions = Dimensions.get('window');
extraStyle = {
bottom: -windowDimensions.height,
right: -windowDimensions.width
};
} else {
extraStyle = { display: 'none' };
}
}
return (
<FastImage
onError = { this._onAvatarLoadError }
onLoadEnd = { this._onAvatarLoaded }
resizeMode = 'contain'
source = { source }
style = { [ this._getImageStyle(), extraStyle ] } />
);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
render() {
const { source } = this.state;
return (
<Fragment>
{ source.uri && this._renderAvatar() }
{ this.useDefaultAvatar && this._renderDefaultAvatar() }
</Fragment>
);
}
}
/* eslint-disable react/no-multi-comp */
/**
* Implements an avatar as a React Native/mobile {@link Component}.
*
* Note: we use `key` in order to trigger a new component creation in case
* the URI changes.
*/
export default class Avatar extends PureComponent<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
render() {
return (
<AvatarContent
key = { this.props.uri }
{ ...this.props } />
);
}
}

View File

@@ -1,38 +0,0 @@
// @flow
import React, { Component } from 'react';
/**
* The type of the React {@link Component} props of {@link Avatar}.
*/
type Props = {
/**
* The URI of the {@link Avatar}.
*/
uri: string
};
/**
* Implements an avatar as a React/Web {@link Component}.
*/
export default class Avatar extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
render() {
// Propagate all props of this Avatar but the ones consumed by this
// Avatar to the img it renders.
// eslint-disable-next-line no-unused-vars
const { uri, ...props } = this.props;
return (
<img
{ ...props }
src = { uri } />
);
}
}

View File

@@ -2,8 +2,8 @@
import React, { Component } from 'react';
import { Text, View } from 'react-native';
import FastImage from 'react-native-fast-image';
import { Avatar } from '../../avatar';
import { translate } from '../../i18n';
import { JitsiParticipantConnectionStatus } from '../../lib-jitsi-meet';
import {
@@ -16,13 +16,7 @@ import { StyleType } from '../../styles';
import { TestHint } from '../../testing/components';
import { getTrackByMediaTypeAndParticipant } from '../../tracks';
import Avatar from './Avatar';
import {
getAvatarURL,
getParticipantById,
getParticipantDisplayName,
shouldRenderParticipantVideo
} from '../functions';
import { shouldRenderParticipantVideo } from '../functions';
import styles from './styles';
/**
@@ -30,14 +24,6 @@ import styles from './styles';
*/
type Props = {
/**
* The source (e.g. URI, URL) of the avatar image of the participant with
* {@link #participantId}.
*
* @private
*/
_avatar: string,
/**
* The connection status of the participant. Her video will only be rendered
* if the connection status is 'active'; otherwise, the avatar will be
@@ -192,7 +178,6 @@ class ParticipantView extends Component<Props> {
*/
render() {
const {
_avatar: avatar,
_connectionStatus: connectionStatus,
_renderVideo: renderVideo,
_videoTrack: videoTrack,
@@ -202,9 +187,6 @@ class ParticipantView extends Component<Props> {
const waitForVideoStarted = false;
// Is the avatar to be rendered?
const renderAvatar = Boolean(!renderVideo && avatar);
// If the connection has problems, we will "tint" the video / avatar.
const connectionProblem
= connectionStatus !== JitsiParticipantConnectionStatus.ACTIVE;
@@ -238,10 +220,12 @@ class ParticipantView extends Component<Props> {
zOrder = { this.props.zOrder }
zoomEnabled = { this.props.zoomEnabled } /> }
{ renderAvatar
&& <Avatar
size = { this.props.avatarSize }
uri = { avatar } /> }
{ !renderVideo
&& <View style = { styles.avatarContainer }>
<Avatar
participantId = { this.props.participantId }
size = { this.props.avatarSize } />
</View> }
{ useTint
@@ -265,45 +249,14 @@ class ParticipantView extends Component<Props> {
* @param {Object} ownProps - The React {@code Component} props passed to the
* associated (instance of) {@code ParticipantView}.
* @private
* @returns {{
* _avatar: string,
* _connectionStatus: string,
* _participantName: string,
* _renderVideo: boolean,
* _videoTrack: Track
* }}
* @returns {Props}
*/
function _mapStateToProps(state, ownProps) {
const { participantId } = ownProps;
const participant = getParticipantById(state, participantId);
let avatar;
let connectionStatus;
let participantName;
if (participant) {
avatar = getAvatarURL(participant);
connectionStatus = participant.connectionStatus;
participantName = getParticipantDisplayName(state, participant.id);
// Avatar (on React Native) now has the ability to generate an
// automatically-colored default image when no URI/URL is specified or
// when it fails to load. In order to make the coloring permanent(ish)
// per participant, Avatar will need something permanent(ish) per
// perticipant, obviously. A participant's ID is such a piece of data.
// But the local participant changes her ID as she joins, leaves.
// TODO @lyubomir: The participants may change their avatar URLs at
// runtime which means that, if their old and new avatar URLs fail to
// download, Avatar will change their automatically-generated colors.
avatar || participant.local || (avatar = `#${participant.id}`);
// ParticipantView knows before Avatar that an avatar URL will be used
// so it's advisable to prefetch here.
avatar && !avatar.startsWith('#')
&& FastImage.preload([ { uri: avatar } ]);
}
return {
_avatar: avatar,
_connectionStatus:
connectionStatus
|| JitsiParticipantConnectionStatus.ACTIVE,

View File

@@ -1,2 +1,3 @@
export { default as Avatar } from './Avatar';
// @flow
export { default as ParticipantView } from './ParticipantView';

View File

@@ -1,15 +1,17 @@
import { BoxModel, ColorPalette, createStyleSheet } from '../../styles';
// @flow
import { BoxModel, ColorPalette } from '../../styles';
/**
* The styles of the feature base/participants.
*/
export default createStyleSheet({
export default {
/**
* The style of the avatar of the participant.
* Container for the avatar in the view.
*/
avatar: {
alignSelf: 'center',
flex: 0
avatarContainer: {
alignItems: 'center',
justifyContent: 'center'
},
/**
@@ -42,4 +44,4 @@ export default createStyleSheet({
flex: 1,
justifyContent: 'center'
}
});
};

View File

@@ -1,11 +1,15 @@
// @flow
import { getAvatarURL as _getAvatarURL } from 'js-utils/avatar';
import {
getAvatarURL as _getAvatarURL,
getGravatarURL
} from 'js-utils/avatar';
import { toState } from '../redux';
import { JitsiParticipantConnectionStatus } from '../lib-jitsi-meet';
import { MEDIA_TYPE, shouldRenderVideoTrack } from '../media';
import { getTrackByMediaTypeAndParticipant } from '../tracks';
import { createDeferred } from '../util';
import {
DEFAULT_AVATAR_RELATIVE_PATH,
@@ -13,10 +17,27 @@ import {
MAX_DISPLAY_NAME_LENGTH,
PARTICIPANT_ROLE
} from './constants';
import { preloadImage } from './preloadImage';
declare var config: Object;
declare var interfaceConfig: Object;
/**
* Temp structures for avatar urls to be checked/preloaded.
*/
const AVATAR_QUEUE = [];
const AVATAR_CHECKED_URLS = new Map();
/* eslint-disable arrow-body-style */
const AVATAR_CHECKER_FUNCTIONS = [
participant => {
return participant && participant.avatarURL ? participant.avatarURL : null;
},
participant => {
return participant && participant.email ? getGravatarURL(participant.email) : null;
}
];
/* eslint-enable arrow-body-style */
/**
* Returns the URL of the image for the avatar of a specific participant.
*
@@ -84,6 +105,36 @@ export function getAvatarURLByParticipantId(
return participant && getAvatarURL(participant);
}
/**
* Resolves the first loadable avatar URL for a participant.
*
* @param {Object} participant - The participant to resolve avatars for.
* @returns {Promise}
*/
export function getFirstLoadableAvatarUrl(participant: Object) {
const deferred = createDeferred();
const fullPromise = deferred.promise
.then(() => _getFirstLoadableAvatarUrl(participant))
.then(src => {
if (AVATAR_QUEUE.length) {
const next = AVATAR_QUEUE.shift();
next.resolve();
}
return src;
});
if (AVATAR_QUEUE.length) {
AVATAR_QUEUE.push(deferred);
} else {
deferred.resolve();
}
return fullPromise;
}
/**
* Returns local participant from Redux state.
*
@@ -169,7 +220,6 @@ export function getParticipantCountWithFake(stateful: Object | Function) {
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @param {string} id - The ID of the participant's display name to retrieve.
* @private
* @returns {string}
*/
export function getParticipantDisplayName(
@@ -346,3 +396,35 @@ export function shouldRenderParticipantVideo(
&& shouldRenderVideoTrack(videoTrack, waitForVideoStarted);
}
/**
* Resolves the first loadable avatar URL for a participant.
*
* @param {Object} participant - The participant to resolve avatars for.
* @returns {?string}
*/
async function _getFirstLoadableAvatarUrl(participant) {
for (let i = 0; i < AVATAR_CHECKER_FUNCTIONS.length; i++) {
const url = AVATAR_CHECKER_FUNCTIONS[i](participant);
if (url) {
if (AVATAR_CHECKED_URLS.has(url)) {
if (AVATAR_CHECKED_URLS.get(url)) {
return url;
}
} else {
try {
const finalUrl = await preloadImage(url);
AVATAR_CHECKED_URLS.set(finalUrl, true);
return finalUrl;
} catch (e) {
AVATAR_CHECKED_URLS.set(url, false);
}
}
}
}
return undefined;
}

View File

@@ -21,7 +21,7 @@ import {
localParticipantLeft,
participantLeft,
participantUpdated,
showParticipantJoinedNotification
setLoadableAvatarUrl
} from './actions';
import {
DOMINANT_SPEAKER_CHANGED,
@@ -38,8 +38,9 @@ import {
PARTICIPANT_LEFT_SOUND_ID
} from './constants';
import {
getAvatarURLByParticipantId,
getFirstLoadableAvatarUrl,
getLocalParticipant,
getParticipantById,
getParticipantCount,
getParticipantDisplayName
} from './functions';
@@ -118,15 +119,7 @@ MiddlewareRegistry.register(store => next => action => {
case PARTICIPANT_JOINED: {
_maybePlaySounds(store, action);
const result = _participantJoinedOrUpdated(store, next, action);
const { participant: p } = action;
if (!p.local) {
store.dispatch(showParticipantJoinedNotification(getParticipantDisplayName(store.getState, p.id)));
}
return result;
return _participantJoinedOrUpdated(store, next, action);
}
case PARTICIPANT_LEFT:
@@ -323,8 +316,8 @@ function _maybePlaySounds({ getState, dispatch }, action) {
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _participantJoinedOrUpdated({ getState }, next, action) {
const { participant: { id, local, raisedHand } } = action;
function _participantJoinedOrUpdated({ dispatch, getState }, next, action) {
const { participant: { avatarURL, email, id, local, name, raisedHand } } = action;
// Send an external update of the local participant's raised hand state
// if a new raised hand state is defined in the action.
@@ -339,26 +332,29 @@ function _participantJoinedOrUpdated({ getState }, next, action) {
}
}
// Notify external listeners of potential avatarURL changes.
if (typeof APP === 'object') {
const oldAvatarURL = getAvatarURLByParticipantId(getState(), id);
// Allow the redux update to go through and compare the old avatar
// to the new avatar and emit out change events if necessary.
const result = next(action);
// Allow the redux update to go through and compare the old avatar
// to the new avatar and emit out change events if necessary.
const result = next(action);
const newAvatarURL = getAvatarURLByParticipantId(getState(), id);
if (avatarURL || email || id || name) {
const participantId = !id && local ? getLocalParticipant(getState()).id : id;
const updatedParticipant = getParticipantById(getState(), participantId);
if (oldAvatarURL !== newAvatarURL) {
const currentKnownId = local ? APP.conference.getMyUserId() : id;
APP.UI.refreshAvatarDisplay(currentKnownId, newAvatarURL);
APP.API.notifyAvatarChanged(currentKnownId, newAvatarURL);
}
return result;
getFirstLoadableAvatarUrl(updatedParticipant)
.then(url => {
dispatch(setLoadableAvatarUrl(participantId, url));
});
}
return next(action);
// Notify external listeners of potential avatarURL changes.
if (typeof APP === 'object') {
const currentKnownId = local ? APP.conference.getMyUserId() : id;
// Force update of local video getting a new id.
APP.UI.refreshAvatarDisplay(currentKnownId);
}
return result;
}
/**

View File

@@ -0,0 +1,16 @@
// @flow
import { Image } from 'react-native';
/**
* Tries to preload an image.
*
* @param {string} src - Source of the avatar.
* @returns {Promise}
*/
export function preloadImage(src: string): Promise<string> {
return new Promise((resolve, reject) => {
Image.prefetch(src).then(() => resolve(src), reject);
});
}

View File

@@ -0,0 +1,24 @@
// @flow
declare var config: Object;
/**
* Tries to preload an image.
*
* @param {string} src - Source of the avatar.
* @returns {Promise}
*/
export function preloadImage(src: string): Promise<string> {
if (typeof config === 'object' && config.disableThirdPartyRequests) {
return Promise.reject();
}
return new Promise((resolve, reject) => {
const image = document.createElement('img');
image.onload = () => resolve(src);
image.onerror = reject;
image.src = src;
});
}

View File

@@ -10,7 +10,8 @@ import {
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
PIN_PARTICIPANT
PIN_PARTICIPANT,
SET_LOADABLE_AVATAR_URL
} from './actionTypes';
import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants';
@@ -65,6 +66,7 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
*/
ReducerRegistry.register('features/base/participants', (state = [], action) => {
switch (action.type) {
case SET_LOADABLE_AVATAR_URL:
case DOMINANT_SPEAKER_CHANGED:
case PARTICIPANT_ID_CHANGED:
case PARTICIPANT_UPDATED:
@@ -133,6 +135,7 @@ function _participant(state: Object = {}, action) {
break;
}
case SET_LOADABLE_AVATAR_URL:
case PARTICIPANT_UPDATED: {
const { participant } = action; // eslint-disable-line no-shadow
let { id } = participant;
@@ -186,6 +189,7 @@ function _participantJoined({ participant }) {
dominantSpeaker,
email,
isFakeParticipant,
loadableAvatarUrl,
local,
name,
pinned,
@@ -221,6 +225,7 @@ function _participantJoined({ participant }) {
email,
id,
isFakeParticipant,
loadableAvatarUrl,
local: local || false,
name,
pinned: pinned || false,

View File

@@ -3,8 +3,7 @@
import React, { Component } from 'react';
import { Text } from 'react-native';
import { Icon } from '../../../font-icons';
import { Avatar } from '../../../participants';
import { Avatar } from '../../../avatar';
import { StyleType } from '../../../styles';
import { type Item } from '../../Types';
@@ -70,44 +69,6 @@ export default class AvatarListItem extends Component<Props> {
this._renderItemLine = this._renderItemLine.bind(this);
}
/**
* Helper function to render the content in the avatar container.
*
* @returns {React$Element}
*/
_getAvatarContent() {
const {
avatarSize = AVATAR_SIZE,
avatarTextStyle
} = this.props;
const { avatar, title } = this.props.item;
const isAvatarURL = Boolean(avatar && avatar.match(/^http[s]*:\/\//i));
if (isAvatarURL) {
return (
<Avatar
size = { avatarSize }
uri = { avatar } />
);
}
if (avatar && !isAvatarURL) {
return (
<Icon name = { avatar } />
);
}
return (
<Text
style = { [
styles.avatarContent,
avatarTextStyle
] }>
{ title.substr(0, 1).toUpperCase() }
</Text>
);
}
/**
* Implements {@code Component#render}.
*
@@ -118,26 +79,19 @@ export default class AvatarListItem extends Component<Props> {
avatarSize = AVATAR_SIZE,
avatarStyle
} = this.props;
const { colorBase, lines, title } = this.props.item;
const avatarStyles = {
...styles.avatar,
...this._getAvatarColor(colorBase),
...avatarStyle,
borderRadius: avatarSize / 2,
height: avatarSize,
width: avatarSize
};
const { avatar, colorBase, lines, title } = this.props.item;
return (
<Container
onClick = { this.props.onPress }
style = { styles.listItem }
underlayColor = { UNDERLAY_COLOR }>
<Container style = { styles.avatarContainer }>
<Container style = { avatarStyles }>
{ this._getAvatarContent() }
</Container>
</Container>
<Avatar
colorBase = { colorBase }
displayName = { title }
size = { avatarSize }
style = { avatarStyle }
uri = { avatar } />
<Container style = { styles.listItemDetails }>
<Text
numberOfLines = { 1 }
@@ -155,27 +109,6 @@ export default class AvatarListItem extends Component<Props> {
);
}
/**
* 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;
/**

View File

@@ -2,7 +2,6 @@
import { BoxModel, ColorPalette, createStyleSheet } from '../../../styles';
const AVATAR_OPACITY = 0.4;
const OVERLAY_FONT_COLOR = 'rgba(255, 255, 255, 0.6)';
export const AVATAR_SIZE = 65;
@@ -92,40 +91,6 @@ const PAGED_LIST_STYLES = {
};
const SECTION_LIST_STYLES = {
/**
* The style of the actual avatar.
*/
avatar: {
alignItems: 'center',
backgroundColor: `rgba(23, 160, 219, ${AVATAR_OPACITY})`,
justifyContent: 'center'
},
/**
* List of styles of the avatar of a remote meeting (not the default
* server). The number of colors are limited because they should match
* nicely.
*/
avatarColor1: {
backgroundColor: `rgba(232, 105, 156, ${AVATAR_OPACITY})`
},
avatarColor2: {
backgroundColor: `rgba(255, 198, 115, ${AVATAR_OPACITY})`
},
avatarColor3: {
backgroundColor: `rgba(128, 128, 255, ${AVATAR_OPACITY})`
},
avatarColor4: {
backgroundColor: `rgba(105, 232, 194, ${AVATAR_OPACITY})`
},
avatarColor5: {
backgroundColor: `rgba(234, 255, 128, ${AVATAR_OPACITY})`
},
/**
* The style of the avatar container that makes the avatar rounded.
*/

View File

@@ -124,9 +124,7 @@ MiddlewareRegistry.register(store => next => action => {
} else {
APP.UI.setVideoMuted(participantID, muted);
}
APP.UI.onPeerVideoTypeChanged(
participantID,
jitsiTrack.videoType);
APP.UI.onPeerVideoTypeChanged(participantID, jitsiTrack.videoType);
} else if (jitsiTrack.isLocal()) {
APP.conference.setAudioMuteStatus(muted);
} else {

View File

@@ -2,6 +2,22 @@
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Creates a deferred object.
*
* @returns {{promise, resolve, reject}}
*/
export function createDeferred(): Object {
const deferred = {};
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
return deferred;
}
/**
* Returns the namespace for all global variables, functions, etc that we need.
*

View File

@@ -12,6 +12,8 @@ import { getShareInfoText } from '../../invite';
import { setCalendarAPIAuthState } from '../actions';
import { findWindows } from 'windows-iana';
/**
* Constants used for interacting with the Microsoft API.
*
@@ -560,9 +562,13 @@ function requestCalendarEvents( // eslint-disable-line max-params
startDate.toISOString()}' and End/DateTime lt '${
endDate.toISOString()}'`;
const ianaTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
const windowsTimeZone = findWindows(ianaTimeZone);
return client
.api(`/me/calendars/${calendarId}/events`)
.filter(filter)
.header('Prefer', `outlook.timezone="${windowsTimeZone}"`)
.select('id,subject,start,end,location,body')
.orderby('createdDateTime DESC')
.get()

View File

@@ -3,7 +3,6 @@
import { PureComponent } from 'react';
import { getLocalizedDateFormatter } from '../../base/i18n';
import { getAvatarURLByParticipantId } from '../../base/participants';
/**
* Formatter string to display the message timestamp.
@@ -15,11 +14,6 @@ const TIMESTAMP_FORMAT = 'H:mm';
*/
export type Props = {
/**
* The URL of the avatar of the participant.
*/
_avatarURL: string,
/**
* The representation of a chat message.
*/
@@ -63,20 +57,3 @@ export default class AbstractChatMessage<P: Props> extends PureComponent<P> {
.format(TIMESTAMP_FORMAT);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Props} ownProps - The own props of the component.
* @returns {{
* _avatarURL: string
* }}
*/
export function _mapStateToProps(state: Object, ownProps: Props) {
const { message } = ownProps;
return {
_avatarURL: getAvatarURLByParticipantId(state, message.id)
};
}

View File

@@ -3,14 +3,10 @@
import React from 'react';
import { Text, View } from 'react-native';
import { Avatar } from '../../../base/avatar';
import { translate } from '../../../base/i18n';
import { Avatar } from '../../../base/participants';
import { connect } from '../../../base/redux';
import AbstractChatMessage, {
_mapStateToProps,
type Props
} from '../AbstractChatMessage';
import AbstractChatMessage, { type Props } from '../AbstractChatMessage';
import styles from './styles';
/**
@@ -81,8 +77,8 @@ class ChatMessage extends AbstractChatMessage<Props> {
return (
<View style = { styles.avatarWrapper }>
{ this.props.showAvatar && <Avatar
size = { styles.avatarWrapper.width }
uri = { this.props._avatarURL } />
participantId = { this.props.message.id }
size = { styles.avatarWrapper.width } />
}
</View>
);
@@ -115,4 +111,4 @@ class ChatMessage extends AbstractChatMessage<Props> {
}
}
export default translate(connect(_mapStateToProps)(ChatMessage));
export default translate(ChatMessage);

View File

@@ -372,7 +372,8 @@ class DeviceSelection extends AbstractDialogTab<Props, State> {
label: 'settings.selectCamera',
onSelect: selectedVideoInputId =>
super._onChange({ selectedVideoInputId }),
selectedDeviceId: this.props.selectedVideoInputId
selectedDeviceId: this.state.previewVideoTrack
? this.state.previewVideoTrack.getDeviceId() : null
},
{
devices: availableDevices.audioInput,
@@ -384,7 +385,8 @@ class DeviceSelection extends AbstractDialogTab<Props, State> {
label: 'settings.selectMic',
onSelect: selectedAudioInputId =>
super._onChange({ selectedAudioInputId }),
selectedDeviceId: this.props.selectedAudioInputId
selectedDeviceId: this.state.previewAudioTrack
? this.state.previewAudioTrack.getDeviceId() : null
}
];

View File

@@ -1,11 +1,18 @@
// @flow
import { CONFERENCE_FAILED, CONFERENCE_JOINED } from '../base/conference';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
KICKED_OUT
} from '../base/conference';
import { NOTIFY_CAMERA_ERROR, NOTIFY_MIC_ERROR } from '../base/devices';
import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
import {
PARTICIPANT_KICKED,
SET_LOADABLE_AVATAR_URL,
getAvatarURLByParticipantId,
getLocalParticipant
getLocalParticipant,
getParticipantById
} from '../base/participants';
import { MiddlewareRegistry } from '../base/redux';
import { appendSuffix } from '../display-name';
@@ -21,8 +28,31 @@ declare var interfaceConfig: Object;
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
// We need to do these before executing the rest of the middelware chain
switch (action.type) {
case SET_LOADABLE_AVATAR_URL: {
const { id, loadableAvatarUrl } = action.participant;
const participant = getParticipantById(
store.getState(),
id
);
const result = next(action);
if (participant.loadableAvatarUrl !== loadableAvatarUrl) {
APP.API.notifyAvatarChanged(
id,
loadableAvatarUrl
);
}
return result;
}
}
const result = next(action);
// These should happen after the rest of the middleware chain ran
switch (action.type) {
case CONFERENCE_FAILED: {
if (action.conference
@@ -49,10 +79,19 @@ MiddlewareRegistry.register(store => next => action => {
avatarURL: getAvatarURLByParticipantId(state, id)
}
);
break;
}
case KICKED_OUT:
APP.API.notifyKickedOut(
{
id: getLocalParticipant(store.getState()).id,
local: true
},
{ id: action.participant.getId() }
);
break;
case NOTIFY_CAMERA_ERROR:
if (action.error) {
APP.API.notifyOnCameraError(
@@ -66,6 +105,15 @@ MiddlewareRegistry.register(store => next => action => {
}
break;
case PARTICIPANT_KICKED:
APP.API.notifyKickedOut(
{
id: action.kicked,
local: false
},
{ id: action.kicker });
break;
case SET_FILMSTRIP_VISIBLE:
APP.API.notifyFilmstripDisplayChanged(action.visible);
break;

View File

@@ -0,0 +1,28 @@
// @flow
/**
* Utility class with no dependencies. Used in components that are stripped in separate bundles
* and requires as less dependencies as possible.
*/
/**
* Formats the conference pin in readable way for UI to display it.
* Formats the pin in 3 groups of digits:
* XXXX XXXX XX or XXXXX XXXXX XXX.
* The length of first and second group is Math.ceil(pin.length / 3)
*
* @param {Object} conferenceID - The conference id to format, string or number.
* @returns {string} - The formatted conference pin.
* @private
*/
export function _formatConferenceIDPin(conferenceID: Object) {
const conferenceIDStr = conferenceID.toString();
// let's split the conferenceID in 3 parts, to be easier to read
const partLen = Math.ceil(conferenceIDStr.length / 3);
return `${
conferenceIDStr.substring(0, partLen)} ${
conferenceIDStr.substring(partLen, 2 * partLen)} ${
conferenceIDStr.substring(2 * partLen, conferenceIDStr.length)}`;
}

View File

@@ -7,6 +7,7 @@ import {
Alert,
FlatList,
KeyboardAvoidingView,
Platform,
SafeAreaView,
TextInput,
TouchableOpacity,
@@ -51,6 +52,11 @@ type Props = AbstractProps & {
type State = AbstractState & {
/**
* State variable to keep track of the search field value.
*/
fieldValue: string,
/**
* True if a search is in progress, false otherwise.
*/
@@ -73,6 +79,7 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
defaultState = {
addToCallError: false,
addToCallInProgress: false,
fieldValue: '',
inviteItems: [],
searchInprogress: false,
selectableItems: []
@@ -101,6 +108,7 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
this._keyExtractor = this._keyExtractor.bind(this);
this._renderItem = this._renderItem.bind(this);
this._renderSeparator = this._renderSeparator.bind(this);
this._onClearField = this._onClearField.bind(this);
this._onCloseAddPeopleDialog = this._onCloseAddPeopleDialog.bind(this);
this._onInvite = this._onInvite.bind(this);
this._onPressItem = this._onPressItem.bind(this);
@@ -168,12 +176,15 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
<TextInput
autoCorrect = { false }
autoFocus = { true }
clearButtonMode = 'always' // iOS only
onChangeText = { this._onTypeQuery }
placeholder = {
this.props.t(`inviteDialog.${placeholderKey}`)
}
ref = { this._setFieldRef }
style = { styles.searchField } />
style = { styles.searchField }
value = { this.state.fieldValue } />
{ this._renderAndroidClearButton() }
</View>
<FlatList
ItemSeparatorComponent = { this._renderSeparator }
@@ -215,6 +226,22 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
return item.type === 'user' ? item.id || item.user_id : item.number;
}
_onClearField: () => void
/**
* Callback to clear the text field.
*
* @returns {void}
*/
_onClearField() {
this.setState({
fieldValue: ''
});
// Clear search results
this._onTypeQuery('');
}
_onCloseAddPeopleDialog: () => void
/**
@@ -288,6 +315,10 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
* @returns {void}
*/
_onTypeQuery(query) {
this.setState({
fieldValue: query
});
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.setState({
@@ -342,6 +373,31 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<Props, State> {
_renderItem: Object => ?React$Element<*>
/**
* Renders a button to clear the text field on Android.
*
* NOTE: For the best platform experience we use the native solution on iOS.
*
* @returns {React#Element<*>}
*/
_renderAndroidClearButton() {
if (Platform.OS !== 'android' || !this.state.fieldValue.length) {
return null;
}
return (
<TouchableOpacity
onPress = { this._onClearField }
style = { styles.clearButton }>
<View style = { styles.clearIconContainer }>
<Icon
name = 'close'
style = { styles.clearIcon } />
</View>
</TouchableOpacity>
);
}
/**
* Renders a single item in the {@code FlatList}.
*

View File

@@ -12,12 +12,6 @@ import { isAddPeopleEnabled, isDialOutEnabled } from '../../../functions';
type Props = AbstractButtonProps & {
/**
* Whether or not the feature to invite people to join the
* conference is available.
*/
_addPeopleEnabled: boolean,
/**
* The Redux dispatch function.
*/
@@ -42,16 +36,6 @@ class InviteButton extends AbstractButton<Props, *> {
_handleClick() {
this.props.dispatch(setAddPeopleDialogVisible(true));
}
/**
* Returns true if none of the invite methods are available.
*
* @protected
* @returns {boolean}
*/
_isDisabled() {
return !this.props._addPeopleEnabled;
}
}
/**
@@ -59,14 +43,17 @@ class InviteButton extends AbstractButton<Props, *> {
* props.
*
* @param {Object} state - The redux store/state.
* @param {Object} ownProps - The properties explicitly passed to the component
* instance.
* @private
* @returns {{
* _addPeopleEnabled: boolean
* }}
* @returns {Object}
*/
function _mapStateToProps(state) {
function _mapStateToProps(state: Object, ownProps: Object) {
const addPeopleEnabled = isAddPeopleEnabled(state) || isDialOutEnabled(state);
const { visible = Boolean(addPeopleEnabled) } = ownProps;
return {
_addPeopleEnabled: isAddPeopleEnabled(state) || isDialOutEnabled(state)
visible
};
}

View File

@@ -7,6 +7,8 @@ export const DARK_GREY = 'rgb(28, 32, 37)';
export const LIGHT_GREY = 'rgb(209, 219, 232)';
export const ICON_SIZE = 15;
const FIELD_COLOR = 'rgb(240, 243, 247)';
export default {
avatar: {
backgroundColor: LIGHT_GREY
@@ -21,6 +23,27 @@ export default {
flex: 1
},
clearButton: {
alignItems: 'center',
justifyContent: 'center',
marginLeft: 5
},
clearIcon: {
color: DARK_GREY,
fontSize: 18,
textAlign: 'center'
},
clearIconContainer: {
alignItems: 'center',
backgroundColor: FIELD_COLOR,
borderRadius: 12,
justifyContent: 'center',
height: 24,
width: 24
},
dialogWrapper: {
alignItems: 'stretch',
backgroundColor: ColorPalette.white,
@@ -56,7 +79,7 @@ export default {
},
searchField: {
backgroundColor: 'rgb(240, 243, 247)',
backgroundColor: FIELD_COLOR,
borderBottomRightRadius: 10,
borderTopRightRadius: 10,
color: DARK_GREY,
@@ -86,7 +109,7 @@ export default {
searchIconWrapper: {
alignItems: 'center',
backgroundColor: 'rgb(240, 243, 247)',
backgroundColor: FIELD_COLOR,
borderBottomLeftRadius: 10,
borderTopLeftRadius: 10,
flexDirection: 'row',

View File

@@ -2,10 +2,9 @@
import React, { Component } from 'react';
import { Avatar } from '../../../base/avatar';
import { MEDIA_TYPE } from '../../../base/media';
import {
Avatar,
getAvatarURL,
getParticipants,
getParticipantDisplayName,
getParticipantPresenceStatus
@@ -23,7 +22,7 @@ import styles from './styles';
type Props = {
/**
* The callee's information such as avatar and display name.
* The callee's information such as display name.
*/
_callee: Object,
@@ -46,7 +45,7 @@ class CalleeInfo extends Component<Props> {
*/
render() {
const {
avatar,
id,
name,
status = CALLING
} = this.props._callee;
@@ -60,7 +59,7 @@ class CalleeInfo extends Component<Props> {
{ ...this._style('ringing__content') }>
<Avatar
{ ...this._style('ringing__avatar') }
uri = { avatar } />
participantId = { id } />
<Container { ...this._style('ringing__status') }>
<PresenceLabel
defaultPresence = { status }
@@ -144,7 +143,7 @@ function _mapStateToProps(state) {
return {
_callee: {
avatar: getAvatarURL(poltergeist),
id,
name: getParticipantDisplayName(state, id),
status: getParticipantPresenceStatus(state, id)
},

View File

@@ -4,6 +4,8 @@ import React, { Component } from 'react';
import { translate } from '../../../../base/i18n';
import { _formatConferenceIDPin } from '../../../_utils';
/**
* The type of the React {@code Component} props of {@link ConferenceID}.
*/
@@ -49,7 +51,7 @@ class ConferenceID extends Component<Props> {
{ t('info.dialANumber') }
</div>
<div className = 'dial-in-conference-pin'>
{ `${t('info.dialInConferenceID')} ${conferenceID}` }
{ `${t('info.dialInConferenceID')} ${_formatConferenceIDPin(conferenceID)}` }
</div>
</div>
);

View File

@@ -4,6 +4,8 @@ import React, { Component } from 'react';
import { translate } from '../../../../base/i18n';
import { _formatConferenceIDPin } from '../../../_utils';
/**
* The type of the React {@code Component} props of {@link DialInNumber}.
*/
@@ -61,7 +63,7 @@ class DialInNumber extends Component<Props> {
</span>
<span className = 'spacer'>&nbsp;</span>
<span className = 'info-value'>
{ `${conferenceID}#` }
{ `${_formatConferenceIDPin(conferenceID)}#` }
</span>
</span>
</div>

View File

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

View File

@@ -38,7 +38,7 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case CONFERENCE_JOINED:
case PARTICIPANT_JOINED:
case PARTICIPANT_LEFT:
case PIN_PARTICIPANT:
@@ -47,13 +47,6 @@ MiddlewareRegistry.register(store => next => action => {
store.dispatch(selectParticipantInLargeVideo());
break;
case CONFERENCE_JOINED:
// Ensure a participant is selected on conference join. This addresses
// the case where video tracks were received before CONFERENCE_JOINED
// fired; without the conference selection may not happen.
store.dispatch(selectParticipant());
break;
case TRACK_UPDATED:
// In order to minimize re-calculations, we need to select participant
// only if the videoType of the current participant rendered in

View File

@@ -0,0 +1,14 @@
// @flow
import { StateListenerRegistry } from '../base/redux';
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
/**
* Updates the on stage participant video.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/large-video'].participantId,
/* listener */ participantId => {
VideoLayout.updateLargeVideo(participantId, true);
}
);

View File

@@ -4,8 +4,8 @@ import React, { Component } from 'react';
import { Image, Text, View } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { Avatar } from '../../../base/avatar';
import { translate } from '../../../base/i18n';
import { Avatar } from '../../../base/participants';
import { connect } from '../../../base/redux';
import AnswerButton from './AnswerButton';

View File

@@ -1,5 +1,9 @@
// @flow
import throttle from 'lodash/throttle';
import type { Dispatch } from 'redux';
import {
CLEAR_NOTIFICATIONS,
HIDE_NOTIFICATION,
@@ -7,7 +11,7 @@ import {
SHOW_NOTIFICATION
} from './actionTypes';
import { NOTIFICATION_TYPE } from './constants';
import { NOTIFICATION_TIMEOUT, NOTIFICATION_TYPE } from './constants';
/**
* Clears (removes) all the notifications.
@@ -102,3 +106,73 @@ export function showWarningNotification(props: Object) {
appearance: NOTIFICATION_TYPE.WARNING
});
}
/**
* An array of names of participants that have joined the conference. The array
* is replaced with an empty array as notifications are displayed.
*
* @private
* @type {string[]}
*/
let joinedParticipantsNames = [];
/**
* A throttled internal function that takes the internal list of participant
* names, {@code joinedParticipantsNames}, and triggers the display of a
* notification informing of their joining.
*
* @private
* @type {Function}
*/
const _throttledNotifyParticipantConnected = throttle((dispatch: Dispatch<any>) => {
const joinedParticipantsCount = joinedParticipantsNames.length;
let notificationProps;
if (joinedParticipantsCount >= 3) {
notificationProps = {
titleArguments: {
name: joinedParticipantsNames[0],
count: joinedParticipantsCount - 1
},
titleKey: 'notify.connectedThreePlusMembers'
};
} else if (joinedParticipantsCount === 2) {
notificationProps = {
titleArguments: {
first: joinedParticipantsNames[0],
second: joinedParticipantsNames[1]
},
titleKey: 'notify.connectedTwoMembers'
};
} else if (joinedParticipantsCount) {
notificationProps = {
titleArguments: {
name: joinedParticipantsNames[0]
},
titleKey: 'notify.connectedOneMember'
};
}
if (notificationProps) {
dispatch(
showNotification(notificationProps, NOTIFICATION_TIMEOUT));
}
joinedParticipantsNames = [];
}, 500, { leading: false });
/**
* Queues the display of a notification of a participant having connected to
* the meeting. The notifications are batched so that quick consecutive
* connection events are shown in one notification.
*
* @param {string} displayName - The name of the participant that connected.
* @returns {Function}
*/
export function showParticipantJoinedNotification(displayName: string) {
joinedParticipantsNames.push(displayName);
return (dispatch: Dispatch<any>) => _throttledNotifyParticipantConnected(dispatch);
}

View File

@@ -1,9 +1,67 @@
/* @flow */
import { getCurrentConference } from '../base/conference';
import { StateListenerRegistry } from '../base/redux';
import {
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
getParticipantById,
getParticipantDisplayName
} from '../base/participants';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { clearNotifications } from './actions';
import {
clearNotifications,
showNotification,
showParticipantJoinedNotification
} from './actions';
import { NOTIFICATION_TIMEOUT } from './constants';
declare var interfaceConfig: Object;
/**
* Middleware that captures actions to display notifications.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case PARTICIPANT_JOINED: {
const result = next(action);
const { participant: p } = action;
if (!p.local) {
store.dispatch(showParticipantJoinedNotification(
getParticipantDisplayName(store.getState, p.id)
));
}
return result;
}
case PARTICIPANT_LEFT: {
const participant = getParticipantById(
store.getState(),
action.participant.id
);
if (typeof interfaceConfig === 'object'
&& participant
&& !participant.local) {
store.dispatch(showNotification({
descriptionKey: 'notify.disconnected',
titleKey: 'notify.somebody',
title: participant.name
},
NOTIFICATION_TIMEOUT));
}
return next(action);
}
}
return next(action);
});
/**
* StateListenerRegistry provides a reliable way to detect the leaving of a

View File

@@ -2,11 +2,8 @@
import React, { Component } from 'react';
import {
Avatar,
getAvatarURL,
getLocalParticipant
} from '../../../base/participants';
import { Avatar } from '../../../base/avatar';
import { getLocalParticipant } from '../../../base/participants';
import { connect } from '../../../base/redux';
import OverlayFrame from './OverlayFrame';
@@ -18,9 +15,9 @@ import OverlayFrame from './OverlayFrame';
type Props = {
/**
* The source (e.g. URI, URL) of the avatar image of the local participant.
* The ID of the local participant.
*/
_avatar: string,
_localParticipantId: string,
/**
* The children components to be displayed into the overlay frame for
@@ -85,7 +82,7 @@ class FilmstripOnlyOverlayFrame extends Component<Props> {
}
</div>
<div className = 'inlay-filmstrip-only__avatar-container'>
<Avatar uri = { this.props._avatar } />
<Avatar participantId = { this.props._localParticipantId } />
{
this._renderIcon()
}
@@ -103,12 +100,12 @@ class FilmstripOnlyOverlayFrame extends Component<Props> {
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _avatar: string
* _localParticipantId: string
* }}
*/
function _mapStateToProps(state) {
return {
_avatar: getAvatarURL(getLocalParticipant(state) || {})
_localParticipantId: (getLocalParticipant(state) || {}).id
};
}

View File

@@ -3,15 +3,12 @@
import React, { Component } from 'react';
import { Text, View } from 'react-native';
import { Avatar } from '../../../base/avatar';
import { ColorSchemeRegistry } from '../../../base/color-scheme';
import {
BottomSheet
} from '../../../base/dialog';
import {
Avatar,
getAvatarURL,
getParticipantDisplayName
} from '../../../base/participants';
import { getParticipantDisplayName } from '../../../base/participants';
import { connect } from '../../../base/redux';
import { StyleType } from '../../../base/styles';
@@ -39,11 +36,6 @@ type Props = {
*/
participant: Object,
/**
* URL of the avatar of the participant.
*/
_avatarURL: string,
/**
* The color-schemed stylesheet of the BottomSheet.
*/
@@ -76,10 +68,11 @@ class RemoteVideoMenu extends Component<Props> {
* @inheritdoc
*/
render() {
const { participant } = this.props;
const buttonProps = {
afterClick: this._onCancel,
showLabel: true,
participantID: this.props.participant.id,
participantID: participant.id,
styles: this.props._bottomSheetStyles
};
@@ -87,8 +80,8 @@ class RemoteVideoMenu extends Component<Props> {
<BottomSheet onCancel = { this._onCancel }>
<View style = { styles.participantNameContainer }>
<Avatar
size = { AVATAR_SIZE }
uri = { this.props._avatarURL } />
participantId = { participant.id }
size = { AVATAR_SIZE } />
<Text style = { styles.participantNameLabel }>
{ this.props._participantDisplayName }
</Text>
@@ -120,7 +113,6 @@ class RemoteVideoMenu extends Component<Props> {
* @param {Object} ownProps - Properties of component.
* @private
* @returns {{
* _avatarURL: string,
* _bottomSheetStyles: StyleType,
* _participantDisplayName: string
* }}
@@ -129,7 +121,6 @@ function _mapStateToProps(state, ownProps) {
const { participant } = ownProps;
return {
_avatarURL: getAvatarURL(participant),
_bottomSheetStyles:
ColorSchemeRegistry.get(state, 'BottomSheet'),
_participantDisplayName: getParticipantDisplayName(

View File

@@ -2,7 +2,10 @@
import type { Dispatch } from 'redux';
import { appNavigate } from '../app';
import {
appNavigate,
maybeRedirectToWelcomePage
} from '../app';
import {
conferenceLeft,
JITSI_CONFERENCE_URL_KEY,
@@ -11,6 +14,8 @@ import {
import { hideDialog, openDialog } from '../base/dialog';
import { PasswordRequiredPrompt, RoomLockPrompt } from './components';
declare var APP: Object;
/**
* Begins a (user) request to lock a specific conference/room.
*
@@ -44,6 +49,16 @@ export function beginRoomLockRequest(conference: ?Object) {
*/
export function _cancelPasswordRequiredPrompt(conference: Object) {
return (dispatch: Dispatch<any>, getState: Function) => {
if (typeof APP !== 'undefined') {
// when we are redirecting the library should handle any
// unload and clean of the connection.
APP.API.notifyReadyToClose();
dispatch(maybeRedirectToWelcomePage());
return;
}
// Canceling PasswordRequiredPrompt is to navigate the app/user to
// WelcomePage. In other words, the canceling invalidates the
// locationURL. Make sure that the canceling indeed has the intent to

View File

@@ -9,6 +9,8 @@ import { Dialog } from '../../base/dialog';
import { translate } from '../../base/i18n';
import { connect } from '../../base/redux';
import { _cancelPasswordRequiredPrompt } from '../actions';
/**
* The type of the React {@code Component} props of
* {@link PasswordRequiredPrompt}.
@@ -63,6 +65,7 @@ class PasswordRequiredPrompt extends Component<Props, State> {
// Bind event handlers so they are only bound once per instance.
this._onPasswordChanged = this._onPasswordChanged.bind(this);
this._onCancel = this._onCancel.bind(this);
this._onSubmit = this._onSubmit.bind(this);
}
@@ -75,7 +78,9 @@ class PasswordRequiredPrompt extends Component<Props, State> {
render() {
return (
<Dialog
isModal = { true }
disableBlanketClickDismiss = { true }
isModal = { false }
onCancel = { this._onCancel }
onSubmit = { this._onSubmit }
titleKey = 'dialog.passwordRequired'
width = 'small'>
@@ -121,6 +126,22 @@ class PasswordRequiredPrompt extends Component<Props, State> {
});
}
_onCancel: () => boolean;
/**
* Dispatches action to cancel and dismiss this dialog.
*
* @private
* @returns {boolean}
*/
_onCancel() {
this.props.dispatch(
_cancelPasswordRequiredPrompt(this.props.conference));
return true;
}
_onSubmit: () => boolean;
/**

View File

@@ -8,10 +8,15 @@ import {
import { hideDialog } from '../base/dialog';
import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
import { MiddlewareRegistry } from '../base/redux';
import {
NOTIFICATION_TIMEOUT,
showNotification
} from '../notifications';
import UIEvents from '../../../service/UI/UIEvents';
import { _openPasswordRequiredPrompt } from './actions';
import { PasswordRequiredPrompt, RoomLockPrompt } from './components';
import { LOCKED_REMOTELY } from './constants';
declare var APP: Object;
@@ -29,14 +34,33 @@ MiddlewareRegistry.register(store => next => action => {
case CONFERENCE_FAILED:
return _conferenceFailed(store, next, action);
case LOCK_STATE_CHANGED:
case LOCK_STATE_CHANGED: {
// TODO Remove this logic when all components interested in the lock
// state change event are moved into react/redux.
if (typeof APP !== 'undefined') {
APP.UI.emitEvent(UIEvents.TOGGLE_ROOM_LOCK, action.locked);
}
break;
const previousLockedState = store.getState()['features/base/conference'].locked;
const result = next(action);
const currentLockedState = store.getState()['features/base/conference'].locked;
if (currentLockedState === LOCKED_REMOTELY) {
store.dispatch(
showNotification({
titleKey: 'notify.passwordSetRemotely'
}, NOTIFICATION_TIMEOUT));
} else if (previousLockedState === LOCKED_REMOTELY && !currentLockedState) {
store.dispatch(
showNotification({
titleKey: 'notify.passwordRemovedRemotely'
}, NOTIFICATION_TIMEOUT));
}
return result;
}
case SET_PASSWORD_FAILED:
return _setPasswordFailed(store, next, action);
}

View File

@@ -53,6 +53,11 @@ export type Props = {
* @extends Component
*/
class ProfileTab extends AbstractDialogTab<Props> {
static defaultProps = {
displayName: '',
email: ''
};
/**
* Initializes a new {@code ConnectedSettingsDialog} instance.
*

View File

@@ -2,14 +2,10 @@
import React, { Component } from 'react';
import { Avatar } from '../../../base/avatar';
import { getLocalParticipant } from '../../../base/participants';
import { connect } from '../../../base/redux';
import {
Avatar,
getAvatarURL,
getLocalParticipant
} from '../../../base/participants';
declare var interfaceConfig: Object;
/**
@@ -65,7 +61,6 @@ class OverflowMenuProfileItem extends Component<Props> {
const { _localParticipant, _unclickable } = this.props;
const classNames = `overflow-menu-item ${
_unclickable ? 'unclickable' : ''}`;
const avatarURL = getAvatarURL(_localParticipant);
let displayName;
if (_localParticipant && _localParticipant.name) {
@@ -80,7 +75,9 @@ class OverflowMenuProfileItem extends Component<Props> {
className = { classNames }
onClick = { this._onClick }>
<span className = 'overflow-menu-item-icon'>
<Avatar uri = { avatarURL } />
<Avatar
participantId = { _localParticipant.id }
size = { 24 } />
</span>
<span className = 'profile-text'>
{ displayName }

View File

@@ -3,9 +3,8 @@
import React, { Component } from 'react';
import { SafeAreaView, ScrollView, Text } from 'react-native';
import { Avatar } from '../../base/avatar';
import {
Avatar,
getAvatarURL,
getLocalParticipant,
getParticipantDisplayName
} from '../../base/participants';
@@ -42,16 +41,16 @@ type Props = {
*/
dispatch: Function,
/**
* The avatar URL to be rendered.
*/
_avatarURL: string,
/**
* Display name of the local participant.
*/
_displayName: string,
/**
* ID of the local participant.
*/
_localParticipantId: string,
/**
* Sets the side bar visible or hidden.
*/
@@ -90,9 +89,8 @@ class WelcomePageSideBar extends Component<Props> {
style = { styles.sideBar } >
<Header style = { styles.sideBarHeader }>
<Avatar
size = { SIDEBAR_AVATAR_SIZE }
style = { styles.avatar }
uri = { this.props._avatarURL } />
participantId = { this.props._localParticipantId }
size = { SIDEBAR_AVATAR_SIZE } />
<Text style = { styles.displayName }>
{ this.props._displayName }
</Text>
@@ -155,18 +153,14 @@ class WelcomePageSideBar extends Component<Props> {
*
* @param {Object} state - The redux state.
* @protected
* @returns {{
* _avatarURL: string,
* _displayName: string,
* _visible: boolean
* }}
* @returns {Props}
*/
function _mapStateToProps(state: Object) {
const localParticipant = getLocalParticipant(state);
const _localParticipant = getLocalParticipant(state);
return {
_avatarURL: getAvatarURL(localParticipant),
_displayName: getParticipantDisplayName(state, localParticipant.id),
_displayName: getParticipantDisplayName(state, _localParticipant.id),
_localParticipantId: _localParticipant.id,
_visible: state['features/welcome'].sideBarVisible
};
}

View File

@@ -38,14 +38,6 @@ export default createStyleSheet({
flexDirection: 'row'
},
/**
* Style of the avatar in te side bar.
*/
avatar: {
alignSelf: 'center',
flex: 0
},
/**
* Join button style.
*/