Add TimezoneDB

This commit is contained in:
Manti 2023-12-31 21:13:50 +03:00
parent a500375e55
commit 03822eb59e
3 changed files with 422 additions and 0 deletions

View file

@ -0,0 +1,120 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { VENCORD_USER_AGENT } from "@utils/constants";
import { debounce } from "@utils/debounce";
import { findStoreLazy } from "@webpack";
export const DATASTORE_KEY = "plugins.Timezones.savedTimezones";
import { CustomTimezonePreference } from "./settings";
export interface TimezoneDB {
[userId: string]: string;
}
export const API_URL = "https://timezonedb.catvibers.me";
const Cache: Record<string, string> = {};
const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore");
export function getTimeString(timezone: string, timestamp = new Date()): string {
try {
const locale = UserSettingsProtoStore.settings.localization.locale.value;
return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric", timeZone: timezone }).format(timestamp); // we hate javascript
} catch (e) {
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()
.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) {
console.error("Timezone fetching failed: ", 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<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 gist = "e321f856f98676505efb90aad82feff1";
const revision = "91034ee32eff93a7cb62d10702f6b1d01e0309e6";
const timezonesLink = `https://gist.githubusercontent.com/ArjixWasTaken/${gist}/raw/${revision}/timezones.json`;
export const getAllTimezones = async (): Promise<string[]> => {
if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) {
try {
return Intl.supportedValuesOf("timeZone");
} catch { }
}
return await fetch(timezonesLink).then(tzs => tzs.json());
};

View file

@ -0,0 +1,243 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { Devs, VENCORD_USER_AGENT } from "@utils/constants";
import definePlugin from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { React, SearchableSelect, Text, Toasts, UserStore } from "@webpack/common";
import { Message, User } from "discord-types/general";
import settings from "./settings";
const classNames = findByPropsLazy("customStatusSection");
import { CogWheel, DeleteIcon } from "@components/Icons";
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 styles = findByPropsLazy("timestampInline");
const useTimezones = makeLazy(getAllTimezones);
export default definePlugin({
settings,
name: "Timezones",
description: "Allows you to see and set the timezones of other users.",
authors: [Devs.mantikafasi, Devs.Arjix],
commands: [
{
name: "timezone",
description: "Sends link to a website that shows timezone string, useful if you want to know your friends timezone",
execute: () => {
return { content: "https://gh.lewisakura.moe/timezone/" };
}
}
],
settingsAboutComponent: () => {
const href = `${API_URL}?client_mod=${encodeURIComponent(VENCORD_USER_AGENT)}`;
return (
<Text variant="text-md/normal">
A plugin that displays the local time for specific users using their timezone. <br />
Timezones can either be set manually or fetched automatically from the <a href={href}>TimezoneDB</a>
</Text>
);
},
patches: [
{
find: "copyMetaData:\"User Tag\"",
replacement: {
match: /return(\(0,.\.jsx\)\(.\.default,{className:.+?}\)]}\)}\))/,
replace: "return [$1, $self.getProfileTimezonesComponent(arguments[0])]"
},
},
{
// thank you https://github.com/Syncxv/vc-timezones/blob/master/index.tsx for saving me from painful work
find: ".badgesContainer,",
replacement: {
match: /id:\(0,\i\.getMessageTimestampId\)\(\i\),timestamp.{1,50}}\),/,
replace: "$&,$self.getTimezonesComponent(arguments[0]),"
}
}
],
getProfileTimezonesComponent: ({ user }: { user: User; }) => {
const { preference, showTimezonesInProfile } = settings.use(["preference", "showTimezonesInProfile"]);
const [timezone, setTimezone] = React.useState<string | undefined>();
const [isInEditMode, setIsInEditMode] = React.useState(false);
const [timezones, setTimezones] = React.useState<string[]>([]);
const forceUpdate = useForceUpdater();
React.useEffect(() => {
useTimezones().then(setTimezones);
getUserTimezone(user.id, preference).then(tz => setTimezone(tz));
// Rerender every 10 seconds to stay in sync.
const interval = setInterval(forceUpdate, 10 * 1000);
return () => clearInterval(interval);
}, [preference]);
if (!showTimezonesInProfile)
return null;
return (
<Text variant="text-sm/normal" className={classNames.customStatusSection}
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
...(isInEditMode ? {
display: "flex",
flexDirection: "column",
} : {})
}}
>
{!isInEditMode &&
<span
style={{ fontSize: "1.2em", cursor: (timezone ? "pointer" : "") }}
onClick={() => {
if (timezone) {
Toasts.show({
type: Toasts.Type.MESSAGE,
message: timezone,
id: Toasts.genId()
});
}
}}
>
{(timezone) ? getTimeString(timezone) : "No timezone set"}
</span>
}
{isInEditMode && (
<span style={{ width: "90%" }}>
<SearchableSelect
placeholder="Pick a timezone"
options={timezones.map(tz => ({ label: tz, value: tz }))}
value={timezone ? { label: timezone, value: timezone } : undefined}
onChange={value => { setTimezone(value); }}
/>
</span>
)}
<span style={
isInEditMode ? {
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-around",
width: "60%",
marginTop: "5%"
} : {
marginLeft: "2%",
display: "flex"
}}
>
<CogWheel
style={{ cursor: "pointer", padding: "2px", border: "2px solid grey", borderRadius: "50px" }}
onClick={() => {
if (!isInEditMode) {
setIsInEditMode(true);
return;
}
if (!timezone) {
setIsInEditMode(false);
return;
}
DataStore.update(DATASTORE_KEY, (oldValue: TimezoneDB | undefined) => {
oldValue = oldValue || {};
oldValue[user.id] = timezone;
return oldValue;
}).then(() => {
Toasts.show({
type: Toasts.Type.SUCCESS,
message: "Timezone set!",
id: Toasts.genId()
});
setIsInEditMode(false);
}).catch(err => {
console.error(err);
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Something went wrong, please try again later.",
id: Toasts.genId()
});
});
}}
color="var(--primary-330)"
height="16"
width="16"
/>
{isInEditMode &&
<DeleteIcon
style={{ cursor: "pointer", padding: "2px", border: "2px solid grey", borderRadius: "50px" }}
onClick={() => {
DataStore.update(DATASTORE_KEY, (oldValue: TimezoneDB | undefined) => {
oldValue = oldValue || {};
delete oldValue[user.id];
return oldValue;
}).then(async () => {
Toasts.show({
type: Toasts.Type.SUCCESS,
message: "Timezone removed!",
id: Toasts.genId()
});
setIsInEditMode(false);
setTimezone(await getUserTimezone(user.id, preference));
}).catch(err => {
console.error(err);
Toasts.show({
type: Toasts.Type.FAILURE,
message: "Something went wrong, please try again later.",
id: Toasts.genId()
});
});
}}
color="var(--red-360)"
height="16"
width="16"
/>
}
</span>
</Text >
);
},
getTimezonesComponent: ({ message }: { message: Message; }) => {
console.log(message);
const { showTimezonesInChat, preference } = settings.use(["preference", "showTimezonesInChat"]);
const [timezone, setTimezone] = React.useState<string | undefined>();
React.useEffect(() => {
if (!showTimezonesInChat) return;
getUserTimezone(message.author.id, preference).then(tz => setTimezone(tz));
}, [showTimezonesInChat, preference]);
if (!showTimezonesInChat || message.author.id === UserStore.getCurrentUser()?.id)
return null;
return (
<span className={classes(styles.timestampInline, styles.timestamp)}>
{timezone && "• " + getTimeString(timezone, message.timestamp.toDate())}
</span>);
}
});

View file

@ -0,0 +1,59 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export enum CustomTimezonePreference {
Never,
Secondary,
Always
}
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,
},
showTimezonesInChat: {
type: OptionType.BOOLEAN,
description: "Show timezones in chat",
default: true,
},
showTimezonesInProfile: {
type: OptionType.BOOLEAN,
description: "Show timezones in profile",
default: true,
},
});