diff --git a/src/equicordplugins/streaks/constants.ts b/src/equicordplugins/streaks/constants.ts new file mode 100644 index 000000000..157fd7d8f --- /dev/null +++ b/src/equicordplugins/streaks/constants.ts @@ -0,0 +1,10 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export const BASE_URL = "https://streaks.equicord.org"; +export const API_URL = `${BASE_URL}/api`; +export const AUTHORIZE_URL = `${API_URL}/authorize`; +export const CLIENT_ID = "1243063117852835941"; diff --git a/src/equicordplugins/streaks/index.tsx b/src/equicordplugins/streaks/index.tsx index df6c3647f..4576d5f6a 100644 --- a/src/equicordplugins/streaks/index.tsx +++ b/src/equicordplugins/streaks/index.tsx @@ -1,98 +1,62 @@ /* * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors + * Copyright (c) 2026 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import "./style.css"; -import { DataStore } from "@api/index"; import { DecoratorProps } from "@api/MemberListDecorators"; import { iconsModule } from "@equicordplugins/_core/concatenatedModules"; -import { EquicordDevs } from "@utils/constants"; +import { Devs, EquicordDevs } from "@utils/constants"; import { classNameFactory } from "@utils/css"; import definePlugin from "@utils/types"; import { Message } from "@vencord/discord-types"; import { ChannelStore, moment, Tooltip, UserStore } from "@webpack/common"; -const cl = classNameFactory("vc-streaks-"); -const dataKey = "vc-streaks-data"; +import { settings } from "./settings"; +import { useAuthorizationStore } from "./stores/AuthorizationStore"; +import { useStreaksStore } from "./stores/StreaksStore"; -const Flags = { - SENT: 1, - RECEIVED: 2, - BOTH: 3 -}; +const cl = classNameFactory("vc-streaks-"); const STREAK_THRESHOLDS = { ELITE: 100, DIAMOND: 60, + PLATINUM: 45, GOLD: 30, - SILVER: 14 -}; - -interface UserStreak { - count: number; - lastDay: string; - todayFlags: number; - todayDate: string; -} - -let streaks: Record = {}; - -const todayKey = () => moment().format("YYYY-MM-DD"); -const yesterdayKey = () => moment().subtract(1, "day").format("YYYY-MM-DD"); - -const updateStreak = (userId: string, outgoing: boolean) => { - const today = todayKey(); - const entry = streaks[userId] ??= { count: 0, lastDay: "", todayFlags: 0, todayDate: "" }; - - if (entry.todayDate !== today) { - entry.todayDate = today; - entry.todayFlags = 0; - } - - const next = entry.todayFlags | (outgoing ? Flags.SENT : Flags.RECEIVED); - if (next === entry.todayFlags) return; - entry.todayFlags = next; - - if (next === Flags.BOTH && entry.lastDay !== today) { - entry.count = entry.lastDay === yesterdayKey() ? entry.count + 1 : 1; - entry.lastDay = today; - } - - DataStore.set(dataKey, streaks); -}; - -const getStreakInfo = (userId: string) => { - const entry = streaks[userId]; - if (!entry) return null; - const today = todayKey(); - if (entry.lastDay !== today && entry.lastDay !== yesterdayKey()) return null; - return { count: entry.count, active: entry.lastDay === today }; + SILVER: 14, + BRONZE: 7 }; const colorFor = (streak: number) => { - if (streak >= STREAK_THRESHOLDS.ELITE) return "#9b39fe"; - if (streak >= STREAK_THRESHOLDS.DIAMOND) return "#f7409c"; - if (streak >= STREAK_THRESHOLDS.GOLD) return "#f75340"; - if (streak >= STREAK_THRESHOLDS.SILVER) return "#f57b0b"; - return "#f59e0b"; + if (streak >= STREAK_THRESHOLDS.ELITE) return settings.store.eliteColor; + if (streak >= STREAK_THRESHOLDS.DIAMOND) return settings.store.diamondColor; + if (streak >= STREAK_THRESHOLDS.PLATINUM) return settings.store.platinumColor; + if (streak >= STREAK_THRESHOLDS.GOLD) return settings.store.goldColor; + if (streak >= STREAK_THRESHOLDS.SILVER) return settings.store.silverColor; + if (streak >= STREAK_THRESHOLDS.BRONZE) return settings.store.bronzeColor; + return settings.store.defaultColor; }; const StreakBadge = ({ userId }: { userId: string; }) => { - const info = getStreakInfo(userId); - if (!info || info.count < 1) return null; + const streaks = useStreaksStore(state => state.streaks); + const streak = streaks[userId]; + + if (!streak || streak.count < 1) return null; + + const today = moment().format("YYYY-MM-DD"); + const active = streak.last_streak_date === today; const FireIcon = iconsModule?.FireIcon; - const color = info.active ? colorFor(info.count) : "#9ca3af"; + const color = active ? colorFor(streak.count) : "#9ca3af"; return ( - + {tooltipProps => ( {FireIcon && } - {info.count} + {streak.count} )} @@ -102,19 +66,20 @@ const StreakBadge = ({ userId }: { userId: string; }) => { export default definePlugin({ name: "Streaks", description: "Shows a streak next to a user when you exchange DMs with them on consecutive days.", - authors: [EquicordDevs.Moowi], + authors: [EquicordDevs.Moowi, Devs.thororen], tags: ["Friends", "Fun"], dependencies: ["MessageDecorationsAPI", "MemberListDecoratorsAPI", "ConcatenatedModules"], - - async start() { - streaks = await DataStore.get(dataKey) ?? {}; - }, - - stop() { - streaks = {}; - }, + settings, flux: { + async CONNECTION_OPEN() { + useAuthorizationStore.getState().init(); + useStreaksStore.getState().clear(); + if (useAuthorizationStore.getState().isAuthorized()) { + await useStreaksStore.getState().migrate(); + await useStreaksStore.getState().fetch(); + } + }, async MESSAGE_CREATE({ optimistic, type, message, channelId }: { optimistic: boolean; type: string; message: Message; channelId: string; }) { if (optimistic || type !== "MESSAGE_CREATE" || message.state === "SENDING") return; if (message.author?.bot) return; @@ -126,8 +91,9 @@ export default definePlugin({ if (!recipientId) return; const me = UserStore.getCurrentUser()?.id; - if (message.author.id === me) updateStreak(recipientId, true); - else if (message.author.id === recipientId) updateStreak(recipientId, false); + if ((message.author.id === me || message.author.id === recipientId) && useAuthorizationStore.getState().isAuthorized()) { + useStreaksStore.getState().update(recipientId); + } }, }, @@ -137,8 +103,8 @@ export default definePlugin({ return ; }, - renderMemberListDecorator({ user }: DecoratorProps) { - if (!user || user.id === UserStore.getCurrentUser()?.id) return null; + renderMemberListDecorator({ user, type }: DecoratorProps) { + if (type !== "dm" || !user || user.id === UserStore.getCurrentUser()?.id) return null; return ; }, }); diff --git a/src/equicordplugins/streaks/settings.tsx b/src/equicordplugins/streaks/settings.tsx new file mode 100644 index 000000000..96983f6ad --- /dev/null +++ b/src/equicordplugins/streaks/settings.tsx @@ -0,0 +1,84 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Button } from "@components/Button"; +import { Flex } from "@components/Flex"; +import { OptionType } from "@utils/types"; +import { UserStore } from "@webpack/common"; + +import { useAuthorizationStore } from "./stores/AuthorizationStore"; +import { useStreaksStore } from "./stores/StreaksStore"; + +export const settings = definePluginSettings({ + account: { + type: OptionType.COMPONENT, + description: "Log in or out of the Streaks API.", + component() { + const { isAuthorized, authorize, remove } = useAuthorizationStore(); + + if (isAuthorized()) { + return ( + + + + ); + } else { + return ( + + + + ); + } + } + }, + eliteColor: { + type: OptionType.STRING, + description: "Elite Streak Color (100+ days)", + default: "#9b39fe" + }, + diamondColor: { + type: OptionType.STRING, + description: "Diamond Streak Color (60+ days)", + default: "#f7409c" + }, + platinumColor: { + type: OptionType.STRING, + description: "Platinum Streak Color (45+ days)", + default: "#856bfe" + }, + goldColor: { + type: OptionType.STRING, + description: "Gold Streak Color (30+ days)", + default: "#f75340" + }, + silverColor: { + type: OptionType.STRING, + description: "Silver Streak Color (14+ days)", + default: "#f57b0b" + }, + bronzeColor: { + type: OptionType.STRING, + description: "Bronze Streak Color (7+ days)", + default: "#b08d57" + }, + defaultColor: { + type: OptionType.STRING, + description: "Default Streak Color (1+ days)", + default: "#f59e0b" + } +}); diff --git a/src/equicordplugins/streaks/stores/AuthorizationStore.tsx b/src/equicordplugins/streaks/stores/AuthorizationStore.tsx new file mode 100644 index 000000000..51c3d9647 --- /dev/null +++ b/src/equicordplugins/streaks/stores/AuthorizationStore.tsx @@ -0,0 +1,119 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import * as DataStore from "@api/DataStore"; +import { proxyLazy } from "@utils/lazy"; +import { Logger } from "@utils/Logger"; +import { openModal } from "@utils/modal"; +import { OAuth2AuthorizeModal, showToast, Toasts, UserStore, zustandCreate, zustandPersist } from "@webpack/common"; + +import { AUTHORIZE_URL, CLIENT_ID } from "../constants"; + +interface AuthorizationState { + token: string | null; + tokens: Record; + init: () => void; + authorize: () => Promise; + setToken: (token: string) => void; + remove: (id: string) => void; + isAuthorized: () => boolean; +} + +const indexedDBStorage = { + async getItem(name: string): Promise { + return DataStore.get(name).then(v => v ?? null); + }, + async setItem(name: string, value: string): Promise { + await DataStore.set(name, value); + }, + async removeItem(name: string): Promise { + await DataStore.del(name); + }, +}; + +export const useAuthorizationStore = proxyLazy(() => zustandCreate( + zustandPersist( + (set: any, get: any) => ({ + token: null, + tokens: {}, + init: () => { set({ token: get().tokens[UserStore.getCurrentUser()?.id] ?? null }); }, + setToken: (token: string) => { + const id = UserStore.getCurrentUser()?.id; + if (!id) return; + set({ token, tokens: { ...get().tokens, [id]: token } }); + }, + remove: (id: string) => { + const { tokens, init } = get(); + const newTokens = { ...tokens }; + delete newTokens[id]; + set({ tokens: newTokens }); + + init(); + }, + async authorize() { + return new Promise((resolve, reject) => { + let hasCallbackStarted = false; + openModal(props => + { + hasCallbackStarted = true; + try { + const url = new URL(response.location); + const code = url.searchParams.get("code"); + if (!code) throw new Error("No code in redirect"); + + const req = await fetch(`${AUTHORIZE_URL}?code=${code}`); + + if (req?.ok) { + const token = await req.text(); + get().setToken(token); + } else { + throw new Error("Request not OK"); + } + resolve(void 0); + } catch (e) { + if (e instanceof Error) { + showToast(`Failed to authorize: ${e.message}`, Toasts.Type.FAILURE); + new Logger("Streaks").error("Failed to authorize", e); + reject(e); + } + } + }} + />, { + onCloseCallback() { + if (!hasCallbackStarted) { + reject(new Error("Authorization cancelled")); + } + }, + }); + }); + }, + isAuthorized: () => !!get().token, + } as AuthorizationState), + { + name: "vc-streaks-auth", + storage: indexedDBStorage, + partialize: state => ({ tokens: state.tokens }), + onRehydrateStorage: () => async state => { + if (!state) return; + state.init(); + if (state.isAuthorized()) { + const { useStreaksStore } = require("./StreaksStore"); + useStreaksStore.getState().clear(); + await useStreaksStore.getState().migrate(); + await useStreaksStore.getState().fetch(); + } + } + } + ) +)); diff --git a/src/equicordplugins/streaks/stores/StreaksStore.ts b/src/equicordplugins/streaks/stores/StreaksStore.ts new file mode 100644 index 000000000..ea9916976 --- /dev/null +++ b/src/equicordplugins/streaks/stores/StreaksStore.ts @@ -0,0 +1,100 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import * as DataStore from "@api/DataStore"; +import { proxyLazy } from "@utils/lazy"; +import { UserStore, zustandCreate } from "@webpack/common"; + +import { API_URL } from "../constants"; +import { useAuthorizationStore } from "./AuthorizationStore"; + +export interface RemoteStreak { + id: string; + user_a_id: string; + user_b_id: string; + count: number; + last_streak_date: string | null; + user_a_today: boolean; + user_b_today: boolean; + today_date: string; +} + +export interface StreaksState { + streaks: Record; + fetch: () => Promise; + update: (recipientId: string) => Promise; + migrate: () => Promise; + clear: () => void; +} + +export const useStreaksStore = proxyLazy(() => zustandCreate((set: any, get: any) => ({ + streaks: {}, + clear: () => set({ streaks: {} }), + async fetch() { + const { token } = useAuthorizationStore.getState(); + if (!token) return; + + try { + const res = await fetch(`${API_URL}/streaks`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + const data: RemoteStreak[] = await res.json(); + const myId = UserStore.getCurrentUser()?.id; + const streaksMap: Record = {}; + for (const s of data) { + const otherId = s.user_a_id === myId ? s.user_b_id : s.user_a_id; + streaksMap[otherId] = s; + } + set({ streaks: streaksMap }); + } + } catch (e) { + console.error("Failed to fetch streaks", e); + } + }, + async update(recipientId: string) { + const { token } = useAuthorizationStore.getState(); + if (!token) return; + + try { + const res = await fetch(`${API_URL}/streaks/${recipientId}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + const streak: RemoteStreak = await res.json(); + set({ streaks: { ...get().streaks, [recipientId]: streak } }); + } + } catch (e) { + console.error("Failed to update streak", e); + } + }, + async migrate() { + const { token } = useAuthorizationStore.getState(); + if (!token) return; + + const legacyData = await DataStore.get("vc-streaks-data"); + if (!legacyData || Object.keys(legacyData).length === 0) return; + + try { + const res = await fetch(`${API_URL}/streaks/migrate`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(legacyData) + }); + + if (res.ok) { + await DataStore.del("vc-streaks-data"); + console.log("Successfully migrated local streaks to API"); + } + } catch (e) { + console.error("Failed to migrate streaks", e); + } + } +} as StreaksState)));