mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 00:56:34 +00:00
feat: implement vaulting functionality with unlock dates and locked UI states for letters
This commit is contained in:
+14
@@ -13,3 +13,17 @@ dist/
|
||||
# Certificates
|
||||
certs/*.pem
|
||||
tmp/
|
||||
.idea/.gitignore
|
||||
.idea/misc.xml
|
||||
.idea/modules.xml
|
||||
.idea/pi ku.iml
|
||||
.idea/vcs.xml
|
||||
.idea/inspectionProfiles/profiles_settings.xml
|
||||
.idea/runConfigurations/pi_ku.xml
|
||||
backend/.idea/.gitignore
|
||||
backend/.idea/backend.iml
|
||||
backend/.idea/misc.xml
|
||||
backend/.idea/modules.xml
|
||||
backend/.idea/vcs.xml
|
||||
backend/.idea/inspectionProfiles/profiles_settings.xml
|
||||
backend/.idea/runConfigurations/backend.xml
|
||||
|
||||
@@ -25,7 +25,7 @@ export function DrawerSection({
|
||||
<div
|
||||
className={`transition-normal duration-1000 ease-in-out bg-neutral/10 ${
|
||||
isOpen
|
||||
? "max-h-125 opacity-100 py-3 border-b border-base-content/5"
|
||||
? "opacity-100 py-3 border-b border-base-content/5"
|
||||
: "max-h-0 opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LockIcon, LockKeyOpenIcon, LockOpenIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PATHS } from "../../config/routes";
|
||||
|
||||
@@ -6,14 +7,19 @@ export function LetterItem({
|
||||
timestamp,
|
||||
id,
|
||||
status,
|
||||
unlock_at,
|
||||
isLocked = false,
|
||||
}: {
|
||||
preview: string;
|
||||
timestamp: string;
|
||||
id: string;
|
||||
status: "DRAFT" | "SEALED" | "BURNED";
|
||||
unlock_at?: string;
|
||||
isLocked?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
function handleNavigate(): void {
|
||||
if (isLocked) return;
|
||||
if (status === "SEALED") {
|
||||
navigate(PATHS.read(id));
|
||||
} else {
|
||||
@@ -25,14 +31,29 @@ export function LetterItem({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className="p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2"
|
||||
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
|
||||
>
|
||||
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none">
|
||||
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
|
||||
{preview}
|
||||
</div>
|
||||
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
|
||||
{timestamp}
|
||||
</div>
|
||||
{unlock_at ? (
|
||||
<div className="flex flex-col items-end">
|
||||
{isLocked ? (
|
||||
<div className="font-sans text-xs badge badge-accent badge-soft rounded-2xl">
|
||||
<LockIcon weight="duotone" size={16} />
|
||||
Locked Until {unlock_at}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-sans text-xs badge badge-primary badge-soft rounded-2xl">
|
||||
<LockKeyOpenIcon weight="duotone" size={16} /> Unlocked
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
|
||||
{timestamp}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import { LetterItem } from "../components/ui/LetterItem";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useLetters } from "../hooks/useLetters";
|
||||
import { formatRelativeDate } from "../utils/dateFormat.ts";
|
||||
import {
|
||||
formateRelativeDateWithoutTime,
|
||||
formatRelativeDate,
|
||||
} from "../utils/dateFormat.ts";
|
||||
|
||||
export default function Drawer() {
|
||||
const { user, logout, unlock } = useAuth();
|
||||
@@ -172,6 +175,8 @@ export default function Drawer() {
|
||||
id={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Future Self"}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
unlock_at={formateRelativeDateWithoutTime(letter.unlock_at)}
|
||||
isLocked={letter.unlock_at > new Date().toISOString()}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mockMasterKey } from "../../test/fixtures/auth.fixture";
|
||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||
import { server } from "../../test/mocks/server";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { useAuthStore } from "../store/useAuthStore";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import Editor from "./Editor";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Mock ComposeCanvas to avoid Fabric.js issues and check readOnly prop
|
||||
vi.mock("../components/ui/ComposeCanvas", () => ({
|
||||
ComposeCanvas: vi.fn(({ readOnly }) => (
|
||||
<div data-testid="canvas" data-readonly={readOnly}>
|
||||
Canvas
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock CryptoUtils to avoid real crypto calls in UI tests
|
||||
vi.mock("../utils/crypto", () => {
|
||||
return {
|
||||
CryptoUtils: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
encryptLetter: vi.fn().mockResolvedValue({
|
||||
encrypted_content: "enc-content",
|
||||
encrypted_dek: "enc-dek",
|
||||
sharingKey: "share-key",
|
||||
}),
|
||||
encryptMetadata: vi.fn().mockResolvedValue({
|
||||
encrypted_content: "enc-meta",
|
||||
encrypted_dek: "enc-dek",
|
||||
}),
|
||||
decryptMetadata: vi.fn().mockResolvedValue({ recipient: "Test User" }),
|
||||
decryptLetter: vi.fn().mockResolvedValue("{}"),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Editor Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAuthStore.setState({
|
||||
user: mockUser,
|
||||
accessToken: "fake-token",
|
||||
isInitializing: false,
|
||||
});
|
||||
useKeyStore.setState({ masterKey: mockMasterKey });
|
||||
});
|
||||
|
||||
it("should set canvas to readOnly when status is VAULT", async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}${endpoints.LETTERS}:id/`, () => {
|
||||
return HttpResponse.json({
|
||||
public_id: "test-id",
|
||||
status: "DRAFT",
|
||||
updated_at: new Date().toISOString(),
|
||||
encrypted_content: "{}",
|
||||
encrypted_metadata: "{}",
|
||||
encrypted_dek: "wrapped-dek",
|
||||
});
|
||||
}),
|
||||
http.put(`${API_URL}${endpoints.LETTERS}:id/`, () => {
|
||||
return HttpResponse.json({ status: "success" });
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={["/write/test-id"]}>
|
||||
<Routes>
|
||||
<Route path="/write/:public_id" element={<Editor />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Wait for initial load to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initial state: DRAFT (not read-only)
|
||||
const canvas = screen.getByTestId("canvas");
|
||||
expect(canvas.getAttribute("data-readonly")).toBe("false");
|
||||
|
||||
// Click Seal in the main toolbar (it's in the div with id="writer-toolbar")
|
||||
const toolbar = container.querySelector("#writer-toolbar");
|
||||
const sealBtn = toolbar?.querySelector(".btn-primary");
|
||||
if (!sealBtn) throw new Error("Seal button not found");
|
||||
fireEvent.click(sealBtn);
|
||||
|
||||
// Click Vault to show confirm modal
|
||||
const vaultBtn = screen.getByRole("button", { name: /vault/i });
|
||||
fireEvent.click(vaultBtn);
|
||||
|
||||
// Set date and submit vault form
|
||||
const dateInput = container.querySelector('input[name="vault-date"]');
|
||||
if (!dateInput) throw new Error("Date input not found");
|
||||
fireEvent.change(dateInput, { target: { value: "2026-12-31" } });
|
||||
|
||||
const confirmVaultBtn = container.querySelector(
|
||||
'button[form="vault-form"]',
|
||||
);
|
||||
if (!confirmVaultBtn) throw new Error("Confirm vault button not found");
|
||||
fireEvent.click(confirmVaultBtn);
|
||||
|
||||
// Wait for save to complete and check readOnly
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
||||
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should set canvas to readOnly when status is SEALED", async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}${endpoints.LETTERS}:id/`, () => {
|
||||
return HttpResponse.json({
|
||||
public_id: "test-id",
|
||||
status: "DRAFT",
|
||||
updated_at: new Date().toISOString(),
|
||||
encrypted_content: "{}",
|
||||
encrypted_metadata: "{}",
|
||||
encrypted_dek: "wrapped-dek",
|
||||
});
|
||||
}),
|
||||
http.put(`${API_URL}${endpoints.LETTERS}:id/`, () => {
|
||||
return HttpResponse.json({ status: "success" });
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={["/write/test-id"]}>
|
||||
<Routes>
|
||||
<Route path="/write/:public_id" element={<Editor />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const canvas = screen.getByTestId("canvas");
|
||||
|
||||
const toolbar = container.querySelector("#writer-toolbar");
|
||||
const sealBtn = toolbar?.querySelector(".btn-primary");
|
||||
if (!sealBtn) throw new Error("Seal button not found");
|
||||
fireEvent.click(sealBtn);
|
||||
|
||||
// The secondary seal button appears (it has btn-accent class)
|
||||
const secondarySealBtn = container.querySelector(".btn-accent");
|
||||
if (!secondarySealBtn) throw new Error("Secondary seal button not found");
|
||||
fireEvent.click(secondarySealBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Sealed & Ready/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
||||
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,12 @@ const OVERLAY_FADE_MS = 250;
|
||||
const SAVED_VISIBLE_MS = 1400;
|
||||
const ERROR_VISIBLE_MS = 2400;
|
||||
|
||||
const toPlaceholderList = [
|
||||
"Someone dear...",
|
||||
"Somewhere near...",
|
||||
"Something to bear...",
|
||||
];
|
||||
|
||||
export default function Editor() {
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = useRef<NavigateFunction>(navigate);
|
||||
@@ -65,33 +71,36 @@ export default function Editor() {
|
||||
|
||||
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("idle");
|
||||
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
|
||||
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
|
||||
|
||||
const { masterKey } = useKeyStore();
|
||||
|
||||
const canvasRef = useRef<CanvasTools>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [offset, setOffset] = useState(0);
|
||||
const toPlaceholderList = [
|
||||
"Someone dear...",
|
||||
"Somewhere near...",
|
||||
"Something to bear...",
|
||||
];
|
||||
const [toPlaceholder, setToPlaceholder] = useState(toPlaceholderList[0]);
|
||||
const recipientInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setOffset((offset) => {
|
||||
const nextOffset = offset + 1;
|
||||
setToPlaceholder(toPlaceholderList[offset % toPlaceholderList.length]);
|
||||
console.log("Setting to ", toPlaceholder);
|
||||
return nextOffset;
|
||||
});
|
||||
if (recipientInputRef.current) {
|
||||
let currentOffset = parseInt(
|
||||
recipientInputRef.current.dataset.offset || "0",
|
||||
);
|
||||
recipientInputRef.current.dataset.offset = (
|
||||
(currentOffset + 1) %
|
||||
toPlaceholderList.length
|
||||
).toString();
|
||||
recipientInputRef.current.placeholder =
|
||||
toPlaceholderList[parseInt(recipientInputRef.current.dataset.offset)];
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [offset, toPlaceholder]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(public_id && masterKey)) return;
|
||||
@@ -221,6 +230,7 @@ export default function Editor() {
|
||||
|
||||
const handleSave = async (
|
||||
status: "SEALED" | "DRAFT" | "VAULT",
|
||||
vaultDate?: Date,
|
||||
): Promise<void> => {
|
||||
setSealBtnClicked(false);
|
||||
|
||||
@@ -260,8 +270,12 @@ export default function Editor() {
|
||||
|
||||
const formData = new FormData();
|
||||
if (status === "VAULT") {
|
||||
const finalDate = vaultDate || unlockDate;
|
||||
console.log(finalDate?.toISOString());
|
||||
formData.append("type", "VAULT");
|
||||
formData.append("unlock_at", "");
|
||||
if (finalDate) {
|
||||
formData.append("unlock_at", finalDate.toISOString());
|
||||
}
|
||||
formData.append("status", "SEALED");
|
||||
} else {
|
||||
formData.append("type", "KEPT");
|
||||
@@ -393,14 +407,14 @@ export default function Editor() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral btn-sm rounded-full px-6 group"
|
||||
onClick={() => handleSave("VAULT")}
|
||||
onClick={() => setConfirmModal("VAULT")}
|
||||
>
|
||||
<VaultIcon size={16} weight="fill" className="mr-1" />
|
||||
<span className="transition-all duration-1000">Vault</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.alert("Message")}
|
||||
onClick={() => setSealBtnClicked(false)}
|
||||
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
|
||||
>
|
||||
<QuestionIcon weight="duotone" size={20} className={""} />
|
||||
@@ -422,6 +436,66 @@ export default function Editor() {
|
||||
);
|
||||
}
|
||||
|
||||
function VaultConfirm() {
|
||||
return (
|
||||
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}>
|
||||
<div className="modal-box p-12 flex flex-col items-center">
|
||||
<VaultIcon
|
||||
size={48}
|
||||
className="text-primary mx-auto mb-8 animate-pulse"
|
||||
/>
|
||||
<h3 className="font-serif text-3xl">Vault this letter?</h3>
|
||||
<p className="text-base-content/60 text-sm text-center mt-4">
|
||||
Vaulting locks the letter permanently and will be{" "}
|
||||
<span className={"font-bold text-primary"}>mailed</span> to you
|
||||
automatically on the unlock date.
|
||||
<br />
|
||||
<span className={"underline"}>
|
||||
You cannot edit or view the contents of the letter until then.
|
||||
</span>
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const unlockDateStr = formData.get("vault-date") as string;
|
||||
const newUnlockDate = new Date(unlockDateStr);
|
||||
setUnlockDate(newUnlockDate);
|
||||
setConfirmModal(null);
|
||||
handleSave("VAULT", newUnlockDate);
|
||||
}}
|
||||
id="vault-form"
|
||||
>
|
||||
<div className={"divider tracking-tightest font-display text-sm"}>
|
||||
Set an unlock date
|
||||
</div>
|
||||
<input
|
||||
required
|
||||
type="date"
|
||||
className="input input-bordered w-full"
|
||||
name="vault-date"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary mt-4"
|
||||
type="submit"
|
||||
form="vault-form"
|
||||
>
|
||||
Vault
|
||||
</button>
|
||||
|
||||
<button
|
||||
type={"submit"}
|
||||
className="btn btn-ghost mt-4"
|
||||
onClick={() => setConfirmModal(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar
|
||||
@@ -575,6 +649,8 @@ export default function Editor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmModal === "VAULT" && <VaultConfirm />}
|
||||
|
||||
<div className="max-w-180 mx-auto px-1 md:px-0">
|
||||
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
@@ -587,9 +663,11 @@ export default function Editor() {
|
||||
<input
|
||||
id="recipient"
|
||||
type="text"
|
||||
placeholder={toPlaceholder}
|
||||
ref={recipientInputRef}
|
||||
placeholder={toPlaceholderList[0]}
|
||||
data-offset={"0"}
|
||||
value={recipient}
|
||||
disabled={status === "SEALED"}
|
||||
disabled={status !== "DRAFT"}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full disabled:opacity-50"
|
||||
/>
|
||||
@@ -599,7 +677,7 @@ export default function Editor() {
|
||||
|
||||
{status === "DRAFT" ? <ToolBar /> : <LetterHead />}
|
||||
|
||||
<ComposeCanvas ref={canvasRef} readOnly={status === "SEALED"} />
|
||||
<ComposeCanvas ref={canvasRef} readOnly={status !== "DRAFT"} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -152,26 +152,13 @@ export default function Reader() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-100 px-6 font-serif">
|
||||
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.4)_100%)] pointer-events-none z-0" />
|
||||
<div className="max-w-md w-full glass-card p-12 text-center space-y-6 z-10 animate-in fade-in zoom-in-95 duration-700">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-error font-display text-xl">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-base-content/60 text-sm leading-relaxed">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm text-xs uppercase tracking-widest hover:text-primary transition-colors"
|
||||
onClick={() => (window.location.href = "/")}
|
||||
>
|
||||
Return Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<LogModal
|
||||
isOpen={!!error}
|
||||
onClose={() => (window.location.href = "/")}
|
||||
message={error}
|
||||
log={error}
|
||||
status="ERROR"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ function startOfDay(d: Date) {
|
||||
}
|
||||
|
||||
export function formatRelativeDate(input: Date | string | number) {
|
||||
if (!input) return "";
|
||||
const date = new Date(input);
|
||||
const now = new Date();
|
||||
|
||||
@@ -32,3 +33,20 @@ export function formatRelativeDate(input: Date | string | number) {
|
||||
|
||||
return dateTimeFormatter.format(date);
|
||||
}
|
||||
|
||||
export function formateRelativeDateWithoutTime(input: Date | string | number) {
|
||||
if (!input) return "";
|
||||
const date = new Date(input);
|
||||
const now = new Date();
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const diffDays = Math.round(
|
||||
(startOfDay(date).getTime() - startOfDay(now).getTime()) / dayMs,
|
||||
);
|
||||
|
||||
if (diffDays === 0) return `Today`;
|
||||
if (diffDays === -1) return `Yesterday`;
|
||||
if (diffDays > -7) return `${rtf.format(diffDays, "day")}`;
|
||||
|
||||
return date.toDateString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user