- Remove .prettierrc (dead config, Biome replaces Prettier) - Rename lighthouserc.json to lighthouserc.cjs with module.exports for ESM compat - Add logger.warn to empty catch blocks in auth, register, site-settings, prisma-cache, redis, security, rate-limit - Unify ActionResult type: action-helper.ts uses 'ok' consistent with safe-action-shared.ts - Add noUnusedLocals + noUnusedParameters to tsconfig + fix 25 pre-existing unused vars - Replace barrel export src/types/index.ts with direct @/types/common imports - Make trustHost conditional (development only) in auth.ts - Add pre-flight URL validation to update-Nitrov3.sh to catch image.library.url misconfigurations - Improve NITRO_IMAGE_LIBRARY_URL content validation in pre-flight & post-compute checks
397 lines
11 KiB
TypeScript
397 lines
11 KiB
TypeScript
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import type { CSSProperties } from "react";
|
|
import {
|
|
acceptFriend,
|
|
declineFriendRequest,
|
|
sendOfflineMessage,
|
|
} from "@/actions/messenger";
|
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
|
import { auth } from "@/lib/auth";
|
|
import { avatarImageUrl } from "@/lib/format";
|
|
import { formatDate } from "@/lib/format-date";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
type SearchParams = Promise<{
|
|
accepted?: string;
|
|
declined?: string;
|
|
sent?: string;
|
|
error?: string;
|
|
send_error?: string;
|
|
}>;
|
|
|
|
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
|
const accent =
|
|
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
|
return {
|
|
margin: 0,
|
|
padding: "0.85rem 1rem",
|
|
borderRadius: "var(--radius-md)",
|
|
border: `1px solid ${accent}`,
|
|
color: "var(--color-text-readable, var(--color-text))",
|
|
fontSize: "0.9rem",
|
|
fontWeight: 600,
|
|
background: "var(--color-surface)",
|
|
borderLeft: `4px solid ${accent}`,
|
|
};
|
|
}
|
|
|
|
export default async function MessagesPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: SearchParams;
|
|
}) {
|
|
const t = await getTranslations("pages.messages");
|
|
const session = await auth();
|
|
if (!session?.user?.id) redirect("/login");
|
|
|
|
const userId = Number(session.user.id);
|
|
const { accepted, declined, sent, error, send_error } = await searchParams;
|
|
|
|
let requests: { id: number; userFromId: number }[] = [];
|
|
let messages: {
|
|
id: number;
|
|
userFromId: number;
|
|
message: string;
|
|
sendedOn: number;
|
|
}[] = [];
|
|
let friendships: { userOneId: number; userTwoId: number }[] = [];
|
|
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
|
|
|
|
try {
|
|
const [reqRows, msgRows, friendRows, base] = await Promise.all([
|
|
prisma.messengerFriendrequests.findMany({
|
|
where: { userToId: userId },
|
|
select: { id: true, userFromId: true },
|
|
orderBy: { id: "desc" },
|
|
take: 100,
|
|
}),
|
|
prisma.messengerOffline.findMany({
|
|
where: { userId },
|
|
select: { id: true, userFromId: true, message: true, sendedOn: true },
|
|
orderBy: { sendedOn: "desc" },
|
|
take: 50,
|
|
}),
|
|
prisma.messengerFriendships.findMany({
|
|
where: {
|
|
OR: [{ userOneId: userId }, { userTwoId: userId }],
|
|
},
|
|
select: { userOneId: true, userTwoId: true },
|
|
}),
|
|
siteSettings.get(
|
|
"habbo_imaging_url",
|
|
"https://www.habbo.com/habbo-imaging/avatarimage",
|
|
),
|
|
]);
|
|
requests = reqRows;
|
|
messages = msgRows;
|
|
friendships = friendRows;
|
|
if (base) imagerBase = base;
|
|
} catch {
|
|
requests = [];
|
|
messages = [];
|
|
friendships = [];
|
|
}
|
|
|
|
const friendIds = Array.from(
|
|
new Set(
|
|
friendships.map((f) =>
|
|
f.userOneId === userId ? f.userTwoId : f.userOneId,
|
|
),
|
|
),
|
|
).filter((id) => id > 0 && id !== userId);
|
|
|
|
const otherIds = Array.from(
|
|
new Set([
|
|
...requests.map((r) => r.userFromId),
|
|
...messages.map((m) => m.userFromId),
|
|
...friendIds,
|
|
]),
|
|
).filter((id) => id && id > 0);
|
|
|
|
let userById = new Map<
|
|
number,
|
|
{ id: number; username: string; look: string }
|
|
>();
|
|
if (otherIds.length > 0) {
|
|
try {
|
|
const users = await prisma.user.findMany({
|
|
where: { id: { in: otherIds } },
|
|
select: { id: true, username: true, look: true },
|
|
});
|
|
userById = new Map(users.map((u) => [u.id, u]));
|
|
} catch {
|
|
userById = new Map();
|
|
}
|
|
}
|
|
|
|
const friendsForSelect = friendIds
|
|
.map((id) => userById.get(id))
|
|
.filter((u): u is { id: number; username: string; look: string } =>
|
|
Boolean(u),
|
|
)
|
|
.sort((a, b) => a.username.localeCompare(b.username));
|
|
|
|
const errorMessage =
|
|
error === "not_found"
|
|
? t("errors.notFound")
|
|
: error === "unauthorized"
|
|
? t("errors.unauthorized")
|
|
: error === "invalid"
|
|
? t("errors.invalid")
|
|
: error
|
|
? t("errors.error")
|
|
: null;
|
|
|
|
const sendErrorMessage =
|
|
send_error === "not_friend"
|
|
? t("errors.notFriend")
|
|
: send_error === "empty"
|
|
? t("errors.emptyMessage")
|
|
: send_error === "rate_limited"
|
|
? t("errors.rateLimited")
|
|
: send_error === "invalid"
|
|
? t("errors.invalid")
|
|
: send_error
|
|
? t("errors.error")
|
|
: null;
|
|
|
|
return (
|
|
<main style={{ display: "grid", gap: "1.5rem" }}>
|
|
{accepted === "1" ? (
|
|
<div role="status" style={feedbackStyle("success")}>
|
|
{t("success.accepted")}
|
|
</div>
|
|
) : null}
|
|
{declined === "1" ? (
|
|
<div role="status" style={feedbackStyle("success")}>
|
|
{t("success.declined")}
|
|
</div>
|
|
) : null}
|
|
{sent === "1" ? (
|
|
<div role="status" style={feedbackStyle("success")}>
|
|
{t("success.sent")}
|
|
</div>
|
|
) : null}
|
|
{errorMessage ? (
|
|
<div role="alert" style={feedbackStyle("error")}>
|
|
{errorMessage}
|
|
</div>
|
|
) : null}
|
|
{sendErrorMessage ? (
|
|
<div role="alert" style={feedbackStyle("error")}>
|
|
{sendErrorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
<ContentCard icon="✉️" title={t("title")} subtitle={t("subtitle")} />
|
|
|
|
<ContentCard
|
|
icon="📤"
|
|
title={t("composeTitle")}
|
|
subtitle={t("composeSubtitle")}
|
|
padded={friendsForSelect.length === 0}
|
|
>
|
|
{friendsForSelect.length === 0 ? (
|
|
<EmptyState icon="👥">{t("composeEmptyFriends")}</EmptyState>
|
|
) : (
|
|
<form
|
|
action={sendOfflineMessage}
|
|
className="grid"
|
|
style={{ padding: "1rem", gap: "0.75rem", maxWidth: "36rem" }}
|
|
>
|
|
<label className="grid" style={{ gap: "0.35rem" }}>
|
|
<span className="muted">{t("composeFriendLabel")}</span>
|
|
<select name="friendId" required defaultValue="">
|
|
<option value="" disabled>
|
|
{t("composeFriendPlaceholder")}
|
|
</option>
|
|
{friendsForSelect.map((friend) => (
|
|
<option key={friend.id} value={String(friend.id)}>
|
|
{friend.username}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="grid" style={{ gap: "0.35rem" }}>
|
|
<span className="muted">{t("composeMessageLabel")}</span>
|
|
<textarea
|
|
name="message"
|
|
required
|
|
maxLength={500}
|
|
rows={4}
|
|
placeholder={t("composeMessagePlaceholder")}
|
|
style={{ resize: "vertical" }}
|
|
/>
|
|
</label>
|
|
<div>
|
|
<button type="submit" className="btn btn-secondary">
|
|
{t("composeSend")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</ContentCard>
|
|
|
|
{/* ── Pending friend requests ───────────────────────────── */}
|
|
<ContentCard
|
|
icon="🙋"
|
|
title={t("requestsTitle")}
|
|
subtitle={
|
|
requests.length === 0
|
|
? t("requestsEmptySubtitle")
|
|
: t("requestsSubtitle", { count: requests.length })
|
|
}
|
|
padded={requests.length === 0}
|
|
>
|
|
{requests.length === 0 ? (
|
|
<EmptyState icon="🙋">{t("requestsEmptyState")}</EmptyState>
|
|
) : (
|
|
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
|
|
{requests.map((req) => {
|
|
const from = userById.get(req.userFromId);
|
|
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
|
|
size: "s",
|
|
headOnly: true,
|
|
});
|
|
return (
|
|
<div
|
|
key={req.id}
|
|
className="card"
|
|
style={{
|
|
display: "flex",
|
|
gap: "0.85rem",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
className="avatar"
|
|
src={avatar}
|
|
alt={`${from?.username ?? "User"} avatar`}
|
|
width={50}
|
|
height={50}
|
|
/>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<p style={{ margin: 0 }}>
|
|
{from ? (
|
|
<Link href={`/u/${from.username}`}>
|
|
<strong>{from.username}</strong>
|
|
</Link>
|
|
) : (
|
|
<strong>
|
|
{t("unknownUser", { id: req.userFromId })}
|
|
</strong>
|
|
)}
|
|
</p>
|
|
<p className="muted" style={{ margin: 0 }}>
|
|
{t("wantsToBeFriend")}
|
|
</p>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: "0.5rem",
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<form action={acceptFriend}>
|
|
<input
|
|
type="hidden"
|
|
name="requestId"
|
|
value={String(req.id)}
|
|
/>
|
|
<button type="submit" className="btn btn-secondary">
|
|
{t("accept")}
|
|
</button>
|
|
</form>
|
|
<form action={declineFriendRequest}>
|
|
<input
|
|
type="hidden"
|
|
name="requestId"
|
|
value={String(req.id)}
|
|
/>
|
|
<button type="submit" className="btn">
|
|
{t("decline")}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</ContentCard>
|
|
|
|
{/* ── Offline messages ──────────────────────────────────── */}
|
|
<ContentCard
|
|
icon="💬"
|
|
title={t("offlineTitle")}
|
|
subtitle={
|
|
messages.length === 0
|
|
? t("offlineEmptySubtitle")
|
|
: t("offlineSubtitle", { count: messages.length })
|
|
}
|
|
padded={messages.length === 0}
|
|
>
|
|
{messages.length === 0 ? (
|
|
<EmptyState icon="💬">{t("offlineEmptyState")}</EmptyState>
|
|
) : (
|
|
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
|
|
{messages.map((msg) => {
|
|
const from = userById.get(msg.userFromId);
|
|
const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
|
|
size: "s",
|
|
headOnly: true,
|
|
});
|
|
const when = formatDate(new Date(msg.sendedOn * 1000));
|
|
return (
|
|
<article
|
|
key={msg.id}
|
|
className="card"
|
|
style={{
|
|
display: "flex",
|
|
gap: "0.85rem",
|
|
alignItems: "flex-start",
|
|
}}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
className="avatar"
|
|
src={avatar}
|
|
alt={`${from?.username ?? "User"} avatar`}
|
|
width={50}
|
|
height={50}
|
|
/>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<p
|
|
className="muted"
|
|
style={{ marginTop: 0, marginBottom: "0.35rem" }}
|
|
>
|
|
{from ? (
|
|
<Link href={`/u/${from.username}`}>
|
|
{from.username}
|
|
</Link>
|
|
) : (
|
|
<span>{t("unknownUser", { id: msg.userFromId })}</span>
|
|
)}
|
|
{when ? <span> · {when}</span> : null}
|
|
</p>
|
|
<p style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
|
{msg.message}
|
|
</p>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</ContentCard>
|
|
</main>
|
|
);
|
|
}
|