M4: Calendar plugin (builtin) + mail HTML/attachment fixes
Calendar plugin (webmail_plugin_calendar, non-removable core plugin): - Local calendars/events CRUD with RLS isolation; recurrence expanded at query time (DST-correct), EXDATE + detached-override merge. - Calendar app bundle: month/week/day/3-day views, mini-month, event form (color, show-as, timezone combobox, repeat, reminders, guests w/ recipient autocomplete, Jitsi video call), event popover with RSVP badges, resizable/ collapsible left panel + theme toggle, overlap layout. - iMIP invites: sdk.mail sends REQUEST from the organizer's mailbox (HTML + text/calendar + invite.ics); public /rsvp endpoint (vault-sealed token) records accepted/declined/tentative per attendee. - "This and following" edit + scoped delete (this / this-and-following), with a delete confirmation dialog for recurring events. Core: - Settings: time_format (12/24h, default 24h) + jitsi_server; new Integrations section; date order follows time format (en-GB vs en-US) across Mail. - Plugin SDK: mail (send from a user's account), vault (envelope encrypt), multi-method SSRF-guarded http; AppSwitcher product switcher; full-bleed PluginPage. Mail HTML rendering: - Sanitizer allows <style> blocks + class (CSS sanitized: drop script-like, strip @import/url() when remote images blocked); SANITIZER_VERSION 3 so already-indexed mail re-sanitizes on view. - Attachments: a Content-Disposition: attachment part is a real attachment even when it also carries a Content-ID (fixes hidden invite.ics). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { usePluginPages } from "../plugins/manifest";
|
||||
|
||||
// Product switcher shown in the sidebar header: Webmail + any plugin "app.page"
|
||||
// (Calendar, later Contacts). Plugin-agnostic — the app list comes from the
|
||||
// manifest, so a new page-plugin appears here with no core change.
|
||||
export function AppSwitcher() {
|
||||
const navigate = useNavigate();
|
||||
const pages = usePluginPages();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
const apps = [
|
||||
{ key: "mail", label: "Mail", to: "/mail" },
|
||||
...pages.map((p) => ({ key: `${p.plugin}${p.route}`, label: p.nav.label, to: `/p${p.route}` })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover"
|
||||
>
|
||||
Mail
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-app-border bg-app-elevated py-1 shadow-lg">
|
||||
{apps.map((a) => (
|
||||
<button
|
||||
key={a.key}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
navigate(a.to);
|
||||
}}
|
||||
className={`flex w-full items-center px-3 py-1.5 text-left text-sm hover:bg-app-hover ${
|
||||
a.key === "mail" ? "font-medium text-app-fg" : "text-app-muted"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useT } from "../../i18n";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
if (d.toDateString() === now.toDateString()) return formatClock(d);
|
||||
return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function DraftRow({
|
||||
|
||||
@@ -2,17 +2,17 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
if (d.toDateString() === now.toDateString()) return formatClock(d);
|
||||
if (d.getFullYear() === now.getFullYear())
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { printMessages } from "../../lib/print";
|
||||
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
|
||||
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||
@@ -39,13 +40,17 @@ function recipientSummary(addrs: { name: string; email: string }[]): string {
|
||||
|
||||
function attributionFor(msg: MessageEnvelope): string {
|
||||
const d = new Date(msg.date);
|
||||
const date = d.toLocaleDateString("en-US", {
|
||||
const date = d.toLocaleDateString(getDateLocale(), {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
const time = d.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: getHour12(),
|
||||
});
|
||||
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
|
||||
return `On ${date}, at ${time}, ${who} wrote:`;
|
||||
}
|
||||
@@ -110,13 +115,14 @@ function fmtRelative(iso: string): string {
|
||||
}
|
||||
|
||||
function fmtFull(iso: string): string {
|
||||
return new Date(iso).toLocaleString("en-US", {
|
||||
return new Date(iso).toLocaleString(getDateLocale(), {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: getHour12(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,13 @@ export const en = {
|
||||
"settings.revoke": "Revoke",
|
||||
"settings.threadOrder": "Conversation order",
|
||||
"settings.timezone": "Time zone",
|
||||
"settings.timeFormat": "Time format",
|
||||
"settings.timeFormat24": "24-hour (14:30)",
|
||||
"settings.timeFormat12": "12-hour (2:30 PM)",
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.jitsi": "Jitsi Meet",
|
||||
"settings.jitsiHint":
|
||||
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
||||
"settings.addAccount": "Add account",
|
||||
"settings.syncState": "State: {state}",
|
||||
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
||||
|
||||
@@ -166,7 +166,9 @@ export const api = {
|
||||
|
||||
// --- settings ---
|
||||
settings: () => apiFetch<UserSettings>("/settings"),
|
||||
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
|
||||
patchSettings: (
|
||||
patch: Partial<Pick<UserSettings, "thread_order" | "tz" | "time_format" | "jitsi_server">>,
|
||||
) =>
|
||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// the browser's print dialog. Reuses the same sanitized HTML the reader shows.
|
||||
|
||||
import { api } from "./api";
|
||||
import { getDateLocale, getHour12 } from "./timeFormat";
|
||||
import type { Address, AttachmentOut, MessageEnvelope } from "./types";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -24,13 +25,14 @@ function fmtList(addrs: Address[]): string {
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
return d.toLocaleString(getDateLocale(), {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: getHour12(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Shared 12h/24h time preference (SPEC §9.1). The value lives in user settings
|
||||
// (`time_format`), synced here so the module-level date formatters used across
|
||||
// Mail can read it synchronously during render. 24-hour is the default.
|
||||
|
||||
let hour12 = false;
|
||||
|
||||
try {
|
||||
if (typeof localStorage !== "undefined" && localStorage.getItem("time_format") === "12") {
|
||||
hour12 = true;
|
||||
}
|
||||
} catch {
|
||||
/* localStorage unavailable (SSR/tests) — keep the 24h default. */
|
||||
}
|
||||
|
||||
/** Called when the settings query resolves/changes to sync the preference. */
|
||||
export function setTimeFormat(fmt: "12" | "24"): void {
|
||||
hour12 = fmt === "12";
|
||||
try {
|
||||
localStorage.setItem("time_format", fmt);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** `hour12` option for Intl date/time formatting, reflecting the preference. */
|
||||
export function getHour12(): boolean {
|
||||
return hour12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale for date formatting, tied to the time preference: 24-hour → en-GB
|
||||
* (day/month/year, European), 12-hour → en-US (month/day/year, American).
|
||||
*/
|
||||
export function getDateLocale(): string {
|
||||
return hour12 ? "en-US" : "en-GB";
|
||||
}
|
||||
|
||||
/** Format just the clock portion (e.g. "14:30" or "2:30 PM") of a Date. */
|
||||
export function formatClock(d: Date): string {
|
||||
return d.toLocaleTimeString([], { hour: hour12 ? "numeric" : "2-digit", minute: "2-digit", hour12 });
|
||||
}
|
||||
@@ -151,6 +151,8 @@ export interface SessionInfo {
|
||||
export interface UserSettings {
|
||||
thread_order: "asc" | "desc";
|
||||
tz: string;
|
||||
time_format: "12" | "24";
|
||||
jitsi_server: string;
|
||||
allowed_image_senders: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// header keeps a way back to Mail. (Sidebar persistence is a later refinement.)
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { loadBundle } from "./bundleLoader";
|
||||
@@ -40,7 +40,7 @@ function PageMount({
|
||||
};
|
||||
}, [bundle, ctx, props, pluginName]);
|
||||
|
||||
return <div ref={ref} data-plugin-page={pluginName} className="min-h-0 flex-1 overflow-auto" />;
|
||||
return <div ref={ref} data-plugin-page={pluginName} className="h-full overflow-auto" />;
|
||||
}
|
||||
|
||||
export function PluginPage() {
|
||||
@@ -58,17 +58,10 @@ export function PluginPage() {
|
||||
}
|
||||
if (!match) return <Navigate to="/mail" replace />;
|
||||
|
||||
// Full-bleed: app pages (like Calendar) own their entire chrome, including the
|
||||
// product switcher for navigating back to Mail.
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-app-bg">
|
||||
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
|
||||
<Link
|
||||
to="/mail"
|
||||
className="rounded px-2 py-1 text-sm text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
← {t("nav.mail")}
|
||||
</Link>
|
||||
<span className="text-sm font-semibold text-app-fg">{match.nav.label}</span>
|
||||
</header>
|
||||
<div className="h-screen bg-app-bg">
|
||||
<PageMount pluginName={match.plugin} bundle={match.bundle} subpath={subpath} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Builds the PluginContext handed to plugin bundles at mount (SPEC §11.3).
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { useT } from "../i18n";
|
||||
@@ -11,6 +12,7 @@ import type { PluginContext } from "./types";
|
||||
/** A memoised context bound to one plugin, usable from slots and pages. */
|
||||
export function usePluginContext(pluginName: string): PluginContext {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
return useMemo<PluginContext>(
|
||||
() => ({
|
||||
pluginName,
|
||||
@@ -27,7 +29,8 @@ export function usePluginContext(pluginName: string): PluginContext {
|
||||
// Plugins mostly ship their own strings; app keys resolve, unknown keys
|
||||
// fall through as-is (the app translator returns the key when missing).
|
||||
t: (key, vars) => t(key as MessageKey, vars),
|
||||
navigate: (path) => navigate(path),
|
||||
}),
|
||||
[pluginName, t],
|
||||
[pluginName, t, navigate],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface PluginContext {
|
||||
api: PluginApi;
|
||||
events: PluginEvents;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
/** Navigate the app's SPA router (e.g. back to "/mail" from a page bundle). */
|
||||
navigate: (path: string) => void;
|
||||
}
|
||||
|
||||
export type PluginUnmount = () => void;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useMatch, useNavigate } from "react-router-dom";
|
||||
|
||||
import { AppSwitcher } from "../components/AppSwitcher";
|
||||
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
||||
import { DraftsList } from "../components/list/DraftsList";
|
||||
import { Icon } from "../components/Icon";
|
||||
@@ -14,6 +15,7 @@ import { ThreadView } from "../components/thread/ThreadView";
|
||||
import { useT } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { api } from "../lib/api";
|
||||
import { setTimeFormat } from "../lib/timeFormat";
|
||||
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
import { usePluginPages } from "../plugins/manifest";
|
||||
@@ -137,6 +139,10 @@ export function MailLayout() {
|
||||
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
|
||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
||||
const timeFormat = settingsQuery.data?.time_format;
|
||||
useEffect(() => {
|
||||
if (timeFormat) setTimeFormat(timeFormat);
|
||||
}, [timeFormat]);
|
||||
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
|
||||
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
|
||||
|
||||
@@ -430,11 +436,7 @@ export function MailLayout() {
|
||||
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
|
||||
}`}
|
||||
>
|
||||
{!collapsed && (
|
||||
<span className="text-[13px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("common.appName")}
|
||||
</span>
|
||||
)}
|
||||
{!collapsed && <AppSwitcher />}
|
||||
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
|
||||
<button
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
|
||||
@@ -8,9 +8,10 @@ import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { useMe } from "../../lib/auth";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
import { setTimeFormat } from "../../lib/timeFormat";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
|
||||
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
|
||||
const SECTIONS = ["profile", "security", "accounts", "appearance", "integrations"] as const;
|
||||
type Section = (typeof SECTIONS)[number];
|
||||
|
||||
export function Settings() {
|
||||
@@ -45,6 +46,7 @@ export function Settings() {
|
||||
{section === "security" && <SecuritySection />}
|
||||
{section === "accounts" && <AccountsSection />}
|
||||
{section === "appearance" && <AppearanceSection />}
|
||||
{section === "integrations" && <IntegrationsSection />}
|
||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
|
||||
Matches the accounts section width (max-w-5xl) with a gap above it. */}
|
||||
{section === "accounts" && (
|
||||
@@ -595,7 +597,54 @@ function AppearanceSection() {
|
||||
<option value="desc">{t("mail.orderNewest")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{t("settings.timeFormat")}</span>
|
||||
<select
|
||||
className="rounded border border-app-border bg-app-surface px-2 py-1"
|
||||
value={settings.data?.time_format ?? "24"}
|
||||
onChange={(e) => {
|
||||
const fmt = e.target.value as "12" | "24";
|
||||
setTimeFormat(fmt);
|
||||
patch.mutate({ time_format: fmt });
|
||||
}}
|
||||
>
|
||||
<option value="24">{t("settings.timeFormat24")}</option>
|
||||
<option value="12">{t("settings.timeFormat12")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_JITSI = "https://meet.jit.si/";
|
||||
|
||||
function IntegrationsSection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||
});
|
||||
const [jitsi, setJitsi] = useState<string | null>(null);
|
||||
const value = jitsi ?? settings.data?.jitsi_server ?? "";
|
||||
|
||||
return (
|
||||
<section className="max-w-4xl space-y-6">
|
||||
<h2 className="text-lg font-semibold">{t("settings.integrations")}</h2>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t("settings.jitsi")}</h3>
|
||||
<p className="text-sm text-app-muted">{t("settings.jitsiHint")}</p>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
placeholder={DEFAULT_JITSI}
|
||||
value={value}
|
||||
onChange={(e) => setJitsi(e.target.value)}
|
||||
onBlur={(e) => patch.mutate({ jitsi_server: e.target.value.trim() })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "../i18n";
|
||||
@@ -41,9 +42,11 @@ function wrap(children: ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(["plugin-ui-manifest"], MANIFEST); // seed so no network fetch
|
||||
return (
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
<MemoryRouter>
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user