Timer Signal on Mobile with Notification
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Timer, Play, Pause, RotateCcw, Edit2, Plus, Minus, X, Check } from 'lucide-react';
|
import { Timer, Play, Pause, RotateCcw, Edit2, Plus, Minus, X, Check, Bell, BellOff } from 'lucide-react';
|
||||||
import { useRestTimer } from '../../hooks/useRestTimer';
|
import { useRestTimer } from '../../hooks/useRestTimer';
|
||||||
|
import { requestNotificationPermission } from '../../utils/notifications';
|
||||||
|
|
||||||
interface RestTimerFABProps {
|
interface RestTimerFABProps {
|
||||||
timer: ReturnType<typeof useRestTimer>;
|
timer: ReturnType<typeof useRestTimer>;
|
||||||
@@ -21,6 +22,10 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState(120);
|
const [editValue, setEditValue] = useState(120);
|
||||||
const [inputValue, setInputValue] = useState(formatSeconds(120));
|
const [inputValue, setInputValue] = useState(formatSeconds(120));
|
||||||
|
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>(
|
||||||
|
'Notification' in window ? Notification.permission : 'default'
|
||||||
|
);
|
||||||
|
const [isSecure, setIsSecure] = useState(true);
|
||||||
|
|
||||||
// Auto-expand when running if not already expanded? No, requirement says "when time is running, show digits of the countdown on the enlarged timer FAB even if the menu is collapsed".
|
// Auto-expand when running if not already expanded? No, requirement says "when time is running, show digits of the countdown on the enlarged timer FAB even if the menu is collapsed".
|
||||||
// So the FAB itself grows.
|
// So the FAB itself grows.
|
||||||
@@ -38,6 +43,20 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
|||||||
setInputValue(formatSeconds(editValue));
|
setInputValue(formatSeconds(editValue));
|
||||||
}, [editValue]);
|
}, [editValue]);
|
||||||
|
|
||||||
|
// Check permission on mount and focus
|
||||||
|
useEffect(() => {
|
||||||
|
const checkState = () => {
|
||||||
|
if ('Notification' in window) {
|
||||||
|
setNotificationPermission(Notification.permission);
|
||||||
|
}
|
||||||
|
setIsSecure(window.isSecureContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkState();
|
||||||
|
window.addEventListener('focus', checkState);
|
||||||
|
return () => window.removeEventListener('focus', checkState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (isEditing) return; // Don't toggle if editing
|
if (isEditing) return; // Don't toggle if editing
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
@@ -54,6 +73,18 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
|||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRequestPermission = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!isSecure) {
|
||||||
|
alert("Notifications require a secure context (HTTPS) or localhost.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requestNotificationPermission();
|
||||||
|
if (result) setNotificationPermission(result);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (e: React.MouseEvent) => {
|
const handleEdit = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const initialVal = timeLeft > 0 ? timeLeft : 120;
|
const initialVal = timeLeft > 0 ? timeLeft : 120;
|
||||||
@@ -189,6 +220,21 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-end gap-3 animate-in slide-in-from-bottom-4 fade-in duration-200 mb-4 mr-1">
|
<div className="flex flex-col items-end gap-3 animate-in slide-in-from-bottom-4 fade-in duration-200 mb-4 mr-1">
|
||||||
|
{/* Notification Permission Button (Only if not granted) */}
|
||||||
|
{notificationPermission !== 'granted' && 'Notification' in window && (
|
||||||
|
<button
|
||||||
|
onClick={handleRequestPermission}
|
||||||
|
className={`w-10 h-10 flex items-center justify-center rounded-full shadow-elevation-2 transition-all ${isSecure
|
||||||
|
? "bg-tertiary-container text-on-tertiary-container hover:brightness-95 hover:scale-110 animate-pulse"
|
||||||
|
: "bg-surface-container-high text-outline"
|
||||||
|
}`}
|
||||||
|
aria-label={isSecure ? "Enable Notifications" : "Notifications Failed"}
|
||||||
|
title={isSecure ? "Enable Notifications for Timer" : "HTTPS required for notifications"}
|
||||||
|
>
|
||||||
|
{isSecure ? <Bell size={18} /> : <BellOff size={18} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mini FABs */}
|
{/* Mini FABs */}
|
||||||
<button onClick={handleEdit} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface hover:text-primary rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label="Edit">
|
<button onClick={handleEdit} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface hover:text-primary rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label="Edit">
|
||||||
<Edit2 size={18} />
|
<Edit2 size={18} />
|
||||||
|
|||||||
81
src/hooks/timer.worker.ts
Normal file
81
src/hooks/timer.worker.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/* eslint-disable no-restricted-globals */
|
||||||
|
// Web Worker to handle the timer interval in a background thread.
|
||||||
|
// This prevents the timer from being throttled when the tab is inactive or screen is off.
|
||||||
|
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let targetEndTime: number | null = null;
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent) => {
|
||||||
|
const { type, payload } = e.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'START':
|
||||||
|
if (payload?.endTime) {
|
||||||
|
targetEndTime = payload.endTime;
|
||||||
|
|
||||||
|
// Clear any existing interval
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
|
||||||
|
// Start a fast tick loop
|
||||||
|
// We tick faster than 1s to ensure we don't miss the :00 mark by much
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
if (!targetEndTime) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const timeLeft = Math.max(0, Math.ceil((targetEndTime - now) / 1000));
|
||||||
|
|
||||||
|
// Send tick update
|
||||||
|
self.postMessage({ type: 'TICK', timeLeft });
|
||||||
|
|
||||||
|
if (timeLeft <= 0) {
|
||||||
|
self.postMessage({ type: 'FINISHED' });
|
||||||
|
|
||||||
|
// Fire notification directly from worker to bypass frozen main thread
|
||||||
|
if ('Notification' in self && (self as any).Notification.permission === 'granted') {
|
||||||
|
try {
|
||||||
|
// Try ServiceWorker registration first (more reliable on mobile)
|
||||||
|
// Cast to any because TS dedicated worker scope doesn't know about registration
|
||||||
|
const swReg = (self as any).registration;
|
||||||
|
if ('serviceWorker' in self.navigator && swReg && swReg.showNotification) {
|
||||||
|
swReg.showNotification("Time's Up!", {
|
||||||
|
body: "Rest period finished",
|
||||||
|
icon: '/assets/favicon.svg',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
tag: 'rest-timer',
|
||||||
|
renotify: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to standard Notification API
|
||||||
|
// Cast options to any to allow 'renotify' which might be missing in strict lib
|
||||||
|
new Notification("Time's Up!", {
|
||||||
|
body: "Rest period finished",
|
||||||
|
icon: '/assets/favicon.svg',
|
||||||
|
tag: 'rest-timer',
|
||||||
|
['renotify' as any]: true,
|
||||||
|
} as NotificationOptions);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Worker notification failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
targetEndTime = null;
|
||||||
|
}
|
||||||
|
}, 200); // 200ms check for responsiveness
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PAUSE':
|
||||||
|
case 'STOP':
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
targetEndTime = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { playTimeUpSignal } from '../utils/audio';
|
import { playTimeUpSignal } from '../utils/audio';
|
||||||
|
import { requestNotificationPermission, sendNotification, vibrateDevice } from '../utils/notifications';
|
||||||
|
|
||||||
export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED';
|
export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED';
|
||||||
|
|
||||||
@@ -63,35 +64,104 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
|||||||
const [status, setStatus] = useState<TimerStatus>(initialStatus);
|
const [status, setStatus] = useState<TimerStatus>(initialStatus);
|
||||||
const [duration, setDuration] = useState(initialDuration);
|
const [duration, setDuration] = useState(initialDuration);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Worker reference
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
// Initialize Worker
|
||||||
|
useEffect(() => {
|
||||||
|
// Create worker instance
|
||||||
|
workerRef.current = new Worker(new URL('./timer.worker.ts', import.meta.url), { type: 'module' });
|
||||||
|
|
||||||
|
workerRef.current.onmessage = (e) => {
|
||||||
|
const { type, timeLeft: workerTimeLeft } = e.data;
|
||||||
|
|
||||||
|
if (type === 'TICK') {
|
||||||
|
if (document.hidden) {
|
||||||
|
setTimeLeft(workerTimeLeft);
|
||||||
|
}
|
||||||
|
} else if (type === 'FINISHED') {
|
||||||
|
// Worker says done.
|
||||||
|
setStatus((prev) => {
|
||||||
|
if (prev === 'FINISHED') return prev;
|
||||||
|
|
||||||
|
playTimeUpSignal();
|
||||||
|
sendNotification("Time's Up!", "Rest period finished");
|
||||||
|
vibrateDevice();
|
||||||
|
|
||||||
|
if (onFinish) onFinish();
|
||||||
|
|
||||||
|
// Cleanup RAF if it was running
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
endTimeRef.current = null;
|
||||||
|
|
||||||
|
return 'FINISHED';
|
||||||
|
});
|
||||||
|
setTimeLeft(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
workerRef.current?.terminate();
|
||||||
|
};
|
||||||
|
}, [onFinish, duration]);
|
||||||
|
|
||||||
|
|
||||||
|
// Recover worker if we restored a RUNNING state
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialStatus === 'RUNNING' && savedState?.endTime) {
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
type: 'START',
|
||||||
|
payload: { endTime: savedState.endTime }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const endTimeRef = useRef<number | null>(savedState?.endTime || null);
|
const endTimeRef = useRef<number | null>(savedState?.endTime || null);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
const prevDefaultTimeRef = useRef(defaultTime);
|
const prevDefaultTimeRef = useRef(defaultTime);
|
||||||
|
|
||||||
// Tick function - defined before effects
|
// Tick function - defined before effects (RAF version)
|
||||||
const tick = useCallback(() => {
|
const tick = useCallback(() => {
|
||||||
if (!endTimeRef.current) return;
|
if (!endTimeRef.current) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000));
|
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000));
|
||||||
|
|
||||||
|
// Only update state if it changed (to avoid extra renders, though React handles this)
|
||||||
setTimeLeft(remaining);
|
setTimeLeft(remaining);
|
||||||
|
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
|
// Finished
|
||||||
setStatus('FINISHED');
|
setStatus('FINISHED');
|
||||||
playTimeUpSignal();
|
playTimeUpSignal();
|
||||||
if (onFinish) onFinish();
|
sendNotification("Time's Up!", "Rest period finished");
|
||||||
|
vibrateDevice();
|
||||||
|
|
||||||
|
if (onFinish) onFinish(); // Ensure this is only called once per finish
|
||||||
|
|
||||||
|
endTimeRef.current = null;
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
|
||||||
endTimeRef.current = null; // Clear end time
|
|
||||||
|
|
||||||
// Auto-reset visuals after 3 seconds of "FINISHED" state?
|
|
||||||
setTimeout(() => {
|
|
||||||
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
|
|
||||||
setTimeLeft(prev => prev === 0 ? duration : prev);
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
} else {
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
}, [duration, onFinish]);
|
}, [duration, onFinish]);
|
||||||
|
|
||||||
|
// Handle Auto-Reset when status becomes FINISHED (covers both active finish and restore-from-finished)
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'FINISHED') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
|
||||||
|
setTimeLeft(prev => prev === 0 ? duration : prev);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [status, duration]);
|
||||||
|
|
||||||
// Save to localStorage whenever relevant state changes
|
// Save to localStorage whenever relevant state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stateToSave = {
|
const stateToSave = {
|
||||||
@@ -143,17 +213,32 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
|||||||
|
|
||||||
// If starting from IDLE or PAUSED
|
// If starting from IDLE or PAUSED
|
||||||
const targetSeconds = status === 'PAUSED' ? timeLeft : duration;
|
const targetSeconds = status === 'PAUSED' ? timeLeft : duration;
|
||||||
endTimeRef.current = Date.now() + targetSeconds * 1000;
|
const endTime = Date.now() + targetSeconds * 1000;
|
||||||
|
endTimeRef.current = endTime;
|
||||||
|
|
||||||
setStatus('RUNNING');
|
setStatus('RUNNING');
|
||||||
// Effect will trigger tick
|
|
||||||
|
// Request Permissions strictly on user interaction
|
||||||
|
requestNotificationPermission();
|
||||||
|
|
||||||
|
// Start Worker
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
type: 'START',
|
||||||
|
payload: { endTime }
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [status, timeLeft, duration]);
|
}, [status, timeLeft, duration]);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
if (status !== 'RUNNING') return;
|
if (status !== 'RUNNING') return;
|
||||||
setStatus('PAUSED');
|
setStatus('PAUSED');
|
||||||
// Effect calls cancelAnimationFrame
|
|
||||||
endTimeRef.current = null;
|
endTimeRef.current = null;
|
||||||
|
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.postMessage({ type: 'PAUSE' });
|
||||||
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const reset = useCallback((newDuration?: number) => {
|
const reset = useCallback((newDuration?: number) => {
|
||||||
@@ -161,8 +246,12 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
|||||||
setDuration(nextDuration);
|
setDuration(nextDuration);
|
||||||
setTimeLeft(nextDuration);
|
setTimeLeft(nextDuration);
|
||||||
setStatus('IDLE');
|
setStatus('IDLE');
|
||||||
|
|
||||||
endTimeRef.current = null;
|
endTimeRef.current = null;
|
||||||
// Effect calls cancelAnimationFrame (since status becomes IDLE)
|
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.postMessage({ type: 'STOP' });
|
||||||
|
}
|
||||||
}, [duration]);
|
}, [duration]);
|
||||||
|
|
||||||
const addTime = useCallback((seconds: number) => {
|
const addTime = useCallback((seconds: number) => {
|
||||||
@@ -173,7 +262,15 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
|||||||
// Add to current target
|
// Add to current target
|
||||||
if (endTimeRef.current) {
|
if (endTimeRef.current) {
|
||||||
endTimeRef.current += seconds * 1000;
|
endTimeRef.current += seconds * 1000;
|
||||||
// Force immediate update to avoid flicker
|
// Update Worker
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
type: 'START',
|
||||||
|
payload: { endTime: endTimeRef.current }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force immediate update locally to avoid flicker
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)));
|
setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)));
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/utils/notifications.ts
Normal file
80
src/utils/notifications.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Request notification permissions from the user.
|
||||||
|
* Safe to call multiple times (idempotent).
|
||||||
|
*/
|
||||||
|
export const requestNotificationPermission = async () => {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
console.log('This browser does not support desktop notification');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Current notification permission:', Notification.permission);
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission !== 'denied') {
|
||||||
|
try {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
console.log('Notification permission request result:', permission);
|
||||||
|
return permission;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error requesting notification permission', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Notification permission is denied. User must enable it in settings.');
|
||||||
|
}
|
||||||
|
return Notification.permission;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a system notification.
|
||||||
|
* @param title Notification title
|
||||||
|
* @param body Notification body text
|
||||||
|
*/
|
||||||
|
export const sendNotification = (title: string, body?: string) => {
|
||||||
|
if (!('Notification' in window)) return;
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
try {
|
||||||
|
// Check if service worker is available for more reliable notifications on mobile
|
||||||
|
if ('serviceWorker' in navigator && navigator.serviceWorker.ready) {
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
registration.showNotification(title, {
|
||||||
|
body,
|
||||||
|
icon: '/assets/favicon.svg',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
tag: 'rest-timer',
|
||||||
|
renotify: true
|
||||||
|
} as any); // Cast to any to allow extended properties
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to standard notification API
|
||||||
|
new Notification(title, {
|
||||||
|
body,
|
||||||
|
icon: '/assets/favicon.svg',
|
||||||
|
tag: 'rest-timer',
|
||||||
|
renotify: true,
|
||||||
|
vibrate: [200, 100, 200]
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error sending notification', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger device vibration.
|
||||||
|
* @param pattern settings for navigator.vibrate
|
||||||
|
*/
|
||||||
|
export const vibrateDevice = (pattern: number | number[] = [200, 100, 200, 100, 200]) => {
|
||||||
|
if ('vibrate' in navigator) {
|
||||||
|
try {
|
||||||
|
navigator.vibrate(pattern);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error vibrating device', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user