mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-01 05:07:56 +00:00
huge refacto of the permissions handling + fix various regression
/** * Keeps permissionsStore aligned with the browser. The store is a cache, * never a second source of truth: every signal re-reads the browser via * syncPermissions() (single writer). * * Re-sync triggers, all event-driven — no polling: * - devicechange: granting permission reveals device labels/ids, so this * fires on grant in every browser INCLUDING Safari — replacing the * previous 500ms Safari polling. (A denial is caught by the concurrent * getUserMedia rejection → notePermissionDeniedFromGum.) * - window focus: returning from the browser/system permission UI. * - Permissions API 'change' events, where the query is supported. */
This commit is contained in:
Generated
-10
@@ -22,7 +22,6 @@
|
|||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.3.1",
|
"i18next": "26.3.1",
|
||||||
@@ -4710,15 +4709,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/derive-valtio": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"valtio": ">=2.0.0-rc.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.3.1",
|
"i18next": "26.3.1",
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
|||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
import { userStore } from '@/stores/user'
|
import { userStore } from '@/stores/user'
|
||||||
|
import {
|
||||||
|
PERMISSION_BY_DEVICE_KIND,
|
||||||
|
notePermissionDeniedFromGum,
|
||||||
|
} from '@/stores/permissions'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -313,6 +317,9 @@ export const Conference = ({
|
|||||||
case MediaDeviceFailure.DeviceInUse:
|
case MediaDeviceFailure.DeviceInUse:
|
||||||
setMediaDeviceError({ error: e, kind })
|
setMediaDeviceError({ error: e, kind })
|
||||||
break
|
break
|
||||||
|
case MediaDeviceFailure.PermissionDenied:
|
||||||
|
notePermissionDeniedFromGum(PERMISSION_BY_DEVICE_KIND[kind])
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createLocalVideoTrack,
|
createLocalVideoTrack,
|
||||||
type LocalAudioTrack,
|
type LocalAudioTrack,
|
||||||
type LocalVideoTrack,
|
type LocalVideoTrack,
|
||||||
|
MediaDeviceFailure,
|
||||||
Track,
|
Track,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { H } from '@/primitives/H'
|
import { H } from '@/primitives/H'
|
||||||
@@ -33,7 +34,11 @@ import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
|
|||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||||
import { openPermissionsDialog } from '@/stores/permissions'
|
import {
|
||||||
|
notePermissionDeniedFromGum,
|
||||||
|
openPermissionsDialog,
|
||||||
|
PermissionKind,
|
||||||
|
} from '@/stores/permissions'
|
||||||
import { isSafari } from '@/utils/livekit'
|
import { isSafari } from '@/utils/livekit'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
@@ -54,8 +59,14 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
const onError = (e: Error) =>
|
const onError = (e: Error, kind?: PermissionKind) => {
|
||||||
reportError('join_preview_failure', e, { path: 'join_preview' })
|
reportError('join_preview_failure', e, { path: 'join_preview' })
|
||||||
|
if (
|
||||||
|
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
|
||||||
|
) {
|
||||||
|
notePermissionDeniedFromGum(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const Effects = ({
|
const Effects = ({
|
||||||
videoTrack,
|
videoTrack,
|
||||||
@@ -198,7 +209,7 @@ export const Join = ({
|
|||||||
})
|
})
|
||||||
setDynamicVideoTrack(track)
|
setDynamicVideoTrack(track)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError(error as Error)
|
onError(error as Error, 'camera')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +245,7 @@ export const Join = ({
|
|||||||
})
|
})
|
||||||
setDynamicAudioTrack(track)
|
setDynamicAudioTrack(track)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError(error as Error)
|
onError(error as Error, 'microphone')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,165 +1,36 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { permissionsStore } from '@/stores/permissions'
|
import { syncPermissions } from '@/stores/permissions'
|
||||||
import { isSafari } from '@/utils/livekit'
|
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
const POLLING_TIME = 500
|
|
||||||
|
|
||||||
export const useWatchPermissions = () => {
|
export const useWatchPermissions = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cleanup: (() => void) | undefined
|
const sync = () => void syncPermissions()
|
||||||
let intervalId: ReturnType<typeof setTimeout> | undefined
|
sync()
|
||||||
let isCancelled = false
|
|
||||||
|
|
||||||
const checkPermissions = async () => {
|
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
|
||||||
try {
|
window.addEventListener('focus', sync)
|
||||||
if (!navigator.permissions) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
permissionsStore.cameraPermission = 'unavailable'
|
|
||||||
permissionsStore.microphonePermission = 'unavailable'
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const [cameraPermission, microphonePermission] = await Promise.all([
|
let statuses: PermissionStatus[] = []
|
||||||
navigator.permissions.query({ name: 'camera' }),
|
let cancelled = false
|
||||||
navigator.permissions.query({ name: 'microphone' }),
|
if (navigator.permissions) {
|
||||||
])
|
Promise.all([
|
||||||
|
navigator.permissions.query({ name: 'camera' as PermissionName }),
|
||||||
if (isCancelled) return
|
navigator.permissions.query({ name: 'microphone' as PermissionName }),
|
||||||
|
])
|
||||||
/**
|
.then((results) => {
|
||||||
* Safari Permission API Limitation Workaround
|
if (cancelled) return
|
||||||
*
|
statuses = results
|
||||||
* Safari has a known issue where permission change events are not reliably fired
|
statuses.forEach((s) => s.addEventListener('change', sync))
|
||||||
* when users interact with permission prompts. This is documented in Apple's forums:
|
})
|
||||||
* https://developer.apple.com/forums/thread/757353
|
.catch(() => {
|
||||||
*
|
// Query unsupported: devicechange/focus + gUM outcomes cover it.
|
||||||
* The problem:
|
})
|
||||||
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
|
|
||||||
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
|
|
||||||
* - This leaves the UI in an inconsistent state showing outdated permission status
|
|
||||||
*
|
|
||||||
* The solution:
|
|
||||||
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
|
|
||||||
* - Continue polling until both permissions are no longer in 'prompt' state
|
|
||||||
* - This ensures we catch permission changes even when Safari fails to fire events
|
|
||||||
*
|
|
||||||
* This polling is Safari-specific and only activates when needed to minimize performance impact.
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
isSafari() &&
|
|
||||||
(cameraPermission.state === 'prompt' ||
|
|
||||||
microphonePermission.state === 'prompt')
|
|
||||||
) {
|
|
||||||
// Start polling every 1 second if either permission is in 'prompt' state
|
|
||||||
if (!intervalId) {
|
|
||||||
intervalId = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const [updatedCamera, updatedMicrophone] = await Promise.all([
|
|
||||||
navigator.permissions.query({ name: 'camera' }),
|
|
||||||
navigator.permissions.query({ name: 'microphone' }),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (isCancelled) return
|
|
||||||
|
|
||||||
const cameraChanged =
|
|
||||||
permissionsStore.cameraPermission !== updatedCamera.state
|
|
||||||
const microphoneChanged =
|
|
||||||
permissionsStore.microphonePermission !==
|
|
||||||
updatedMicrophone.state
|
|
||||||
|
|
||||||
if (cameraChanged) {
|
|
||||||
permissionsStore.cameraPermission = updatedCamera.state
|
|
||||||
}
|
|
||||||
|
|
||||||
if (microphoneChanged) {
|
|
||||||
permissionsStore.microphonePermission =
|
|
||||||
updatedMicrophone.state
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
updatedCamera.state !== 'prompt' &&
|
|
||||||
updatedMicrophone.state !== 'prompt'
|
|
||||||
) {
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
reportError('permission_poll_failure', error, {
|
|
||||||
context: 'Error polling permissions:',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, POLLING_TIME)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
permissionsStore.cameraPermission = cameraPermission.state
|
|
||||||
permissionsStore.microphonePermission = microphonePermission.state
|
|
||||||
|
|
||||||
const handleCameraChange = (e: Event) => {
|
|
||||||
const target = e.target as PermissionStatus
|
|
||||||
permissionsStore.cameraPermission = target.state
|
|
||||||
|
|
||||||
if (
|
|
||||||
intervalId &&
|
|
||||||
target.state !== 'prompt' &&
|
|
||||||
microphonePermission.state !== 'prompt'
|
|
||||||
) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMicrophoneChange = (e: Event) => {
|
|
||||||
const target = e.target as PermissionStatus
|
|
||||||
permissionsStore.microphonePermission = target.state
|
|
||||||
|
|
||||||
if (
|
|
||||||
intervalId &&
|
|
||||||
target.state !== 'prompt' &&
|
|
||||||
microphonePermission.state !== 'prompt'
|
|
||||||
) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cameraPermission.addEventListener('change', handleCameraChange)
|
|
||||||
microphonePermission.addEventListener('change', handleMicrophoneChange)
|
|
||||||
|
|
||||||
cleanup = () => {
|
|
||||||
cameraPermission.removeEventListener('change', handleCameraChange)
|
|
||||||
microphonePermission.removeEventListener(
|
|
||||||
'change',
|
|
||||||
handleMicrophoneChange
|
|
||||||
)
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
reportError('permission_poll_failure', error, {
|
|
||||||
context: 'Error checking permissions:',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!isCancelled) {
|
|
||||||
permissionsStore.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
checkPermissions()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isCancelled = true
|
cancelled = true
|
||||||
cleanup?.()
|
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
|
||||||
|
window.removeEventListener('focus', sync)
|
||||||
|
statuses.forEach((s) => s.removeEventListener('change', sync))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import { proxy } from 'valtio'
|
import { proxy } from 'valtio'
|
||||||
import { derive } from 'derive-valtio'
|
|
||||||
|
|
||||||
type PermissionState =
|
type PermissionState =
|
||||||
| undefined
|
| undefined
|
||||||
| 'granted'
|
| 'granted'
|
||||||
| 'prompt'
|
| 'prompt'
|
||||||
| 'denied'
|
| 'denied'
|
||||||
| 'unavailable'
|
|
||||||
|
|
||||||
type BaseState = {
|
type State = {
|
||||||
cameraPermission: PermissionState
|
cameraPermission: PermissionState
|
||||||
microphonePermission: PermissionState
|
microphonePermission: PermissionState
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isPermissionDialogOpen: boolean
|
isPermissionDialogOpen: boolean
|
||||||
requestOrigin?: 'audioinput' | 'videoinput'
|
requestOrigin?: 'audioinput' | 'videoinput'
|
||||||
}
|
|
||||||
|
|
||||||
type DerivedState = {
|
|
||||||
isCameraGranted: boolean
|
isCameraGranted: boolean
|
||||||
isMicrophoneGranted: boolean
|
isMicrophoneGranted: boolean
|
||||||
isCameraDenied: boolean
|
isCameraDenied: boolean
|
||||||
@@ -25,34 +20,31 @@ type DerivedState = {
|
|||||||
isMicrophonePrompted: boolean
|
isMicrophonePrompted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = BaseState & DerivedState
|
export const permissionsStore = proxy<State>({
|
||||||
|
|
||||||
export const permissionsStore = proxy<BaseState>({
|
|
||||||
cameraPermission: undefined,
|
cameraPermission: undefined,
|
||||||
microphonePermission: undefined,
|
microphonePermission: undefined,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isPermissionDialogOpen: false,
|
isPermissionDialogOpen: false,
|
||||||
requestOrigin: undefined,
|
requestOrigin: undefined,
|
||||||
}) as State
|
get isCameraGranted() {
|
||||||
|
return this.cameraPermission === 'granted'
|
||||||
derive(
|
|
||||||
{
|
|
||||||
isCameraGranted: (get) =>
|
|
||||||
get(permissionsStore).cameraPermission == 'granted',
|
|
||||||
isMicrophoneGranted: (get) =>
|
|
||||||
get(permissionsStore).microphonePermission == 'granted',
|
|
||||||
isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
|
|
||||||
isMicrophoneDenied: (get) =>
|
|
||||||
get(permissionsStore).microphonePermission == 'denied',
|
|
||||||
isCameraPrompted: (get) =>
|
|
||||||
get(permissionsStore).cameraPermission == 'prompt',
|
|
||||||
isMicrophonePrompted: (get) =>
|
|
||||||
get(permissionsStore).microphonePermission == 'prompt',
|
|
||||||
},
|
},
|
||||||
{
|
get isMicrophoneGranted() {
|
||||||
proxy: permissionsStore,
|
return this.microphonePermission === 'granted'
|
||||||
}
|
},
|
||||||
)
|
get isCameraDenied() {
|
||||||
|
return this.cameraPermission === 'denied'
|
||||||
|
},
|
||||||
|
get isMicrophoneDenied() {
|
||||||
|
return this.microphonePermission === 'denied'
|
||||||
|
},
|
||||||
|
get isCameraPrompted() {
|
||||||
|
return this.cameraPermission === 'prompt'
|
||||||
|
},
|
||||||
|
get isMicrophonePrompted() {
|
||||||
|
return this.microphonePermission === 'prompt'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const openPermissionsDialog = (
|
export const openPermissionsDialog = (
|
||||||
requestOrigin?: 'audioinput' | 'videoinput'
|
requestOrigin?: 'audioinput' | 'videoinput'
|
||||||
@@ -64,3 +56,84 @@ export const openPermissionsDialog = (
|
|||||||
export const closePermissionsDialog = () => {
|
export const closePermissionsDialog = () => {
|
||||||
permissionsStore.isPermissionDialogOpen = false
|
permissionsStore.isPermissionDialogOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PermissionKind = 'camera' | 'microphone'
|
||||||
|
|
||||||
|
const KIND_MAP = {
|
||||||
|
camera: 'videoinput',
|
||||||
|
microphone: 'audioinput',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PERMISSION_BY_DEVICE_KIND: Partial<
|
||||||
|
Record<MediaDeviceKind, PermissionKind>
|
||||||
|
> = {
|
||||||
|
videoinput: 'camera',
|
||||||
|
audioinput: 'microphone',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setPermissions = (
|
||||||
|
p: Partial<Record<PermissionKind, PermissionState>>
|
||||||
|
) => {
|
||||||
|
if (p.camera && p.camera !== permissionsStore.cameraPermission) {
|
||||||
|
permissionsStore.cameraPermission = p.camera
|
||||||
|
}
|
||||||
|
if (p.microphone && p.microphone !== permissionsStore.microphonePermission) {
|
||||||
|
permissionsStore.microphonePermission = p.microphone
|
||||||
|
}
|
||||||
|
permissionsStore.isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryPermission = async (name: PermissionKind) => {
|
||||||
|
try {
|
||||||
|
const status = await navigator.permissions.query({
|
||||||
|
name: name as PermissionName,
|
||||||
|
})
|
||||||
|
return status.state
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelsVisible = async () => {
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
return (kind: PermissionKind) =>
|
||||||
|
devices.some((d) => d.kind === KIND_MAP[kind] && !!d.label)
|
||||||
|
} catch {
|
||||||
|
return (_kind: PermissionKind) => false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncPermissions = async () => {
|
||||||
|
const [camera, microphone] = await Promise.all([
|
||||||
|
queryPermission('camera'),
|
||||||
|
queryPermission('microphone'),
|
||||||
|
])
|
||||||
|
if (camera && microphone) {
|
||||||
|
setPermissions({ camera, microphone })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const granted = await labelsVisible()
|
||||||
|
const resolve = (kind: PermissionKind, queried?: PermissionState) => {
|
||||||
|
if (queried) return queried
|
||||||
|
const current =
|
||||||
|
kind === 'camera'
|
||||||
|
? permissionsStore.cameraPermission
|
||||||
|
: permissionsStore.microphonePermission
|
||||||
|
if (current === 'denied') return 'denied'
|
||||||
|
return granted(kind) ? 'granted' : 'prompt'
|
||||||
|
}
|
||||||
|
setPermissions({
|
||||||
|
camera: resolve('camera', camera),
|
||||||
|
microphone: resolve('microphone', microphone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notePermissionDeniedFromGum = (kind?: PermissionKind) => {
|
||||||
|
if (kind) {
|
||||||
|
setPermissions({ [kind]: 'denied' })
|
||||||
|
void syncPermissions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void syncPermissions()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user