use components from vc-timezones

This commit is contained in:
rushiiMachine 2024-06-23 20:42:04 -07:00
parent 925629f85d
commit 1b145a5695
No known key found for this signature in database
GPG key ID: DCBE5952BB3B6420
7 changed files with 229 additions and 212 deletions

View file

@ -1,5 +1,12 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent"; import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import settings from "./settings"; import settings from "./settings";
export type Snowflake = string; export type Snowflake = string;
@ -22,10 +29,10 @@ export async function fetchTimezonesBulk(ids: Snowflake[]): Promise<Record<Snowf
const json: BulkFetchResponse = await req.json(); const json: BulkFetchResponse = await req.json();
if ("error" in json) throw "API Error: " + json.error; if ("error" in json) throw "API Error: " + json.error;
let parsed: Record<Snowflake, string | null> = {}; const parsed: Record<Snowflake, string | null> = {};
for (const userId of Object.keys(json)) { for (const userId of Object.keys(json)) {
parsed[userId] = json[userId].timezoneId; parsed[userId] = json[userId]?.timezoneId ?? null;
} }
return parsed; return parsed;
@ -48,7 +55,7 @@ export async function fetchTimezone(userId: Snowflake): Promise<string | null |
const json: UserFetchResponse = await req.json(); const json: UserFetchResponse = await req.json();
if ("error" in json) { if ("error" in json) {
if (json.error == "not_found") return null; if (json.error === "not_found") return null;
throw "API Error: " + json.error; throw "API Error: " + json.error;
} }

View file

@ -1,10 +1,17 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore"; import * as DataStore from "@api/DataStore";
import { createStore } 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 { debounce } from "@shared/debounce";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import { fetchTimezone, fetchTimezonesBulk, Snowflake } from "./api";
import settings, { TimezoneOverwrites } from "./settings";
// TODO: cache invalidation // TODO: cache invalidation
const TimezoneCache = createStore("UsersTimezoneCache", "TimezoneCache"); const TimezoneCache = createStore("UsersTimezoneCache", "TimezoneCache");
@ -16,14 +23,14 @@ let BulkFetchQueue: BulkFetchCallbacks = {};
// Executes all queued requests and calls their callbacks // Executes all queued requests and calls their callbacks
const debounceProcessBulkQueue = debounce(processBulkQueue, 750); const debounceProcessBulkQueue = debounce(processBulkQueue, 750);
async function processBulkQueue(attempt: number = 1, existingCallbacks?: BulkFetchCallbacks) { async function processBulkQueue(attempt: number = 1, retryQueue?: BulkFetchCallbacks) {
if (attempt > 3) { if (attempt > 3) {
new Logger("Timezones").warn("Bulk queue fetch ran out of retries!"); new Logger("Timezones").warn("Bulk queue fetch ran out of retries!");
return; return;
} }
const callbacks = existingCallbacks ?? BulkFetchQueue; const callbacks = retryQueue ?? BulkFetchQueue;
if (!existingCallbacks) BulkFetchQueue = {}; if (!retryQueue) BulkFetchQueue = {};
const timezones = await fetchTimezonesBulk(Object.keys(callbacks)); const timezones = await fetchTimezonesBulk(Object.keys(callbacks));
if (!timezones) { if (!timezones) {
@ -47,9 +54,10 @@ export async function getUserTimezone(
immediate: boolean = false, immediate: boolean = false,
force: boolean = false, force: boolean = false,
): Promise<string | null> { ): Promise<string | null> {
const overwrites = settings.store.timezoneOverwrites as TimezoneOverwrites; const overwrites = settings.store.timezoneOverwrites ?? {} as TimezoneOverwrites;
const useApi = settings.store.enableApi;
const overwrite = overwrites[userId]; const overwrite = overwrites[userId];
if (overwrite) return overwrite ?? null; if (overwrite || !useApi) return overwrite ?? null;
if (!force) { if (!force) {
const cachedTimezone = await DataStore.get<string | null>(userId, TimezoneCache); const cachedTimezone = await DataStore.get<string | null>(userId, TimezoneCache);
@ -61,9 +69,10 @@ export async function getUserTimezone(
if (immediate) { if (immediate) {
let tries = 3; let tries = 3;
while (tries-- > 0) { while (tries-- > 0) {
let timezone = await fetchTimezone(userId); const timezone = await fetchTimezone(userId);
if (timezone === undefined) continue; if (timezone === undefined) continue;
DataStore.set(userId, timezone, TimezoneCache).catch(_ => _);
return timezone; return timezone;
} }

View file

@ -0,0 +1,88 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { ErrorBoundary } from "@components/index";
import { findByPropsLazy } from "@webpack";
import { React, Tooltip, useEffect, useState } from "@webpack/common";
import { Snowflake } from "./api";
import { getUserTimezone } from "./cache";
import { formatTimestamp } from "./utils";
// Based on Syncxv's vc-timezones user plugin //
const messageClasses = findByPropsLazy("timestamp", "compact", "contentOnly");
interface LocalTimestampProps {
userId: Snowflake;
timestamp?: Date;
type: "message" | "profile";
}
export function LocalTimestamp(props: LocalTimestampProps): JSX.Element {
return <ErrorBoundary noop={true} wrappedProps={props}>
<LocalTimestampInner {...props} />
</ErrorBoundary>;
}
function LocalTimestampInner(props: LocalTimestampProps): JSX.Element | null {
const [timezone, setTimezone] = useState<string | null>();
const [timestamp, setTimestamp] = useState(props.timestamp ?? Date.now());
useEffect(() => {
if (!timezone) {
getUserTimezone(props.userId, props.type === "profile").then(setTimezone);
return;
}
let timer: NodeJS.Timeout;
if (props.type === "profile") {
setTimestamp(Date.now());
const now = new Date();
const delay = (60 - now.getSeconds()) * 1000 + 1000 - now.getMilliseconds();
timer = setTimeout(() => setTimestamp(Date.now()), delay);
}
return () => timer && clearTimeout(timer);
}, [timezone, timestamp]);
if (!timezone) return null;
const longTime = formatTimestamp(timezone, timestamp, true);
const shortTime = formatTimestamp(timezone, timestamp, false);
const shortTimeFormatted = props.type === "message"
? `${shortTime}`
: shortTime;
const classes = props.type === "message"
? `timezone-message-item ${messageClasses.timestamp}`
: "timezone-profile-item";
return <>
<Tooltip
position="top"
// @ts-ignore
delay={750}
allowOverflow={false}
spacing={8}
hideOnClick={true}
tooltipClassName="timezone-tooltip"
text={longTime}
>
{toolTipProps => <>
<span className={classes} {...toolTipProps}>
{shortTimeFormatted}
</span>
</>}
</Tooltip>
</>;
}

View file

@ -6,22 +6,15 @@
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 { SearchableSelect, Text, Toasts, useEffect, UserStore, useState } from "@webpack/common";
import { Message, User } from "discord-types/general"; import { Message, User } from "discord-types/general";
import { LocalTimestamp } from "./components";
import settings, { SettingsComponent } from "./settings"; import settings, { SettingsComponent } from "./settings";
import { CogWheel, DeleteIcon } from "@components/Icons";
import { classes } from "@utils/misc";
import { useForceUpdater } from "@utils/react";
const classNames = findByPropsLazy("customStatusSection");
const styles = findByPropsLazy("timestampInline");
export default definePlugin({ export default definePlugin({
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.Aria, Devs.mantikafasi, Devs.Arjix],
settings, settings,
settingsAboutComponent: SettingsComponent, settingsAboutComponent: SettingsComponent,
@ -44,197 +37,50 @@ export default definePlugin({
// replace: "return [$1, $self.getProfileTimezonesComponent(arguments[0])] }", // replace: "return [$1, $self.getProfileTimezonesComponent(arguments[0])] }",
// }, // },
// }, // },
{ // {
// TODO: fix this // // TODO: fix this
// thank you https://github.com/Syncxv/vc-timezones/blob/master/index.tsx for saving me from painful work // // thank you https://github.com/Syncxv/vc-timezones/blob/master/index.tsx for saving me from painful work
find: ".badgesContainer,{", // find: ".badgesContainer,{",
// replacement: {
// match: /id:\(0,\i\.getMessageTimestampId\)\(\i\),timestamp.{1,50}}\),/,
// replace: "$&,$self.getTimezonesComponent(arguments[0]),",
// },
// },
// Based on Syncxv's vc-timezones user plugin //
...[".NITRO_BANNER,", "=!1,canUsePremiumCustomization:"].map(find => ({
find,
replacement: { replacement: {
match: /id:\(0,\i\.getMessageTimestampId\)\(\i\),timestamp.{1,50}}\),/, match: /(?<=hasProfileEffect.+?)children:\[/,
replace: "$&,$self.getTimezonesComponent(arguments[0]),", 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]),",
}, },
}, },
], ],
// TODO: make this not ugly (port vc-timezones plugin) renderProfileTimezone: (props?: { user?: User; }) => {
getProfileTimezonesComponent: ({ user }: { user: User; }) => { if (!settings.store.displayInProfile || !props?.user?.id) return null;
const { displayInProfile } = settings.use(["displayInProfile"]);
const [timezone, setTimezone] = useState<string | undefined>(); return <LocalTimestamp
const [isInEditMode, setIsInEditMode] = useState(false); userId={props.user.id}
const [timezones, setTimezones] = useState<string[]>([]); type="profile"
/>;
const forceUpdate = useForceUpdater();
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 (!showInProfile)
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; }) => { renderMessageTimezone: (props?: { message?: Message; }) => {
console.log(message); if (!settings.store.displayInChat || !props?.message) return null;
const { showInChat, preference } = settings.use(["preference", "showInChat"]); return <LocalTimestamp
const [timeString, setTimeString] = useState<string>(); userId={props.message.author.id}
timestamp={props.message.timestamp as unknown as Date}
if (!showInChat || message.author.id === UserStore.getCurrentUser()?.id) type="message"
return null; />;
useEffect(() => {
if (!showInChat) return;
(async function() {
const timezone = await getUserTimezone(message.author.id, preference);
const timestamp = (message.timestamp as unknown) as Date; // discord-types is outdated
setTimeString(timezone && "• " + getTimeString(timezone, timestamp));
})();
}, [showInChat, preference]);
return <>
<span className={classes(styles.timestampInline, styles.timestamp)}>
{timeString}
</span>
</>;
}, },
}); });

View file

@ -17,9 +17,10 @@
*/ */
import { definePluginSettings } from "@api/Settings"; import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
import { Text } from "@webpack/common";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import { IPluginOptionComponentProps, OptionType } from "@utils/types";
import { Text } from "@webpack/common";
import { Snowflake } from "./api"; import { Snowflake } from "./api";
export type TimezoneOverwrites = Record<Snowflake, string>; export type TimezoneOverwrites = Record<Snowflake, string>;
@ -48,8 +49,13 @@ const settings = definePluginSettings({
timezoneOverwrites: { timezoneOverwrites: {
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
description: "Local overwrites for users' timezones", description: "Local overwrites for users' timezones",
component: () => <></> // TODO: settings component to manage local overwrites, component: props => <>
} <TimezoneOverwritesSetting
setValue={props.setValue}
setError={props.setError}
option={props.option} />
</>,
},
}); });
export default settings; export default settings;
@ -69,3 +75,7 @@ export function SettingsComponent(): JSX.Element {
</Text> </Text>
</>; </>;
} }
function TimezoneOverwritesSetting(props: IPluginOptionComponentProps): JSX.Element {
return <></>;
}

View file

@ -0,0 +1,43 @@
/** Based on Syncxv's vc-timezones user plugin **/
.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 {
justify-content: space-between;
align-content: center;
}
.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
}

View file

@ -4,19 +4,33 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { findStoreLazy } from "@webpack";
import { Logger } from "@utils/Logger";
import { makeLazy } from "@utils/lazy"; import { makeLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger";
import { findStoreLazy } from "@webpack";
const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore"); const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore");
const TIMEZONE_LIST = "https://gist.githubusercontent.com/ArjixWasTaken/e321f856f98676505efb90aad82feff1/raw/91034ee32eff93a7cb62d10702f6b1d01e0309e6/timezones.json"; const TIMEZONE_LIST = "https://gist.githubusercontent.com/ArjixWasTaken/e321f856f98676505efb90aad82feff1/raw/91034ee32eff93a7cb62d10702f6b1d01e0309e6/timezones.json";
export function formatTimestamp(timezone: string, timestamp: Date = new Date()): string | undefined { export function formatTimestamp(
timezone: string,
timestamp: number | Date | undefined,
long: boolean,
): string | undefined {
try { try {
const locale = UserSettingsProtoStore.settings.localization.locale.value; const locale = UserSettingsProtoStore.settings.localization.locale.value;
const formatter = new Intl.DateTimeFormat(locale, { const options: Intl.DateTimeFormatOptions = !long
? { hour: "numeric", minute: "numeric" }
: {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
};
const formatter = new Intl.DateTimeFormat(locale, {
...options,
timeZone: timezone, timeZone: timezone,
}); });