Initial commit - modstagram-next
This commit is contained in:
19
src/lib/chat/dedupeMessages.ts
Normal file
19
src/lib/chat/dedupeMessages.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
|
||||
|
||||
export function dedupeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ChatMessage[] = [];
|
||||
|
||||
for (const m of messages) {
|
||||
if (!m) continue;
|
||||
const idKey =
|
||||
m._id && /^[a-f\d]{24}$/i.test(m._id)
|
||||
? m._id
|
||||
: `${m.senderId}|${m.content}|${m.file}|${m.createdAt}`;
|
||||
if (seen.has(idKey)) continue;
|
||||
seen.add(idKey);
|
||||
result.push(m);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
41
src/lib/chat/detectFileType.ts
Normal file
41
src/lib/chat/detectFileType.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
|
||||
|
||||
const IMAGE_EXT = ["jpg", "jpeg", "png", "webp", "gif", "avif"];
|
||||
const VIDEO_EXT = ["mp4", "webm", "mov", "m4v"];
|
||||
const VOICE_EXT = ["mp3", "wav", "aac", "m4a", "ogg", "opus", "webm"];
|
||||
const DOC_EXT = ["pdf", "zip", "rar", "doc", "docx", "xls", "xlsx", "txt"];
|
||||
|
||||
/** Reliable media type — voice never shown as video */
|
||||
export function detectChatFileType(
|
||||
fileUrl: string,
|
||||
declared?: ChatMessage["fileType"]
|
||||
): ChatMessage["fileType"] | null {
|
||||
if (declared === "location") return "location";
|
||||
if (declared === "voice") return "voice";
|
||||
if (!fileUrl) return declared ?? null;
|
||||
|
||||
const lower = fileUrl.toLowerCase();
|
||||
const ext = lower.split(".").pop()?.split("?")[0] || "";
|
||||
|
||||
if (declared === "image" || IMAGE_EXT.includes(ext)) return "image";
|
||||
if (declared === "voice") return "voice";
|
||||
if (VOICE_EXT.includes(ext) && ext !== "webm") return "voice";
|
||||
if (ext === "webm") {
|
||||
if (
|
||||
lower.includes("voice") ||
|
||||
lower.includes("audio") ||
|
||||
declared === "voice"
|
||||
) {
|
||||
return "voice";
|
||||
}
|
||||
if (declared === "video" || lower.includes("video")) return "video";
|
||||
return "voice";
|
||||
}
|
||||
if (declared === "video" || VIDEO_EXT.includes(ext)) return "video";
|
||||
if (declared === "file" || DOC_EXT.includes(ext)) return "file";
|
||||
|
||||
if (IMAGE_EXT.includes(ext)) return "image";
|
||||
if (["mp3", "wav", "aac", "m4a", "opus"].includes(ext)) return "voice";
|
||||
if (VIDEO_EXT.includes(ext)) return "video";
|
||||
return "file";
|
||||
}
|
||||
36
src/lib/chat/formatMessageTime.ts
Normal file
36
src/lib/chat/formatMessageTime.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** Bubble time: hours, minutes, seconds only (no date) */
|
||||
export function formatBubbleTime(createdAt?: string | null): string {
|
||||
if (!createdAt) {
|
||||
return new Date().toLocaleTimeString("fa-IR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const raw = String(createdAt).trim();
|
||||
|
||||
// Jalali datetime "1403-01-15 14:30:45" or ISO
|
||||
const timePart = raw.includes(" ")
|
||||
? raw.split(" ").pop()
|
||||
: raw.length <= 8 && raw.includes(":")
|
||||
? raw
|
||||
: null;
|
||||
|
||||
if (timePart && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timePart)) {
|
||||
const parts = timePart.split(":");
|
||||
if (parts.length === 2) return `${parts[0]}:${parts[1]}:00`;
|
||||
return timePart;
|
||||
}
|
||||
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
return d.toLocaleTimeString("fa-IR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
67
src/lib/chat/groupMessagesByDate.ts
Normal file
67
src/lib/chat/groupMessagesByDate.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
|
||||
|
||||
export type MessageGroup =
|
||||
| { type: "date"; label: string; key: string }
|
||||
| { type: "message"; message: ChatMessage; key: string };
|
||||
|
||||
function normalizeDateInput(isoOrTime?: string | null): string {
|
||||
if (isoOrTime == null || String(isoOrTime).trim() === "") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return String(isoOrTime);
|
||||
}
|
||||
|
||||
function formatDateLabel(isoOrTime?: string | null): string {
|
||||
const raw = normalizeDateInput(isoOrTime);
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
if (d.toDateString() === today.toDateString()) return "امروز";
|
||||
if (d.toDateString() === yesterday.toDateString()) return "دیروز";
|
||||
return d.toLocaleDateString("fa-IR", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
const fallback = raw.split(" ")[0];
|
||||
return fallback || "امروز";
|
||||
}
|
||||
|
||||
function dayKey(isoOrTime?: string | null): string {
|
||||
const raw = normalizeDateInput(isoOrTime);
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
return d.toDateString();
|
||||
}
|
||||
return raw.split(" ")[0] || "unknown-day";
|
||||
}
|
||||
|
||||
export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
|
||||
const groups: MessageGroup[] = [];
|
||||
let lastDay = "";
|
||||
|
||||
messages.forEach((msg, i) => {
|
||||
if (!msg) return;
|
||||
|
||||
const dk = dayKey(msg.createdAt);
|
||||
if (dk !== lastDay) {
|
||||
lastDay = dk;
|
||||
groups.push({
|
||||
type: "date",
|
||||
label: formatDateLabel(msg.createdAt),
|
||||
key: `date-${dk}`,
|
||||
});
|
||||
}
|
||||
groups.push({
|
||||
type: "message",
|
||||
message: msg,
|
||||
key: msg._id || `m-${i}`,
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
33
src/lib/i18n.ts
Normal file
33
src/lib/i18n.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import HttpApi from "i18next-http-backend";
|
||||
|
||||
i18n
|
||||
.use(HttpApi)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
supportedLngs: [
|
||||
"en", "fa", "ar", "zh", "hi", "es", "sw", "el", "sr",
|
||||
"fr", "ru", "bn", "pt", "id", "de", "ja", "te", "tr"
|
||||
],
|
||||
defaultNS: "common",
|
||||
ns: ["common"],
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
detection: {
|
||||
order: ["cookie", "localStorage", "navigator", "htmlTag"],
|
||||
caches: ["cookie"],
|
||||
},
|
||||
backend: {
|
||||
loadPath: "/locales/{{lng}}/{{ns}}.json",
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
17
src/lib/media/index.ts
Normal file
17
src/lib/media/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { optimizeImageToWebP, optimizeImages } from "./optimizeImage";
|
||||
import { optimizeVideoToMp4 } from "./optimizeVideo";
|
||||
|
||||
export async function optimizeMediaFile(
|
||||
file: File,
|
||||
onProgress?: (pct: number) => void
|
||||
): Promise<File> {
|
||||
if (file.type.startsWith("image/")) {
|
||||
return optimizeImageToWebP(file);
|
||||
}
|
||||
if (file.type.startsWith("video/")) {
|
||||
return optimizeVideoToMp4(file, onProgress);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
export { optimizeImageToWebP, optimizeImages, optimizeVideoToMp4 };
|
||||
33
src/lib/media/optimizeImage.ts
Normal file
33
src/lib/media/optimizeImage.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Convert user images to WebP before upload (client-side) */
|
||||
const MAX_EDGE = 1920;
|
||||
const WEBP_QUALITY = 0.85;
|
||||
|
||||
export async function optimizeImageToWebP(file: File): Promise<File> {
|
||||
if (!file.type.startsWith("image/")) return file;
|
||||
if (file.type === "image/webp" && file.size < 2 * 1024 * 1024) return file;
|
||||
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
||||
const w = Math.round(bitmap.width * scale);
|
||||
const h = Math.round(bitmap.height * scale);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/webp", WEBP_QUALITY)
|
||||
);
|
||||
if (!blob) return file;
|
||||
|
||||
const baseName = file.name.replace(/\.[^.]+$/, "") || "image";
|
||||
return new File([blob], `${baseName}.webp`, { type: "image/webp" });
|
||||
}
|
||||
|
||||
export async function optimizeImages(files: File[]): Promise<File[]> {
|
||||
return Promise.all(files.map((f) => optimizeImageToWebP(f)));
|
||||
}
|
||||
88
src/lib/media/optimizeVideo.ts
Normal file
88
src/lib/media/optimizeVideo.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||
import { fetchFile, toBlobURL } from "@ffmpeg/util";
|
||||
|
||||
let ffmpegInstance: FFmpeg | null = null;
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
async function getFfmpeg(): Promise<FFmpeg> {
|
||||
if (ffmpegInstance?.loaded) return ffmpegInstance;
|
||||
if (!loadPromise) {
|
||||
loadPromise = (async () => {
|
||||
const ffmpeg = new FFmpeg();
|
||||
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm";
|
||||
await ffmpeg.load({
|
||||
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
|
||||
wasmURL: await toBlobURL(
|
||||
`${baseURL}/ffmpeg-core.wasm`,
|
||||
"application/wasm"
|
||||
),
|
||||
});
|
||||
ffmpegInstance = ffmpeg;
|
||||
})();
|
||||
}
|
||||
await loadPromise;
|
||||
return ffmpegInstance!;
|
||||
}
|
||||
|
||||
const MAX_VIDEO_EDGE = 1080;
|
||||
|
||||
/**
|
||||
* Transcode to MP4 H.264 (Instagram-style) — lazy-loads ffmpeg.wasm on first use.
|
||||
*/
|
||||
export async function optimizeVideoToMp4(
|
||||
file: File,
|
||||
onProgress?: (pct: number) => void
|
||||
): Promise<File> {
|
||||
if (!file.type.startsWith("video/")) return file;
|
||||
|
||||
if (
|
||||
file.type === "video/mp4" &&
|
||||
file.size < 40 * 1024 * 1024 &&
|
||||
!file.name.toLowerCase().includes(".mov")
|
||||
) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const ffmpeg = await getFfmpeg();
|
||||
|
||||
if (onProgress) {
|
||||
ffmpeg.on("progress", ({ progress }) => {
|
||||
onProgress(Math.min(99, Math.round((progress ?? 0) * 100)));
|
||||
});
|
||||
}
|
||||
|
||||
const inputName = "input" + (file.name.match(/\.\w+$/)?.[0] || ".mp4");
|
||||
const outputName = "output.mp4";
|
||||
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(file));
|
||||
|
||||
await ffmpeg.exec([
|
||||
"-i",
|
||||
inputName,
|
||||
"-vf",
|
||||
`scale='min(${MAX_VIDEO_EDGE},iw)':-2`,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"28",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-y",
|
||||
outputName,
|
||||
]);
|
||||
|
||||
const data = await ffmpeg.readFile(outputName);
|
||||
await ffmpeg.deleteFile(inputName);
|
||||
await ffmpeg.deleteFile(outputName);
|
||||
|
||||
const blob = new Blob([data as BlobPart], { type: "video/mp4" });
|
||||
const baseName = file.name.replace(/\.[^.]+$/, "") || "video";
|
||||
onProgress?.(100);
|
||||
return new File([blob], `${baseName}.mp4`, { type: "video/mp4" });
|
||||
}
|
||||
21
src/lib/settings.ts
Normal file
21
src/lib/settings.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const languages = [
|
||||
{ code: 'en', label: 'English', dir: 'ltr' },
|
||||
{ code: 'fa', label: 'فارسی', dir: 'rtl' },
|
||||
{ code: 'ar', label: 'العربية', dir: 'rtl' },
|
||||
{ code: 'zh', label: '中文', dir: 'ltr' },
|
||||
{ code: 'hi', label: 'हिन्दी', dir: 'ltr' },
|
||||
{ code: 'es', label: 'Español', dir: 'ltr' },
|
||||
{ code: 'fr', label: 'Français', dir: 'ltr' },
|
||||
{ code: 'ru', label: 'Русский', dir: 'ltr' },
|
||||
{ code: 'bn', label: 'বাংলা', dir: 'ltr' },
|
||||
{ code: 'pt', label: 'Português', dir: 'ltr' },
|
||||
{ code: 'id', label: 'Bahasa', dir: 'ltr' },
|
||||
{ code: 'de', label: 'Deutsch', dir: 'ltr' },
|
||||
{ code: 'ja', label: '日本語', dir: 'ltr' },
|
||||
{ code: 'te', label: 'తెలుగు', dir: 'ltr' },
|
||||
{ code: 'tr', label: 'Türkçe', dir: 'ltr' },
|
||||
{ code: 'sw', label: 'Swahili', dir: 'ltr' },
|
||||
{ code: 'el', label: 'Ελληνικά', dir: 'ltr' },
|
||||
{ code: 'sr', label: 'Српски', dir: 'ltr' },
|
||||
];
|
||||
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user