streaks: use api

This commit is contained in:
thororen1234 2026-05-10 19:21:23 -04:00
parent fc5ab6b04b
commit d3a22be94a
No known key found for this signature in database
5 changed files with 354 additions and 75 deletions

View file

@ -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";

View file

@ -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<string, UserStreak> = {};
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 (
<Tooltip text={`${info.count} day streak`}>
<Tooltip text={`${streak.count} day streak`}>
{tooltipProps => (
<span {...tooltipProps} className={cl("badge")} style={{ color }}>
{FireIcon && <FireIcon size="xs" color={color} />}
<span className={cl("count")}>{info.count}</span>
<span className={cl("count")}>{streak.count}</span>
</span>
)}
</Tooltip>
@ -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 <StreakBadge userId={userId} />;
},
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 <StreakBadge userId={user.id} />;
},
});

View file

@ -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 (
<Flex>
<Button
onClick={() => remove(UserStore.getCurrentUser()?.id)}
variant="dangerPrimary"
>
Log Out of Streaks API
</Button>
</Flex>
);
} else {
return (
<Flex>
<Button onClick={async () => {
await authorize();
await useStreaksStore.getState().migrate();
await useStreaksStore.getState().fetch();
}}>
Log In to Streaks API
</Button>
</Flex>
);
}
}
},
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"
}
});

View file

@ -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<string, string>;
init: () => void;
authorize: () => Promise<void>;
setToken: (token: string) => void;
remove: (id: string) => void;
isAuthorized: () => boolean;
}
const indexedDBStorage = {
async getItem(name: string): Promise<string | null> {
return DataStore.get(name).then(v => v ?? null);
},
async setItem(name: string, value: string): Promise<void> {
await DataStore.set(name, value);
},
async removeItem(name: string): Promise<void> {
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 =>
<OAuth2AuthorizeModal
{...props}
scopes={["identify"]}
responseType="code"
redirectUri={AUTHORIZE_URL}
permissions={0n}
clientId={CLIENT_ID}
cancelCompletesFlow={false}
callback={async (response: any) => {
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();
}
}
}
)
));

View file

@ -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<string, RemoteStreak>;
fetch: () => Promise<void>;
update: (recipientId: string) => Promise<void>;
migrate: () => Promise<void>;
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<string, RemoteStreak> = {};
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)));