init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
/**
* Action type to signal the deletion of a list entry.
*
* {
* type: DELETE_RECENT_LIST_ENTRY,
* entryId: Object
* }
*/
export const DELETE_RECENT_LIST_ENTRY = 'DELETE_RECENT_LIST_ENTRY';
/**
* Action type to signal a new addition to the list.
*
* {
* type: _STORE_CURRENT_CONFERENCE,
* locationURL: Object
* }
*
* @protected
*/
export const _STORE_CURRENT_CONFERENCE = '_STORE_CURRENT_CONFERENCE';
/**
* Action type to signal that a new conference duration info is available.
*
* {
* type: _UPDATE_CONFERENCE_DURATION,
* locationURL: Object
* }
*
* @protected
*/
export const _UPDATE_CONFERENCE_DURATION
= '_UPDATE_CONFERENCE_DURATION';

View File

@@ -0,0 +1,56 @@
import {
DELETE_RECENT_LIST_ENTRY,
_STORE_CURRENT_CONFERENCE,
_UPDATE_CONFERENCE_DURATION
} from './actionTypes';
/**
* Deletes a recent list entry based on url and date.
*
* @param {Object} entryId - An object constructed of the url and the date of
* the entry for easy identification.
* @returns {{
* type: DELETE_RECENT_LIST_ENTRY,
* entryId: Object
* }}
*/
export function deleteRecentListEntry(entryId: Object) {
return {
type: DELETE_RECENT_LIST_ENTRY,
entryId
};
}
/**
* Action to initiate a new addition to the list.
*
* @param {Object} locationURL - The current location URL.
* @protected
* @returns {{
* type: _STORE_CURRENT_CONFERENCE,
* locationURL: Object
* }}
*/
export function _storeCurrentConference(locationURL: Object) {
return {
type: _STORE_CURRENT_CONFERENCE,
locationURL
};
}
/**
* Action to initiate the update of the duration of the last conference.
*
* @param {Object} locationURL - The current location URL.
* @protected
* @returns {{
* type: _UPDATE_CONFERENCE_DURATION,
* locationURL: Object
* }}
*/
export function _updateConferenceDuration(locationURL: Object) {
return {
type: _UPDATE_CONFERENCE_DURATION,
locationURL
};
}

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { createRecentClickedEvent, createRecentSelectedEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { appNavigate } from '../../app/actions';
import { IStore } from '../../app/types';
import AbstractPage from '../../base/react/components/AbstractPage';
import { Container, Text } from '../../base/react/components/index';
import styles from './styles';
/**
* The type of the React {@code Component} props of {@link AbstractRecentList}.
*/
interface IProps {
/**
* The redux store's {@code dispatch} function.
*/
dispatch: IStore['dispatch'];
/**
* The translate function.
*/
t: Function;
}
/**
* An abstract component for the recent list.
*
*/
export default class AbstractRecentList<P extends IProps> extends AbstractPage<P> {
/**
* Initializes a new {@code RecentList} instance.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
this._onPress = this._onPress.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}. Invoked
* immediately after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
sendAnalytics(createRecentSelectedEvent());
}
/**
* Returns a list empty component if a custom one has to be rendered instead
* of the default one in the {@link NavigateSectionList}.
*
* @private
* @returns {React$Component}
*/
_getRenderListEmptyComponent() {
const { t } = this.props;
const descriptionId = 'meetings-list-empty-description';
return (
<Container
aria-describedby = { descriptionId }
aria-label = { t('welcomepage.recentList') }
className = 'meetings-list-empty'
role = 'region'
style = { styles.emptyListContainer as any }>
<Text // @ts-ignore
className = 'description'
id = { descriptionId }
style = { styles.emptyListText as any }>
{ t('welcomepage.recentListEmpty') }
</Text>
</Container>
);
}
/**
* Handles the list's navigate action.
*
* @private
* @param {string} url - The url string to navigate to.
* @returns {void}
*/
_onPress(url: string) {
const { dispatch } = this.props;
sendAnalytics(createRecentClickedEvent('meeting.tile'));
dispatch(appNavigate(url));
}
}

View File

@@ -0,0 +1,37 @@
import { connect } from 'react-redux';
import { translate } from '../../base/i18n/functions';
import { IconTrash } from '../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { deleteRecentListEntry } from '../actions';
export interface IProps extends AbstractButtonProps {
/**
* The ID of the entry to be deleted.
*/
itemId: Object;
}
/**
* A recent list menu button which deletes the selected entry.
*/
class DeleteItemButton extends AbstractButton<IProps> {
override accessibilityLabel = 'welcomepage.recentListDelete';
override icon = IconTrash;
override label = 'welcomepage.recentListDelete';
/**
* Handles clicking / pressing the button.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, itemId } = this.props;
dispatch(deleteRecentListEntry(itemId));
}
}
export default translate(connect()(DeleteItemButton));

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { GestureResponderEvent, TouchableWithoutFeedback, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { getDefaultURL } from '../../app/functions.native';
import { IReduxState, IStore } from '../../app/types';
import { openSheet } from '../../base/dialog/actions';
import { translate } from '../../base/i18n/functions';
import NavigateSectionList from '../../base/react/components/native/NavigateSectionList';
import { Item, Section } from '../../base/react/types';
import styles from '../../welcome/components/styles';
import { isRecentListEnabled, toDisplayableList } from '../functions.native';
import AbstractRecentList from './AbstractRecentList';
import RecentListItemMenu from './RecentListItemMenu.native';
/**
* The type of the React {@code Component} props of {@link RecentList}.
*/
interface IProps extends WithTranslation {
/**
* The default server URL.
*/
_defaultServerURL: string;
/**
* The recent list from the Redux store.
*/
_recentList: Array<Section>;
/**
* Renders the list disabled.
*/
disabled: boolean;
/**
* The redux store's {@code dispatch} function.
*/
dispatch: IStore['dispatch'];
/**
* Callback to be invoked when pressing the list container.
*/
onListContainerPress?: (e?: GestureResponderEvent) => void;
}
/**
* A class that renders the list of the recently joined rooms.
*
*/
class RecentList extends AbstractRecentList<IProps> {
/**
* Initializes a new {@code RecentList} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onLongPress = this._onLongPress.bind(this);
}
/**
* Implements the React Components's render method.
*
* @inheritdoc
*/
override render() {
if (!isRecentListEnabled()) {
return null;
}
const {
disabled,
onListContainerPress,
t,
_defaultServerURL,
_recentList
} = this.props; // @ts-ignore
const recentList = toDisplayableList(_recentList, t, _defaultServerURL);
return (
<TouchableWithoutFeedback
onPress = { onListContainerPress }>
<View style = { (disabled ? styles.recentListDisabled : styles.recentList) as ViewStyle }>
<NavigateSectionList
disabled = { disabled }
onLongPress = { this._onLongPress }
onPress = { this._onPress }
renderListEmptyComponent
= { this._getRenderListEmptyComponent() }
// @ts-ignore
sections = { recentList } />
</View>
</TouchableWithoutFeedback>
);
}
/**
* Handles the list's navigate action.
*
* @private
* @param {Object} item - The item which was long pressed.
* @returns {void}
*/
_onLongPress(item: Item) {
this.props.dispatch(openSheet(RecentListItemMenu, { item }));
}
}
/**
* Maps redux state to component props.
*
* @param {Object} state - The redux state.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState) {
return {
_defaultServerURL: getDefaultURL(state),
_recentList: state['features/recent-list']
};
}
// @ts-ignore
export default translate(connect(_mapStateToProps)(RecentList));

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../app/types';
import { translate } from '../../base/i18n/functions';
import MeetingsList from '../../base/react/components/web/MeetingsList';
import { deleteRecentListEntry } from '../actions';
import { isRecentListEnabled, toDisplayableList } from '../functions.web';
import AbstractRecentList from './AbstractRecentList';
/**
* The type of the React {@code Component} props of {@link RecentList}.
*/
interface IProps extends WithTranslation {
/**
* The recent list from the Redux store.
*/
_recentList: Array<any>;
/**
* Renders the list disabled.
*/
disabled?: boolean;
/**
* The redux store's {@code dispatch} function.
*/
dispatch: IStore['dispatch'];
}
/**
* The cross platform container rendering the list of the recently joined rooms.
*
*/
class RecentList extends AbstractRecentList<IProps> {
/**
* Initializes a new {@code RecentList} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._getRenderListEmptyComponent
= this._getRenderListEmptyComponent.bind(this);
this._onPress = this._onPress.bind(this);
this._onItemDelete = this._onItemDelete.bind(this);
}
/**
* Deletes a recent entry.
*
* @param {Object} entry - The entry to be deleted.
* @inheritdoc
*/
_onItemDelete(entry: Object) {
this.props.dispatch(deleteRecentListEntry(entry));
}
/**
* Implements the React Components's render method.
*
* @inheritdoc
*/
override render() {
if (!isRecentListEnabled()) {
return null;
}
const {
disabled,
_recentList
} = this.props;
const recentList = toDisplayableList(_recentList);
return (
<MeetingsList
disabled = { Boolean(disabled) }
hideURL = { true }
listEmptyComponent = { this._getRenderListEmptyComponent() }
meetings = { recentList }
onItemDelete = { this._onItemDelete }
onPress = { this._onPress } />
);
}
}
/**
* Maps redux state to component props.
*
* @param {Object} state - The redux state.
* @returns {{
* _defaultServerURL: string,
* _recentList: Array
* }}
*/
export function _mapStateToProps(state: IReduxState) {
return {
_recentList: state['features/recent-list']
};
}
export default translate(connect(_mapStateToProps)(RecentList));

View File

@@ -0,0 +1,102 @@
import React, { PureComponent } from 'react';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IStore } from '../../app/types';
import { hideSheet } from '../../base/dialog/actions';
import BottomSheet from '../../base/dialog/components/native/BottomSheet';
import { bottomSheetStyles } from '../../base/dialog/components/native/styles';
import { Item } from '../../base/react/types';
import DeleteItemButton from './DeleteItemButton.native';
import ShowDialInInfoButton from './ShowDialInInfoButton.native';
import styles from './styles.native';
interface IProps {
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Item being rendered in this menu.
*/
item: Item;
}
/**
* Class to implement a popup menu that opens upon long pressing a recent list item.
*/
class RecentListItemMenu extends PureComponent<IProps> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onCancel = this._onCancel.bind(this);
this._renderMenuHeader = this._renderMenuHeader.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const { item } = this.props;
const buttonProps = {
afterClick: this._onCancel,
itemId: item.id,
showLabel: true,
styles: bottomSheetStyles.buttons
};
return (
<BottomSheet
renderHeader = { this._renderMenuHeader }>
<DeleteItemButton { ...buttonProps } />
<ShowDialInInfoButton { ...buttonProps } />
</BottomSheet>
);
}
/**
* Callback to hide this menu.
*
* @private
* @returns {boolean}
*/
_onCancel() {
this.props.dispatch(hideSheet());
}
/**
* Function to render the menu's header.
*
* @returns {React$Element}
*/
_renderMenuHeader() {
const { item } = this.props;
return (
<View
style = { [
bottomSheetStyles.sheet,
styles.entryNameContainer as ViewStyle
] }>
<Text
ellipsizeMode = { 'middle' }
numberOfLines = { 1 }
style = { styles.entryNameLabel as TextStyle }>
{ item.title }
</Text>
</View>
);
}
}
export default connect()(RecentListItemMenu);

View File

@@ -0,0 +1,40 @@
import { connect } from 'react-redux';
import { translate } from '../../base/i18n/functions';
import { IconInfoCircle } from '../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { navigateRoot } from '../../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../../mobile/navigation/routes';
export interface IProps extends AbstractButtonProps {
/**
* The ID of the entry to be deleted.
*/
itemId: any;
}
/**
* A recent list menu button which opens the dial-in info dialog.
*/
class ShowDialInInfoButton extends AbstractButton<IProps> {
override accessibilityLabel = 'welcomepage.info';
override icon = IconInfoCircle;
override label = 'welcomepage.info';
/**
* Handles clicking / pressing the button.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { itemId } = this.props;
navigateRoot(screen.dialInSummary, {
summaryUrl: itemId.url
});
}
}
export default translate(connect()(ShowDialInInfoButton));

View File

@@ -0,0 +1,45 @@
import { ColorPalette } from '../../base/styles/components/styles/ColorPalette';
import { createStyleSheet } from '../../base/styles/functions.native';
/**
* The styles of the React {@code Component}s of the feature recent-list i.e.
* {@code CalendarList}.
*/
export default createStyleSheet({
/**
* Text style of the empty recent list message.
*/
emptyListText: {
backgroundColor: 'transparent',
color: 'rgba(255, 255, 255, 0.6)',
textAlign: 'center'
},
/**
* The style of the empty recent list container.
*/
emptyListContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 20
},
entryNameContainer: {
alignItems: 'center',
borderBottomColor: ColorPalette.lightGrey,
borderBottomWidth: 1,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
flexDirection: 'row',
justifyContent: 'center',
height: 48
},
entryNameLabel: {
color: ColorPalette.lightGrey,
flexShrink: 1,
fontSize: 16,
opacity: 0.90
}
});

View File

@@ -0,0 +1,4 @@
export default {
emptyListContainer: {},
emptyListText: {}
};

View File

@@ -0,0 +1,157 @@
import {
getLocalizedDateFormatter,
getLocalizedDurationFormatter
} from '../base/i18n/dateUtil';
import NavigateSectionList from '../base/react/components/native/NavigateSectionList';
import { parseURIString, safeDecodeURIComponent } from '../base/util/uri';
import { IRecentItem } from './types';
/**
* Creates a displayable list item of a recent list entry.
*
* @private
* @param {Object} item - The recent list entry.
* @param {string} defaultServerURL - The default server URL.
* @param {Function} t - The translate function.
* @returns {Object}
*/
function toDisplayableItem(item: IRecentItem,
defaultServerURL: string, t: Function) {
const location = parseURIString(item.conference);
const baseURL = `${location.protocol}//${location.host}`;
const serverName = baseURL === defaultServerURL ? null : location.host;
return {
colorBase: serverName,
id: {
date: item.date,
url: item.conference
},
key: `key-${item.conference}-${item.date}`,
lines: [
_toDateString(item.date, t),
_toDurationString(item.duration),
serverName
],
title: safeDecodeURIComponent(location.room),
url: item.conference
};
}
/**
* Generates a duration string for the item.
*
* @private
* @param {number} duration - The item's duration.
* @returns {string}
*/
function _toDurationString(duration: number) {
if (duration) {
return getLocalizedDurationFormatter(duration);
}
return null;
}
/**
* Generates a date string for the item.
*
* @private
* @param {number} itemDate - The item's timestamp.
* @param {Function} t - The translate function.
* @returns {string}
*/
function _toDateString(itemDate: number, t: Function) {
const m = getLocalizedDateFormatter(itemDate);
const date = new Date(itemDate);
const dateInMs = date.getTime();
const now = new Date();
const todayInMs = new Date().setHours(0, 0, 0, 0);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const yesterdayInMs = yesterday.getTime();
if (dateInMs >= todayInMs) {
return m.fromNow();
} else if (dateInMs >= yesterdayInMs) {
return `${t('dateUtils.yesterday')}, ${m.format('h:mm A')}`;
} else if (date.getFullYear() !== now.getFullYear()) {
// We only want to include the year in the date if its not the current
// year.
return m.format('ddd, MMMM DD h:mm A, gggg');
}
return m.format('ddd, MMMM DD h:mm A');
}
/**
* Transforms the history list to a displayable list
* with sections.
*
* @private
* @param {Array<Object>} recentList - The recent list form the redux store.
* @param {Function} t - The translate function.
* @param {string} defaultServerURL - The default server URL.
* @returns {Array<Object>}
*/
export function toDisplayableList(recentList: IRecentItem[],
t: Function, defaultServerURL: string) {
const { createSection } = NavigateSectionList;
const todaySection = createSection(t('dateUtils.today'), 'today');
const yesterdaySection
= createSection(t('dateUtils.yesterday'), 'yesterday');
const earlierSection
= createSection(t('dateUtils.earlier'), 'earlier');
const today = new Date();
const todayString = today.toDateString();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayString = yesterday.toDateString();
for (const item of recentList) {
const itemDateString = new Date(item.date).toDateString();
const displayableItem = toDisplayableItem(item, defaultServerURL, t);
if (itemDateString === todayString) {
todaySection.data.push(displayableItem);
} else if (itemDateString === yesterdayString) {
yesterdaySection.data.push(displayableItem);
} else {
earlierSection.data.push(displayableItem);
}
}
const displayableList = [];
// the recent list in the redux store has the latest date in the last index
// therefore all the sectionLists' data that was created by parsing through
// the recent list is in reverse order and must be reversed for the most
// item to show first
if (todaySection.data.length) {
todaySection.data.reverse();
displayableList.push(todaySection);
}
if (yesterdaySection.data.length) {
yesterdaySection.data.reverse();
displayableList.push(yesterdaySection);
}
if (earlierSection.data.length) {
earlierSection.data.reverse();
displayableList.push(earlierSection);
}
return displayableList;
}
/**
* Returns <tt>true</tt> if recent list is enabled and <tt>false</tt> otherwise.
*
* @returns {boolean} <tt>true</tt> if recent list is enabled and <tt>false</tt>
* otherwise.
*/
export function isRecentListEnabled() {
return true;
}

View File

@@ -0,0 +1,33 @@
import { parseURIString, safeDecodeURIComponent } from '../base/util/uri';
/**
* Transforms the history list to a displayable list.
*
* @private
* @param {Array<Object>} recentList - The recent list form the redux store.
* @returns {Array<Object>}
*/
export function toDisplayableList(recentList: Array<{ conference: string; date: Date; duration: number; }>) {
return (
[ ...recentList ].reverse()
.map(item => {
return {
date: item.date,
duration: item.duration,
time: [ item.date ],
title: safeDecodeURIComponent(parseURIString(item.conference).room),
url: item.conference
};
}));
}
/**
* Returns <tt>true</tt> if recent list is enabled and <tt>false</tt> otherwise.
*
* @returns {boolean} <tt>true</tt> if recent list is enabled and <tt>false</tt>
* otherwise.
*/
export function isRecentListEnabled() {
return interfaceConfig.RECENT_LIST_ENABLED;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/recent-list');

View File

@@ -0,0 +1,150 @@
import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import { APP_WILL_MOUNT } from '../base/app/actionTypes';
import { CONFERENCE_WILL_LEAVE, SET_ROOM } from '../base/conference/actionTypes';
import { JITSI_CONFERENCE_URL_KEY } from '../base/conference/constants';
import { addKnownDomains } from '../base/known-domains/actions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { isEmbedded } from '../base/util/embedUtils';
import { parseURIString } from '../base/util/uri';
import { _storeCurrentConference, _updateConferenceDuration } from './actions';
import { isRecentListEnabled } from './functions';
/**
* Middleware that captures joined rooms so they can be saved into
* {@code window.localStorage}.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
if (isRecentListEnabled()) {
switch (action.type) {
case APP_WILL_MOUNT:
return _appWillMount(store, next, action);
case CONFERENCE_WILL_LEAVE:
return _conferenceWillLeave(store, next, action);
case SET_ROOM:
return _setRoom(store, next, action);
}
}
return next(action);
});
/**
* Notifies the feature recent-list that the redux action {@link APP_WILL_MOUNT}
* is being dispatched in a specific redux store.
*
* @param {Store} store - The redux store in which the specified action is being
* dispatched.
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified action to the specified store.
* @param {Action} action - The redux action {@code APP_WILL_MOUNT} which is
* being dispatched in the specified redux store.
* @private
* @returns {*} The result returned by {@code next(action)}.
*/
function _appWillMount({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const result = next(action);
// It's an opportune time to transfer the feature recent-list's knowledge
// about "known domains" (which is local to the feature) to the feature
// base/known-domains (which is global to the app).
//
// XXX Since the feature recent-list predates the feature calendar-sync and,
// consequently, the feature known-domains, it's possible for the feature
// known-list to know of domains which the feature known-domains is yet to
// discover.
const knownDomains = [];
for (const { conference } of getState()['features/recent-list']) {
const uri = parseURIString(conference);
let host;
uri && (host = uri.host) && knownDomains.push(host);
}
knownDomains.length && dispatch(addKnownDomains(knownDomains));
return result;
}
/**
* Updates the duration of the last conference stored in the list.
*
* @param {Store} store - The redux store.
* @param {Dispatch} next - The redux {@code dispatch} function.
* @param {Action} action - The redux action {@link CONFERENCE_WILL_LEAVE}.
* @private
* @returns {*} The result returned by {@code next(action)}.
*/
function _conferenceWillLeave({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const state = getState();
const { doNotStoreRoom } = state['features/base/config'];
if (!doNotStoreRoom && !isEmbedded()) {
let locationURL;
/**
* FIXME:
* It is better to use action.conference[JITSI_CONFERENCE_URL_KEY]
* in order to make sure we get the url the conference is leaving
* from (i.e. The room we are leaving from) because if the order of events
* is different, we cannot be guaranteed that the location URL in base
* connection is the url we are leaving from... Not the one we are going to
* (the latter happens on mobile -- if we use the web implementation);
* however, the conference object on web does not have
* JITSI_CONFERENCE_URL_KEY so we cannot call it and must use the other way.
*/
if (typeof APP === 'undefined') {
const { conference } = action;
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
locationURL = conference && conference[JITSI_CONFERENCE_URL_KEY];
} else {
locationURL = state['features/base/connection'].locationURL;
}
dispatch(
_updateConferenceDuration(
locationURL
));
}
return next(action);
}
/**
* Checks if there is a current conference (upon SET_ROOM action), and saves it
* if necessary.
*
* @param {Store} store - The redux store.
* @param {Dispatch} next - The redux {@code dispatch} function.
* @param {Action} action - The redux action {@link SET_ROOM}.
* @private
* @returns {*} The result returned by {@code next(action)}.
*/
function _setRoom({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const { doNotStoreRoom } = getState()['features/base/config'];
if (!doNotStoreRoom && !isEmbedded() && action.room) {
const { locationURL } = getState()['features/base/connection'];
if (locationURL) {
dispatch(_storeCurrentConference(locationURL));
// Whatever domain the feature recent-list knows about, the app as a
// whole should know about.
//
// XXX Technically, _storeCurrentConference could be turned into an
// asynchronous action creator which dispatches both
// _STORE_CURRENT_CONFERENCE and addKnownDomains but...
dispatch(addKnownDomains(locationURL.host));
}
}
return next(action);
}

View File

@@ -0,0 +1,151 @@
import { getURLWithoutParamsNormalized } from '../base/connection/utils';
import PersistenceRegistry from '../base/redux/PersistenceRegistry';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
DELETE_RECENT_LIST_ENTRY,
_STORE_CURRENT_CONFERENCE,
_UPDATE_CONFERENCE_DURATION
} from './actionTypes';
import { isRecentListEnabled } from './functions';
interface IRecent {
conference: string;
date: number;
duration: number;
}
export type IRecentListState = IRecent[];
/**
* The default/initial redux state of the feature {@code recent-list}.
*
* @type {IRecentListState}
*/
const DEFAULT_STATE: IRecentListState = [];
/**
* The max size of the list.
*
* @type {number}
*/
export const MAX_LIST_SIZE = 30;
/**
* The redux subtree of this feature.
*/
const STORE_NAME = 'features/recent-list';
/**
* Sets up the persistence of the feature {@code recent-list}.
*/
PersistenceRegistry.register(STORE_NAME);
/**
* Reduces redux actions for the purposes of the feature {@code recent-list}.
*/
ReducerRegistry.register<IRecentListState>(STORE_NAME, (state = DEFAULT_STATE, action): IRecentListState => {
if (isRecentListEnabled()) {
switch (action.type) {
case DELETE_RECENT_LIST_ENTRY:
return _deleteRecentListEntry(state, action.entryId);
case _STORE_CURRENT_CONFERENCE:
return _storeCurrentConference(state, action);
case _UPDATE_CONFERENCE_DURATION:
return _updateConferenceDuration(state, action);
default:
return state;
}
}
return state;
});
/**
* Deletes a recent list entry based on the url and date of the item.
*
* @param {IRecentListState} state - The Redux state.
* @param {Object} entryId - The ID object of the entry.
* @returns {IRecentListState}
*/
function _deleteRecentListEntry(
state: Array<IRecent>, entryId: { date: number; url: string; }): Array<IRecent> {
return state.filter(entry =>
entry.conference !== entryId.url || entry.date !== entryId.date);
}
/**
* Adds a new list entry to the redux store.
*
* @param {IRecentListState} state - The redux state of the feature {@code recent-list}.
* @param {Object} action - The redux action.
* @returns {Object}
*/
function _storeCurrentConference(state: IRecentListState, { locationURL }: { locationURL: { href: string; }; }) {
const conference = getURLWithoutParamsNormalized(new URL(locationURL.href));
// If the current conference is already in the list, we remove it to re-add
// it to the top.
const nextState
= state.filter(e => !_urlStringEquals(e.conference, conference));
// The list is a reverse-sorted (i.e. the newer elements are at the end).
nextState.push({
conference,
date: Date.now(),
duration: 0 // We don't have the duration yet!
});
// Ensure the list doesn't exceed a/the maximum size.
nextState.splice(0, nextState.length - MAX_LIST_SIZE);
return nextState;
}
/**
* Updates the conference length when left.
*
* @param {IRecentListState} state - The redux state of the feature {@code recent-list}.
* @param {Object} action - The redux action.
* @returns {Object} The next redux state of the feature {@code recent-list}.
*/
function _updateConferenceDuration(state: IRecentListState, { locationURL }: { locationURL: { href: string; }; }) {
if (locationURL?.href && state.length) {
const mostRecentIndex = state.length - 1;
const mostRecent = state[mostRecentIndex];
if (_urlStringEquals(mostRecent.conference, locationURL.href)) {
// The last conference start was stored so we need to update the
// length.
const nextMostRecent = {
...mostRecent,
duration: Date.now() - mostRecent.date
};
// Shallow copy to avoid in-place modification.
const nextState = state.slice();
nextState[mostRecentIndex] = nextMostRecent;
return nextState;
}
}
return state;
}
/**
* Determines whether two specific URL {@code strings} are equal in the sense
* that they identify one and the same conference resource (irrespective of
* time) for the purposes of the feature {@code recent-list}.
*
* @param {string} a - The URL {@code string} to test for equality to {@code b}.
* @param {string} b - The URL {@code string} to test for equality to {@code a}.
* @returns {boolean}
*/
function _urlStringEquals(a: string, b: string) {
const aHref = getURLWithoutParamsNormalized(new URL(a));
const bHref = getURLWithoutParamsNormalized(new URL(b));
return aHref === bHref;
}

View File

@@ -0,0 +1,5 @@
export interface IRecentItem {
conference: string;
date: number;
duration: number;
}