diff --git a/src/Client.ts b/src/Client.ts index 3fcdcfa0..2c77701d 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,9 +1,10 @@ import { Accessor, Setter, createSignal } from "solid-js"; -import { API, Metadata } from "revolt-api"; +import EventEmitter from "eventemitter3"; +import { API, Metadata, RelationshipStatus, Role } from "revolt-api"; import type { DataLogin, RevoltConfig } from "revolt-api"; -import { User } from "./classes"; +import { Channel, Emoji, Message, Server, ServerMember, User } from "./classes"; import { ChannelCollection, EmojiCollection, @@ -12,14 +13,89 @@ import { ServerMemberCollection, UserCollection, } from "./collections"; -import { EventClient, createEventClient } from "./events/client"; +import { ConnectionState, EventClient } from "./events/client"; +import { handleEvent } from "./events/v1"; +import { + HydratedChannel, + HydratedEmoji, + HydratedMessage, + HydratedServer, + HydratedServerMember, + HydratedUser, +} from "./hydration"; -export type Session = { token: string; user_id: string } | string; +export type Session = { _id: string; token: string; user_id: string } | string; /** - * Revolt.js Client + * Events provided by the client */ -export class Client { +type Events = { + error(error: Error): void; + + connected(): void; + connecting(): void; + disconnected(): void; + ready(): void; + logout(): void; + + messageCreate(message: Message): void; + messageUpdate(message: Message, previousMessage: HydratedMessage): void; + messageDelete(message: HydratedMessage): void; + messageDeleteBulk(messages: HydratedMessage[], channel?: Channel): void; + messageReactionAdd(message: Message, userId: string, emoji: string): void; + messageReactionRemove(message: Message, userId: string, emoji: string): void; + messageReactionRemoveEmoji(message: Message, emoji: string): void; + + channelCreate(channel: Channel): void; + channelUpdate(channel: Channel, previousChannel: HydratedChannel): void; + channelDelete(channel: HydratedChannel): void; + channelGroupJoin(channel: Channel, user: User): void; + channelGroupLeave(channel: Channel, user?: User): void; + channelStartTyping(channel: Channel, user?: User): void; + channelStopTyping(channel: Channel, user?: User): void; + channelAcknowledged(channel: Channel, messageId: string): void; + + serverCreate(server: Server): void; + serverUpdate(server: Server, previousServer: HydratedServer): void; + serverDelete(server: HydratedServer): void; + serverRoleUpdate(server: Server, roleId: string, previousRole: Role): void; + serverRoleDelete(server: Server, roleId: string, role: Role): void; + + serverMemberUpdate( + member: ServerMember, + previousMember: HydratedServerMember + ): void; + serverMemberJoin(member: ServerMember): void; + serverMemberLeave(member: HydratedServerMember): void; + + userUpdate(user: User, previousUser: HydratedUser): void; + // ^ userRelationshipChanged(user: User, previousRelationship: RelationshipStatus): void; + // ^ userPresenceChanged(user: User, previousPresence: boolean): void; + userSettingsUpdate( + id: string, + update: Record + ): void; + + emojiCreate(emoji: Emoji): void; + emojiDelete(emoji: HydratedEmoji): void; +}; + +/** + * Client options object + */ +export interface ClientOptions { + baseURL: string; + + /** + * Whether to allow partial objects to emit from events. + */ + partials: boolean; +} + +/** + * Revolt.js Clients + */ +export class Client extends EventEmitter { readonly channels; readonly emojis; readonly messages; @@ -28,7 +104,7 @@ export class Client { readonly serverMembers; readonly api: API; - readonly baseURL: string; + readonly options: ClientOptions; readonly events: EventClient<1>; configuration: RevoltConfig | undefined; @@ -41,13 +117,18 @@ export class Client { /** * Create Revolt.js Client */ - constructor(baseURL?: string) { - this.baseURL = baseURL ?? "https://api.revolt.chat"; - this.api = new API({ - baseURL, - }); + constructor(options?: Partial) { + super(); - this.events = createEventClient(1); + this.options = { + baseURL: "https://api.revolt.chat", + partials: false, + ...options, + }; + + this.api = new API({ + baseURL: this.options.baseURL, + }); const [ready, setReady] = createSignal(false); this.ready = ready; @@ -59,67 +140,35 @@ export class Client { this.users = new UserCollection(this); this.servers = new ServerCollection(this); this.serverMembers = new ServerMemberCollection(this); - } - /** - * Connect - */ - connect() { - this.events.on("event", (event) => { - console.info("[EVENT]", JSON.stringify(event).substring(0, 32)); - if (event.type === "Ready") { - console.time("load users"); - for (const user of event.users) { - const u = this.users.getOrCreate(user._id, user); - - if (u.relationship === "User") { - this.user = u; - } - } - console.timeEnd("load users"); - console.time("load servers"); - for (const server of event.servers) { - this.servers.getOrCreate(server._id, server); - } - console.timeEnd("load servers"); - console.time("load memberships"); - for (const member of event.members) { - this.serverMembers.getOrCreate(member._id, member); - } - console.timeEnd("load memberships"); - console.time("load channels"); - for (const channel of event.channels) { - this.channels.getOrCreate(channel._id, channel); - } - console.timeEnd("load channels"); - console.time("load emojis"); - for (const emoji of event.emojis) { - this.emojis.getOrCreate(emoji._id, emoji); - } - console.timeEnd("load emojis"); - - const lounge = this.servers.get("01F7ZSBSFHQ8TA81725KQCSDDP")!; - console.info( - `The owner of ${lounge.name} is ${lounge.owner!.username}!` - ); - console.log(lounge.owner); - console.log( - "It has the channels:", - lounge.channels.map((channel) => channel.name) - ); - /*console.log( - "They joined at:", - this.serverMembers.get({ server: lounge.id, user: lounge.owner!.id }) - ?.joinedAt - );*/ - - this.#setReady(true); + this.events = new EventClient(1); + this.events.on("error", (error) => this.emit("error", error)); + this.events.on("state", (state) => { + console.info("[state]", state); + switch (state) { + case ConnectionState.Connected: + this.emit("connected"); + break; + case ConnectionState.Connecting: + this.emit("connecting"); + break; + case ConnectionState.Disconnected: + this.emit("disconnected"); + break; } }); - this.events.on("state", (state) => console.info("STATE =", state)); - this.events.on("error", (error) => console.error("ERROR =", error)); + this.events.on("event", (event) => + handleEvent(this, event, this.#setReady) + ); + } + /** + * Connect to Revolt + */ + connect() { + this.events.disconnect(); + this.#setReady(false); this.events.connect( "wss://ws.revolt.chat", typeof this.session === "string" ? this.session : this.session!.token @@ -140,7 +189,7 @@ export class Client { */ #updateHeaders() { (this.api as API) = new API({ - baseURL: this.baseURL, + baseURL: this.options.baseURL, authentication: { revolt: this.session, }, @@ -187,14 +236,14 @@ export class Client { } /** - * Generates a URL to a given file with given options. + * Creates a URL to a given file with given options. * @param attachment Partial of attachment object * @param options Optional query parameters to modify object * @param allowAnimation Returns GIF if applicable, no operations occur on image * @param fallback Fallback URL * @returns Generated URL or nothing */ - generateFileURL( + createFileURL( attachment?: { tag: string; _id: string; diff --git a/src/classes/Channel.ts b/src/classes/Channel.ts index 0d131e33..153507af 100644 --- a/src/classes/Channel.ts +++ b/src/classes/Channel.ts @@ -1,16 +1,17 @@ import type { Channel as ApiChannel, + Member as ApiMember, + Message as ApiMessage, + User as ApiUser, DataEditChannel, DataMessageSend, - Member, - Message, OptionsMessageSearch, Override, - User, } from "revolt-api"; import { APIRoutes } from "revolt-api/dist/routes"; import { decodeTime, ulid } from "ulid"; +import { Message } from ".."; import { ChannelCollection } from "../collections"; import { bitwiseAndEq, calculatePermission } from "../permissions/calculator"; import { Permission } from "../permissions/definitions"; @@ -93,7 +94,23 @@ export class Channel { } /** - * User Ids of recipients of the group + * User ids of people currently typing in channel + */ + get typingIds() { + return this.collection.getUnderlyingObject(this.id).recipientIds; + } + + /** + * Users currently trying in channel + */ + get typing() { + return [ + ...this.collection.getUnderlyingObject(this.id).typingIds.values(), + ].map((id) => this.collection.client.users.get(id)!); + } + + /** + * User ids of recipients of the group */ get recipientIds() { return this.collection.getUnderlyingObject(this.id).recipientIds; @@ -103,9 +120,9 @@ export class Channel { * Recipients of the group */ get recipients() { - return this.collection - .getUnderlyingObject(this.id) - .recipientIds.map((id) => this.collection.client.users.get(id)!); + return [ + ...this.collection.getUnderlyingObject(this.id).recipientIds.values(), + ].map((id) => this.collection.client.users.get(id)!); } /** @@ -232,7 +249,7 @@ export class Channel { * URL to the channel icon */ get iconURL() { - return this.collection.client.generateFileURL( + return this.collection.client.createFileURL( this.icon ?? this.recipient?.avatar, { max_side: 256, @@ -244,7 +261,7 @@ export class Channel { * URL to a small variant of the channel icon */ get smallIconURL() { - return this.collection.client.generateFileURL( + return this.collection.client.createFileURL( this.icon ?? this.recipient?.avatar, { max_side: 64, @@ -256,7 +273,7 @@ export class Channel { * URL to the animated channel icon */ get animatedIconURL() { - return this.collection.client.generateFileURL( + return this.collection.client.createFileURL( this.icon ?? this.recipient?.avatar, { max_side: 256 }, true @@ -322,13 +339,6 @@ export class Channel { return; } - if (this.type === "TextChannel" || this.type === "VoiceChannel") { - const server = this.server; - if (server) { - server.channelIds.delete(this.id); - } - } - this.collection.client.channels.delete(this.id); } @@ -373,7 +383,11 @@ export class Channel { } ); - return this.collection.client.messages.getOrCreate(message._id, message); + return this.collection.client.messages.getOrCreate( + message._id, + message, + true + ); } /** @@ -406,7 +420,7 @@ export class Channel { const messages = (await this.collection.client.api.get( `/channels/${this.id as ""}/messages`, { ...params } - )) as Message[]; + )) as ApiMessage[]; return messages.map((message) => this.collection.client.messages.getOrCreate(message._id, message) @@ -430,7 +444,7 @@ export class Channel { const data = (await this.collection.client.api.get( `/channels/${this.id as ""}/messages`, { ...params, include_users: true } - )) as { messages: Message[]; users: User[]; members?: Member[] }; + )) as { messages: ApiMessage[]; users: ApiUser[]; members?: ApiMember[] }; return { messages: data.messages.map((message) => @@ -454,7 +468,7 @@ export class Channel { const messages = (await this.collection.client.api.post( `/channels/${this.id as ""}/search`, params - )) as Message[]; + )) as ApiMessage[]; return messages.map((message) => this.collection.client.messages.getOrCreate(message._id, message) @@ -473,7 +487,7 @@ export class Channel { ...params, include_users: true, } - )) as { messages: Message[]; users: User[]; members?: Member[] }; + )) as { messages: ApiMessage[]; users: ApiUser[]; members?: ApiMember[] }; return { messages: data.messages.map((message) => @@ -517,16 +531,17 @@ export class Channel { */ async ack(message?: Message | string, skipRateLimiter?: boolean) { const id = - (typeof message === "string" ? message : message?._id) ?? + (typeof message === "string" ? message : message?.id) ?? this.lastMessageId ?? ulid(); + const performAck = () => { this.#ackLimit = undefined; this.collection.client.api.put(`/channels/${this.id}/ack/${id as ""}`); }; - /* TODO: if (!this.collection.client.options.ackRateLimiter || skipRateLimiter) - return performAck();*/ + // TODO: !this.collection.client.options.ackRateLimiter + if (skipRateLimiter) return performAck(); clearTimeout(this.#ackTimeout); if (this.#ackLimit && +new Date() > this.#ackLimit) { diff --git a/src/classes/Message.ts b/src/classes/Message.ts index c8afd1b6..9c5dd9bc 100644 --- a/src/classes/Message.ts +++ b/src/classes/Message.ts @@ -1,3 +1,4 @@ +import { DataEditMessage, DataMessageSend } from "revolt-api"; import { decodeTime } from "ulid"; import { MessageCollection } from "../collections"; @@ -247,4 +248,82 @@ export class Message { return { type, from: get(system.from), to: get(system.to) }; } } + + /** + * Edit a message + * @param data Message edit route data + */ + async edit(data: DataEditMessage) { + return await this.collection.client.api.patch( + `/channels/${this.channelId as ""}/messages/${this.id as ""}`, + data + ); + } + + /** + * Delete a message + */ + async delete() { + return await this.collection.client.api.delete( + `/channels/${this.channelId as ""}/messages/${this.id as ""}` + ); + } + + /** + * Acknowledge this message as read + */ + ack() { + this.channel?.ack(this); + } + + /** + * Reply to Message + */ + reply( + data: + | string + | (Omit & { + nonce?: string; + }), + mention = true + ) { + const obj = typeof data === "string" ? { content: data } : data; + return this.channel?.sendMessage({ + ...obj, + replies: [{ id: this.id, mention }], + }); + } + + /** + * Clear all reactions from this message + */ + async clearReactions() { + return await this.collection.client.api.delete( + `/channels/${this.channelId as ""}/messages/${this.id as ""}/reactions` + ); + } + + /** + * React to a message + * @param emoji Unicode or emoji ID + */ + async react(emoji: string) { + return await this.collection.client.api.put( + `/channels/${this.channelId as ""}/messages/${this.id as ""}/reactions/${ + emoji as "" + }` + ); + } + + /** + * Unreact from a message + * @param emoji Unicode or emoji ID + */ + async unreact(emoji: string) { + return await this.collection.client.api.delete( + `/channels/${this.channelId as ""}/messages/${this.id as ""}/reactions/${ + emoji as "" + }` + ); + } } diff --git a/src/classes/Server.ts b/src/classes/Server.ts index acb23d00..9595ee9e 100644 --- a/src/classes/Server.ts +++ b/src/classes/Server.ts @@ -1,4 +1,4 @@ -import type { Server as ApiServer, Category } from "revolt-api"; +import type { Category } from "revolt-api"; import { decodeTime } from "ulid"; import { ServerCollection } from "../collections"; @@ -237,14 +237,14 @@ export class Server { * URL to the server's icon */ get iconURL() { - return this.collection.client.generateFileURL(this.icon, { max_side: 256 }); + return this.collection.client.createFileURL(this.icon, { max_side: 256 }); } /** * URL to the server's animated icon */ get animatedIconURL() { - return this.collection.client.generateFileURL( + return this.collection.client.createFileURL( this.icon, { max_side: 256 }, true @@ -255,7 +255,7 @@ export class Server { * URL to the server's banner */ get bannerURL() { - return this.collection.client.generateFileURL(this.banner, { + return this.collection.client.createFileURL(this.banner, { max_side: 256, }); } diff --git a/src/classes/ServerMember.ts b/src/classes/ServerMember.ts index 72335649..4122e6b8 100644 --- a/src/classes/ServerMember.ts +++ b/src/classes/ServerMember.ts @@ -179,7 +179,7 @@ export class ServerMember { */ get avatarURL() { return ( - this.collection.client.generateFileURL(this.avatar, { max_side: 256 }) ?? + this.collection.client.createFileURL(this.avatar, { max_side: 256 }) ?? this.user?.avatarURL ); } @@ -189,7 +189,7 @@ export class ServerMember { */ get animatedAvatarURL() { return ( - this.collection.client.generateFileURL( + this.collection.client.createFileURL( this.avatar, { max_side: 256 }, true diff --git a/src/classes/User.ts b/src/classes/User.ts index 4b2072fd..b8fc1297 100644 --- a/src/classes/User.ts +++ b/src/classes/User.ts @@ -102,7 +102,7 @@ export class User { */ get avatarURL() { return ( - this.collection.client.generateFileURL(this.avatar, { max_side: 256 }) ?? + this.collection.client.createFileURL(this.avatar, { max_side: 256 }) ?? this.defaultAvatarURL ); } @@ -112,7 +112,7 @@ export class User { */ get animatedAvatarURL() { return ( - this.collection.client.generateFileURL( + this.collection.client.createFileURL( this.avatar, { max_side: 256 }, true diff --git a/src/collections/Collection.ts b/src/collections/Collection.ts index cb5ad716..bd5556bd 100644 --- a/src/collections/Collection.ts +++ b/src/collections/Collection.ts @@ -2,7 +2,7 @@ import { SetStoreFunction } from "solid-js/store"; import { ReactiveMap } from "@solid-primitives/map"; -import { Hydrators, hydrate } from "../hydration"; +import { Hydrators } from "../hydration"; import { ObjectStorage } from "../storage/ObjectStorage"; /** @@ -127,6 +127,15 @@ export abstract class StoreCollection extends Collection { this.#objects.set(id, instance); } + /** + * Check whether an object is partially defined + * @param id Id + * @returns Whether it is a partial + */ + isPartial(id: string): boolean { + return !!(this.getUnderlyingObject(id) as { partial: boolean }).partial; + } + /** * Number of stored objects * @returns Size diff --git a/src/collections/index.ts b/src/collections/index.ts index f351283b..2bc8fff2 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -33,12 +33,22 @@ export class ChannelCollection extends ClassCollection< Channel, HydratedChannel > { + /** + * Delete an object + * @param id Id + */ + override delete(id: string): void { + let channel = this.get(id); + channel?.server?.channelIds.delete(id); + super.delete(id); + } + /** * Fetch channel by ID * @param id Id * @returns Channel */ - async fetch(id: string): Promise { + async fetch(id: string): Promise { const channel = this.get(id); if (channel) return channel; const data = await this.client.api.get(`/channels/${id as ""}`); @@ -49,13 +59,32 @@ export class ChannelCollection extends ClassCollection< * Get or create * @param id Id * @param data Data + * @param isNew Whether this object is new */ - getOrCreate(id: string, data: API.Channel) { + getOrCreate(id: string, data: API.Channel, isNew = false) { if (this.has(id)) { return this.get(id)!; } else { const instance = new Channel(this, id); this.create(id, "channel", instance, data); + isNew && this.client.emit("channelCreate", instance); + return instance; + } + } + + /** + * Get or return partial + * @param id Id + */ + getOrPartial(id: string) { + if (this.has(id)) { + return this.get(id)!; + } else if (this.client.options.partials) { + const instance = new Channel(this, id); + this.create(id, "channel", instance, { + id, + partial: true, + }); return instance; } } @@ -67,7 +96,7 @@ export class EmojiCollection extends ClassCollection { * @param id Id * @returns Emoji */ - async fetch(id: string): Promise { + async fetch(id: string): Promise { const emoji = this.get(id); if (emoji) return emoji; const data = await this.client.api.get(`/custom/emoji/${id as ""}`); @@ -78,13 +107,31 @@ export class EmojiCollection extends ClassCollection { * Get or create * @param id Id * @param data Data + * @param isNew Whether this object is new */ - getOrCreate(id: string, data: API.Emoji) { + getOrCreate(id: string, data: API.Emoji, isNew = false) { if (this.has(id)) { return this.get(id)!; } else { const instance = new Emoji(this, id); this.create(id, "emoji", instance, data); + isNew && this.client.emit("emojiCreate", instance); + return instance; + } + } + + /** + * Get or return partial + * @param id Id + */ + getOrPartial(id: string) { + if (this.has(id)) { + return this.get(id)!; + } else if (this.client.options.partials) { + const instance = new Emoji(this, id); + this.create(id, "emoji", instance, { + id, + }); return instance; } } @@ -100,10 +147,7 @@ export class MessageCollection extends ClassCollection< * @param messageId Message Id * @returns Message */ - async fetch( - channelId: string, - messageId: string - ): Promise { + async fetch(channelId: string, messageId: string): Promise { const message = this.get(messageId); if (message) return message; @@ -111,20 +155,39 @@ export class MessageCollection extends ClassCollection< `/channels/${channelId as ""}/messages/${messageId as ""}` ); - return this.getOrCreate(data._id, data); + return this.getOrCreate(data._id, data, false); } /** * Get or create * @param id Id * @param data Data + * @param isNew Whether this object is new */ - getOrCreate(id: string, data: API.Message) { + getOrCreate(id: string, data: API.Message, isNew = false) { if (this.has(id)) { return this.get(id)!; } else { const instance = new Message(this, id); this.create(id, "message", instance, data); + isNew && this.client.emit("messageCreate", instance); + return instance; + } + } + + /** + * Get or return partial + * @param id Id + */ + getOrPartial(id: string) { + if (this.has(id)) { + return this.get(id)!; + } else if (this.client.options.partials) { + const instance = new Message(this, id); + this.create(id, "message", instance, { + id, + partial: true, + }); return instance; } } @@ -136,7 +199,7 @@ export class ServerCollection extends ClassCollection { * @param id Id * @returns Server */ - async fetch(id: string): Promise { + async fetch(id: string): Promise { const server = this.get(id); if (server) return server; const data = await this.client.api.get(`/servers/${id as ""}`); @@ -147,13 +210,32 @@ export class ServerCollection extends ClassCollection { * Get or create * @param id Id * @param data Data + * @param isNew Whether this object is new */ - getOrCreate(id: string, data: API.Server) { + getOrCreate(id: string, data: API.Server, isNew = false) { if (this.has(id)) { return this.get(id)!; } else { const instance = new Server(this, id); this.create(id, "server", instance, data); + isNew && this.client.emit("serverCreate", instance); + return instance; + } + } + + /** + * Get or return partial + * @param id Id + */ + getOrPartial(id: string) { + if (this.has(id)) { + return this.get(id)!; + } else if (this.client.options.partials) { + const instance = new Server(this, id); + this.create(id, "server", instance, { + id, + partial: true, + }); return instance; } } @@ -165,7 +247,7 @@ export class UserCollection extends ClassCollection { * @param id Id * @returns User */ - async fetch(id: string): Promise { + async fetch(id: string): Promise { const user = this.get(id); if (user) return user; const data = await this.client.api.get(`/users/${id as ""}`); @@ -176,6 +258,7 @@ export class UserCollection extends ClassCollection { * Get or create * @param id Id * @param data Data + * @param isNew Whether this object is new */ getOrCreate(id: string, data: API.User) { if (this.has(id)) { @@ -186,6 +269,23 @@ export class UserCollection extends ClassCollection { return instance; } } + + /** + * Get or return partial + * @param id Id + */ + getOrPartial(id: string) { + if (this.has(id)) { + return this.get(id)!; + } else if (this.client.options.partials) { + const instance = new User(this, id); + this.create(id, "user", instance, { + id, + partial: true, + }); + return instance; + } + } } export class ServerMemberCollection extends ClassCollection< @@ -216,10 +316,7 @@ export class ServerMemberCollection extends ClassCollection< * @param userId User Id * @returns Message */ - async fetch( - serverId: string, - userId: string - ): Promise { + async fetch(serverId: string, userId: string): Promise { const member = this.get(userId); if (member) return member; @@ -244,4 +341,21 @@ export class ServerMemberCollection extends ClassCollection< return instance; } } + + /** + * Get or return partial + * @param id Id + */ + getOrPartial(id: API.MemberCompositeKey) { + if (this.hasByKey(id)) { + return this.getByKey(id)!; + } else if (this.client.options.partials) { + const instance = new ServerMember(this, id); + this.create(id.server + id.user, "serverMember", instance, { + id, + partial: true, + }); + return instance; + } + } } diff --git a/src/events/client.ts b/src/events/client.ts index 93171645..1a091720 100644 --- a/src/events/client.ts +++ b/src/events/client.ts @@ -2,7 +2,6 @@ import { Accessor, Setter, createSignal } from "solid-js"; import EventEmitter from "eventemitter3"; import WebSocket from "isomorphic-ws"; -import type TypedEmitter from "typed-emitter"; import type { AvailableProtocols, EventProtocol } from "."; @@ -28,10 +27,9 @@ type Events> = { /** * Simple wrapper around the Revolt websocket service. */ -class Client< - T extends AvailableProtocols, - P extends EventProtocol -> extends EventEmitter { +export class EventClient extends EventEmitter< + Events> +> { #protocolVersion: T; #transportFormat: "json" | "msgpack"; #heartbeatInterval: number; @@ -78,7 +76,6 @@ class Client< private setState(state: ConnectionState) { this.#setStateSetter(state); this.emit("state", state); - // TODO: debug weird state changes } /** @@ -107,7 +104,7 @@ class Client< }; this.#socket.onerror = (error) => { - this.emit("error", error); + this.emit("error", error as never); }; this.#socket.onmessage = (event) => { @@ -129,14 +126,16 @@ class Client< */ disconnect() { if (!this.#socket) return; + this.setState(ConnectionState.Disconnected); this.#socket.close(); + this.#socket = undefined; } /** * Send an event to the server. * @param event Event */ - send(event: P["client"]) { + send(event: EventProtocol["client"]) { console.info(event); } @@ -144,7 +143,7 @@ class Client< * Handle events intended for client before passing them along. * @param event Event */ - handle(event: P["server"]) { + handle(event: EventProtocol["server"]) { switch (event.type) { case "Ping": this.send({ @@ -156,7 +155,7 @@ class Client< clearTimeout(this.#pongTimeoutReference); return; case "Error": - this.emit("error", event); + this.emit("error", event as never); this.disconnect(); return; } @@ -186,36 +185,3 @@ class Client< } } } - -/** - * Event client - */ -export type EventClient = Omit< - Client>, - "on" | "once" -> & - TypedEmitter>>; - -/** - * Create a new event client. - * @param protocolVersion Target protocol version - * @param transportFormat Communication format - * @param heartbeatInterval Interval in seconds to send ping - * @param pongTimeout Time in seconds until heartbeat times out - */ -export function createEventClient< - T extends AvailableProtocols, - P extends EventProtocol ->( - protocolVersion: T, - transportFormat?: "json", - heartbeatInterval?: number, - pongTimeout?: number -) { - return new Client( - protocolVersion, - transportFormat, - heartbeatInterval, - pongTimeout - ) as never as EventClient; -} diff --git a/src/events/v1.ts b/src/events/v1.ts index 7a199142..00d56b04 100644 --- a/src/events/v1.ts +++ b/src/events/v1.ts @@ -1,5 +1,9 @@ +import { Setter } from "solid-js"; + +import { ReactiveSet } from "@solid-primitives/set"; import type { Channel, + Embed, Emoji, FieldsChannel, FieldsMember, @@ -14,6 +18,9 @@ import type { User, } from "revolt-api"; +import { Client } from ".."; +import { Merge } from "../lib/merge"; + /** * Version 1 of the events protocol */ @@ -182,3 +189,592 @@ type WebSocketError = { | "OnboardingNotFinished" | "AlreadyAuthenticated"; }; + +/** + * Handle an event for the Client + * @param client Client + * @param event Event + * @param setReady Signal state change + */ +export async function handleEvent( + client: Client, + event: ServerMessage, + setReady: Setter +) { + console.info("[EVENT]", JSON.stringify(event).substring(0, 64)); + switch (event.type) { + case "Bulk": { + for (const item of event.v) { + handleEvent(client, item, setReady); + } + break; + } + case "Ready": { + 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 emoji of event.emojis) { + client.emojis.getOrCreate(emoji._id, emoji); + } + + setReady(true); + client.emit("ready"); + break; + } + case "Message": { + if (!client.messages.has(event._id)) { + client.messages.getOrCreate(event._id, event, true); + } + break; + } + case "MessageUpdate": { + const message = client.messages.getOrPartial(event.id); + if (message) { + const previousMessage = { + ...client.messages.getUnderlyingObject(event.id), + }; + + client.messages.updateUnderlyingObject(event.id, { + ...(event.data as {}), + editedAt: new Date(), + }); + + client.emit("messageUpdate", message, previousMessage); + } + break; + } + case "MessageAppend": { + const message = client.messages.getOrPartial(event.id); + if (message) { + const previousMessage = { + ...client.messages.getUnderlyingObject(event.id), + }; + + client.messages.updateUnderlyingObject( + event.id, + "embeds", + (embeds) => [...(embeds ?? []), event.append.embeds ?? []] as Embed[] + ); + + client.emit("messageUpdate", message, previousMessage); + } + break; + } + case "MessageDelete": { + if (client.messages.getOrPartial(event.id)) { + const message = client.messages.getUnderlyingObject(event.id); + client.emit("messageDelete", message); + client.messages.delete(event.id); + } + break; + } + case "BulkMessageDelete": { + client.emit( + "messageDeleteBulk", + event.ids + .map((id) => { + if (client.messages.has(id)) { + const message = client.messages.getUnderlyingObject(id); + client.messages.delete(id); + return message!; + } + + return undefined!; + }) + .filter((x) => x), + client.channels.get(event.channel) + ); + break; + } + case "MessageReact": { + const message = client.messages.getOrPartial(event.id); + if (message) { + const reactions = message.reactions; + const set = reactions.get(event.emoji_id)!; + if (set) { + if (set.has(event.user_id)) return; + set.add(event.user_id); + } else { + reactions.set(event.emoji_id, new ReactiveSet([event.user_id])); + } + + client.emit( + "messageReactionAdd", + message, + event.user_id, + event.emoji_id + ); + } + break; + } + case "MessageUnreact": { + const message = client.messages.getOrPartial(event.id); + if (message) { + const set = message.reactions.get(event.emoji_id); + if (set?.has(event.user_id)) { + set.delete(event.user_id); + } else if (!client.messages.isPartial(event.id)) { + return; + } + + client.emit( + "messageReactionRemove", + message, + event.user_id, + event.emoji_id + ); + } + break; + } + case "MessageRemoveReaction": { + const message = client.messages.getOrPartial(event.id); + if (message) { + const reactions = message.reactions; + if (reactions.has(event.emoji_id)) { + reactions.delete(event.emoji_id); + } else if (!client.messages.isPartial(event.id)) { + return; + } + + client.emit("messageReactionRemoveEmoji", message, event.emoji_id); + } + break; + } + case "ChannelCreate": { + if (!client.channels.has(event._id)) { + client.channels.getOrCreate(event._id, event, true); + } + break; + } + case "ChannelUpdate": { + const channel = client.channels.getOrPartial(event.id); + if (channel) { + const previousChannel = { + ...client.channels.getUnderlyingObject(event.id), + }; + + const changes = { + ...(event.data as Merge), + }; + + if (event.clear) { + for (const remove of event.clear) { + switch (remove) { + case "Description": + changes["description"] = undefined; + break; + case "DefaultPermissions": + changes["default_permissions"] = undefined; + break; + case "Icon": + changes["icon"] = undefined; + break; + } + } + } + + client.channels.updateUnderlyingObject(event.id, changes as never); + client.emit("channelUpdate", channel, previousChannel); + } + break; + } + case "ChannelDelete": { + if (client.channels.getOrPartial(event.id)) { + const channel = client.channels.getUnderlyingObject(event.id); + client.emit("channelDelete", channel); + client.channels.delete(event.id); + } + break; + } + case "ChannelGroupJoin": { + const channel = client.channels.getOrPartial(event.id); + if (channel) { + if (!channel.recipientIds.has(event.user)) { + channel.recipientIds.add(event.user); + } else if (!client.channels.isPartial(event.id)) { + return; + } + + client.emit( + "channelGroupJoin", + channel, + await client.users.fetch(event.user) + ); + } + break; + } + case "ChannelGroupLeave": { + const channel = client.channels.getOrPartial(event.id); + if (channel) { + if (channel.recipientIds.has(event.user)) { + channel.recipientIds.delete(event.user); + } else if (!client.channels.isPartial(event.id)) { + return; + } + + client.emit( + "channelGroupLeave", + channel, + client.users.getOrPartial(event.user)! + ); + } + break; + } + case "ChannelStartTyping": { + const channel = client.channels.getOrPartial(event.id); + if (channel) { + if (!channel.typingIds.has(event.user)) { + channel.typingIds.add(event.user); + } else if (!client.channels.isPartial(event.id)) { + return; + } + + client.emit( + "channelStartTyping", + channel, + client.users.getOrPartial(event.user)! + ); + } + break; + } + case "ChannelStopTyping": { + const channel = client.channels.getOrPartial(event.id); + if (channel) { + if (channel.typingIds.has(event.user)) { + channel.typingIds.delete(event.user); + } else if (!client.channels.isPartial(event.id)) { + return; + } + + client.emit( + "channelStopTyping", + channel, + client.users.getOrPartial(event.user)! + ); + } + break; + } + case "ChannelAck": { + const channel = client.channels.getOrPartial(event.id); + if (channel) { + client.emit("channelAcknowledged", channel, event.message_id); + } + break; + } + case "ServerCreate": { + if (!client.servers.has(event.server._id)) { + for (const channel of event.channels) { + client.channels.getOrCreate(channel._id, channel); + } + + client.servers.getOrCreate(event.server._id, event.server, true); + } + break; + } + case "ServerUpdate": { + const server = client.servers.getOrPartial(event.id); + if (server) { + const previousServer = { + ...client.servers.getUnderlyingObject(event.id), + }; + + const changes = { + ...event.data, + }; + + if (event.clear) { + for (const remove of event.clear) { + switch (remove) { + case "Banner": + changes["banner"] = undefined; + break; + case "Categories": + changes["categories"] = undefined; + break; + case "SystemMessages": + changes["system_messages"] = undefined; + break; + case "Description": + changes["description"] = undefined; + break; + case "Icon": + changes["icon"] = undefined; + break; + } + } + } + + client.servers.updateUnderlyingObject(event.id, changes as never); + client.emit("serverUpdate", server, previousServer); + } + break; + } + case "ServerDelete": { + if (client.servers.getOrPartial(event.id)) { + const server = client.servers.getUnderlyingObject(event.id); + client.emit("serverDelete", server); + client.servers.delete(event.id); + + for (const channel of server.channelIds) { + client.channels.delete(channel); + } + } + break; + } + case "ServerRoleUpdate": { + const server = client.servers.getOrPartial(event.id); + if (server) { + const role = server.roles.get(event.role_id) ?? {}; + server.roles.set(event.role_id, { + ...role, + ...event.data, + } as Role); + + client.emit("serverRoleUpdate", server, event.role_id, role as never); + } + break; + } + case "ServerRoleDelete": { + const server = client.servers.getOrPartial(event.id); + if (server) { + let role = {}; + const roles = server.roles; + if (roles.has(event.role_id)) { + role = roles.get(event.role_id) as Role; + roles.delete(event.role_id); + } else if (!client.servers.isPartial(event.id)) { + return; + } + + client.emit("serverRoleDelete", server, event.role_id, role as never); + } + break; + } + case "ServerMemberJoin": { + const id = { + server: event.id, + user: event.user, + }; + + if (!client.serverMembers.hasByKey(id)) { + client.emit( + "serverMemberJoin", + client.serverMembers.getOrCreate(id, { + _id: id, + joined_at: new Date().toUTCString(), + }) + ); + } + break; + } + case "ServerMemberUpdate": { + const member = client.serverMembers.getOrPartial(event.id); + if (member) { + const previousMember = { + ...client.serverMembers.getUnderlyingObject( + event.id.server + event.id.user + ), + }; + + const changes = { + ...event.data, + }; + + if (event.clear) { + for (const remove of event.clear) { + switch (remove) { + case "Nickname": + changes["nickname"] = undefined; + break; + case "Avatar": + changes["avatar"] = undefined; + break; + case "Roles": + changes["roles"] = undefined; + break; + case "Timeout": + changes["timeout"] = undefined; + break; + } + } + } + + client.serverMembers.updateUnderlyingObject( + event.id.server + event.id.user, + changes as never + ); + + client.emit("serverMemberUpdate", member, previousMember); + } + break; + } + case "ServerMemberLeave": { + const id = { + server: event.id, + user: event.user, + }; + + if (client.serverMembers.getOrPartial(id)) { + const member = client.serverMembers.getUnderlyingObject( + id.server + id.user + ); + client.emit("serverMemberLeave", member); + client.serverMembers.delete(id.server + id.user); + } + break; + } + case "UserUpdate": { + const user = client.users.getOrPartial(event.id); + if (user) { + const previousUser = { + ...client.users.getUnderlyingObject(event.id), + }; + + const changes = { + ...event.data, + }; + + if (event.clear) { + for (const remove of event.clear) { + switch (remove) { + case "Avatar": + changes["avatar"] = undefined; + break; + case "StatusPresence": + changes["status"] = { + ...(previousUser.status ?? {}), + ...(changes["status"] ?? {}), + presence: undefined, + }; + break; + case "StatusText": + changes["status"] = { + ...(previousUser.status ?? {}), + ...(changes["status"] ?? {}), + text: undefined, + }; + break; + } + } + } + + client.users.updateUnderlyingObject(event.id, changes as never); + client.emit("userUpdate", user, previousUser); + } + break; + } + case "UserRelationship": { + handleEvent( + client, + { + type: "UserUpdate", + id: event.user._id, + data: { + relationship: event.user.relationship!, + }, + }, + setReady + ); + break; + } + case "UserPresence": { + handleEvent( + client, + { + type: "UserUpdate", + id: event.id, + data: { + online: event.online, + }, + }, + setReady + ); + break; + } + case "UserSettingsUpdate": { + client.emit("userSettingsUpdate", event.id, event.update); + break; + } + case "UserPlatformWipe": { + handleEvent( + client, + { + type: "BulkMessageDelete", + channel: "0", + ids: client.messages + .toList() + .filter((message) => message.authorId === event.user_id) + .map((message) => message.id), + }, + setReady + ); + + handleEvent( + client, + { + type: "UserUpdate", + id: event.user_id, + data: { + username: `Deleted User`, + online: false, + flags: event.flags, + badges: 0, + relationship: "None", + }, + clear: ["Avatar", "StatusPresence", "StatusText"], + }, + setReady + ); + + break; + } + case "EmojiCreate": { + if (!client.emojis.has(event._id)) { + client.emojis.getOrCreate(event._id, event, true); + } + break; + } + case "EmojiDelete": { + if (client.emojis.getOrPartial(event.id)) { + const emoji = client.emojis.getUnderlyingObject(event.id); + client.emit("emojiDelete", emoji); + client.emojis.delete(event.id); + } + break; + } + case "Auth": { + if (typeof client.session === "string") throw "Unreachable code"; + + switch (event.event_type) { + case "DeleteSession": + if (event.session_id === client.session!._id) { + // TODO: logout + } + break; + case "DeleteAllSessions": + if (event.exclude_session_id !== client.session!._id) { + // TODO: logout + } + break; + } + break; + } + } +} diff --git a/src/hydration/channel.ts b/src/hydration/channel.ts index cbbd00b8..14319578 100644 --- a/src/hydration/channel.ts +++ b/src/hydration/channel.ts @@ -1,3 +1,4 @@ +import { ReactiveSet } from "@solid-primitives/set"; import { Channel as ApiChannel, File, OverrideField } from "revolt-api"; import type { Merge } from "../lib/merge"; @@ -13,7 +14,8 @@ export type HydratedChannel = { icon?: File; active: boolean; - recipientIds: string[]; + typingIds: ReactiveSet; + recipientIds: ReactiveSet; userId?: string; ownerId?: string; @@ -46,7 +48,8 @@ export const channelHydration: Hydrate, HydratedChannel> = { description: (channel) => channel.description!, icon: (channel) => channel.icon!, active: (channel) => channel.active || false, - recipientIds: (channel) => channel.recipients ?? [], + typingIds: () => new ReactiveSet(), + recipientIds: (channel) => new ReactiveSet(channel.recipients), userId: (channel) => channel.user, ownerId: (channel) => channel.owner, serverId: (channel) => channel.server, @@ -56,4 +59,8 @@ export const channelHydration: Hydrate, HydratedChannel> = { nsfw: (channel) => channel.nsfw || false, lastMessageId: (channel) => channel.last_message_id!, }, + initialHydration: () => ({ + typingIds: new ReactiveSet(), + recipientIds: new ReactiveSet(), + }), }; diff --git a/src/hydration/emoji.ts b/src/hydration/emoji.ts index 8c1cfd66..115473ec 100644 --- a/src/hydration/emoji.ts +++ b/src/hydration/emoji.ts @@ -26,4 +26,5 @@ export const emojiHydration: Hydrate, HydratedEmoji> = { animated: (emoji) => emoji.animated || false, nsfw: (emoji) => emoji.nsfw || false, }, + initialHydration: () => ({}), }; diff --git a/src/hydration/index.ts b/src/hydration/index.ts index 0cc49ab5..4f1aeebc 100644 --- a/src/hydration/index.ts +++ b/src/hydration/index.ts @@ -31,6 +31,7 @@ export type KeyMapping = Record; export type Hydrate = { keyMapping: Partial>; functions: MappingFns; + initialHydration: () => Partial; }; /** @@ -81,13 +82,16 @@ type ExtractOutput = T extends Hydrate ? O : never; * Hydrate some input with a given type * @param type Type * @param input Input Object + * @param initial Whether this is the initial hydration * @returns Hydrated Object */ export function hydrate( type: T, - input: Partial> + input: Partial>, + initial?: boolean ) { - return hydrateInternal(hydrators[type] as never, input) as ExtractOutput< - Hydrators[T] - >; + return hydrateInternal( + hydrators[type] as never, + initial ? { ...hydrators[type].initialHydration(), ...input } : input + ) as ExtractOutput; } diff --git a/src/hydration/message.ts b/src/hydration/message.ts index 310035cd..cb3cad77 100644 --- a/src/hydration/message.ts +++ b/src/hydration/message.ts @@ -25,7 +25,7 @@ export type HydratedMessage = { embeds?: Embed[]; mentionIds?: string[]; replyIds?: string[]; - reactions?: ReactiveMap>; + reactions: ReactiveMap>; interactions?: Interactions; masquerade?: Masquerade; }; @@ -64,4 +64,7 @@ export const messageHydration: Hydrate, HydratedMessage> = { interactions: (message) => message.interactions, masquerade: (message) => message.masquerade!, }, + initialHydration: () => ({ + reactions: new ReactiveMap(), + }), }; diff --git a/src/hydration/server.ts b/src/hydration/server.ts index 143ec5d5..6d2b219f 100644 --- a/src/hydration/server.ts +++ b/src/hydration/server.ts @@ -24,7 +24,7 @@ export type HydratedServer = { categories?: Category[]; systemMessages?: SystemMessageChannels; - roles?: ReactiveMap; + roles: ReactiveMap; defaultPermissions: number; flags: ServerFlags; @@ -61,6 +61,10 @@ export const serverHydration: Hydrate = { discoverable: (server) => server.discoverable || false, nsfw: (server) => server.nsfw || false, }, + initialHydration: () => ({ + channelIds: new ReactiveSet(), + roles: new ReactiveMap(), + }), }; /** diff --git a/src/hydration/serverMember.ts b/src/hydration/serverMember.ts index 3d9398e9..561d0084 100644 --- a/src/hydration/serverMember.ts +++ b/src/hydration/serverMember.ts @@ -9,7 +9,7 @@ export type HydratedServerMember = { joinedAt: Date; nickname?: string; avatar?: File; - roles?: string[]; + roles: string[]; timeout?: Date; }; @@ -29,4 +29,7 @@ export const serverMemberHydration: Hydrate< roles: (member) => member.roles, timeout: (member) => new Date(member.timeout!), }, + initialHydration: () => ({ + roles: [], + }), }; diff --git a/src/hydration/user.ts b/src/hydration/user.ts index af1f67d2..964db2e1 100644 --- a/src/hydration/user.ts +++ b/src/hydration/user.ts @@ -43,6 +43,9 @@ export const userHydration: Hydrate = { status: (user) => user.status! ?? undefined, bot: (user) => user.bot! ?? undefined, }, + initialHydration: () => ({ + relationship: "None", + }), }; /** diff --git a/src/storage/ObjectStorage.ts b/src/storage/ObjectStorage.ts index 0e116e19..c7082389 100644 --- a/src/storage/ObjectStorage.ts +++ b/src/storage/ObjectStorage.ts @@ -36,7 +36,7 @@ export class ObjectStorage { */ hydrate(id: string, type: keyof Hydrators, data?: unknown) { if (data) { - this.set(id, hydrate(type, data as never) as T); + this.set(id, hydrate(type, data as never, true) as T); } } }