diff --git a/src/Client.ts b/src/Client.ts index dc8f536d..7ff07aba 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -348,55 +348,6 @@ export class Client extends EventEmitter { .replace(RE_SPOILER, ""); } - /** - * 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 - */ - createFileURL( - attachment?: { - tag: string; - _id: string; - content_type?: string; - metadata?: Metadata; - }, - ...args: FileArgs - ) { - const [options, allowAnimation, fallback] = args; - - const autumn = this.configuration?.features.autumn; - if (!autumn?.enabled) return fallback; - if (!attachment) return fallback; - - const { tag, _id, content_type, metadata } = attachment; - - // TODO: server-side - if (metadata?.type === "Image") { - if ( - Math.min(metadata.width, metadata.height) <= 0 || - (content_type === "image/gif" && - Math.max(metadata.width, metadata.height) >= 4096) - ) - return fallback; - } - - let query = ""; - if (options) { - if (!allowAnimation || content_type !== "image/gif") { - query = - "?" + - Object.keys(options) - .map((k) => `${k}=${options[k as keyof FileArgs[0]]}`) - .join("&"); - } - } - - return `${autumn.url}/${tag}/${_id}${query}`; - } - /** * Proxy a file through January. * @param url URL to proxy @@ -410,14 +361,3 @@ export class Client extends EventEmitter { } } } - -export type FileArgs = [ - options?: { - max_side?: number; - size?: number; - width?: number; - height?: number; - }, - allowAnimation?: boolean, - fallback?: string -]; diff --git a/src/classes/Channel.ts b/src/classes/Channel.ts index b6cd391d..6a087bc6 100644 --- a/src/classes/Channel.ts +++ b/src/classes/Channel.ts @@ -291,11 +291,8 @@ export class Channel { * URL to the channel icon */ get iconURL() { - return this.#collection.client.createFileURL( - this.icon ?? this.recipient?.avatar, - { - max_side: 256, - } + return ( + this.icon?.createFileURL({ max_side: 256 }) ?? this.recipient?.avatarURL ); } @@ -303,22 +300,16 @@ export class Channel { * URL to a small variant of the channel icon */ get smallIconURL() { - return this.#collection.client.createFileURL( - this.icon ?? this.recipient?.avatar, - { - max_side: 64, - } - ); + return this.icon?.createFileURL({ max_side: 64 }); } /** * URL to the animated channel icon */ get animatedIconURL() { - return this.#collection.client.createFileURL( - this.icon ?? this.recipient?.avatar, - { max_side: 256 }, - true + return ( + this.icon?.createFileURL({ max_side: 256 }, true) ?? + this.recipient?.animatedAvatarURL ); } diff --git a/src/classes/File.ts b/src/classes/File.ts new file mode 100644 index 00000000..5fcb1298 --- /dev/null +++ b/src/classes/File.ts @@ -0,0 +1,128 @@ +import { Metadata } from "revolt-api"; + +import { API, Client } from ".."; + +/** + * Uploaded File + */ +export class File { + #client: Client; + + /** + * File Id + */ + readonly id: string; + + /** + * File bucket + */ + readonly tag: string; + + /** + * Original filename + */ + readonly filename: string; + + /** + * Parsed metadata of the file + */ + readonly metadata: Metadata; + + /** + * Raw content type of this file + */ + readonly contentType: string; + + /** + * Size of the file (in bytes) + */ + readonly size: number; + + /** + * Construct file + * @param client Client + * @param file File + */ + constructor(client: Client, file: API.File) { + this.#client = client; + this.id = file._id; + this.tag = file.tag; + this.filename = file.filename; + this.metadata = file.metadata; + this.contentType = file.content_type; + this.size = file.size; + } + + /** + * Direct URL to the file + */ + get url() { + return `${this.#client.configuration?.features.autumn}/${this.tag}/${ + this.id + }/${this.filename}`; + } + + /** + * Download URL for the file + */ + get downloadURL() { + return `${this.#client.configuration?.features.autumn}/${ + this.tag + }/download/${this.id}/${this.filename}`; + } + + /** + * Human readable file size + */ + get humanReadableSize() { + if (this.size > 1e6) { + return `${(this.size / 1e6).toFixed(2)} MB`; + } else if (this.size > 1e3) { + return `${(this.size / 1e3).toFixed(2)} KB`; + } + + return `${this.size} B`; + } + + /** + * Creates a URL to a given file with given options. + * @param options Optional query parameters to modify object + * @param allowAnimation Returns GIF if applicable, no operations occur on image + * @returns Generated URL or nothing + */ + createFileURL( + options?: { + max_side?: number; + size?: number; + width?: number; + height?: number; + }, + allowAnimation?: boolean + ) { + const autumn = this.#client.configuration?.features.autumn; + if (!autumn?.enabled) return; + + // TODO: server-side + if (this.metadata.type === "Image") { + if ( + Math.min(this.metadata.width, this.metadata.height) <= 0 || + (this.contentType === "image/gif" && + Math.max(this.metadata.width, this.metadata.height) >= 4096) + ) + return; + } + + let query = ""; + if (options) { + if (!allowAnimation || this.contentType !== "image/gif") { + query = + "?" + + Object.keys(options) + .map((k) => `${k}=${options[k as keyof typeof options]}`) + .join("&"); + } + } + + return `${autumn.url}/${this.tag}/${this.id}${query}`; + } +} diff --git a/src/classes/Server.ts b/src/classes/Server.ts index 5298bcce..706e3f86 100644 --- a/src/classes/Server.ts +++ b/src/classes/Server.ts @@ -259,27 +259,21 @@ export class Server { * URL to the server's icon */ get iconURL() { - return this.#collection.client.createFileURL(this.icon, { max_side: 256 }); + return this.icon?.createFileURL({ max_side: 256 }); } /** * URL to the server's animated icon */ get animatedIconURL() { - return this.#collection.client.createFileURL( - this.icon, - { max_side: 256 }, - true - ); + return this.icon?.createFileURL({ max_side: 256 }, true); } /** * URL to the server's banner */ get bannerURL() { - return this.#collection.client.createFileURL(this.banner, { - max_side: 256, - }); + return this.banner?.createFileURL({ max_side: 256 }, true); } /** diff --git a/src/classes/ServerMember.ts b/src/classes/ServerMember.ts index 54df61f4..800bf3bd 100644 --- a/src/classes/ServerMember.ts +++ b/src/classes/ServerMember.ts @@ -187,8 +187,7 @@ export class ServerMember { */ get avatarURL() { return ( - this.#collection.client.createFileURL(this.avatar, { max_side: 256 }) ?? - this.user?.avatarURL + this.avatar?.createFileURL({ max_side: 256 }) ?? this.user?.avatarURL ); } @@ -197,11 +196,8 @@ export class ServerMember { */ get animatedAvatarURL() { return ( - this.#collection.client.createFileURL( - this.avatar, - { max_side: 256 }, - true - ) ?? this.user?.animatedAvatarURL + this.avatar?.createFileURL({ max_side: 256 }, true) ?? + this.user?.animatedAvatarURL ); } } diff --git a/src/classes/User.ts b/src/classes/User.ts index 4e2af924..2394b16d 100644 --- a/src/classes/User.ts +++ b/src/classes/User.ts @@ -112,8 +112,7 @@ export class User { */ get avatarURL() { return ( - this.#collection.client.createFileURL(this.avatar, { max_side: 256 }) ?? - this.defaultAvatarURL + this.avatar?.createFileURL({ max_side: 256 }) ?? this.defaultAvatarURL ); } @@ -122,11 +121,8 @@ export class User { */ get animatedAvatarURL() { return ( - this.#collection.client.createFileURL( - this.avatar, - { max_side: 256 }, - true - ) ?? this.defaultAvatarURL + this.avatar?.createFileURL({ max_side: 256 }, true) ?? + this.defaultAvatarURL ); } diff --git a/src/classes/index.ts b/src/classes/index.ts index 3085bfee..f20804bc 100644 --- a/src/classes/index.ts +++ b/src/classes/index.ts @@ -4,3 +4,5 @@ export { Message } from "./Message"; export { Server } from "./Server"; export { ServerMember } from "./ServerMember"; export { User } from "./User"; +export { File } from "./File"; +export { ChannelUnread } from "./ChannelUnread"; diff --git a/src/collections/ChannelCollection.ts b/src/collections/ChannelCollection.ts index 5f0d104c..e804fd3b 100644 --- a/src/collections/ChannelCollection.ts +++ b/src/collections/ChannelCollection.ts @@ -40,7 +40,7 @@ export class ChannelCollection extends ClassCollection< return this.get(id)!; } else { const instance = new Channel(this, id); - this.create(id, "channel", instance, data); + this.create(id, "channel", instance, this.client, data); isNew && this.client.emit("channelCreate", instance); return instance; } @@ -55,7 +55,7 @@ export class ChannelCollection extends ClassCollection< return this.get(id)!; } else if (this.client.options.partials) { const instance = new Channel(this, id); - this.create(id, "channel", instance, { + this.create(id, "channel", instance, this.client, { id, partial: true, }); diff --git a/src/collections/ChannelUnreadCollection.ts b/src/collections/ChannelUnreadCollection.ts index d6cd4831..5eb30072 100644 --- a/src/collections/ChannelUnreadCollection.ts +++ b/src/collections/ChannelUnreadCollection.ts @@ -36,7 +36,7 @@ export class ChannelUnreadCollection extends ClassCollection< return this.get(id)!; } else { const instance = new ChannelUnread(this, id); - this.create(id, "channelUnread", instance, data); + this.create(id, "channelUnread", instance, this.client, data); return instance; } } diff --git a/src/collections/Collection.ts b/src/collections/Collection.ts index 4a6efb50..f0b968cd 100644 --- a/src/collections/Collection.ts +++ b/src/collections/Collection.ts @@ -121,10 +121,17 @@ export abstract class StoreCollection extends Collection { * @param id Id * @param type Type * @param instance Instance + * @param context Context * @param data Data */ - create(id: string, type: keyof Hydrators, instance: T, data?: unknown) { - this.#storage.hydrate(id, type, data); + create( + id: string, + type: keyof Hydrators, + instance: T, + context: any, + data?: unknown + ) { + this.#storage.hydrate(id, type, context, data); this.#objects.set(id, instance); } diff --git a/src/collections/EmojiCollection.ts b/src/collections/EmojiCollection.ts index e1a6f0cd..4c5faef8 100644 --- a/src/collections/EmojiCollection.ts +++ b/src/collections/EmojiCollection.ts @@ -27,7 +27,7 @@ export class EmojiCollection extends ClassCollection { return this.get(id)!; } else { const instance = new Emoji(this, id); - this.create(id, "emoji", instance, data); + this.create(id, "emoji", instance, this.client, data); isNew && this.client.emit("emojiCreate", instance); return instance; } @@ -42,7 +42,7 @@ export class EmojiCollection extends ClassCollection { return this.get(id)!; } else if (this.client.options.partials) { const instance = new Emoji(this, id); - this.create(id, "emoji", instance, { + this.create(id, "emoji", instance, this.client, { id, }); return instance; diff --git a/src/collections/MessageCollection.ts b/src/collections/MessageCollection.ts index bca244dc..9fabdd1a 100644 --- a/src/collections/MessageCollection.ts +++ b/src/collections/MessageCollection.ts @@ -35,7 +35,7 @@ export class MessageCollection extends ClassCollection< return this.get(id)!; } else { const instance = new Message(this, id); - this.create(id, "message", instance, data); + this.create(id, "message", instance, this.client, data); isNew && this.client.emit("messageCreate", instance); return instance; } @@ -50,7 +50,7 @@ export class MessageCollection extends ClassCollection< return this.get(id)!; } else if (this.client.options.partials) { const instance = new Message(this, id); - this.create(id, "message", instance, { + this.create(id, "message", instance, this.client, { id, partial: true, }); diff --git a/src/collections/ServerCollection.ts b/src/collections/ServerCollection.ts index 701e8835..151ab58b 100644 --- a/src/collections/ServerCollection.ts +++ b/src/collections/ServerCollection.ts @@ -27,7 +27,7 @@ export class ServerCollection extends ClassCollection { return this.get(id)!; } else { const instance = new Server(this, id); - this.create(id, "server", instance, data); + this.create(id, "server", instance, this.client, data); isNew && this.client.emit("serverCreate", instance); return instance; } @@ -42,7 +42,7 @@ export class ServerCollection extends ClassCollection { return this.get(id)!; } else if (this.client.options.partials) { const instance = new Server(this, id); - this.create(id, "server", instance, { + this.create(id, "server", instance, this.client, { id, partial: true, }); diff --git a/src/collections/ServerMemberCollection.ts b/src/collections/ServerMemberCollection.ts index 259d7872..e1df09ff 100644 --- a/src/collections/ServerMemberCollection.ts +++ b/src/collections/ServerMemberCollection.ts @@ -52,7 +52,13 @@ export class ServerMemberCollection extends ClassCollection< return this.getByKey(id)!; } else { const instance = new ServerMember(this, id); - this.create(id.server + id.user, "serverMember", instance, data); + this.create( + id.server + id.user, + "serverMember", + instance, + this.client, + data + ); return instance; } } @@ -66,7 +72,7 @@ export class ServerMemberCollection extends ClassCollection< return this.getByKey(id)!; } else if (this.client.options.partials) { const instance = new ServerMember(this, id); - this.create(id.server + id.user, "serverMember", instance, { + this.create(id.server + id.user, "serverMember", instance, this.client, { id, partial: true, }); diff --git a/src/collections/UserCollection.ts b/src/collections/UserCollection.ts index d9dd83fd..05ba3de5 100644 --- a/src/collections/UserCollection.ts +++ b/src/collections/UserCollection.ts @@ -27,7 +27,7 @@ export class UserCollection extends ClassCollection { return this.get(id)!; } else { const instance = new User(this, id); - this.create(id, "user", instance, data); + this.create(id, "user", instance, this.client, data); return instance; } } @@ -41,7 +41,7 @@ export class UserCollection extends ClassCollection { return this.get(id)!; } else if (this.client.options.partials) { const instance = new User(this, id); - this.create(id, "user", instance, { + this.create(id, "user", instance, this.client, { id, partial: true, }); diff --git a/src/hydration/channel.ts b/src/hydration/channel.ts index 14319578..abeae68f 100644 --- a/src/hydration/channel.ts +++ b/src/hydration/channel.ts @@ -1,6 +1,7 @@ import { ReactiveSet } from "@solid-primitives/set"; -import { Channel as ApiChannel, File, OverrideField } from "revolt-api"; +import { Channel as ApiChannel, OverrideField } from "revolt-api"; +import { File } from ".."; import type { Merge } from "../lib/merge"; import { Hydrate } from "."; @@ -46,7 +47,7 @@ export const channelHydration: Hydrate, HydratedChannel> = { channelType: (channel) => channel.channel_type, name: (channel) => channel.name, description: (channel) => channel.description!, - icon: (channel) => channel.icon!, + icon: (channel, ctx) => new File(ctx, channel.icon!), active: (channel) => channel.active || false, typingIds: () => new ReactiveSet(), recipientIds: (channel) => new ReactiveSet(channel.recipients), diff --git a/src/hydration/index.ts b/src/hydration/index.ts index e3e424d4..4cb1fdd7 100644 --- a/src/hydration/index.ts +++ b/src/hydration/index.ts @@ -19,7 +19,7 @@ export type { HydratedUser } from "./user"; */ export type MappingFns = Record< Key, - (value: Input) => Output[Key] + (value: Input, context: any) => Output[Key] >; /** @@ -44,13 +44,14 @@ export type Hydrate = { */ function hydrateInternal( hydration: Hydrate, - input: Input + input: Input, + context: any ): Output { return (Object.keys(input) as (keyof Input)[]).reduce((acc, key) => { let targetKey, value; try { targetKey = hydration.keyMapping[key] ?? key; - value = hydration.functions[targetKey as keyof Output](input); + value = hydration.functions[targetKey as keyof Output](input, context); } catch (err) { if (key === "type") return acc; console.debug(`Skipping key ${String(key)} during hydration!`); @@ -92,10 +93,12 @@ type ExtractOutput = T extends Hydrate ? O : never; export function hydrate( type: T, input: Partial>, + context: any, initial?: boolean ) { return hydrateInternal( hydrators[type] as never, - initial ? { ...hydrators[type].initialHydration(), ...input } : input + initial ? { ...hydrators[type].initialHydration(), ...input } : input, + context ) as ExtractOutput; } diff --git a/src/hydration/message.ts b/src/hydration/message.ts index cb3cad77..0f2c42e6 100644 --- a/src/hydration/message.ts +++ b/src/hydration/message.ts @@ -3,12 +3,12 @@ import { ReactiveSet } from "@solid-primitives/set"; import { Message as ApiMessage, Embed, - File, Interactions, Masquerade, SystemMessage, } from "revolt-api"; +import { File } from ".."; import type { Merge } from "../lib/merge"; import { Hydrate } from "."; @@ -47,7 +47,8 @@ export const messageHydration: Hydrate, HydratedMessage> = { authorId: (message) => message.author, content: (message) => message.content!, systemMessage: (message) => message.system!, - attachments: (message) => message.attachments!, + attachments: (message, ctx) => + message.attachments!.map((file) => new File(ctx, file)), editedAt: (message) => new Date(message.edited!), embeds: (message) => message.embeds!, mentionIds: (message) => message.mentions!, diff --git a/src/hydration/server.ts b/src/hydration/server.ts index 6d2b219f..aa586dc5 100644 --- a/src/hydration/server.ts +++ b/src/hydration/server.ts @@ -3,11 +3,12 @@ import { ReactiveSet } from "@solid-primitives/set"; import { Server as ApiServer, Category, - File, Role, SystemMessageChannels, } from "revolt-api"; +import { File } from ".."; + import { Hydrate } from "."; export type HydratedServer = { @@ -54,8 +55,8 @@ export const serverHydration: Hydrate = { Object.keys(server.roles!).map((id) => [id, server.roles![id]]) ), defaultPermissions: (server) => server.default_permissions, - icon: (server) => server.icon!, - banner: (server) => server.banner!, + icon: (server, ctx) => new File(ctx, server.icon!), + banner: (server, ctx) => new File(ctx, server.banner!), flags: (server) => server.flags!, analytics: (server) => server.analytics || false, discoverable: (server) => server.discoverable || false, diff --git a/src/hydration/serverMember.ts b/src/hydration/serverMember.ts index 561d0084..cb143155 100644 --- a/src/hydration/serverMember.ts +++ b/src/hydration/serverMember.ts @@ -1,5 +1,6 @@ -import { Member as ApiMember, File, MemberCompositeKey } from "revolt-api"; +import { Member as ApiMember, MemberCompositeKey } from "revolt-api"; +import { File } from ".."; import type { Merge } from "../lib/merge"; import { Hydrate } from "."; @@ -25,7 +26,7 @@ export const serverMemberHydration: Hydrate< id: (member) => member._id, joinedAt: (member) => new Date(member.joined_at), nickname: (member) => member.nickname!, - avatar: (member) => member.avatar!, + avatar: (member, ctx) => new File(ctx, member.avatar!), roles: (member) => member.roles, timeout: (member) => new Date(member.timeout!), }, diff --git a/src/hydration/user.ts b/src/hydration/user.ts index 964db2e1..9b38cfe6 100644 --- a/src/hydration/user.ts +++ b/src/hydration/user.ts @@ -1,11 +1,12 @@ import { User as ApiUser, BotInformation, - File, RelationshipStatus, UserStatus, } from "revolt-api"; +import { File } from ".."; + import { Hydrate } from "."; export type HydratedUser = { @@ -33,15 +34,15 @@ export const userHydration: Hydrate = { username: (user) => user.username, relationship: (user) => user.relationship ?? "None", - online: (user) => user.online || false, - privileged: (user) => user.privileged || false, + online: (user) => user.online!, + privileged: (user) => user.privileged, badges: (user) => user.badges ?? 0, flags: (user) => user.flags ?? 0, - avatar: (user) => user.avatar! ?? undefined, - status: (user) => user.status! ?? undefined, - bot: (user) => user.bot! ?? undefined, + avatar: (user, ctx) => new File(ctx, user.avatar!), + status: (user) => user.status!, + bot: (user) => user.bot!, }, initialHydration: () => ({ relationship: "None", diff --git a/src/storage/ObjectStorage.ts b/src/storage/ObjectStorage.ts index c7082389..43a16c16 100644 --- a/src/storage/ObjectStorage.ts +++ b/src/storage/ObjectStorage.ts @@ -32,11 +32,12 @@ export class ObjectStorage { * Hydrate some data into storage * @param id ID * @param type Hydration type + * @param context Context * @param data Input Data */ - hydrate(id: string, type: keyof Hydrators, data?: unknown) { + hydrate(id: string, type: keyof Hydrators, context: any, data?: unknown) { if (data) { - this.set(id, hydrate(type, data as never, true) as T); + this.set(id, hydrate(type, data as never, context, true) as T); } } }