streaks: use api
This commit is contained in:
parent
fc5ab6b04b
commit
d3a22be94a
5 changed files with 354 additions and 75 deletions
10
src/equicordplugins/streaks/constants.ts
Normal file
10
src/equicordplugins/streaks/constants.ts
Normal 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";
|
||||
|
|
@ -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} />;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
84
src/equicordplugins/streaks/settings.tsx
Normal file
84
src/equicordplugins/streaks/settings.tsx
Normal 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"
|
||||
}
|
||||
});
|
||||
119
src/equicordplugins/streaks/stores/AuthorizationStore.tsx
Normal file
119
src/equicordplugins/streaks/stores/AuthorizationStore.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
));
|
||||
100
src/equicordplugins/streaks/stores/StreaksStore.ts
Normal file
100
src/equicordplugins/streaks/stores/StreaksStore.ts
Normal 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)));
|
||||
Loading…
Add table
Reference in a new issue