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,19 @@
/**
* Action used to set custom user properties.
*/
export const SET_DYNAMIC_BRANDING_DATA = 'SET_DYNAMIC_BRANDING_DATA';
/**
* Action used to signal the customization failed.
*/
export const SET_DYNAMIC_BRANDING_FAILED = 'SET_DYNAMIC_BRANDING_FAILED';
/**
* Action used to signal the branding elements are ready to be displayed
*/
export const SET_DYNAMIC_BRANDING_READY = 'SET_DYNAMIC_BRANDING_READY';
/**
* Action used to unset branding elements
*/
export const UNSET_DYNAMIC_BRANDING = 'UNSET_DYNAMIC_BRANDING';

View File

@@ -0,0 +1,78 @@
import { IStore } from '../app/types';
import { doGetJSON } from '../base/util/httpUtils';
import {
SET_DYNAMIC_BRANDING_DATA,
SET_DYNAMIC_BRANDING_FAILED,
SET_DYNAMIC_BRANDING_READY
} from './actionTypes';
import { getDynamicBrandingUrl } from './functions.any';
import logger from './logger';
/**
* Fetches custom branding data.
* If there is no data or the request fails, sets the `customizationReady` flag
* so the defaults can be displayed.
*
* @returns {Function}
*/
export function fetchCustomBrandingData() {
return async function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const state = getState();
const { customizationReady } = state['features/dynamic-branding'];
if (!customizationReady) {
const url = await getDynamicBrandingUrl(state);
if (url) {
try {
const res = await doGetJSON(url);
return dispatch(setDynamicBrandingData(res));
} catch (err) {
logger.error('Error fetching branding data', err);
return dispatch(setDynamicBrandingFailed());
}
}
dispatch(setDynamicBrandingReady());
}
};
}
/**
* Action used to set the user customizations.
*
* @param {Object} value - The custom data to be set.
* @returns {Object}
*/
export function setDynamicBrandingData(value: Object) {
return {
type: SET_DYNAMIC_BRANDING_DATA,
value
};
}
/**
* Action used to signal the branding elements are ready to be displayed.
*
* @returns {Object}
*/
export function setDynamicBrandingReady() {
return {
type: SET_DYNAMIC_BRANDING_READY
};
}
/**
* Action used to signal the branding request failed.
*
* @returns {Object}
*/
export function setDynamicBrandingFailed() {
return {
type: SET_DYNAMIC_BRANDING_FAILED
};
}

View File

@@ -0,0 +1,56 @@
import { IStore } from '../app/types';
import { doGetJSON } from '../base/util/httpUtils';
import { UNSET_DYNAMIC_BRANDING } from './actionTypes';
import {
setDynamicBrandingData,
setDynamicBrandingFailed,
setDynamicBrandingReady
} from './actions.any';
import { getDynamicBrandingUrl } from './functions.any';
import logger from './logger';
/**
* Fetches custom branding data.
* If there is no data or the request fails, sets the `customizationReady` flag
* so the defaults can be displayed.
*
* @returns {Function}
*/
export function fetchCustomBrandingData() {
return async function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const state = getState();
const dynamicBrandingUrl = await getDynamicBrandingUrl(state);
if (dynamicBrandingUrl) {
try {
return dispatch(
setDynamicBrandingData(
await doGetJSON(dynamicBrandingUrl))
);
} catch (err) {
logger.error('Error fetching branding data', err);
return dispatch(
setDynamicBrandingFailed()
);
}
} else {
dispatch(unsetDynamicBranding());
}
dispatch(setDynamicBrandingReady());
};
}
/**
* Action used to unset branding elements.
*
* @returns {Object}
*/
export function unsetDynamicBranding() {
return {
type: UNSET_DYNAMIC_BRANDING
};
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Image, ImageStyle, StyleProp, ViewStyle } from 'react-native';
import { SvgUri } from 'react-native-svg';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import styles from './styles';
interface IProps {
uri?: string;
}
/**
* Component that displays a branding background image.
*
* @param {IProps} props - The props of the component.
* @returns {ReactElement}
*/
const BrandingImageBackground: React.FC<IProps> = ({ uri }: IProps) => {
const imageType = uri?.substr(uri.lastIndexOf('/') + 1);
const imgSrc = uri ? uri : undefined;
let backgroundImage;
if (!uri) {
return null;
}
if (imageType?.includes('.svg')) {
backgroundImage
= (
<SvgUri
height = '100%'
// Force uniform scaling.
// Align the <min-x> of the element's viewBox
// with the smallest X value of the viewport.
// Align the <min-y> of the element's viewBox
// with the smallest Y value of the viewport.
preserveAspectRatio = 'xMinYMin'
style = { styles.brandingImageBackgroundSvg as StyleProp<ViewStyle> }
uri = { imgSrc ?? null }
viewBox = '0 0 400 650'
width = '100%' />
);
} else {
backgroundImage
= (
<Image
source = {{ uri: imgSrc }}
style = { styles.brandingImageBackground as StyleProp<ImageStyle> } />
);
}
return backgroundImage;
};
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code DialInLink} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { backgroundImageUrl } = state['features/dynamic-branding'];
return {
uri: backgroundImageUrl
};
}
export default connect(_mapStateToProps)(BrandingImageBackground);

View File

@@ -0,0 +1,15 @@
export default {
/**
* {@code BrandingImageBackground} Style.
*/
brandingImageBackgroundSvg: {
position: 'absolute'
},
brandingImageBackground: {
height: '100%',
position: 'absolute',
width: '100%'
}
};

View File

@@ -0,0 +1,96 @@
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { toState } from '../base/redux/functions';
import { cleanSvg } from './functions';
import logger from './logger';
/**
* Extracts the fqn part from a path, where fqn represents
* tenant/roomName.
*
* @param {Object} state - A redux state.
* @returns {string}
*/
export function extractFqnFromPath(state?: IReduxState) {
let pathname;
if (window.location.pathname) {
pathname = window.location.pathname;
} else if (state?.['features/base/connection']) {
pathname = state['features/base/connection'].locationURL?.pathname ?? '';
} else {
return '';
}
const parts = pathname.split('/');
const len = parts.length;
return parts.length > 2 ? `${parts[len - 2]}/${parts[len - 1]}` : parts[1];
}
/**
* Returns the url used for fetching dynamic branding.
*
* @param {Object | Function} stateful - The redux store, state, or
* {@code getState} function.
* @returns {string}
*/
export async function getDynamicBrandingUrl(stateful: IStateful) {
const state = toState(stateful);
// NB: On web this is dispatched really early, before the config has been stored in the
// state. Thus, fetch it from the window global.
const config
= navigator.product === 'ReactNative' ? state['features/base/config'] : window.config;
const { dynamicBrandingUrl } = config;
if (dynamicBrandingUrl) {
return dynamicBrandingUrl;
}
const { brandingDataUrl: baseUrl } = config;
const fqn = extractFqnFromPath(state);
if (baseUrl && fqn) {
return `${baseUrl}?conferenceFqn=${encodeURIComponent(fqn)}`;
}
}
/**
* Selector used for getting the load state of the dynamic branding data.
*
* @param {Object} state - Global state of the app.
* @returns {boolean}
*/
export function isDynamicBrandingDataLoaded(state: IReduxState) {
return state['features/dynamic-branding'].customizationReady;
}
/**
* Fetch SVG XMLs from branding icons urls.
*
* @param {Object} customIcons - The map of branded icons.
* @returns {Object}
*/
export const fetchCustomIcons = async (customIcons: Record<string, string>) => {
const localCustomIcons: Record<string, string> = {};
for (const [ key, url ] of Object.entries(customIcons)) {
try {
const response = await fetch(url);
if (response.ok) {
const svgXml = await response.text();
localCustomIcons[key] = cleanSvg(svgXml);
} else {
logger.error(`Failed to fetch ${url}. Status: ${response.status}`);
}
} catch (error) {
logger.error(`Error fetching ${url}:`, error);
}
}
return localCustomIcons;
};

View File

@@ -0,0 +1,9 @@
/**
* Sanitizes the given SVG by removing dangerous elements.
*
* @param {string} svg - The SVG string to clean.
* @returns {string} The sanitized SVG string.
*/
export function cleanSvg(svg: string): string {
return svg;
}

View File

@@ -0,0 +1,127 @@
import { Theme } from '@mui/material';
import { adaptV4Theme, createTheme } from '@mui/material/styles';
import DOMPurify from 'dompurify';
import { breakpoints, colorMap, font, shape, spacing, typography } from '../base/ui/Tokens';
import { createColorTokens } from '../base/ui/utils';
const DEFAULT_FONT_SIZE = 16;
/**
* Sanitizes the given SVG by removing dangerous elements.
*
* @param {string} svg - The SVG string to clean.
* @returns {string} The sanitized SVG string.
*/
export function cleanSvg(svg: string): string {
return DOMPurify.sanitize(svg);
}
/**
* Converts unitless fontSize and lineHeight values in a typography style object to rem units.
* Backward compatibility: This conversion supports custom themes that may still override
* typography values with numeric (pixel-based) values instead of rem strings.
*
* @param {Object} style - The typography style object to convert.
* @returns {void}
*/
function convertTypographyToRem(style: any): void {
if (style) {
// Support for backward compatibility with numeric font size overrides
if (typeof style.fontSize === 'number') {
style.fontSize = `${style.fontSize / DEFAULT_FONT_SIZE}rem`;
}
// Support for backward compatibility with numeric line height overrides
if (typeof style.lineHeight === 'number') {
style.lineHeight = `${style.lineHeight / DEFAULT_FONT_SIZE}rem`;
}
}
}
/**
* Creates MUI branding theme based on the custom theme json.
*
* @param {Object} customTheme - The branded custom theme.
* @returns {Object} - The MUI theme.
*/
export function createMuiBrandingTheme(customTheme: Theme) {
const {
palette: customPalette,
shape: customShape,
typography: customTypography,
breakpoints: customBreakpoints,
spacing: customSpacing
} = customTheme;
const newPalette = createColorTokens(colorMap);
if (customPalette) {
overwriteRecurrsive(newPalette, customPalette);
}
const newShape = { ...shape };
if (customShape) {
overwriteRecurrsive(newShape, customShape);
}
const newTypography = {
font: { ...font },
...typography
};
if (customTypography) {
overwriteRecurrsive(newTypography, customTypography);
// Convert typography values to rem units in case some of the overrides are using the legacy unitless format.
// Note: We do the conversion onlt when we do have custom typography overrides. All other values are already in rem.
for (const variant of Object.keys(newTypography)) {
convertTypographyToRem((newTypography as Record<string, any>)[variant]);
}
}
const newBreakpoints = { ...breakpoints };
if (customBreakpoints) {
overwriteRecurrsive(newBreakpoints, customBreakpoints);
}
let newSpacing: any = [ ...spacing ];
if (customSpacing?.length) {
newSpacing = customSpacing;
}
return createTheme(adaptV4Theme({
spacing: newSpacing,
palette: newPalette,
shape: newShape,
// @ts-ignore
typography: newTypography,
// @ts-ignore
breakpoints: newBreakpoints
}));
}
/**
* Overwrites recursively values from object 2 into object 1 based on common keys.
* (Merges object2 into object1).
*
* @param {Object} obj1 - The object holding the merged values.
* @param {Object} obj2 - The object to compare to and take values from.
* @returns {void}
*/
function overwriteRecurrsive(obj1: Object, obj2: Object) {
Object.keys(obj2).forEach(key => {
if (obj1.hasOwnProperty(key)) {
if (typeof obj1[key as keyof typeof obj1] === 'object') {
overwriteRecurrsive(obj1[key as keyof typeof obj1], obj2[key as keyof typeof obj2]);
} else {
// @ts-ignore
obj1[key] = obj2[key];
}
}
});
}

View File

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

View File

@@ -0,0 +1,103 @@
import { IStateful } from '../base/app/types';
import { getCurrentConference } from '../base/conference/functions';
import { PARTICIPANT_ROLE_CHANGED } from '../base/participants/actionTypes';
import { PARTICIPANT_ROLE } from '../base/participants/constants';
import { getLocalParticipant, isLocalParticipantModerator } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { toState } from '../base/redux/functions';
import { SET_DYNAMIC_BRANDING_DATA, SET_DYNAMIC_BRANDING_READY } from './actionTypes';
import { fetchCustomIcons } from './functions.any';
import logger from './logger';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case SET_DYNAMIC_BRANDING_DATA: {
const { customIcons } = action.value;
if (customIcons) {
fetchCustomIcons(customIcons)
.then(localCustomIcons => {
action.value.brandedIcons = localCustomIcons;
return next(action);
})
.catch((error: any) => {
logger.error('Error fetching branded custom icons:', error);
});
}
break;
}
case PARTICIPANT_ROLE_CHANGED: {
const state = store.getState();
const localParticipant = getLocalParticipant(state);
if (localParticipant?.id !== action.participant.id
&& action.participant.role !== PARTICIPANT_ROLE.MODERATOR) {
break;
}
maybeUpdatePermissions(state);
break;
}
case SET_DYNAMIC_BRANDING_READY: {
const state = store.getState();
if (!isLocalParticipantModerator(state)) {
break;
}
maybeUpdatePermissions(state);
break;
}
}
return next(action);
});
/**
* Updates the permissions metadata for the current conference if the local participant is a moderator.
*
* @param {Object|Function} stateful - Object or function that can be resolved
* to the Redux state.
* @returns {void}
*/
function maybeUpdatePermissions(stateful: IStateful): void {
const {
groupChatRequiresPermission,
pollCreationRequiresPermission
} = toState(stateful)['features/dynamic-branding'];
if (groupChatRequiresPermission || pollCreationRequiresPermission) {
const conference = getCurrentConference(stateful);
if (!conference) {
return;
}
const permissions: {
groupChatRestricted?: boolean;
pollCreationRestricted?: boolean;
} = conference.getMetadataHandler().getMetadata().permissions || {};
let sendUpdate = false;
if (groupChatRequiresPermission && !permissions.groupChatRestricted) {
permissions.groupChatRestricted = true;
sendUpdate = true;
}
if (pollCreationRequiresPermission && !permissions.pollCreationRestricted) {
permissions.pollCreationRestricted = true;
sendUpdate = true;
}
if (sendUpdate) {
conference.getMetadataHandler().setMetadata('permissions', permissions);
}
}
}

View File

@@ -0,0 +1,35 @@
import { SET_CONFIG } from '../base/config/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { SET_DYNAMIC_BRANDING_DATA } from './actionTypes';
import { fetchCustomBrandingData } from './actions.native';
import './middleware.any';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case SET_CONFIG: {
const result = next(action);
store.dispatch(fetchCustomBrandingData());
return result;
}
case SET_DYNAMIC_BRANDING_DATA: {
const { avatarBackgrounds = [] } = action.value;
// The backend may send an empty string, make sure we skip that.
if (Array.isArray(avatarBackgrounds)) {
// TODO: implement support for gradients.
action.value.avatarBackgrounds = avatarBackgrounds.filter(
(color: string) => !color.includes('linear-gradient')
);
}
break;
}
}
return next(action);
});

View File

@@ -0,0 +1,27 @@
import { APP_WILL_MOUNT } from '../base/app/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { SET_DYNAMIC_BRANDING_DATA } from './actionTypes';
import { fetchCustomBrandingData } from './actions.any';
import { createMuiBrandingTheme } from './functions.web';
import './middleware.any';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT: {
store.dispatch(fetchCustomBrandingData());
break;
}
case SET_DYNAMIC_BRANDING_DATA: {
const { customTheme } = action.value;
if (customTheme) {
action.value.muiBrandedTheme = createMuiBrandingTheme(customTheme);
}
}
}
return next(action);
});

View File

@@ -0,0 +1,290 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { type Image } from '../virtual-background/constants';
import {
SET_DYNAMIC_BRANDING_DATA,
SET_DYNAMIC_BRANDING_FAILED,
SET_DYNAMIC_BRANDING_READY,
UNSET_DYNAMIC_BRANDING
} from './actionTypes';
/**
* The name of the redux store/state property which is the root of the redux
* state of the feature {@code dynamic-branding}.
*/
const STORE_NAME = 'features/dynamic-branding';
const DEFAULT_STATE = {
/**
* The pool of avatar backgrounds.
*
* @public
* @type {Array<string>}
*/
avatarBackgrounds: [],
/**
* The custom background color for the LargeVideo.
*
* @public
* @type {string}
*/
backgroundColor: '',
/**
* The custom background image used on the LargeVideo.
*
* @public
* @type {string}
*/
backgroundImageUrl: '',
/**
* Flag indicating that the branding data can be displayed.
* This is used in order to avoid image flickering / text changing(blipping).
*
* @public
* @type {boolean}
*/
customizationReady: false,
/**
* Flag indicating that the dynamic branding data request has failed.
* When the request fails there is no logo (JitsiWatermark) displayed.
*
* @public
* @type {boolean}
*/
customizationFailed: false,
/**
* Flag indicating that the dynamic branding has not been modified and should use
* the default options.
*
* @public
* @type {boolean}
*/
defaultBranding: true,
/**
* Url for a custom page for DID numbers list.
*
* @public
* @type {string}
*/
didPageUrl: '',
/**
* Whether participant can only send group chat message if `send-groupchat`
* feature is enabled in jwt.
*
* @public
* @type {boolean}
*/
groupChatRequiresPermission: false,
/**
* The custom invite domain.
*
* @public
* @type {string}
*/
inviteDomain: '',
/**
* An object containing the mapping between the language and url where the translation
* bundle is hosted.
*
* @public
* @type {Object}
*/
labels: null,
/**
* The custom url used when the user clicks the logo.
*
* @public
* @type {string}
*/
logoClickUrl: '',
/**
* The custom logo (JitisWatermark).
*
* @public
* @type {string}
*/
logoImageUrl: '',
/**
* The generated MUI branded theme based on the custom theme json.
*
* @public
* @type {boolean}
*/
muiBrandedTheme: undefined,
/**
* Whether participant can only create polls if `create-polls` feature is enabled in jwt.
*
* @public
* @type {boolean}
*/
pollCreationRequiresPermission: false,
/**
* The lobby/prejoin background.
*
* @public
* @type {string}
*/
premeetingBackground: '',
/**
* Flag used to signal if the app should use a custom logo or not.
*
* @public
* @type {boolean}
*/
useDynamicBrandingData: false,
/**
* An array of images to be used as virtual backgrounds instead of the default ones.
*
* @public
* @type {Array<Object>}
*/
virtualBackgrounds: []
};
export interface IDynamicBrandingState {
avatarBackgrounds: string[];
backgroundColor: string;
backgroundImageUrl: string;
brandedIcons?: Record<string, string>;
customizationFailed: boolean;
customizationReady: boolean;
defaultBranding: boolean;
defaultTranscriptionLanguage?: boolean;
didPageUrl: string;
groupChatRequiresPermission: boolean;
inviteDomain: string;
labels: Object | null;
logoClickUrl: string;
logoImageUrl: string;
muiBrandedTheme?: boolean;
pollCreationRequiresPermission: boolean;
premeetingBackground: string;
requireRecordingConsent?: boolean;
sharedVideoAllowedURLDomains?: Array<string>;
showGiphyIntegration?: boolean;
skipRecordingConsentInMeeting?: boolean;
supportUrl?: string;
useDynamicBrandingData: boolean;
virtualBackgrounds: Array<Image>;
}
/**
* Reduces redux actions for the purposes of the feature {@code dynamic-branding}.
*/
ReducerRegistry.register<IDynamicBrandingState>(STORE_NAME, (state = DEFAULT_STATE, action): IDynamicBrandingState => {
switch (action.type) {
case SET_DYNAMIC_BRANDING_DATA: {
const {
avatarBackgrounds,
backgroundColor,
backgroundImageUrl,
brandedIcons,
defaultBranding,
didPageUrl,
groupChatRequiresPermission,
inviteDomain,
labels,
logoClickUrl,
logoImageUrl,
muiBrandedTheme,
pollCreationRequiresPermission,
premeetingBackground,
requireRecordingConsent,
sharedVideoAllowedURLDomains,
showGiphyIntegration,
skipRecordingConsentInMeeting,
supportUrl,
virtualBackgrounds
} = action.value;
return {
avatarBackgrounds,
backgroundColor,
backgroundImageUrl,
brandedIcons,
defaultBranding,
didPageUrl,
groupChatRequiresPermission,
inviteDomain,
labels,
logoClickUrl,
logoImageUrl,
muiBrandedTheme,
pollCreationRequiresPermission,
premeetingBackground,
requireRecordingConsent,
sharedVideoAllowedURLDomains,
showGiphyIntegration,
skipRecordingConsentInMeeting,
supportUrl,
customizationFailed: false,
customizationReady: true,
useDynamicBrandingData: true,
virtualBackgrounds: formatImages(virtualBackgrounds || [])
};
}
case SET_DYNAMIC_BRANDING_FAILED: {
return {
...state,
customizationReady: true,
customizationFailed: true,
useDynamicBrandingData: true
};
}
case SET_DYNAMIC_BRANDING_READY:
return {
...state,
customizationReady: true
};
case UNSET_DYNAMIC_BRANDING:
return DEFAULT_STATE;
}
return state;
});
/**
* Transforms the branding images into an array of Images objects ready
* to be used as virtual backgrounds.
*
* @param {Array<string>} images - The branding images.
* @private
* @returns {{Props}}
*/
function formatImages(images: Array<string> | Array<{ src: string; tooltip?: string; }>): Array<Image> {
return images.map((img, i) => {
let src;
let tooltip;
if (typeof img === 'object') {
({ src, tooltip } = img);
} else {
src = img;
}
return {
id: `branding-${i}`,
src,
tooltip
};
});
}