Initial commit

This commit is contained in:
payacom
2026-07-08 21:01:18 +03:30
parent 78decbba05
commit d2d3398471
1210 changed files with 9645 additions and 2522 deletions

View File

@@ -1,3 +1,37 @@
/** Parse message timestamp (ISO 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;
return null;
}
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 {
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 (d.getFullYear() === today.getFullYear()) {
return d.toLocaleDateString("fa-IR", { month: "long", day: "numeric" });
}
return d.toLocaleDateString("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/** Bubble time: hours, minutes, seconds only (no date) */
export function formatBubbleTime(createdAt?: string | null): string {
if (!createdAt) {

View File

@@ -1,43 +1,17 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
import {
formatChatDateLabel,
parseMessageDate,
} from "@/lib/chat/formatMessageTime";
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";
function dayKey(createdAt?: string | null): string {
const d = parseMessageDate(createdAt);
if (d) return d.toDateString();
return "unknown-day";
}
export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
@@ -52,7 +26,7 @@ export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
lastDay = dk;
groups.push({
type: "date",
label: formatDateLabel(msg.createdAt),
label: formatChatDateLabel(msg.createdAt),
key: `date-${dk}`,
});
}

View File

@@ -0,0 +1,80 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
import { parseMessageDate } from "@/lib/chat/formatMessageTime";
export type BubbleGroupPosition = "single" | "first" | "middle" | "last";
function dayKey(createdAt?: string | null): string {
const d = parseMessageDate(createdAt);
if (d) return d.toDateString();
return "unknown-day";
}
export function sameSender(a: ChatMessage, b: ChatMessage): boolean {
return String(a.senderId) === String(b.senderId);
}
function sameVisualGroup(a: ChatMessage, b: ChatMessage): boolean {
return sameSender(a, b) && dayKey(a.createdAt) === dayKey(b.createdAt);
}
export { sameVisualGroup };
export function getBubbleGroupPosition(
messages: ChatMessage[],
index: number
): BubbleGroupPosition {
const current = messages[index];
if (!current) return "single";
const sameAsPrev =
index > 0 && sameVisualGroup(messages[index - 1], current);
const sameAsNext =
index < messages.length - 1 &&
sameVisualGroup(messages[index + 1], current);
if (!sameAsPrev && !sameAsNext) return "single";
if (!sameAsPrev && sameAsNext) return "first";
if (sameAsPrev && sameAsNext) return "middle";
return "last";
}
/** Instagram-style corner radii (TL TR BR BL) */
export function getBubbleRadius(
isOutgoing: boolean,
position: BubbleGroupPosition
): string {
const r = 18;
const s = 4;
if (isOutgoing) {
switch (position) {
case "single":
return `${r}px ${r}px ${r}px ${s}px`;
case "first":
return `${r}px ${r}px ${r}px ${s}px`;
case "middle":
return `${s}px ${r}px ${r}px ${s}px`;
case "last":
return `${s}px ${r}px ${r}px ${r}px`;
}
}
switch (position) {
case "single":
return `${r}px ${r}px ${s}px ${r}px`;
case "first":
return `${r}px ${r}px ${s}px ${r}px`;
case "middle":
return `${r}px ${s}px ${s}px ${r}px`;
case "last":
return `${r}px ${s}px ${r}px ${r}px`;
}
}
export function getMessageRowSpacing(
position: BubbleGroupPosition,
isGroupedWithPrev: boolean
): string {
if (isGroupedWithPrev) return "mt-[2px]";
return "mt-3";
}

View File

@@ -0,0 +1,3 @@
export function chatThreadQueryKey(senderId: string, receiverId: string) {
return ["chatThread", senderId, receiverId] as const;
}

View File

@@ -0,0 +1,36 @@
import type { InfiniteData } from "@tanstack/react-query";
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
export type ChatThreadPage = { messages: ChatMessage[]; nextPage?: number };
export type ChatThreadData = InfiniteData<ChatThreadPage, number>;
export function normalizeThreadId(id: unknown): string {
if (!id) return "";
return String(id);
}
export function appendMessageToThreadCache(
oldData: ChatThreadData | undefined,
message: ChatMessage
): ChatThreadData {
if (!oldData?.pages?.length) {
return {
pages: [{ messages: [message], nextPage: undefined }],
pageParams: [1],
};
}
const exists = oldData.pages.some((p) =>
p.messages.some((m) => m._id === message._id)
);
if (exists) return oldData;
const newPages = [...oldData.pages];
const firstPage = newPages[0];
newPages[0] = {
...firstPage,
messages: [...firstPage.messages, message],
};
return { ...oldData, pages: newPages };
}

View File

@@ -0,0 +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 },
] as const;
export function formatTimedLabel(seconds: number | null): string {
const opt = TIMED_MESSAGE_OPTIONS.find((o) => o.value === seconds);
return opt?.label ?? "خاموش";
}
export function getRemainingSeconds(expiresAt?: string | null): number | null {
if (!expiresAt) return null;
const ms = new Date(expiresAt).getTime() - Date.now();
if (Number.isNaN(ms) || ms <= 0) return 0;
return Math.ceil(ms / 1000);
}
export function formatCountdown(totalSeconds: number): string {
if (totalSeconds >= 3600) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function buildExpiresAtIso(seconds: number | null): string | undefined {
if (!seconds) return undefined;
return new Date(Date.now() + seconds * 1000).toISOString();
}

22
src/lib/chat/viewOnce.ts Normal file
View File

@@ -0,0 +1,22 @@
export const VIEW_ONCE_MEDIA_TYPES = ["image", "video", "voice"] as const;
export type ViewOnceMediaType = (typeof VIEW_ONCE_MEDIA_TYPES)[number];
export function isViewOnceMediaType(
fileType?: string | null
): fileType is ViewOnceMediaType {
return VIEW_ONCE_MEDIA_TYPES.includes(fileType as ViewOnceMediaType);
}
export function viewOnceLabel(fileType?: string): string {
switch (fileType) {
case "image":
return "عکس یک‌بار مصرف";
case "video":
return "فیلم یک‌بار مصرف";
case "voice":
return "ویس یک‌بار مصرف";
default:
return "پیام یک‌بار مصرف";
}
}