feat(activities): attach voice and files on activity notes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 16:34:03 +03:30
co-authored by Cursor
parent 43437b0280
commit 06977d27ed
9 changed files with 1009 additions and 117 deletions
+69
View File
@@ -0,0 +1,69 @@
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
import { compressVoiceFile } from '@/lib/voice-note';
export const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
const FILE_MIME_BY_EXT: Record<string, string> = {
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip',
};
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp']);
const AUDIO_EXT = new Set(['webm', 'ogg', 'mp3', 'm4a', 'aac', 'wav', 'mpeg']);
export type PreparedAttachment = {
kind: 'image' | 'audio' | 'file';
name: string;
file: File;
previewUrl: string;
};
function extensionOf(name: string): string {
return name.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '') ?? '';
}
function kindOf(file: File): 'image' | 'audio' | 'file' {
const type = file.type.toLowerCase().split(';')[0] ?? '';
const ext = extensionOf(file.name);
if (type.startsWith('image/') || IMAGE_EXT.has(ext)) return 'image';
if (type.startsWith('audio/') || AUDIO_EXT.has(ext)) return 'audio';
return 'file';
}
export async function prepareActivityAttachment(file: File): Promise<PreparedAttachment> {
const kind = kindOf(file);
if (kind === 'image') {
try {
const prepared = await prepareFormFieldFile(file);
return { kind, name: prepared.name, file: prepared.file, previewUrl: prepared.dataUrl };
} catch (error) {
if (error instanceof FormFileTooLargeError) {
throw new Error('حجم عکس باید زیر ۲۰۰ کیلوبایت باشد');
}
throw new Error('فشرده‌سازی عکس ناموفق بود.');
}
}
if (kind === 'audio') {
const compressed = await compressVoiceFile(file);
return {
kind,
name: compressed.name,
file: compressed,
previewUrl: URL.createObjectURL(compressed),
};
}
const ext = extensionOf(file.name);
const mime = FILE_MIME_BY_EXT[ext];
if (!mime) throw new Error('این نوع فایل مجاز نیست.');
if (file.size > ACTIVITY_FILE_MAX_BYTES) throw new Error('حجم فایل باید زیر ۱۰ مگابایت باشد');
const named = file.type === mime ? file : new File([file], file.name, { type: mime });
return { kind: 'file', name: named.name, file: named, previewUrl: '' };
}
+166
View File
@@ -0,0 +1,166 @@
/** پیام صوتی به سبک بله و تلگرام: Opus تک‌کاناله، حدود ۳۲ کیلوبیت بر ثانیه. */
export const VOICE_BITRATE = 32_000;
export const VOICE_MAX_SECONDS = 5 * 60;
export const VOICE_MAX_BYTES = Math.floor(1.8 * 1024 * 1024);
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/webm',
'audio/mp4',
];
export function voiceRecorderMime(): string {
if (typeof MediaRecorder === 'undefined') return '';
return MIME_CANDIDATES.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? '';
}
function extensionFor(mime: string): string {
if (mime.includes('mp4')) return 'm4a';
if (mime.includes('ogg')) return 'ogg';
return 'webm';
}
function fileFromBlob(blob: Blob, mime: string): File {
const type = mime.split(';')[0] || blob.type || 'audio/webm';
return new File([blob], `voice.${extensionFor(mime)}`, { type });
}
export type VoiceRecorder = {
stop: () => Promise<File>;
cancel: () => void;
};
export async function startVoiceRecorder(handlers: {
onTick: (seconds: number) => void;
onAutoStop: (file: File) => void;
}): Promise<VoiceRecorder> {
const mime = voiceRecorderMime();
if (!mime || !navigator.mediaDevices?.getUserMedia) {
throw new Error('این مرورگر ضبط صوت را پشتیبانی نمی‌کند.');
}
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: { ideal: 1 },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
} catch {
throw new Error('دسترسی به میکروفون داده نشد.');
}
const rec = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE });
const chunks: Blob[] = [];
rec.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
const started = Date.now();
let settled = false;
let timer = 0;
let auto = 0;
const stopped = new Promise<File>((resolve, reject) => {
rec.onstop = () => {
const file = fileFromBlob(new Blob(chunks, { type: mime.split(';')[0] }), mime);
if (!file.size) {
reject(new Error('صدایی ضبط نشد.'));
return;
}
if (file.size > VOICE_MAX_BYTES) {
reject(new Error('حجم صوت بعد از فشرده‌سازی هنوز زیاد است.'));
return;
}
resolve(file);
};
rec.onerror = () => reject(new Error('ضبط صوت ناموفق بود.'));
});
const finish = () => {
if (settled) return;
settled = true;
window.clearInterval(timer);
window.clearTimeout(auto);
stream.getTracks().forEach((track) => track.stop());
if (rec.state !== 'inactive') rec.stop();
};
timer = window.setInterval(() => {
handlers.onTick(Math.min(VOICE_MAX_SECONDS, Math.round((Date.now() - started) / 1000)));
}, 250);
auto = window.setTimeout(() => {
finish();
void stopped.then(handlers.onAutoStop).catch(() => undefined);
}, VOICE_MAX_SECONDS * 1000);
rec.start(250);
return {
stop: () => {
finish();
return stopped;
},
cancel: () => {
chunks.length = 0;
finish();
void stopped.catch(() => undefined);
},
};
}
export async function compressVoiceFile(file: File): Promise<File> {
const mime = voiceRecorderMime();
if (!mime) throw new Error('این مرورگر فشرده‌سازی صوت را پشتیبانی نمی‌کند.');
const ctx = new AudioContext();
let audio: AudioBuffer;
try {
audio = await ctx.decodeAudioData(await file.arrayBuffer());
} catch {
await ctx.close();
throw new Error('این فایل صوتی قابل خواندن نیست.');
}
if (audio.duration > VOICE_MAX_SECONDS + 0.5) {
await ctx.close();
throw new Error('صوت حداکثر ۵ دقیقه می‌تواند باشد.');
}
const mono = ctx.createBuffer(1, audio.length, audio.sampleRate);
const out = mono.getChannelData(0);
for (let i = 0; i < audio.length; i += 1) {
let sum = 0;
for (let channel = 0; channel < audio.numberOfChannels; channel += 1) {
sum += audio.getChannelData(channel)[i] ?? 0;
}
out[i] = sum / audio.numberOfChannels;
}
const dest = ctx.createMediaStreamDestination();
const source = ctx.createBufferSource();
source.buffer = mono;
source.connect(dest);
const rec = new MediaRecorder(dest.stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE });
const chunks: Blob[] = [];
rec.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
const encoded = new Promise<Blob>((resolve, reject) => {
rec.onstop = () => resolve(new Blob(chunks, { type: mime.split(';')[0] }));
rec.onerror = () => reject(new Error('فشرده‌سازی صوت ناموفق بود.'));
});
rec.start(250);
source.start();
await new Promise<void>((resolve) => {
source.onended = () => resolve();
});
await new Promise((resolve) => window.setTimeout(resolve, 180));
if (rec.state !== 'inactive') rec.stop();
const blob = await encoded;
source.disconnect();
await ctx.close();
const result = fileFromBlob(blob, mime);
if (!result.size) throw new Error('فشرده‌سازی صوت ناموفق بود.');
if (result.size > VOICE_MAX_BYTES) throw new Error('حجم صوت بعد از فشرده‌سازی هنوز زیاد است.');
return result;
}