mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
♻️(frontend) introduce persistent reaction toolbar
Inspired by proprietary solutions. Replace the dialog-based reaction UI with a toolbar integrated directly into the DOM. Allow it to remain open and support proper keyboard interaction, improving accessibility and user experience.
This commit is contained in:
@@ -278,6 +278,7 @@ const config: Config = {
|
||||
'room-side-panel': { value: '360px' },
|
||||
'room-side-panel-margin': { value: '1.5rem' },
|
||||
'room-control-bar': { value: '80px' },
|
||||
'room-reaction-toolbar-height': { value: '42px' },
|
||||
},
|
||||
spacing,
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
|
||||
const RoomViewport = styled(
|
||||
'div',
|
||||
@@ -14,15 +15,25 @@ const RoomViewport = styled(
|
||||
base: {
|
||||
position: 'absolute',
|
||||
maxHeight: '100%',
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
transition:
|
||||
'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms, padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
},
|
||||
variants: {
|
||||
isSidePanelOpen: {
|
||||
true: {
|
||||
inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar) + var(--lk-grid-gap)) 16px`,
|
||||
inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar)) 16px`,
|
||||
},
|
||||
false: {
|
||||
inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar) + var(--lk-grid-gap))`,
|
||||
inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar))`,
|
||||
},
|
||||
},
|
||||
isReactionToolbarOpen: {
|
||||
true: {
|
||||
paddingBottom:
|
||||
'calc(var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))',
|
||||
},
|
||||
false: {
|
||||
paddingBottom: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -58,9 +69,13 @@ interface RoomContentAreaProps {
|
||||
export function RoomContentArea({ children }: RoomContentAreaProps) {
|
||||
const { isSidePanelOpen } = useSidePanel()
|
||||
const { areSubtitlesOpen } = useSubtitles()
|
||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||
|
||||
return (
|
||||
<RoomViewport isSidePanelOpen={isSidePanelOpen}>
|
||||
<RoomViewport
|
||||
isSidePanelOpen={isSidePanelOpen}
|
||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||
>
|
||||
<TrackAreaContainer areSubtitlesOpen={areSubtitlesOpen}>
|
||||
{children}
|
||||
</TrackAreaContainer>
|
||||
|
||||
@@ -1,102 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButton, Button } from '@/primitives'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import {
|
||||
Popover as RACPopover,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
} from 'react-aria-components'
|
||||
import { FocusScope } from '@react-aria/focus'
|
||||
import { useReactions } from '../hooks/useReactions'
|
||||
import { Emoji } from '../types'
|
||||
import { getEmojiLabel } from '../utils'
|
||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||
|
||||
export const ReactionsToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { sendReaction } = useReactions()
|
||||
const { isOpen, toggle } = useReactionsToolbar()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: () => setIsOpen((prev) => !prev),
|
||||
handler: toggle,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={css({ position: 'relative' })}>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
tooltip={t('button')}
|
||||
isSelected={isOpen}
|
||||
onChange={setIsOpen}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
<RACPopover
|
||||
placement="top"
|
||||
offset={8}
|
||||
isNonModal
|
||||
shouldCloseOnInteractOutside={() => false}
|
||||
className={css({
|
||||
borderRadius: '8px',
|
||||
padding: '0.35rem',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
'&[data-entering]': {
|
||||
animation: 'fade 200ms ease',
|
||||
},
|
||||
'&[data-exiting]': {
|
||||
animation: 'fade 200ms ease-in reverse',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Dialog className={css({ outline: 'none' })}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
|
||||
<FocusScope contain autoFocus restoreFocus>
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t('button')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{Object.values(Emoji).map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => sendReaction(emoji)}
|
||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
square
|
||||
data-attr={`send-reaction-${emoji}`}
|
||||
>
|
||||
<img
|
||||
src={`/assets/reactions/${emoji}.png`}
|
||||
alt=""
|
||||
className={css({
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FocusScope>
|
||||
</Dialog>
|
||||
</RACPopover>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</>
|
||||
<ToggleButton
|
||||
id="reactions-toggle"
|
||||
data-attr="reactions-toggle"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
tooltip={t('button')}
|
||||
isSelected={isOpen}
|
||||
onChange={toggle}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { getEmojiLabel } from '../../utils'
|
||||
import { Emoji } from '../../types'
|
||||
import { useReactions } from '../../hooks/useReactions'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||
export const ReactionButton = ({ emoji }: { emoji: Emoji }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const { sendReaction } = useReactions()
|
||||
return (
|
||||
<Button
|
||||
onPress={() => sendReaction(emoji)}
|
||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
round
|
||||
data-attr={`send-reaction-${emoji}`}
|
||||
>
|
||||
<img
|
||||
src={`/assets/reactions/${emoji}.png`}
|
||||
alt=""
|
||||
className={css({
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { FocusScope, useFocusManager } from '@react-aria/focus'
|
||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { ReactionButton } from './ReactionButton'
|
||||
import { Emoji } from '../../types'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { getFirstControlBarFocusable } from '@/utils/dom'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
||||
|
||||
const Container = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
bottom: 'var(--sizes-room-control-bar)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledStrip = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.2rem',
|
||||
borderRadius: '21px',
|
||||
padding: '0.15rem',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
opacity: 0,
|
||||
transform: 'translateY(3.25rem)',
|
||||
transition: 'opacity, transform',
|
||||
transitionDuration: '0.5s',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
variants: {
|
||||
isVisible: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
desktopOffset: {
|
||||
true: {
|
||||
// Ideally this value should be calculated dynamically in JavaScript to keep
|
||||
// the reaction toolbar perfectly centered relative to the reaction toggle.
|
||||
// However, for simplicity and to follow a pragmatic 80/20 approach,
|
||||
// this value is currently hardcoded in CSS.
|
||||
marginRight: '30px',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Strip = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const isMobile = useIsMobile()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// defer one frame so the browser paints opacity:0 first
|
||||
const id = requestAnimationFrame(() => setIsVisible(true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<StyledStrip
|
||||
ref={ref}
|
||||
aria-hidden={!isOpen}
|
||||
isVisible={isVisible}
|
||||
desktopOffset={!isMobile}
|
||||
>
|
||||
{children}
|
||||
</StyledStrip>
|
||||
)
|
||||
}
|
||||
|
||||
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
|
||||
const focusManager = useFocusManager()
|
||||
|
||||
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
|
||||
if (comingFromOutside) {
|
||||
focusManager?.focusFirst()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext({ wrap: true })
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious({ wrap: true })
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
document.getElementById('reactions-toggle')?.focus()
|
||||
layoutStore.showReactionsToolbar = false
|
||||
break
|
||||
case 'Tab':
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault()
|
||||
getFirstControlBarFocusable('control-bar')?.focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="toolbar" onKeyDown={onKeyDown} onFocus={onFocus}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionsToolbar = () => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const shouldMount = useDelayUnmount(isOpen, 300)
|
||||
|
||||
if (!shouldMount) return null
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
|
||||
<FocusScope autoFocus>
|
||||
<KeyboardNavigation>
|
||||
<Strip>
|
||||
{Object.values(Emoji).map((emoji) => (
|
||||
<ReactionButton key={emoji} emoji={emoji} />
|
||||
))}
|
||||
</Strip>
|
||||
</KeyboardNavigation>
|
||||
</FocusScope>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export const useReactionsToolbar = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
|
||||
return {
|
||||
isOpen: layoutSnap.showReactionsToolbar,
|
||||
toggle: () => {
|
||||
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Emoji } from './types'
|
||||
|
||||
export const getEmojiLabel = (
|
||||
emoji: string,
|
||||
emoji: string | Emoji,
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
) => {
|
||||
const emojiLabels: Record<string, string> = {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -25,6 +26,7 @@ type StyledSidePanelProps = {
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
backButtonLabel: string
|
||||
isReactionToolbarOpen?: boolean
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
@@ -33,6 +35,7 @@ const StyledSidePanel = ({
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
isReactionToolbarOpen,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
@@ -53,11 +56,11 @@ const StyledSidePanel = ({
|
||||
flexDirection: 'column',
|
||||
margin: 'var(--sizes-room-side-panel-margin)',
|
||||
marginLeft: 0,
|
||||
marginBottom: 0,
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 'var(--sizes-room-control-bar)',
|
||||
width: 'var(--sizes-room-side-panel)',
|
||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||
})}
|
||||
@@ -65,6 +68,9 @@ const StyledSidePanel = ({
|
||||
transform: isClosed
|
||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
||||
: 'none',
|
||||
bottom: isReactionToolbarOpen
|
||||
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
||||
: 'var(--sizes-room-control-bar)',
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
@@ -154,6 +160,8 @@ export const SidePanel = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||
|
||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={title}
|
||||
@@ -167,6 +175,7 @@ export const SidePanel = () => {
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as React from 'react'
|
||||
import { MobileControlBar } from './MobileControlBar'
|
||||
import { DesktopControlBar } from './DesktopControlBar'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||
@@ -16,10 +18,32 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileControlBar onDeviceError={onDeviceError} />
|
||||
}
|
||||
return <DesktopControlBar onDeviceError={onDeviceError} />
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
>
|
||||
<ReactionsToolbar />
|
||||
<div
|
||||
id="control-bar"
|
||||
className={css({
|
||||
zIndex: 100,
|
||||
})}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MobileControlBar onDeviceError={onDeviceError} />
|
||||
) : (
|
||||
<DesktopControlBar onDeviceError={onDeviceError} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
||||
|
||||
@@ -48,11 +48,7 @@ export function DesktopControlBar({
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
padding: '1.125rem',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -43,12 +43,8 @@ export function MobileControlBar({
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
padding: '1.125rem',
|
||||
justifyContent: 'center',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const useDelayUnmount = (isMounted: boolean, delayTime: number) => {
|
||||
const [shouldRender, setShouldRender] = useState(false)
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
if (isMounted && !shouldRender) {
|
||||
setShouldRender(true)
|
||||
} else if (!isMounted && shouldRender) {
|
||||
timeoutId = setTimeout(() => setShouldRender(false), delayTime)
|
||||
}
|
||||
return () => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}, [isMounted, delayTime, shouldRender])
|
||||
return shouldRender
|
||||
}
|
||||
@@ -44,6 +44,13 @@ export const buttonRecipe = cva({
|
||||
paddingY: 'var(--square-padding)',
|
||||
},
|
||||
},
|
||||
round: {
|
||||
true: {
|
||||
borderRadius: '50%',
|
||||
paddingX: 'var(--square-padding)',
|
||||
paddingY: 'var(--square-padding)',
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
primary: {
|
||||
backgroundColor: 'primary.800',
|
||||
|
||||
@@ -10,6 +10,7 @@ type State = {
|
||||
showSubtitles: boolean
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
showReactionsToolbar: boolean
|
||||
}
|
||||
|
||||
export const layoutStore = proxy<State>({
|
||||
@@ -18,4 +19,5 @@ export const layoutStore = proxy<State>({
|
||||
showSubtitles: false,
|
||||
activePanelId: null,
|
||||
activeSubPanelId: null,
|
||||
showReactionsToolbar: false,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const getFirstControlBarFocusable = (id: string): HTMLElement | null =>
|
||||
document
|
||||
.getElementById(id)
|
||||
?.querySelector(
|
||||
'input, select, textarea, button, object, a, area[href], [tabindex]'
|
||||
) ?? null
|
||||
Reference in New Issue
Block a user