feat(web): improve message UX, voice gestures, and attachments
Some checks failed
CI / test (push) Failing after 21s
Some checks failed
CI / test (push) Failing after 21s
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { buildWsUrl } from "../utils/ws";
|
||||
|
||||
type RecordingState = "idle" | "recording" | "locked";
|
||||
|
||||
export function MessageComposer() {
|
||||
const activeChatId = useChatStore((s) => s.activeChatId);
|
||||
const me = useAuthStore((s) => s.me);
|
||||
@@ -16,17 +18,39 @@ export function MessageComposer() {
|
||||
const replyToByChat = useChatStore((s) => s.replyToByChat);
|
||||
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const recordingStreamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
const sendVoiceOnStopRef = useRef<boolean>(true);
|
||||
const recordingStartedAtRef = useRef<number | null>(null);
|
||||
const pointerStartYRef = useRef<number>(0);
|
||||
const pointerStartXRef = useRef<number>(0);
|
||||
const pointerCancelArmedRef = useRef<boolean>(false);
|
||||
const pointerMoveHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null);
|
||||
const pointerUpHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null);
|
||||
const recordingStateRef = useRef<RecordingState>("idle");
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const [captionDraft, setCaptionDraft] = useState("");
|
||||
const mediaInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>("idle");
|
||||
const [recordSeconds, setRecordSeconds] = useState(0);
|
||||
const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
recordingStateRef.current = recordingState;
|
||||
}, [recordingState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChatId) {
|
||||
@@ -46,9 +70,29 @@ export function MessageComposer() {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
if (pointerMoveHandlerRef.current) {
|
||||
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
|
||||
}
|
||||
if (pointerUpHandlerRef.current) {
|
||||
window.removeEventListener("pointerup", pointerUpHandlerRef.current);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recordingState === "idle") {
|
||||
return;
|
||||
}
|
||||
const interval = window.setInterval(() => {
|
||||
if (!recordingStartedAtRef.current) {
|
||||
return;
|
||||
}
|
||||
const sec = Math.max(0, Math.floor((Date.now() - recordingStartedAtRef.current) / 1000));
|
||||
setRecordSeconds(sec);
|
||||
}, 250);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [recordingState]);
|
||||
|
||||
function makeClientMessageId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
@@ -147,15 +191,9 @@ export function MessageComposer() {
|
||||
}
|
||||
|
||||
function inferType(file: File): "file" | "image" | "video" | "audio" {
|
||||
if (file.type.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (file.type.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (file.type.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
if (file.type.startsWith("image/")) return "image";
|
||||
if (file.type.startsWith("video/")) return "video";
|
||||
if (file.type.startsWith("audio/")) return "audio";
|
||||
return "file";
|
||||
}
|
||||
|
||||
@@ -209,41 +247,136 @@ export function MessageComposer() {
|
||||
}
|
||||
|
||||
async function startRecord() {
|
||||
if (recordingState !== "idle") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (navigator.permissions && navigator.permissions.query) {
|
||||
const permission = await navigator.permissions.query({ name: "microphone" as PermissionName });
|
||||
if (permission.state === "denied") {
|
||||
setUploadError("Microphone access denied. Allow microphone in browser site permissions.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
recordingStreamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (e) => chunksRef.current.push(e.data);
|
||||
sendVoiceOnStopRef.current = true;
|
||||
recorder.ondataavailable = (event) => chunksRef.current.push(event.data);
|
||||
recorder.onstop = async () => {
|
||||
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
const shouldSend = sendVoiceOnStopRef.current;
|
||||
const data = [...chunksRef.current];
|
||||
chunksRef.current = [];
|
||||
if (recordingStreamRef.current) {
|
||||
recordingStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
recordingStreamRef.current = null;
|
||||
}
|
||||
if (!shouldSend || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob(data, { type: "audio/webm" });
|
||||
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
|
||||
setIsRecording(false);
|
||||
await handleUpload(file, "voice");
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
recordingStartedAtRef.current = Date.now();
|
||||
setRecordSeconds(0);
|
||||
setRecordingState("recording");
|
||||
return true;
|
||||
} catch {
|
||||
setUploadError("Microphone access denied. Please allow microphone and retry.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecord() {
|
||||
recorderRef.current?.stop();
|
||||
function stopRecord(send: boolean) {
|
||||
sendVoiceOnStopRef.current = send;
|
||||
pointerCancelArmedRef.current = false;
|
||||
setDragHint("idle");
|
||||
if (recorderRef.current && recorderRef.current.state !== "inactive") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
recorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
recordingStartedAtRef.current = null;
|
||||
setRecordingState("idle");
|
||||
setRecordSeconds(0);
|
||||
}
|
||||
|
||||
async function onMicPointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
const started = await startRecord();
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
pointerStartYRef.current = event.clientY;
|
||||
pointerStartXRef.current = event.clientX;
|
||||
pointerCancelArmedRef.current = false;
|
||||
setDragHint("idle");
|
||||
|
||||
if (pointerMoveHandlerRef.current) {
|
||||
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
|
||||
}
|
||||
if (pointerUpHandlerRef.current) {
|
||||
window.removeEventListener("pointerup", pointerUpHandlerRef.current);
|
||||
}
|
||||
|
||||
const onPointerMove = (moveEvent: globalThis.PointerEvent) => {
|
||||
const current = recordingStateRef.current;
|
||||
if (current === "idle") {
|
||||
return;
|
||||
}
|
||||
const deltaY = pointerStartYRef.current - moveEvent.clientY;
|
||||
const deltaX = pointerStartXRef.current - moveEvent.clientX;
|
||||
|
||||
if (current === "recording" && deltaY > 70) {
|
||||
setRecordingState("locked");
|
||||
setDragHint("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (current === "recording") {
|
||||
if (deltaX > 90) {
|
||||
pointerCancelArmedRef.current = true;
|
||||
setDragHint("cancel");
|
||||
} else if (deltaY > 40) {
|
||||
pointerCancelArmedRef.current = false;
|
||||
setDragHint("lock");
|
||||
} else {
|
||||
pointerCancelArmedRef.current = false;
|
||||
setDragHint("idle");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (pointerMoveHandlerRef.current) {
|
||||
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
|
||||
}
|
||||
if (pointerUpHandlerRef.current) {
|
||||
window.removeEventListener("pointerup", pointerUpHandlerRef.current);
|
||||
}
|
||||
pointerMoveHandlerRef.current = null;
|
||||
pointerUpHandlerRef.current = null;
|
||||
|
||||
const current = recordingStateRef.current;
|
||||
if (current === "recording") {
|
||||
stopRecord(!pointerCancelArmedRef.current);
|
||||
}
|
||||
setDragHint("idle");
|
||||
};
|
||||
|
||||
pointerMoveHandlerRef.current = onPointerMove;
|
||||
pointerUpHandlerRef.current = onPointerUp;
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
function selectFile(file: File) {
|
||||
setUploadError(null);
|
||||
setSelectedFile(file);
|
||||
setShowAttachMenu(false);
|
||||
const fileType = inferType(file);
|
||||
setSelectedType(fileType);
|
||||
if (previewUrl) {
|
||||
@@ -262,11 +395,23 @@ export function MessageComposer() {
|
||||
}
|
||||
const uploadFile = await prepareFileForUpload(selectedFile, selectedType);
|
||||
await handleUpload(uploadFile, selectedType);
|
||||
if (captionDraft.trim() && activeChatId && me) {
|
||||
const clientMessageId = makeClientMessageId();
|
||||
const textValue = captionDraft.trim();
|
||||
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId });
|
||||
try {
|
||||
const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId);
|
||||
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
||||
} catch {
|
||||
removeOptimisticMessage(activeChatId, clientMessageId);
|
||||
}
|
||||
}
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
setCaptionDraft("");
|
||||
setSelectedType("file");
|
||||
setUploadProgress(0);
|
||||
}
|
||||
@@ -277,18 +422,15 @@ export function MessageComposer() {
|
||||
}
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
setCaptionDraft("");
|
||||
setSelectedType("file");
|
||||
setUploadProgress(0);
|
||||
setUploadError(null);
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
@@ -305,86 +447,186 @@ export function MessageComposer() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<label className="cursor-pointer rounded-full bg-slate-700/80 px-3 py-2 text-xs font-semibold hover:bg-slate-700">
|
||||
+
|
||||
|
||||
{recordingState !== "idle" ? (
|
||||
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>🎤 Recording {formatDuration(recordSeconds)}</span>
|
||||
{recordingState === "recording" ? (
|
||||
<span className={dragHint === "cancel" ? "text-red-300" : dragHint === "lock" ? "text-sky-300" : "text-slate-400"}>
|
||||
{dragHint === "cancel" ? "Release to cancel" : dragHint === "lock" ? "Release up to lock" : "Slide up to lock, left to cancel"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sky-300">Locked</span>
|
||||
)}
|
||||
</div>
|
||||
{recordingState === "locked" ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button className="w-full rounded bg-slate-700 px-3 py-1.5" onClick={() => stopRecord(false)} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="w-full rounded bg-sky-500 px-3 py-1.5 font-semibold text-slate-950" onClick={() => stopRecord(true)} type="button">
|
||||
Send Voice
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-2 flex items-end gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
className="h-[42px] min-w-[42px] rounded-full bg-slate-700/85 px-3 text-sm font-semibold text-slate-200 hover:bg-slate-700 disabled:opacity-60"
|
||||
disabled={isUploading || recordingState !== "idle"}
|
||||
onClick={() => setShowAttachMenu((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
{showAttachMenu ? (
|
||||
<div className="absolute bottom-12 left-0 z-20 w-44 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1.5 shadow-2xl">
|
||||
<button
|
||||
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
|
||||
onClick={() => mediaInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
Photo or Video
|
||||
</button>
|
||||
<button
|
||||
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
ref={mediaInputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
disabled={isUploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
accept="image/*,video/*"
|
||||
disabled={isUploading || recordingState !== "idle"}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
selectFile(file);
|
||||
}
|
||||
e.currentTarget.value = "";
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
disabled={isUploading || recordingState !== "idle"}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
selectFile(file);
|
||||
}
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="flex-1 resize-none rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
||||
className="min-h-[42px] max-h-40 flex-1 resize-y rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
||||
placeholder="Write a message..."
|
||||
rows={1}
|
||||
value={text}
|
||||
onKeyDown={onComposerKeyDown}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setText(next);
|
||||
if (activeChatId) {
|
||||
setDraft(activeChatId, next);
|
||||
}
|
||||
if (activeChatId) {
|
||||
const ws = getWs();
|
||||
ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="rounded-full bg-sky-500 px-4 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400" onClick={handleSend}>
|
||||
|
||||
<button
|
||||
className="h-[42px] w-[72px] rounded-full bg-sky-500 px-4 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60"
|
||||
disabled={recordingState !== "idle"}
|
||||
onClick={handleSend}
|
||||
type="button"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`h-[42px] w-[56px] rounded-full px-3 text-sm font-semibold ${recordingState === "idle" ? "bg-emerald-500 text-slate-950 hover:bg-emerald-400" : "bg-slate-700 text-slate-300"}`}
|
||||
disabled={isUploading}
|
||||
onPointerDown={onMicPointerDown}
|
||||
type="button"
|
||||
>
|
||||
Mic
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 p-3 text-sm">
|
||||
<div className="mb-2 font-semibold">Ready to send</div>
|
||||
<div className="mb-1 break-all text-slate-300">{selectedFile.name}</div>
|
||||
<div className="mb-2 text-xs text-slate-400">{formatBytes(selectedFile.size)}</div>
|
||||
<div className="mb-2 rounded-2xl border border-slate-700/80 bg-slate-900/95 p-3 text-sm shadow-xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">Send {selectedType === "image" || selectedType === "video" ? "Photo" : "File"}</p>
|
||||
<p className="text-xs text-slate-400">{selectedFile.name} • {formatBytes(selectedFile.size)}</p>
|
||||
</div>
|
||||
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={cancelSelectedFile} type="button">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{previewUrl && selectedType === "image" ? (
|
||||
<img className="mb-2 max-h-56 rounded object-contain" src={previewUrl} alt={selectedFile.name} />
|
||||
<img className="mb-2 max-h-72 w-full rounded-xl object-contain" src={previewUrl} alt={selectedFile.name} />
|
||||
) : null}
|
||||
{previewUrl && selectedType === "video" ? (
|
||||
<video className="mb-2 max-h-56 w-full rounded" src={previewUrl} controls muted />
|
||||
<video className="mb-2 max-h-72 w-full rounded-xl" src={previewUrl} controls muted />
|
||||
) : null}
|
||||
{!previewUrl ? (
|
||||
<div className="mb-2 rounded-lg border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-xs text-slate-300">
|
||||
No preview available
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
className="mb-2 w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
||||
maxLength={1000}
|
||||
placeholder="Add a caption..."
|
||||
value={captionDraft}
|
||||
onChange={(event) => setCaptionDraft(event.target.value)}
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 text-xs text-slate-300">Uploading: {uploadProgress}%</div>
|
||||
<div className="h-2 rounded bg-slate-700">
|
||||
<div className="h-2 rounded bg-accent transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||
<div className="h-2 rounded bg-sky-500 transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded-lg bg-sky-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-50"
|
||||
className="w-full rounded-xl bg-sky-500 px-3 py-2 font-semibold text-slate-950 disabled:opacity-50"
|
||||
onClick={() => void sendSelectedFile()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Send media
|
||||
Send
|
||||
</button>
|
||||
<button className="rounded-lg bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={cancelSelectedFile} disabled={isUploading}>
|
||||
<button className="w-full rounded-xl bg-slate-700 px-3 py-2 disabled:opacity-50" onClick={cancelSelectedFile} disabled={isUploading}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={startRecord} disabled={isUploading || isRecording}>
|
||||
{isRecording ? "Recording..." : "Record Voice"}
|
||||
</button>
|
||||
<button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={stopRecord} disabled={!isRecording}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user