commit 73b678e688cce86b5295f16d8ff45bd35932bbcb Author: creations Date: Sun Jun 1 08:51:28 2025 -0400 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b3c621 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Timezones + +A Vencord plugin that shows the local time of users in profiles and message headers. Supports persistent timezones via a centralized database. + +## Features + +- Shows user timezones in message headers and profile modals. +- Allows setting local or database-based timezones. +- 24h or 12h format toggle. +- Easily manage your own timezone with database sync. +- Tooltip with detailed datetime format. + +## Settings + +| Setting | Type | Description | Default | +|---------------------------|-----------|-----------------------------------------------------------------------------|-----------| +| Show Own Timezone | Boolean | Show your own timezone in message headers and profiles | `true` | +| 24h Time | Boolean | Display time in 24-hour format | `false` | +| Show Time in Messages | Boolean | Show local time next to messages | `true` | +| Show Time in Profiles | Boolean | Show local time in user profiles | `true` | +| Use Database | Boolean | Enable pulling timezones from the online database | `true` | +| Prefer Database Over Local| Boolean | Prefer database timezones over locally stored ones | `true` | +| Set Database Timezone | Component | Opens a modal to set your timezone in the database | | +| Reset Database Timezone | Component | Clears your stored database timezone | | + +## Toolbox Actions + +- **Set Database Timezone**: Opens the authorization modal to set your current timezone. +- **Refresh Database Timezones**: Refetches all stored timezones from the server. + +## Installation + +For installation instructions, see [Vencord Docs](https://docs.vencord.dev/installing/custom-plugins/). + +## Infos + +- Timezones are saved per-user. +- Database-based timezones are synced from [timezone.creations.works](https://timezone.creations.works). +- The plugin prompts the user to store their timezone on the first use if database mode is enabled. + +## Author + +- [daveyy](https://daveyy.net) +- [creations](https://creations.works) + +## License + +[GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html) diff --git a/TimezoneModal.tsx b/TimezoneModal.tsx new file mode 100644 index 0000000..bd90d37 --- /dev/null +++ b/TimezoneModal.tsx @@ -0,0 +1,106 @@ +/* + * 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 { classNameFactory } from "@api/Styles"; +import { Margins } from "@utils/margins"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal"; +import { Button, Forms, SearchableSelect, useEffect, useMemo, useState } from "@webpack/common"; + +import { DATASTORE_KEY, settings, timezones } from "."; +import { getTimezone, setTimezone, setUserDatabaseTimezone } from "./database"; + +export async function setUserTimezone(userId: string, timezone: string | null) { + timezones[userId] = timezone; + await DataStore.set(DATASTORE_KEY, timezones); +} + +const cl = classNameFactory("vc-timezone-"); + +export function SetTimezoneModal({ userId, modalProps, database }: { userId: string, modalProps: ModalProps; database?: boolean; }) { + const [currentValue, setCurrentValue] = useState(timezones[userId] ?? null); + + useEffect(() => { + const localTimezone = timezones[userId]; + const shouldUseDatabase = + settings.store.useDatabase && + (settings.store.preferDatabaseOverLocal || !localTimezone); + + const value = shouldUseDatabase + ? getTimezone(userId) ?? localTimezone + : localTimezone; + + setCurrentValue(value ?? Intl.DateTimeFormat().resolvedOptions().timeZone); + }, [userId, settings.store.useDatabase, settings.store.preferDatabaseOverLocal]); + + const options = useMemo(() => { + return Intl.supportedValuesOf("timeZone").map(timezone => { + const offset = new Intl.DateTimeFormat(undefined, { timeZone: timezone, timeZoneName: "short" }) + .formatToParts(new Date()) + .find(part => part.type === "timeZoneName")!.value; + + return { label: `${timezone} (${offset})`, value: timezone }; + }); + }, []); + + return ( + + + + Timezones + + + + + +
+ + Select Timezone + + + o.value === currentValue)} + placeholder={"Select a Timezone"} + maxVisibleItems={5} + closeOnSelect={true} + onChange={v => setCurrentValue(v)} + /> +
+
+ + + {!database && ( + + )} + + +
+ ); +} diff --git a/database.tsx b/database.tsx new file mode 100644 index 0000000..f382c91 --- /dev/null +++ b/database.tsx @@ -0,0 +1,110 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { openModal } from "@utils/index"; +import { OAuth2AuthorizeModal, showToast, Toasts } from "@webpack/common"; + +const databaseTimezones: Record = {}; + +const DOMAIN = "https://timezone.creations.works"; +const REDIRECT_URI = `${DOMAIN}/auth/discord/callback`; +const CLIENT_ID = "1377021506810417173"; + +export async function setUserDatabaseTimezone(userId: string, timezone: string | null) { + databaseTimezones[userId] = { value: timezone }; +} + +export function getTimezone(userId: string): string | null { + return databaseTimezones[userId]?.value ?? null; +} + +export async function loadDatabaseTimezones(): Promise { + try { + const res = await fetch(`${DOMAIN}/list`, { + headers: { Accept: "application/json" } + }); + + if (res.ok) { + const json = await res.json(); + for (const id in json) { + databaseTimezones[id] = { + value: json[id]?.timezone ?? null + }; + } + + return true; + } + + return false; + } catch (e) { + console.error("Failed to fetch timezones list:", e); + return false; + } +} + +export async function setTimezone(timezone: string): Promise { + 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 { + 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 => ( + { + if (!res || !res.location) return; + + 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) { + console.error("Error during authorization:", e); + showToast("Unexpected error during authorization", Toasts.Type.FAILURE); + } + }} + /> + )); +} diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..a6b5076 --- /dev/null +++ b/index.tsx @@ -0,0 +1,303 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import * as DataStore from "@api/DataStore"; +import { definePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import { openModal } from "@utils/modal"; +import definePlugin, { OptionType } from "@utils/types"; +import { findByPropsLazy } from "@webpack"; +import { Button, Menu, showToast, Toasts, Tooltip, useEffect, UserStore, useState } from "@webpack/common"; +import { Message, User } from "discord-types/general"; + +import { authModal, deleteTimezone, getTimezone, loadDatabaseTimezones, setUserDatabaseTimezone } from "./database"; +import { SetTimezoneModal } from "./TimezoneModal"; + +export let timezones: Record = {}; +export const DATASTORE_KEY = "vencord-timezones"; + +const classes = findByPropsLazy("timestamp", "compact", "contentOnly"); +const locale = findByPropsLazy("getLocale"); + +export const settings = definePluginSettings({ + "Show Own Timezone": { + type: OptionType.BOOLEAN, + description: "Show your own timezone in profiles and message headers", + default: true + }, + + "24h Time": { + type: OptionType.BOOLEAN, + description: "Show time in 24h format", + default: false + }, + + showMessageHeaderTime: { + type: OptionType.BOOLEAN, + description: "Show time in message headers", + default: true + }, + + showProfileTime: { + type: OptionType.BOOLEAN, + description: "Show time in profiles", + default: true + }, + + useDatabase: { + type: OptionType.BOOLEAN, + description: "Enable database for getting user timezones", + default: true + }, + + 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: () => ( + + ) + }, + + resetDatabaseTimezone: { + description: "Reset your timezone on the database", + type: OptionType.COMPONENT, + component: () => ( + + ) + }, + + askedTimezone: { + type: OptionType.BOOLEAN, + description: "Whether the user has been asked to set their timezone", + hidden: true, + default: false + } +}); + +function getTime(timezone: string, timestamp: string | number, props: Intl.DateTimeFormatOptions = {}) { + const date = new Date(timestamp); + const formatter = new Intl.DateTimeFormat(locale.getLocale() ?? "en-US", { + hour12: !settings.store["24h Time"], + timeZone: timezone, + ...props + }); + return formatter.format(date); +} + +interface Props { + userId: string; + timestamp?: string; + type: "message" | "profile"; +} + +const TimestampComponent = ErrorBoundary.wrap(({ userId, timestamp, type }: Props) => { + const [currentTime, setCurrentTime] = useState(timestamp || Date.now()); + const [timezone, setTimezone] = useState(null); + + useEffect(() => { + const localTimezone = timezones[userId]; + const shouldUseDatabase = + settings.store.useDatabase && + (settings.store.preferDatabaseOverLocal || !localTimezone); + + if (shouldUseDatabase) { + setTimezone(getTimezone(userId) ?? localTimezone); + } else { + setTimezone(localTimezone); + } + }, [userId, settings.store.useDatabase, settings.store.preferDatabaseOverLocal]); + + useEffect(() => { + if (type !== "profile") return; + + setCurrentTime(Date.now()); + + const now = new Date(); + const delay = (60 - now.getSeconds()) * 1000 + 1000 - now.getMilliseconds(); + const timer = setTimeout(() => { + setCurrentTime(Date.now()); + }, delay); + + return () => clearTimeout(timer); + }, [type, currentTime]); + + if (!timezone) return null; + + const shortTime = getTime(timezone, currentTime, { hour: "numeric", minute: "numeric" }); + const longTime = getTime(timezone, currentTime, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "numeric" + }); + + return ( + + {toolTipProps => ( + + {type === "message" ? `(${shortTime})` : shortTime} + + )} + + ); +}, { noop: true }); + +const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: { user: User; }) => { + if (user?.id == null) return; + + const setTimezoneItem = ( + openModal(modalProps => )} + /> + ); + + children.push(, setTimezoneItem); +}; + +export default definePlugin({ + name: "Timezones", + authors: [Devs.Aria, { + name: "creations", + id: 209830981060788225n, + }], + description: "Shows the local time of users in profiles and message headers", + contextMenus: { + "user-context": userContextMenuPatch + }, + + patches: [ + // stolen from ViewIcons + { + find: 'backgroundColor:"COMPLETE"', + replacement: { + match: /(?<=backgroundImage.+?)children:\[/, + replace: "$&$self.renderProfileTimezone(arguments[0])," + } + }, + { + find: '"Message Username"', + replacement: { + // thanks https://github.com/Syncxv/vc-timezones/pull/4 + match: /(?<=isVisibleOnlyOnHover.+?)id:.{1,11},timestamp.{1,50}}\),/, + replace: "$&,$self.renderMessageTimezone(arguments[0])," + } + } + ], + + toolboxActions: { + "Set Database Timezone": () => { + authModal(async () => { + openModal(modalProps => ); + }); + }, + "Refresh Database Timezones": async () => { + try { + const good = await loadDatabaseTimezones(); + + if (good) { + showToast("Timezones refreshed successfully!", Toasts.Type.SUCCESS); + } else { + showToast("Timezones Failed to refresh!", Toasts.Type.FAILURE); + } + } + catch (error) { + console.error("Failed to refresh timezone:", error); + showToast("Failed to refresh timezones.", Toasts.Type.FAILURE); + } + } + }, + + async start() { + timezones = await DataStore.get>(DATASTORE_KEY) || {}; + + if (settings.store.useDatabase) { + await loadDatabaseTimezones(); + + if (!settings.store.askedTimezone) { + showToast( + "", + Toasts.Type.MESSAGE, + { + duration: 10000, + component: ( + + ), + position: Toasts.Position.BOTTOM + } + ); + settings.store.askedTimezone = true; + } + } + }, + + settings, + getTime, + + renderProfileTimezone: (props?: { user?: User; }) => { + if (!settings.store.showProfileTime || !props?.user?.id) return null; + if (props.user.id === UserStore.getCurrentUser().id && !settings.store["Show Own Timezone"]) return null; + + return ; + }, + + renderMessageTimezone: (props?: { message?: Message; }) => { + if (!settings.store.showMessageHeaderTime || !props?.message) return null; + if (props.message.author.id === UserStore.getCurrentUser().id && !settings.store["Show Own Timezone"]) return null; + + return ; + } +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..386e197 --- /dev/null +++ b/styles.css @@ -0,0 +1,41 @@ +.timezone-profile-item { + position: absolute; + right: 0; + bottom: 0; + margin: 28px 16px 4px; + background: var(--profile-body-background-color, var(--background-primary)); + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + color: var(--text-normal); +} + +[class*="topSection"] .timezone-profile-item { + margin: 16px; +} + +.timezone-message-item { + margin-left: 4px; +} + +.vc-timezone-modal-header { + place-content: center; + justify-content: space-between; +} + +.vc-timezone-modal-header h1 { + margin: 0; +} + +.vc-timezone-modal-content { + padding: 1em; +} + +.vc-timezone-modal-footer { + gap: 16px; +} + +.timezone-tooltip { + max-width: none!important; + white-space: nowrap +}