Compare commits

...

34 Commits

Author SHA1 Message Date
Jacob Schlecht 7fef0567c7 fix: linter errors regarding unused imports (#171)
Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-07-17 23:18:14 -06:00
İspik c6b90b08ee feat: add pronouns (#169)
* feat: pronouns

Signed-off-by: İspik <ispik@ispik.dev>

* feat: add pronouns

Signed-off-by: İspik <ispik@ispik.dev>

---------

Signed-off-by: İspik <ispik@ispik.dev>
2026-07-17 23:08:56 -06:00
Jacob Schlecht 297710dbbe fix: Fix missing import for pr 125 (#170)
Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-07-17 21:38:54 -06:00
Taureon 2c3d0c5366 fix: Specify argument in Client "error" event to be Error instead of any. (#125)
Client.ts: change Events.error callback argument from any to Error

Only location where the "error" event was emitted:
https://github.com/stoatchat/javascript-client-sdk/blob/cdf406094b9b81c3ff285b14ed97fb3d138e3061/src/Client.ts#L263

Callback argument type where that "error" event comes from:
https://github.com/stoatchat/javascript-client-sdk/blob/main/src/events/EventClient.ts#L70

Signed-off-by: Taureon <45183108+Taureon@users.noreply.github.com>
2026-07-17 21:34:38 -06:00
İspik ed6ee065fe chore: update stoat-api (#168)
* chore: update stoat-api

Signed-off-by: İspik <ispik@ispik.dev>

* chore: update stoat-api

Signed-off-by: İspik <ispik@ispik.dev>

---------

Signed-off-by: İspik <ispik@ispik.dev>
2026-07-15 11:43:55 -06:00
Jacob Schlecht 4573e6b2bb refactor: Reexport UserLimits, change getLimits to get limits (#167)
* refactor: Reexport UserLimits, change getLimits to get limits

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* fix: Use new userlimits on the client class

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

---------

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-06-29 12:00:18 -06:00
Jacob Schlecht afdd11fc3b feat: Add the ability to get a user's limits, including current (#153)
Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-06-25 09:05:21 -06:00
Jacob Schlecht 9da2032818 feat: Add voice statuses for participants, channels, and servers (#166)
This will allow marking servers as having active voice members

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-06-25 09:05:10 -06:00
Jacob Schlecht 3453407ba8 chore: Add tsconfigRootDir to eslint to fix linting in for-web (#164)
Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-05-23 19:04:10 -06:00
İspik 0ae58c30be feat: Add the userslowmode event (#162)
* feat: Add the userslowmode event

Signed-off-by: ispik <ispik@ispik.dev>

* chore: format

Signed-off-by: ispik <ispik@ispik.dev>

* chore: format

Signed-off-by: ispik <ispik@ispik.dev>

* refactor: satisfy review

Signed-off-by: ispik <ispik@ispik.dev>

* refactor: satisfy review

Signed-off-by: ispik <ispik@ispik.dev>

---------

Signed-off-by: ispik <ispik@ispik.dev>
2026-05-23 13:18:03 -06:00
Jacob Schlecht 8e9b8fa71a feat: Add a logout function for disposal of the client (#163)
* feat: Add a logout function for disposal of the client

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* fix: typo

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

---------

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-05-23 13:17:45 -06:00
izzy cfc2b5b433 fix: typing for server ordered role 2026-05-17 23:08:47 +01:00
izzy de9908896a chore: update lockfile 2026-05-17 22:16:04 +01:00
izzy 58ae86c5c6 fix: pass entire role object for role icon 2026-05-17 22:10:37 +01:00
izzy 64d449d7d3 chore: role icon property 2026-05-17 21:57:13 +01:00
izzy 7de00aa7ce chore: upgrade stoat-api 2026-05-17 21:31:18 +01:00
Amy a672fea424 fix: hydrate Bot and User when using edit() (#147)
* fix: hydrate Bot and User when using edit()

closes #146

Signed-off-by: Amy <amy+git@amogus.cloud>

* chore: run formatter

Signed-off-by: Amy <amy+git@amogus.cloud>

---------

Signed-off-by: Amy <amy+git@amogus.cloud>
2026-05-10 18:15:26 -06:00
İspik 46b20bd7f7 feat: hydrate slowmode into channels and add the new permission (#151)
* feat: implement slowmode functionality with cooldown display

Signed-off-by: ispik <ispik@ispik.dev>

* refactor: format

Signed-off-by: ispik <ispik@ispik.dev>

* refactor: remove unnecessary function

Signed-off-by: ispik <ispik@ispik.dev>

---------

Signed-off-by: ispik <ispik@ispik.dev>
2026-05-10 18:14:53 -06:00
Amy 1c97bc2d5a chore: bump stoat-api version to 0.12.0 (#149) 2026-04-12 00:35:53 +01:00
Mihai b2a49fae3c Fix/server deletion lockup (#134)
* fix: Server deletion client lockup

Signed-off-by: mihai <45673304+mihaicm93@users.noreply.github.com>

* fix: lint errors

Signed-off-by: mihai <45673304+mihaicm93@users.noreply.github.com>

* fix lint error

Signed-off-by: mihai <45673304+mihaicm93@users.noreply.github.com>

---------

Signed-off-by: mihai <45673304+mihaicm93@users.noreply.github.com>
Co-authored-by: Christopher Hultin <chris.hultin@gmail.com>
2026-03-02 21:17:03 -07:00
Jacob Schlecht 7cdf5b1bce fix: linter fix to make pr checks pass (#137)
* fix: linter fix to make pr checks pass

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* fix: add missing eslint library

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

---------

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-03-02 21:14:24 -07:00
Jacob Schlecht 4a1b1addff feat: Add emoji parsing to sync markdownToText (#136)
This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-03-02 20:47:39 -07:00
Jacob Schlecht 3c71fa73e8 fix: markdownToTextFetch will now return results even if fetches fail (#135)
This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-03-02 20:47:27 -07:00
z-nexx 64d8d2c657 fix: correct event name in example (#130)
correct event name

Signed-off-by: z-nexx <znexxpwnz0r@gmail.com>
2026-03-02 08:26:49 -07:00
Jacob Schlecht 97d924bb3a feat: Add the ability to parse markdown and replace tags asynchronously, and fetch the missing users and channels. (#131)
* chore: Update ulid to v3 to allow use in service workers

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* feat: Add async message parsing that calls fetch for service workers.

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* feat: add custom emoji text replacements to the markdown parser

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* feat: dedupe regex matches, wait for all requests at once

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* fix: run lint for Client.ts

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* refactor: Wait for all promises at once

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* Using spread operator

Signed-off-by: Christopher Hultin <chris.hultin@gmail.com>

* fix: run linter

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

---------

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
Signed-off-by: Christopher Hultin <chris.hultin@gmail.com>
Co-authored-by: Christopher Hultin <chris.hultin@gmail.com>
2026-03-02 08:26:31 -07:00
Jacob Schlecht 746afa7839 feat: Enable asynchronous configuration loading and configuration signal (#127)
Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
2026-02-28 12:24:27 -07:00
Christopher Hultin 0f2cd21df8 fix: lint issues (#133)
Signed-off-by: Chris Hultin <chris.hultin@gmail.com>
2026-02-28 12:18:54 -07:00
izzy e1a9c8a856 chore: aggressively optimise large servers by just not loading many members 2026-02-12 21:45:40 +00:00
izzy cdf406094b chore: bump version to 7.3.6
Signed-off-by: izzy <me@insrt.uk>
2026-01-16 20:33:47 +00:00
Amy 075832ff2e fix: make ReadyData partial to match event documentation (#123)
Signed-off-by: Amy <amy+git@amogus.cloud>
2026-01-16 13:28:35 -05:00
Amy 8d2def3ee3 fix: missing undefined check on policy_changes (#122)
* feat: add nix shell

Signed-off-by: Amy <amy+git@amogus.cloud>

* fix: missing undefined check in policy_changes

Signed-off-by: Amy <amy+git@amogus.cloud>

---------

Signed-off-by: Amy <amy+git@amogus.cloud>
2026-01-01 12:51:30 -05:00
Amy 393bd00fdd feat: add nix shell (#121)
Signed-off-by: Amy <amy+git@amogus.cloud>
2026-01-01 12:50:43 -05:00
izzy dd74283320 fix: create user object when creating bot 2025-11-16 14:22:33 +00:00
izzy ec178587bf fix: consider DMs as voice channels 2025-10-23 13:34:31 +01:00
29 changed files with 744 additions and 261 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ client.on("ready", async () =>
console.info(`Logged in as ${client.user.username}!`),
);
client.on("messageCreate", async (message) => {
client.on("message", async (message) => {
if (message.content === "hello") {
message.channel.sendMessage("world");
}
+19
View File
@@ -0,0 +1,19 @@
{
pkgs ? import <nixpkgs> { },
}:
with pkgs;
pkgs.mkShell {
name = "stoatEnv";
buildInputs = [
# Tools
git
gh
deno
# Node
nodejs
nodejs.pkgs.pnpm
];
}
+7 -1
View File
@@ -5,9 +5,15 @@ import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig([
{
languageOptions: {
parserOptions: { tsconfigRootDir: import.meta.dirname },
},
},
eslint.configs.recommended,
tseslint.configs.recommended,
solid,
prettier,
{
rules: {
"@typescript-eslint/no-unused-vars": [
@@ -15,9 +21,9 @@ export default defineConfig([
{
caughtErrors: "all",
varsIgnorePattern: "^_",
argsIgnorePattern: "^_",
},
],
},
},
prettier,
]);
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "stoat.js",
"version": "7.3.4",
"version": "7.3.7",
"type": "module",
"exports": {
".": "./lib/index.js"
@@ -31,10 +31,11 @@
"@vladfrangu/async_event_emitter": "^2.4.6",
"json-with-bigint": "^3.4.4",
"solid-js": "^1.9.6",
"stoat-api": "0.8.9-4",
"ulid": "^2.4.0"
"stoat-api": "0.14.0",
"ulid": "^3.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@mxssfd/typedoc-theme": "^1.1.7",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "^22.15.17",
@@ -46,4 +47,4 @@
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.0"
}
}
}
+34 -12
View File
@@ -24,12 +24,15 @@ importers:
specifier: ^1.9.6
version: 1.9.6
stoat-api:
specifier: 0.8.9-4
version: 0.8.9-4
specifier: 0.14.0
version: 0.14.0
ulid:
specifier: ^2.4.0
version: 2.4.0
specifier: ^3.0.2
version: 3.0.2
devDependencies:
'@eslint/js':
specifier: ^9.39.1
version: 9.39.3
'@mxssfd/typedoc-theme':
specifier: ^1.1.7
version: 1.1.7(typedoc@0.27.9(typescript@5.8.3))
@@ -44,7 +47,7 @@ importers:
version: 9.26.0
eslint-plugin-prettier:
specifier: ^5.4.0
version: 5.4.0(eslint@9.26.0)(prettier@3.5.3)
version: 5.4.0(eslint-config-prettier@10.1.8(eslint@9.26.0))(eslint@9.26.0)(prettier@3.5.3)
eslint-plugin-solid:
specifier: ^0.14.5
version: 0.14.5(eslint@9.26.0)(typescript@5.8.3)
@@ -126,6 +129,10 @@ packages:
resolution: {integrity: sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.3':
resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -460,6 +467,12 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-plugin-prettier@5.4.0:
resolution: {integrity: sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -981,8 +994,8 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
stoat-api@0.8.9-4:
resolution: {integrity: sha512-6N4kfE1x+j/XYVaBuvYqzbWxjgqbxSarvNjMv8GEcBaqbkiyPzUwsCj12NJsNNp4uyZmsqoUYUj2jIq018uqiA==}
stoat-api@0.14.0:
resolution: {integrity: sha512-Kg6PS8D6aKKhs6KALvdHe6ygtwCuiCyj9JDT/KDR0BRDzoP2W9LjAePYZZBoA9BITIeBU270ug/0qDpWMGYb/w==}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
@@ -1046,8 +1059,8 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ulid@2.4.0:
resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==}
ulid@3.0.2:
resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==}
hasBin: true
undici-types@6.21.0:
@@ -1177,6 +1190,8 @@ snapshots:
'@eslint/js@9.26.0': {}
'@eslint/js@9.39.3': {}
'@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.2.8':
@@ -1524,12 +1539,19 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-plugin-prettier@5.4.0(eslint@9.26.0)(prettier@3.5.3):
eslint-config-prettier@10.1.8(eslint@9.26.0):
dependencies:
eslint: 9.26.0
optional: true
eslint-plugin-prettier@5.4.0(eslint-config-prettier@10.1.8(eslint@9.26.0))(eslint@9.26.0)(prettier@3.5.3):
dependencies:
eslint: 9.26.0
prettier: 3.5.3
prettier-linter-helpers: 1.0.0
synckit: 0.11.4
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.26.0)
eslint-plugin-solid@0.14.5(eslint@9.26.0)(typescript@5.8.3):
dependencies:
@@ -2068,7 +2090,7 @@ snapshots:
statuses@2.0.1: {}
stoat-api@0.8.9-4:
stoat-api@0.14.0:
dependencies:
json-with-bigint: 3.4.4
@@ -2132,7 +2154,7 @@ snapshots:
uc.micro@2.1.0: {}
ulid@2.4.0: {}
ulid@3.0.2: {}
undici-types@6.21.0: {}
+179 -8
View File
@@ -1,16 +1,17 @@
import type { Accessor, Setter } from "solid-js";
import { batch, createSignal } from "solid-js";
import { ReactiveMap } from "@solid-primitives/map";
import { AsyncEventEmitter } from "@vladfrangu/async_event_emitter";
import { API } from "stoat-api";
import type { DataLogin, RevoltConfig, Role } from "stoat-api";
import type { DataLogin, Error, RevoltConfig, Role } from "stoat-api";
import type { Channel } from "./classes/Channel.js";
import type { Emoji } from "./classes/Emoji.js";
import type { Message } from "./classes/Message.js";
import type { Server } from "./classes/Server.js";
import type { ServerMember } from "./classes/ServerMember.js";
import type { User } from "./classes/User.js";
import type { User, UserLimits } from "./classes/User.js";
import { AccountCollection } from "./collections/AccountCollection.js";
import { BotCollection } from "./collections/BotCollection.js";
import { ChannelCollection } from "./collections/ChannelCollection.js";
@@ -27,14 +28,19 @@ import {
EventClient,
type EventClientOptions,
} from "./events/EventClient.js";
import { ProtocolV1, handleEvent } from "./events/v1.js";
import { ProtocolV1, UserSlowmodes, handleEvent } from "./events/v1.js";
import type { HydratedChannel } from "./hydration/channel.js";
import type { HydratedEmoji } from "./hydration/emoji.js";
import type { HydratedMessage } from "./hydration/message.js";
import type { HydratedServer } from "./hydration/server.js";
import type { HydratedServerMember } from "./hydration/serverMember.js";
import type { HydratedUser } from "./hydration/user.js";
import { RE_CHANNELS, RE_MENTIONS, RE_SPOILER } from "./lib/regex.js";
import {
RE_CHANNELS,
RE_CUSTOM_EMOJI,
RE_MENTIONS,
RE_SPOILER,
} from "./lib/regex.js";
export type Session = { _id: string; token: string; user_id: string } | string;
@@ -42,8 +48,7 @@ export type Session = { _id: string; token: string; user_id: string } | string;
* Events provided by the client
*/
export type Events = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: [error: any];
error: [error: Error];
connected: [];
connecting: [];
@@ -94,6 +99,8 @@ export type Events = {
emojiCreate: [emoji: Emoji];
emojiDelete: [emoji: HydratedEmoji];
userSlowmodes: [];
};
/**
@@ -176,6 +183,7 @@ export class Client extends AsyncEventEmitter<Events> {
readonly serverMembers;
readonly sessions;
readonly users;
readonly userSlowmodes;
readonly api: API;
readonly options: ClientOptions;
@@ -188,10 +196,13 @@ export class Client extends AsyncEventEmitter<Events> {
readonly ready: Accessor<boolean>;
#setReady: Setter<boolean>;
readonly configured: Accessor<boolean>;
#setConfigured: Setter<boolean>;
readonly connectionFailureCount: Accessor<number>;
#setConnectionFailureCount: Setter<number>;
#reconnectTimeout: number | undefined;
#slowmodeTimers = new Map<string, ReturnType<typeof setTimeout>>();
/**
* Create Stoat.js Client
*/
@@ -239,6 +250,14 @@ export class Client extends AsyncEventEmitter<Events> {
baseURL: this.options.baseURL,
});
const [configured, setConfigured] = createSignal(
configuration !== undefined,
);
this.configured = configured;
this.#setConfigured = setConfigured;
this.#fetchConfiguration();
const [ready, setReady] = createSignal(false);
this.ready = ready;
this.#setReady = setReady;
@@ -258,6 +277,7 @@ export class Client extends AsyncEventEmitter<Events> {
this.serverMembers = new ServerMemberCollection(this);
this.sessions = new SessionCollection(this);
this.users = new UserCollection(this);
this.userSlowmodes = new ReactiveMap<string, UserSlowmodes>();
this.events = new EventClient(1, "json", this.options);
this.events.on("error", (error) => this.emit("error", error));
@@ -279,7 +299,7 @@ export class Client extends AsyncEventEmitter<Events> {
this.#reconnectTimeout = setTimeout(
() => this.connect(),
this.options.retryDelayFunction(this.connectionFailureCount()) *
1e3,
1e3,
) as never;
this.#setConnectionFailureCount((count) => count + 1);
@@ -328,6 +348,7 @@ export class Client extends AsyncEventEmitter<Events> {
async #fetchConfiguration(): Promise<void> {
if (!this.configuration) {
this.configuration = await this.api.get("/");
this.#setConfigured(true);
}
}
@@ -378,6 +399,18 @@ export class Client extends AsyncEventEmitter<Events> {
this.connect();
}
/**
* Log out of current session
*
* This function prepares the client for disposal by removing all event listeners and killing the events socket.
*/
async logout(): Promise<void> {
await this.api.post("/auth/session/logout");
this.events.removeAllListeners();
this.removeAllListeners();
this.events.disconnect();
}
/**
* Prepare a markdown-based message to be displayed to the user as plain text.
* @param source Source markdown text
@@ -403,6 +436,118 @@ export class Client extends AsyncEventEmitter<Events> {
return sub;
})
.replace(RE_CUSTOM_EMOJI, (sub: string, id: string) => {
const emoji = this.emojis.get(id as string);
if (emoji) {
return `:${emoji.name}:`;
}
return sub;
})
.replace(RE_SPOILER, "<spoiler>");
}
/**
* Prepare a markdown-based message to be displayed to the user as plain text. This method will fetch each user or channel if they are missing. Useful for serviceworkers.
* @param source Source markdown text
* @returns Modified plain text
*/
async markdownToTextFetch(source: string): Promise<string> {
// Get all user matches, create a map to dedupe
const userMatches = Object.fromEntries(
Array.from(source.matchAll(RE_MENTIONS), (match) => {
return [match[0], match[1]];
}),
);
// Get all channel matches, create a map to dedupe
const channelMatches = Object.fromEntries(
Array.from(source.matchAll(RE_CHANNELS), (match) => {
return [match[0], match[1]];
}),
);
// Get all custom emoji matches, create a map to dedupe
const customEmojiMatches = Object.fromEntries(
Array.from(source.matchAll(RE_CUSTOM_EMOJI), (match) => {
return [match[0], match[1]];
}),
);
// Send requests to replace user ids
const userReplacementPromises = Object.keys(userMatches).map(
async (key) => {
const substr = userMatches[key];
if (substr) {
try {
const user = await this.users.fetch(substr);
if (user) {
return [key, `@${user.username}`];
}
} catch {
// If the fetch fails, just show the match as a default
return [key, key];
}
}
return [key, key];
},
);
// Send requests to replace channel ids
const channelReplacementPromises = Object.keys(channelMatches).map(
async (key) => {
const substr = channelMatches[key];
if (substr) {
try {
const channel = await this.channels.fetch(substr);
if (channel) {
return [key, `#${channel.displayName}`];
}
} catch {
// If the fetch fails, just show the match as a default
return [key, key];
}
}
return [key, key];
},
);
// Send requests to replace custom emojis
const customEmojiReplacementPromises = Object.keys(customEmojiMatches).map(
async (key) => {
const substr = customEmojiMatches[key];
if (substr) {
try {
const emoji = await this.emojis.fetch(substr);
if (emoji) {
return [key, `:${emoji.name}:`];
}
} catch {
// If the fetch fails, just show the match as a default
return [key, key];
}
}
return [key, key];
},
);
// Await for all promises to get the strings to replace with.
const replacements = await Promise.all([
...userReplacementPromises,
...channelReplacementPromises,
...customEmojiReplacementPromises,
]);
const replacementsMap = Object.fromEntries(replacements);
return source
.replace(RE_MENTIONS, (match) => replacementsMap[match])
.replace(RE_CHANNELS, (match) => replacementsMap[match])
.replace(RE_CUSTOM_EMOJI, (match) => replacementsMap[match])
.replace(RE_SPOILER, "<spoiler>");
}
@@ -449,4 +594,30 @@ export class Client extends AsyncEventEmitter<Events> {
return data.id;
}
setSlowmode(channelId: string, data: UserSlowmodes): void {
const existing = this.#slowmodeTimers.get(channelId);
if (existing) clearTimeout(existing);
this.userSlowmodes.set(channelId, { ...data, receivedAt: Date.now() });
const timer = setTimeout(() => {
this.userSlowmodes.delete(channelId);
this.#slowmodeTimers.delete(channelId);
this.emit("userSlowmodes");
}, data.retry_after * 1000);
this.#slowmodeTimers.set(channelId, timer);
}
/**
* Backend enforced limits for the logged in user
*/
get limits(): UserLimits | undefined {
if (!this.configured() || !this.user) {
return;
}
return this.user.limits;
}
}
+10 -1
View File
@@ -3,6 +3,7 @@ import { decodeTime } from "ulid";
import type { BotCollection } from "../collections/BotCollection.js";
import type { BotFlags } from "../hydration/bot.js";
import { hydrate } from "../hydration/index.js";
import { PublicBot } from "./PublicBot.js";
import type { User } from "./User.js";
@@ -139,7 +140,15 @@ export class Bot {
* @param data Changes
*/
async edit(data: DataEditBot): Promise<void> {
await this.#collection.client.api.patch(`/bots/${this.id as ""}`, data);
this.#collection.updateUnderlyingObject(
this.id,
hydrate(
"bot",
await this.#collection.client.api.patch(`/bots/${this.id as ""}`, data),
this.#collection.client,
false,
),
);
}
/**
+56 -14
View File
@@ -1,5 +1,6 @@
import { batch } from "solid-js";
import { ReactiveMap } from "@solid-primitives/map";
import type { ReactiveSet } from "@solid-primitives/set";
import type {
Channel as APIChannel,
@@ -29,8 +30,7 @@ import type { Message } from "./Message.js";
import type { Server } from "./Server.js";
import type { ServerMember } from "./ServerMember.js";
import type { User } from "./User.js";
import { ReactiveMap } from "@solid-primitives/map";
import { VoiceParticipant } from "./VoiceParticipant.js";
import { VoiceParticipant, VoiceStatus } from "./VoiceParticipant.js";
/**
* Channel Class
@@ -177,8 +177,8 @@ export class Channel {
get recipient(): User | undefined {
return this.type === "DirectMessage"
? this.recipients?.find(
(user) => user?.id !== this.#collection.client.user!.id,
)
(user) => user?.id !== this.#collection.client.user!.id,
)
: undefined;
}
@@ -317,8 +317,7 @@ export class Channel {
* Get mentions in this channel for user.
*/
get mentions(): ReactiveSet<string> | undefined {
if (this.type === "SavedMessages")
return undefined;
if (this.type === "SavedMessages") return undefined;
return this.#collection.client.channelUnreads.get(this.id)
?.messageMentionIds;
@@ -330,7 +329,11 @@ export class Channel {
* NB. subject to change as vc(2) goes to production
*/
get isVoice(): boolean {
return typeof this.#collection.getUnderlyingObject(this.id).voice === 'object';
return (
this.type === "DirectMessage" ||
this.type === "Group" ||
typeof this.#collection.getUnderlyingObject(this.id).voice === "object"
);
}
/**
@@ -795,20 +798,35 @@ export class Channel {
);
}
/**
* Get slowmode value for the channel
*/
get slowmode(): number {
return this.#collection.getUnderlyingObject(this.id).slowmode ?? 0;
}
/**
* Join a call
* @param node Target node
* @param node Target node
* @param forceDisconnect Whether to disconnect existing call
* @param recipients Ring targets
* @returns LiveKit URL and Token
*/
async joinCall(node?: string, forceDisconnect = true, recipients?: (User | string)[]) {
async joinCall(
node?: string,
forceDisconnect = true,
recipients?: (User | string)[],
) {
return await this.#collection.client.api.post(
`/channels/${this.id as ''}/join_call`, {
node,
recipients: recipients?.map(entry => typeof entry === 'string' ? entry : entry.id),
force_disconnect: forceDisconnect
});
`/channels/${this.id as ""}/join_call`,
{
node,
recipients: recipients?.map((entry) =>
typeof entry === "string" ? entry : entry.id,
),
force_disconnect: forceDisconnect,
},
);
}
/**
@@ -832,4 +850,28 @@ export class Channel {
channel: this.id,
});
}
/**
* The voice status of a channel. Screenshare supersedes video, video
* supersedes voice, and voice supersedes none. This getter takes the highest
* priority status from all participants in the channel.
*/
get voiceStatus(): VoiceStatus {
if (!this.isVoice || this.voiceParticipants.size === 0) {
return "none";
}
let highest: VoiceStatus = "voice";
for (const participant of this.voiceParticipants.values()) {
const status = participant.voiceStatus;
if (status === "screenshare") {
return "screenshare";
}
if (status === "video") {
highest = "video";
}
}
return highest;
}
}
+3 -3
View File
@@ -31,14 +31,14 @@ export class MFA {
* Whether authenticator app is enabled
*/
get authenticatorEnabled(): boolean {
return this.#store[0].totp_mfa;
return this.#store[0].totp_mfa ?? false;
}
/**
* Whether recovery codes are enabled
*/
get recoveryEnabled(): boolean {
return this.#store[0].recovery_active;
return this.#store[0].recovery_active ?? false;
}
/**
@@ -61,7 +61,7 @@ export class MFA {
return new MFATicket(
this.#client,
await this.#client.api.put("/auth/mfa/ticket", params),
this.#store[1]
this.#store[1],
);
}
+7
View File
@@ -271,6 +271,13 @@ export class Message {
return this.masquerade?.colour ?? this.member?.roleColour;
}
/**
* Get the role colour for this message
*/
get iconRole(): ServerRole | null | undefined {
return this.member?.iconRole;
}
/**
* Get the avatar URL for this message
*/
+82 -24
View File
@@ -13,7 +13,6 @@ import type {
DataEditRole,
DataEditServer,
Override,
OverrideField,
Role,
} from "stoat-api";
import { decodeTime } from "ulid";
@@ -35,6 +34,7 @@ import { ServerBan } from "./ServerBan.js";
import { ServerMember } from "./ServerMember.js";
import { ServerRole } from "./ServerRole.js";
import { User } from "./User.js";
import { VoiceStatus } from "./VoiceParticipant.js";
/**
* Server Class
@@ -136,9 +136,10 @@ export class Server {
* Channels
*/
get channels(): Channel[] {
return [
...this.#collection.getUnderlyingObject(this.id).channelIds.values(),
]
const channelIds = this.#collection.getUnderlyingObject(this.id).channelIds;
if (!channelIds) return [];
return [...channelIds.values()]
.map((id) => this.#collection.client.channels.get(id)!)
.filter((x) => x);
}
@@ -269,14 +270,7 @@ export class Server {
* ranking roles. This is dictated by the "rank" property
* which is smaller for higher priority roles.
*/
get orderedRoles(): {
name: string;
permissions: { a: bigint, d: bigint };
colour?: string | null;
hoist?: boolean;
rank?: number;
id: string;
}[] {
get orderedRoles(): ServerRole[] {
const roles = this.roles;
return roles
? [...roles.values()].sort((a, b) => (a.rank || 0) - (b.rank || 0))
@@ -338,6 +332,7 @@ export class Server {
* Permission the currently authenticated user has against this server
*/
get permission(): bigint {
if (!this.$exists) return 0n;
return calculatePermission(this.#collection.client, this);
}
@@ -514,11 +509,12 @@ export class Server {
*/
async kickUser(user: string | User | ServerMember): Promise<void> {
return await this.#collection.client.api.delete(
`/servers/${this.id as ""}/members/${typeof user === "string"
? user
: user instanceof User
? user.id
: user.id.user
`/servers/${this.id as ""}/members/${
typeof user === "string"
? user
: user instanceof User
? user.id
: user.id.user
}`,
);
}
@@ -647,11 +643,10 @@ export class Server {
#synced: undefined | "partial" | "full";
/**
* Optimised member fetch route
* @param excludeOffline
*/
async syncMembers(excludeOffline?: boolean): Promise<void> {
async syncMembers(
excludeOffline?: boolean,
excludeOfflineUserCap?: number,
): Promise<void> {
if (this.#synced && (this.#synced === "full" || excludeOffline)) return;
const data = await this.#collection.client.api.get(
@@ -660,7 +655,45 @@ export class Server {
);
batch(() => {
if (excludeOffline) {
if (excludeOffline && excludeOfflineUserCap) {
// quick fix to cap users
let count = 0;
for (
let i = 0;
i < data.users.length && count < excludeOfflineUserCap;
i++
) {
const user = data.users[i];
if (user.online && data.members[i].roles?.length) {
this.#collection.client.users.getOrCreate(user._id, user);
this.#collection.client.serverMembers.getOrCreate(
data.members[i]._id,
data.members[i],
);
count++;
}
}
for (
let i = 0;
i < data.users.length && count < excludeOfflineUserCap;
i++
) {
const user = data.users[i];
if (user.online && !data.members[i].roles?.length) {
this.#collection.client.users.getOrCreate(user._id, user);
this.#collection.client.serverMembers.getOrCreate(
data.members[i]._id,
data.members[i],
);
count++;
}
}
// end quick fix
} else if (excludeOffline) {
for (let i = 0; i < data.users.length; i++) {
const user = data.users[i];
if (user.online) {
@@ -722,7 +755,8 @@ export class Server {
query: string,
): Promise<{ members: ServerMember[]; users: User[] }> {
const data = (await this.#collection.client.api.get(
`/servers/${this.id as ""
`/servers/${
this.id as ""
}/members_experimental_query?experimental_api=true&query=${encodeURIComponent(
query,
)}` as never,
@@ -793,4 +827,28 @@ export class Server {
async deleteEmoji(emojiId: string): Promise<void> {
await this.#collection.client.api.delete(`/custom/emoji/${emojiId}`);
}
/**
* The voice status of a server. Screenshare supersedes video, video
* supersedes voice, and voice supersedes none. This getter takes the highest
* priority status from all channels in the server.
*/
get voiceStatus(): VoiceStatus {
let highest: VoiceStatus = "none";
for (const chan of this.channels) {
const status = chan.voiceStatus;
if (status === "screenshare") {
return "screenshare";
}
if (status === "video") {
highest = "video";
}
if (status === "voice" && highest === "none") {
highest = "voice";
}
}
return highest;
}
}
+26 -11
View File
@@ -2,7 +2,6 @@ import type {
DataBanCreate,
DataMemberEdit,
MemberCompositeKey,
Role,
} from "stoat-api";
import type { ServerMemberCollection } from "../collections/ServerMemberCollection.js";
@@ -15,6 +14,7 @@ import { Permission } from "../permissions/definitions.js";
import type { Channel } from "./Channel.js";
import type { File } from "./File.js";
import type { Server } from "./Server.js";
import { ServerRole } from "./ServerRole.js";
import type { User } from "./User.js";
/**
@@ -93,6 +93,13 @@ export class ServerMember {
return this.#collection.getUnderlyingObject(key(this.id)).avatar;
}
/**
* Pronouns
*/
get pronouns(): string | undefined {
return this.#collection.getUnderlyingObject(key(this.id)).pronouns;
}
/**
* List of role IDs
*/
@@ -110,22 +117,18 @@ export class ServerMember {
/**
* Ordered list of roles for this member, from lowest to highest priority.
*/
get orderedRoles(): (Partial<Omit<Role, 'permissions'> & { permissions: { a: bigint, d: bigint } }> & { id: string })[] {
get orderedRoles(): ServerRole[] {
const server = this.server!;
return (
this.roles
?.map((id) => ({
id,
...server.roles?.get(id),
}))
.sort((a, b) => b.rank! - a.rank!) ?? []
);
return this.roles
?.map((id) => server.roles.get(id)!)
.filter((role) => role)
.sort((a, b) => b.rank! - a.rank!);
}
/**
* Member's currently hoisted role.
*/
get hoistedRole(): Partial<Omit<Role, 'permissions'> & { permissions: { a: bigint, d: bigint } }> | null {
get hoistedRole(): ServerRole | null {
const roles = this.orderedRoles.filter((x) => x.hoist);
if (roles.length > 0) {
return roles[roles.length - 1];
@@ -146,6 +149,18 @@ export class ServerMember {
}
}
/**
* Member's current role icon with name.
*/
get iconRole(): ServerRole | null | undefined {
const roles = this.orderedRoles.filter((x) => x.icon);
if (roles.length > 0) {
return roles[roles.length - 1]!;
} else {
return null;
}
}
/**
* Member's ranking
* Smaller values are ranked as higher priority
+7 -3
View File
@@ -2,6 +2,8 @@ import type { Role as APIRole } from "stoat-api";
import type { Client } from "../Client.js";
import { File } from "./File.js";
/**
* Server Role
*/
@@ -11,9 +13,10 @@ export class ServerRole {
readonly id: string;
readonly name: string;
readonly icon?: File;
readonly permissions: {
a: bigint,
d: bigint
a: bigint;
d: bigint;
};
readonly colour?: string;
readonly hoist: boolean;
@@ -32,9 +35,10 @@ export class ServerRole {
this.id = id;
this.name = data.name;
this.icon = data.icon ? new File(client, data.icon) : undefined;
this.permissions = {
a: BigInt(data.permissions.a),
d: BigInt(data.permissions.d)
d: BigInt(data.permissions.d),
};
this.colour = data.colour ?? undefined;
this.hoist = data.hoist || false;
+14 -4
View File
@@ -1,10 +1,13 @@
import type { SystemMessage as APISystemMessage, Message as APIMessage } from "stoat-api";
import type {
Message as APIMessage,
SystemMessage as APISystemMessage,
} from "stoat-api";
import { decodeTime } from "ulid";
import type { Client } from "../Client.js";
import type { User } from "./User.js";
import { Message } from "./index.js";
import { decodeTime } from "ulid";
/**
* System Message
@@ -29,7 +32,11 @@ export abstract class SystemMessage {
* @param embed Data
* @returns System Message
*/
static from(client: Client, parent: APIMessage, message: APISystemMessage): SystemMessage {
static from(
client: Client,
parent: APIMessage,
message: APISystemMessage,
): SystemMessage {
switch (message.type) {
case "text":
return new TextSystemMessage(client, message);
@@ -299,7 +306,10 @@ export class CallStartedSystemMessage extends SystemMessage {
super(client, systemMessage.type);
this.byId = systemMessage.by;
this.startedAt = new Date(decodeTime(parent._id));
this.finishedAt = systemMessage.finished_at != null ? new Date(systemMessage.finished_at) : null;
this.finishedAt =
systemMessage.finished_at != null
? new Date(systemMessage.finished_at)
: null;
}
/**
+54 -6
View File
@@ -1,13 +1,21 @@
import type { User as APIUser, DataEditUser, Presence } from "stoat-api";
import type {
User as APIUser,
UserLimits as APIUserLimits,
DataEditUser,
Presence,
} from "stoat-api";
import { decodeTime } from "ulid";
import type { UserCollection } from "../collections/UserCollection.js";
import { hydrate } from "../hydration/index.js";
import { U32_MAX, UserPermission } from "../permissions/definitions.js";
import type { Channel } from "./Channel.js";
import type { File } from "./File.js";
import { UserProfile } from "./UserProfile.js";
export type UserLimits = APIUserLimits;
/**
* User Class
*/
@@ -47,6 +55,13 @@ export class User {
return new Date(decodeTime(this.id));
}
/**
* How old this user is in milliseconds
*/
get age() {
return new Date().getTime() - this.createdAt.getTime();
}
/**
* Username
*/
@@ -78,6 +93,13 @@ export class User {
return this.#collection.getUnderlyingObject(this.id).avatar;
}
/**
* Pronouns
*/
get pronouns(): string | undefined {
return this.#collection.getUnderlyingObject(this.id).pronouns;
}
/**
* Badges
*/
@@ -225,11 +247,19 @@ export class User {
* @param data Changes
*/
async edit(data: DataEditUser): Promise<void> {
await this.#collection.client.api.patch(
`/users/${
this.id === this.#collection.client.user?.id ? "@me" : this.id
}`,
data,
this.#collection.updateUnderlyingObject(
this.id,
hydrate(
"user",
await this.#collection.client.api.patch(
`/users/${
this.id === this.#collection.client.user?.id ? "@me" : this.id
}`,
data,
),
this.#collection.client,
false,
),
);
}
@@ -325,4 +355,22 @@ export class User {
`/users/${this.id as ""}/mutual`,
);
}
/**
* Backend enforced limits for a user
*/
get limits(): UserLimits | undefined {
if (!this.#collection.client.configured()) {
return;
}
if (
this.age <
(this.#collection.client.configuration?.features.limits.global
.new_user_hours ?? 72) *
3600_0000
) {
return this.#collection.client.configuration?.features.limits.new_user;
}
return this.#collection.client.configuration?.features.limits.default;
}
}
+26 -6
View File
@@ -1,8 +1,13 @@
import { Accessor, Setter, createSignal } from "solid-js";
import { Accessor, createSignal, Setter } from "solid-js";
import type { Client } from "../Client.js";
import { UserVoiceState } from "../events/v1.js";
/**
* The voice status of an entity. Screenshare supersedes video, video supersedes voice, and voice supersedes none.
*/
export type VoiceStatus = "none" | "voice" | "video" | "screenshare";
/**
* Voice Participant
*/
@@ -39,7 +44,9 @@ export class VoiceParticipant {
this.isPublishing = isPublishing;
this.#setPublishing = setPublishing;
const [isScreensharing, setScreensharing] = createSignal(data.screensharing);
const [isScreensharing, setScreensharing] = createSignal(
data.screensharing,
);
this.isScreensharing = isScreensharing;
this.#setScreensharing = setScreensharing;
@@ -53,20 +60,33 @@ export class VoiceParticipant {
* @param data Data
*/
update(data: Partial<UserVoiceState>) {
if (typeof data.is_receiving === 'boolean') {
if (typeof data.is_receiving === "boolean") {
this.#setReceiving(data.is_receiving);
}
if (typeof data.is_publishing === 'boolean') {
if (typeof data.is_publishing === "boolean") {
this.#setPublishing(data.is_publishing);
}
if (typeof data.screensharing === 'boolean') {
if (typeof data.screensharing === "boolean") {
this.#setScreensharing(data.screensharing);
}
if (typeof data.camera === 'boolean') {
if (typeof data.camera === "boolean") {
this.#setCamera(data.camera);
}
}
/**
* The voice status of a participant. Screenshare supersedes video, video supersedes voice, and voice supersedes none.
*/
get voiceStatus(): VoiceStatus {
if (this.isScreensharing()) {
return "screenshare";
}
if (this.isCamera()) {
return "video";
}
return "voice";
}
}
+2 -1
View File
@@ -71,10 +71,11 @@ export class BotCollection extends ClassCollection<Bot, HydratedBot> {
* @returns The newly-created bot
*/
async createBot(name: string): Promise<Bot> {
const bot = await this.client.api.post(`/bots/create`, {
const { user, ...bot } = await this.client.api.post(`/bots/create`, {
name,
});
this.client.users.getOrCreate(user._id, user);
return this.getOrCreate(bot._id, bot);
}
}
+1 -5
View File
@@ -1,10 +1,6 @@
import { batch } from "solid-js";
import type {
Server as APIServer,
Channel,
DataCreateServer,
} from "stoat-api";
import type { Server as APIServer, Channel, DataCreateServer } from "stoat-api";
import { Server } from "../classes/Server.js";
import type { HydratedServer } from "../hydration/server.js";
+8 -8
View File
@@ -95,7 +95,7 @@ export class EventClient<
#connectTimeoutReference: number | undefined;
#lastError: // eslint-disable-next-line @typescript-eslint/no-explicit-any
{ type: "socket"; data: any } | { type: "revolt"; data: Error } | undefined;
{ type: "socket"; data: any } | { type: "revolt"; data: Error } | undefined;
/**
* Create a new event client.
@@ -291,14 +291,14 @@ export class EventClient<
*/
get lastError():
| {
type: "socket";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
}
type: "socket";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
}
| {
type: "revolt";
data: Error;
}
type: "revolt";
data: Error;
}
| undefined {
return this.#lastError;
}
+177 -139
View File
@@ -44,21 +44,21 @@ export type ProtocolV1 = {
type ClientMessage =
| { type: "Authenticate"; token: string }
| {
type: "BeginTyping";
channel: string;
}
type: "BeginTyping";
channel: string;
}
| {
type: "EndTyping";
channel: string;
}
type: "EndTyping";
channel: string;
}
| {
type: "Ping";
data: number;
}
type: "Ping";
data: number;
}
| {
type: "Pong";
data: number;
};
type: "Pong";
data: number;
};
/**
* Messages sent from the server
@@ -67,51 +67,51 @@ type ServerMessage =
| { type: "Error"; data: Error }
| { type: "Bulk"; v: ServerMessage[] }
| { type: "Authenticated" }
| ({ type: "Ready" } & ReadyData)
| ({ type: "Ready" } & Partial<ReadyData>)
| { type: "Ping"; data: number }
| { type: "Pong"; data: number }
| ({ type: "Message" } & Message)
| {
type: "MessageUpdate";
id: string;
channel: string;
data: Partial<Message>;
}
type: "MessageUpdate";
id: string;
channel: string;
data: Partial<Message>;
}
| {
type: "MessageAppend";
id: string;
channel: string;
append: Pick<Partial<Message>, "embeds">;
}
type: "MessageAppend";
id: string;
channel: string;
append: Pick<Partial<Message>, "embeds">;
}
| { type: "MessageDelete"; id: string; channel: string }
| {
type: "MessageReact";
id: string;
channel_id: string;
user_id: string;
emoji_id: string;
}
type: "MessageReact";
id: string;
channel_id: string;
user_id: string;
emoji_id: string;
}
| {
type: "MessageUnreact";
id: string;
channel_id: string;
user_id: string;
emoji_id: string;
}
type: "MessageUnreact";
id: string;
channel_id: string;
user_id: string;
emoji_id: string;
}
| {
type: "MessageRemoveReaction";
id: string;
channel_id: string;
emoji_id: string;
}
type: "MessageRemoveReaction";
id: string;
channel_id: string;
emoji_id: string;
}
| { type: "BulkMessageDelete"; channel: string; ids: string[] }
| ({ type: "ChannelCreate" } & Channel)
| {
type: "ChannelUpdate";
id: string;
data: Partial<Channel>;
clear?: FieldsChannel[];
}
type: "ChannelUpdate";
id: string;
data: Partial<Channel>;
clear?: FieldsChannel[];
}
| { type: "ChannelDelete"; id: string }
| { type: "ChannelGroupJoin"; id: string; user: string }
| { type: "ChannelGroupLeave"; id: string; user: string }
@@ -119,91 +119,95 @@ type ServerMessage =
| { type: "ChannelStopTyping"; id: string; user: string }
| { type: "ChannelAck"; id: string; user: string; message_id: string }
| {
type: "ServerCreate";
id: string;
server: Server;
channels: Channel[];
}
type: "ServerCreate";
id: string;
server: Server;
channels: Channel[];
}
| {
type: "ServerUpdate";
id: string;
data: Partial<Server>;
clear?: FieldsServer[];
}
type: "ServerUpdate";
id: string;
data: Partial<Server>;
clear?: FieldsServer[];
}
| { type: "ServerDelete"; id: string }
| {
type: "ServerMemberUpdate";
id: MemberCompositeKey;
data: Partial<Member>;
clear?: FieldsMember[];
}
type: "ServerMemberUpdate";
id: MemberCompositeKey;
data: Partial<Member>;
clear?: FieldsMember[];
}
| { type: "ServerMemberJoin"; id: string; user: string }
| { type: "ServerMemberLeave"; id: string; user: string }
| {
type: "ServerRoleUpdate";
id: string;
role_id: string;
data: Partial<Role>;
}
type: "ServerRoleUpdate";
id: string;
role_id: string;
data: Partial<Role>;
}
| { type: "ServerRoleDelete"; id: string; role_id: string }
| {
type: "UserUpdate";
id: string;
data: Partial<User>;
clear?: FieldsUser[];
}
type: "UserUpdate";
id: string;
data: Partial<User>;
clear?: FieldsUser[];
}
| { type: "UserRelationship"; user: User; status: RelationshipStatus }
| { type: "UserPresence"; id: string; online: boolean }
| {
type: "UserSettingsUpdate";
id: string;
update: { [key: string]: [number, string] };
}
type: "UserSettingsUpdate";
id: string;
update: { [key: string]: [number, string] };
}
| { type: "UserPlatformWipe"; user_id: string; flags: number }
| ({ type: "EmojiCreate" } & Emoji)
| { type: "EmojiDelete"; id: string }
| ({
type: "Auth";
} & (
type: "Auth";
} & (
| {
event_type: "DeleteSession";
user_id: string;
session_id: string;
}
event_type: "DeleteSession";
user_id: string;
session_id: string;
}
| {
event_type: "DeleteAllSessions";
user_id: string;
exclude_session_id: string;
}
event_type: "DeleteAllSessions";
user_id: string;
exclude_session_id: string;
}
))
| {
type: "VoiceChannelJoin";
id: string;
state: UserVoiceState;
}
type: "VoiceChannelJoin";
id: string;
state: UserVoiceState;
}
| {
type: "VoiceChannelLeave";
id: string;
user: string;
}
type: "VoiceChannelLeave";
id: string;
user: string;
}
| {
type: "VoiceChannelMove";
user: string;
from: string;
to: string;
state: UserVoiceState;
}
type: "VoiceChannelMove";
user: string;
from: string;
to: string;
state: UserVoiceState;
}
| {
type: "UserVoiceStateUpdate";
id: string;
channel_id: string;
data: Partial<UserVoiceState>;
}
type: "UserVoiceStateUpdate";
id: string;
channel_id: string;
data: Partial<UserVoiceState>;
}
| {
type: "UserMoveVoiceChannel";
node: string;
token: string;
};
type: "UserMoveVoiceChannel";
node: string;
token: string;
}
| {
type: "UserSlowmodes";
slowmodes: UserSlowmodes[];
};
/**
* Policy change type
@@ -235,6 +239,16 @@ type ChannelVoiceState = {
participants: UserVoiceState[];
};
/**
* Channel slowmodes for the active user
*/
export type UserSlowmodes = {
channel_id: string;
duration: number;
retry_after: number;
receivedAt?: number;
};
/**
* Initial synchronisation packet
*/
@@ -276,42 +290,53 @@ export async function handleEvent(
}
case "Ready": {
batch(() => {
for (const user of event.users) {
const u = client.users.getOrCreate(user._id, user);
if (event.users) {
for (const user of event.users) {
const u = client.users.getOrCreate(user._id, user);
if (u.relationship === "User") {
client.user = u;
}
}
for (const server of event.servers) {
client.servers.getOrCreate(server._id, server);
}
for (const member of event.members) {
client.serverMembers.getOrCreate(member._id, member);
}
for (const channel of event.channels) {
client.channels.getOrCreate(channel._id, channel);
}
for (const state of event.voice_states) {
const channel = client.channels.get(state.id);
if (channel) {
channel.voiceParticipants.clear();
for (const participant of state.participants) {
channel.voiceParticipants.set(
participant.id,
new VoiceParticipant(client, participant),
);
if (u.relationship === "User") {
client.user = u;
}
}
}
for (const emoji of event.emojis) {
client.emojis.getOrCreate(emoji._id, emoji);
if (event.servers) {
for (const server of event.servers) {
client.servers.getOrCreate(server._id, server);
}
}
if (event.members) {
for (const member of event.members) {
client.serverMembers.getOrCreate(member._id, member);
}
}
if (event.channels) {
for (const channel of event.channels) {
client.channels.getOrCreate(channel._id, channel);
}
}
if (event.voice_states) {
for (const state of event.voice_states) {
const channel = client.channels.get(state.id);
if (channel) {
channel.voiceParticipants.clear();
for (const participant of state.participants) {
channel.voiceParticipants.set(
participant.id,
new VoiceParticipant(client, participant),
);
}
}
}
}
if (event.emojis) {
for (const emoji of event.emojis) {
client.emojis.getOrCreate(emoji._id, emoji);
}
}
});
@@ -322,7 +347,7 @@ export async function handleEvent(
setReady(true);
client.emit("ready");
if (event.policy_changes.length) {
if (event.policy_changes?.length) {
client.emit("policyChanges", event.policy_changes, async () =>
client.api.post("/policy/acknowledge"),
);
@@ -773,6 +798,9 @@ export async function handleEvent(
case "Timeout":
changes["timeout"] = undefined;
break;
case "Pronouns":
changes["pronouns"] = undefined;
break;
}
}
}
@@ -844,6 +872,9 @@ export async function handleEvent(
text: undefined,
};
break;
case "Pronouns":
changes["pronouns"] = undefined;
break;
}
}
}
@@ -973,5 +1004,12 @@ export async function handleEvent(
// todo
break;
}
case "UserSlowmodes": {
for (const slowmode of event.slowmodes) {
client.setSlowmode(slowmode.channel_id, slowmode);
}
client.emit("userSlowmodes");
break;
}
}
}
+1 -1
View File
@@ -41,4 +41,4 @@ export const botHydration: Hydrate<APIBot, HydratedBot> = {
/**
* Flags attributed to users
*/
export enum BotFlags { }
export enum BotFlags {}
+10 -5
View File
@@ -6,8 +6,6 @@ import { File } from "../classes/File.js";
import type { Merge } from "../lib/merge.js";
import type { Hydrate } from "./index.js";
import { VoiceParticipant } from "../classes/VoiceParticipant.js";
import { ReactiveMap } from "@solid-primitives/map";
export type HydratedChannel = {
id: string;
@@ -29,6 +27,7 @@ export type HydratedChannel = {
defaultPermissions?: { a: bigint; d: bigint };
rolePermissions?: Record<string, { a: bigint; d: bigint }>;
nsfw: boolean;
slowmode: number;
lastMessageId?: string;
@@ -46,6 +45,7 @@ export const channelHydration: Hydrate<Merge<APIChannel>, HydratedChannel> = {
default_permissions: "defaultPermissions",
role_permissions: "rolePermissions",
last_message_id: "lastMessageId",
slowmode: "slowmode",
},
functions: {
id: (channel) => channel._id,
@@ -76,10 +76,15 @@ export const channelHydration: Hydrate<Merge<APIChannel>, HydratedChannel> = {
),
nsfw: (channel) => channel.nsfw || false,
lastMessageId: (channel) => channel.last_message_id!,
slowmode: (channel) => channel.slowmode ?? 0,
voice: (channel) =>
!!channel.voice || channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group' ? ({
maxUsers: channel.voice?.max_users || undefined,
}) : undefined,
!!channel.voice ||
channel.channel_type === "DirectMessage" ||
channel.channel_type === "Group"
? {
maxUsers: channel.voice?.max_users || undefined,
}
: undefined,
},
initialHydration: () => ({
typingIds: new ReactiveSet(),
+2 -2
View File
@@ -1,6 +1,6 @@
import { ReactiveMap } from "@solid-primitives/map";
import { ReactiveSet } from "@solid-primitives/set";
import type { Embed, Interactions, Masquerade, Message } from "stoat-api";
import type { Interactions, Masquerade, Message } from "stoat-api";
import type { Client } from "../Client.js";
import { File } from "../classes/File.js";
@@ -98,4 +98,4 @@ export enum MessageFlags {
* This cannot be true if MentionsEveryone is true
*/
MentionsOnline = 3,
}
}
+2
View File
@@ -11,6 +11,7 @@ export type HydratedServerMember = {
joinedAt: Date;
nickname?: string;
avatar?: File;
pronouns?: string;
roles: string[];
timeout?: Date;
};
@@ -28,6 +29,7 @@ export const serverMemberHydration: Hydrate<
joinedAt: (member) => new Date(member.joined_at),
nickname: (member) => member.nickname!,
avatar: (member, ctx) => new File(ctx as Client, member.avatar!),
pronouns: (member) => member.pronouns!,
roles: (member) => member.roles,
timeout: (member) => new Date(member.timeout!),
},
+2
View File
@@ -25,6 +25,7 @@ export type HydratedUser = {
flags: UserFlags;
avatar?: File;
pronouns?: string;
status?: UserStatus;
bot?: BotInformation;
};
@@ -49,6 +50,7 @@ export const userHydration: Hydrate<APIUser, HydratedUser> = {
flags: (user) => user.flags!,
avatar: (user, ctx) => new File(ctx as Client, user.avatar!),
pronouns: (user) => user.pronouns,
status: (user) => user.status!,
bot: (user) => user.bot!,
},
+1 -1
View File
@@ -8,4 +8,4 @@ export { BotFlags } from "./hydration/bot.js";
export { ServerFlags } from "./hydration/server.js";
export { UserBadges, UserFlags } from "./hydration/user.js";
export * from "./lib/regex.js";
export * from './permissions/definitions.js';
export * from "./permissions/definitions.js";
+5
View File
@@ -8,6 +8,11 @@ export const RE_MENTIONS = /<@([0-9ABCDEFGHJKMNPQRSTVWXYZ]{26})>/g;
*/
export const RE_CHANNELS = /<#([0-9ABCDEFGHJKMNPQRSTVWXYZ]{26})>/g;
/**
* Regular expression for stripping custom emojis.
*/
export const RE_CUSTOM_EMOJI = /:([0-9ABCDEFGHJKMNPQRSTVWXYZ]{26}):/g;
/**
* Regular expression for spoilers.
*/
+2
View File
@@ -67,6 +67,8 @@ export const Permission = {
Masquerade: 2n ** 28n,
/// React to messages with emoji
React: 2n ** 29n,
/// Bypass slowmode
BypassSlowmode: 2n ** 39n,
// * Voice permissions
/// Connect to a voice channel
+1 -1
View File
@@ -40,7 +40,7 @@ export class ObjectStorage<T> {
id: string,
type: keyof Hydrators,
context: unknown,
data?: unknown
data?: unknown,
): void {
if (data) {
data = { partial: false, ...data };