diff --git a/assets/openapi.json b/assets/openapi.json index b7d25d70ef..3c5430ba84 100644 --- a/assets/openapi.json +++ b/assets/openapi.json @@ -279,6 +279,62 @@ "status": { "$ref": "#/components/schemas/Status" }, + "client_status": { + "type": "object", + "properties": { + "desktop": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "mobile": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "web": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "embedded": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "vr": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + } + }, + "additionalProperties": false + }, "activities": { "type": "array", "items": { @@ -9411,6 +9467,9 @@ }, "embedded": { "type": "string" + }, + "vr": { + "type": "string" } } }, diff --git a/assets/schemas.json b/assets/schemas.json index 55de553817..e1794ad93d 100644 --- a/assets/schemas.json +++ b/assets/schemas.json @@ -266,6 +266,62 @@ "status": { "$ref": "#/definitions/Status" }, + "client_status": { + "type": "object", + "properties": { + "desktop": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "mobile": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "web": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "embedded": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + }, + "vr": { + "enum": [ + "dnd", + "idle", + "invisible", + "offline", + "online" + ], + "type": "string" + } + }, + "additionalProperties": false + }, "activities": { "type": "array", "items": { @@ -10009,6 +10065,9 @@ }, "embedded": { "type": "string" + }, + "vr": { + "type": "string" } }, "additionalProperties": false, diff --git a/package-lock.json b/package-lock.json index 4e064272f1..b875e9fa70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1207,8 +1207,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@spacebarchat/spacebar-webrtc-types/-/spacebar-webrtc-types-1.0.1.tgz", "integrity": "sha512-WfBRUN2520w7o5vU9HNDug9alNvydQP7H/jwAy8LeHTHwlMMUw/60A54FQDIAtsahw787fR3QZ83UGjhKDzDTg==", - "license": "AGPL-3.0-only", - "peer": true + "license": "AGPL-3.0-only" }, "node_modules/@sqltools/formatter": { "version": "1.2.5", @@ -1478,7 +1477,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -1596,7 +1594,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1731,7 +1728,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -2144,7 +2140,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3382,7 +3377,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5837,7 +5831,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -5911,7 +5904,6 @@ "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.14.0.tgz", "integrity": "sha512-B1LLxgqngAATPciOPYYKyaQfsw5wyP6BZq6nHqQOC5QaaEBsfW/0OBwWUga+knCAqENMeoow9I8Zgi2m3P9rWw==", "license": "MIT", - "peer": true, "dependencies": { "pg-cursor": "^2.19.0" }, @@ -6077,7 +6069,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6940,7 +6931,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7056,7 +7046,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7322,7 +7311,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/api/routes/users/@me/settings-proto/1.ts b/src/api/routes/users/@me/settings-proto/1.ts index 510af975bc..294aac0243 100644 --- a/src/api/routes/users/@me/settings-proto/1.ts +++ b/src/api/routes/users/@me/settings-proto/1.ts @@ -18,7 +18,8 @@ import { route } from "@spacebar/api"; import { Request, Response, Router } from "express"; -import { emitEvent, OrmUtils, UserSettingsProtos } from "@spacebar/util"; +import { emitEvent, OrmUtils, PresenceUpdateEvent, Session, User, UserSettings, UserSettingsProtos } from "@spacebar/util"; +import { getMostRelevantSession } from "@spacebar/gateway"; import { PreloadedUserSettings } from "discord-protos"; import { JsonValue } from "@protobuf-ts/runtime"; import { SettingsProtoJsonResponse, SettingsProtoResponse, SettingsProtoUpdateJsonSchema, SettingsProtoUpdateSchema } from "@spacebar/schemas"; @@ -162,6 +163,46 @@ async function patchUserSettings(userId: string, updatedSettings: PreloadedUserS userSettings.userSettings = settings; await userSettings.save(); + const settingsJson = PreloadedUserSettings.toJson(settings); + const protoStatus = (settingsJson as { status?: { status?: string } } | undefined)?.status?.status; + const allowedStatuses = ["online", "idle", "dnd", "offline", "invisible"] as const; + type AllowedStatus = (typeof allowedStatuses)[number]; + + if (protoStatus && allowedStatuses.includes(protoStatus as AllowedStatus)) { + const nextStatus = protoStatus as AllowedStatus; + + const user = await User.findOne({ + where: { id: userId, bot: false }, + relations: { settings: true }, + }); + + if (user) { + const currentStatus = user.settings?.status; + if (currentStatus !== nextStatus) { + if (!user.settings) user.settings = UserSettings.create({ status: nextStatus }); + else user.settings.status = nextStatus; + await user.settings.save(); + + await Session.update({ user_id: userId }, { status: nextStatus }); + const sessions = await Session.find({ where: { user_id: userId } }); + const session = getMostRelevantSession(sessions); + + if (session) { + await emitEvent({ + event: "PRESENCE_UPDATE", + user_id: userId, + data: { + user: user.toPublicUser(), + activities: session.activities, + client_status: session.client_status, + status: session.getPublicStatus(), + }, + } satisfies PresenceUpdateEvent); + } + } + } + } + await emitEvent({ user_id: userId, event: "USER_SETTINGS_PROTO_UPDATE", diff --git a/src/api/routes/users/@me/settings.ts b/src/api/routes/users/@me/settings.ts index 5136c4abcf..e48877bc1d 100644 --- a/src/api/routes/users/@me/settings.ts +++ b/src/api/routes/users/@me/settings.ts @@ -18,6 +18,7 @@ import { route } from "@spacebar/api"; import { User, UserSettings, emitEvent, Session, PresenceUpdateEvent } from "@spacebar/util"; +import { getMostRelevantSession } from "@spacebar/gateway"; import { Request, Response, Router } from "express"; import { UserSettingsUpdateSchema } from "@spacebar/schemas"; @@ -75,25 +76,24 @@ router.patch( await user.settings.save(); await user.save(); if (body.status) { - const [session] = (await Session.find({ + await Session.update({ user_id: user.id }, { status: body.status }); + + const sessions = await Session.find({ where: { user_id: user.id }, - })) as [Session | undefined]; - if (session) { - session.status = body.status; + }); + const session = getMostRelevantSession(sessions); - await Promise.all([ - emitEvent({ - event: "PRESENCE_UPDATE", - user_id: user.id, - data: { - user: user.toPublicUser(), - activities: session.activities, - client_status: session?.client_status, - status: session.getPublicStatus(), - }, - } satisfies PresenceUpdateEvent), - session.save(), - ]); + if (session) { + await emitEvent({ + event: "PRESENCE_UPDATE", + user_id: user.id, + data: { + user: user.toPublicUser(), + activities: session.activities, + client_status: session.client_status, + status: session.getPublicStatus(), + }, + } satisfies PresenceUpdateEvent); } } diff --git a/src/gateway/events/Close.ts b/src/gateway/events/Close.ts index 8c37525314..ed779ae0c5 100644 --- a/src/gateway/events/Close.ts +++ b/src/gateway/events/Close.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import { WebSocket } from "@spacebar/gateway"; +import { WebSocket, getMostRelevantSession } from "@spacebar/gateway"; import { emitEvent, Member, PresenceUpdateEvent, Session, SessionsReplace, User, VoiceState, VoiceStateUpdateEvent } from "@spacebar/util"; export async function Close(this: WebSocket, code: number, reason: Buffer) { @@ -68,6 +68,17 @@ export async function Close(this: WebSocket, code: number, reason: Buffer) { } if (this.user_id) { + if (this.session_id) { + await Session.update( + { session_id: this.session_id }, + { + status: "offline", + client_status: {}, + activities: [], + }, + ); + } + const sessions = await Session.find({ where: { user_id: this.user_id }, }); @@ -76,11 +87,15 @@ export async function Close(this: WebSocket, code: number, reason: Buffer) { user_id: this.user_id, data: sessions.map((x) => x.toPrivateGatewayDeviceInfo()), } as SessionsReplace); - const session = sessions[0] || { - activities: [], - client_status: {}, - status: "offline", - }; + const mostRelevantSession = getMostRelevantSession(sessions); + const session = mostRelevantSession + ? mostRelevantSession + : { + activities: [], + client_status: {}, + status: "offline", + getPublicStatus: () => "offline", + }; const user = await User.getPublicUser(this.user_id).catch(() => undefined); @@ -93,7 +108,7 @@ export async function Close(this: WebSocket, code: number, reason: Buffer) { user: user, activities: session.activities, client_status: session?.client_status, - status: session.getPublicStatus?.() ?? session.status, + status: (session.getPublicStatus?.() ?? session.status) as import("@spacebar/util").Status, }, } satisfies PresenceUpdateEvent); } diff --git a/src/gateway/listener/listener.ts b/src/gateway/listener/listener.ts index 149b0edb72..805e86111f 100644 --- a/src/gateway/listener/listener.ts +++ b/src/gateway/listener/listener.ts @@ -47,10 +47,12 @@ import { bgRedBright } from "picocolors"; export function handlePresenceUpdate(this: WebSocket, { event, acknowledge, data }: EventOpts) { acknowledge?.(); if (event === EVENTEnum.PresenceUpdate) { + const payloadData = data.user.id === this.user_id && this.session?.status === "invisible" && data.status === "offline" ? { ...data, status: "invisible" } : data; + return Send(this, { op: OPCODES.Dispatch, t: event, - d: data, + d: payloadData, s: this.sequence++, }); } @@ -378,10 +380,15 @@ async function consume(this: WebSocket, opts: EventOpts) { } } + const payloadData = + event === "PRESENCE_UPDATE" && data?.user?.id === this.user_id && this.session?.status === "invisible" && data.status === "offline" + ? { ...data, status: "invisible" } + : data; + await Send(this, { op: OPCODES.Dispatch, t: event, - d: data, + d: payloadData, s: this.sequence++, }); } diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts index 012632d39f..6c5cc75cff 100644 --- a/src/gateway/opcodes/Identify.ts +++ b/src/gateway/opcodes/Identify.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import { Capabilities, CLOSECODES, OPCODES, Payload, Send, setupListener, WebSocket } from "@spacebar/gateway"; +import { Capabilities, CLOSECODES, OPCODES, Payload, Send, getMostRelevantSession, setupListener, WebSocket } from "@spacebar/gateway"; import { arrayGroupBy, Application, @@ -162,12 +162,30 @@ export async function onIdentify(this: WebSocket, data: Payload) { this.session_id = session.session_id; this.session = session; - this.session.status = identify.presence?.status || "online"; + + const statusValues = ["online", "idle", "dnd", "offline", "invisible"] as const; + type StatusValue = (typeof statusValues)[number]; + + const incomingStatus = identify.presence?.status as string | undefined; + const status = statusValues.includes(incomingStatus as StatusValue) ? (incomingStatus as StatusValue) : undefined; + this.session.status = incomingStatus === "unknown" ? (session.status && session.status !== "offline" ? session.status : "online") : (status ?? "online"); + this.session.last_seen = new Date(); this.session.client_info ??= {}; this.session.client_info.platform = identify.properties?.$device ?? identify.properties?.$device; this.session.client_info.os = identify.properties?.os || identify.properties?.$os; - this.session.client_status = {}; + + this.session.client_status ??= {}; + if (identify.presence?.client_status) { + for (const key of ["desktop", "mobile", "web", "embedded", "vr"] as const) { + const value = identify.presence.client_status[key] as string | undefined; + if (value === undefined || value === "unknown") continue; + if (statusValues.includes(value as StatusValue)) { + this.session.client_status[key] = value as StatusValue; + } + } + } + this.session.activities = identify.presence?.activities ?? []; // TODO: validation if (this.ipAddress && this.ipAddress !== this.session.last_seen_ip) { @@ -290,6 +308,14 @@ export async function onIdentify(this: WebSocket, data: Payload) { user.relationships = relationships; user.settings = settings; + if (incomingStatus === "unknown") { + const settingsStatus = user.settings?.status ?? "online"; + if (this.session.status !== settingsStatus) { + this.session.status = settingsStatus; + await Session.update({ session_id: this.session_id }, { status: settingsStatus }); + } + } + const userMetaQueryTime = taskSw.getElapsedAndReset(); const memberGuildIds = members.map((m) => m.guild_id); @@ -573,7 +599,9 @@ export async function onIdentify(this: WebSocket, data: Payload) { const appendRelationshipsTime = taskSw.getElapsedAndReset(); // Send SESSIONS_REPLACE and PRESENCE_UPDATE - const allSessions = sessions.concat(this.session!).map((x) => x.toPrivateGatewayDeviceInfo()); + const sessionsWithCurrent = sessions.concat(this.session!); + const allSessions = sessionsWithCurrent.map((x) => x.toPrivateGatewayDeviceInfo()); + const mostRelevantSession = getMostRelevantSession(sessionsWithCurrent); const findAndGenerateSessionReplaceTime = taskSw.getElapsedAndReset(); const [{ elapsed: emitSessionsReplaceTime }, { elapsed: emitPresenceUpdateTime }] = await Promise.all([ @@ -590,9 +618,9 @@ export async function onIdentify(this: WebSocket, data: Payload) { user_id: this.user_id, data: { user: user.toPublicUser(), - activities: this.session!.activities, - client_status: this.session!.client_status, - status: this.session!.getPublicStatus(), + activities: mostRelevantSession.activities, + client_status: mostRelevantSession.client_status, + status: mostRelevantSession.getPublicStatus(), }, } satisfies PresenceUpdateEvent), ), diff --git a/src/gateway/opcodes/LazyRequest.ts b/src/gateway/opcodes/LazyRequest.ts index cb465cf072..6fb2032a58 100644 --- a/src/gateway/opcodes/LazyRequest.ts +++ b/src/gateway/opcodes/LazyRequest.ts @@ -96,12 +96,6 @@ async function getMembers(guild_id: string, range: [number, number]) { const session: Session | undefined = getMostRelevantSession(member.user.sessions); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (session?.status == "unknown") { - session.status = member?.user?.settings?.status || "online"; - } - const item = { member: { ...member, @@ -111,7 +105,7 @@ async function getMembers(guild_id: string, range: [number, number]) { activities: session?.activities || [], user: { id: member.user.id }, client_status: session?.client_status, - status: session?.status, + status: session?.getPublicStatus() || "offline", }, }, }; diff --git a/src/gateway/opcodes/PresenceUpdate.ts b/src/gateway/opcodes/PresenceUpdate.ts index ca52af2a26..c5a0575d76 100644 --- a/src/gateway/opcodes/PresenceUpdate.ts +++ b/src/gateway/opcodes/PresenceUpdate.ts @@ -26,10 +26,23 @@ export async function onPresenceUpdate(this: WebSocket, { d }: Payload) { check.call(this, ActivitySchema, d); const presence = d as ActivitySchema; - await Session.update({ session_id: this.session_id }, { status: presence.status, activities: presence.activities }); + const statusValues = ["online", "idle", "dnd", "invisible"] as const; + type StatusValue = (typeof statusValues)[number]; - const session = await Session.findOneOrFail({ - select: { client_status: true }, + const incomingStatus = presence.status as string | undefined; + const nextStatus = statusValues.includes(incomingStatus as StatusValue) ? (incomingStatus as StatusValue) : undefined; + + const updatePayload: { status?: StatusValue; activities?: ActivitySchema["activities"] } = { + activities: presence.activities, + }; + + if (incomingStatus !== "unknown" && nextStatus) { + updatePayload.status = nextStatus; + } + + await Session.update({ session_id: this.session_id }, updatePayload); + + const session = await Session.findOne({ where: { session_id: this.session_id }, }); @@ -38,9 +51,9 @@ export async function onPresenceUpdate(this: WebSocket, { d }: Payload) { user_id: this.user_id, data: { user: await User.getPublicUser(this.user_id), - status: session.getPublicStatus(), - activities: presence.activities ?? [], - client_status: session.client_status, + status: session?.getPublicStatus() ?? "offline", + activities: session?.activities ?? [], + client_status: session?.client_status ?? {}, }, } satisfies PresenceUpdateEvent); diff --git a/src/gateway/opcodes/RequestGuildMembers.ts b/src/gateway/opcodes/RequestGuildMembers.ts index fbb249d30d..315bcb7e45 100644 --- a/src/gateway/opcodes/RequestGuildMembers.ts +++ b/src/gateway/opcodes/RequestGuildMembers.ts @@ -16,10 +16,10 @@ along with this program. If not, see . */ -import { Config, DateBuilder, getDatabase, getPermission, GuildMembersChunkEvent, Member, Presence, Session } from "@spacebar/util"; -import { WebSocket, Payload, OPCODES, Send, handleOffloadedGatewayRequest } from "@spacebar/gateway"; +import { Config, getDatabase, getPermission, GuildMembersChunkEvent, Member, Presence, Session } from "@spacebar/util"; +import { WebSocket, Payload, OPCODES, Send, getMostRelevantSession, handleOffloadedGatewayRequest } from "@spacebar/gateway"; import { check } from "./instanceOf"; -import { FindManyOptions, ILike, In, MoreThan } from "typeorm"; +import { FindManyOptions, ILike, In } from "typeorm"; import { RequestGuildMembersSchema } from "@spacebar/schemas"; export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) { @@ -140,15 +140,13 @@ export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) { let notFound: string[] = []; if (user_ids && user_ids.length > 0) notFound = user_ids.filter((id) => !members.some((member) => member.id == id)); - const recentlyActiveSince = new DateBuilder().addMinutes(-15).build(); - while (members.length > 0) { const chunk: Member[] = members.splice(0, chunkSize); let presenceList: Presence[] = []; if (presences) { const sessions = await Session.find({ - where: { user_id: In(chunk.map((m) => m.id)), is_admin_session: false, last_seen: MoreThan(recentlyActiveSince) }, + where: { user_id: In(chunk.map((m) => m.id)), is_admin_session: false }, select: { user: true, status: true, @@ -158,13 +156,16 @@ export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) { relations: { user: true }, }); - const foundUids = new Set(); - presenceList = sessions - .filter((s) => { - if (foundUids.has(s.user.id)) return false; - foundUids.add(s.user.id); - return true; - }) + const sessionsByUserId = new Map(); + for (const session of sessions) { + const uid = session.user.id; + if (!sessionsByUserId.has(uid)) sessionsByUserId.set(uid, []); + sessionsByUserId.get(uid)!.push(session); + } + + presenceList = Array.from(sessionsByUserId.values()) + .map((userSessions) => getMostRelevantSession(userSessions)) + .filter((session): session is Session => !!session) .map((session) => ({ user: session.user.toPublicUser(), status: session.getPublicStatus(), diff --git a/src/gateway/util/SessionUtils.ts b/src/gateway/util/SessionUtils.ts index 9d2f01b335..c9e13ed133 100644 --- a/src/gateway/util/SessionUtils.ts +++ b/src/gateway/util/SessionUtils.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import { Session } from "@spacebar/util"; +import { Session, Status } from "@spacebar/util"; export function genSessionId() { return genRanHex(32); @@ -30,18 +30,22 @@ function genRanHex(size: number) { return [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join(""); } -export function getMostRelevantSession(sessions: Session[]) { - const statusMap = { +export function getMostRelevantSession(sessions: Session[]): Session { + const statusPriority: Record = { online: 0, idle: 1, dnd: 2, invisible: 3, offline: 4, }; - // sort sessions by relevance - sessions = sessions.sort((a, b) => { - return statusMap[a.status] - statusMap[b.status] + ((a.activities?.length ?? 0) - (b.activities?.length ?? 0)) * 2; - }); - return sessions[0]; + const getPriority = (status: unknown) => { + return statusPriority[(status as Status) ?? "offline"] ?? 5; + }; + + return sessions.slice().sort((a, b) => { + const statusDiff = getPriority(a.status) - getPriority(b.status); + const activityDiff = (b.activities?.length ?? 0) - (a.activities?.length ?? 0); + return statusDiff || activityDiff; + })[0]; } diff --git a/src/schemas/uncategorised/ActivitySchema.ts b/src/schemas/uncategorised/ActivitySchema.ts index e54948fcaf..36675ed06f 100644 --- a/src/schemas/uncategorised/ActivitySchema.ts +++ b/src/schemas/uncategorised/ActivitySchema.ts @@ -21,6 +21,13 @@ import { Activity, Status } from "@spacebar/util"; export const ActivitySchema = { $afk: Boolean, status: String, + client_status: { + desktop: String, + mobile: String, + web: String, + embedded: String, + vr: String, + }, $activities: [ { name: String, @@ -75,6 +82,13 @@ export const ActivitySchema = { export interface ActivitySchema { afk?: boolean; status: Status; + client_status?: { + desktop?: Status; + mobile?: Status; + web?: Status; + embedded?: Status; + vr?: Status; + }; activities?: Activity[]; since?: number; // unix time (in milliseconds) of when the client went idle, or null if the client is not idle } diff --git a/src/util/entities/Session.ts b/src/util/entities/Session.ts index a5cdeb77f8..6b736dbbe2 100644 --- a/src/util/entities/Session.ts +++ b/src/util/entities/Session.ts @@ -81,7 +81,9 @@ export class Session extends BaseClassWithoutId { session_nickname?: string; getPublicStatus() { - return this.status === "invisible" ? "offline" : this.status; + if (this.status === "invisible") return "offline"; + if (this.status === "online" || this.status === "idle" || this.status === "dnd" || this.status === "offline") return this.status; + return "offline"; } getDiscordDeviceInfo() { diff --git a/src/util/interfaces/Status.ts b/src/util/interfaces/Status.ts index 4ee0b843da..1bb6bb56c7 100644 --- a/src/util/interfaces/Status.ts +++ b/src/util/interfaces/Status.ts @@ -23,4 +23,5 @@ export interface ClientStatus { mobile?: string; // e.g. iOS/Android web?: string; // e.g. browser, bot account, unknown embedded?: string; // e.g. embedded + vr?: string; // e.g. Meta Quest 3 }