From 8d8ea52ddc794ae1e8b11353195757c3ab9f9229 Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Tue, 26 May 2026 15:42:48 -0300 Subject: [PATCH 1/7] [ref-perf] refactor user-basic-info hook to be used only by the presenter. --- src/commons/utils.ts | 6 +- .../action-button-dropdown/types.ts | 4 +- src/components/modal/component.tsx | 11 +- src/components/modal/hooks.ts | 4 +- .../modal/picked-user-view/types.ts | 2 +- .../modal/presenter-view/component.tsx | 28 +++- src/components/modal/presenter-view/hooks.ts | 156 ++++++++++++++++++ .../modal/presenter-view/styles.tsx | 25 +-- src/components/modal/presenter-view/types.ts | 13 +- .../presenter-view}/utils.ts | 6 +- src/components/modal/styles.tsx | 1 + src/components/modal/types.ts | 7 +- src/components/pick-random-user/component.tsx | 79 ++------- src/components/pick-random-user/context.ts | 16 -- src/components/pick-random-user/hooks.ts | 93 +---------- src/components/pick-random-user/types.ts | 6 - tsconfig.json | 1 + 17 files changed, 229 insertions(+), 229 deletions(-) create mode 100644 src/components/modal/presenter-view/hooks.ts rename src/components/{pick-random-user => modal/presenter-view}/utils.ts (86%) delete mode 100644 src/components/pick-random-user/context.ts diff --git a/src/commons/utils.ts b/src/commons/utils.ts index f4ed9cf..d06ea39 100644 --- a/src/commons/utils.ts +++ b/src/commons/utils.ts @@ -13,11 +13,11 @@ export const hasCurrentUserSeenPickedUser = ( pickedUserSeenEntries: GraphqlResponseWrapper< DataChannelEntryResponseType[]>, currentUserId: string, - pickedUserId: string, -) => pickedUserSeenEntries?.data + pickedUserId?: string, +) => !!(pickedUserSeenEntries?.data && pickedUserSeenEntries?.data.length > 0 && pickedUserSeenEntries.data.some((view) => view.payloadJson && view.payloadJson.seenByUserId === currentUserId - && view.payloadJson.pickedUserId === pickedUserId); + && view.payloadJson.pickedUserId === pickedUserId)); export const isNumber = (obj: unknown): boolean => obj && typeof obj && !Number.isNaN(obj); diff --git a/src/components/extensible-areas/action-button-dropdown/types.ts b/src/components/extensible-areas/action-button-dropdown/types.ts index 3b4e437..7e9ec4f 100644 --- a/src/components/extensible-areas/action-button-dropdown/types.ts +++ b/src/components/extensible-areas/action-button-dropdown/types.ts @@ -4,9 +4,9 @@ import { PickedUserWithEntryId } from '../../pick-random-user/types'; export interface ActionButtonDropdownManagerProps { intl: IntlShape - currentPickedUser: PickedUserWithEntryId; + currentPickedUser: PickedUserWithEntryId | null; currentUser: CurrentUserData; pluginApi: PluginApi; setShowModal: React.Dispatch>; - currentUserInfo: GraphqlResponseWrapper; + currentUserInfo?: GraphqlResponseWrapper; } diff --git a/src/components/modal/component.tsx b/src/components/modal/component.tsx index 6de75a8..c8668a9 100644 --- a/src/components/modal/component.tsx +++ b/src/components/modal/component.tsx @@ -28,12 +28,11 @@ const intlMessages = defineMessages({ export function PickUserModal(props: PickUserModalProps) { const { pickRandomUserSettings, + pluginApi, intl, showModal, handleCloseModal, - users, currentPickedUser, - handlePickRandomUser, currentUser, dataChannelPickedUsers, deletionFunction, @@ -56,9 +55,10 @@ export function PickUserModal(props: PickUserModalProps) { intl.formatMessage(intlMessages.currentUserPicked), ); + const isPresenter = currentUser?.presenter; useEffect(() => { - setShowPresenterView(currentUser?.presenter && !currentPickedUser); - }, [currentUser, currentPickedUser]); + setShowPresenterView(isPresenter && !currentPickedUser); + }, [isPresenter, currentPickedUser]); const { remainingSeconds, canClose } = usePreventCloseModalCountdown( currentUser, @@ -110,10 +110,9 @@ export function PickUserModal(props: PickUserModalProps) { {...{ intl, deletionFunction, - handlePickRandomUser, dataChannelPickedUsers, + pluginApi, pickedUserWithEntryId: currentPickedUser, - users, }} /> ) : ( diff --git a/src/components/modal/hooks.ts b/src/components/modal/hooks.ts index 443b2df..5e796e4 100644 --- a/src/components/modal/hooks.ts +++ b/src/components/modal/hooks.ts @@ -67,7 +67,7 @@ export const useHandleCurrentUserNotification = ( currentUser: CurrentUserData, pickedUserSeenEntries: GraphqlResponseWrapper< DataChannelEntryResponseType[]>, - currentPickedUser: PickedUserWithEntryId, + currentPickedUser: PickedUserWithEntryId | null, pickRandomUserSettings: PickRandomUserSettings, notificationMessage: string, ) => { @@ -94,7 +94,7 @@ export const usePreventCloseModalCountdown = ( currentUser: CurrentUserData, pickedUserSeenEntries: GraphqlResponseWrapper< DataChannelEntryResponseType[]>, - currentPickedUser: PickedUserWithEntryId, + currentPickedUser: PickedUserWithEntryId | null, pickRandomUserSettings: PickRandomUserSettings, ) => { const { preventCloseDelaySeconds } = pickRandomUserSettings; diff --git a/src/components/modal/picked-user-view/types.ts b/src/components/modal/picked-user-view/types.ts index b195c17..afb6773 100644 --- a/src/components/modal/picked-user-view/types.ts +++ b/src/components/modal/picked-user-view/types.ts @@ -9,7 +9,7 @@ import { PickedUserWithEntryId, PickedUserSeenEntryDataChannel } from '../../pic export interface PickedUserViewComponentProps { intl: IntlShape; - pickedUserWithEntryId: PickedUserWithEntryId; + pickedUserWithEntryId: PickedUserWithEntryId | null; currentUser: CurrentUserData; pickedUserSeenEntries: GraphqlResponseWrapper< DataChannelEntryResponseType[]>; diff --git a/src/components/modal/presenter-view/component.tsx b/src/components/modal/presenter-view/component.tsx index 52f899b..272f0d0 100644 --- a/src/components/modal/presenter-view/component.tsx +++ b/src/components/modal/presenter-view/component.tsx @@ -2,12 +2,11 @@ import * as React from 'react'; import { RESET_DATA_CHANNEL } from 'bigbluebutton-html-plugin-sdk'; import { DataChannelEntryResponseType } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/types'; import { defineMessages } from 'react-intl'; -import { useContext } from 'react'; import * as Styled from './styles'; import { PickedUser } from '../../pick-random-user/types'; import { PresenterViewComponentProps } from './types'; -import { FilterOptionsContext } from '../../pick-random-user/context'; +import { useGetFilterOptions, useGetPickRandomUserFunction, useGetPossibleUsersToBePicked } from './hooks'; const intlMessages = defineMessages({ filterChipsLabel: { @@ -177,16 +176,29 @@ export function PresenterViewComponent(props: PresenterViewComponentProps) { const { intl, deletionFunction, - handlePickRandomUser, dataChannelPickedUsers, pickedUserWithEntryId, - users, + pluginApi, } = props; - const { filterOptions, setFilterOptions } = useContext(FilterOptionsContext); - const { includeModerators, includePresenter, includePickedUsers } = filterOptions; + const currentUserInfo = pluginApi.useCurrentUser(); + const { data: currentUser } = currentUserInfo; - const usersCount = users?.length ?? 0; + const [{ + includeModerators, + includePresenter, + includePickedUsers, + }, setFilterOptions] = useGetFilterOptions(pluginApi, currentUser?.presenter); + + const usersToBePicked = useGetPossibleUsersToBePicked(pluginApi, { + includeModerators, + includePresenter, + includePickedUsers, + }); + + const handlePickRandomUser = useGetPickRandomUserFunction(pluginApi, usersToBePicked); + + const usersCount = usersToBePicked?.length ?? 0; const userRoleLabel = (() => { if (!includeModerators) { return usersCount !== 1 @@ -285,7 +297,7 @@ export function PresenterViewComponent(props: PresenterViewComponentProps) { ) : ( - {users?.map((user) => { + {usersToBePicked?.map((user) => { const initials = getInitials(user.name); let roleBadgeLabel: string | null = null; if (user.role === 'MODERATOR') { diff --git a/src/components/modal/presenter-view/hooks.ts b/src/components/modal/presenter-view/hooks.ts new file mode 100644 index 0000000..1711b27 --- /dev/null +++ b/src/components/modal/presenter-view/hooks.ts @@ -0,0 +1,156 @@ +import { + DataChannelEntryResponseType, + DataChannelTypes, + GraphqlResponseWrapper, + PluginApi, + PushEntryFunction, + RESET_DATA_CHANNEL, + UsersBasicInfoData, +} from 'bigbluebutton-html-plugin-sdk'; +import { useEffect, useState } from 'react'; +import { + FilterOptionsType, +} from './types'; +import { PickedUser, PickedUserSeenEntryDataChannel } from '../../pick-random-user/types'; +import { filterPossibleUsersToBePicked } from './utils'; + +export function useGetPickRandomUserFunction( + pluginApi: PluginApi, + possibleUsersToBePicked: UsersBasicInfoData[], +) { + const currentUserInfo = pluginApi.useCurrentUser(); + const { data: currentUser } = currentUserInfo; + + const { + pushEntry: pushPickedUser, + } = pluginApi.useDataChannel('pickRandomUser'); + + const { + deleteEntry: deletePickedUserSeenEntries, + } = pluginApi.useDataChannel('pickedUserSeenEntry'); + + const handlePickRandomUser = () => { + if ( + possibleUsersToBePicked + && possibleUsersToBePicked.length > 0 + && currentUser?.presenter + ) { + deletePickedUserSeenEntries([RESET_DATA_CHANNEL]); + const randomIndex = Math.floor(Math.random() * possibleUsersToBePicked.length); + const randomlyPickedUser = possibleUsersToBePicked[randomIndex]; + pushPickedUser(randomlyPickedUser); + } + }; + + return handlePickRandomUser; +} + +export function useGetPossibleUsersToBePicked( + pluginApi: PluginApi, + filterOptions: FilterOptionsType, +) { + const allUsersInfo = pluginApi?.useUsersBasicInfo + ? pluginApi?.useUsersBasicInfo() + : { data: undefined as undefined }; + const { data: allUsers } = allUsersInfo; + + const { + data: pickedUserFromDataChannelResponse, + } = pluginApi.useDataChannel('pickRandomUser'); + const pickedUserFromDataChannel = pickedUserFromDataChannelResponse?.data || []; + + return filterPossibleUsersToBePicked( + allUsers, + pickedUserFromDataChannel, + filterOptions, + ).user; +} + +// Filter Options hooks and utilities + +const useUpdateFilterOptionsOnDataChannel = ( + pushFilterOptionsToDataChannel: PushEntryFunction, + filterOptions: FilterOptionsType, + isPresenter: boolean, + dataChannelLoading: boolean, + hasDataChannelBeenApplied: boolean, +) => { + useEffect(() => { + if (hasDataChannelBeenApplied && isPresenter && !dataChannelLoading) { + pushFilterOptionsToDataChannel(filterOptions); + } + }, [isPresenter, filterOptions, dataChannelLoading, hasDataChannelBeenApplied]); +}; + +const hasFilterOptionsChanged = ( + currentFilterOptions: FilterOptionsType, + filterOptionsFromDataChannel?: FilterOptionsType, +) => filterOptionsFromDataChannel?.includePickedUsers !== currentFilterOptions.includePickedUsers + || filterOptionsFromDataChannel?.includeModerators !== currentFilterOptions.includeModerators + || filterOptionsFromDataChannel?.includePresenter !== currentFilterOptions.includePresenter; + +const useObserveFilterOptionsFromDataChannel = ( + currentFilterOptions: FilterOptionsType, + filterOptionsFromDataChannel: FilterOptionsType | null, + setFilterOptions: React.Dispatch>, + dataChannelLoading: boolean, + setHasDataChannelBeenApplied: React.Dispatch>, +) => { + useEffect(() => { + if (dataChannelLoading) return; + setHasDataChannelBeenApplied(true); + if (filterOptionsFromDataChannel + && hasFilterOptionsChanged(currentFilterOptions, filterOptionsFromDataChannel)) { + setFilterOptions({ + includePickedUsers: filterOptionsFromDataChannel.includePickedUsers, + includeModerators: filterOptionsFromDataChannel.includeModerators, + includePresenter: filterOptionsFromDataChannel.includePresenter, + }); + } + }, [filterOptionsFromDataChannel, dataChannelLoading]); +}; + +const getLatestFilterOptionsFromDataChannel = ( + filterOptionsFromDataChannelResponse: GraphqlResponseWrapper< + DataChannelEntryResponseType[] + >, +) => { + const persistedFilterOptionsList = filterOptionsFromDataChannelResponse.data; + const currentFilterOptionsFromDataChannel = persistedFilterOptionsList + ? persistedFilterOptionsList[0]?.payloadJson : null; + return currentFilterOptionsFromDataChannel; +}; + +export const useGetFilterOptions = ( + pluginApi: PluginApi, + currentUserPresenter: boolean, +): [FilterOptionsType, React.Dispatch>] => { + const [filterOptions, setFilterOptions] = useState({ + includeModerators: false, + includePresenter: false, + includePickedUsers: false, + }); + const [hasDataChannelBeenApplied, setHasDataChannelBeenApplied] = useState(false); + const { + data: filterOptionsFromDataChannel, + pushEntry: pushFilterOptionsToDataChannel, + } = pluginApi.useDataChannel('filterOptions', DataChannelTypes.LATEST_ITEM); + const latestFilterOptionFromDataChannel = getLatestFilterOptionsFromDataChannel( + filterOptionsFromDataChannel, + ); + useObserveFilterOptionsFromDataChannel( + filterOptions, + latestFilterOptionFromDataChannel, + setFilterOptions, + filterOptionsFromDataChannel.loading, + setHasDataChannelBeenApplied, + ); + useUpdateFilterOptionsOnDataChannel( + pushFilterOptionsToDataChannel, + filterOptions, + currentUserPresenter, + filterOptionsFromDataChannel.loading, + hasDataChannelBeenApplied, + ); + return [filterOptions, setFilterOptions]; +}; diff --git a/src/components/modal/presenter-view/styles.tsx b/src/components/modal/presenter-view/styles.tsx index 5e8e772..fb0a81c 100644 --- a/src/components/modal/presenter-view/styles.tsx +++ b/src/components/modal/presenter-view/styles.tsx @@ -182,30 +182,7 @@ const EmptyStateText = styled.span` color: #A7B3C3; `; -const PickedUserListContainer = styled.div` - background: #F7F9FB; - border-radius: 0.375rem; - padding: 0.625rem 0.75rem; - display: flex; - flex-direction: column; - gap: 0.375rem; - overflow-y: auto; - - &::-webkit-scrollbar { - width: 4px; - } - &::-webkit-scrollbar-track { - background: transparent; - } - &::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); - border-radius: 999px; - } - &::-webkit-scrollbar-button { - display: none; - } - scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +const PickedUserListContainer = styled(UserListContainer)` `; //
    that test selectors target with [data-test="pickRandomUserPreviouslyPickedList"] diff --git a/src/components/modal/presenter-view/types.ts b/src/components/modal/presenter-view/types.ts index 26cabcc..393aea5 100644 --- a/src/components/modal/presenter-view/types.ts +++ b/src/components/modal/presenter-view/types.ts @@ -1,13 +1,18 @@ import { DataChannelEntryResponseType } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/types'; -import { DeleteEntryFunction } from 'bigbluebutton-html-plugin-sdk'; +import { DeleteEntryFunction, PluginApi } from 'bigbluebutton-html-plugin-sdk'; import { IntlShape } from 'react-intl'; import { PickedUser, PickedUserWithEntryId } from '../../pick-random-user/types'; export interface PresenterViewComponentProps { intl: IntlShape; deletionFunction: DeleteEntryFunction; - handlePickRandomUser: () => void; dataChannelPickedUsers?: DataChannelEntryResponseType[]; - pickedUserWithEntryId: PickedUserWithEntryId; - users?: PickedUser[]; + pickedUserWithEntryId: PickedUserWithEntryId | null; + pluginApi: PluginApi; +} + +export interface FilterOptionsType { + includeModerators: boolean; + includePresenter: boolean; + includePickedUsers: boolean; } diff --git a/src/components/pick-random-user/utils.ts b/src/components/modal/presenter-view/utils.ts similarity index 86% rename from src/components/pick-random-user/utils.ts rename to src/components/modal/presenter-view/utils.ts index 921ed74..4b60dc9 100644 --- a/src/components/pick-random-user/utils.ts +++ b/src/components/modal/presenter-view/utils.ts @@ -1,9 +1,9 @@ import { DataChannelEntryResponseType, UsersBasicInfoResponseFromGraphqlWrapper } from 'bigbluebutton-html-plugin-sdk'; import { - FilterOptionsType, PickedUser, -} from './types'; -import { Role } from './enums'; +} from '../../pick-random-user/types'; +import { Role } from '../../pick-random-user/enums'; +import { FilterOptionsType } from './types'; export const filterPossibleUsersToBePicked = ( allUsers: UsersBasicInfoResponseFromGraphqlWrapper | undefined, diff --git a/src/components/modal/styles.tsx b/src/components/modal/styles.tsx index 352a1be..f734327 100644 --- a/src/components/modal/styles.tsx +++ b/src/components/modal/styles.tsx @@ -12,6 +12,7 @@ const PluginModal = styled(ReactModal)` background-color: #fff !important; width: 25rem; max-width: 95vw; + max-height: 90vh; border-radius: 0.5rem; box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.4); overflow: hidden; diff --git a/src/components/modal/types.ts b/src/components/modal/types.ts index d63e820..3e729d3 100644 --- a/src/components/modal/types.ts +++ b/src/components/modal/types.ts @@ -1,4 +1,4 @@ -import { CurrentUserData, DeleteEntryFunction, GraphqlResponseWrapper } from 'bigbluebutton-html-plugin-sdk'; +import { CurrentUserData, DeleteEntryFunction, GraphqlResponseWrapper, PluginApi } from 'bigbluebutton-html-plugin-sdk'; import { IntlShape } from 'react-intl'; import { DataChannelEntryResponseType, PushEntryFunction } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/types'; import { PickedUser, PickedUserWithEntryId, PickedUserSeenEntryDataChannel } from '../pick-random-user/types'; @@ -10,9 +10,8 @@ export interface PickUserModalProps { intl: IntlShape showModal: boolean; handleCloseModal: () => void; - users?: PickedUser[]; - currentPickedUser: PickedUserWithEntryId; - handlePickRandomUser: () => void; + pluginApi: PluginApi; + currentPickedUser: PickedUserWithEntryId | null; currentUser: CurrentUserData; dataChannelPickedUsers?: DataChannelEntryResponseType[]; deletionFunction: DeleteEntryFunction; diff --git a/src/components/pick-random-user/component.tsx b/src/components/pick-random-user/component.tsx index e1cdb49..75da05e 100644 --- a/src/components/pick-random-user/component.tsx +++ b/src/components/pick-random-user/component.tsx @@ -1,12 +1,11 @@ import * as React from 'react'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; -import { BbbPluginSdk, PluginApi, RESET_DATA_CHANNEL } from 'bigbluebutton-html-plugin-sdk'; +import { BbbPluginSdk, PluginApi } from 'bigbluebutton-html-plugin-sdk'; import { useControlModalState, useGetAllSettings, useGetCurrentPickedUser, - useGetFilterOptions, useRequestPermissionForNotification, } from './hooks'; import { @@ -14,10 +13,8 @@ import { PickedUserSeenEntryDataChannel, PickedUser, } from './types'; -import { FilterOptionsContext } from './context'; import { PickUserModal } from '../modal/component'; import ActionButtonDropdownManager from '../extensible-areas/action-button-dropdown/component'; -import { filterPossibleUsersToBePicked } from './utils'; import { useGetInternationalization } from '../../commons/hooks'; function PickRandomUserPlugin({ pluginUuid: uuid }: PickRandomUserPluginProps) { @@ -36,10 +33,6 @@ function PickRandomUserPlugin({ pluginUuid: uuid }: PickRandomUserPluginProps) { const currentUserInfo = pluginApi.useCurrentUser(); const shouldUnmountPlugin = pluginApi.useShouldUnmountPlugin(); const { data: currentUser } = currentUserInfo; - const allUsersInfo = pluginApi?.useUsersBasicInfo - ? pluginApi?.useUsersBasicInfo() - : { data: undefined as undefined }; - const { data: allUsers } = allUsersInfo; const { intl, @@ -48,40 +41,17 @@ function PickRandomUserPlugin({ pluginUuid: uuid }: PickRandomUserPluginProps) { const { data: pickedUserFromDataChannelResponse, - pushEntry: pushPickedUser, deleteEntry: deletePickedUser, } = pluginApi.useDataChannel('pickRandomUser'); const pickedUserFromDataChannel = pickedUserFromDataChannelResponse?.data; - const [filterOptions, setFilterOptions] = useGetFilterOptions(pluginApi, currentUser?.presenter); - const currentPickedUser = useGetCurrentPickedUser(pickedUserFromDataChannel); const { data: pickedUserSeenEntries, pushEntry: pushPickedUserSeen, - deleteEntry: deletePickedUserSeenEntries, } = pluginApi.useDataChannel('pickedUserSeenEntry'); - const possibleUsersToBePicked = filterPossibleUsersToBePicked( - allUsers, - pickedUserFromDataChannel, - filterOptions, - ); - - const handlePickRandomUser = () => { - if ( - possibleUsersToBePicked - && possibleUsersToBePicked.user.length > 0 - && currentUser?.presenter - ) { - deletePickedUserSeenEntries([RESET_DATA_CHANNEL]); - const randomIndex = Math.floor(Math.random() * possibleUsersToBePicked.user.length); - const randomlyPickedUser = possibleUsersToBePicked.user[randomIndex]; - pushPickedUser(randomlyPickedUser); - } - }; - const handleCloseModal = (): void => { setShowModal(false); }; @@ -95,14 +65,6 @@ function PickRandomUserPlugin({ pluginUuid: uuid }: PickRandomUserPluginProps) { setShowModal, ); - const value = useMemo( - () => ({ - filterOptions, - setFilterOptions, - }), - [filterOptions, setFilterOptions], - ); - useEffect(() => { if (!currentUser?.presenter) handleCloseModal(); }, [currentUser]); @@ -111,27 +73,22 @@ function PickRandomUserPlugin({ pluginUuid: uuid }: PickRandomUserPluginProps) { return !shouldUnmountPlugin && ( <> - - - + > -} - -export const FilterOptionsContext = createContext({ - filterOptions: { - includeModerators: false, - includePresenter: false, - includePickedUsers: false, - }, - setFilterOptions: () => {}, -}); diff --git a/src/components/pick-random-user/hooks.ts b/src/components/pick-random-user/hooks.ts index f9796e9..2f04365 100644 --- a/src/components/pick-random-user/hooks.ts +++ b/src/components/pick-random-user/hooks.ts @@ -1,10 +1,7 @@ import { CurrentUserData, DataChannelEntryResponseType, - DataChannelTypes, GraphqlResponseWrapper, - PluginApi, - PushEntryFunction, } from 'bigbluebutton-html-plugin-sdk'; import { PluginSettingsData } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/domain/settings/plugin-settings/types'; import { useEffect, useState } from 'react'; @@ -17,7 +14,6 @@ import { hasCurrentUserSeenPickedUser, isNumber } from '../../commons/utils'; import { PickRandomUserSettings } from '../../commons/types'; import { WindowClientSettings } from '../modal/types'; import { - FilterOptionsType, PickedUser, PickedUserSeenEntryDataChannel, PickedUserWithEntryId, @@ -125,95 +121,14 @@ export const useGetAllSettings = ( }; }; -// Filter Options hooks and utilities - -const useUpdateFilterOptionsOnDataChannel = ( - pushFilterOptionsToDataChannel: PushEntryFunction, - filterOptions: FilterOptionsType, - isPresenter: boolean, - dataChannelLoading: boolean, -) => { - useEffect(() => { - if (isPresenter && !dataChannelLoading) { - pushFilterOptionsToDataChannel(filterOptions); - } - }, [isPresenter, filterOptions, dataChannelLoading]); -}; - -const hasFilterOptionsChanged = ( - currentFilterOptions: FilterOptionsType, - filterOptionsFromDataChannel?: FilterOptionsType, -) => filterOptionsFromDataChannel?.includePickedUsers !== currentFilterOptions.includePickedUsers - || filterOptionsFromDataChannel?.includeModerators !== currentFilterOptions.includeModerators - || filterOptionsFromDataChannel?.includePresenter !== currentFilterOptions.includePresenter; - -const useObserveFilterOptionsFromDataChannel = ( - currentFilterOptions: FilterOptionsType, - filterOptionsFromDataChannel: FilterOptionsType | undefined, - setFilterOptions: React.Dispatch>, -) => { - useEffect(() => { - if ( - filterOptionsFromDataChannel - && hasFilterOptionsChanged(currentFilterOptions, filterOptionsFromDataChannel)) { - setFilterOptions({ - includePickedUsers: filterOptionsFromDataChannel.includePickedUsers, - includeModerators: filterOptionsFromDataChannel.includeModerators, - includePresenter: filterOptionsFromDataChannel.includePresenter, - }); - } - }, [filterOptionsFromDataChannel]); -}; - -const getLatestFilterOptionsFromDataChannel = ( - filterOptionsFromDataChannelResponse: GraphqlResponseWrapper< - DataChannelEntryResponseType[] - >, -) => { - const persistedFilterOptionsList = filterOptionsFromDataChannelResponse.data; - const currentFilterOptionsFromDataChannel = persistedFilterOptionsList - ? persistedFilterOptionsList[0]?.payloadJson : null; - return currentFilterOptionsFromDataChannel; -}; - -export const useGetFilterOptions = ( - pluginApi: PluginApi, - currentUserPresenter: boolean, -): [FilterOptionsType, React.Dispatch>] => { - const [filterOptions, setFilterOptions] = useState({ - includeModerators: false, - includePresenter: false, - includePickedUsers: false, - }); - const { - data: filterOptionsFromDataChannel, - pushEntry: pushFilterOptionsToDataChannel, - } = pluginApi.useDataChannel('filterOptions', DataChannelTypes.LATEST_ITEM); - const latestFilterOptionFromDataChannel = getLatestFilterOptionsFromDataChannel( - filterOptionsFromDataChannel, - ); - useObserveFilterOptionsFromDataChannel( - filterOptions, - latestFilterOptionFromDataChannel, - setFilterOptions, - ); - useUpdateFilterOptionsOnDataChannel( - pushFilterOptionsToDataChannel, - filterOptions, - currentUserPresenter, - filterOptionsFromDataChannel.loading, - ); - return [filterOptions, setFilterOptions]; -}; - // --- export const useGetCurrentPickedUser = ( - pickedUserFromDataChannel: DataChannelEntryResponseType[], -): PickedUserWithEntryId | undefined => { + pickedUserFromDataChannel: DataChannelEntryResponseType[] | undefined, +): PickedUserWithEntryId | null => { const [ pickedUserWithEntryId, - setPickedUserWithEntryId] = useState(); + setPickedUserWithEntryId] = useState(null); useEffect(() => { if (pickedUserFromDataChannel @@ -239,7 +154,7 @@ export const useControlModalState = ( DataChannelEntryResponseType[]>, currentUser: CurrentUserData, pickedUserTimeWindow: number, - currentPickedUser: PickedUserWithEntryId, + currentPickedUser: PickedUserWithEntryId | null, setShowModal: React.Dispatch>, ) => { const hasValidPickInTimeWindow = (pickedUser?: DataChannelEntryResponseType) => { diff --git a/src/components/pick-random-user/types.ts b/src/components/pick-random-user/types.ts index 5fc600c..c3f245e 100644 --- a/src/components/pick-random-user/types.ts +++ b/src/components/pick-random-user/types.ts @@ -22,9 +22,3 @@ export interface PickedUserSeenEntryDataChannel { pickedUserId: string; seenByUserId: string; } - -export interface FilterOptionsType { - includeModerators: boolean; - includePresenter: boolean; - includePickedUsers: boolean; -} diff --git a/tsconfig.json b/tsconfig.json index 4d5cb5d..2943606 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "skipLibCheck": true, "module": "es6", "target": "es5", + "lib": ["es2017", "dom"], "jsx": "react", "allowJs": true, "moduleResolution": "node" From 8ad42766d90ea22fc7e3ded6018f548cf0247603 Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Wed, 27 May 2026 13:50:15 -0300 Subject: [PATCH 2/7] [ref-perf] move data-filter options channel reading up to fix bugs related to initialization of the hook --- src/components/modal/component.tsx | 6 +- src/components/modal/hooks.ts | 102 +++++++++++++++++- .../modal/presenter-view/component.tsx | 17 ++- src/components/modal/presenter-view/hooks.ts | 96 +---------------- src/components/modal/presenter-view/types.ts | 9 +- src/components/modal/presenter-view/utils.ts | 2 +- src/components/modal/types.ts | 13 ++- 7 files changed, 128 insertions(+), 117 deletions(-) diff --git a/src/components/modal/component.tsx b/src/components/modal/component.tsx index c8668a9..2239e10 100644 --- a/src/components/modal/component.tsx +++ b/src/components/modal/component.tsx @@ -5,7 +5,7 @@ import * as Styled from './styles'; import { PickUserModalProps } from './types'; import { PickedUserViewComponent } from './picked-user-view/component'; import { PresenterViewComponent } from './presenter-view/component'; -import { useHandleCurrentUserNotification, usePreventCloseModalCountdown } from './hooks'; +import { useGetFilterOptions, useHandleCurrentUserNotification, usePreventCloseModalCountdown } from './hooks'; const intlMessages = defineMessages({ currentUserPicked: { @@ -41,6 +41,8 @@ export function PickUserModal(props: PickUserModalProps) { uuid, } = props; + const [filterOptions, setFilterOptions] = useGetFilterOptions(pluginApi, currentUser?.presenter ?? false); + const modalAnchor = useRef(document.getElementById(uuid)); const [showPresenterView, setShowPresenterView] = useState( @@ -109,6 +111,8 @@ export function PickUserModal(props: PickUserModalProps) { []>, - currentPickedUser: PickedUserWithEntryId, + currentPickedUser: PickedUserWithEntryId | null, ) => { const currentUserId = useCurrentUserId(currentUser); @@ -155,3 +165,91 @@ export const usePreventCloseModalCountdown = ( return { remainingSeconds, canClose }; }; + +// Filter Options hooks and utilities +const useUpdateFilterOptionsOnDataChannel = ( + pushFilterOptionsToDataChannel: PushEntryFunction, + filterOptions: FilterOptionsType, + isPresenter: boolean, + dataChannelLoading: boolean, + hasDataChannelBeenApplied: boolean, +) => { + useEffect(() => { + if (hasDataChannelBeenApplied && isPresenter && !dataChannelLoading) { + pushFilterOptionsToDataChannel(filterOptions); + } + }, [isPresenter, filterOptions, dataChannelLoading, hasDataChannelBeenApplied]); +}; + +const hasFilterOptionsChanged = ( + currentFilterOptions: FilterOptionsType, + filterOptionsFromDataChannel?: FilterOptionsType, +) => filterOptionsFromDataChannel?.includePickedUsers !== currentFilterOptions.includePickedUsers + || filterOptionsFromDataChannel?.includeModerators !== currentFilterOptions.includeModerators + || filterOptionsFromDataChannel?.includePresenter !== currentFilterOptions.includePresenter; + +const useObserveFilterOptionsFromDataChannel = ( + currentFilterOptions: FilterOptionsType, + filterOptionsFromDataChannel: FilterOptionsType | null, + setFilterOptions: React.Dispatch>, + dataChannelLoading: boolean, + setHasDataChannelBeenApplied: React.Dispatch>, +) => { + useEffect(() => { + if (dataChannelLoading) return; + setHasDataChannelBeenApplied(true); + if (filterOptionsFromDataChannel + && hasFilterOptionsChanged(currentFilterOptions, filterOptionsFromDataChannel)) { + setFilterOptions({ + includePickedUsers: filterOptionsFromDataChannel.includePickedUsers, + includeModerators: filterOptionsFromDataChannel.includeModerators, + includePresenter: filterOptionsFromDataChannel.includePresenter, + }); + } + }, [filterOptionsFromDataChannel, dataChannelLoading]); +}; + +const getLatestFilterOptionsFromDataChannel = ( + filterOptionsFromDataChannelResponse: GraphqlResponseWrapper< + DataChannelEntryResponseType[] + >, +) => { + const persistedFilterOptionsList = filterOptionsFromDataChannelResponse.data; + const currentFilterOptionsFromDataChannel = persistedFilterOptionsList + ? persistedFilterOptionsList[0]?.payloadJson : null; + return currentFilterOptionsFromDataChannel; +}; + +export const useGetFilterOptions = ( + pluginApi: PluginApi, + currentUserPresenter: boolean, +): [FilterOptionsType, React.Dispatch>] => { + const [filterOptions, setFilterOptions] = useState({ + includeModerators: false, + includePresenter: false, + includePickedUsers: false, + }); + const [hasDataChannelBeenApplied, setHasDataChannelBeenApplied] = useState(false); + const { + data: filterOptionsFromDataChannel, + pushEntry: pushFilterOptionsToDataChannel, + } = pluginApi.useDataChannel('filterOptions', DataChannelTypes.LATEST_ITEM); + const latestFilterOptionFromDataChannel = getLatestFilterOptionsFromDataChannel( + filterOptionsFromDataChannel, + ); + useObserveFilterOptionsFromDataChannel( + filterOptions, + latestFilterOptionFromDataChannel, + setFilterOptions, + filterOptionsFromDataChannel.loading, + setHasDataChannelBeenApplied, + ); + useUpdateFilterOptionsOnDataChannel( + pushFilterOptionsToDataChannel, + filterOptions, + currentUserPresenter, + filterOptionsFromDataChannel.loading, + hasDataChannelBeenApplied, + ); + return [filterOptions, setFilterOptions]; +}; diff --git a/src/components/modal/presenter-view/component.tsx b/src/components/modal/presenter-view/component.tsx index 272f0d0..8700fb1 100644 --- a/src/components/modal/presenter-view/component.tsx +++ b/src/components/modal/presenter-view/component.tsx @@ -6,7 +6,7 @@ import { defineMessages } from 'react-intl'; import * as Styled from './styles'; import { PickedUser } from '../../pick-random-user/types'; import { PresenterViewComponentProps } from './types'; -import { useGetFilterOptions, useGetPickRandomUserFunction, useGetPossibleUsersToBePicked } from './hooks'; +import { useGetPickRandomUserFunction, useGetPossibleUsersToBePicked } from './hooks'; const intlMessages = defineMessages({ filterChipsLabel: { @@ -179,22 +179,17 @@ export function PresenterViewComponent(props: PresenterViewComponentProps) { dataChannelPickedUsers, pickedUserWithEntryId, pluginApi, + filterOptions, + setFilterOptions, } = props; - const currentUserInfo = pluginApi.useCurrentUser(); - const { data: currentUser } = currentUserInfo; - - const [{ + const { includeModerators, includePresenter, includePickedUsers, - }, setFilterOptions] = useGetFilterOptions(pluginApi, currentUser?.presenter); + } = filterOptions; - const usersToBePicked = useGetPossibleUsersToBePicked(pluginApi, { - includeModerators, - includePresenter, - includePickedUsers, - }); + const usersToBePicked = useGetPossibleUsersToBePicked(pluginApi, filterOptions); const handlePickRandomUser = useGetPickRandomUserFunction(pluginApi, usersToBePicked); diff --git a/src/components/modal/presenter-view/hooks.ts b/src/components/modal/presenter-view/hooks.ts index 1711b27..aa6a267 100644 --- a/src/components/modal/presenter-view/hooks.ts +++ b/src/components/modal/presenter-view/hooks.ts @@ -1,16 +1,11 @@ import { - DataChannelEntryResponseType, - DataChannelTypes, - GraphqlResponseWrapper, PluginApi, - PushEntryFunction, RESET_DATA_CHANNEL, UsersBasicInfoData, } from 'bigbluebutton-html-plugin-sdk'; -import { useEffect, useState } from 'react'; import { FilterOptionsType, -} from './types'; +} from '../types'; import { PickedUser, PickedUserSeenEntryDataChannel } from '../../pick-random-user/types'; import { filterPossibleUsersToBePicked } from './utils'; @@ -65,92 +60,3 @@ export function useGetPossibleUsersToBePicked( filterOptions, ).user; } - -// Filter Options hooks and utilities - -const useUpdateFilterOptionsOnDataChannel = ( - pushFilterOptionsToDataChannel: PushEntryFunction, - filterOptions: FilterOptionsType, - isPresenter: boolean, - dataChannelLoading: boolean, - hasDataChannelBeenApplied: boolean, -) => { - useEffect(() => { - if (hasDataChannelBeenApplied && isPresenter && !dataChannelLoading) { - pushFilterOptionsToDataChannel(filterOptions); - } - }, [isPresenter, filterOptions, dataChannelLoading, hasDataChannelBeenApplied]); -}; - -const hasFilterOptionsChanged = ( - currentFilterOptions: FilterOptionsType, - filterOptionsFromDataChannel?: FilterOptionsType, -) => filterOptionsFromDataChannel?.includePickedUsers !== currentFilterOptions.includePickedUsers - || filterOptionsFromDataChannel?.includeModerators !== currentFilterOptions.includeModerators - || filterOptionsFromDataChannel?.includePresenter !== currentFilterOptions.includePresenter; - -const useObserveFilterOptionsFromDataChannel = ( - currentFilterOptions: FilterOptionsType, - filterOptionsFromDataChannel: FilterOptionsType | null, - setFilterOptions: React.Dispatch>, - dataChannelLoading: boolean, - setHasDataChannelBeenApplied: React.Dispatch>, -) => { - useEffect(() => { - if (dataChannelLoading) return; - setHasDataChannelBeenApplied(true); - if (filterOptionsFromDataChannel - && hasFilterOptionsChanged(currentFilterOptions, filterOptionsFromDataChannel)) { - setFilterOptions({ - includePickedUsers: filterOptionsFromDataChannel.includePickedUsers, - includeModerators: filterOptionsFromDataChannel.includeModerators, - includePresenter: filterOptionsFromDataChannel.includePresenter, - }); - } - }, [filterOptionsFromDataChannel, dataChannelLoading]); -}; - -const getLatestFilterOptionsFromDataChannel = ( - filterOptionsFromDataChannelResponse: GraphqlResponseWrapper< - DataChannelEntryResponseType[] - >, -) => { - const persistedFilterOptionsList = filterOptionsFromDataChannelResponse.data; - const currentFilterOptionsFromDataChannel = persistedFilterOptionsList - ? persistedFilterOptionsList[0]?.payloadJson : null; - return currentFilterOptionsFromDataChannel; -}; - -export const useGetFilterOptions = ( - pluginApi: PluginApi, - currentUserPresenter: boolean, -): [FilterOptionsType, React.Dispatch>] => { - const [filterOptions, setFilterOptions] = useState({ - includeModerators: false, - includePresenter: false, - includePickedUsers: false, - }); - const [hasDataChannelBeenApplied, setHasDataChannelBeenApplied] = useState(false); - const { - data: filterOptionsFromDataChannel, - pushEntry: pushFilterOptionsToDataChannel, - } = pluginApi.useDataChannel('filterOptions', DataChannelTypes.LATEST_ITEM); - const latestFilterOptionFromDataChannel = getLatestFilterOptionsFromDataChannel( - filterOptionsFromDataChannel, - ); - useObserveFilterOptionsFromDataChannel( - filterOptions, - latestFilterOptionFromDataChannel, - setFilterOptions, - filterOptionsFromDataChannel.loading, - setHasDataChannelBeenApplied, - ); - useUpdateFilterOptionsOnDataChannel( - pushFilterOptionsToDataChannel, - filterOptions, - currentUserPresenter, - filterOptionsFromDataChannel.loading, - hasDataChannelBeenApplied, - ); - return [filterOptions, setFilterOptions]; -}; diff --git a/src/components/modal/presenter-view/types.ts b/src/components/modal/presenter-view/types.ts index 393aea5..14f23df 100644 --- a/src/components/modal/presenter-view/types.ts +++ b/src/components/modal/presenter-view/types.ts @@ -2,6 +2,7 @@ import { DataChannelEntryResponseType } from 'bigbluebutton-html-plugin-sdk/dist import { DeleteEntryFunction, PluginApi } from 'bigbluebutton-html-plugin-sdk'; import { IntlShape } from 'react-intl'; import { PickedUser, PickedUserWithEntryId } from '../../pick-random-user/types'; +import { FilterOptionsType } from '../types'; export interface PresenterViewComponentProps { intl: IntlShape; @@ -9,10 +10,6 @@ export interface PresenterViewComponentProps { dataChannelPickedUsers?: DataChannelEntryResponseType[]; pickedUserWithEntryId: PickedUserWithEntryId | null; pluginApi: PluginApi; -} - -export interface FilterOptionsType { - includeModerators: boolean; - includePresenter: boolean; - includePickedUsers: boolean; + filterOptions: FilterOptionsType; + setFilterOptions: React.Dispatch>; } diff --git a/src/components/modal/presenter-view/utils.ts b/src/components/modal/presenter-view/utils.ts index 4b60dc9..3f95590 100644 --- a/src/components/modal/presenter-view/utils.ts +++ b/src/components/modal/presenter-view/utils.ts @@ -3,7 +3,7 @@ import { PickedUser, } from '../../pick-random-user/types'; import { Role } from '../../pick-random-user/enums'; -import { FilterOptionsType } from './types'; +import { FilterOptionsType } from '../types'; export const filterPossibleUsersToBePicked = ( allUsers: UsersBasicInfoResponseFromGraphqlWrapper | undefined, diff --git a/src/components/modal/types.ts b/src/components/modal/types.ts index 3e729d3..435b0cd 100644 --- a/src/components/modal/types.ts +++ b/src/components/modal/types.ts @@ -1,4 +1,9 @@ -import { CurrentUserData, DeleteEntryFunction, GraphqlResponseWrapper, PluginApi } from 'bigbluebutton-html-plugin-sdk'; +import { + CurrentUserData, + DeleteEntryFunction, + GraphqlResponseWrapper, + PluginApi, +} from 'bigbluebutton-html-plugin-sdk'; import { IntlShape } from 'react-intl'; import { DataChannelEntryResponseType, PushEntryFunction } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-channel/types'; import { PickedUser, PickedUserWithEntryId, PickedUserSeenEntryDataChannel } from '../pick-random-user/types'; @@ -20,6 +25,12 @@ export interface PickUserModalProps { pushPickedUserSeen: PushEntryFunction; } +export interface FilterOptionsType { + includeModerators: boolean; + includePresenter: boolean; + includePickedUsers: boolean; +} + export interface WindowClientSettings extends Window { meetingClientSettings?: { public: { From 68440e9c4f3840e10a138011c27e2ade1b43f918 Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Thu, 28 May 2026 11:05:33 -0300 Subject: [PATCH 3/7] [ref-perf] Unify countdown message for both presenter and viewer - Height remains constant with or without toast --- src/components/modal/component.tsx | 81 +++++++++++++++++-- .../modal/picked-user-view/component.tsx | 40 +-------- .../modal/picked-user-view/styles.tsx | 34 +------- .../modal/picked-user-view/types.ts | 1 - src/components/modal/styles.tsx | 52 +++++++++--- tests/behavioral/multi-user.spec.ts | 10 +-- tests/elements.ts | 1 - 7 files changed, 120 insertions(+), 99 deletions(-) diff --git a/src/components/modal/component.tsx b/src/components/modal/component.tsx index 2239e10..3f9e591 100644 --- a/src/components/modal/component.tsx +++ b/src/components/modal/component.tsx @@ -23,6 +23,16 @@ const intlMessages = defineMessages({ description: 'Aria label for the modal close button', defaultMessage: 'Close', }, + modalCloseDelayMessage: { + id: 'pickRandomUserPlugin.modal.closeDelayMessage', + description: 'Message showing countdown before modal can be closed', + defaultMessage: 'You can close this modal in {seconds} seconds', + }, + modalCloseDelayMessageSingular: { + id: 'pickRandomUserPlugin.modal.closeDelayMessageSingular', + description: 'Message showing countdown before modal can be closed (singular)', + defaultMessage: 'You can close this modal in {seconds} second', + }, }); export function PickUserModal(props: PickUserModalProps) { @@ -69,6 +79,34 @@ export function PickUserModal(props: PickUserModalProps) { pickRandomUserSettings, ); + const toastPhaseRef = useRef<'hidden' | 'visible' | 'exiting'>('hidden'); + const [toastRendered, setToastRendered] = useState(false); + const [toastExiting, setToastExiting] = useState(false); + + useEffect(() => { + const phase = toastPhaseRef.current; + const show = !showPresenterView && !canClose; + if (show && phase === 'hidden') { + toastPhaseRef.current = 'visible'; + setToastRendered(true); + setToastExiting(false); + } else if (!show && phase === 'visible') { + toastPhaseRef.current = 'exiting'; + setToastExiting(true); + const t = setTimeout(() => { + toastPhaseRef.current = 'hidden'; + setToastRendered(false); + setToastExiting(false); + }, 400); + return () => clearTimeout(t); + } else if (showPresenterView && phase !== 'hidden') { + toastPhaseRef.current = 'hidden'; + setToastRendered(false); + setToastExiting(false); + } + return undefined; + }, [showPresenterView, canClose]); + if (!showModal) return null; const handleCloseAttempt = () => { @@ -77,9 +115,33 @@ export function PickUserModal(props: PickUserModalProps) { } }; - const progressPercentage = pickRandomUserSettings.preventCloseDelaySeconds > 0 - ? (remainingSeconds / pickRandomUserSettings.preventCloseDelaySeconds) * 100 - : 0; + const toastMessage = intl.formatMessage( + remainingSeconds === 1 + ? intlMessages.modalCloseDelayMessageSingular + : intlMessages.modalCloseDelayMessage, + { seconds: Math.ceil(remainingSeconds) }, + ); + + const toast = toastRendered ? ( + + + {toastMessage} + + ) : null; return ( , + contentEl: React.ReactElement, + ) => ( +
    + + {contentEl} + {toast} + +
    + )} > @@ -127,11 +200,9 @@ export function PickUserModal(props: PickUserModalProps) { pickedUserWithEntryId: currentPickedUser, intl, currentUser, - showModal, setShowPresenterView, remainingSeconds, canClose, - progressPercentage, }} /> ) diff --git a/src/components/modal/picked-user-view/component.tsx b/src/components/modal/picked-user-view/component.tsx index 3993226..eb947a4 100644 --- a/src/components/modal/picked-user-view/component.tsx +++ b/src/components/modal/picked-user-view/component.tsx @@ -11,11 +11,6 @@ const intlMessages = defineMessages({ description: 'Section label shown above the picked user result', defaultMessage: 'Result', }, - currentUserPicked: { - id: 'pickRandomUserPlugin.modal.pickedUserView.title.currentUserPicked', - description: 'Title to show that current user has been picked', - defaultMessage: 'You have been randomly picked', - }, backButtonLabel: { id: 'pickRandomUserPlugin.modal.pickedUserView.backButton.label', description: 'Label of back button in picked-user view on the modal', @@ -26,16 +21,6 @@ const intlMessages = defineMessages({ description: 'Alternative text for avatar image', defaultMessage: 'Avatar image of user {0}', }, - modalCloseDelayMessage: { - id: 'pickRandomUserPlugin.modal.closeDelayMessage', - description: 'Message showing countdown before modal can be closed', - defaultMessage: 'You can close this modal in {seconds} seconds', - }, - modalCloseDelayMessageSingular: { - id: 'pickRandomUserPlugin.modal.closeDelayMessageSingular', - description: 'Message showing countdown before modal can be closed (singular)', - defaultMessage: 'You can close this modal in {seconds} second', - }, }); export function PickedUserViewComponent(props: PickedUserViewComponentProps) { @@ -46,9 +31,6 @@ export function PickedUserViewComponent(props: PickedUserViewComponentProps) { setShowPresenterView, pickedUserSeenEntries, pushPickedUserSeen, - remainingSeconds, - canClose, - progressPercentage, } = props; const handleBackToPresenterView = () => { @@ -71,7 +53,7 @@ export function PickedUserViewComponent(props: PickedUserViewComponentProps) { }); } }, [pickedUserWithEntryId]); - const avatarAltDescriptor = intl.formatMessage(intlMessages.currentUserPicked, { + const avatarAltDescriptor = intl.formatMessage(intlMessages.avatarImageAlternativeText, { 0: pickedUserWithEntryId?.pickedUser?.name, }); @@ -100,32 +82,12 @@ export function PickedUserViewComponent(props: PickedUserViewComponentProps) { ) : null } - {!canClose && remainingSeconds > 0 && !currentUser?.presenter && ( - - {intl.formatMessage( - remainingSeconds === 1 - ? intlMessages.modalCloseDelayMessageSingular - : intlMessages.modalCloseDelayMessage, - { seconds: Math.ceil(remainingSeconds) }, - )} - - )} {currentUser?.presenter && ( {intl.formatMessage(intlMessages.backButtonLabel)} - {!canClose && remainingSeconds > 0 && ( - - - - )} )} diff --git a/src/components/modal/picked-user-view/styles.tsx b/src/components/modal/picked-user-view/styles.tsx index 0945a89..d74a774 100644 --- a/src/components/modal/picked-user-view/styles.tsx +++ b/src/components/modal/picked-user-view/styles.tsx @@ -11,7 +11,7 @@ const PickedUserViewBody = styled.div` display: flex; flex-direction: column; align-items: center; - padding: 1.5rem 1.25rem 1rem; + padding: 1.2; `; const PickedUserViewFooter = styled.div` @@ -78,35 +78,6 @@ const BackButton = styled.button` } `; -const CountdownMessage = styled.div` - width: 100%; - text-align: center; - padding: 0.75rem 1rem; - margin-top: 1rem; - background-color: #f8f9fa; - border: 1px solid #dee2e6; - border-radius: 0.25rem; - color: #6c757d; - font-size: 0.9rem; - font-weight: 500; -`; - -const CountdownBarContainer = styled.div` - width: 100%; - height: 0.25rem; - background-color: #e9ecef; - border-radius: 0.125rem; - overflow: hidden; -`; - -const CountdownBar = styled.div<{ progress: number }>` - height: 100%; - background: linear-gradient(90deg, #0F70D7 0%, #0C57A7 100%); - width: ${({ progress }) => progress}%; - transition: width linear 0.1s; - border-radius: 0.125rem; -`; - export { PickedUserViewWrapper, PickedUserViewBody, @@ -116,7 +87,4 @@ export { PickedUserAvatarImage, PickedUserName, BackButton, - CountdownMessage, - CountdownBarContainer, - CountdownBar, }; diff --git a/src/components/modal/picked-user-view/types.ts b/src/components/modal/picked-user-view/types.ts index afb6773..fe98a4d 100644 --- a/src/components/modal/picked-user-view/types.ts +++ b/src/components/modal/picked-user-view/types.ts @@ -17,7 +17,6 @@ export interface PickedUserViewComponentProps { setShowPresenterView: React.Dispatch>; remainingSeconds: number; canClose: boolean; - progressPercentage: number; } export interface ModalAvatarProps { diff --git a/src/components/modal/styles.tsx b/src/components/modal/styles.tsx index f734327..e53347d 100644 --- a/src/components/modal/styles.tsx +++ b/src/components/modal/styles.tsx @@ -1,5 +1,5 @@ import * as ReactModal from 'react-modal'; -import styled from 'styled-components'; +import styled, { css, keyframes } from 'styled-components'; const PluginModal = styled(ReactModal)` position: relative; @@ -78,19 +78,47 @@ const CloseButton = styled.button` } `; -const CountdownMessage = styled.div` - width: 100%; - text-align: center; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - background-color: #f8f9fa; - border: 1px solid #dee2e6; - border-radius: 0.25rem; +const toastSlideIn = keyframes` + from { opacity: 0; transform: translateX(-50%) translateY(8px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +`; + +const toastSlideOut = keyframes` + from { opacity: 1; transform: translateX(-50%) translateY(0); } + to { opacity: 0; transform: translateX(-50%) translateY(8px); } +`; + +/* position:relative so the absolutely-positioned toast anchors to this wrapper, + not to the overlay. The wrapper's own height equals only the modal — no shift. */ +const ModalWithToastWrapper = styled.div` + position: relative; +`; + +const FloatingToast = styled.div<{ $exiting: boolean }>` + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 10px; + width: fit-content; + white-space: nowrap; + padding: 10px 16px; + border-radius: 10px; + background-color: #fff; + border: 0.5px solid #E8EDF2; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + display: flex; + align-items: center; + gap: 8px; + font-family: 'Source Sans Pro', Arial, sans-serif; + font-size: 13px; color: #6c757d; - font-size: 0.9rem; - font-weight: 500; + pointer-events: none; + ${({ $exiting }) => css` + animation: ${$exiting ? toastSlideOut : toastSlideIn} 0.35s ease forwards; + `} `; export { - PluginModal, ModalHeader, ModalTitle, CloseButton, CountdownMessage, + PluginModal, ModalHeader, ModalTitle, CloseButton, ModalWithToastWrapper, FloatingToast, }; diff --git a/tests/behavioral/multi-user.spec.ts b/tests/behavioral/multi-user.spec.ts index fd92244..98a7677 100644 --- a/tests/behavioral/multi-user.spec.ts +++ b/tests/behavioral/multi-user.spec.ts @@ -205,7 +205,7 @@ test.describe('Pick Random User Plugin - Behavioural (multi-user)', () => { ); }); - test('should show a countdown message only to the attendee and a countdown bar only to the presenter', async (): Promise => { + test('should show a countdown message to both users during the prevent-close delay', async (): Promise => { await waitForAttendeeMeeting(attendeePage); await openModal(modPage); await modPage.hasElement(e.pickRandomUserPickButton, 'pick button should be visible', ELEMENT_WAIT_LONGER_TIME); @@ -223,13 +223,7 @@ test.describe('Pick Random User Plugin - Behavioural (multi-user)', () => { const presenterCountdown = modPage.getLocator(e.pickRandomUserCountDownMessage); await test.expect( presenterCountdown, - 'presenter should NOT see the viewer-side countdown message', - ).toBeHidden({ timeout: ELEMENT_WAIT_TIME }); - - const presenterProgressBar = modPage.getLocator(e.pickRandomUserCountDownProgressBar); - await test.expect( - presenterProgressBar, - 'presenter should see the progress countdown bar', + 'presenter should see the countdown message during the prevent-close delay', ).toBeVisible({ timeout: ELEMENT_WAIT_TIME }); }); diff --git a/tests/elements.ts b/tests/elements.ts index d5b57e0..0607e40 100644 --- a/tests/elements.ts +++ b/tests/elements.ts @@ -36,7 +36,6 @@ export const elements = { pickRandomUserPickedUserName: '[data-test="pickRandomUserPickedUserName"]', pickRandomUserBackButton: '[data-test="pickRandomUserBackButton"]', pickRandomUserCountDownMessage: 'div[data-test="countDownMessage"]', - pickRandomUserCountDownProgressBar: 'div[data-test="countDownProgressBar"]', // Modal overlay (ReactModal renders this as a full-screen backdrop) pickRandomUserModalOverlay: '.modalOverlay', From 1f8e8bb1f9772d03353e6edc006bde083da0f9ea Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Thu, 28 May 2026 14:00:10 -0300 Subject: [PATCH 4/7] [ref-perf] Added different shapes for moderator and viewer --- .../modal/picked-user-view/component.tsx | 28 +++------------ .../modal/picked-user-view/styles.tsx | 26 +------------- .../modal/picked-user-view/types.ts | 4 --- .../modal/presenter-view/component.tsx | 32 +++-------------- .../modal/presenter-view/styles.tsx | 24 +------------ .../modal/user-avatar/component.tsx | 36 +++++++++++++++++++ src/components/modal/user-avatar/styles.tsx | 32 +++++++++++++++++ 7 files changed, 79 insertions(+), 103 deletions(-) create mode 100644 src/components/modal/user-avatar/component.tsx create mode 100644 src/components/modal/user-avatar/styles.tsx diff --git a/src/components/modal/picked-user-view/component.tsx b/src/components/modal/picked-user-view/component.tsx index eb947a4..01cba5c 100644 --- a/src/components/modal/picked-user-view/component.tsx +++ b/src/components/modal/picked-user-view/component.tsx @@ -4,6 +4,7 @@ import { defineMessages } from 'react-intl'; import { PickedUserViewComponentProps } from './types'; import * as Styled from './styles'; import { hasCurrentUserSeenPickedUser } from '../../../commons/utils'; +import { UserAvatar } from '../user-avatar/component'; const intlMessages = defineMessages({ resultSectionLabel: { @@ -16,11 +17,6 @@ const intlMessages = defineMessages({ description: 'Label of back button in picked-user view on the modal', defaultMessage: 'back', }, - avatarImageAlternativeText: { - id: 'pickRandomUserPlugin.modal.pickedUserView.avatarImage.alternativeText', - description: 'Alternative text for avatar image', - defaultMessage: 'Avatar image of user {0}', - }, }); export function PickedUserViewComponent(props: PickedUserViewComponentProps) { @@ -38,8 +34,6 @@ export function PickedUserViewComponent(props: PickedUserViewComponentProps) { setShowPresenterView(true); } }; - const avatarUrl = pickedUserWithEntryId?.pickedUser?.avatar; - useEffect(() => { const hasCurrentUserSeen = hasCurrentUserSeenPickedUser( pickedUserSeenEntries, @@ -53,10 +47,6 @@ export function PickedUserViewComponent(props: PickedUserViewComponentProps) { }); } }, [pickedUserWithEntryId]); - const avatarAltDescriptor = intl.formatMessage(intlMessages.avatarImageAlternativeText, { - 0: pickedUserWithEntryId?.pickedUser?.name, - }); - return ( @@ -66,18 +56,10 @@ export function PickedUserViewComponent(props: PickedUserViewComponentProps) { { (pickedUserWithEntryId) ? ( <> - {avatarUrl ? ( - - ) : ( - - {pickedUserWithEntryId?.pickedUser?.name.slice(0, 2)} - - )} + {pickedUserWithEntryId?.pickedUser?.name} ) : null diff --git a/src/components/modal/picked-user-view/styles.tsx b/src/components/modal/picked-user-view/styles.tsx index d74a774..0876e83 100644 --- a/src/components/modal/picked-user-view/styles.tsx +++ b/src/components/modal/picked-user-view/styles.tsx @@ -1,5 +1,4 @@ import styled from 'styled-components'; -import { ModalAvatarProps } from './types'; const PickedUserViewWrapper = styled.div` width: 100%; @@ -30,28 +29,6 @@ const ResultSectionLabel = styled.span` margin: 1rem 0; `; -const PickedUserAvatarInitials = styled.div` - background-color: ${({ background }) => background}; - height: 6rem; - width: 6rem; - border-radius: 50%; - display: flex; - justify-content: center; - align-items: center; - color: white; - font-size: 2.75rem; - font-weight: 400; - text-transform: capitalize; -`; - -const PickedUserAvatarImage = styled.img` - height: 8rem; - width: 8rem; - border-radius: 50%; - display: flex; - justify-content: center; - align-items: center; -`; const PickedUserName = styled.p` font-size: 1.875rem; @@ -83,8 +60,7 @@ export { PickedUserViewBody, PickedUserViewFooter, ResultSectionLabel, - PickedUserAvatarInitials, - PickedUserAvatarImage, + PickedUserName, BackButton, }; diff --git a/src/components/modal/picked-user-view/types.ts b/src/components/modal/picked-user-view/types.ts index fe98a4d..0315b4c 100644 --- a/src/components/modal/picked-user-view/types.ts +++ b/src/components/modal/picked-user-view/types.ts @@ -18,7 +18,3 @@ export interface PickedUserViewComponentProps { remainingSeconds: number; canClose: boolean; } - -export interface ModalAvatarProps { - background: string; -} diff --git a/src/components/modal/presenter-view/component.tsx b/src/components/modal/presenter-view/component.tsx index 8700fb1..eda2ac6 100644 --- a/src/components/modal/presenter-view/component.tsx +++ b/src/components/modal/presenter-view/component.tsx @@ -6,6 +6,7 @@ import { defineMessages } from 'react-intl'; import * as Styled from './styles'; import { PickedUser } from '../../pick-random-user/types'; import { PresenterViewComponentProps } from './types'; +import { UserAvatar } from '../user-avatar/component'; import { useGetPickRandomUserFunction, useGetPossibleUsersToBePicked } from './hooks'; const intlMessages = defineMessages({ @@ -106,16 +107,6 @@ const intlMessages = defineMessages({ }, }); -const FALLBACK_AVATAR_COLORS = ['#4E7FF8', '#2BA084', '#E07A3A', '#7B61D9', '#D4733B']; - -function getAvatarColorFallback(name: string): string { - const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - return FALLBACK_AVATAR_COLORS[hash % FALLBACK_AVATAR_COLORS.length]; -} - -function getInitials(name: string): string { - return name.slice(0, 2); -} function CheckboxSquare({ active }: { active: boolean }) { const style: React.CSSProperties = active ? { @@ -154,18 +145,10 @@ function makePickedUserRows(list?: DataChannelEntryResponseType[]) { const time = new Date(u.createdAt); const hh = String(time.getHours()).padStart(2, '0'); const mm = String(time.getMinutes()).padStart(2, '0'); - const { avatar, color, name } = u.payloadJson; - const initials = getInitials(name); return ( - {avatar ? ( - - ) : ( - - {initials} - - )} - {name} + + {u.payloadJson.name} {`${hh}:${mm}`} ); @@ -293,7 +276,6 @@ export function PresenterViewComponent(props: PresenterViewComponentProps) { ) : ( {usersToBePicked?.map((user) => { - const initials = getInitials(user.name); let roleBadgeLabel: string | null = null; if (user.role === 'MODERATOR') { roleBadgeLabel = intl.formatMessage(intlMessages.moderatorRoleLabel); @@ -302,13 +284,7 @@ export function PresenterViewComponent(props: PresenterViewComponentProps) { } return ( - {user.avatar ? ( - - ) : ( - - {initials} - - )} + {user.name} {roleBadgeLabel && ( {roleBadgeLabel} diff --git a/src/components/modal/presenter-view/styles.tsx b/src/components/modal/presenter-view/styles.tsx index fb0a81c..50b7245 100644 --- a/src/components/modal/presenter-view/styles.tsx +++ b/src/components/modal/presenter-view/styles.tsx @@ -110,27 +110,6 @@ const UserRow = styled.div` gap: 0.5rem; `; -const UserAvatar = styled.div<{ $color: string }>` - width: 1.625rem; - height: 1.625rem; - border-radius: 50%; - background: ${({ $color }) => $color}; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.625rem; - font-weight: 600; - color: #fff; - flex-shrink: 0; -`; - -const UserAvatarImage = styled.img` - width: 1.625rem; - height: 1.625rem; - border-radius: 50%; - object-fit: cover; - flex-shrink: 0; -`; const UserNameText = styled.span` font-size: 0.8125rem; @@ -274,8 +253,7 @@ export { CountBadge, UserListContainer, UserRow, - UserAvatar, - UserAvatarImage, + UserNameText, RoleBadge, ClearAllButton, diff --git a/src/components/modal/user-avatar/component.tsx b/src/components/modal/user-avatar/component.tsx new file mode 100644 index 0000000..ac92e0f --- /dev/null +++ b/src/components/modal/user-avatar/component.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import * as Styled from './styles'; + +const FALLBACK_AVATAR_COLORS = ['#4E7FF8', '#2BA084', '#E07A3A', '#7B61D9', '#D4733B']; + +function getAvatarColorFallback(name: string): string { + const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return FALLBACK_AVATAR_COLORS[hash % FALLBACK_AVATAR_COLORS.length]; +} + +interface AvatarUser { + name: string; + avatar: string; + color: string; + role: string; +} + +interface UserAvatarProps { + user: AvatarUser; + size: 'small' | 'large'; +} + +export function UserAvatar({ user, size }: UserAvatarProps) { + const color = user.color || getAvatarColorFallback(user.name); + const initials = user.name.slice(0, 2); + const isModerator = user.role === 'MODERATOR'; + + if (user.avatar) { + return ; + } + return ( + + {initials} + + ); +} diff --git a/src/components/modal/user-avatar/styles.tsx b/src/components/modal/user-avatar/styles.tsx new file mode 100644 index 0000000..c72e879 --- /dev/null +++ b/src/components/modal/user-avatar/styles.tsx @@ -0,0 +1,32 @@ +import styled from 'styled-components'; + +const SIZES = { + small: { dimension: '1.625rem', fontSize: '0.625rem', fontWeight: '600' }, + large: { dimension: '6rem', fontSize: '2.75rem', fontWeight: '400' }, +}; + +const AvatarInitials = styled.div<{ $size: 'small' | 'large'; $color: string; $isModerator: boolean }>` + width: ${({ $size }) => SIZES[$size].dimension}; + height: ${({ $size }) => SIZES[$size].dimension}; + border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')}; + flex-shrink: 0; + background: ${({ $color }) => $color}; + display: flex; + align-items: center; + justify-content: center; + font-size: ${({ $size }) => SIZES[$size].fontSize}; + font-weight: ${({ $size }) => SIZES[$size].fontWeight}; + color: #fff; + text-transform: capitalize; +`; + +const AvatarImage = styled.img<{ $size: 'small' | 'large'; $isModerator: boolean; $color: string }>` + width: ${({ $size }) => SIZES[$size].dimension}; + height: ${({ $size }) => SIZES[$size].dimension}; + border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')}; + flex-shrink: 0; + object-fit: cover; + border: 2px solid ${({ $color }) => $color}; +`; + +export { AvatarInitials, AvatarImage }; From 3a3794d531d5f3e4279a88698d916065dede04fc Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Thu, 28 May 2026 15:40:20 -0300 Subject: [PATCH 5/7] [ref-perf] improved timeout experience. --- src/commons/constants.ts | 2 ++ src/components/modal/component.tsx | 29 +++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/commons/constants.ts b/src/commons/constants.ts index 070b395..b2c03c1 100644 --- a/src/commons/constants.ts +++ b/src/commons/constants.ts @@ -5,3 +5,5 @@ export const TIMEOUT_CLOSE_NOTIFICATION = 5000; export const DEFAULT_PING_SOUND_URL = 'resources/sounds/doorbell.mp3'; export const DEFAULT_PREVENT_CLOSE_DELAY_SECONDS = 1; // seconds + +export const MIN_PREVENT_CLOSE_DELAY_FOR_TOAST_SECONDS = 2; // seconds diff --git a/src/components/modal/component.tsx b/src/components/modal/component.tsx index 3f9e591..be6959b 100644 --- a/src/components/modal/component.tsx +++ b/src/components/modal/component.tsx @@ -6,6 +6,7 @@ import { PickUserModalProps } from './types'; import { PickedUserViewComponent } from './picked-user-view/component'; import { PresenterViewComponent } from './presenter-view/component'; import { useGetFilterOptions, useHandleCurrentUserNotification, usePreventCloseModalCountdown } from './hooks'; +import { MIN_PREVENT_CLOSE_DELAY_FOR_TOAST_SECONDS } from '../../commons/constants'; const intlMessages = defineMessages({ currentUserPicked: { @@ -33,6 +34,11 @@ const intlMessages = defineMessages({ description: 'Message showing countdown before modal can be closed (singular)', defaultMessage: 'You can close this modal in {seconds} second', }, + modalCloseDelayMessageMs: { + id: 'pickRandomUserPlugin.modal.closeDelayMessageMs', + description: 'Message showing millisecond countdown before modal can be closed', + defaultMessage: 'You can close this modal in {ms}ms', + }, }); export function PickUserModal(props: PickUserModalProps) { @@ -72,6 +78,8 @@ export function PickUserModal(props: PickUserModalProps) { setShowPresenterView(isPresenter && !currentPickedUser); }, [isPresenter, currentPickedUser]); + const { preventCloseDelaySeconds } = pickRandomUserSettings; + const { remainingSeconds, canClose } = usePreventCloseModalCountdown( currentUser, pickedUserSeenEntries, @@ -85,7 +93,8 @@ export function PickUserModal(props: PickUserModalProps) { useEffect(() => { const phase = toastPhaseRef.current; - const show = !showPresenterView && !canClose; + const show = !showPresenterView && !canClose && remainingSeconds >= 0.3 + && preventCloseDelaySeconds >= MIN_PREVENT_CLOSE_DELAY_FOR_TOAST_SECONDS; if (show && phase === 'hidden') { toastPhaseRef.current = 'visible'; setToastRendered(true); @@ -105,7 +114,7 @@ export function PickUserModal(props: PickUserModalProps) { setToastExiting(false); } return undefined; - }, [showPresenterView, canClose]); + }, [showPresenterView, canClose, remainingSeconds]); if (!showModal) return null; @@ -115,12 +124,16 @@ export function PickUserModal(props: PickUserModalProps) { } }; - const toastMessage = intl.formatMessage( - remainingSeconds === 1 - ? intlMessages.modalCloseDelayMessageSingular - : intlMessages.modalCloseDelayMessage, - { seconds: Math.ceil(remainingSeconds) }, - ); + const toastMessage = remainingSeconds < 1 + ? intl.formatMessage(intlMessages.modalCloseDelayMessageMs, { + ms: Math.round(remainingSeconds * 1000), + }) + : intl.formatMessage( + Math.ceil(remainingSeconds) === 1 + ? intlMessages.modalCloseDelayMessageSingular + : intlMessages.modalCloseDelayMessage, + { seconds: Math.ceil(remainingSeconds) }, + ); const toast = toastRendered ? ( From 9ef9ed811cb5f72aaf5e8862fd120d9da8b0887c Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Wed, 3 Jun 2026 11:25:50 -0300 Subject: [PATCH 6/7] [ref-perf] changes in review - add locale, and fix some css and lint problems --- public/locales/de.json | 3 ++- public/locales/en.json | 3 ++- public/locales/fr-FR.json | 3 ++- public/locales/it.json | 3 ++- public/locales/ja.json | 3 ++- public/locales/pt-BR.json | 3 ++- src/components/modal/picked-user-view/styles.tsx | 2 +- src/components/modal/user-avatar/component.tsx | 10 +++++++++- 8 files changed, 22 insertions(+), 8 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index a9b6c72..03ad14a 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -30,5 +30,6 @@ "pickRandomUserPlugin.modal.presenterView.availableSection.emptyState": "Keine {0} zur Auswahl verfügbar", "pickRandomUserPlugin.modal.presenterView.previouslyPickedSection.emptyState": "Noch kein Teilnehmer ausgewählt", "pickRandomUserPlugin.modal.presenterView.roleLabel.moderator": "Moderator", - "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "Präsentator" + "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "Präsentator", + "pickRandomUserPlugin.modal.closeDelayMessageMs": "Sie können dieses Fenster in {ms}ms schließen" } diff --git a/public/locales/en.json b/public/locales/en.json index 526201e..ba636cc 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -32,5 +32,6 @@ "pickRandomUserPlugin.modal.presenterView.roleLabel.moderator": "moderator", "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "presenter", "pickRandomUserPlugin.modal.closeDelayMessage": "You can close this modal in {seconds} seconds", - "pickRandomUserPlugin.modal.closeDelayMessageSingular": "You can close this modal in {seconds} second" + "pickRandomUserPlugin.modal.closeDelayMessageSingular": "You can close this modal in {seconds} second", + "pickRandomUserPlugin.modal.closeDelayMessageMs": "You can close this modal in {ms}ms" } diff --git a/public/locales/fr-FR.json b/public/locales/fr-FR.json index b862585..8de3365 100644 --- a/public/locales/fr-FR.json +++ b/public/locales/fr-FR.json @@ -29,5 +29,6 @@ "pickRandomUserPlugin.modal.presenterView.availableSection.emptyState": "Aucun {0} disponible pour la sélection", "pickRandomUserPlugin.modal.presenterView.previouslyPickedSection.emptyState": "Aucun participant sélectionné pour l'instant", "pickRandomUserPlugin.modal.presenterView.roleLabel.moderator": "modérateur", - "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "présentateur" + "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "présentateur", + "pickRandomUserPlugin.modal.closeDelayMessageMs": "Vous pouvez fermer cette fenêtre dans {ms}ms" } diff --git a/public/locales/it.json b/public/locales/it.json index 68e1bb9..505a4ff 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -29,5 +29,6 @@ "pickRandomUserPlugin.modal.presenterView.availableSection.emptyState": "Nessun {0} disponibile per la selezione", "pickRandomUserPlugin.modal.presenterView.previouslyPickedSection.emptyState": "Nessun utente selezionato ancora", "pickRandomUserPlugin.modal.presenterView.roleLabel.moderator": "moderatore", - "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "presentatore" + "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "presentatore", + "pickRandomUserPlugin.modal.closeDelayMessageMs": "Puoi chiudere questa finestra in {ms}ms" } diff --git a/public/locales/ja.json b/public/locales/ja.json index 7533564..7b2a645 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -30,5 +30,6 @@ "pickRandomUserPlugin.modal.presenterView.availableSection.emptyState": "選択可能な{0}はいません", "pickRandomUserPlugin.modal.presenterView.previouslyPickedSection.emptyState": "まだユーザーが選択されていません", "pickRandomUserPlugin.modal.presenterView.roleLabel.moderator": "モデレーター", - "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "発表者" + "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "発表者", + "pickRandomUserPlugin.modal.closeDelayMessageMs": "このモーダルは{ms}ms後に閉じることができます" } diff --git a/public/locales/pt-BR.json b/public/locales/pt-BR.json index 24fea30..24b65cd 100644 --- a/public/locales/pt-BR.json +++ b/public/locales/pt-BR.json @@ -30,5 +30,6 @@ "pickRandomUserPlugin.modal.presenterView.availableSection.emptyState": "Nenhum {0} disponível para seleção", "pickRandomUserPlugin.modal.presenterView.previouslyPickedSection.emptyState": "Nenhum usuário selecionado ainda", "pickRandomUserPlugin.modal.presenterView.roleLabel.moderator": "moderador", - "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "apresentador" + "pickRandomUserPlugin.modal.presenterView.roleLabel.presenter": "apresentador", + "pickRandomUserPlugin.modal.closeDelayMessageMs": "Você pode fechar este modal em {ms}ms" } diff --git a/src/components/modal/picked-user-view/styles.tsx b/src/components/modal/picked-user-view/styles.tsx index 0876e83..5b8f583 100644 --- a/src/components/modal/picked-user-view/styles.tsx +++ b/src/components/modal/picked-user-view/styles.tsx @@ -10,7 +10,7 @@ const PickedUserViewBody = styled.div` display: flex; flex-direction: column; align-items: center; - padding: 1.2; + padding: 1.2rem; `; const PickedUserViewFooter = styled.div` diff --git a/src/components/modal/user-avatar/component.tsx b/src/components/modal/user-avatar/component.tsx index ac92e0f..ad69142 100644 --- a/src/components/modal/user-avatar/component.tsx +++ b/src/components/modal/user-avatar/component.tsx @@ -26,7 +26,15 @@ export function UserAvatar({ user, size }: UserAvatarProps) { const isModerator = user.role === 'MODERATOR'; if (user.avatar) { - return ; + return ( + + ); } return ( From 7f4d9ad79c805cd50680d5126099a7fc0a8444b9 Mon Sep 17 00:00:00 2001 From: Guilherme Leme Date: Wed, 3 Jun 2026 14:36:52 -0300 Subject: [PATCH 7/7] [ref-perf] changes in review - fix tests and lint errors --- .env.template | 7 + src/components/modal/component.tsx | 66 ++++-- .../modal/picked-user-view/styles.tsx | 1 - .../modal/presenter-view/component.tsx | 1 - .../modal/presenter-view/styles.tsx | 1 - tests/behavioral/multi-user.spec.ts | 198 +++++++++++++----- 6 files changed, 199 insertions(+), 75 deletions(-) diff --git a/.env.template b/.env.template index 268df93..35eb4cd 100644 --- a/.env.template +++ b/.env.template @@ -21,3 +21,10 @@ TIMEOUT_MULTIPLIER=1 # Set to "true" to enable CI mode (1 worker, retries, blob reporter). CI="false" + +# Public URL of a clientSettingsOverride JSON that sets preventCloseDelaySeconds: 10. +# Required for the countdown / close-prevention behavioural tests. +# Must be reachable by the BBB server (a GitHub Gist raw URL works well). +# Leave empty to skip those tests. +# Example: PREVENT_CLOSE_DELAY_SETTINGS_URL="https://gist.githubusercontent.com///raw//bbb-pick-random-user-prevent-close-delay.json" +PREVENT_CLOSE_DELAY_SETTINGS_URL="https://bigbluebutton.nyc3.digitaloceanspaces.com/plugins/assets/bbb-plugin-pick-random-user/prevent-close-delay-10s.json" diff --git a/src/components/modal/component.tsx b/src/components/modal/component.tsx index be6959b..8669100 100644 --- a/src/components/modal/component.tsx +++ b/src/components/modal/component.tsx @@ -1,4 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; +import { + useCallback, useEffect, useRef, useState, +} from 'react'; import * as React from 'react'; import { defineMessages } from 'react-intl'; import * as Styled from './styles'; @@ -41,6 +43,25 @@ const intlMessages = defineMessages({ }, }); +function OverlayWithToast({ + overlayProps, + contentEl, + toast, +}: { + overlayProps: React.ComponentPropsWithRef<'div'>; + contentEl: React.ReactElement; + toast: React.ReactNode; +}) { + return ( +
    + + {contentEl} + {toast} + +
    + ); +} + export function PickUserModal(props: PickUserModalProps) { const { pickRandomUserSettings, @@ -57,7 +78,10 @@ export function PickUserModal(props: PickUserModalProps) { uuid, } = props; - const [filterOptions, setFilterOptions] = useGetFilterOptions(pluginApi, currentUser?.presenter ?? false); + const [filterOptions, setFilterOptions] = useGetFilterOptions( + pluginApi, + currentUser?.presenter ?? false, + ); const modalAnchor = useRef(document.getElementById(uuid)); @@ -116,14 +140,6 @@ export function PickUserModal(props: PickUserModalProps) { return undefined; }, [showPresenterView, canClose, remainingSeconds]); - if (!showModal) return null; - - const handleCloseAttempt = () => { - if (canClose) { - handleCloseModal(); - } - }; - const toastMessage = remainingSeconds < 1 ? intl.formatMessage(intlMessages.modalCloseDelayMessageMs, { ms: Math.round(remainingSeconds * 1000), @@ -156,6 +172,24 @@ export function PickUserModal(props: PickUserModalProps) {
    ) : null; + const renderOverlay = useCallback( + ( + overlayProps: React.ComponentPropsWithRef<'div'>, + contentEl: React.ReactElement, + ) => ( + + ), + [toast], + ); + + if (!showModal) return null; + + const handleCloseAttempt = () => { + if (canClose) { + handleCloseModal(); + } + }; + return ( , - contentEl: React.ReactElement, - ) => ( -
    - - {contentEl} - {toast} - -
    - )} + overlayElement={renderOverlay} > diff --git a/src/components/modal/picked-user-view/styles.tsx b/src/components/modal/picked-user-view/styles.tsx index 5b8f583..b90e369 100644 --- a/src/components/modal/picked-user-view/styles.tsx +++ b/src/components/modal/picked-user-view/styles.tsx @@ -29,7 +29,6 @@ const ResultSectionLabel = styled.span` margin: 1rem 0; `; - const PickedUserName = styled.p` font-size: 1.875rem; font-weight: 500; diff --git a/src/components/modal/presenter-view/component.tsx b/src/components/modal/presenter-view/component.tsx index eda2ac6..106ec15 100644 --- a/src/components/modal/presenter-view/component.tsx +++ b/src/components/modal/presenter-view/component.tsx @@ -107,7 +107,6 @@ const intlMessages = defineMessages({ }, }); - function CheckboxSquare({ active }: { active: boolean }) { const style: React.CSSProperties = active ? { width: '0.875rem', diff --git a/src/components/modal/presenter-view/styles.tsx b/src/components/modal/presenter-view/styles.tsx index 50b7245..23d9b58 100644 --- a/src/components/modal/presenter-view/styles.tsx +++ b/src/components/modal/presenter-view/styles.tsx @@ -110,7 +110,6 @@ const UserRow = styled.div` gap: 0.5rem; `; - const UserNameText = styled.span` font-size: 0.8125rem; color: #1C2B3A; diff --git a/tests/behavioral/multi-user.spec.ts b/tests/behavioral/multi-user.spec.ts index 98a7677..080dcd0 100644 --- a/tests/behavioral/multi-user.spec.ts +++ b/tests/behavioral/multi-user.spec.ts @@ -205,57 +205,6 @@ test.describe('Pick Random User Plugin - Behavioural (multi-user)', () => { ); }); - test('should show a countdown message to both users during the prevent-close delay', async (): Promise => { - await waitForAttendeeMeeting(attendeePage); - await openModal(modPage); - await modPage.hasElement(e.pickRandomUserPickButton, 'pick button should be visible', ELEMENT_WAIT_LONGER_TIME); - await modPage.page.click(e.pickRandomUserPickButton); - - await modPage.hasElement(e.pickRandomUserPickedUserViewTitle, 'presenter view should open', ELEMENT_WAIT_LONGER_TIME); - await attendeePage.hasElement(e.pickRandomUserPickedUserViewTitle, 'attendee view should open', ELEMENT_WAIT_LONGER_TIME); - - const attendeeCountdown = attendeePage.getLocator(e.pickRandomUserCountDownMessage); - await test.expect( - attendeeCountdown, - 'attendee should see the countdown message during the prevent-close delay', - ).toBeVisible({ timeout: ELEMENT_WAIT_TIME }); - - const presenterCountdown = modPage.getLocator(e.pickRandomUserCountDownMessage); - await test.expect( - presenterCountdown, - 'presenter should see the countdown message during the prevent-close delay', - ).toBeVisible({ timeout: ELEMENT_WAIT_TIME }); - }); - - test('should not close the picked attendee modal when clicking the overlay during the countdown lock period', async (): Promise => { - await waitForAttendeeMeeting(attendeePage); - await openModal(modPage); - await modPage.hasElement(e.pickRandomUserPickButton, 'pick button should be visible', ELEMENT_WAIT_LONGER_TIME); - - // Pick the attendee – the countdown lock starts on the attendee side immediately. - await modPage.page.click(e.pickRandomUserPickButton); - - // Wait for the attendee's modal to open via data-channel sync. - await attendeePage.hasElement( - e.pickRandomUserPickedUserViewTitle, - 'attendee modal should open after being picked', - ELEMENT_WAIT_LONGER_TIME, - ); - - // Click the modal overlay at the top-left corner (well outside the centred modal - // content) while the countdown is still active (shouldCloseOnOverlayClick === false). - await attendeePage.page.locator(e.pickRandomUserModalOverlay).click({ - position: { x: 5, y: 5 }, - force: true, - }); - - // The modal must still be open – the overlay click should have been swallowed. - await test.expect( - attendeePage.getLocator(e.pickRandomUserPickedUserViewTitle), - 'modal should remain open after clicking the overlay during the countdown lock period', - ).toBeVisible({ timeout: ELEMENT_WAIT_TIME }); - }); - test('should keep the previously-picked viewer in the available pool and re-pick the same user when "include already picked users" is enabled and the presenter navigates back', async (): Promise => { await waitForAttendeeMeeting(attendeePage); await openModal(modPage); @@ -356,3 +305,150 @@ test.describe('Pick Random User Plugin - Behavioural (multi-user)', () => { ); }); }); + +// ── Countdown / close-prevention tests ──────────────────────────────────────── +// These tests require preventCloseDelaySeconds to exceed the source-code threshold +// (MIN_PREVENT_CLOSE_DELAY_FOR_TOAST_SECONDS = 2 s) so the countdown toast appears, +// and to be long enough that the countdown is still active after the data-channel +// sync delay. The setting is injected at meeting-creation time via a +// clientSettingsOverride JSON hosted at a publicly reachable URL (set via env var). +// +// NOTE: the BBB server must be able to fetch PREVENT_CLOSE_DELAY_SETTINGS_URL. +// A URL pointing to a private/Docker IP will be rejected by BBB's URL validator. +// Use a public gist (or any public HTTPS host) and set PREVENT_CLOSE_DELAY_SETTINGS_URL +// in .env. Until then these tests are marked fixme and skipped. +test.describe('Pick Random User Plugin - Behavioural (countdown and close-prevention, multi-user)', () => { + test.describe.configure({ mode: ISOLATED ? 'default' : 'serial' }); + + const SETTINGS_OVERRIDE_URL = process.env.PREVENT_CLOSE_DELAY_SETTINGS_URL ?? ''; + + let modPage: Page; + let attendeePage: Page; + let modContext: BrowserContext; + let attendeeContext: BrowserContext; + + async function setupMeeting(browser: Browser, request: APIRequestContext, testInfo: TestInfo) { + await checkPluginAvailability({ + pluginName: PLUGIN_NAME, + envVarName: ENV_VAR_NAME, + setPluginUrl, + getPluginUrl, + })({ request }, testInfo); + + const resolvedUrl = getPluginUrl(); + if (!resolvedUrl) return; + + const pluginManifestsParam = encodeCustomParams( + `pluginManifests=${JSON.stringify([{ url: resolvedUrl }])}`, + ); + const createParameter = SETTINGS_OVERRIDE_URL + ? `${pluginManifestsParam}&clientSettingsOverrideJsonUrl=${encodeURIComponent(SETTINGS_OVERRIDE_URL)}` + : pluginManifestsParam; + + modContext = await browser.newContext({ + permissions: ['clipboard-read', 'clipboard-write', 'camera', 'microphone'], + viewport: { width: 1280, height: 720 }, + }); + const modRawPage = await modContext.newPage(); + const sample = new Plugin({ browser, context: modContext }); + await sample.initModPage(modRawPage, { createParameter }); + modPage = sample.modPage; + + attendeeContext = await browser.newContext({ + permissions: ['clipboard-read', 'clipboard-write', 'camera', 'microphone'], + viewport: { width: 1280, height: 720 }, + }); + const attendeeRawPage = await attendeeContext.newPage(); + attendeePage = new Page({ browser, page: attendeeRawPage }); + + const joinUrl = getJoinURL({ + meetingID: modPage.meetingId, + isModerator: false, + skipSessionDetailsModal: true, + }); + await attendeeRawPage.goto(joinUrl); + await attendeeRawPage.waitForSelector('div#layout', { timeout: ELEMENT_WAIT_EXTRA_LONG_TIME }); + attendeePage.settings = await generateSettingsData(attendeeRawPage); + if (attendeePage.settings?.autoJoinAudioModal) { + await attendeePage.closeAudioModal(); + } + await attendeeRawPage.addStyleTag({ + content: "body { font-family: 'Liberation Sans', Arial, sans-serif; }", + }); + attendeePage.meetingId = modPage.meetingId; + } + + if (ISOLATED) { + test.beforeEach(async ({ browser, request }, testInfo) => { + await setupMeeting(browser, request, testInfo); + }); + test.afterEach(async () => { + await modContext?.close(); + await attendeeContext?.close(); + }); + } else { + test.beforeAll(async ({ browser, request }, testInfo) => { + await setupMeeting(browser, request, testInfo); + }); + test.afterAll(async () => { + await modContext?.close(); + await attendeeContext?.close(); + }); + test.afterEach(async () => { + if (modPage && attendeePage) await cleanupAfterTest(modPage, attendeePage); + }); + } + + test('should show a countdown message to both users during the prevent-close delay', async (): Promise => { + test.skip(!SETTINGS_OVERRIDE_URL, 'Set PREVENT_CLOSE_DELAY_SETTINGS_URL in .env to enable this test'); + await waitForAttendeeMeeting(attendeePage); + await openModal(modPage); + await modPage.hasElement(e.pickRandomUserPickButton, 'pick button should be visible', ELEMENT_WAIT_LONGER_TIME); + await modPage.page.click(e.pickRandomUserPickButton); + + await modPage.hasElement(e.pickRandomUserPickedUserViewTitle, 'presenter view should open', ELEMENT_WAIT_LONGER_TIME); + await attendeePage.hasElement(e.pickRandomUserPickedUserViewTitle, 'attendee view should open', ELEMENT_WAIT_LONGER_TIME); + + const attendeeCountdown = attendeePage.getLocator(e.pickRandomUserCountDownMessage); + await test.expect( + attendeeCountdown, + 'attendee should see the countdown message during the prevent-close delay', + ).toBeVisible({ timeout: ELEMENT_WAIT_TIME }); + + const presenterCountdown = modPage.getLocator(e.pickRandomUserCountDownMessage); + await test.expect( + presenterCountdown, + 'presenter should see the countdown message during the prevent-close delay', + ).toBeVisible({ timeout: ELEMENT_WAIT_TIME }); + }); + + test('should not close the picked attendee modal when clicking the overlay during the countdown lock period', async (): Promise => { + test.skip(!SETTINGS_OVERRIDE_URL, 'Set PREVENT_CLOSE_DELAY_SETTINGS_URL in .env to enable this test'); + await waitForAttendeeMeeting(attendeePage); + await openModal(modPage); + await modPage.hasElement(e.pickRandomUserPickButton, 'pick button should be visible', ELEMENT_WAIT_LONGER_TIME); + + // Pick the attendee – the countdown lock starts on the attendee side immediately. + await modPage.page.click(e.pickRandomUserPickButton); + + // Wait for the attendee's modal to open via data-channel sync. + await attendeePage.hasElement( + e.pickRandomUserPickedUserViewTitle, + 'attendee modal should open after being picked', + ELEMENT_WAIT_LONGER_TIME, + ); + + // Click the modal overlay at the top-left corner (well outside the centred modal + // content) while the countdown is still active (shouldCloseOnOverlayClick === false). + await attendeePage.page.locator(e.pickRandomUserModalOverlay).click({ + position: { x: 5, y: 5 }, + force: true, + }); + + // The modal must still be open – the overlay click should have been swallowed. + await test.expect( + attendeePage.getLocator(e.pickRandomUserPickedUserViewTitle), + 'modal should remain open after clicking the overlay during the countdown lock period', + ).toBeVisible({ timeout: ELEMENT_WAIT_TIME }); + }); +});