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,29 @@
/**
* The type of the action which signals feedback was closed without submitting.
*
* {
* type: CANCEL_FEEDBACK,
* message: string,
* score: number
* }
*/
export const CANCEL_FEEDBACK = 'CANCEL_FEEDBACK';
/**
* The type of the action which signals feedback failed to be recorded.
*
* {
* type: SUBMIT_FEEDBACK_ERROR
* error: string
* }
*/
export const SUBMIT_FEEDBACK_ERROR = 'SUBMIT_FEEDBACK_ERROR';
/**
* The type of the action which signals feedback has been recorded.
*
* {
* type: SUBMIT_FEEDBACK_SUCCESS,
* }
*/
export const SUBMIT_FEEDBACK_SUCCESS = 'SUBMIT_FEEDBACK_SUCCESS';

View File

@@ -0,0 +1,197 @@
// @ts-expect-error
import { FEEDBACK_REQUEST_IN_PROGRESS } from '../../../modules/UI/UIErrors';
import { IStore } from '../app/types';
import { IJitsiConference } from '../base/conference/reducer';
import { openDialog } from '../base/dialog/actions';
import { extractFqnFromPath } from '../dynamic-branding/functions.any';
import {
CANCEL_FEEDBACK,
SUBMIT_FEEDBACK_ERROR,
SUBMIT_FEEDBACK_SUCCESS
} from './actionTypes';
import FeedbackDialog from './components/FeedbackDialog.web';
import { sendFeedbackToJaaSRequest, shouldSendJaaSFeedbackMetadata } from './functions.web';
/**
* Caches the passed in feedback in the redux store.
*
* @param {number} score - The quality score given to the conference.
* @param {string} message - A description entered by the participant that
* explains the rating.
* @returns {{
* type: CANCEL_FEEDBACK,
* message: string,
* score: number
* }}
*/
export function cancelFeedback(score: number, message: string) {
return {
type: CANCEL_FEEDBACK,
message,
score
};
}
/**
* Potentially open the {@code FeedbackDialog}. It will not be opened if it is
* already open or feedback has already been submitted.
*
* @param {JistiConference} conference - The conference for which the feedback
* would be about. The conference is passed in because feedback can occur after
* a conference has been left, so references to it may no longer exist in redux.
* @param {string} title - The feedback dialog title.
* @returns {Promise} Resolved with value - false if the dialog is enabled and
* resolved with true if the dialog is disabled or the feedback was already
* submitted. Rejected if another dialog is already displayed.
*/
export function maybeOpenFeedbackDialog(conference: IJitsiConference, title?: string) {
type R = {
feedbackSubmitted: boolean;
showThankYou: boolean;
wasDialogShown: boolean;
};
return (dispatch: IStore['dispatch'], getState: IStore['getState']): Promise<R> => {
const state = getState();
const { feedbackPercentage = 100 } = state['features/base/config'];
if (config.iAmRecorder) {
// Intentionally fall through the if chain to prevent further action
// from being taken with regards to showing feedback.
} else if (state['features/base/dialog'].component === FeedbackDialog) {
// Feedback is currently being displayed.
return Promise.reject(FEEDBACK_REQUEST_IN_PROGRESS);
} else if (state['features/feedback'].submitted) {
// Feedback has been submitted already.
return Promise.resolve({
feedbackSubmitted: true,
showThankYou: true,
wasDialogShown: false
});
} else if (shouldSendJaaSFeedbackMetadata(state)
&& feedbackPercentage > Math.random() * 100) {
return new Promise(resolve => {
dispatch(openFeedbackDialog(conference, title, () => {
const { submitted } = getState()['features/feedback'];
resolve({
feedbackSubmitted: submitted,
showThankYou: false,
wasDialogShown: true
});
}));
});
}
// If the feedback functionality isn't enabled we show a "thank you"
// message. Signaling it (true), so the caller of requestFeedback can
// act on it.
return Promise.resolve({
feedbackSubmitted: false,
showThankYou: true,
wasDialogShown: false
});
};
}
/**
* Opens {@code FeedbackDialog}.
*
* @param {JitsiConference} conference - The JitsiConference that is being
* rated. The conference is passed in because feedback can occur after a
* conference has been left, so references to it may no longer exist in redux.
* @param {string} [title] - The feedback dialog title.
* @param {Function} [onClose] - An optional callback to invoke when the dialog
* is closed.
* @returns {Object}
*/
export function openFeedbackDialog(conference?: IJitsiConference, title?: string, onClose?: Function) {
return openDialog(FeedbackDialog, {
conference,
onClose,
title
});
}
/**
* Sends feedback metadata to JaaS endpoint.
*
* @param {JitsiConference} conference - The JitsiConference that is being rated.
* @param {Object} feedback - The feedback message and score.
*
* @returns {Promise}
*/
export function sendJaasFeedbackMetadata(conference: IJitsiConference, feedback: Object) {
return (_dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
if (!shouldSendJaaSFeedbackMetadata(state)) {
return Promise.resolve();
}
const { jaasFeedbackMetadataURL } = state['features/base/config'];
const { jwt, user, tenant } = state['features/base/jwt'];
const meetingFqn = extractFqnFromPath();
const feedbackData = {
...feedback,
sessionId: conference.sessionId,
userId: user?.id,
meetingFqn,
jwt,
tenant
};
return sendFeedbackToJaaSRequest(jaasFeedbackMetadataURL, feedbackData);
};
}
/**
* Send the passed in feedback.
*
* @param {number} score - An integer between 1 and 5 indicating the user
* feedback. The negative integer -1 is used to denote no score was selected.
* @param {string} message - Detailed feedback from the user to explain the
* rating.
* @param {JitsiConference} conference - The JitsiConference for which the
* feedback is being left.
* @returns {Function}
*/
export function submitFeedback(
score: number,
message: string,
conference: IJitsiConference) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const promises = [];
if (shouldSendJaaSFeedbackMetadata(state)) {
promises.push(dispatch(sendJaasFeedbackMetadata(conference, {
score,
message
})));
}
return Promise.allSettled(promises)
.then(results => {
const rejected = results.find((result): result is PromiseRejectedResult => result?.status === 'rejected');
if (typeof rejected === 'undefined') {
dispatch({ type: SUBMIT_FEEDBACK_SUCCESS });
return Promise.resolve();
}
const error = rejected.reason;
dispatch({
type: SUBMIT_FEEDBACK_ERROR,
error
});
return Promise.reject(error);
});
};
}

View File

@@ -0,0 +1,57 @@
import { connect } from 'react-redux';
import { createToolbarEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { IReduxState } from '../../app/types';
import { IJitsiConference } from '../../base/conference/reducer';
import { translate } from '../../base/i18n/functions';
import { IconFeedback } from '../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { openFeedbackDialog } from '../actions';
import { shouldSendJaaSFeedbackMetadata } from '../functions.web';
/**
* The type of the React {@code Component} props of {@link FeedbackButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference?: IJitsiConference;
}
/**
* Implementation of a button for opening feedback dialog.
*/
class FeedbackButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.feedback';
override icon = IconFeedback;
override label = 'toolbar.feedback';
override tooltip = 'toolbar.feedback';
/**
* Handles clicking / pressing the button, and opens the appropriate dialog.
*
* @protected
* @returns {void}
*/
override _handleClick() {
const { _conference, dispatch } = this.props;
sendAnalytics(createToolbarEvent('feedback'));
dispatch(openFeedbackDialog(_conference));
}
}
const mapStateToProps = (state: IReduxState) => {
const { conference } = state['features/base/conference'];
return {
_conference: conference,
visible: shouldSendJaaSFeedbackMetadata(state)
};
};
export default translate(connect(mapStateToProps)(FeedbackButton));

View File

@@ -0,0 +1,3 @@
import { Component } from 'react';
export default Component;

View File

@@ -0,0 +1,330 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { createFeedbackOpenEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { IReduxState } from '../../app/types';
import { IJitsiConference } from '../../base/conference/reducer';
import { isMobileBrowser } from '../../base/environment/utils';
import Icon from '../../base/icons/components/Icon';
import { IconFavorite, IconFavoriteSolid } from '../../base/icons/svg';
import Dialog from '../../base/ui/components/web/Dialog';
import Input from '../../base/ui/components/web/Input';
import { cancelFeedback, submitFeedback } from '../actions.web';
const useStyles = makeStyles()(theme => {
return {
dialog: {
marginBottom: theme.spacing(1)
},
rating: {
display: 'flex',
flexDirection: 'column-reverse' as const,
alignItems: 'center',
justifyContent: 'center',
marginTop: theme.spacing(4),
marginBottom: theme.spacing(3)
},
ratingLabel: {
...theme.typography.bodyShortBold,
color: theme.palette.text01,
marginBottom: theme.spacing(2),
height: '20px'
},
stars: {
display: 'flex'
},
starBtn: {
display: 'inline-block',
cursor: 'pointer',
marginRight: theme.spacing(3),
'&:last-of-type': {
marginRight: 0
},
'&.active svg': {
fill: theme.palette.success01
},
'&:focus': {
outline: `1px solid ${theme.palette.action01}`,
borderRadius: '4px'
}
},
title: {
fontSize: '1rem'
},
details: {
'& textarea': {
minHeight: '122px'
}
}
};
});
/**
* The scores to display for selecting. The score is the index in the array and
* the value of the index is a translation key used for display in the dialog.
*/
const SCORES = [
'feedback.veryBad',
'feedback.bad',
'feedback.average',
'feedback.good',
'feedback.veryGood'
];
const ICON_SIZE = 32;
/**
* The type of the React {@code Component} props of {@link FeedbackDialog}.
*/
interface IProps {
/**
* The JitsiConference that is being rated. The conference is passed in
* because feedback can occur after a conference has been left, so
* references to it may no longer exist in redux.
*/
conference: IJitsiConference;
/**
* Callback invoked when {@code FeedbackDialog} is unmounted.
*/
onClose: Function;
/**
* The title to display in the dialog. Usually the reason that triggered the feedback.
*/
title?: string;
}
/**
* A React {@code Component} for displaying a dialog to rate the current
* conference quality, write a message describing the experience, and submit
* the feedback.
*
* @param {IProps} props - Component's props.
* @returns {JSX}
*/
const FeedbackDialog = ({ conference, onClose, title }: IProps) => {
const { classes } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const _message = useSelector((state: IReduxState) => state['features/feedback'].message);
const _score = useSelector((state: IReduxState) => state['features/feedback'].score);
/**
* The currently entered feedback message.
*/
const [ message, setMessage ] = useState(_message);
/**
* The score selection index which is currently being hovered. The
* value -1 is used as a sentinel value to match store behavior of
* using -1 for no score having been selected.
*/
const [ mousedOverScore, setMousedOverScore ] = useState(-1);
/**
* The currently selected score selection index. The score will not
* be 0 indexed so subtract one to map with SCORES.
*/
const [ score, setScore ] = useState(_score > -1 ? _score - 1 : _score);
/**
* An array of objects with click handlers for each of the scores listed in
* the constant SCORES. This pattern is used for binding event handlers only
* once for each score selection icon.
*/
const scoreClickConfigurations = useRef(SCORES.map((textKey, index) => {
return {
_onClick: () => onScoreSelect(index),
_onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
onScoreSelect(index);
}
},
_onMouseOver: () => onScoreMouseOver(index)
};
}));
useEffect(() => {
sendAnalytics(createFeedbackOpenEvent());
if (typeof APP !== 'undefined') {
APP.API.notifyFeedbackPromptDisplayed();
}
return () => {
onClose?.();
};
}, []);
/**
* Dispatches an action notifying feedback was not submitted. The submitted
* score will have one added as the rest of the app does not expect 0
* indexing.
*
* @private
* @returns {boolean} Returns true to close the dialog.
*/
const onCancel = useCallback(() => {
const scoreToSubmit = score > -1 ? score + 1 : score;
dispatch(cancelFeedback(scoreToSubmit, message));
return true;
}, [ score, message ]);
/**
* Updates the known entered feedback message.
*
* @param {string} newValue - The new value from updating the textfield for the
* feedback message.
* @private
* @returns {void}
*/
const onMessageChange = useCallback((newValue: string) => {
setMessage(newValue);
}, []);
/**
* Updates the currently selected score.
*
* @param {number} newScore - The index of the selected score in SCORES.
* @private
* @returns {void}
*/
function onScoreSelect(newScore: number) {
setScore(newScore);
}
/**
* Sets the currently hovered score to null to indicate no hover is
* occurring.
*
* @private
* @returns {void}
*/
const onScoreContainerMouseLeave = useCallback(() => {
setMousedOverScore(-1);
}, []);
/**
* Updates the known state of the score icon currently behind hovered over.
*
* @param {number} newMousedOverScore - The index of the SCORES value currently
* being moused over.
* @private
* @returns {void}
*/
function onScoreMouseOver(newMousedOverScore: number) {
setMousedOverScore(newMousedOverScore);
}
/**
* Dispatches the entered feedback for submission. The submitted score will
* have one added as the rest of the app does not expect 0 indexing.
*
* @private
* @returns {boolean} Returns true to close the dialog.
*/
const _onSubmit = useCallback(() => {
const scoreToSubmit = score > -1 ? score + 1 : score;
dispatch(submitFeedback(scoreToSubmit, message, conference));
return true;
}, [ score, message, conference ]);
const scoreToDisplayAsSelected
= mousedOverScore > -1 ? mousedOverScore : score;
const scoreIcons = scoreClickConfigurations.current.map(
(config, index) => {
const isFilled = index <= scoreToDisplayAsSelected;
const activeClass = isFilled ? 'active' : '';
const className
= `${classes.starBtn} ${activeClass}`;
return (
<span
aria-label = { t(SCORES[index]) }
className = { className }
key = { index }
onClick = { config._onClick }
onKeyDown = { config._onKeyDown }
role = 'button'
tabIndex = { 0 }
{ ...(isMobileBrowser() ? {} : {
onMouseOver: config._onMouseOver
}) }>
{isFilled
? <Icon
size = { ICON_SIZE }
src = { IconFavoriteSolid } />
: <Icon
size = { ICON_SIZE }
src = { IconFavorite } />}
</span>
);
});
return (
<Dialog
disableEnter = { true }
ok = {{
translationKey: 'dialog.Submit'
}}
onCancel = { onCancel }
onSubmit = { _onSubmit }
size = 'large'
titleKey = 'feedback.rateExperience'>
<div className = { classes.dialog }>
{title ? <div className = { classes.title }>{t(title)}</div> : null}
<div className = { classes.rating }>
<div
className = { classes.stars }
onMouseLeave = { onScoreContainerMouseLeave }>
{scoreIcons}
</div>
<div
className = { classes.ratingLabel } >
<p className = 'sr-only'>
{t('feedback.accessibilityLabel.yourChoice', {
rating: t(SCORES[scoreToDisplayAsSelected])
})}
</p>
<p
aria-hidden = { true }
id = 'starLabel'>
{t(SCORES[scoreToDisplayAsSelected])}
</p>
</div>
</div>
<div className = { classes.details }>
<Input
id = 'feedbackTextArea'
label = { t('feedback.detailsLabel') }
onChange = { onMessageChange }
textarea = { true }
value = { message } />
</div>
</div>
</Dialog>
);
};
export default FeedbackDialog;

View File

@@ -0,0 +1,70 @@
import { IReduxState } from '../app/types';
import { isVpaasMeeting } from '../jaas/functions';
import logger from './logger';
/**
* Sends feedback metadata to JaaS endpoints.
*
* @param {string|undefined} url - The JaaS metadata endpoint URL.
* @param {Object} feedbackData - The feedback data object.
* @returns {Promise}
*/
export async function sendFeedbackToJaaSRequest(url: string | undefined, feedbackData: {
jwt?: string; meetingFqn: string; message?: string; score?: number;
sessionId: string; tenant?: string; userId?: string;
}) {
if (!url) {
throw new TypeError('Trying to send jaas feedback request to an undefined URL!');
}
const {
jwt,
sessionId,
meetingFqn,
score,
message,
userId,
tenant
} = feedbackData;
const headers = {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json'
};
const data = {
sessionId,
meetingFqn,
userId,
tenant,
submitted: new Date().getTime(),
rating: score,
comments: message
};
try {
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(data)
});
if (!res.ok) {
logger.error('Status error:', res.status);
}
} catch (err) {
logger.error('Could not send request', err);
}
}
/**
* Returns whether jaas feedback metadata should be send or not.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} - True if jaas feedback metadata should be send and false otherwise.
*/
export function shouldSendJaaSFeedbackMetadata(state: IReduxState) {
const { jaasFeedbackMetadataURL } = state['features/base/config'];
return Boolean(isVpaasMeeting(state) && jaasFeedbackMetadataURL);
}

View File

@@ -0,0 +1,23 @@
import { useSelector } from 'react-redux';
import FeedbackButtonWeb from './components/FeedbackButton.web';
import { shouldSendJaaSFeedbackMetadata } from './functions.web';
const feedback = {
key: 'feedback',
Content: FeedbackButtonWeb,
group: 4
};
/**
* A hook that returns the feedback button if it is enabled and undefined otherwise.
*
* @returns {Object | undefined}
*/
export function useFeedbackButton() {
const visible = useSelector(shouldSendJaaSFeedbackMetadata);
if (visible) {
return feedback;
}
}

View File

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

View File

@@ -0,0 +1,51 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
CANCEL_FEEDBACK,
SUBMIT_FEEDBACK_ERROR,
SUBMIT_FEEDBACK_SUCCESS
} from './actionTypes';
const DEFAULT_STATE = {
message: '',
// The sentinel value -1 is used to denote no rating has been set and to
// preserve pre-redux behavior.
score: -1,
submitted: false
};
export interface IFeedbackState {
message: string;
score: number;
submitted: boolean;
}
/**
* Reduces the Redux actions of the feature features/feedback.
*/
ReducerRegistry.register<IFeedbackState>(
'features/feedback',
(state = DEFAULT_STATE, action): IFeedbackState => {
switch (action.type) {
case CANCEL_FEEDBACK: {
return {
...state,
message: action.message,
score: action.score
};
}
case SUBMIT_FEEDBACK_ERROR:
case SUBMIT_FEEDBACK_SUCCESS: {
return {
...state,
message: '',
score: -1,
submitted: true
};
}
}
return state;
});