mirror of
https://github.com/Vendicated/Vencord.git
synced 2025-02-24 15:35:11 +00:00
rewrite api/cache/utils
This commit is contained in:
parent
a8cdeb10bf
commit
925629f85d
5 changed files with 207 additions and 152 deletions
61
src/plugins/timezones/api.ts
Normal file
61
src/plugins/timezones/api.ts
Normal file
|
@ -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<Snowflake, { timezoneId: string | null }>;
|
||||||
|
|
||||||
|
export async function fetchTimezonesBulk(ids: Snowflake[]): Promise<Record<Snowflake, string | null> | 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<Snowflake, string | null> = {};
|
||||||
|
|
||||||
|
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<string | null | undefined> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
82
src/plugins/timezones/cache.ts
Normal file
82
src/plugins/timezones/cache.ts
Normal file
|
@ -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<Snowflake, (BulkFetchCallback)[]>;
|
||||||
|
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<string | null> {
|
||||||
|
const overwrites = settings.store.timezoneOverwrites as TimezoneOverwrites;
|
||||||
|
const overwrite = overwrites[userId];
|
||||||
|
if (overwrite) return overwrite ?? null;
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
const cachedTimezone = await DataStore.get<string | null>(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();
|
||||||
|
});
|
||||||
|
}
|
|
@ -4,55 +4,38 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as DataStore from "@api/DataStore";
|
|
||||||
import { Devs } from "@utils/constants";
|
import { Devs } from "@utils/constants";
|
||||||
import definePlugin from "@utils/types";
|
import definePlugin from "@utils/types";
|
||||||
import { findByPropsLazy } from "@webpack";
|
import { findByPropsLazy } from "@webpack";
|
||||||
import { SearchableSelect, Text, Toasts, useEffect, UserStore, useState } from "@webpack/common";
|
import { SearchableSelect, Text, Toasts, useEffect, UserStore, useState } from "@webpack/common";
|
||||||
import { Message, User } from "discord-types/general";
|
import { Message, User } from "discord-types/general";
|
||||||
|
|
||||||
import settings from "./settings";
|
import settings, { SettingsComponent } from "./settings";
|
||||||
import { CogWheel, DeleteIcon } from "@components/Icons";
|
import { CogWheel, DeleteIcon } from "@components/Icons";
|
||||||
import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent";
|
|
||||||
import { makeLazy } from "@utils/lazy";
|
|
||||||
import { classes } from "@utils/misc";
|
import { classes } from "@utils/misc";
|
||||||
import { useForceUpdater } from "@utils/react";
|
import { useForceUpdater } from "@utils/react";
|
||||||
|
|
||||||
import { API_URL, DATASTORE_KEY, getAllTimezones, getTimeString, getUserTimezone, TimezoneDB } from "./utils";
|
|
||||||
|
|
||||||
const classNames = findByPropsLazy("customStatusSection");
|
const classNames = findByPropsLazy("customStatusSection");
|
||||||
|
|
||||||
const styles = findByPropsLazy("timestampInline");
|
const styles = findByPropsLazy("timestampInline");
|
||||||
|
|
||||||
const useTimezones = makeLazy(getAllTimezones);
|
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
settings,
|
|
||||||
|
|
||||||
name: "Timezones",
|
name: "Timezones",
|
||||||
description: "Set and display the local times of you and other users via TimezoneDB",
|
description: "Set and display the local times of you and other users via TimezoneDB",
|
||||||
authors: [Devs.rushii, Devs.mantikafasi, Devs.Aria, Devs.Arjix],
|
authors: [Devs.rushii, Devs.mantikafasi, Devs.Aria, Devs.Arjix],
|
||||||
|
|
||||||
|
settings,
|
||||||
|
settingsAboutComponent: SettingsComponent,
|
||||||
|
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
name: "timezone",
|
name: "timezone",
|
||||||
description: "Sends a link to a utility website that shows your current timezone identifier",
|
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 (
|
|
||||||
<Text variant="text-md/normal">
|
|
||||||
<br/>
|
|
||||||
This plugin supports setting your own Timezone publicly for others to fetch and display via <a href={href}>TimezoneDB</a>.
|
|
||||||
You can override other users' timezones locally if they haven't set their own.
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
patches: [
|
patches: [
|
||||||
// {
|
// {
|
||||||
// find: "copyMetaData:\"User Tag\"",
|
// find: "copyMetaData:\"User Tag\"",
|
||||||
|
@ -74,7 +57,7 @@ export default definePlugin({
|
||||||
|
|
||||||
// TODO: make this not ugly (port vc-timezones plugin)
|
// TODO: make this not ugly (port vc-timezones plugin)
|
||||||
getProfileTimezonesComponent: ({ user }: { user: User; }) => {
|
getProfileTimezonesComponent: ({ user }: { user: User; }) => {
|
||||||
const { preference, showInProfile } = settings.use(["preference", "showInProfile"]);
|
const { displayInProfile } = settings.use(["displayInProfile"]);
|
||||||
|
|
||||||
const [timezone, setTimezone] = useState<string | undefined>();
|
const [timezone, setTimezone] = useState<string | undefined>();
|
||||||
const [isInEditMode, setIsInEditMode] = useState(false);
|
const [isInEditMode, setIsInEditMode] = useState(false);
|
||||||
|
|
|
@ -18,42 +18,54 @@
|
||||||
|
|
||||||
import { definePluginSettings } from "@api/Settings";
|
import { definePluginSettings } from "@api/Settings";
|
||||||
import { OptionType } from "@utils/types";
|
import { OptionType } from "@utils/types";
|
||||||
|
import { Text } from "@webpack/common";
|
||||||
|
import { Link } from "@components/Link";
|
||||||
|
import { Snowflake } from "./api";
|
||||||
|
|
||||||
export enum CustomTimezonePreference {
|
export type TimezoneOverwrites = Record<Snowflake, string>;
|
||||||
Never,
|
|
||||||
Secondary,
|
|
||||||
Always
|
|
||||||
}
|
|
||||||
|
|
||||||
export default definePluginSettings({
|
const settings = definePluginSettings({
|
||||||
preference: {
|
enableApi: {
|
||||||
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: {
|
|
||||||
type: OptionType.BOOLEAN,
|
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,
|
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,
|
type: OptionType.BOOLEAN,
|
||||||
description: "Show timezones in user profiles",
|
description: "Show local timestamp of messages",
|
||||||
default: true,
|
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 <>
|
||||||
|
<Text variant="text-md/normal">
|
||||||
|
<br />
|
||||||
|
This plugin supports setting your own Timezone publicly for others to
|
||||||
|
fetch and display via <Link href="https://github.com/rushiimachine/timezonedb">TimezoneDB</Link>.
|
||||||
|
You can override other users' timezones locally if they haven't set their own.
|
||||||
|
</Text>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
|
@ -4,122 +4,39 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as DataStore from "@api/DataStore";
|
|
||||||
import { findStoreLazy } from "@webpack";
|
import { findStoreLazy } from "@webpack";
|
||||||
import { debounce } from "@shared/debounce";
|
import { Logger } from "@utils/Logger";
|
||||||
import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent";
|
import { makeLazy } from "@utils/lazy";
|
||||||
|
|
||||||
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<string, string> = {};
|
|
||||||
|
|
||||||
const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore");
|
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 {
|
try {
|
||||||
const locale = UserSettingsProtoStore.settings.localization.locale.value;
|
const locale = UserSettingsProtoStore.settings.localization.locale.value;
|
||||||
return new Intl.DateTimeFormat(locale, {
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
timeZone: timezone,
|
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<string, ((timezone: string) => void)[]> = {};
|
|
||||||
|
|
||||||
|
|
||||||
async function bulkFetchTimezones(ids: string[]): Promise<TimezoneDB | undefined> {
|
|
||||||
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()
|
return formatter.format(timestamp);
|
||||||
.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;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getTimezones(): Promise<string[]> {
|
||||||
// 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<string | undefined> {
|
|
||||||
|
|
||||||
return new Promise(res => {
|
|
||||||
const timezone = (DataStore.get(DATASTORE_KEY) as Promise<TimezoneDB | undefined>).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<string[]> => {
|
|
||||||
if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) {
|
if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) {
|
||||||
try {
|
try {
|
||||||
return Intl.supportedValuesOf("timeZone");
|
return Intl.supportedValuesOf("timeZone");
|
||||||
} catch {
|
} 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);
|
||||||
|
|
Loading…
Add table
Reference in a new issue