feat(native-participants-pane) rebase, resolved conflicts pt. 1
This commit is contained in:
parent
e8ad2365b6
commit
d62e378528
|
@ -261,6 +261,23 @@ export function haveParticipantWithScreenSharingFeature(stateful: Object | Funct
|
||||||
return toState(stateful)['features/base/participants'].haveParticipantWithScreenSharingFeature;
|
return toState(stateful)['features/base/participants'].haveParticipantWithScreenSharingFeature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selectors for getting all known participant ids, with fake participants filtered
|
||||||
|
* out.
|
||||||
|
*
|
||||||
|
* @param {(Function|Object|Participant[])} stateful - The redux state
|
||||||
|
* features/base/participants, the (whole) redux state, or redux's
|
||||||
|
* {@code getState} function to be used to retrieve the state
|
||||||
|
* features/base/participants.
|
||||||
|
* @returns {Participant[]}
|
||||||
|
*/
|
||||||
|
export function getParticipantsById(stateful: Object | Function) {
|
||||||
|
const state = toState(stateful)['features/base/participants'];
|
||||||
|
const noFakeParticipants = state.filter(p => !p.fakeParticipants);
|
||||||
|
|
||||||
|
return noFakeParticipants.map(p => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selectors for getting all remote participants.
|
* Selectors for getting all remote participants.
|
||||||
*
|
*
|
||||||
|
|
|
@ -20,11 +20,11 @@ export function showContextMenuReject(participant: Object) {
|
||||||
/**
|
/**
|
||||||
* Displays the context menu for the selected meeting participant.
|
* Displays the context menu for the selected meeting participant.
|
||||||
*
|
*
|
||||||
* @param {Object} participant - The selected meeting participant.
|
* @param {string} participantID - The selected meeting participant id.
|
||||||
* @returns {Function}
|
* @returns {Function}
|
||||||
*/
|
*/
|
||||||
export function showContextMenuDetails(participant: Object) {
|
export function showContextMenuDetails(participantID: String) {
|
||||||
return openDialog(ContextMenuMeetingParticipantDetails, { participant });
|
return openDialog(ContextMenuMeetingParticipantDetails, { participantID });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -4,9 +4,10 @@ import React, { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TouchableOpacity, View } from 'react-native';
|
import { TouchableOpacity, View } from 'react-native';
|
||||||
import { Divider, Text } from 'react-native-paper';
|
import { Divider, Text } from 'react-native-paper';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
import { Avatar } from '../../../base/avatar';
|
import { Avatar } from '../../../base/avatar';
|
||||||
|
import { isToolbarButtonEnabled } from '../../../base/config';
|
||||||
import { hideDialog, openDialog } from '../../../base/dialog/actions';
|
import { hideDialog, openDialog } from '../../../base/dialog/actions';
|
||||||
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
|
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
|
||||||
import {
|
import {
|
||||||
|
@ -15,10 +16,14 @@ import {
|
||||||
IconMuteEveryoneElse, IconVideoOff
|
IconMuteEveryoneElse, IconVideoOff
|
||||||
} from '../../../base/icons';
|
} from '../../../base/icons';
|
||||||
import {
|
import {
|
||||||
getParticipantsById,
|
getParticipantByIdOrUndefined, getParticipantDisplayName,
|
||||||
isLocalParticipantModerator
|
isLocalParticipantModerator
|
||||||
} from '../../../base/participants';
|
} from '../../../base/participants/functions';
|
||||||
import { getIsParticipantVideoMuted } from '../../../base/tracks';
|
import { connect } from '../../../base/redux';
|
||||||
|
import {
|
||||||
|
isParticipantAudioMuted,
|
||||||
|
isParticipantVideoMuted
|
||||||
|
} from '../../../base/tracks/functions';
|
||||||
import { openChat } from '../../../chat/actions.native';
|
import { openChat } from '../../../chat/actions.native';
|
||||||
import {
|
import {
|
||||||
KickRemoteParticipantDialog,
|
KickRemoteParticipantDialog,
|
||||||
|
@ -32,131 +37,179 @@ import styles from './styles';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display name of the participant.
|
||||||
|
*/
|
||||||
|
_displayName: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the local participant is moderator and false otherwise.
|
||||||
|
*/
|
||||||
|
_isLocalModerator: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the chat button is enabled and false otherwise.
|
||||||
|
*/
|
||||||
|
_isChatButtonEnabled: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is moderator and false otherwise.
|
||||||
|
*/
|
||||||
|
_isParticipantModerator: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is video muted and false otherwise.
|
||||||
|
*/
|
||||||
|
_isParticipantVideoMuted: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is audio muted and false otherwise.
|
||||||
|
*/
|
||||||
|
_isParticipantAudioMuted: boolean,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Participant reference
|
* Participant reference
|
||||||
*/
|
*/
|
||||||
participant: Object
|
_participant: Object,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the participant.
|
||||||
|
*/
|
||||||
|
participantID: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ContextMenuMeetingParticipantDetails = ({ participant: p }: Props) => {
|
export const ContextMenuMeetingParticipantDetails = (
|
||||||
|
{
|
||||||
|
_displayName,
|
||||||
|
_isLocalModerator,
|
||||||
|
_isChatButtonEnabled,
|
||||||
|
_isParticipantVideoMuted,
|
||||||
|
_isParticipantAudioMuted,
|
||||||
|
_participant,
|
||||||
|
participantID
|
||||||
|
}: Props) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const participantsIDArr = useSelector(getParticipantsById);
|
|
||||||
const participantIsAvailable = participantsIDArr.find(partId => partId === p.id);
|
|
||||||
const cancel = useCallback(() => dispatch(hideDialog()), [ dispatch ]);
|
const cancel = useCallback(() => dispatch(hideDialog()), [ dispatch ]);
|
||||||
const displayName = p.name;
|
|
||||||
const isLocalModerator = useSelector(isLocalParticipantModerator);
|
|
||||||
const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(p));
|
|
||||||
const kickRemoteParticipant = useCallback(() => {
|
const kickRemoteParticipant = useCallback(() => {
|
||||||
dispatch(openDialog(KickRemoteParticipantDialog, {
|
dispatch(openDialog(KickRemoteParticipantDialog, {
|
||||||
participantID: p.id
|
participantID
|
||||||
}));
|
}));
|
||||||
}, [ dispatch, p ]);
|
}, [ dispatch, participantID ]);
|
||||||
const muteAudio = useCallback(() => {
|
const muteAudio = useCallback(() => {
|
||||||
dispatch(openDialog(MuteRemoteParticipantDialog, {
|
dispatch(openDialog(MuteRemoteParticipantDialog, {
|
||||||
participantID: p.id
|
participantID
|
||||||
}));
|
}));
|
||||||
}, [ dispatch, p ]);
|
}, [ dispatch, participantID ]);
|
||||||
const muteEveryoneElse = useCallback(() => {
|
const muteEveryoneElse = useCallback(() => {
|
||||||
dispatch(openDialog(MuteEveryoneDialog, {
|
dispatch(openDialog(MuteEveryoneDialog, {
|
||||||
exclude: [ p.id ]
|
exclude: [ participantID ]
|
||||||
}));
|
}));
|
||||||
}, [ dispatch, p ]);
|
}, [ dispatch, participantID ]);
|
||||||
const muteVideo = useCallback(() => {
|
const muteVideo = useCallback(() => {
|
||||||
dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
|
dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
|
||||||
participantID: p.id
|
participantID
|
||||||
}));
|
}));
|
||||||
}, [ dispatch, p ]);
|
}, [ dispatch, participantID ]);
|
||||||
|
|
||||||
const sendPrivateMessage = useCallback(() => {
|
const sendPrivateMessage = useCallback(() => {
|
||||||
dispatch(hideDialog());
|
dispatch(hideDialog());
|
||||||
dispatch(openChat(p));
|
dispatch(openChat(_participant));
|
||||||
}, [ dispatch, p ]);
|
}, [ dispatch, _participant ]);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheet
|
<BottomSheet
|
||||||
addScrollViewPadding = { false }
|
addScrollViewPadding = { false }
|
||||||
onCancel = { cancel }
|
onCancel = { cancel }
|
||||||
showSlidingView = { Boolean(participantIsAvailable) }
|
|
||||||
style = { styles.contextMenuMeetingParticipantDetails }>
|
style = { styles.contextMenuMeetingParticipantDetails }>
|
||||||
<View
|
<View
|
||||||
style = { styles.contextMenuItemSectionAvatar }>
|
style = { styles.contextMenuItemSectionAvatar }>
|
||||||
<Avatar
|
<Avatar
|
||||||
className = 'participant-avatar'
|
className = 'participant-avatar'
|
||||||
participantId = { p.id }
|
participantId = { participantID }
|
||||||
size = { 20 } />
|
size = { 20 } />
|
||||||
<View style = { styles.contextMenuItemAvatarText }>
|
<View style = { styles.contextMenuItemAvatarText }>
|
||||||
<Text style = { styles.contextMenuItemName }>
|
<Text style = { styles.contextMenuItemName }>
|
||||||
{ displayName }
|
{ _displayName }
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Divider style = { styles.divider } />
|
<Divider style = { styles.divider } />
|
||||||
{
|
{
|
||||||
isLocalModerator
|
_isLocalModerator && (
|
||||||
&& <TouchableOpacity
|
<>
|
||||||
onPress = { muteAudio }
|
{
|
||||||
style = { styles.contextMenuItem }>
|
!_isParticipantAudioMuted
|
||||||
<Icon
|
&& <TouchableOpacity
|
||||||
size = { 20 }
|
onPress = { muteAudio }
|
||||||
src = { IconMicrophoneEmptySlash } />
|
style = { styles.contextMenuItem }>
|
||||||
<Text style = { styles.contextMenuItemText }>
|
<Icon
|
||||||
{ t('participantsPane.actions.mute') }
|
size = { 20 }
|
||||||
</Text>
|
src = { IconMicrophoneEmptySlash } />
|
||||||
</TouchableOpacity>
|
<Text style = { styles.contextMenuItemText }>
|
||||||
}
|
{ t('participantsPane.actions.mute') }
|
||||||
{
|
</Text>
|
||||||
isLocalModerator
|
</TouchableOpacity>
|
||||||
&& <TouchableOpacity
|
}
|
||||||
onPress = { muteEveryoneElse }
|
|
||||||
style = { styles.contextMenuItem }>
|
<TouchableOpacity
|
||||||
<Icon
|
onPress = { muteEveryoneElse }
|
||||||
size = { 20 }
|
style = { styles.contextMenuItem }>
|
||||||
src = { IconMuteEveryoneElse } />
|
<Icon
|
||||||
<Text style = { styles.contextMenuItemText }>
|
size = { 20 }
|
||||||
{ t('participantsPane.actions.muteEveryoneElse') }
|
src = { IconMuteEveryoneElse } />
|
||||||
</Text>
|
<Text style = { styles.contextMenuItemText }>
|
||||||
</TouchableOpacity>
|
{ t('participantsPane.actions.muteEveryoneElse') }
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
<Divider style = { styles.divider } />
|
<Divider style = { styles.divider } />
|
||||||
{
|
{
|
||||||
isLocalModerator && (
|
_isLocalModerator && (
|
||||||
isParticipantVideoMuted
|
<>
|
||||||
|| <TouchableOpacity
|
{
|
||||||
onPress = { muteVideo }
|
_isParticipantVideoMuted
|
||||||
style = { styles.contextMenuItemSection }>
|
|| <TouchableOpacity
|
||||||
|
onPress = { muteVideo }
|
||||||
|
style = { styles.contextMenuItemSection }>
|
||||||
|
<Icon
|
||||||
|
size = { 20 }
|
||||||
|
src = { IconVideoOff } />
|
||||||
|
<Text style = { styles.contextMenuItemText }>
|
||||||
|
{ t('participantsPane.actions.stopVideo') }
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress = { kickRemoteParticipant }
|
||||||
|
style = { styles.contextMenuItem }>
|
||||||
|
<Icon
|
||||||
|
size = { 20 }
|
||||||
|
src = { IconCloseCircle } />
|
||||||
|
<Text style = { styles.contextMenuItemText }>
|
||||||
|
{ t('videothumbnail.kick') }
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
_isChatButtonEnabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress = { sendPrivateMessage }
|
||||||
|
style = { styles.contextMenuItem }>
|
||||||
<Icon
|
<Icon
|
||||||
size = { 20 }
|
size = { 20 }
|
||||||
src = { IconVideoOff } />
|
src = { IconMessage } />
|
||||||
<Text style = { styles.contextMenuItemText }>
|
<Text style = { styles.contextMenuItemText }>
|
||||||
{ t('participantsPane.actions.stopVideo') }
|
{ t('toolbar.accessibilityLabel.privateMessage') }
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
|
||||||
isLocalModerator
|
|
||||||
&& <TouchableOpacity
|
|
||||||
onPress = { kickRemoteParticipant }
|
|
||||||
style = { styles.contextMenuItem }>
|
|
||||||
<Icon
|
|
||||||
size = { 20 }
|
|
||||||
src = { IconCloseCircle } />
|
|
||||||
<Text style = { styles.contextMenuItemText }>
|
|
||||||
{ t('videothumbnail.kick') }
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress = { sendPrivateMessage }
|
|
||||||
style = { styles.contextMenuItem }>
|
|
||||||
<Icon
|
|
||||||
size = { 20 }
|
|
||||||
src = { IconMessage } />
|
|
||||||
<Text style = { styles.contextMenuItemText }>
|
|
||||||
{ t('toolbar.accessibilityLabel.privateMessage') }
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{/* We need design specs for this*/}
|
{/* We need design specs for this*/}
|
||||||
{/* <TouchableOpacity*/}
|
{/* <TouchableOpacity*/}
|
||||||
{/* style = { styles.contextMenuItemSection }>*/}
|
{/* style = { styles.contextMenuItemSection }>*/}
|
||||||
|
@ -167,7 +220,36 @@ export const ContextMenuMeetingParticipantDetails = ({ participant: p }: Props)
|
||||||
{/* <Text style = { styles.contextMenuItemText }>{ t('participantsPane.actions.networkStats') }</Text>*/}
|
{/* <Text style = { styles.contextMenuItemText }>{ t('participantsPane.actions.networkStats') }</Text>*/}
|
||||||
{/* </TouchableOpacity>*/}
|
{/* </TouchableOpacity>*/}
|
||||||
<Divider style = { styles.divider } />
|
<Divider style = { styles.divider } />
|
||||||
<VolumeSlider participant = { p } />
|
<VolumeSlider participant = { _participant } />
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps (parts of) the redux state to the associated props for this component.
|
||||||
|
*
|
||||||
|
* @param {Object} state - The Redux state.
|
||||||
|
* @param {Object} ownProps - The own props of the component.
|
||||||
|
* @private
|
||||||
|
* @returns {Props}
|
||||||
|
*/
|
||||||
|
function _mapStateToProps(state, ownProps): Object {
|
||||||
|
const { participantID } = ownProps;
|
||||||
|
const participant = getParticipantByIdOrUndefined(state, participantID);
|
||||||
|
const _isLocalModerator = isLocalParticipantModerator(state);
|
||||||
|
const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state);
|
||||||
|
const _isParticipantVideoMuted = isParticipantVideoMuted(participant, state);
|
||||||
|
const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
_displayName: getParticipantDisplayName(state, participantID),
|
||||||
|
_isLocalModerator,
|
||||||
|
_isChatButtonEnabled,
|
||||||
|
_isParticipantAudioMuted,
|
||||||
|
_isParticipantVideoMuted,
|
||||||
|
_participant: participant
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(_mapStateToProps)(ContextMenuMeetingParticipantDetails);
|
||||||
|
|
|
@ -29,10 +29,13 @@ export const LobbyParticipantItem = ({ participant: p }: Props) => {
|
||||||
return (
|
return (
|
||||||
<ParticipantItem
|
<ParticipantItem
|
||||||
audioMediaState = { MEDIA_STATE.NONE }
|
audioMediaState = { MEDIA_STATE.NONE }
|
||||||
|
displayName = { p.name }
|
||||||
isKnockingParticipant = { true }
|
isKnockingParticipant = { true }
|
||||||
name = { p.name }
|
local = { p.local }
|
||||||
onPress = { openContextMenuReject }
|
onPress = { openContextMenuReject }
|
||||||
participant = { p }
|
participant = { p }
|
||||||
|
participantID = { p.id }
|
||||||
|
raisedHand = { p.raisedHand }
|
||||||
videoMediaState = { MEDIA_STATE.NONE }>
|
videoMediaState = { MEDIA_STATE.NONE }>
|
||||||
<Button
|
<Button
|
||||||
children = { t('lobby.admit') }
|
children = { t('lobby.admit') }
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
import React, { useCallback } from 'react';
|
import React from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
|
import { translate } from '../../../base/i18n';
|
||||||
import {
|
import {
|
||||||
getIsParticipantAudioMuted,
|
getParticipantByIdOrUndefined,
|
||||||
getIsParticipantVideoMuted
|
getParticipantDisplayName
|
||||||
|
} from '../../../base/participants';
|
||||||
|
import { connect } from '../../../base/redux';
|
||||||
|
import {
|
||||||
|
isParticipantAudioMuted,
|
||||||
|
isParticipantVideoMuted
|
||||||
} from '../../../base/tracks';
|
} from '../../../base/tracks';
|
||||||
import { showContextMenuDetails } from '../../actions.native';
|
|
||||||
import { MEDIA_STATE } from '../../constants';
|
import { MEDIA_STATE } from '../../constants';
|
||||||
|
import type { MediaState } from '../../constants';
|
||||||
import { getParticipantAudioMediaState } from '../../functions';
|
import { getParticipantAudioMediaState } from '../../functions';
|
||||||
|
|
||||||
import ParticipantItem from './ParticipantItem';
|
import ParticipantItem from './ParticipantItem';
|
||||||
|
@ -17,26 +22,100 @@ import ParticipantItem from './ParticipantItem';
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Participant reference
|
* Media state for audio.
|
||||||
*/
|
*/
|
||||||
participant: Object
|
_audioMediaState: MediaState,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display name of the participant.
|
||||||
|
*/
|
||||||
|
_displayName: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is video muted.
|
||||||
|
*/
|
||||||
|
_isVideoMuted: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant is the local participant.
|
||||||
|
*/
|
||||||
|
_local: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The participant ID.
|
||||||
|
*/
|
||||||
|
_participantID: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant have raised hand.
|
||||||
|
*/
|
||||||
|
_raisedHand: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback to invoke when item is pressed.
|
||||||
|
*/
|
||||||
|
onPress: Function,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the participant.
|
||||||
|
*/
|
||||||
|
participantID: ?string
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MeetingParticipantItem = ({ participant: p }: Props) => {
|
const MeetingParticipantItem = (
|
||||||
const dispatch = useDispatch();
|
{
|
||||||
const isAudioMuted = useSelector(getIsParticipantAudioMuted(p));
|
_audioMediaState,
|
||||||
const isVideoMuted = useSelector(getIsParticipantVideoMuted(p));
|
_displayName,
|
||||||
const audioMediaState = useSelector(getParticipantAudioMediaState(p, isAudioMuted));
|
_isVideoMuted,
|
||||||
const openContextMenuDetails = useCallback(() => !p.local && dispatch(showContextMenuDetails(p), [ dispatch ]));
|
_local,
|
||||||
|
_participantID,
|
||||||
|
_raisedHand,
|
||||||
|
onPress
|
||||||
|
}: Props) => {
|
||||||
|
const showParticipantDetails = !_local && onPress;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParticipantItem
|
<ParticipantItem
|
||||||
audioMediaState = { audioMediaState }
|
audioMediaState = { _audioMediaState }
|
||||||
|
displayName = { _displayName }
|
||||||
isKnockingParticipant = { false }
|
isKnockingParticipant = { false }
|
||||||
name = { p.name }
|
local = { _local }
|
||||||
onPress = { openContextMenuDetails }
|
onPress = { showParticipantDetails }
|
||||||
participant = { p }
|
participantID = { _participantID }
|
||||||
videoMediaState = { isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED } />
|
raisedHand = { _raisedHand }
|
||||||
|
videoMediaState = { _isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED } />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps (parts of) the redux state to the associated props for this component.
|
||||||
|
*
|
||||||
|
* @param {Object} state - The Redux state.
|
||||||
|
* @param {Object} ownProps - The own props of the component.
|
||||||
|
* @private
|
||||||
|
* @returns {Props}
|
||||||
|
*/
|
||||||
|
function mapStateToProps(state, ownProps): Object {
|
||||||
|
const { participantID } = ownProps;
|
||||||
|
const participant = getParticipantByIdOrUndefined(state, participantID);
|
||||||
|
const _isAudioMuted = isParticipantAudioMuted(participant, state);
|
||||||
|
const isVideoMuted = isParticipantVideoMuted(participant, state);
|
||||||
|
const audioMediaState = getParticipantAudioMediaState(
|
||||||
|
participant, _isAudioMuted, state
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
_audioMediaState: audioMediaState,
|
||||||
|
_displayName: getParticipantDisplayName(state, participant?.id),
|
||||||
|
_isAudioMuted,
|
||||||
|
_isVideoMuted: isVideoMuted,
|
||||||
|
_local: Boolean(participant?.local),
|
||||||
|
_participantID: participant?.id,
|
||||||
|
_raisedHand: Boolean(participant?.raisedHand)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default translate(connect(mapStateToProps)(MeetingParticipantItem));
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,25 +7,49 @@ import { Button } from 'react-native-paper';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { Icon, IconInviteMore } from '../../../base/icons';
|
import { Icon, IconInviteMore } from '../../../base/icons';
|
||||||
import { getParticipants } from '../../../base/participants';
|
import {
|
||||||
|
getLocalParticipant,
|
||||||
|
getParticipantCountWithFake,
|
||||||
|
getRemoteParticipants
|
||||||
|
} from '../../../base/participants';
|
||||||
import { doInvitePeople } from '../../../invite/actions.native';
|
import { doInvitePeople } from '../../../invite/actions.native';
|
||||||
|
import { showContextMenuDetails } from '../../actions.native';
|
||||||
import { shouldRenderInviteButton } from '../../functions';
|
import { shouldRenderInviteButton } from '../../functions';
|
||||||
|
|
||||||
import { MeetingParticipantItem } from './MeetingParticipantItem';
|
import MeetingParticipantItem from './MeetingParticipantItem';
|
||||||
import styles from './styles';
|
import styles from './styles';
|
||||||
|
|
||||||
export const MeetingParticipantList = () => {
|
export const MeetingParticipantList = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const items = [];
|
||||||
|
const localParticipant = useSelector(getLocalParticipant);
|
||||||
const onInvite = useCallback(() => dispatch(doInvitePeople()), [ dispatch ]);
|
const onInvite = useCallback(() => dispatch(doInvitePeople()), [ dispatch ]);
|
||||||
|
const participants = useSelector(getRemoteParticipants);
|
||||||
|
const participantsCount = useSelector(getParticipantCountWithFake);
|
||||||
const showInviteButton = useSelector(shouldRenderInviteButton);
|
const showInviteButton = useSelector(shouldRenderInviteButton);
|
||||||
const participants = useSelector(getParticipants);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/no-multi-comp
|
||||||
|
const renderParticipant = id => (
|
||||||
|
<MeetingParticipantItem
|
||||||
|
key = { id }
|
||||||
|
/* eslint-disable-next-line react/jsx-no-bind */
|
||||||
|
onPress = { () => dispatch(showContextMenuDetails(id)) }
|
||||||
|
participantID = { id } />
|
||||||
|
);
|
||||||
|
|
||||||
|
localParticipant && items.push(renderParticipant(localParticipant?.id));
|
||||||
|
|
||||||
|
participants.forEach(p => {
|
||||||
|
items.push(renderParticipant(p?.id));
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style = { styles.meetingList }>
|
<View style = { styles.meetingList }>
|
||||||
<Text style = { styles.meetingListDescription }>
|
<Text style = { styles.meetingListDescription }>
|
||||||
{t('participantsPane.headings.participantsList',
|
{t('participantsPane.headings.participantsList',
|
||||||
{ count: participants.length })}
|
{ count: participantsCount })}
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
showInviteButton
|
showInviteButton
|
||||||
|
@ -42,13 +66,8 @@ export const MeetingParticipantList = () => {
|
||||||
onPress = { onInvite }
|
onPress = { onInvite }
|
||||||
style = { styles.inviteButton } />
|
style = { styles.inviteButton } />
|
||||||
}
|
}
|
||||||
{
|
{ items }
|
||||||
participants.map(p => (
|
|
||||||
<MeetingParticipantItem
|
|
||||||
key = { p.id }
|
|
||||||
participant = { p } />)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,8 @@ import type { Node } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TouchableOpacity, View } from 'react-native';
|
import { TouchableOpacity, View } from 'react-native';
|
||||||
import { Text } from 'react-native-paper';
|
import { Text } from 'react-native-paper';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { Avatar } from '../../../base/avatar';
|
import { Avatar } from '../../../base/avatar';
|
||||||
import { getParticipantDisplayNameWithId } from '../../../base/participants';
|
|
||||||
import { MEDIA_STATE, type MediaState, AudioStateIcons, VideoStateIcons } from '../../constants';
|
import { MEDIA_STATE, type MediaState, AudioStateIcons, VideoStateIcons } from '../../constants';
|
||||||
|
|
||||||
import { RaisedHandIndicator } from './RaisedHandIndicator';
|
import { RaisedHandIndicator } from './RaisedHandIndicator';
|
||||||
|
@ -26,15 +24,20 @@ type Props = {
|
||||||
*/
|
*/
|
||||||
children?: Node,
|
children?: Node,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the participant. Used for showing lobby names.
|
||||||
|
*/
|
||||||
|
displayName: string,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the participant waiting?
|
* Is the participant waiting?
|
||||||
*/
|
*/
|
||||||
isKnockingParticipant: boolean,
|
isKnockingParticipant: boolean,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name of the participant. Used for showing lobby names.
|
* True if the participant is local.
|
||||||
*/
|
*/
|
||||||
name?: string,
|
local: boolean,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback to be invoked on pressing the participant item.
|
* Callback to be invoked on pressing the participant item.
|
||||||
|
@ -42,9 +45,14 @@ type Props = {
|
||||||
onPress?: Function,
|
onPress?: Function,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Participant reference
|
* The ID of the participant.
|
||||||
*/
|
*/
|
||||||
participant: Object,
|
participantID: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the participant have raised hand.
|
||||||
|
*/
|
||||||
|
raisedHand: boolean,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Media state for video
|
* Media state for video
|
||||||
|
@ -59,15 +67,16 @@ type Props = {
|
||||||
*/
|
*/
|
||||||
function ParticipantItem({
|
function ParticipantItem({
|
||||||
children,
|
children,
|
||||||
|
displayName,
|
||||||
isKnockingParticipant,
|
isKnockingParticipant,
|
||||||
name,
|
local,
|
||||||
onPress,
|
onPress,
|
||||||
participant: p,
|
participantID,
|
||||||
|
raisedHand,
|
||||||
audioMediaState = MEDIA_STATE.NONE,
|
audioMediaState = MEDIA_STATE.NONE,
|
||||||
videoMediaState = MEDIA_STATE.NONE
|
videoMediaState = MEDIA_STATE.NONE
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const displayName = name || useSelector(getParticipantDisplayNameWithId(p.id));
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -77,19 +86,19 @@ function ParticipantItem({
|
||||||
style = { styles.participantContent }>
|
style = { styles.participantContent }>
|
||||||
<Avatar
|
<Avatar
|
||||||
className = 'participant-avatar'
|
className = 'participant-avatar'
|
||||||
participantId = { p.id }
|
participantId = { participantID }
|
||||||
size = { 32 } />
|
size = { 32 } />
|
||||||
<View style = { styles.participantNameContainer }>
|
<View style = { styles.participantNameContainer }>
|
||||||
<Text style = { styles.participantName }>
|
<Text style = { styles.participantName }>
|
||||||
{ displayName }
|
{ displayName }
|
||||||
</Text>
|
</Text>
|
||||||
{ p.local ? <Text style = { styles.isLocal }>({t('chat.you')})</Text> : null }
|
{ local ? <Text style = { styles.isLocal }>({t('chat.you')})</Text> : null }
|
||||||
</View>
|
</View>
|
||||||
{
|
{
|
||||||
!isKnockingParticipant
|
!isKnockingParticipant
|
||||||
&& <>
|
&& <>
|
||||||
{
|
{
|
||||||
p.raisedHand && <RaisedHandIndicator />
|
raisedHand && <RaisedHandIndicator />
|
||||||
}
|
}
|
||||||
<View style = { styles.participantStatesContainer }>
|
<View style = { styles.participantStatesContainer }>
|
||||||
<View style = { styles.participantStateVideo }>{VideoStateIcons[videoMediaState]}</View>
|
<View style = { styles.participantStateVideo }>{VideoStateIcons[videoMediaState]}</View>
|
||||||
|
@ -98,7 +107,7 @@ function ParticipantItem({
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{ !p.local && children }
|
{ !local && children }
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
import { isToolbarButtonEnabled } from '../../base/config/functions.web';
|
import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
|
||||||
import { openDialog } from '../../base/dialog';
|
import { openDialog } from '../../../base/dialog';
|
||||||
import { translate } from '../../base/i18n';
|
import { translate } from '../../../base/i18n';
|
||||||
import {
|
import {
|
||||||
IconCloseCircle,
|
IconCloseCircle,
|
||||||
IconCrown,
|
IconCrown,
|
||||||
|
@ -12,18 +12,18 @@ import {
|
||||||
IconMicDisabled,
|
IconMicDisabled,
|
||||||
IconMuteEveryoneElse,
|
IconMuteEveryoneElse,
|
||||||
IconVideoOff
|
IconVideoOff
|
||||||
} from '../../base/icons';
|
} from '../../../base/icons';
|
||||||
import {
|
import {
|
||||||
getParticipantByIdOrUndefined,
|
getParticipantByIdOrUndefined,
|
||||||
isLocalParticipantModerator,
|
isLocalParticipantModerator,
|
||||||
isParticipantModerator
|
isParticipantModerator
|
||||||
} from '../../base/participants';
|
} from '../../../base/participants';
|
||||||
import { connect } from '../../base/redux';
|
import { connect } from '../../../base/redux';
|
||||||
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../base/tracks';
|
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
|
||||||
import { openChat } from '../../chat/actions';
|
import { openChat } from '../../../chat/actions';
|
||||||
import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu';
|
import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu';
|
||||||
import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
|
import MuteRemoteParticipantsVideoDialog from '../../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
|
||||||
import { getComputedOuterHeight } from '../functions';
|
import { getComputedOuterHeight } from '../../functions';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
|
|
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { getParticipantByIdOrUndefined, getParticipantDisplayName } from '../../base/participants';
|
import { getParticipantByIdOrUndefined, getParticipantDisplayName } from '../../../base/participants';
|
||||||
import { connect } from '../../base/redux';
|
import { connect } from '../../../base/redux';
|
||||||
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../base/tracks';
|
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
|
||||||
import { ACTION_TRIGGER, MEDIA_STATE, type MediaState } from '../constants';
|
import { ACTION_TRIGGER, MEDIA_STATE, type MediaState } from '../../constants';
|
||||||
import { getParticipantAudioMediaState, getQuickActionButtonType } from '../functions';
|
import { getParticipantAudioMediaState, getQuickActionButtonType } from '../../functions';
|
||||||
|
import ParticipantQuickAction from '../ParticipantQuickAction';
|
||||||
|
|
||||||
import ParticipantItem from './ParticipantItem';
|
import ParticipantItem from './ParticipantItem';
|
||||||
import ParticipantQuickAction from './ParticipantQuickAction';
|
|
||||||
import { ParticipantActionEllipsis } from './styled';
|
import { ParticipantActionEllipsis } from './styled';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
|
@ -4,14 +4,14 @@ import React, { useCallback, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
|
||||||
import { openDialog } from '../../base/dialog';
|
import { openDialog } from '../../../base/dialog';
|
||||||
import {
|
import {
|
||||||
getLocalParticipant,
|
getLocalParticipant,
|
||||||
getParticipantCountWithFake,
|
getParticipantCountWithFake,
|
||||||
getRemoteParticipants
|
getRemoteParticipants
|
||||||
} from '../../base/participants';
|
} from '../../../base/participants';
|
||||||
import MuteRemoteParticipantDialog from '../../video-menu/components/web/MuteRemoteParticipantDialog';
|
import MuteRemoteParticipantDialog from '../../../video-menu/components/web/MuteRemoteParticipantDialog';
|
||||||
import { findStyledAncestor, shouldRenderInviteButton } from '../functions';
|
import { findStyledAncestor, shouldRenderInviteButton } from '../../functions';
|
||||||
|
|
||||||
import { InviteButton } from './InviteButton';
|
import { InviteButton } from './InviteButton';
|
||||||
import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
|
import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
|
||||||
|
|
|
@ -2,15 +2,15 @@
|
||||||
|
|
||||||
import React, { type Node } from 'react';
|
import React, { type Node } from 'react';
|
||||||
|
|
||||||
import { Avatar } from '../../base/avatar';
|
import { Avatar } from '../../../base/avatar';
|
||||||
import {
|
import {
|
||||||
Icon,
|
Icon,
|
||||||
IconCameraEmpty,
|
IconCameraEmpty,
|
||||||
IconCameraEmptyDisabled,
|
IconCameraEmptyDisabled,
|
||||||
IconMicrophoneEmpty,
|
IconMicrophoneEmpty,
|
||||||
IconMicrophoneEmptySlash
|
IconMicrophoneEmptySlash
|
||||||
} from '../../base/icons';
|
} from '../../../base/icons';
|
||||||
import { ACTION_TRIGGER, MEDIA_STATE, type ActionTrigger, type MediaState } from '../constants';
|
import { ACTION_TRIGGER, MEDIA_STATE, type ActionTrigger, type MediaState } from '../../constants';
|
||||||
|
|
||||||
import { RaisedHandIndicator } from './RaisedHandIndicator';
|
import { RaisedHandIndicator } from './RaisedHandIndicator';
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -3,19 +3,19 @@
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { ThemeProvider } from 'styled-components';
|
import { ThemeProvider } from 'styled-components';
|
||||||
|
|
||||||
import { openDialog } from '../../base/dialog';
|
import { openDialog } from '../../../base/dialog';
|
||||||
import { translate } from '../../base/i18n';
|
import { translate } from '../../../base/i18n';
|
||||||
import {
|
import {
|
||||||
getParticipantCount,
|
getParticipantCount,
|
||||||
isLocalParticipantModerator
|
isLocalParticipantModerator
|
||||||
} from '../../base/participants';
|
} from '../../../base/participants';
|
||||||
import { connect } from '../../base/redux';
|
import { connect } from '../../../base/redux';
|
||||||
import { MuteEveryoneDialog } from '../../video-menu/components/';
|
import { MuteEveryoneDialog } from '../../../video-menu/components/';
|
||||||
import { close } from '../actions';
|
import { close } from '../../actions';
|
||||||
import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../functions';
|
import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../../functions';
|
||||||
import theme from '../theme.json';
|
import theme from '../../theme.json';
|
||||||
|
import { FooterContextMenu } from '../FooterContextMenu';
|
||||||
|
|
||||||
import { FooterContextMenu } from './FooterContextMenu';
|
|
||||||
import { LobbyParticipantList } from './LobbyParticipantList';
|
import { LobbyParticipantList } from './LobbyParticipantList';
|
||||||
import { MeetingParticipantList } from './MeetingParticipantList';
|
import { MeetingParticipantList } from './MeetingParticipantList';
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -3,5 +3,5 @@ export * from './LobbyParticipantItem';
|
||||||
export * from './LobbyParticipantList';
|
export * from './LobbyParticipantList';
|
||||||
export * from './MeetingParticipantList';
|
export * from './MeetingParticipantList';
|
||||||
export { default as ParticipantsPane } from './ParticipantsPane';
|
export { default as ParticipantsPane } from './ParticipantsPane';
|
||||||
export * from './ParticipantsPaneButton';
|
export * from '../ParticipantsPaneButton';
|
||||||
export * from './RaisedHandIndicator';
|
export * from './RaisedHandIndicator';
|
||||||
|
|
|
@ -5,8 +5,8 @@ import React, { PureComponent } from 'react';
|
||||||
import { Slider, View } from 'react-native';
|
import { Slider, View } from 'react-native';
|
||||||
import { withTheme } from 'react-native-paper';
|
import { withTheme } from 'react-native-paper';
|
||||||
|
|
||||||
import { translate } from '../../../base/i18n';
|
|
||||||
import { Icon, IconVolumeEmpty } from '../../../base/icons';
|
import { Icon, IconVolumeEmpty } from '../../../base/icons';
|
||||||
|
import { getParticipantByIdOrUndefined } from '../../../base/participants';
|
||||||
import { connect } from '../../../base/redux';
|
import { connect } from '../../../base/redux';
|
||||||
import { setVolume } from '../../../participants-pane/actions.native';
|
import { setVolume } from '../../../participants-pane/actions.native';
|
||||||
import { VOLUME_SLIDER_SCALE } from '../../constants';
|
import { VOLUME_SLIDER_SCALE } from '../../constants';
|
||||||
|
@ -19,6 +19,11 @@ import styles from './styles';
|
||||||
*/
|
*/
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Participant reference
|
||||||
|
*/
|
||||||
|
_participant: Object,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the participant enters the conference silent.
|
* Whether the participant enters the conference silent.
|
||||||
*/
|
*/
|
||||||
|
@ -35,9 +40,9 @@ type Props = {
|
||||||
dispatch: Function,
|
dispatch: Function,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Participant reference
|
* The ID of the participant.
|
||||||
*/
|
*/
|
||||||
participant: Object,
|
participantID: string,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Theme used for styles.
|
* Theme used for styles.
|
||||||
|
@ -126,8 +131,8 @@ class VolumeSlider extends PureComponent<Props, State> {
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_onVolumeChange(volumeLevel) {
|
_onVolumeChange(volumeLevel) {
|
||||||
const { dispatch, participant } = this.props;
|
const { dispatch, _participant } = this.props;
|
||||||
const { id } = participant;
|
const { id } = _participant;
|
||||||
|
|
||||||
dispatch(setVolume(id, volumeLevel));
|
dispatch(setVolume(id, volumeLevel));
|
||||||
}
|
}
|
||||||
|
@ -142,16 +147,18 @@ class VolumeSlider extends PureComponent<Props, State> {
|
||||||
* @returns {Props}
|
* @returns {Props}
|
||||||
*/
|
*/
|
||||||
function mapStateToProps(state, ownProps): Object {
|
function mapStateToProps(state, ownProps): Object {
|
||||||
const { participant } = ownProps;
|
const { participantID } = ownProps;
|
||||||
|
const participant = getParticipantByIdOrUndefined(state, participantID);
|
||||||
const { id, local } = participant;
|
const { id, local } = participant;
|
||||||
const { participantsVolume } = state['features/participants-pane'];
|
const { participantsVolume } = state['features/participants-pane'];
|
||||||
const { startSilent } = state['features/base/config'];
|
const { startSilent } = state['features/base/config'];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
_participant: participant,
|
||||||
_startSilent: Boolean(startSilent),
|
_startSilent: Boolean(startSilent),
|
||||||
_volume: local ? undefined : participantsVolume[id]
|
_volume: local ? undefined : participantsVolume[id]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default translate(connect(mapStateToProps)(withTheme(VolumeSlider)));
|
export default connect(mapStateToProps)(withTheme(VolumeSlider));
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue