feat(timezones): add a database option (#272)

* add a database option for timezones

* forgot to remove wrong comments lol
This commit is contained in:
Creation's 2025-05-28 09:25:51 -04:00 committed by GitHub
parent 2aae71abf7
commit 43e8870dbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 266 additions and 41 deletions

View file

@ -8,9 +8,10 @@ import * as DataStore from "@api/DataStore";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { Button, Forms, SearchableSelect, useMemo, useState } from "@webpack/common"; import { Button, Forms, SearchableSelect, useEffect, useMemo, useState } from "@webpack/common";
import { DATASTORE_KEY, timezones } from "."; import { DATASTORE_KEY, settings, timezones } from ".";
import { getTimezone, setTimezone, setUserDatabaseTimezone } from "./database";
export async function setUserTimezone(userId: string, timezone: string | null) { export async function setUserTimezone(userId: string, timezone: string | null) {
timezones[userId] = timezone; timezones[userId] = timezone;
@ -19,9 +20,24 @@ export async function setUserTimezone(userId: string, timezone: string | null) {
const cl = classNameFactory("vc-timezone-"); const cl = classNameFactory("vc-timezone-");
export function SetTimezoneModal({ userId, modalProps }: { userId: string, modalProps: ModalProps; }) { export function SetTimezoneModal({ userId, modalProps, database }: { userId: string, modalProps: ModalProps; database?: boolean; }) {
const [currentValue, setCurrentValue] = useState<string | null>(timezones[userId] ?? null); const [currentValue, setCurrentValue] = useState<string | null>(timezones[userId] ?? null);
useEffect(() => {
if (!database) return;
const localTimezone = timezones[userId];
const shouldUseDatabase =
settings.store.useDatabase &&
(settings.store.preferDatabaseOverLocal || localTimezone == null);
if (shouldUseDatabase) {
getTimezone(userId).then(setCurrentValue);
} else {
setCurrentValue(localTimezone);
}
}, [userId, settings.store.useDatabase, settings.store.preferDatabaseOverLocal, database]);
const options = useMemo(() => { const options = useMemo(() => {
return Intl.supportedValuesOf("timeZone").map(timezone => { return Intl.supportedValuesOf("timeZone").map(timezone => {
const offset = new Intl.DateTimeFormat(undefined, { timeZone: timezone, timeZoneName: "short" }) const offset = new Intl.DateTimeFormat(undefined, { timeZone: timezone, timeZoneName: "short" })
@ -59,6 +75,7 @@ export function SetTimezoneModal({ userId, modalProps }: { userId: string, modal
</ModalContent> </ModalContent>
<ModalFooter className={cl("modal-footer")}> <ModalFooter className={cl("modal-footer")}>
{!database && (
<Button <Button
color={Button.Colors.RED} color={Button.Colors.RED}
onClick={async () => { onClick={async () => {
@ -68,11 +85,18 @@ export function SetTimezoneModal({ userId, modalProps }: { userId: string, modal
> >
Delete Timezone Delete Timezone
</Button> </Button>
)}
<Button <Button
color={Button.Colors.BRAND} color={Button.Colors.BRAND}
disabled={currentValue === null} disabled={currentValue === null}
onClick={async () => { onClick={async () => {
await setUserTimezone(userId, currentValue!); if (database) {
await setUserDatabaseTimezone(userId, currentValue);
await setTimezone(currentValue!);
} else {
await setUserTimezone(userId, currentValue);
}
modalProps.onClose(); modalProps.onClose();
}} }}
> >

View file

@ -0,0 +1,125 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
type CacheEntry = {
value: string | null;
expires: number;
};
import { DataStore } from "@api/index";
import { openModal } from "@utils/modal";
import { OAuth2AuthorizeModal, showToast, Toasts } from "@webpack/common";
export const DOMAIN = "https://timezone.creations.works";
export const REDIRECT_URI = `${DOMAIN}/auth/discord/callback`;
export const CLIENT_ID = "1377021506810417173";
export const DATASTORE_KEY = "vencord-database-timezones";
export let databaseTimezones: Record<string, CacheEntry> = {};
(async () => {
databaseTimezones = await DataStore.get<Record<string, CacheEntry>>(DATASTORE_KEY) || {};
})();
const pendingRequests: Record<string, Promise<string | null>> = {};
export async function setUserDatabaseTimezone(userId: string, timezone: string | null) {
databaseTimezones[userId] = {
value: timezone,
expires: Date.now() + 60 * 60 * 1000 // 1 hour
};
await DataStore.set(DATASTORE_KEY, databaseTimezones);
}
export async function getTimezone(userId: string): Promise<string | null> {
const now = Date.now();
const cached = databaseTimezones[userId];
if (cached && now < cached.expires) return cached.value;
if (!pendingRequests[userId]) {
pendingRequests[userId] = (async () => {
const res = await fetch(`${DOMAIN}/get?id=${userId}`, {
headers: { Accept: "application/json" }
});
let value: string | null = null;
if (res.ok) {
const json = await res.json();
if (json?.timezone && typeof json.timezone === "string") {
value = json.timezone;
}
}
setUserDatabaseTimezone(userId, value);
delete pendingRequests[userId];
return value;
})();
}
return pendingRequests[userId];
}
export async function setTimezone(timezone: string): Promise<boolean> {
const res = await fetch(`${DOMAIN}/set?timezone=${encodeURIComponent(timezone)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
credentials: "include"
});
return res.ok;
}
export async function deleteTimezone(): Promise<boolean> {
const res = await fetch(`${DOMAIN}/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
credentials: "include"
});
return res.ok;
}
export function authModal(callback?: () => void) {
openModal(modalProps => (
<OAuth2AuthorizeModal
{...modalProps}
clientId={CLIENT_ID}
redirectUri={REDIRECT_URI}
responseType="code"
scopes={["identify"]}
permissions={0n}
cancelCompletesFlow={false}
callback={async (res: any) => {
try {
const url = new URL(res.location);
const r = await fetch(url, {
credentials: "include",
headers: { Accept: "application/json" }
});
const json = await r.json();
if (!r.ok) {
showToast(json.message ?? "Authorization failed", Toasts.Type.FAILURE);
return;
}
showToast("Authorization successful!", Toasts.Type.SUCCESS);
callback?.();
} catch (e) {
showToast("Unexpected error during authorization", Toasts.Type.FAILURE);
}
}}
/>
));
}

View file

@ -10,13 +10,14 @@ import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import * as DataStore from "@api/DataStore"; import * as DataStore from "@api/DataStore";
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { openModal } from "@utils/modal"; import { openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { Menu, Tooltip, useEffect, useState } from "@webpack/common"; import { Button, Menu, showToast, Toasts, Tooltip, useEffect, UserStore, useState } from "@webpack/common";
import { Message, User } from "discord-types/general"; import { Message, User } from "discord-types/general";
import { authModal, deleteTimezone, getTimezone, setUserDatabaseTimezone } from "./database";
import { SetTimezoneModal } from "./TimezoneModal"; import { SetTimezoneModal } from "./TimezoneModal";
export const DATASTORE_KEY = "vencord-timezones"; export const DATASTORE_KEY = "vencord-timezones";
@ -46,6 +47,51 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN, type: OptionType.BOOLEAN,
description: "Show time in profiles", description: "Show time in profiles",
default: true default: true
},
useDatabase: {
type: OptionType.BOOLEAN,
description: "Enable database for getting user timezones",
default: false
},
preferDatabaseOverLocal: {
type: OptionType.BOOLEAN,
description: "Prefer database over local storage for timezones",
default: true
},
setDatabaseTimezone: {
description: "Set your timezone on the database",
type: OptionType.COMPONENT,
component: () => (
<Button onClick={() => {
authModal(async () => {
openModal(modalProps => <SetTimezoneModal userId={UserStore.getCurrentUser().id} modalProps={modalProps} database={true} />);
}
);
}}>
Set Timezone on Database
</Button>
)
},
resetDatabaseTimezone: {
description: "Reset your timezone on the database",
type: OptionType.COMPONENT,
component: () => (
<Button
color={Button.Colors.RED}
onClick={() => {
authModal(async () => {
await setUserDatabaseTimezone(UserStore.getCurrentUser().id, null);
await deleteTimezone();
});
}}
>
Reset Database Timezones
</Button>
)
} }
}); });
@ -66,23 +112,33 @@ interface Props {
} }
const TimestampComponent = ErrorBoundary.wrap(({ userId, timestamp, type }: Props) => { const TimestampComponent = ErrorBoundary.wrap(({ userId, timestamp, type }: Props) => {
const [currentTime, setCurrentTime] = useState(timestamp || Date.now()); const [currentTime, setCurrentTime] = useState(timestamp || Date.now());
const timezone = timezones[userId]; const [timezone, setTimezone] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let timer: NodeJS.Timeout; const localTimezone = timezones[userId];
const shouldUseDatabase =
settings.store.useDatabase &&
(settings.store.preferDatabaseOverLocal || localTimezone == null);
if (shouldUseDatabase) {
getTimezone(userId).then(setTimezone);
} else {
setTimezone(localTimezone);
}
}, [userId, settings.store.useDatabase, settings.store.preferDatabaseOverLocal]);
useEffect(() => {
if (type !== "profile") return;
if (type === "profile") {
setCurrentTime(Date.now()); setCurrentTime(Date.now());
const now = new Date(); const now = new Date();
const delay = (60 - now.getSeconds()) * 1000 + 1000 - now.getMilliseconds(); const delay = (60 - now.getSeconds()) * 1000 + 1000 - now.getMilliseconds();
const timer = setTimeout(() => {
timer = setTimeout(() => {
setCurrentTime(Date.now()); setCurrentTime(Date.now());
}, delay); }, delay);
}
return () => timer && clearTimeout(timer); return () => clearTimeout(timer);
}, [type, currentTime]); }, [type, currentTime]);
if (!timezone) return null; if (!timezone) return null;
@ -94,8 +150,9 @@ const TimestampComponent = ErrorBoundary.wrap(({ userId, timestamp, type }: Prop
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric"
}); });
return ( return (
<Tooltip <Tooltip
position="top" position="top"
@ -107,22 +164,19 @@ const TimestampComponent = ErrorBoundary.wrap(({ userId, timestamp, type }: Prop
tooltipClassName="timezone-tooltip" tooltipClassName="timezone-tooltip"
text={longTime} text={longTime}
> >
{toolTipProps => { {toolTipProps => (
return (
<span <span
{...toolTipProps} {...toolTipProps}
className={type === "message" ? `timezone-message-item ${classes.timestamp}` : "timezone-profile-item"} className={type === "message" ? `timezone-message-item ${classes.timestamp}` : "timezone-profile-item"}
> >
{ {type === "message" ? `(${shortTime})` : shortTime}
type === "message" ? `(${shortTime})` : shortTime
}
</span> </span>
); )}
}}
</Tooltip> </Tooltip>
); );
}, { noop: true }); }, { noop: true });
const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: { user: User; }) => { const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: { user: User; }) => {
if (user?.id == null) return; if (user?.id == null) return;
@ -136,11 +190,33 @@ const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: {
children.push(<Menu.MenuSeparator />, setTimezoneItem); children.push(<Menu.MenuSeparator />, setTimezoneItem);
if (settings.store.useDatabase) {
const refreshTimezoneItem = (
<Menu.MenuItem
label="Refresh Timezone"
id="refresh-timezone"
action={async () => {
showToast("Refreshing timezone...", Toasts.Type.CLOCK);
try {
const timezone = await getTimezone(user.id);
setUserDatabaseTimezone(user.id, timezone);
timezones[user.id] = timezone;
showToast("Timezone refreshed successfully!", Toasts.Type.SUCCESS);
} catch (error) {
console.error("Failed to refresh timezone:", error);
showToast("Failed to refresh timezone.", Toasts.Type.FAILURE);
}
}}
/>
);
children.push(refreshTimezoneItem);
}
}; };
export default definePlugin({ export default definePlugin({
name: "Timezones", name: "Timezones",
authors: [Devs.Aria], authors: [Devs.Aria, EquicordDevs.creations],
description: "Shows the local time of users in profiles and message headers", description: "Shows the local time of users in profiles and message headers",
contextMenus: { contextMenus: {
"user-context": userContextMenuPatch "user-context": userContextMenuPatch