From c2cf09a2cada32b6d0dcb122132f541e38f89b87 Mon Sep 17 00:00:00 2001 From: theunafraid Date: Mon, 13 Jan 2020 19:12:25 +0200 Subject: [PATCH] Add conference timer (#4958) --- conference.js | 10 +- css/_subject.scss | 6 + .../prosody.cfg.lua-jvb.example | 5 + package-lock.json | 4 +- package.json | 2 +- react/features/base/conference/actionTypes.js | 10 + react/features/base/conference/actions.js | 25 ++- react/features/base/conference/functions.js | 14 ++ react/features/base/conference/reducer.js | 4 + .../conference/components/ConferenceTimer.js | 176 ++++++++++++++++++ .../native/ConferenceTimerDisplay.js | 23 +++ .../components/native/NavigationBar.js | 2 + .../conference/components/native/index.js | 1 + .../conference/components/native/styles.js | 10 +- .../components/web/ConferenceTimerDisplay.js | 16 ++ .../conference/components/web/Subject.js | 2 + .../conference/components/web/index.js | 2 + .../mod_conference_duration.lua | 5 + .../mod_conference_duration_component.lua | 66 +++++++ 19 files changed, 376 insertions(+), 7 deletions(-) create mode 100644 react/features/conference/components/ConferenceTimer.js create mode 100644 react/features/conference/components/native/ConferenceTimerDisplay.js create mode 100644 react/features/conference/components/web/ConferenceTimerDisplay.js create mode 100644 resources/prosody-plugins/mod_conference_duration.lua create mode 100644 resources/prosody-plugins/mod_conference_duration_component.lua diff --git a/conference.js b/conference.js index b66a8b4f62..c901b6dd22 100644 --- a/conference.js +++ b/conference.js @@ -41,6 +41,7 @@ import { conferenceJoined, conferenceLeft, conferenceSubjectChanged, + conferenceTimestampChanged, conferenceWillJoin, conferenceWillLeave, dataChannelOpened, @@ -1818,7 +1819,10 @@ export default { room.on( JitsiConferenceEvents.CONFERENCE_LEFT, - (...args) => APP.store.dispatch(conferenceLeft(room, ...args))); + (...args) => { + APP.store.dispatch(conferenceTimestampChanged(0)); + APP.store.dispatch(conferenceLeft(room, ...args)); + }); room.on( JitsiConferenceEvents.AUTH_STATUS_CHANGED, @@ -1948,6 +1952,10 @@ export default { JitsiConferenceEvents.DOMINANT_SPEAKER_CHANGED, id => APP.store.dispatch(dominantSpeakerChanged(id, room))); + room.on( + JitsiConferenceEvents.CONFERENCE_CREATED_TIMESTAMP, + conferenceTimestamp => APP.store.dispatch(conferenceTimestampChanged(conferenceTimestamp))); + room.on(JitsiConferenceEvents.CONNECTION_INTERRUPTED, () => { APP.store.dispatch(localParticipantConnectionStatusChanged( JitsiParticipantConnectionStatus.INTERRUPTED)); diff --git a/css/_subject.scss b/css/_subject.scss index 59a6aa98a6..8599eea1a0 100644 --- a/css/_subject.scss +++ b/css/_subject.scss @@ -23,4 +23,10 @@ &-text { vertical-align: middle; } + + &-conference-timer { + display: block; + font-size: 15px; + opacity: 0.6; + } } diff --git a/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example b/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example index b7f0db42e0..159af5e159 100644 --- a/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example +++ b/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example @@ -30,6 +30,7 @@ VirtualHost "jitmeet.example.com" certificate = "/etc/prosody/certs/jitmeet.example.com.crt"; } speakerstats_component = "speakerstats.jitmeet.example.com" + conference_duration_component = "conference_duration.jitmeet.example.com" -- we need bosh modules_enabled = { "bosh"; @@ -37,6 +38,7 @@ VirtualHost "jitmeet.example.com" "ping"; -- Enable mod_ping "speakerstats"; "turncredentials"; + "conference_duration"; } c2s_require_encryption = false @@ -65,3 +67,6 @@ Component "focus.jitmeet.example.com" Component "speakerstats.jitmeet.example.com" "speakerstats_component" muc_component = "conference.jitmeet.example.com" + +Component "conference_duration.jitmeet.example.com" "conference_duration_component" + muc_component = "conference.jitmeet.example.com" diff --git a/package-lock.json b/package-lock.json index 7d64a34a56..e1bf93d657 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10869,8 +10869,8 @@ } }, "lib-jitsi-meet": { - "version": "github:jitsi/lib-jitsi-meet#9ba4c15e85c6b270c367affab07180a069041ae9", - "from": "github:jitsi/lib-jitsi-meet#9ba4c15e85c6b270c367affab07180a069041ae9", + "version": "github:jitsi/lib-jitsi-meet#f35c74222f644b6420cddd8f6a4a2dfffe451f3b", + "from": "github:jitsi/lib-jitsi-meet#f35c74222f644b6420cddd8f6a4a2dfffe451f3b", "requires": { "@jitsi/sdp-interop": "0.1.14", "@jitsi/sdp-simulcast": "0.2.2", diff --git a/package.json b/package.json index c0c9b2e631..db2224fa72 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "js-utils": "github:jitsi/js-utils#400ce825d3565019946ee75d86ed773c6f21e117", "jsrsasign": "8.0.12", "jwt-decode": "2.2.0", - "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#9ba4c15e85c6b270c367affab07180a069041ae9", + "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#f35c74222f644b6420cddd8f6a4a2dfffe451f3b", "libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d", "lodash": "4.17.13", "moment": "2.19.4", diff --git a/react/features/base/conference/actionTypes.js b/react/features/base/conference/actionTypes.js index c1486c8e57..2a9bbec391 100644 --- a/react/features/base/conference/actionTypes.js +++ b/react/features/base/conference/actionTypes.js @@ -52,6 +52,16 @@ export const CONFERENCE_LEFT = 'CONFERENCE_LEFT'; */ export const CONFERENCE_SUBJECT_CHANGED = 'CONFERENCE_SUBJECT_CHANGED'; +/** +* The type of (redux) action, which indicates conference UTC timestamp changes. +* +* { +* type: CONFERENCE_TIMESTAMP_CHANGED +* timestamp: number +* } +*/ +export const CONFERENCE_TIMESTAMP_CHANGED = 'CONFERENCE_TIMESTAMP_CHANGED'; + /** * The type of (redux) action which signals that a specific conference will be * joined. diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index 9b809d89f2..1f67a7d00e 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -35,6 +35,7 @@ import { CONFERENCE_JOINED, CONFERENCE_LEFT, CONFERENCE_SUBJECT_CHANGED, + CONFERENCE_TIMESTAMP_CHANGED, CONFERENCE_WILL_JOIN, CONFERENCE_WILL_LEAVE, DATA_CHANNEL_OPENED, @@ -93,10 +94,16 @@ function _addConferenceListeners(conference, dispatch) { (...args) => dispatch(conferenceJoined(conference, ...args))); conference.on( JitsiConferenceEvents.CONFERENCE_LEFT, - (...args) => dispatch(conferenceLeft(conference, ...args))); + (...args) => { + dispatch(conferenceTimestampChanged(0)); + dispatch(conferenceLeft(conference, ...args)); + }); conference.on(JitsiConferenceEvents.SUBJECT_CHANGED, (...args) => dispatch(conferenceSubjectChanged(...args))); + conference.on(JitsiConferenceEvents.CONFERENCE_CREATED_TIMESTAMP, + (...args) => dispatch(conferenceTimestampChanged(...args))); + conference.on( JitsiConferenceEvents.KICKED, (...args) => dispatch(kickedOut(conference, ...args))); @@ -313,6 +320,22 @@ export function conferenceSubjectChanged(subject: string) { }; } +/** +* Signals that the conference timestamp has been changed. +* +* @param {number} conferenceTimestamp - The UTC timestamp. +* @returns {{ +* type: CONFERENCE_TIMESTAMP_CHANGED, +* conferenceTimestamp +* }} +*/ +export function conferenceTimestampChanged(conferenceTimestamp: number) { + return { + type: CONFERENCE_TIMESTAMP_CHANGED, + conferenceTimestamp + }; +} + /** * Adds any existing local tracks to a specific conference before the conference * is joined. Then signals the intention of the application to have the local diff --git a/react/features/base/conference/functions.js b/react/features/base/conference/functions.js index 5303e78348..8bb8304ce0 100644 --- a/react/features/base/conference/functions.js +++ b/react/features/base/conference/functions.js @@ -167,6 +167,20 @@ export function getConferenceName(stateful: Function | Object): string { || _.startCase(safeDecodeURIComponent(room)); } +/** +* Returns the UTC timestamp when the first participant joined the conference. +* +* @param {Function | Object} stateful - Reference that can be resolved to Redux +* state with the {@code toState} function. +* @returns {number} +*/ +export function getConferenceTimestamp(stateful: Function | Object): number { + const state = toState(stateful); + const { conferenceTimestamp } = state['features/base/conference']; + + return conferenceTimestamp; +} + /** * Returns the current {@code JitsiConference} which is joining or joined and is * not leaving. Please note the contrast with merely reading the diff --git a/react/features/base/conference/reducer.js b/react/features/base/conference/reducer.js index 17ffccdabe..d1ab669297 100644 --- a/react/features/base/conference/reducer.js +++ b/react/features/base/conference/reducer.js @@ -11,6 +11,7 @@ import { CONFERENCE_JOINED, CONFERENCE_LEFT, CONFERENCE_SUBJECT_CHANGED, + CONFERENCE_TIMESTAMP_CHANGED, CONFERENCE_WILL_JOIN, CONFERENCE_WILL_LEAVE, LOCK_STATE_CHANGED, @@ -59,6 +60,9 @@ ReducerRegistry.register( case CONFERENCE_SUBJECT_CHANGED: return set(state, 'subject', action.subject); + case CONFERENCE_TIMESTAMP_CHANGED: + return set(state, 'conferenceTimestamp', action.conferenceTimestamp); + case CONFERENCE_LEFT: case CONFERENCE_WILL_LEAVE: return _conferenceLeftOrWillLeave(state, action); diff --git a/react/features/conference/components/ConferenceTimer.js b/react/features/conference/components/ConferenceTimer.js new file mode 100644 index 0000000000..ce019b6a15 --- /dev/null +++ b/react/features/conference/components/ConferenceTimer.js @@ -0,0 +1,176 @@ +// @flow + +import { Component } from 'react'; + +import { connect } from '../../base/redux'; +import { getLocalizedDurationFormatter } from '../../base/i18n'; +import { getConferenceTimestamp } from '../../base/conference/functions'; +import { renderConferenceTimer } from '../'; + +/** + * The type of the React {@code Component} props of {@link ConferenceTimer}. + */ +type Props = { + + /** + * The UTC timestamp representing the time when first participant joined. + */ + _startTimestamp: ?number, + + /** + * The redux {@code dispatch} function. + */ + dispatch: Function +}; + +/** + * The type of the React {@code Component} state of {@link ConferenceTimer}. + */ +type State = { + + /** + * Value of current conference time. + */ + timerValue: string +}; + +/** + * ConferenceTimer react component. + * + * @class ConferenceTimer + * @extends Component + */ +class ConferenceTimer extends Component { + + /** + * Handle for setInterval timer. + */ + _interval; + + /** + * Initializes a new {@code ConferenceTimer} instance. + * + * @param {Props} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props: Props) { + super(props); + + this.state = { + timerValue: getLocalizedDurationFormatter(0) + }; + } + + /** + * Starts the conference timer when component will be + * mounted. + * + * @inheritdoc + */ + componentDidMount() { + this._startTimer(); + } + + /** + * Stops the conference timer when component will be + * unmounted. + * + * @inheritdoc + */ + componentWillUnmount() { + this._stopTimer(); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { timerValue } = this.state; + const { _startTimestamp } = this.props; + + if (!_startTimestamp) { + return null; + } + + return renderConferenceTimer(timerValue); + } + + /** + * Sets the current state values that will be used to render the timer. + * + * @param {number} refValueUTC - The initial UTC timestamp value. + * @param {number} currentValueUTC - The current UTC timestamp value. + * + * @returns {void} + */ + _setStateFromUTC(refValueUTC, currentValueUTC) { + + if (!refValueUTC || !currentValueUTC) { + return; + } + + if (currentValueUTC < refValueUTC) { + return; + } + + const timerMsValue = currentValueUTC - refValueUTC; + + const localizedTime = getLocalizedDurationFormatter(timerMsValue); + + this.setState({ + timerValue: localizedTime + }); + } + + /** + * Start conference timer. + * + * @returns {void} + */ + _startTimer() { + if (!this._interval) { + this._setStateFromUTC(this.props._startTimestamp, (new Date()).getTime()); + + this._interval = setInterval(() => { + this._setStateFromUTC(this.props._startTimestamp, (new Date()).getTime()); + }, 1000); + } + } + + /** + * Stop conference timer. + * + * @returns {void} + */ + _stopTimer() { + if (this._interval) { + clearInterval(this._interval); + } + + this.setState({ + timerValue: getLocalizedDurationFormatter(0) + }); + } +} + +/** + * Maps (parts of) the Redux state to the associated + * {@code ConferenceTimer}'s props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * _startTimestamp: number + * }} + */ +export function _mapStateToProps(state: Object) { + + return { + _startTimestamp: getConferenceTimestamp(state) + }; +} + +export default connect(_mapStateToProps)(ConferenceTimer); diff --git a/react/features/conference/components/native/ConferenceTimerDisplay.js b/react/features/conference/components/native/ConferenceTimerDisplay.js new file mode 100644 index 0000000000..302e898447 --- /dev/null +++ b/react/features/conference/components/native/ConferenceTimerDisplay.js @@ -0,0 +1,23 @@ +// @flow + +import React from 'react'; +import { Text } from 'react-native'; + +import styles from './styles'; + +/** + * Returns native element to be rendered. + * + * @param {string} timerValue - String to display as time. + * + * @returns {ReactElement} + */ +export default function renderConferenceTimer(timerValue: string) { + return ( + + { timerValue } + + ); +} diff --git a/react/features/conference/components/native/NavigationBar.js b/react/features/conference/components/native/NavigationBar.js index ae9d26b3d1..02fecfbd76 100644 --- a/react/features/conference/components/native/NavigationBar.js +++ b/react/features/conference/components/native/NavigationBar.js @@ -9,6 +9,7 @@ import { connect } from '../../../base/redux'; import { PictureInPictureButton } from '../../../mobile/picture-in-picture'; import { isToolboxVisible } from '../../../toolbox'; +import ConferenceTimer from '../ConferenceTimer'; import styles, { NAVBAR_GRADIENT_COLORS } from './styles'; type Props = { @@ -63,6 +64,7 @@ class NavigationBar extends Component { style = { styles.roomName }> { this.props._meetingName } + ]; diff --git a/react/features/conference/components/native/index.js b/react/features/conference/components/native/index.js index 0910dcaff3..58fd3dbc95 100644 --- a/react/features/conference/components/native/index.js +++ b/react/features/conference/components/native/index.js @@ -1,3 +1,4 @@ // @flow export { default as Conference } from './Conference'; +export { default as renderConferenceTimer } from './ConferenceTimerDisplay'; diff --git a/react/features/conference/components/native/styles.js b/react/features/conference/components/native/styles.js index 8d6c6e8c94..48467d55b0 100644 --- a/react/features/conference/components/native/styles.js +++ b/react/features/conference/components/native/styles.js @@ -105,6 +105,12 @@ export default { paddingHorizontal: 14 }, + roomTimer: { + color: ColorPalette.white, + fontSize: 15, + opacity: 0.6 + }, + roomName: { color: ColorPalette.white, fontSize: 17, @@ -112,8 +118,8 @@ export default { }, roomNameWrapper: { - flexDirection: 'row', - justifyContent: 'center', + flexDirection: 'column', + alignItems: 'center', left: 0, paddingHorizontal: 48, position: 'absolute', diff --git a/react/features/conference/components/web/ConferenceTimerDisplay.js b/react/features/conference/components/web/ConferenceTimerDisplay.js new file mode 100644 index 0000000000..7b4cd7d619 --- /dev/null +++ b/react/features/conference/components/web/ConferenceTimerDisplay.js @@ -0,0 +1,16 @@ +// @flow + +import React from 'react'; + +/** + * Returns web element to be rendered. + * + * @param {string} timerValue - String to display as time. + * + * @returns {ReactElement} + */ +export default function renderConferenceTimer(timerValue: string) { + return ( + { timerValue } + ); +} diff --git a/react/features/conference/components/web/Subject.js b/react/features/conference/components/web/Subject.js index 57bf05f5b3..8535264526 100644 --- a/react/features/conference/components/web/Subject.js +++ b/react/features/conference/components/web/Subject.js @@ -7,6 +7,7 @@ import { getParticipantCount } from '../../../base/participants/functions'; import { connect } from '../../../base/redux'; import { isToolboxVisible } from '../../../toolbox'; +import ConferenceTimer from '../ConferenceTimer'; import ParticipantsCount from './ParticipantsCount'; /** @@ -51,6 +52,7 @@ class Subject extends Component {
{ _subject } { _showParticipantCount && } +
); } diff --git a/react/features/conference/components/web/index.js b/react/features/conference/components/web/index.js index 0910dcaff3..0f0999c229 100644 --- a/react/features/conference/components/web/index.js +++ b/react/features/conference/components/web/index.js @@ -1,3 +1,5 @@ // @flow export { default as Conference } from './Conference'; +export { default as renderConferenceTimer } from './ConferenceTimerDisplay'; + diff --git a/resources/prosody-plugins/mod_conference_duration.lua b/resources/prosody-plugins/mod_conference_duration.lua new file mode 100644 index 0000000000..629a5671fd --- /dev/null +++ b/resources/prosody-plugins/mod_conference_duration.lua @@ -0,0 +1,5 @@ +local conference_duration_component + = module:get_option_string( + "conference_duration_component", "conference_duration"..module.host); + +module:add_identity("component", "conference_duration", conference_duration_component); diff --git a/resources/prosody-plugins/mod_conference_duration_component.lua b/resources/prosody-plugins/mod_conference_duration_component.lua new file mode 100644 index 0000000000..eed9b15106 --- /dev/null +++ b/resources/prosody-plugins/mod_conference_duration_component.lua @@ -0,0 +1,66 @@ +local st = require "util.stanza"; +local socket = require "socket"; +local json = require "util.json"; +local ext_events = module:require "ext_events"; +local it = require "util.iterators"; + +-- we use async to detect Prosody 0.10 and earlier +local have_async = pcall(require, "util.async"); +if not have_async then + module:log("warn", "conference duration will not work with Prosody version 0.10 or less."); + return; +end + +local muc_component_host = module:get_option_string("muc_component"); +if muc_component_host == nil then + log("error", "No muc_component specified. No muc to operate on!"); + return; +end + +log("info", "Starting conference duration timer for %s", muc_component_host); + +function occupant_joined(event) + local room = event.room; + local occupant = event.occupant; + + local participant_count = it.count(room:each_occupant()); + + if participant_count > 1 then + + if room.created_timestamp == nil then + room.created_timestamp = os.time(os.date("!*t")) * 1000; -- Lua provides UTC time in seconds, so convert to milliseconds + end + + local body_json = {}; + body_json.type = 'conference_duration'; + body_json.created_timestamp = room.created_timestamp; + + local stanza = st.message({ + from = module.host; + to = occupant.jid; + }) + :tag("json-message", {xmlns='http://jitsi.org/jitmeet'}) + :text(json.encode(body_json)):up(); + + room:route_stanza(stanza); + end +end + +-- executed on every host added internally in prosody, including components +function process_host(host) + if host == muc_component_host then -- the conference muc component + module:log("info", "Hook to muc events on %s", host); + + local muc_module = module:context(host) + muc_module:hook("muc-occupant-joined", occupant_joined, -1); + end +end + +if prosody.hosts[muc_component_host] == nil then + module:log("info", "No muc component found, will listen for it: %s", muc_component_host); + + -- when a host or component is added + prosody.events.add_handler("host-activated", process_host); +else + process_host(muc_component_host); +end