diff --git a/src/Client.ts b/src/Client.ts index a778c26c..dc8f536d 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -7,6 +7,7 @@ import type { DataLogin, RevoltConfig } from "revolt-api"; import { Channel, Emoji, Message, Server, ServerMember, User } from "./classes"; import { ChannelCollection, + ChannelUnreadCollection, EmojiCollection, MessageCollection, ServerCollection, @@ -100,6 +101,12 @@ export type ClientOptions = Partial & { */ partials: boolean; + /** + * Whether to automatically sync unreads information + * @default false + */ + syncUnreads: boolean; + /** * Whether to reconnect when disconnected * @default true @@ -112,7 +119,15 @@ export type ClientOptions = Partial & { * @returns Delay in seconds * @default (2^x-1) ±20% */ - retryDelayFunction: (retryCount: number) => number; + retryDelayFunction(retryCount: number): number; + + /** + * Check whether a channel is muted + * @param channel Channel + * @return Whether it is muted + * @default false + */ + channelIsMuted(channel: Channel): boolean; }; /** @@ -120,6 +135,7 @@ export type ClientOptions = Partial & { */ export class Client extends EventEmitter { readonly channels; + readonly channelUnreads; readonly emojis; readonly messages; readonly users; @@ -150,6 +166,7 @@ export class Client extends EventEmitter { this.options = { baseURL: "https://api.revolt.chat", partials: false, + syncUnreads: false, autoReconnect: true, /** * Retry delay function @@ -159,6 +176,14 @@ export class Client extends EventEmitter { retryDelayFunction(retryCount) { return (Math.pow(2, retryCount) - 1) * (0.8 + Math.random() * 0.4); }, + /** + * Check whether a channel is muted + * @param channel Channel + * @return Whether it is muted + */ + channelIsMuted() { + return false; + }, ...options, }; @@ -175,6 +200,7 @@ export class Client extends EventEmitter { this.#setConnectionFailureCount = setConnectionFailureCount; this.channels = new ChannelCollection(this); + this.channelUnreads = new ChannelUnreadCollection(this); this.emojis = new EmojiCollection(this); this.messages = new MessageCollection(this); this.users = new UserCollection(this); diff --git a/src/classes/Channel.ts b/src/classes/Channel.ts index 08e4c878..b6cd391d 100644 --- a/src/classes/Channel.ts +++ b/src/classes/Channel.ts @@ -256,14 +256,35 @@ export class Channel { return this.lastMessageAt ?? this.createdAt; } - // TODO: lastMessage - + /** + * Get whether this channel is unread. + */ get unread() { - return false; + if ( + !this.lastMessageId || + this.type === "SavedMessages" || + this.type === "VoiceChannel" || + this.#collection.client.options.channelIsMuted(this) + ) + return false; + + return ( + ( + this.#collection.client.channelUnreads.get(this.id)?.lastMessageId ?? + "0" + ).localeCompare(this.lastMessageId) === -1 + ); } + /** + * Get mentions in this channel for user. + */ get mentions() { - return []; + if (this.type === "SavedMessages" || this.type === "VoiceChannel") + return undefined; + + return this.#collection.client.channelUnreads.get(this.id) + ?.messageMentionIds; } /** @@ -551,17 +572,30 @@ export class Channel { * @param skipRateLimiter Whether to skip the internal rate limiter */ async ack(message?: Message | string, skipRateLimiter?: boolean) { - const id = + const lastMessageId = (typeof message === "string" ? message : message?.id) ?? this.lastMessageId ?? ulid(); + const unreads = this.#collection.client.channelUnreads; + const channelUnread = unreads.get(this.id); + if (channelUnread) { + unreads.updateUnderlyingObject(this.id, { + lastMessageId, + }); + + if (channelUnread.messageMentionIds.size) { + channelUnread.messageMentionIds.clear(); + } + } + const performAck = () => { this.#ackLimit = undefined; - this.#collection.client.api.put(`/channels/${this.id}/ack/${id as ""}`); + this.#collection.client.api.put( + `/channels/${this.id}/ack/${lastMessageId as ""}` + ); }; - // TODO: !this.collection.client.options.ackRateLimiter if (skipRateLimiter) return performAck(); clearTimeout(this.#ackTimeout); diff --git a/src/classes/ChannelUnread.ts b/src/classes/ChannelUnread.ts index b41f252d..9d5cefb2 100644 --- a/src/classes/ChannelUnread.ts +++ b/src/classes/ChannelUnread.ts @@ -28,6 +28,6 @@ export class ChannelUnread { * List of message IDs that we were mentioned in */ get messageMentionIds() { - return this.#collection.getUnderlyingObject(this.id).lastMessageId; + return this.#collection.getUnderlyingObject(this.id).messageMentionIds; } } diff --git a/src/classes/Server.ts b/src/classes/Server.ts index 4c0b2010..5298bcce 100644 --- a/src/classes/Server.ts +++ b/src/classes/Server.ts @@ -235,12 +235,24 @@ export class Server { : []; } + /** + * Check whether the server is currently unread + * @returns Whether the server is unread + */ get unread() { - return false; + return this.channels.find((channel) => channel.unread); } + /** + * Find all message IDs of unread messages + * @returns Array of message IDs which are unread + */ get mentions() { - return []; + const arr = this.channels.map((channel) => + Array.from(channel.mentions?.values() ?? []) + ); + + return ([] as string[]).concat(...arr); } /** diff --git a/src/collections/ChannelUnreadCollection.ts b/src/collections/ChannelUnreadCollection.ts index d7eb34fa..d6cd4831 100644 --- a/src/collections/ChannelUnreadCollection.ts +++ b/src/collections/ChannelUnreadCollection.ts @@ -8,6 +8,24 @@ export class ChannelUnreadCollection extends ClassCollection< ChannelUnread, HydratedChannelUnread > { + /** + * Load unread information from server + */ + async sync() { + const unreads = await this.client.api.get("/sync/unreads"); + this.reset(); + for (const unread of unreads) { + this.getOrCreate(unread._id.channel, unread); + } + } + + /** + * Clear all unread data + */ + reset() { + this.updateUnderlyingObject({}); + } + /** * Get or create * @param id Id diff --git a/src/collections/Collection.ts b/src/collections/Collection.ts index bd5556bd..4a6efb50 100644 --- a/src/collections/Collection.ts +++ b/src/collections/Collection.ts @@ -2,6 +2,7 @@ import { SetStoreFunction } from "solid-js/store"; import { ReactiveMap } from "@solid-primitives/map"; +import { Client } from ".."; import { Hydrators } from "../hydration"; import { ObjectStorage } from "../storage/ObjectStorage"; @@ -177,3 +178,15 @@ export abstract class StoreCollection extends Collection { return this.#objects.forEach(cb); } } + +/** + * Generic class collection backed by store + */ +export class ClassCollection extends StoreCollection { + readonly client: Client; + + constructor(client: Client) { + super(); + this.client = client; + } +} diff --git a/src/collections/index.ts b/src/collections/index.ts index a2feffb4..57dee4d5 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -1,18 +1,4 @@ -import { Client } from ".."; - -import { StoreCollection } from "./Collection"; - -/** - * Generic class collection backed by store - */ -export class ClassCollection extends StoreCollection { - readonly client: Client; - - constructor(client: Client) { - super(); - this.client = client; - } -} +export * from "./Collection"; export { ChannelCollection } from "./ChannelCollection"; export { ChannelUnreadCollection } from "./ChannelUnreadCollection"; diff --git a/src/events/v1.ts b/src/events/v1.ts index 503eee78..6803b270 100644 --- a/src/events/v1.ts +++ b/src/events/v1.ts @@ -233,8 +233,13 @@ export async function handleEvent( client.emojis.getOrCreate(emoji._id, emoji); } + if (client.options.syncUnreads) { + await client.channelUnreads.sync(); + } + setReady(true); client.emit("ready"); + break; } case "Message": {