The realtime connection now carries the IMVU chats a client is in: the hub tells each proxy in a chat which other T5DE users are there and what community products they wear (adult products only to adult accounts), on joining and on every change. Rooms T5DE users enter are looked up on IMVU; those that are public, listed and whose owner turned on "List my public rooms" appear on the new Rooms page, with the T5DE users in them who show they're online. AP rooms are shown to adults only. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7db9fc0370
commit
6ba4d83317
18 files changed
+1838
-50
No files matched your search
+73
-10
@@ -8,10 +8,11 @@ import { AgeError, AgeService } from "./age";
|
||||
import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
||||
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
|
||||
import { Realtime } from "./realtime";
|
||||
import { isAdultRoom, Rooms } from "./rooms";
|
||||
|
||||
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
||||
|
||||
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService };
|
||||
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService; rooms?: Rooms };
|
||||
|
||||
/** An error shown to the person: the website displays message as is. */
|
||||
export class Problem extends Error {
|
||||
@@ -61,13 +62,20 @@ class Limiter {
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Deps) {
|
||||
export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService }: Deps) {
|
||||
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
||||
const rooms = roomsService ?? new Rooms(db, imvu);
|
||||
const realtime =
|
||||
rt ??
|
||||
new Realtime(async (imvuId) => {
|
||||
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
|
||||
return l?.userId;
|
||||
new Realtime({
|
||||
owner: async (imvuId) => {
|
||||
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
|
||||
return l?.userId;
|
||||
},
|
||||
adult: async (userId, imvuId) => (await age.adultAccounts(userId, [imvuId])).has(imvuId),
|
||||
room: (instanceId) => {
|
||||
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
|
||||
},
|
||||
});
|
||||
const limiter = new Limiter();
|
||||
|
||||
@@ -101,6 +109,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
||||
checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(),
|
||||
showBadge: l.showBadge,
|
||||
showPresence: l.showPresence,
|
||||
listRooms: l.listRooms,
|
||||
online: live !== undefined,
|
||||
outfit: live?.outfit ?? [],
|
||||
};
|
||||
@@ -365,8 +374,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
||||
// Age from IMVU, for this account; a moved account's earlier checks belonged to its previous owner.
|
||||
if (result.moved) await age.forgetAccount(found.id);
|
||||
await age.record({ userId: me.id, imvuId: found.id }, "imvu", imvuResult(found));
|
||||
if (result.moved) realtime.unlinked(result.previousOwner!, found.id);
|
||||
realtime.linked(me.id, found.id);
|
||||
if (result.moved) {
|
||||
await rooms.forgetOwner(found.id);
|
||||
realtime.unlinked(result.previousOwner!, found.id);
|
||||
}
|
||||
await realtime.linked(me.id, found.id);
|
||||
return { link: await linkView(result.link), moved: result.moved };
|
||||
},
|
||||
{ signedIn: true, params: imvuIdParam },
|
||||
@@ -388,6 +400,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
||||
.set({
|
||||
...(body.showBadge !== undefined ? { showBadge: body.showBadge } : {}),
|
||||
...(body.showPresence !== undefined ? { showPresence: body.showPresence } : {}),
|
||||
...(body.listRooms !== undefined ? { listRooms: body.listRooms } : {}),
|
||||
})
|
||||
.where(eq(imvuLink.imvuId, params.imvu))
|
||||
.returning();
|
||||
@@ -396,7 +409,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
||||
{
|
||||
signedIn: true,
|
||||
params: imvuIdParam,
|
||||
body: t.Object({ showBadge: t.Optional(t.Boolean()), showPresence: t.Optional(t.Boolean()) }),
|
||||
body: t.Object({
|
||||
showBadge: t.Optional(t.Boolean()),
|
||||
showPresence: t.Optional(t.Boolean()),
|
||||
listRooms: t.Optional(t.Boolean()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -416,6 +433,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
||||
const l = await ownLink(me, params.imvu);
|
||||
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
|
||||
await age.forgetAccount(l.imvuId);
|
||||
await rooms.forgetOwner(l.imvuId);
|
||||
realtime.unlinked(me.id, l.imvuId);
|
||||
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
|
||||
set.status = 204;
|
||||
@@ -450,6 +468,46 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
||||
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
|
||||
)
|
||||
|
||||
// IMVU rooms whose owners list them, with the T5DE users in each now (those showing they're online).
|
||||
.get("/rooms", async ({ person: me }) => {
|
||||
const viewerAdult =
|
||||
me !== undefined &&
|
||||
(
|
||||
await age.adultAccounts(
|
||||
me.id,
|
||||
(await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, me.id))).map((l) => l.id),
|
||||
)
|
||||
).size > 0;
|
||||
const listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room));
|
||||
const here = new Map<string, number[]>();
|
||||
for (const o of realtime.online()) for (const r of o.rooms) here.set(r, [...(here.get(r) ?? []), o.imvuId]);
|
||||
const ids = [...new Set([...here.values()].flat())];
|
||||
const shown = ids.length
|
||||
? await db
|
||||
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username, badge: imvuLink.showBadge })
|
||||
.from(imvuLink)
|
||||
.where(and(inArray(imvuLink.imvuId, ids), eq(imvuLink.showPresence, true)))
|
||||
: [];
|
||||
const out = listed.map(({ room: r, owner }) => {
|
||||
const t5de = shown.filter((u) => here.get(r.instanceId)?.includes(u.imvuId));
|
||||
return {
|
||||
instanceId: r.instanceId,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
imageURL: r.imageURL,
|
||||
owner: { imvuId: r.ownerImvuId, username: owner },
|
||||
adult: isAdultRoom(r),
|
||||
occupancy: r.occupancy,
|
||||
capacity: r.capacity,
|
||||
url: `https://go.imvu.com/chat/room-${r.instanceId}`,
|
||||
t5de,
|
||||
seenAt: r.seenAt.toISOString(),
|
||||
};
|
||||
});
|
||||
out.sort((a, b) => b.t5de.length - a.t5de.length);
|
||||
return { rooms: out };
|
||||
})
|
||||
|
||||
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
|
||||
.get(
|
||||
"/age",
|
||||
@@ -645,7 +703,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
||||
},
|
||||
open(ws) {
|
||||
const { person } = ws.data as unknown as { person: Person };
|
||||
realtime.open(ws.id, person.id, () => ws.close(4001, "signed out"));
|
||||
realtime.open(
|
||||
ws.id,
|
||||
person.id,
|
||||
() => ws.close(4001, "signed out"),
|
||||
(m) => ws.send(JSON.stringify(m)),
|
||||
);
|
||||
ws.send(JSON.stringify({ type: "welcome" }));
|
||||
},
|
||||
async message(ws, message) {
|
||||
@@ -663,7 +726,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep
|
||||
.get("/health", () => ({ ok: true }))
|
||||
.use(api);
|
||||
|
||||
return { app, realtime, age };
|
||||
return { app, realtime, age, rooms };
|
||||
}
|
||||
|
||||
export type App = ReturnType<typeof createApp>["app"];
|
||||
+59
-6
@@ -1,5 +1,6 @@
|
||||
// IMVU's public user API: who an account is, its tagline (for linking) and whether it's age verified or has Access
|
||||
// Pass. The hub calls it rarely (linking, and age checks) and depends on it for nothing else.
|
||||
// IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass;
|
||||
// and rooms (for the rooms list). The hub calls it rarely (linking, age checks, rooms T5DE users enter) and depends
|
||||
// on it for nothing else.
|
||||
|
||||
export type ImvuUser = {
|
||||
id: number;
|
||||
@@ -9,6 +10,21 @@ export type ImvuUser = {
|
||||
accessPass: boolean;
|
||||
};
|
||||
|
||||
export type ImvuRoom = {
|
||||
instanceId: string; // "<owner>-<n>"
|
||||
ownerId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
imageURL: string;
|
||||
privacy: string; // "public", ...
|
||||
type: string; // "listed", "unlisted", ...
|
||||
contentRating: string; // "GA", "AP"
|
||||
accessPass: boolean;
|
||||
occupancy: number;
|
||||
capacity: number;
|
||||
expired: boolean;
|
||||
};
|
||||
|
||||
export class NoSuchImvuUser extends Error {
|
||||
constructor() {
|
||||
super("no such IMVU account");
|
||||
@@ -31,11 +47,22 @@ export class ImvuClient {
|
||||
byUsername(username: string): Promise<ImvuUser> {
|
||||
const name = username.trim();
|
||||
if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser());
|
||||
return this.get(`/user?username=${encodeURIComponent(name)}`);
|
||||
return this.get(`/user?username=${encodeURIComponent(name)}`).then(parse);
|
||||
}
|
||||
|
||||
byId(id: number): Promise<ImvuUser> {
|
||||
return this.get(`/user/user-${id}`);
|
||||
return this.get(`/user/user-${id}`).then(parse);
|
||||
}
|
||||
|
||||
/** A room by its instance ID ("<owner>-<n>"); undefined if there's no such room. */
|
||||
async room(instanceId: string): Promise<ImvuRoom | undefined> {
|
||||
if (!/^[0-9]{1,12}-[0-9]{1,12}$/.test(instanceId)) return undefined;
|
||||
try {
|
||||
return parseRoom(await this.get(`/room/room-${instanceId}`), instanceId);
|
||||
} catch (e) {
|
||||
if (e instanceof NoSuchImvuUser) return undefined;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async wait() {
|
||||
@@ -45,7 +72,7 @@ export class ImvuClient {
|
||||
if (at > now) await Bun.sleep(at - now);
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<ImvuUser> {
|
||||
private async get(path: string): Promise<unknown> {
|
||||
await this.wait();
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -58,10 +85,36 @@ export class ImvuClient {
|
||||
}
|
||||
if (response.status === 404) throw new NoSuchImvuUser();
|
||||
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||
return parse(await response.json());
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undefined {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
const entry = Object.entries(den).find(([key]) => key.endsWith(`/room/room-${instanceId}`));
|
||||
const d = entry?.[1].data;
|
||||
if (!d) return undefined;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
||||
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
let image = str(d.image_url);
|
||||
if (image.startsWith("//")) image = "https:" + image;
|
||||
if (!image.startsWith("https://")) image = "";
|
||||
return {
|
||||
instanceId,
|
||||
ownerId: num(d.customers_id) || Number(instanceId.split("-")[0]),
|
||||
name: str(d.name).slice(0, 200),
|
||||
description: str(d.description).slice(0, 2000),
|
||||
imageURL: image,
|
||||
privacy: str(d.privacy),
|
||||
type: str(d.type),
|
||||
contentRating: str(d.content_rating),
|
||||
accessPass: d.is_ap === true,
|
||||
occupancy: num(d.occupancy),
|
||||
capacity: num(d.capacity),
|
||||
expired: d.is_expired === true,
|
||||
};
|
||||
}
|
||||
|
||||
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
|
||||
export function parse(answer: unknown): ImvuUser {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
|
||||
@@ -22,11 +22,12 @@ const webURL = env.WEB_URL ?? "http://localhost:4100";
|
||||
|
||||
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
||||
const auth = createAuth(db, { apiURL, webURL, secret });
|
||||
const { app, age } = createApp({ db, auth, imvu: new ImvuClient() });
|
||||
const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient() });
|
||||
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
|
||||
|
||||
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time.
|
||||
setInterval(() => {
|
||||
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
||||
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
|
||||
}, 10 * 60 * 1000);
|
||||
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
||||
+127
-24
@@ -1,34 +1,69 @@
|
||||
// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the
|
||||
// community products it wears. Later, room channels.
|
||||
// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
|
||||
// products it wears, and the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
|
||||
// community products).
|
||||
//
|
||||
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
|
||||
// a bearer token):
|
||||
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
|
||||
// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now
|
||||
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
|
||||
// proxy -> hub {"type": "outfit", "items": [{"id", "name", "adult"}]} the community products it wears now
|
||||
// proxy -> hub {"type": "join", "chat": "1379947638", "room": "1-2"} the client is in this IMVU chat (room: the
|
||||
// IMVU room instance, for a room's chat)
|
||||
// proxy -> hub {"type": "leave", "chat": "1379947638"}
|
||||
// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]}
|
||||
// the other T5DE users in that chat and what they wear; sent on join and on every change. Adult
|
||||
// products only go to clients whose IMVU account counts as adult.
|
||||
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
|
||||
|
||||
export type Item = { id: string; name: string };
|
||||
export type Item = { id: string; name: string; adult: boolean };
|
||||
|
||||
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] };
|
||||
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
|
||||
|
||||
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
|
||||
type Session = { userId: string; imvuId: number; requested: number; since: Date; outfit: Item[]; close: () => void };
|
||||
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
|
||||
type Session = {
|
||||
userId: string;
|
||||
imvuId: number;
|
||||
requested: number;
|
||||
adult: boolean;
|
||||
since: Date;
|
||||
outfit: Item[];
|
||||
chats: Map<string, string>;
|
||||
lastSent: Map<string, string>;
|
||||
send: (message: object) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export type Owners = (imvuId: number) => Promise<string | undefined>;
|
||||
export type RealtimeDeps = {
|
||||
/** The person an IMVU account is linked to. */
|
||||
owner: (imvuId: number) => Promise<string | undefined>;
|
||||
/** Whether a person's IMVU account counts as adult (the age rule). */
|
||||
adult?: (userId: string, imvuId: number) => Promise<boolean>;
|
||||
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
|
||||
room?: (instanceId: string) => void;
|
||||
};
|
||||
|
||||
const maxChats = 16;
|
||||
const chatPattern = /^[0-9]{1,20}$/;
|
||||
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
|
||||
|
||||
export class Realtime {
|
||||
private sessions = new Map<string, Session>();
|
||||
private listeners = new Set<() => void>();
|
||||
private readonly deps: Required<RealtimeDeps>;
|
||||
|
||||
constructor(private readonly owner: Owners) {}
|
||||
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
|
||||
const d = typeof deps === "function" ? { owner: deps } : deps;
|
||||
this.deps = { adult: async () => false, room: () => {}, ...d };
|
||||
}
|
||||
|
||||
/** Calls fn on every change of presence or outfits; returns a function that stops it. */
|
||||
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
private changed() {
|
||||
private changed(chats: Iterable<string> = []) {
|
||||
for (const chat of new Set(chats)) this.sync(chat);
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
|
||||
@@ -39,7 +74,8 @@ export class Realtime {
|
||||
if (!s.imvuId) continue;
|
||||
const prev = byId.get(s.imvuId);
|
||||
if (prev && prev.since > s.since) continue;
|
||||
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] });
|
||||
const rooms = [...s.chats.values()].filter(Boolean);
|
||||
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit], rooms });
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
|
||||
}
|
||||
@@ -48,40 +84,57 @@ export class Realtime {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
open(id: string, userId: string, close: () => void) {
|
||||
this.sessions.set(id, { userId, imvuId: 0, requested: 0, since: new Date(), outfit: [], close });
|
||||
open(id: string, userId: string, close: () => void, send: (message: object) => void = () => {}) {
|
||||
this.sessions.set(id, {
|
||||
userId,
|
||||
imvuId: 0,
|
||||
requested: 0,
|
||||
adult: false,
|
||||
since: new Date(),
|
||||
outfit: [],
|
||||
chats: new Map(),
|
||||
lastSent: new Map(),
|
||||
send,
|
||||
close,
|
||||
});
|
||||
}
|
||||
|
||||
close(id: string) {
|
||||
const s = this.sessions.get(id);
|
||||
this.sessions.delete(id);
|
||||
if (s?.imvuId) this.changed();
|
||||
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
|
||||
}
|
||||
|
||||
/** A person just linked an IMVU account: their clients already using it count now. */
|
||||
linked(userId: string, imvuId: number) {
|
||||
async linked(userId: string, imvuId: number) {
|
||||
const chats: string[] = [];
|
||||
let changed = false;
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) {
|
||||
s.imvuId = imvuId;
|
||||
s.since = new Date();
|
||||
s.adult = await this.deps.adult(userId, imvuId);
|
||||
chats.push(...s.chats.keys());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.changed();
|
||||
if (changed) this.changed(chats);
|
||||
}
|
||||
|
||||
/** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */
|
||||
unlinked(userId: string, imvuId: number) {
|
||||
const chats: string[] = [];
|
||||
let changed = false;
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.userId === userId && s.imvuId === imvuId) {
|
||||
s.imvuId = 0;
|
||||
s.adult = false;
|
||||
s.outfit = [];
|
||||
chats.push(...s.chats.keys());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.changed();
|
||||
if (changed) this.changed(chats);
|
||||
}
|
||||
|
||||
/** Closes a person's connections (banned, or signed out of their computers). */
|
||||
@@ -89,27 +142,59 @@ export class Realtime {
|
||||
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
|
||||
}
|
||||
|
||||
/** Tells everyone in a chat about the other T5DE users there (only those it wasn't told already). */
|
||||
private sync(chat: string) {
|
||||
const members = [...this.sessions.values()].filter((s) => s.chats.has(chat));
|
||||
for (const viewer of members) {
|
||||
const avatars = new Map<number, Item[]>();
|
||||
for (const other of members) {
|
||||
if (!other.imvuId || other.imvuId === viewer.imvuId) continue;
|
||||
avatars.set(
|
||||
other.imvuId,
|
||||
other.outfit.filter((it) => !it.adult || viewer.adult),
|
||||
);
|
||||
}
|
||||
const message = {
|
||||
type: "chat",
|
||||
chat,
|
||||
avatars: [...avatars].map(([imvuId, items]) => ({ imvu_id: imvuId, items })),
|
||||
};
|
||||
const text = JSON.stringify(message);
|
||||
if (viewer.lastSent.get(chat) === text) continue;
|
||||
viewer.lastSent.set(chat, text);
|
||||
viewer.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles a message from a proxy; returns the reply to send, if any. */
|
||||
async message(id: string, raw: unknown): Promise<object | undefined> {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return;
|
||||
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { type?: unknown; imvu_id?: unknown; items?: unknown };
|
||||
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as {
|
||||
type?: unknown;
|
||||
imvu_id?: unknown;
|
||||
items?: unknown;
|
||||
chat?: unknown;
|
||||
room?: unknown;
|
||||
};
|
||||
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
|
||||
if (m.type === "session") {
|
||||
const imvuId = Number(m.imvu_id);
|
||||
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
||||
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined;
|
||||
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
|
||||
if (owner !== s.userId) {
|
||||
// Not this person's account: no presence, no outfit for it.
|
||||
s.imvuId = 0;
|
||||
s.adult = false;
|
||||
s.outfit = [];
|
||||
this.changed();
|
||||
this.changed(s.chats.keys());
|
||||
return { type: "error", message: "not_linked" };
|
||||
}
|
||||
s.imvuId = imvuId;
|
||||
s.since = new Date();
|
||||
s.outfit = [];
|
||||
this.changed();
|
||||
s.adult = await this.deps.adult(s.userId, imvuId);
|
||||
this.changed(s.chats.keys());
|
||||
return;
|
||||
}
|
||||
if (m.type === "outfit") {
|
||||
@@ -119,8 +204,26 @@ export class Realtime {
|
||||
s.outfit = items
|
||||
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
|
||||
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
|
||||
.map((it) => ({ id: it.id, name: it.name }));
|
||||
this.changed();
|
||||
.map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
|
||||
this.changed(s.chats.keys());
|
||||
return;
|
||||
}
|
||||
if (m.type === "join" || m.type === "leave") {
|
||||
const chat = typeof m.chat === "string" ? m.chat : String(m.chat ?? "");
|
||||
if (!chatPattern.test(chat)) return { type: "error", message: "bad chat" };
|
||||
if (m.type === "leave") {
|
||||
if (!s.chats.delete(chat)) return;
|
||||
s.lastSent.delete(chat);
|
||||
this.changed([chat]);
|
||||
return;
|
||||
}
|
||||
const room = typeof m.room === "string" && roomPattern.test(m.room) ? m.room : "";
|
||||
if (!s.chats.has(chat) && s.chats.size >= maxChats) return { type: "error", message: "too many chats" };
|
||||
s.chats.set(chat, room);
|
||||
s.lastSent.delete(chat); // tell it again, even if nothing changed
|
||||
if (s.imvuId) s.adult = await this.deps.adult(s.userId, s.imvuId);
|
||||
if (room) this.deps.room(room);
|
||||
this.changed([chat]);
|
||||
return;
|
||||
}
|
||||
return { type: "error", message: "unknown message type" };
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// The rooms list: IMVU rooms owned by linked accounts that chose to list them. The hub learns of a room when a T5DE
|
||||
// user enters it, and asks IMVU about it (owner, public and listed or not); it's checked again while it's in use,
|
||||
// so a room made unlisted or closed drops off the list.
|
||||
import { and, desc, eq, gt, lt, schema, type Database } from "@t5de/db";
|
||||
import type { ImvuClient } from "./imvu";
|
||||
|
||||
const { room, imvuLink } = schema;
|
||||
|
||||
export type Room = typeof room.$inferSelect;
|
||||
|
||||
const recheckAfter = 60 * 60 * 1000;
|
||||
// Rooms nobody entered for this long aren't checked again or listed.
|
||||
const forgetAfter = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Whether IMVU says anyone may find and enter the room. */
|
||||
export const isListable = (r: Pick<Room, "privacy" | "type" | "expired">) =>
|
||||
r.privacy === "public" && r.type === "listed" && !r.expired;
|
||||
|
||||
/** Whether the room is for adults (Access Pass rooms). */
|
||||
export const isAdultRoom = (r: Pick<Room, "contentRating" | "accessPass">) => r.accessPass || r.contentRating === "AP";
|
||||
|
||||
export class Rooms {
|
||||
private checking = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly imvu: ImvuClient,
|
||||
) {}
|
||||
|
||||
/** A T5DE user entered a room: remember it if its owner is linked, asking IMVU about it if it's due. */
|
||||
async seen(instanceId: string) {
|
||||
const ownerId = Number(instanceId.split("-")[0]);
|
||||
const [owner] = await this.db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.imvuId, ownerId));
|
||||
if (!owner) return;
|
||||
const [known] = await this.db.select().from(room).where(eq(room.instanceId, instanceId));
|
||||
if (known && Date.now() - known.checkedAt.getTime() < recheckAfter) {
|
||||
await this.db.update(room).set({ seenAt: new Date() }).where(eq(room.instanceId, instanceId));
|
||||
return;
|
||||
}
|
||||
await this.check(instanceId);
|
||||
}
|
||||
|
||||
/** Asks IMVU about a room again, and records what it says (or forgets the room if it's gone). */
|
||||
async check(instanceId: string) {
|
||||
if (this.checking.has(instanceId)) return;
|
||||
this.checking.add(instanceId);
|
||||
try {
|
||||
const r = await this.imvu.room(instanceId);
|
||||
if (!r || String(r.ownerId) !== instanceId.split("-")[0]) {
|
||||
await this.db.delete(room).where(eq(room.instanceId, instanceId));
|
||||
return;
|
||||
}
|
||||
const values = {
|
||||
ownerImvuId: r.ownerId,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
imageURL: r.imageURL,
|
||||
privacy: r.privacy,
|
||||
type: r.type,
|
||||
contentRating: r.contentRating,
|
||||
accessPass: r.accessPass,
|
||||
occupancy: r.occupancy,
|
||||
capacity: r.capacity,
|
||||
expired: r.expired,
|
||||
checkedAt: new Date(),
|
||||
seenAt: new Date(),
|
||||
};
|
||||
await this.db
|
||||
.insert(room)
|
||||
.values({ instanceId, ...values })
|
||||
.onConflictDoUpdate({ target: room.instanceId, set: values });
|
||||
} finally {
|
||||
this.checking.delete(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks again rooms in use whose answer is old (at most limit). */
|
||||
async recheckDue(limit = 20): Promise<number> {
|
||||
const due = await this.db
|
||||
.select({ instanceId: room.instanceId })
|
||||
.from(room)
|
||||
.where(and(lt(room.checkedAt, new Date(Date.now() - recheckAfter)), gt(room.seenAt, new Date(Date.now() - forgetAfter))))
|
||||
.limit(limit);
|
||||
let n = 0;
|
||||
for (const r of due) {
|
||||
try {
|
||||
await this.check(r.instanceId);
|
||||
n++;
|
||||
} catch {
|
||||
// IMVU unavailable: try again next time.
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** The rooms to list: listable, owner chose to list, entered lately; with the owner's IMVU name. */
|
||||
async listed() {
|
||||
const rows = await this.db
|
||||
.select({ room, owner: imvuLink.username })
|
||||
.from(room)
|
||||
.innerJoin(imvuLink, eq(imvuLink.imvuId, room.ownerImvuId))
|
||||
.where(and(eq(imvuLink.listRooms, true), gt(room.seenAt, new Date(Date.now() - forgetAfter))))
|
||||
.orderBy(desc(room.seenAt));
|
||||
return rows.filter((r) => isListable(r.room));
|
||||
}
|
||||
|
||||
/** Forgets an IMVU account's rooms (it was unlinked). */
|
||||
async forgetOwner(imvuId: number) {
|
||||
await this.db.delete(room).where(eq(room.ownerImvuId, imvuId));
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,7 @@ describe("T5DE's proxy", () => {
|
||||
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
|
||||
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
|
||||
expect(online).toEqual([
|
||||
{ imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat" }] },
|
||||
{ imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat", adult: false }] },
|
||||
]);
|
||||
|
||||
// A ban closes the connection and ends the proxy's session.
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// The room channel (T5DE users in the same IMVU chat see each other's community products) and the rooms list.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
const rooms = new Map<string, Record<string, unknown>>();
|
||||
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const room = url.pathname.match(/^\/room\/room-(\d+-\d+)$/);
|
||||
if (room) {
|
||||
const r = rooms.get(room[1]!);
|
||||
return Response.json({
|
||||
denormalized: r ? { [`https://api.imvu.com/room/room-${room[1]}`]: { data: r } } : {},
|
||||
});
|
||||
}
|
||||
const u = [...accounts.values()].find(
|
||||
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
|
||||
);
|
||||
return Response.json({
|
||||
denormalized: u
|
||||
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
|
||||
: {},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const imvuRoom = (owner: number, n: number, over: Record<string, unknown> = {}) =>
|
||||
rooms.set(`${owner}-${n}`, {
|
||||
name: `Room ${n}`,
|
||||
description: "<b>hi</b>",
|
||||
type: "listed",
|
||||
privacy: "public",
|
||||
image_url: "//webasset-akm.imvu.com/room.gif",
|
||||
occupancy: 2,
|
||||
capacity: 10,
|
||||
content_rating: "GA",
|
||||
is_ap: false,
|
||||
customers_id: owner,
|
||||
is_expired: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
let stop: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
|
||||
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
|
||||
const server = hub.app.server as unknown as { port: number; stop: () => void };
|
||||
stop = () => server.stop();
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
stop();
|
||||
await close();
|
||||
});
|
||||
|
||||
type Person = { cookie: string; token: string };
|
||||
|
||||
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, json: text ? JSON.parse(text) : undefined, res };
|
||||
}
|
||||
|
||||
/** Signs a person up with one linked IMVU account; token is a session token for their proxy. */
|
||||
async function person(name: string, imvu: { id: number; ageVerified?: boolean }): Promise<Person> {
|
||||
const res = await fetch(base + "/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { Origin: webURL, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
|
||||
});
|
||||
const p = {
|
||||
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
|
||||
token: res.headers.get("set-auth-token") ?? "",
|
||||
};
|
||||
expect(p.token).not.toBe("");
|
||||
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false });
|
||||
const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
|
||||
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
|
||||
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A proxy's live connection: everything the hub sent it, and the latest "chat" message per chat. */
|
||||
async function proxy(p: Person, imvuId: number) {
|
||||
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
|
||||
const got: any[] = [];
|
||||
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const send = (m: object) => ws.send(JSON.stringify(m));
|
||||
send({ type: "session", imvu_id: imvuId });
|
||||
return {
|
||||
ws,
|
||||
got,
|
||||
send,
|
||||
chat: (chat: string) => got.filter((m) => m.type === "chat" && m.chat === chat).at(-1),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(ok: () => boolean) {
|
||||
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
|
||||
expect(ok()).toBe(true);
|
||||
}
|
||||
|
||||
test("T5DE users in the same chat see each other's community products; adult ones only if adult", async () => {
|
||||
const fiona = await person("fiona", { id: 101, ageVerified: true });
|
||||
const gus = await person("gus", { id: 102 });
|
||||
const f = await proxy(fiona, 101);
|
||||
const g = await proxy(gus, 102);
|
||||
f.send({ type: "outfit", items: [{ id: "coat", name: "Coat" }, { id: "lace", name: "Lace", adult: true }] });
|
||||
g.send({ type: "outfit", items: [{ id: "hat", name: "Hat" }] });
|
||||
|
||||
f.send({ type: "join", chat: "555", room: "101-1" });
|
||||
g.send({ type: "join", chat: "555", room: "101-1" });
|
||||
await waitFor(() => g.chat("555")?.avatars.length === 1 && f.chat("555")?.avatars.length === 1);
|
||||
expect(g.chat("555").avatars).toEqual([{ imvu_id: 101, items: [{ id: "coat", name: "Coat", adult: false }] }]);
|
||||
expect(f.chat("555").avatars).toEqual([{ imvu_id: 102, items: [{ id: "hat", name: "Hat", adult: false }] }]);
|
||||
|
||||
// Outfit changes reach the others in the chat; a chat they're not in hears nothing.
|
||||
g.send({ type: "join", chat: "777" });
|
||||
g.send({ type: "outfit", items: [] });
|
||||
await waitFor(() => f.chat("555")?.avatars[0]?.items.length === 0);
|
||||
expect(f.chat("777")).toBeUndefined();
|
||||
|
||||
// Leaving (or disconnecting) takes them off the others' list.
|
||||
g.send({ type: "leave", chat: "555" });
|
||||
await waitFor(() => f.chat("555")?.avatars.length === 0);
|
||||
g.send({ type: "join", chat: "555" });
|
||||
await waitFor(() => f.chat("555")?.avatars.length === 1);
|
||||
g.ws.close();
|
||||
await waitFor(() => f.chat("555")?.avatars.length === 0);
|
||||
|
||||
// Nonsense is refused.
|
||||
f.send({ type: "join", chat: "../x" });
|
||||
await waitFor(() => f.got.some((m) => m.message === "bad chat"));
|
||||
f.ws.close();
|
||||
});
|
||||
|
||||
test("rooms are listed when public and listed, and their owner chose to list them", async () => {
|
||||
const hana = await person("hana", { id: 201, ageVerified: true });
|
||||
const ivan = await person("ivan", { id: 202 });
|
||||
imvuRoom(201, 1);
|
||||
imvuRoom(201, 2, { type: "unlisted" });
|
||||
imvuRoom(201, 3, { content_rating: "AP", is_ap: true });
|
||||
imvuRoom(201, 4, { privacy: "closed" });
|
||||
const h = await proxy(hana, 201);
|
||||
for (const [chat, room] of [["1", "201-1"], ["2", "201-2"], ["3", "201-3"], ["4", "201-4"]]) h.send({ type: "join", chat, room });
|
||||
// A room whose owner isn't on T5DE is never stored.
|
||||
imvuRoom(999, 1);
|
||||
h.send({ type: "join", chat: "9", room: "999-1" });
|
||||
|
||||
const names = async (p?: Person) => ((await call(p, "GET", "/api/v1/rooms")).json.rooms as any[]).map((r) => r.name);
|
||||
// The hub asks IMVU about each (spaced out); until Hana chooses to list them, none are listed.
|
||||
for (let i = 0; i < 300 && (await db.select().from(schema.room)).length < 4; i++) await Bun.sleep(10);
|
||||
expect((await db.select().from(schema.room)).map((r) => r.instanceId).sort()).toEqual(["201-1", "201-2", "201-3", "201-4"]);
|
||||
expect(await names()).toEqual([]);
|
||||
await call(hana, "PATCH", "/api/v1/account/links/201", { listRooms: true });
|
||||
expect(await names()).toEqual(["Room 1"]);
|
||||
expect(await names(ivan)).toEqual(["Room 1"]); // Ivan isn't adult
|
||||
expect((await names(hana)).sort()).toEqual(["Room 1", "Room 3"]);
|
||||
|
||||
const [listed] = (await call(undefined, "GET", "/api/v1/rooms")).json.rooms;
|
||||
expect(listed).toMatchObject({
|
||||
instanceId: "201-1",
|
||||
description: "<b>hi</b>", // the website shows it as text
|
||||
imageURL: "https://webasset-akm.imvu.com/room.gif",
|
||||
owner: { imvuId: 201, username: "hanaAv" },
|
||||
url: "https://go.imvu.com/chat/room-201-1",
|
||||
t5de: [], // Hana is there, but doesn't show she's online
|
||||
});
|
||||
await call(hana, "PATCH", "/api/v1/account/links/201", { showPresence: true });
|
||||
expect((await call(undefined, "GET", "/api/v1/rooms")).json.rooms[0].t5de).toEqual([{ imvuId: 201, username: "hanaAv", badge: false }]);
|
||||
|
||||
// Unlinking forgets the account's rooms.
|
||||
await call(hana, "DELETE", "/api/v1/account/links/201");
|
||||
expect(await names(hana)).toEqual([]);
|
||||
h.ws.close();
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export async function linkAction(_: ActionState, form: FormData): Promise<Action
|
||||
|
||||
export async function saveSettings(
|
||||
imvu: number,
|
||||
settings: { showBadge?: boolean; showPresence?: boolean },
|
||||
settings: { showBadge?: boolean; showPresence?: boolean; listRooms?: boolean },
|
||||
): Promise<ActionState> {
|
||||
return attempt(async () => {
|
||||
unwrap(await api.account.links({ imvu }).patch(settings));
|
||||
|
||||
@@ -27,7 +27,12 @@ export function LinkCard({ link }: { link: Link }) {
|
||||
</div>
|
||||
</div>
|
||||
<Outfit items={link.outfit} />
|
||||
<LinkSettings imvuId={link.imvuId} showBadge={link.showBadge} showPresence={link.showPresence} />
|
||||
<LinkSettings
|
||||
imvuId={link.imvuId}
|
||||
showBadge={link.showBadge}
|
||||
showPresence={link.showPresence}
|
||||
listRooms={link.listRooms}
|
||||
/>
|
||||
</div>
|
||||
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
|
||||
<div className="flex gap-2 sm:justify-end">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import type { ActionState } from "@/lib/actions";
|
||||
import { saveSettings } from "./actions";
|
||||
|
||||
type Settings = { showBadge: boolean; showPresence: boolean };
|
||||
type Settings = { showBadge: boolean; showPresence: boolean; listRooms: boolean };
|
||||
|
||||
export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) {
|
||||
const [settings, setOptimistic] = useOptimistic(saved);
|
||||
@@ -29,6 +29,10 @@ export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }
|
||||
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
|
||||
Show when I'm online in T5DE
|
||||
</label>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Switch checked={settings.listRooms} onCheckedChange={change("listRooms")} disabled={pending} />
|
||||
List my public rooms
|
||||
</label>
|
||||
<FormMessage state={state} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
|
||||
export const metadata: Metadata = { title: "Rooms" };
|
||||
|
||||
export default async function Rooms() {
|
||||
const { rooms } = await api.rooms.get().then(unwrap);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Rooms">Public IMVU rooms of T5DE users.</PageHeader>
|
||||
{rooms.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{rooms.map((r) => (
|
||||
<li key={r.instanceId}>
|
||||
<Card className="h-full overflow-hidden py-0">
|
||||
{r.imageURL && (
|
||||
// IMVU's own image of the room, from IMVU.
|
||||
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
||||
)}
|
||||
<CardContent className="space-y-3 pb-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
|
||||
{r.name}
|
||||
</a>
|
||||
{r.adult && <Tag tone="adult">AP</Tag>}
|
||||
{r.t5de.length > 0 && (
|
||||
<Tag tone="online" dot>
|
||||
{r.t5de.length} on T5DE
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{r.owner.username} · {r.occupancy}/{r.capacity}
|
||||
</p>
|
||||
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
||||
{r.t5de.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export async function Header() {
|
||||
</Link>
|
||||
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||
<NavLink href="/online">Online</NavLink>
|
||||
<NavLink href="/rooms">Rooms</NavLink>
|
||||
{me && <NavLink href="/account">Account</NavLink>}
|
||||
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
||||
</nav>
|
||||
|
||||
Reference in new issue
Block a user