diff --git a/src/plugins/timezones/api.ts b/src/plugins/timezones/api.ts new file mode 100644 index 000000000..321c14ea1 --- /dev/null +++ b/src/plugins/timezones/api.ts @@ -0,0 +1,61 @@ +import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent"; +import { Logger } from "@utils/Logger"; +import settings from "./settings"; + +export type Snowflake = string; +type ApiError = { error: string; }; +type UserFetchResponse = ApiError | { timezoneId: string } +type BulkFetchResponse = ApiError | Record; + +export async function fetchTimezonesBulk(ids: Snowflake[]): Promise | undefined> { + try { + const { apiUrl } = settings.store; + const req = await fetch(`${apiUrl}/user/bulk`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-User-Agent": VENCORD_USER_AGENT, + }, + body: JSON.stringify(ids), + }); + + const json: BulkFetchResponse = await req.json(); + if ("error" in json) throw "API Error: " + json.error; + + let parsed: Record = {}; + + for (const userId of Object.keys(json)) { + parsed[userId] = json[userId].timezoneId; + } + + return parsed; + } catch (e) { + new Logger("Timezones").error("Failed to fetch timezones bulk: ", e); + } +} + +export async function fetchTimezone(userId: Snowflake): Promise { + try { + const { apiUrl } = settings.store; + const req = await fetch(`${apiUrl}/user/${userId}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-User-Agent": VENCORD_USER_AGENT, + }, + }); + + const json: UserFetchResponse = await req.json(); + + if ("error" in json) { + if (json.error == "not_found") return null; + + throw "API Error: " + json.error; + } + + return json.timezoneId; + } catch (e) { + new Logger("Timezones").error("Failed to fetch user timezone: ", e); + return undefined; + } +} diff --git a/src/plugins/timezones/cache.ts b/src/plugins/timezones/cache.ts new file mode 100644 index 000000000..afd942cb4 --- /dev/null +++ b/src/plugins/timezones/cache.ts @@ -0,0 +1,82 @@ +import * as DataStore from "@api/DataStore"; +import { createStore } from "@api/DataStore"; +import { fetchTimezone, fetchTimezonesBulk, Snowflake } from "./api"; +import settings, { TimezoneOverwrites } from "./settings"; +import { debounce } from "@shared/debounce"; +import { Logger } from "@utils/Logger"; + +// TODO: cache invalidation +const TimezoneCache = createStore("UsersTimezoneCache", "TimezoneCache"); + +// A list of callbacks that will trigger on a completed debounced bulk fetch +type BulkFetchCallback = (timezone: string | null) => void; +type BulkFetchCallbacks = Record; +let BulkFetchQueue: BulkFetchCallbacks = {}; + +// Executes all queued requests and calls their callbacks +const debounceProcessBulkQueue = debounce(processBulkQueue, 750); + +async function processBulkQueue(attempt: number = 1, existingCallbacks?: BulkFetchCallbacks) { + if (attempt > 3) { + new Logger("Timezones").warn("Bulk queue fetch ran out of retries!"); + return; + } + + const callbacks = existingCallbacks ?? BulkFetchQueue; + if (!existingCallbacks) BulkFetchQueue = {}; + + const timezones = await fetchTimezonesBulk(Object.keys(callbacks)); + if (!timezones) { + const retry = processBulkQueue.bind(undefined, attempt + 1, callbacks); + + // Exponentially increasing timeout + setTimeout(retry, 1000 * (3 ** attempt)); + return; + } + + for (const [id, callbacksList] of Object.entries(callbacks)) { + const timezone = timezones[id] ?? null; + + DataStore.set(id, timezone, TimezoneCache).catch(_ => _); + callbacksList.forEach(c => c(timezone)); + } +} + +export async function getUserTimezone( + userId: Snowflake, + immediate: boolean = false, + force: boolean = false, +): Promise { + const overwrites = settings.store.timezoneOverwrites as TimezoneOverwrites; + const overwrite = overwrites[userId]; + if (overwrite) return overwrite ?? null; + + if (!force) { + const cachedTimezone = await DataStore.get(userId, TimezoneCache); + + if (cachedTimezone !== undefined) + return cachedTimezone; + } + + if (immediate) { + let tries = 3; + while (tries-- > 0) { + let timezone = await fetchTimezone(userId); + if (timezone === undefined) continue; + + return timezone; + } + + new Logger("Timezones").warn("Immediate fetch ran out of retries!"); + } + + return new Promise(onResolve => { + if (userId in BulkFetchQueue) { + BulkFetchQueue[userId].push(onResolve); + } else { + BulkFetchQueue[userId] = [onResolve]; + } + + debounceProcessBulkQueue(); + }); +} diff --git a/src/plugins/timezones/index.tsx b/src/plugins/timezones/index.tsx index d856924cb..7fdac13aa 100644 --- a/src/plugins/timezones/index.tsx +++ b/src/plugins/timezones/index.tsx @@ -4,55 +4,38 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import * as DataStore from "@api/DataStore"; import { Devs } from "@utils/constants"; import definePlugin from "@utils/types"; import { findByPropsLazy } from "@webpack"; import { SearchableSelect, Text, Toasts, useEffect, UserStore, useState } from "@webpack/common"; import { Message, User } from "discord-types/general"; -import settings from "./settings"; +import settings, { SettingsComponent } from "./settings"; import { CogWheel, DeleteIcon } from "@components/Icons"; -import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent"; -import { makeLazy } from "@utils/lazy"; import { classes } from "@utils/misc"; import { useForceUpdater } from "@utils/react"; -import { API_URL, DATASTORE_KEY, getAllTimezones, getTimeString, getUserTimezone, TimezoneDB } from "./utils"; - const classNames = findByPropsLazy("customStatusSection"); - const styles = findByPropsLazy("timestampInline"); -const useTimezones = makeLazy(getAllTimezones); - export default definePlugin({ - settings, - name: "Timezones", description: "Set and display the local times of you and other users via TimezoneDB", authors: [Devs.rushii, Devs.mantikafasi, Devs.Aria, Devs.Arjix], + settings, + settingsAboutComponent: SettingsComponent, + commands: [ { name: "timezone", description: "Sends a link to a utility website that shows your current timezone identifier", - execute: () => ({ content: "https://gh.lewisakura.moe/timezone/" }), + execute: () => ({ + content: "[IANA Timezone ID](https://gh.lewisakura.moe/timezone/)", + }), }, ], - // TODO: show button to authorize tzdb and manage public tz - settingsAboutComponent: () => { - const href = `${API_URL}?client_mod=${encodeURIComponent(VENCORD_USER_AGENT)}`; - return ( - -
- This plugin supports setting your own Timezone publicly for others to fetch and display via TimezoneDB. - You can override other users' timezones locally if they haven't set their own. -
- ); - }, - patches: [ // { // find: "copyMetaData:\"User Tag\"", @@ -74,7 +57,7 @@ export default definePlugin({ // TODO: make this not ugly (port vc-timezones plugin) getProfileTimezonesComponent: ({ user }: { user: User; }) => { - const { preference, showInProfile } = settings.use(["preference", "showInProfile"]); + const { displayInProfile } = settings.use(["displayInProfile"]); const [timezone, setTimezone] = useState(); const [isInEditMode, setIsInEditMode] = useState(false); diff --git a/src/plugins/timezones/settings.tsx b/src/plugins/timezones/settings.tsx index d39b77ec5..92161694a 100644 --- a/src/plugins/timezones/settings.tsx +++ b/src/plugins/timezones/settings.tsx @@ -18,42 +18,54 @@ import { definePluginSettings } from "@api/Settings"; import { OptionType } from "@utils/types"; +import { Text } from "@webpack/common"; +import { Link } from "@components/Link"; +import { Snowflake } from "./api"; -export enum CustomTimezonePreference { - Never, - Secondary, - Always -} +export type TimezoneOverwrites = Record; -export default definePluginSettings({ - preference: { - type: OptionType.SELECT, - description: "When to use custom timezones over TimezoneDB.", - options: [ - { - label: "Never use custom timezones.", - value: CustomTimezonePreference.Never, - }, - { - label: "Prefer custom timezones over TimezoneDB", - value: CustomTimezonePreference.Secondary, - default: true, - }, - { - label: "Always use custom timezones.", - value: CustomTimezonePreference.Always, - }, - ], - default: CustomTimezonePreference.Secondary, - }, - showInChat: { +const settings = definePluginSettings({ + enableApi: { type: OptionType.BOOLEAN, - description: "Show local time on messages in chat", + description: "Fetch user timezones from TimezoneDB when a local override does not exist", default: true, }, - showInProfile: { + apiUrl: { + type: OptionType.STRING, + description: "The TimezoneDB API instance to fetch from", + default: "https://timezonedb.catvibers.me/api", + }, + displayInChat: { type: OptionType.BOOLEAN, - description: "Show timezones in user profiles", + description: "Show local timestamp of messages", default: true, }, + displayInProfile: { + type: OptionType.BOOLEAN, + description: "Show local time in user profiles", + default: true, + }, + timezoneOverwrites: { + type: OptionType.COMPONENT, + description: "Local overwrites for users' timezones", + component: () => <> // TODO: settings component to manage local overwrites, + } }); + +export default settings; + +export function SettingsComponent(): JSX.Element { + // const { apiUrl } = settings.use(["apiUrl"]); + // const url = `${apiUrl}/../?client_mod=${encodeURIComponent(VENCORD_USER_AGENT)}`; + + // TODO: show button to authorize tzdb and manage public tz + + return <> + +
+ This plugin supports setting your own Timezone publicly for others to + fetch and display via TimezoneDB. + You can override other users' timezones locally if they haven't set their own. +
+ ; +} diff --git a/src/plugins/timezones/utils.ts b/src/plugins/timezones/utils.ts index cbc7e41cf..acd2175a7 100644 --- a/src/plugins/timezones/utils.ts +++ b/src/plugins/timezones/utils.ts @@ -4,122 +4,39 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import * as DataStore from "@api/DataStore"; import { findStoreLazy } from "@webpack"; -import { debounce } from "@shared/debounce"; -import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent"; - -import { CustomTimezonePreference } from "./settings"; - -export const DATASTORE_KEY = "plugins.timezones.savedTimezones"; - -export interface TimezoneDB { - [userId: string]: string; -} - -export const API_URL = "https://timezonedb.catvibers.me"; -const Cache: Record = {}; +import { Logger } from "@utils/Logger"; +import { makeLazy } from "@utils/lazy"; const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore"); +const TIMEZONE_LIST = "https://gist.githubusercontent.com/ArjixWasTaken/e321f856f98676505efb90aad82feff1/raw/91034ee32eff93a7cb62d10702f6b1d01e0309e6/timezones.json"; -export function getTimeString(timezone: string, timestamp = new Date()): string { +export function formatTimestamp(timezone: string, timestamp: Date = new Date()): string | undefined { try { const locale = UserSettingsProtoStore.settings.localization.locale.value; - return new Intl.DateTimeFormat(locale, { + const formatter = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric", timeZone: timezone, - }).format(timestamp); // we hate javascript - } catch (e) { - // TODO: log error - return "Error"; // incase it gets invalid timezone from api, probably not gonna happen but if it does this will prevent discord from crashing - } -} - - -// A map of ids and callbacks that should be triggered on fetch -const requestQueue: Record void)[]> = {}; - - -async function bulkFetchTimezones(ids: string[]): Promise { - try { - const req = await fetch(`${API_URL}/api/user/bulk`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-Agent": VENCORD_USER_AGENT, - }, - body: JSON.stringify(ids), }); - return await req.json() - .then((res: { [userId: string]: { timezoneId: string; } | null; }) => { - const tzs = (Object.keys(res).map(userId => { - return res[userId] && { [userId]: res[userId]!.timezoneId }; - }).filter(Boolean) as TimezoneDB[]).reduce((acc, cur) => ({ ...acc, ...cur }), {}); - - Object.assign(Cache, tzs); - return tzs; - }); + return formatter.format(timestamp); } catch (e) { - console.error("Timezone fetching failed: ", e); + // Probably never going to happen + new Logger("Timezones").error(`Failed to format timestamp with timezone ${timezone}`, e); } } - -// Executes all queued requests and calls their callbacks -const bulkFetch = debounce(async () => { - const ids = Object.keys(requestQueue); - const timezones = await bulkFetchTimezones(ids); - if (!timezones) { - // retry after 15 seconds - setTimeout(bulkFetch, 15000); - return; - } - - for (const id of ids) { - // Call all callbacks for the id - requestQueue[id].forEach(c => c(timezones[id])); - delete requestQueue[id]; - } -}); - -export function getUserTimezone(discordID: string, strategy: CustomTimezonePreference): - Promise { - - return new Promise(res => { - const timezone = (DataStore.get(DATASTORE_KEY) as Promise).then(tzs => tzs?.[discordID]); - timezone.then(tz => { - if (strategy === CustomTimezonePreference.Always) { - if (tz) res(tz); - else res(undefined); - return; - } - - if (tz && strategy === CustomTimezonePreference.Secondary) - res(tz); - else { - if (discordID in Cache) res(Cache[discordID]); - else if (discordID in requestQueue) requestQueue[discordID].push(res); - // If not already added, then add it and call the debounced function to make sure the request gets executed - else { - requestQueue[discordID] = [res]; - bulkFetch(); - } - } - }); - }); -} - -const timezonesLink = "https://gist.githubusercontent.com/ArjixWasTaken/e321f856f98676505efb90aad82feff1/raw/91034ee32eff93a7cb62d10702f6b1d01e0309e6/timezones.json"; - -export const getAllTimezones = async (): Promise => { +async function getTimezones(): Promise { if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) { try { return Intl.supportedValuesOf("timeZone"); } catch { + // Fallthrough to fetching external timezone list } } - return await fetch(timezonesLink).then(tzs => tzs.json()); -}; + return await fetch(TIMEZONE_LIST).then(res => res.json()); +} + +export const getTimezonesLazy = makeLazy(getTimezones, 2);