Added capture tab & REC indicator
X-Lovable-Edit-ID: edt-bb216c0f-61c7-44fc-ad66-d1f17051d2f4 Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
+36
-38
@@ -1,46 +1,44 @@
|
|||||||
## Contexto
|
# Plano: Mini-menu de Captura (Foto + Vídeo) no Modo AR
|
||||||
|
|
||||||
O grid vermelho/azul que aparece perto de paredes/objetos é o **Space Sense / Guardian** do Quest 3 — renderizado pelo runtime da Meta, não pelo app. O app não consegue desligá-lo por código. Porém, **a sensibilidade desse sistema aumenta** quando a WebXR session pede features como `mesh-detection`, `depth-sensing`, `anchors`, etc. Hoje o app usa `createXRStore({...})` sem lista explícita de features, então a lib `@react-three/xr` solicita o conjunto default — que inclui várias features que não usamos.
|
## Objetivo
|
||||||
|
Adicionar dentro do HUD flutuante do modo AR um pequeno menu de captura com:
|
||||||
|
- **📸 Foto** — screenshot do canvas (já existe lógica, expor no AR)
|
||||||
|
- **● Gravar / ⏸ Pausar / ▶ Retomar / ■ Parar** — gravação de vídeo do canvas WebXR
|
||||||
|
|
||||||
## O que vou fazer (Onda 2, sem quebrar nada)
|
## O que será implementado
|
||||||
|
|
||||||
**Auditoria do `createXRStore` em `src/pages/XRSession.tsx`** para pedir só o que realmente usamos:
|
### 1. Novo hook `useCanvasRecorder` (`src/hooks/useCanvasRecorder.ts`)
|
||||||
|
- Usa `canvas.captureStream(30)` + `MediaRecorder` (codec `video/webm;codecs=vp9` com fallback p/ vp8).
|
||||||
|
- Estados expostos: `status` (`idle | recording | paused`), `elapsedMs`.
|
||||||
|
- Métodos: `start()`, `pause()`, `resume()`, `stop()`.
|
||||||
|
- Ao parar: monta `Blob` webm, gera `URL.createObjectURL`, dispara download automático (`tracksteel-AR-<timestamp>.webm`) e também salva uma referência opcional para galeria futura.
|
||||||
|
- Captura screenshot via `canvas.toDataURL('image/png')` reutilizando o store (`addScreenshot`) — mesma lógica já presente em `XRHud.tsx`.
|
||||||
|
|
||||||
- **Manter (usado):**
|
### 2. Nova aba "Captura" no HUD 3D (`src/components/three/XRHudInWorld.tsx`)
|
||||||
- `hit-test` — usado por `XRHitTestPlacement`
|
- Adicionar tab `capture` (ícone 🎥) na barra de tabs (junto com Cena, Ferram., Inspeção, Compart., WebXR).
|
||||||
- `plane-detection` — usado por `XRHitTestPlacement` (snap) e `XRPlaneOverlay`
|
- Componente `CaptureTab` com botões 3D (`XR3DButton`):
|
||||||
- `local-floor` — reference space padrão
|
- **📸 Foto** — chama screenshot, mostra toast "Foto capturada".
|
||||||
- `hand-tracking` — usado
|
- **● REC** (vermelho quando gravando) — inicia/para gravação.
|
||||||
- **Remover do request (não usado):**
|
- **⏸ Pause / ▶ Resume** — alterna pausa (desabilitado quando idle).
|
||||||
- `mesh-detection` — não há leitor de meshes no código
|
- **■ Stop** — finaliza e baixa o arquivo.
|
||||||
- `anchors` — não criamos âncoras persistentes
|
- Texto de status: `● 00:12` em vermelho enquanto grava, amarelo quando pausado.
|
||||||
- `depth-sensing` — não usado
|
- Indicador visual flutuante (pequeno círculo vermelho pulsante) visível mesmo com painel fechado quando estiver gravando — anexado ao `headLockRef`.
|
||||||
- `light-estimation` — não usado
|
|
||||||
|
|
||||||
Configuração proposta (passada para `createXRStore`):
|
### 3. Integração
|
||||||
|
- O hook recebe o `<canvas>` via `document.querySelector('canvas')` (mesmo padrão usado em `XRHud.tsx`/`handleScreenshot`).
|
||||||
|
- Nenhuma mudança no store global é necessária — o estado de gravação vive no hook, instanciado no nível do `XRSession.tsx` e passado por props ao `XRHudInWorld`, OU mantido dentro do próprio `CaptureTab` via singleton simples.
|
||||||
|
|
||||||
```ts
|
## Detalhes técnicos
|
||||||
const store = createXRStore({
|
- `MediaRecorder` com `canvas.captureStream(30)` funciona dentro de sessão WebXR no Quest 3 (Chromium-based browser do Quest suporta).
|
||||||
hand: { left: true, right: true },
|
- Áudio do microfone **não** será incluído (pode ser adicionado depois se solicitado).
|
||||||
controller: { left: true, right: true },
|
- Vídeo gerado é `.webm`; o usuário pode converter para `.mp4` fora do app se quiser.
|
||||||
sessionInit: {
|
- O download é feito ao parar a gravação — o arquivo aparece no armazenamento do Quest (pasta Downloads do browser).
|
||||||
requiredFeatures: ['local-floor'],
|
|
||||||
optionalFeatures: ['hit-test', 'plane-detection', 'hand-tracking'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Tudo continua como `optionalFeatures`, então se o Quest não suportar algum, a sessão ainda inicia. Nenhuma ferramenta atual (hit-test, snap em planos, mãos, grip, joystick, medição, screenshot, inspeção, compartilhamento) depende das features removidas.
|
## Arquivos afetados
|
||||||
|
- `src/hooks/useCanvasRecorder.ts` (novo)
|
||||||
|
- `src/components/three/XRHudInWorld.tsx` (nova tab + CaptureTab + indicador REC)
|
||||||
|
|
||||||
## O que NÃO faço
|
## Fora do escopo
|
||||||
|
- Galeria de vídeos dentro do app (apenas download direto por enquanto).
|
||||||
- Não mexo em `useModelStore`, `XRHud`, `XRHudInWorld`, `XRHitTestPlacement`, `XRPlaneOverlay` — comportamento das ferramentas fica igual.
|
- Áudio/microfone na gravação.
|
||||||
- Não adiciono botão de "desligar Guardian" — impossível por código.
|
- Streaming/upload para nuvem.
|
||||||
|
|
||||||
## Resultado esperado
|
|
||||||
|
|
||||||
Reduz a chance de o Space Sense ativar o grid de segurança perto de objetos, porque a sessão XR não pede mais permissões além das necessárias. Se o grid continuar aparecendo, é configuração do headset (Settings → Segurança → Space Sense) e o usuário precisa desligar lá — posso adicionar depois um painel "Ajuda" com o passo-a-passo se quiser.
|
|
||||||
|
|
||||||
## Arquivo alterado
|
|
||||||
|
|
||||||
- `src/pages/XRSession.tsx` (apenas o objeto passado para `createXRStore`)
|
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { Text } from '@react-three/drei';
|
|||||||
import { XR3DButton, XR3DPanel } from './XRPanel3D';
|
import { XR3DButton, XR3DPanel } from './XRPanel3D';
|
||||||
import { useModelStore, SCALE_PRESETS } from '@/stores/useModelStore';
|
import { useModelStore, SCALE_PRESETS } from '@/stores/useModelStore';
|
||||||
import { getBroadcastSource } from '@/lib/webrtc/broadcastSource';
|
import { getBroadcastSource } from '@/lib/webrtc/broadcastSource';
|
||||||
|
import {
|
||||||
|
useCanvasRecorder, startRecording, pauseRecording, resumeRecording,
|
||||||
|
stopRecording, captureScreenshot, formatElapsed,
|
||||||
|
} from '@/hooks/useCanvasRecorder';
|
||||||
|
|
||||||
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'webxr';
|
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'webxr';
|
||||||
|
|
||||||
interface XRHudInWorldProps {
|
interface XRHudInWorldProps {
|
||||||
freeMove: boolean;
|
freeMove: boolean;
|
||||||
@@ -171,6 +175,7 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
|||||||
<XR3DButton position={[0, -0.008, 0]} size={[0.07, 0.022]}
|
<XR3DButton position={[0, -0.008, 0]} size={[0.07, 0.022]}
|
||||||
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
|
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
|
||||||
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
|
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
|
||||||
|
<RecIndicator />
|
||||||
</group>
|
</group>
|
||||||
{open && (
|
{open && (
|
||||||
<group ref={panelRef}>
|
<group ref={panelRef}>
|
||||||
@@ -205,6 +210,7 @@ function FloatingPanel({
|
|||||||
{ id: 'scene', label: 'Cena', icon: '🧩' },
|
{ id: 'scene', label: 'Cena', icon: '🧩' },
|
||||||
{ id: 'tools', label: 'Ferram.', icon: '🛠' },
|
{ id: 'tools', label: 'Ferram.', icon: '🛠' },
|
||||||
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
|
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
|
||||||
|
{ id: 'capture', label: 'Captura', icon: '🎥' },
|
||||||
{ id: 'share', label: 'Compart.', icon: '📡' },
|
{ id: 'share', label: 'Compart.', icon: '📡' },
|
||||||
{ id: 'webxr', label: 'WebXR', icon: '⚙' },
|
{ id: 'webxr', label: 'WebXR', icon: '⚙' },
|
||||||
];
|
];
|
||||||
@@ -234,9 +240,9 @@ function FloatingPanel({
|
|||||||
<group position={[0, H / 2 - 0.045, 0.001]}>
|
<group position={[0, H / 2 - 0.045, 0.001]}>
|
||||||
{tabs.map((t, i) => (
|
{tabs.map((t, i) => (
|
||||||
<XR3DButton key={t.id}
|
<XR3DButton key={t.id}
|
||||||
position={[-W / 2 + 0.045 + i * 0.092, 0, 0]} size={[0.085, 0.022]}
|
position={[-W / 2 + 0.04 + i * 0.078, 0, 0]} size={[0.072, 0.022]}
|
||||||
label={t.label} icon={t.icon} active={tab === t.id}
|
label={t.label} icon={t.icon} active={tab === t.id}
|
||||||
onClick={() => setTab(t.id)} fontSize={0.0075} />
|
onClick={() => setTab(t.id)} fontSize={0.0072} />
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
@@ -244,6 +250,7 @@ function FloatingPanel({
|
|||||||
{tab === 'scene' && <SceneTab />}
|
{tab === 'scene' && <SceneTab />}
|
||||||
{tab === 'tools' && <ToolsTab {...p} />}
|
{tab === 'tools' && <ToolsTab {...p} />}
|
||||||
{tab === 'inspection' && <InspectionTab />}
|
{tab === 'inspection' && <InspectionTab />}
|
||||||
|
{tab === 'capture' && <CaptureTab />}
|
||||||
{tab === 'share' && <ShareTab {...p} />}
|
{tab === 'share' && <ShareTab {...p} />}
|
||||||
{tab === 'webxr' && <WebXRTab />}
|
{tab === 'webxr' && <WebXRTab />}
|
||||||
</group>
|
</group>
|
||||||
@@ -576,3 +583,88 @@ function WebXRTab() {
|
|||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CaptureTab() {
|
||||||
|
const rec = useCanvasRecorder();
|
||||||
|
const addScreenshot = useModelStore((s) => s.addScreenshot);
|
||||||
|
|
||||||
|
const onPhoto = () => {
|
||||||
|
const dataUrl = captureScreenshot();
|
||||||
|
if (dataUrl) addScreenshot(dataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor =
|
||||||
|
rec.status === 'recording' ? '#dc2626' :
|
||||||
|
rec.status === 'paused' ? '#f59e0b' : '#94a3b8';
|
||||||
|
const statusLabel =
|
||||||
|
rec.status === 'recording' ? `● REC ${formatElapsed(rec.elapsedMs)}` :
|
||||||
|
rec.status === 'paused' ? `❚❚ PAUSADO ${formatElapsed(rec.elapsedMs)}` :
|
||||||
|
'○ Pronto para gravar';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
||||||
|
Capture fotos ou grave a sessão AR (vídeo .webm baixado ao parar).
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text position={[0, 0.075, 0]} fontSize={0.014} color={statusColor}
|
||||||
|
anchorX="center" anchorY="middle">
|
||||||
|
{statusLabel}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Foto */}
|
||||||
|
<XR3DButton position={[-0.13, 0.02, 0]} size={[0.18, 0.038]}
|
||||||
|
label="📸 Tirar Foto" color="#3b82f6"
|
||||||
|
onClick={onPhoto} fontSize={0.011} />
|
||||||
|
|
||||||
|
{/* Iniciar / Parar gravação */}
|
||||||
|
{rec.status === 'idle' ? (
|
||||||
|
<XR3DButton position={[0.09, 0.02, 0]} size={[0.18, 0.038]}
|
||||||
|
label="● Iniciar Gravação" color="#dc2626"
|
||||||
|
onClick={startRecording} fontSize={0.011} />
|
||||||
|
) : (
|
||||||
|
<XR3DButton position={[0.09, 0.02, 0]} size={[0.18, 0.038]}
|
||||||
|
label="■ Parar e Salvar" color="#dc2626" active
|
||||||
|
onClick={stopRecording} fontSize={0.011} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pause / Resume */}
|
||||||
|
{rec.status === 'recording' && (
|
||||||
|
<XR3DButton position={[0, -0.045, 0]} size={[0.2, 0.034]}
|
||||||
|
label="❚❚ Pausar" color="#f59e0b"
|
||||||
|
onClick={pauseRecording} fontSize={0.011} />
|
||||||
|
)}
|
||||||
|
{rec.status === 'paused' && (
|
||||||
|
<XR3DButton position={[0, -0.045, 0]} size={[0.2, 0.034]}
|
||||||
|
label="▶ Retomar" color="#22c55e" active
|
||||||
|
onClick={resumeRecording} fontSize={0.011} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text position={[-0.24, -0.105, 0]} fontSize={0.0075} color="#64748b"
|
||||||
|
anchorX="left" maxWidth={0.48}>
|
||||||
|
Foto → .png | Vídeo → .webm | Arquivos vão para a pasta Downloads do navegador.
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecIndicator() {
|
||||||
|
const rec = useCanvasRecorder();
|
||||||
|
if (rec.status === 'idle') return null;
|
||||||
|
const color = rec.status === 'recording' ? '#dc2626' : '#f59e0b';
|
||||||
|
const label = rec.status === 'recording'
|
||||||
|
? `● REC ${formatElapsed(rec.elapsedMs)}`
|
||||||
|
: `❚❚ ${formatElapsed(rec.elapsedMs)}`;
|
||||||
|
return (
|
||||||
|
<group position={[0.09, 0.018, 0]}>
|
||||||
|
<mesh>
|
||||||
|
<planeGeometry args={[0.078, 0.022]} />
|
||||||
|
<meshBasicMaterial color="#0a0a0a" transparent opacity={0.9} />
|
||||||
|
</mesh>
|
||||||
|
<Text position={[0, 0, 0.001]} fontSize={0.0085} color={color}
|
||||||
|
anchorX="center" anchorY="middle">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export type RecorderStatus = 'idle' | 'recording' | 'paused';
|
||||||
|
|
||||||
|
// Module-level singleton so the recording state can be shared between
|
||||||
|
// the CaptureTab (inside the 3D HUD) and a floating indicator.
|
||||||
|
type State = { status: RecorderStatus; elapsedMs: number };
|
||||||
|
let state: State = { status: 'idle', elapsedMs: 0 };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
function setState(next: Partial<State>) {
|
||||||
|
state = { ...state, ...next };
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
function subscribe(l: () => void) { listeners.add(l); return () => { listeners.delete(l); }; }
|
||||||
|
function getSnapshot() { return state; }
|
||||||
|
|
||||||
|
let recorder: MediaRecorder | null = null;
|
||||||
|
let chunks: Blob[] = [];
|
||||||
|
let startedAt = 0;
|
||||||
|
let accumulatedMs = 0;
|
||||||
|
let pausedAt = 0;
|
||||||
|
let tickTimer: number | null = null;
|
||||||
|
|
||||||
|
function pickMimeType(): string | undefined {
|
||||||
|
const candidates = [
|
||||||
|
'video/webm;codecs=vp9,opus',
|
||||||
|
'video/webm;codecs=vp9',
|
||||||
|
'video/webm;codecs=vp8',
|
||||||
|
'video/webm',
|
||||||
|
'video/mp4',
|
||||||
|
];
|
||||||
|
for (const t of candidates) {
|
||||||
|
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(t)) return t;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTick() {
|
||||||
|
if (tickTimer != null) return;
|
||||||
|
tickTimer = window.setInterval(() => {
|
||||||
|
if (state.status === 'recording') {
|
||||||
|
setState({ elapsedMs: accumulatedMs + (performance.now() - startedAt) });
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
function stopTick() {
|
||||||
|
if (tickTimer != null) { clearInterval(tickTimer); tickTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvas(): HTMLCanvasElement | null {
|
||||||
|
return document.querySelector('canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRecording() {
|
||||||
|
if (state.status !== 'idle') return;
|
||||||
|
const canvas = getCanvas();
|
||||||
|
if (!canvas) { toast.error('Canvas não encontrado'); return; }
|
||||||
|
const stream = (canvas as any).captureStream?.(30) as MediaStream | undefined;
|
||||||
|
if (!stream) { toast.error('captureStream não suportado neste browser'); return; }
|
||||||
|
const mimeType = pickMimeType();
|
||||||
|
try {
|
||||||
|
recorder = new MediaRecorder(stream, mimeType ? { mimeType, videoBitsPerSecond: 8_000_000 } : undefined);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error('MediaRecorder falhou ao iniciar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks = [];
|
||||||
|
recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); };
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const type = recorder?.mimeType || 'video/webm';
|
||||||
|
const blob = new Blob(chunks, { type });
|
||||||
|
const ext = type.includes('mp4') ? 'mp4' : 'webm';
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
toast.success(`Vídeo salvo (${(blob.size / 1_000_000).toFixed(1)} MB)`);
|
||||||
|
recorder = null;
|
||||||
|
chunks = [];
|
||||||
|
accumulatedMs = 0;
|
||||||
|
stopTick();
|
||||||
|
setState({ status: 'idle', elapsedMs: 0 });
|
||||||
|
};
|
||||||
|
recorder.start(1000);
|
||||||
|
startedAt = performance.now();
|
||||||
|
accumulatedMs = 0;
|
||||||
|
setState({ status: 'recording', elapsedMs: 0 });
|
||||||
|
startTick();
|
||||||
|
toast.success('Gravação iniciada');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseRecording() {
|
||||||
|
if (!recorder || state.status !== 'recording') return;
|
||||||
|
recorder.pause();
|
||||||
|
accumulatedMs += performance.now() - startedAt;
|
||||||
|
pausedAt = performance.now();
|
||||||
|
setState({ status: 'paused', elapsedMs: accumulatedMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeRecording() {
|
||||||
|
if (!recorder || state.status !== 'paused') return;
|
||||||
|
recorder.resume();
|
||||||
|
startedAt = performance.now();
|
||||||
|
setState({ status: 'recording' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopRecording() {
|
||||||
|
if (!recorder) return;
|
||||||
|
try { recorder.stop(); } catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureScreenshot(): string | null {
|
||||||
|
const canvas = getCanvas();
|
||||||
|
if (!canvas) { toast.error('Canvas não encontrado'); return null; }
|
||||||
|
try {
|
||||||
|
const dataUrl = canvas.toDataURL('image/png');
|
||||||
|
// Trigger download
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = dataUrl;
|
||||||
|
a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
toast.success('Foto capturada');
|
||||||
|
return dataUrl;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error('Falha ao capturar foto');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCanvasRecorder() {
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsed(ms: number): string {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const ss = (s % 60).toString().padStart(2, '0');
|
||||||
|
const mm = m.toString().padStart(2, '0');
|
||||||
|
return `${mm}:${ss}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user