This commit is contained in:
payacom
2026-07-23 15:58:15 +03:30
parent af5b27b610
commit a1d7db5e7e
445 changed files with 23456 additions and 8865 deletions

View File

@@ -1,11 +1,34 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
export function parseMessageTime(message: ChatMessage): number {
const withIso = message as ChatMessage & { createdAtIso?: string };
if (withIso.createdAtIso) {
const t = new Date(withIso.createdAtIso).getTime();
if (!Number.isNaN(t)) return t;
}
if (message.createdAt?.includes("T")) {
const t = new Date(message.createdAt).getTime();
if (!Number.isNaN(t)) return t;
}
return 0;
}
const PENDING_MATCH_WINDOW_MS = 120_000;
function matchesPendingServer(server: ChatMessage, pending: ChatMessage): boolean {
if (String(server.senderId) !== String(pending.senderId)) return false;
if (pending.fileType !== server.fileType) return false;
if (String(pending.fileType || "") !== String(server.fileType || "")) return false;
const pendingTime = parseMessageTime(pending);
const serverTime = parseMessageTime(server);
if (pendingTime && serverTime) {
if (Math.abs(serverTime - pendingTime) > PENDING_MATCH_WINDOW_MS) return false;
}
if (pending.fileType === "location" || (!pending.fileType && !pending.file)) {
return server.content === pending.content;
}
return Boolean(pending.fileType && server.fileType === pending.fileType);
}
@@ -20,10 +43,10 @@ export function filterResolvedPendingMessages(
server: ChatMessage[],
pending: ChatMessage[]
): ChatMessage[] {
return pending.filter(
(p) =>
!server.some((s) => s._id === p._id || matchesPendingServer(s, p))
);
return pending.filter((p) => {
if (server.some((s) => String(s._id) === String(p._id))) return false;
return !server.some((s) => matchesPendingServer(s, p));
});
}
export function dedupeChatMessages(messages: ChatMessage[]): ChatMessage[] {

View File

@@ -1,9 +1,63 @@
/** Parse message timestamp (ISO or datetime string) for grouping */
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
import { translateCommon } from "@/lib/i18n/translate";
function normalizeJalaliPrefix(raw: string): string | null {
const match = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (!match) return null;
return `${match[1]}-${Number(match[2])}-${Number(match[3])}`;
}
function getTodayJalaliPrefix(): string | null {
try {
const parts = new Intl.DateTimeFormat("en-u-ca-persian", {
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(new Date());
const year = parts.find((p) => p.type === "year")?.value;
const month = parts.find((p) => p.type === "month")?.value;
const day = parts.find((p) => p.type === "day")?.value;
if (!year || !month || !day) return null;
return `${year}-${Number(month)}-${Number(day)}`;
} catch {
return null;
}
}
function getYesterdayJalaliPrefix(): string | null {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
try {
const parts = new Intl.DateTimeFormat("en-u-ca-persian", {
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(yesterday);
const year = parts.find((p) => p.type === "year")?.value;
const month = parts.find((p) => p.type === "month")?.value;
const day = parts.find((p) => p.type === "day")?.value;
if (!year || !month || !day) return null;
return `${year}-${Number(month)}-${Number(day)}`;
} catch {
return null;
}
}
/** Parse message timestamp (ISO, Jalali, or datetime string) for grouping */
export function parseMessageDate(createdAt?: string | null): Date | null {
if (!createdAt) return null;
const raw = String(createdAt).trim();
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d;
const jalaliPrefix = normalizeJalaliPrefix(raw);
if (jalaliPrefix) {
const [y, m, d] = jalaliPrefix.split("-").map(Number);
return new Date(y, m - 1, d);
}
const parsed = new Date(raw);
if (!Number.isNaN(parsed.getTime())) return parsed;
return null;
}
@@ -11,40 +65,98 @@ function isSameDay(a: Date, b: Date): boolean {
return a.toDateString() === b.toDateString();
}
/** Telegram-style date label: امروز، دیروز، or calendar date */
export function formatChatDateLabel(createdAt?: string | null): string {
function formatLocalizedDate(
date: Date,
withYear: boolean,
lang: AppLanguage
): string {
const locale = lang === "fa" ? "fa-IR" : "en-US";
try {
if (withYear) {
return date.toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
}
return date.toLocaleDateString(locale, {
month: "long",
day: "numeric",
});
} catch {
return date.toDateString();
}
}
/** Telegram-style date label: today, yesterday, or calendar date */
export function formatChatDateLabel(
createdAt?: string | null,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
const raw = createdAt ? String(createdAt).trim() : "";
const jalaliPrefix = normalizeJalaliPrefix(raw);
if (jalaliPrefix) {
const today = getTodayJalaliPrefix();
const yesterday = getYesterdayJalaliPrefix();
if (today && jalaliPrefix === today) {
return translateCommon(lang, "chats.today");
}
if (yesterday && jalaliPrefix === yesterday) {
return translateCommon(lang, "chats.yesterday");
}
const [y, m, d] = jalaliPrefix.split("-").map(Number);
const date = new Date(y, m - 1, d);
if (!Number.isNaN(date.getTime())) {
return formatLocalizedDate(date, y !== new Date().getFullYear(), lang);
}
return jalaliPrefix.replace(/-/g, "/");
}
const d = parseMessageDate(createdAt) ?? new Date();
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (isSameDay(d, today)) return "امروز";
if (isSameDay(d, yesterday)) return "دیروز";
if (isSameDay(d, today)) return translateCommon(lang, "chats.today");
if (isSameDay(d, yesterday)) return translateCommon(lang, "chats.yesterday");
if (d.getFullYear() === today.getFullYear()) {
return d.toLocaleDateString("fa-IR", { month: "long", day: "numeric" });
return formatLocalizedDate(d, false, lang);
}
return d.toLocaleDateString("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
});
return formatLocalizedDate(d, true, lang);
}
/** Bubble time: hours, minutes, seconds only (no date) */
export function formatBubbleTime(createdAt?: string | null): string {
export function formatBubbleTime(
createdAt?: string | null,
lang: AppLanguage = DEFAULT_LANGUAGE
): string {
if (!createdAt) {
return new Date().toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return "";
}
const raw = String(createdAt).trim();
const locale = lang === "fa" ? "fa-IR" : "en-US";
// ISO datetime — always prefer real clock time
if (raw.includes("T")) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) {
try {
return d.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return raw;
}
}
}
// Jalali datetime "1403-01-15 14:30:45" or ISO
const timePart = raw.includes(" ")
? raw.split(" ").pop()
: raw.length <= 8 && raw.includes(":")
@@ -57,13 +169,17 @@ export function formatBubbleTime(createdAt?: string | null): string {
return timePart;
}
const d = new Date(raw);
const d = parseMessageDate(raw) ?? new Date(raw);
if (!Number.isNaN(d.getTime())) {
return d.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
try {
return d.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return raw;
}
}
return raw;

View File

@@ -0,0 +1,111 @@
import { parseLocationContent } from "@/components/chat/LocationMessageBubble";
import { parseSharedPost } from "@/components/chat/SharedPostBubble";
import {
extractStoryReactionText,
parseSharedStory,
} from "@/components/chat/SharedStoryBubble";
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
try {
const line = content.split("\n")[0];
const parsed = JSON.parse(line);
return parsed.forwardedFrom ?? null;
} catch {
return null;
}
}
function stripJsonPrefix(content: string): string {
return content.replace(/^\{.*\}\n?/, "").trim();
}
function getTextAfterFirstLine(content: string): string {
const lines = content.split("\n");
if (lines.length <= 1) return "";
return lines.slice(1).join("\n").trim();
}
type CopyableMessage = {
content?: string;
file?: string;
fileType?: ChatMessage["fileType"];
forwardedFrom?: ChatMessage["forwardedFrom"];
};
export function getCopyableMessageText(
message: CopyableMessage
): string | null {
const content = message.content?.trim() || "";
if (!content && !message.file) return null;
const location =
message.fileType === "location" ? parseLocationContent(content) : null;
if (location) {
const label = location.label?.trim();
const coords = `${location.lat}, ${location.lng}`;
return label ? `${label}\n${coords}` : coords;
}
const sharedPost = parseSharedPost(content);
if (sharedPost) {
const caption = sharedPost.caption?.trim();
if (caption) return caption;
return null;
}
const sharedStory = parseSharedStory(content);
if (sharedStory) {
const reactionText = extractStoryReactionText(content);
return reactionText || null;
}
const forwarded = message.forwardedFrom || parseForwarded(content);
if (forwarded) {
return getTextAfterFirstLine(content) || null;
}
if (parseLocationContent(content)) {
const loc = parseLocationContent(content)!;
const label = loc.label?.trim();
const coords = `${loc.lat}, ${loc.lng}`;
return label ? `${label}\n${coords}` : coords;
}
const text = stripJsonPrefix(content);
return text || null;
}
export function getCopyableRoomMessageText(content?: string): string | null {
const text = content?.trim();
return text || null;
}
export async function copyTextToClipboard(text: string): Promise<boolean> {
if (typeof window === "undefined") return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
document.body.removeChild(textarea);
return copied;
} catch {
return false;
}
}

View File

@@ -3,6 +3,7 @@ import {
formatChatDateLabel,
parseMessageDate,
} from "@/lib/chat/formatMessageTime";
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
export type MessageGroup =
| { type: "date"; label: string; key: string }
@@ -14,7 +15,10 @@ function dayKey(msg: ChatMessage): string {
return "unknown-day";
}
export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
export function groupMessagesByDate(
messages: ChatMessage[],
lang: AppLanguage = DEFAULT_LANGUAGE
): MessageGroup[] {
const groups: MessageGroup[] = [];
let lastDay = "";
@@ -26,7 +30,7 @@ export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
lastDay = dk;
groups.push({
type: "date",
label: formatChatDateLabel(msg.createdAtIso ?? msg.createdAt),
label: formatChatDateLabel(msg.createdAtIso ?? msg.createdAt, lang),
key: `date-${dk}`,
});
}

View File

@@ -78,3 +78,8 @@ export function getMessageRowSpacing(
if (isGroupedWithPrev) return "mt-[2px]";
return "mt-3";
}
/** Media bubbles always use fully rounded corners (photo/video/file preview). */
export function getMediaBubbleRadius(): string {
return "18px";
}

View File

@@ -0,0 +1,20 @@
/** Legacy API values returned before i18n rollout */
const LEGACY_ONLINE_VALUES = new Set(["آنلاین", "Online"]);
export function isUserOnline(
lastOnline?: string | null,
onlineLabel?: string
): boolean {
if (!lastOnline) return false;
if (onlineLabel && lastOnline === onlineLabel) return true;
return LEGACY_ONLINE_VALUES.has(lastOnline);
}
export function resolveOnlineLabel(
lastOnline: string | undefined,
onlineLabel: string,
offlineLabel: string
): string {
if (isUserOnline(lastOnline, onlineLabel)) return onlineLabel;
return lastOnline || offlineLabel;
}

15
src/lib/chat/reactions.ts Normal file
View File

@@ -0,0 +1,15 @@
export const QUICK_REACTIONS = ["❤️", "😂", "👍", "😮", "😢", "🙏"] as const;
export type MessageReaction = {
userId: string;
emoji: string;
};
export function groupReactions(reactions: MessageReaction[] | undefined) {
if (!reactions?.length) return [];
const map = new Map<string, number>();
for (const r of reactions) {
map.set(r.emoji, (map.get(r.emoji) ?? 0) + 1);
}
return [...map.entries()].map(([emoji, count]) => ({ emoji, count }));
}

View File

@@ -3,10 +3,36 @@ import { SOCKET_URL } from "@/components/main/BaseUrl";
let sharedSocket: Socket | null = null;
let refCount = 0;
const connectionListeners = new Set<(connected: boolean) => void>();
type PendingGroupJoin = { roomId: string; userId: string };
const pendingGroupJoins = new Map<string, PendingGroupJoin>();
let pendingUserId: string | null = null;
function notifyConnectionListeners(connected: boolean) {
connectionListeners.forEach((fn) => fn(connected));
}
function flushPendingJoins(): void {
if (!sharedSocket?.connected) return;
if (pendingUserId) {
sharedSocket.emit("joinUser", { userId: pendingUserId });
}
for (const { roomId, userId } of pendingGroupJoins.values()) {
sharedSocket.emit("joinGroupRoom", { roomId, userId });
}
}
export function resolveSocketUrl(): string {
return process.env.NEXT_PUBLIC_SOCKET_URL || SOCKET_URL;
}
export function acquireChatSocket(): Socket {
if (!sharedSocket) {
sharedSocket = io(SOCKET_URL, {
sharedSocket = io(resolveSocketUrl(), {
path: "/socket.io/",
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: Infinity,
@@ -16,6 +42,13 @@ export function acquireChatSocket(): Socket {
withCredentials: true,
autoConnect: true,
});
sharedSocket.on("connect", () => {
notifyConnectionListeners(true);
flushPendingJoins();
});
sharedSocket.on("disconnect", () => notifyConnectionListeners(false));
sharedSocket.on("connect_error", () => notifyConnectionListeners(false));
}
refCount += 1;
return sharedSocket;
@@ -35,13 +68,19 @@ export function disconnectChatSocket(): void {
sharedSocket.disconnect();
sharedSocket = null;
refCount = 0;
pendingGroupJoins.clear();
pendingUserId = null;
notifyConnectionListeners(false);
}
}
export function joinUserRoom(userId: string): void {
const id = String(userId).trim();
if (!id || !sharedSocket) return;
sharedSocket.emit("joinUser", { userId: id });
if (!id) return;
pendingUserId = id;
if (sharedSocket?.connected) {
sharedSocket.emit("joinUser", { userId: id });
}
}
export function joinChatRoom(userId: string, receiverId: string): void {
@@ -51,6 +90,30 @@ export function joinChatRoom(userId: string, receiverId: string): void {
sharedSocket.emit("joinChat", { userId: uid, receiverId: rid });
}
export function joinGroupRoom(roomId: string, userId: string): void {
const rid = String(roomId).trim();
const uid = String(userId).trim();
if (!rid || !uid) return;
pendingGroupJoins.set(rid, { roomId: rid, userId: uid });
if (sharedSocket?.connected) {
sharedSocket.emit("joinGroupRoom", { roomId: rid, userId: uid });
}
}
export function leaveGroupRoom(roomId: string): void {
pendingGroupJoins.delete(String(roomId).trim());
}
export function isChatSocketConnected(): boolean {
return Boolean(sharedSocket?.connected);
}
export function subscribeChatSocketConnection(
listener: (connected: boolean) => void
): () => void {
connectionListeners.add(listener);
listener(isChatSocketConnected());
return () => {
connectionListeners.delete(listener);
};
}

View File

@@ -1,5 +1,6 @@
import type { InfiniteData } from "@tanstack/react-query";
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
import { parseMessageTime } from "./dedupeMessages";
export type ChatThreadPage = { messages: ChatMessage[]; nextPage?: number };
export type ChatThreadData = InfiniteData<ChatThreadPage, number>;
@@ -64,3 +65,24 @@ export function upsertMessageInThreadCache(
return appendMessageToThreadCache(oldData, message);
}
/** هنگام refetch، پیام‌های تازهٔ cache که هنوز در پاسخ API نیستند حفظ می‌شوند */
export function mergeFetchedThreadPage(
fetched: ChatMessage[],
cachedFirstPage: ChatMessage[] | undefined
): ChatMessage[] {
if (!cachedFirstPage?.length) return fetched;
const fetchedIds = new Set(fetched.map((m) => String(m._id)));
const preserved = cachedFirstPage.filter(
(m) =>
/^[a-f\d]{24}$/i.test(String(m._id)) &&
!fetchedIds.has(String(m._id))
);
if (!preserved.length) return fetched;
return [...fetched, ...preserved].sort(
(a, b) => parseMessageTime(a) - parseMessageTime(b)
);
}

View File

@@ -1,15 +1,36 @@
export const TIMED_MESSAGE_OPTIONS = [
{ label: "خاموش", value: null as number | null },
{ label: "۱۰ ثانیه", value: 10 },
{ label: "۳۰ ثانیه", value: 30 },
{ label: "۱ دقیقه", value: 60 },
{ label: "۵ دقیقه", value: 300 },
{ label: "۱ ساعت", value: 3600 },
import type { TFunction } from "i18next";
export const TIMED_MESSAGE_SECONDS = [
null,
10,
30,
60,
300,
3600,
] as const;
export function formatTimedLabel(seconds: number | null): string {
const opt = TIMED_MESSAGE_OPTIONS.find((o) => o.value === seconds);
return opt?.label ?? "خاموش";
const TIMED_MESSAGE_KEYS = [
"chats.timed.off",
"chats.timed.10s",
"chats.timed.30s",
"chats.timed.1m",
"chats.timed.5m",
"chats.timed.1h",
] as const;
export function getTimedMessageOptions(t: TFunction) {
return TIMED_MESSAGE_SECONDS.map((value, index) => ({
value,
label: t(TIMED_MESSAGE_KEYS[index]),
}));
}
export function formatTimedLabel(seconds: number | null, t: TFunction): string {
const index = TIMED_MESSAGE_SECONDS.indexOf(
seconds as (typeof TIMED_MESSAGE_SECONDS)[number]
);
if (index >= 0) return t(TIMED_MESSAGE_KEYS[index]);
return t("chats.timed.off");
}
export function getRemainingSeconds(expiresAt?: string | null): number | null {

View File

@@ -8,15 +8,20 @@ export function isViewOnceMediaType(
return VIEW_ONCE_MEDIA_TYPES.includes(fileType as ViewOnceMediaType);
}
export function viewOnceLabel(fileType?: string): string {
import type { TFunction } from "i18next";
export function viewOnceLabel(
fileType: string | undefined,
t: TFunction
): string {
switch (fileType) {
case "image":
return "عکس یک‌بار مصرف";
return t("chats.viewOnce.image");
case "video":
return "فیلم یک‌بار مصرف";
return t("chats.viewOnce.video");
case "voice":
return "ویس یک‌بار مصرف";
return t("chats.viewOnce.voice");
default:
return "پیام یک‌بار مصرف";
return t("chats.viewOnce.default");
}
}