Merge branch 'dev'

This commit is contained in:
thororen1234 2025-09-23 10:28:08 -04:00
commit 5dd9a96439
No known key found for this signature in database
11 changed files with 1934 additions and 407 deletions

View file

@ -0,0 +1,279 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { PluginCard } from "@components/settings/tabs/plugins/PluginCard";
import { ChangeList } from "@utils/ChangeList";
import { Margins } from "@utils/margins";
import { useForceUpdater } from "@utils/react";
import { Button, Card, Forms, React, Tooltip } from "@webpack/common";
import Plugins from "~plugins";
const cl = classNameFactory("vc-changelog-");
interface NewPluginsSectionProps {
newPlugins: string[];
onPluginToggle?: (pluginName: string, enabled: boolean) => void;
}
export function NewPluginsSection({
newPlugins,
onPluginToggle,
}: NewPluginsSectionProps) {
const settings = useSettings();
const changes = React.useMemo(() => new ChangeList<string>(), []);
const forceUpdate = useForceUpdater();
const depMap = React.useMemo(() => {
const o = {} as Record<string, string[]>;
for (const plugin in Plugins) {
const deps = Plugins[plugin].dependencies;
if (deps) {
for (const dep of deps) {
o[dep] ??= [];
o[dep].push(plugin);
}
}
}
return o;
}, []);
const mapPlugins = (array: string[]) =>
array
.map(pn => Plugins[pn])
.filter(p => p && !p.hidden)
.sort((a, b) => a.name.localeCompare(b.name));
const sortedPlugins = React.useMemo(
() => mapPlugins(newPlugins),
[newPlugins],
);
if (sortedPlugins.length === 0) {
return null;
}
const makeDependencyList = (deps: string[]) => {
if (!deps) return null;
return (
<React.Fragment>
<Forms.FormText>This plugin is required by:</Forms.FormText>
{deps.map((dep: string) => (
<Forms.FormText key={dep} className="vc-changelog-dep-text">
{dep}
</Forms.FormText>
))}
</React.Fragment>
);
};
return (
<div className={cl("new-plugins-section")}>
<Forms.FormTitle tag="h5" className={Margins.bottom8}>
New Plugins ({sortedPlugins.length})
</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>
The following plugins have been added in recent updates:
</Forms.FormText>
<div className={cl("new-plugins-grid")}>
{sortedPlugins.map(plugin => {
const isRequired =
plugin.required ||
depMap[plugin.name]?.some(
d => settings.plugins[d].enabled,
) ||
plugin.name.endsWith("API");
const tooltipText = plugin.required
? "This plugin is required for Equicord to function."
: makeDependencyList(
depMap[plugin.name]?.filter(
d => settings.plugins[d].enabled,
),
);
if (isRequired) {
return (
<Tooltip text={tooltipText} key={plugin.name}>
{({ onMouseLeave, onMouseEnter }) => (
<Card
className={cl(
"new-plugin-card",
"required",
)}
>
<PluginCard
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onRestartNeeded={name => {
changes.handleChange(name);
forceUpdate();
}}
disabled={true}
plugin={plugin}
isNew={true}
/>
</Card>
)}
</Tooltip>
);
}
return (
<Card
key={plugin.name}
className={cl("new-plugin-card")}
>
<PluginCard
onRestartNeeded={name => {
changes.handleChange(name);
forceUpdate();
}}
disabled={false}
plugin={plugin}
isNew={true}
/>
</Card>
);
})}
</div>
{changes.hasChanges && (
<div className={cl("restart-notice")}>
<Tooltip
text={
<>
The following plugins require a restart:
<div className={Margins.bottom8} />
<ul>
{changes.map(p => (
<li key={p}>{p}</li>
))}
</ul>
</>
}
>
{tooltipProps => (
<Button
{...tooltipProps}
color={Button.Colors.YELLOW}
size={Button.Sizes.SMALL}
onClick={() => location.reload()}
className={Margins.top16}
>
Restart Required
</Button>
)}
</Tooltip>
</div>
)}
</div>
);
}
interface NewPluginsCompactProps {
newPlugins: string[];
maxDisplay?: number;
}
function CompactPluginCard({
pluginName,
depMap,
settings,
}: {
pluginName: string;
depMap: Record<string, string[]>;
settings: any;
}) {
const plugin = Plugins[pluginName];
if (!plugin || plugin.hidden) return null;
const isRequired =
plugin.required ||
depMap[plugin.name]?.some(d => settings.plugins[d].enabled);
const tooltipText = plugin.required
? "This plugin is required for Equicord to function."
: depMap[plugin.name]?.length > 0
? `This plugin is required by: ${depMap[plugin.name]
?.filter(d => settings.plugins[d].enabled)
.join(", ")}`
: null;
return (
<div className={`vc-changelog-entry ${isRequired ? "required" : ""}`}>
<div className="vc-changelog-entry-header">
<span className="vc-changelog-entry-hash">
{plugin.name}
{isRequired && " *"}
</span>
<span className="vc-changelog-entry-author">
{plugin.authors?.[0]?.name || "Unknown"}
</span>
</div>
<div className="vc-changelog-entry-message">
{plugin.description || "No description available"}
</div>
{tooltipText && (
<div className="vc-changelog-dep-text">{tooltipText}</div>
)}
</div>
);
}
export function NewPluginsCompact({
newPlugins,
maxDisplay = 20,
}: NewPluginsCompactProps) {
const settings = useSettings();
const depMap = React.useMemo(() => {
const o = {} as Record<string, string[]>;
for (const plugin in Plugins) {
const deps = Plugins[plugin].dependencies;
if (deps) {
for (const dep of deps) {
o[dep] ??= [];
o[dep].push(plugin);
}
}
}
return o;
}, []);
if (newPlugins.length === 0) {
return null;
}
const displayPlugins = newPlugins.slice(0, maxDisplay);
const hasMore = newPlugins.length > maxDisplay;
return (
<div className={cl("new-plugins-compact")}>
<div className="vc-changelog-plugins-list">
{displayPlugins.map(pluginName => (
<CompactPluginCard
key={pluginName}
pluginName={pluginName}
depMap={depMap}
settings={settings}
/>
))}
{hasMore && (
<div className="vc-changelog-entry">
<div className="vc-changelog-entry-message">
+{newPlugins.length - maxDisplay} more plugins
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,282 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import gitHash from "~git-hash";
import plugins from "~plugins";
export interface ChangelogEntry {
hash: string;
author: string;
message: string;
timestamp?: number;
}
export interface UpdateSession {
id: string;
timestamp: number;
fromHash: string;
toHash: string;
commits: ChangelogEntry[];
newPlugins: string[];
updatedPlugins: string[];
newSettings?: Map<string, string[]>;
type: "update" | "repository_fetch";
}
export type ChangelogHistory = UpdateSession[];
const CHANGELOG_HISTORY_KEY = "EquicordChangelog_History";
const LAST_SEEN_HASH_KEY = "EquicordChangelog_LastSeenHash";
const KNOWN_PLUGINS_KEY = "EquicordChangelog_KnownPlugins";
const KNOWN_SETTINGS_KEY = "EquicordChangelog_KnownSettings";
const LAST_REPO_CHECK_KEY = "EquicordChangelog_LastRepoCheck";
type KnownPluginSettingsMap = Map<string, Set<string>>;
export async function getChangelogHistory(): Promise<ChangelogHistory> {
const history = (await DataStore.get(
CHANGELOG_HISTORY_KEY,
)) as ChangelogHistory;
return history || [];
}
export async function saveUpdateSession(
commits: ChangelogEntry[],
newPlugins: string[],
updatedPlugins: string[],
newSettings: Map<string, string[]>,
forceLog: boolean = false,
): Promise<void> {
const history = await getChangelogHistory();
const lastSeenHash = await getLastSeenHash();
const currentHash = gitHash;
// For repository fetches, check if we already have this exact state logged (to prevent duplicate logs)
if (forceLog) {
const lastRepoCheck = await getLastRepositoryCheckHash();
const latestRepoHash =
commits.length > 0 ? commits[0].hash : currentHash;
if (lastRepoCheck === latestRepoHash) {
// if the state hasn't changed last check, do NOT make a new log
return;
}
}
// Don't save if no changes, unless explicitly forcing the log (for example repository fetch)
if (
!forceLog &&
commits.length === 0 &&
newPlugins.length === 0 &&
updatedPlugins.length === 0 &&
newSettings.size === 0
) {
return;
}
// Determine session type and hash logic
const sessionType = forceLog ? "repository_fetch" : "update";
let fromHash = currentHash;
let toHash = currentHash;
if (forceLog) {
// This is a repository fetch - show current vs repository state
if (commits.length > 0) {
// Repository has newer commits
toHash = commits[0].hash; // Latest repository hash
}
// If no commits, fromHash === toHash (up to date)
} else {
// This is an actual update session
// Is there a better way to do this?
fromHash = lastSeenHash || "unknown";
toHash = currentHash;
}
const session: UpdateSession = {
id: crypto.randomUUID(),
timestamp: Date.now(),
fromHash: fromHash,
toHash: toHash,
commits,
newPlugins,
updatedPlugins,
newSettings,
type: sessionType,
};
// Add to beginning of history (most recent first)
history.unshift(session);
// Keep only last 50 sessions to prevent storage bloat
if (history.length > 50) {
history.splice(50);
}
await DataStore.set(CHANGELOG_HISTORY_KEY, history);
if (!forceLog) {
await setLastSeenHash(currentHash);
} else {
// for fetches, check the latest repo hash to make sure its not the same
const latestRepoHash =
commits.length > 0 ? commits[0].hash : currentHash;
await setLastRepositoryCheckHash(latestRepoHash);
}
await updateKnownPlugins();
await updateKnownSettings();
}
export async function getLastSeenHash(): Promise<string | null> {
return (await DataStore.get(LAST_SEEN_HASH_KEY)) as string | null;
}
export async function setLastSeenHash(hash: string): Promise<void> {
await DataStore.set(LAST_SEEN_HASH_KEY, hash);
}
export async function getKnownPlugins(): Promise<Set<string>> {
const known = (await DataStore.get(KNOWN_PLUGINS_KEY)) as string[];
return new Set(known || []);
}
export async function updateKnownPlugins(): Promise<void> {
const currentPlugins = Object.keys(plugins);
await DataStore.set(KNOWN_PLUGINS_KEY, currentPlugins);
}
function getSettingsSetForPlugin(plugin: string): Set<string> {
const settings = plugins[plugin]?.settings?.def || {};
return new Set(
Object.keys(settings).filter(setting => setting !== "enabled"),
);
}
function getCurrentSettings(pluginList: string[]): KnownPluginSettingsMap {
return new Map(
pluginList.map(name => [name, getSettingsSetForPlugin(name)]),
);
}
export async function getKnownSettings(): Promise<KnownPluginSettingsMap> {
let map = (await DataStore.get(
KNOWN_SETTINGS_KEY,
)) as KnownPluginSettingsMap;
if (map === undefined) {
const knownPlugins = await getKnownPlugins();
const Plugins = [...Object.keys(plugins), ...Array.from(knownPlugins)];
map = getCurrentSettings(Plugins);
await DataStore.set(KNOWN_SETTINGS_KEY, map);
}
return map;
}
export async function getNewSettings(): Promise<Map<string, string[]>> {
const map = getCurrentSettings(Object.keys(plugins));
const knownSettings = await getKnownSettings();
const newSettings = new Map<string, string[]>();
map.forEach((settings, plugin) => {
const filteredSettings = [...settings].filter(
setting => !knownSettings.get(plugin)?.has(setting),
);
if (filteredSettings.length > 0) {
newSettings.set(plugin, filteredSettings);
}
});
return newSettings;
}
export async function updateKnownSettings(): Promise<void> {
const currentSettings = getCurrentSettings(Object.keys(plugins));
const knownSettings = await getKnownSettings();
const allSettings = new Map();
new Set([...currentSettings.keys(), ...knownSettings.keys()]).forEach(
plugin => {
allSettings.set(
plugin,
new Set([
...(currentSettings.get(plugin) || []),
...(knownSettings.get(plugin) || []),
]),
);
},
);
await DataStore.set(KNOWN_SETTINGS_KEY, allSettings);
}
export async function getNewPlugins(): Promise<string[]> {
const currentPlugins = Object.keys(plugins);
const knownPlugins = await getKnownPlugins();
return currentPlugins.filter(
plugin =>
!knownPlugins.has(plugin) &&
!plugins[plugin].hidden &&
!plugins[plugin].required,
);
}
export async function getUpdatedPlugins(): Promise<string[]> {
// This is a placeholder - in a real implementation, you'd track plugin version changes
// For now, we'll return empty array since plugin version tracking would need to be implemented
return [];
}
export async function clearChangelogHistory(): Promise<void> {
await DataStore.del(CHANGELOG_HISTORY_KEY);
await DataStore.del(LAST_SEEN_HASH_KEY);
await DataStore.del(KNOWN_SETTINGS_KEY);
}
export async function clearIndividualLog(logId: string): Promise<void> {
const history = await getChangelogHistory();
const filteredHistory = history.filter(log => log.id !== logId);
await DataStore.set(CHANGELOG_HISTORY_KEY, filteredHistory);
}
export async function initializeChangelog(): Promise<void> {
// Initialize with current state if first time
const lastSeenHash = await getLastSeenHash();
if (!lastSeenHash) {
await setLastSeenHash(gitHash);
await updateKnownPlugins();
await updateKnownSettings();
}
}
export async function getLastRepositoryCheckHash(): Promise<string | null> {
return (await DataStore.get(LAST_REPO_CHECK_KEY)) as string | null;
}
export async function setLastRepositoryCheckHash(hash: string): Promise<void> {
await DataStore.set(LAST_REPO_CHECK_KEY, hash);
}
export function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 60) {
return `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
} else if (diffDays < 7) {
return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
} else {
return date.toLocaleDateString();
}
}

View file

@ -0,0 +1,693 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { ErrorCard } from "@components/ErrorCard";
import { DeleteIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
import { Margins } from "@utils/margins";
import { useAwaiter } from "@utils/react";
import { getRepo, shortGitHash, UpdateLogger } from "@utils/updater";
import { Alerts, Button, Card, Forms, React, Toasts } from "@webpack/common";
import gitHash from "~git-hash";
import {
ChangelogEntry,
ChangelogHistory,
clearChangelogHistory,
clearIndividualLog,
formatTimestamp,
getChangelogHistory,
getLastRepositoryCheckHash,
getNewPlugins,
getNewSettings,
getUpdatedPlugins,
initializeChangelog,
saveUpdateSession,
UpdateSession,
} from "./changelogManager";
import { NewPluginsCompact, NewPluginsSection } from "./NewPluginsSection";
function HashLink({
repo,
hash,
disabled = false,
}: {
repo: string;
hash: string;
disabled?: boolean;
}) {
return (
<Link href={`${repo}/commit/${hash}`} disabled={disabled}>
{hash}
</Link>
);
}
function ChangelogCard({
entry,
repo,
repoPending,
}: {
entry: ChangelogEntry;
repo: string;
repoPending: boolean;
}) {
return (
<Card className="vc-changelog-entry">
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.25em",
}}
>
<div className="vc-changelog-entry-header">
<code className="vc-changelog-entry-hash">
<HashLink
repo={repo}
hash={entry.hash.slice(0, 7)}
disabled={repoPending}
/>
</code>
<span className="vc-changelog-entry-author">
by {entry.author}
</span>
</div>
<div className="vc-changelog-entry-message">
{entry.message}
</div>
</div>
</Card>
);
}
function UpdateLogCard({
log,
repo,
repoPending,
isExpanded,
onToggleExpand,
onClearLog,
}: {
log: UpdateSession;
repo: string;
repoPending: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
onClearLog: (logId: string) => void;
}) {
const isRepositoryFetch =
log.type === "repository_fetch" ||
(log.type === undefined &&
log.fromHash === log.toHash &&
log.commits.length === 0);
const isUpToDate = log.fromHash === log.toHash;
return (
<Card className="vc-changelog-log">
<div className="vc-changelog-log-header" onClick={onToggleExpand}>
<div className="vc-changelog-log-info">
<div className="vc-changelog-log-title">
<span>
{isRepositoryFetch
? isUpToDate
? `Repository check: ${log.fromHash.slice(0, 7)} (up to date)`
: `Repository check: ${log.fromHash.slice(0, 7)} → ${log.toHash.slice(0, 7)}`
: `Update: ${log.fromHash.slice(0, 7)} → ${log.toHash.slice(0, 7)}`}
</span>
<Button
size={Button.Sizes.NONE}
color={Button.Colors.TRANSPARENT}
look={Button.Looks.BLANK}
className="vc-changelog-delete-button"
style={{
padding: "4px",
color: "var(--status-danger)",
opacity: 0.6,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={e => {
e.stopPropagation();
onClearLog(log.id);
}}
>
<DeleteIcon width={16} height={16} />
</Button>
</div>
<div className="vc-changelog-log-meta">
{formatTimestamp(log.timestamp)}
{log.commits.length > 0 &&
` • ${log.commits.length} commits available`}
{log.commits.length === 0 && " • No new commits"}
{log.newPlugins.length > 0 &&
` • ${log.newPlugins.length} new plugins`}
{log.updatedPlugins.length > 0 &&
` • ${log.updatedPlugins.length} updated plugins`}
{log.newSettings &&
log.newSettings.size > 0 &&
` • ${Array.from(log.newSettings.values()).reduce((sum, arr) => sum + arr.length, 0)} new settings`}
</div>
</div>
<div
className={`vc-changelog-log-toggle ${isExpanded ? "expanded" : ""}`}
>
▼
</div>
</div>
{isExpanded && (
<div className="vc-changelog-log-content">
{log.newPlugins.length > 0 && (
<div className="vc-changelog-log-plugins">
<NewPluginsCompact
newPlugins={log.newPlugins}
maxDisplay={50}
/>
</div>
)}
{log.updatedPlugins.length > 0 && (
<div className="vc-changelog-log-plugins">
<Forms.FormTitle
tag="h6"
className={Margins.bottom8}
>
Updated Plugins
</Forms.FormTitle>
<NewPluginsCompact
newPlugins={log.updatedPlugins}
maxDisplay={50}
/>
</div>
)}
{log.newSettings && log.newSettings.size > 0 && (
<div className="vc-changelog-log-plugins">
<Forms.FormTitle
tag="h6"
className={Margins.bottom8}
>
New Settings
</Forms.FormTitle>
<div className="vc-changelog-new-plugins-list">
{Array.from(
log.newSettings?.entries() || [],
).map(([pluginName, settings]) =>
settings.map(setting => (
<span
key={`${pluginName}-${setting}`}
className="vc-changelog-new-plugin-tag"
title={`New setting in ${pluginName}`}
>
{pluginName}.{setting}
</span>
)),
)}
</div>
</div>
)}
{log.commits.length > 0 && (
<div className="vc-changelog-log-commits">
<div className="vc-changelog-log-commits-list">
{log.commits.map(entry => (
<ChangelogCard
key={entry.hash}
entry={entry}
repo={repo}
repoPending={repoPending}
/>
))}
</div>
</div>
)}
</div>
)}
</Card>
);
}
function ChangelogContent() {
const [repo, repoErr, repoPending] = useAwaiter(getRepo, {
fallbackValue: "Loading...",
});
const [changelog, setChangelog] = React.useState<ChangelogEntry[]>([]);
const [changelogHistory, setChangelogHistory] =
React.useState<ChangelogHistory>([]);
const [newPlugins, setNewPlugins] = React.useState<string[]>([]);
const [updatedPlugins, setUpdatedPlugins] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [expandedLogs, setExpandedLogs] = React.useState<Set<string>>(
new Set(),
);
const [showHistory, setShowHistory] = React.useState(false);
const [recentlyChecked, setRecentlyChecked] = React.useState(false);
React.useEffect(() => {
const init = async () => {
try {
await initializeChangelog();
await loadChangelogHistory();
} catch (err) {
console.error("Failed to initialize changelog:", err);
}
};
init();
}, []);
React.useEffect(() => {
if (repoErr) {
UpdateLogger.error("Failed to retrieve repo", repoErr);
setError("Failed to retrieve repository information");
}
}, [repoErr]);
const loadChangelogHistory = React.useCallback(async () => {
try {
const history = await getChangelogHistory();
setChangelogHistory(history);
} catch (err) {
console.error("Failed to load changelog history:", err);
}
}, []);
const loadNewPlugins = React.useCallback(async () => {
try {
const newPlgs = await getNewPlugins();
const updatedPlgs = await getUpdatedPlugins();
setNewPlugins(newPlgs);
setUpdatedPlugins(updatedPlgs);
} catch (err) {
console.error("Failed to load new plugins:", err);
}
}, []);
// check if the repository was recently refreshed
React.useEffect(() => {
const checkRecentStatus = async () => {
try {
const lastRepoCheck = await getLastRepositoryCheckHash();
const updates = await VencordNative.updater.getUpdates();
if (updates.ok) {
const currentRepoHash =
updates.value.length > 0
? updates.value[0].hash
: gitHash;
setRecentlyChecked(lastRepoCheck === currentRepoHash);
}
} catch (err) {
// ignore errors (hopefully there are none lol)
setRecentlyChecked(false);
}
};
if (!repoPending && !repoErr) {
checkRecentStatus();
}
}, [repoPending, repoErr]);
const fetchChangelog = React.useCallback(async () => {
if (repoPending || repoErr) return;
setIsLoading(true);
setError(null);
try {
// check if the repository was recently refreshed and that nothing has changed
const updates = await VencordNative.updater.getUpdates();
const lastRepoCheck = await getLastRepositoryCheckHash();
const currentRepoHash =
updates.ok && updates.value.length > 0
? updates.value[0].hash
: gitHash;
// If repository state hasn't changed since last check
if (lastRepoCheck === currentRepoHash) {
setIsLoading(false);
setRecentlyChecked(true);
Toasts.show({
message: "Already up to date with repository",
id: Toasts.genId(),
type: Toasts.Type.MESSAGE,
options: {
position: Toasts.Position.BOTTOM,
},
});
return;
}
if (updates.ok && updates.value) {
setChangelog(updates.value);
// Load current new/updated plugins and settings
const newPlgs = await getNewPlugins();
const updatedPlgs = await getUpdatedPlugins();
const newSettings = await getNewSettings();
setNewPlugins(newPlgs);
setUpdatedPlugins(updatedPlgs);
// always save the current fetch session to history to track repo state
await saveUpdateSession(
updates.value,
newPlgs,
updatedPlgs,
newSettings,
true, // forceLog = true for repository fetches
);
await loadChangelogHistory();
setRecentlyChecked(true);
Toasts.show({
message:
updates.value.length > 0
? `Found ${updates.value.length} commit${updates.value.length === 1 ? "" : "s"} from repository`
: "Repository is up to date with your local copy",
id: Toasts.genId(),
type:
updates.value.length > 0
? Toasts.Type.SUCCESS
: Toasts.Type.MESSAGE,
options: {
position: Toasts.Position.BOTTOM,
},
});
} else if (!updates.ok) {
throw new Error(
updates.error?.message || "Failed to fetch from repository",
);
}
} catch (err: any) {
UpdateLogger.error("Failed to fetch commits from repository", err);
const errorMessage =
err?.message ||
"Failed to connect to repository. Check your internet connection.";
setError(errorMessage);
// funny little error toast hopefully doesn't happen!
Toasts.show({
message: "Could not fetch commits from repository",
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
options: {
position: Toasts.Position.BOTTOM,
},
});
} finally {
setIsLoading(false);
}
}, [repoPending, repoErr, loadNewPlugins, loadChangelogHistory]);
React.useEffect(() => {
const loadInitialData = async () => {
if (!repoPending && !repoErr) {
// load new plugins first, then fetch the commits
await loadNewPlugins();
await fetchChangelog();
} else if (!repoPending) {
// perseverance
await loadNewPlugins();
setIsLoading(false);
}
};
loadInitialData();
}, [repoPending, repoErr, fetchChangelog, loadNewPlugins]);
const toggleLogExpanded = (logId: string) => {
const newExpanded = new Set(expandedLogs);
if (newExpanded.has(logId)) {
newExpanded.delete(logId);
} else {
newExpanded.add(logId);
}
setExpandedLogs(newExpanded);
};
const hasCurrentChanges =
changelog.length > 0 ||
newPlugins.length > 0 ||
updatedPlugins.length > 0;
return (
<>
<Forms.FormText className={Margins.bottom16}>
View the most recent changes to Equicord. This fetches commits
directly from the repository to show you what's new.
</Forms.FormText>
<Forms.FormTitle tag="h5">Repository</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>
{repoPending ? (
repo
) : repoErr ? (
"Failed to retrieve - check console"
) : (
<Link href={repo}>
{repo.split("/").slice(-2).join("/")}
</Link>
)}{" "}
(Current:{" "}
<span className="vc-changelog-current-hash">
{shortGitHash()}
</span>
)
</Forms.FormText>
<div className="vc-changelog-controls">
<Button
size={Button.Sizes.SMALL}
disabled={isLoading || repoPending || !!repoErr}
onClick={fetchChangelog}
color={
recentlyChecked
? Button.Colors.GREEN
: Button.Colors.BRAND
}
>
{isLoading
? "Loading..."
: recentlyChecked
? "Repository Up to Date"
: "Fetch from Repository"}
</Button>
{changelogHistory.length > 0 && (
<>
<Button
size={Button.Sizes.SMALL}
color={
showHistory
? Button.Colors.PRIMARY
: Button.Colors.BRAND
}
onClick={() => setShowHistory(!showHistory)}
style={{ marginLeft: "8px" }}
>
{showHistory ? "Hide Logs" : "Show Logs"}
</Button>
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
onClick={() => {
Alerts.show({
title: "Clear All Logs",
body: "Are you sure you would like to clear all logs? This can't be undone.",
confirmText: "Clear All",
confirmColor: "danger",
cancelText: "Cancel",
onConfirm: async () => {
await clearChangelogHistory();
await loadChangelogHistory();
setShowHistory(false);
Toasts.show({
message:
"All logs have been cleared",
id: Toasts.genId(),
type: Toasts.Type.SUCCESS,
options: {
position:
Toasts.Position.BOTTOM,
},
});
},
});
}}
style={{ marginLeft: "8px" }}
>
Clear All Logs
</Button>
</>
)}
</div>
{error && (
<ErrorCard style={{ padding: "1em", marginBottom: "1em" }}>
<p>{error}</p>
<Forms.FormText
style={{
marginTop: "0.5em",
color: "var(--text-muted)",
}}
>
Make sure you have an internet connection and try again.
</Forms.FormText>
</ErrorCard>
)}
<Forms.FormDivider className={Margins.bottom16} />
{/* Current Changes Section */}
{hasCurrentChanges ? (
<div className="vc-changelog-current">
<Forms.FormTitle tag="h6" className={Margins.bottom8}>
Recent Changes
</Forms.FormTitle>
{/* New Plugins Section */}
{newPlugins.length > 0 && (
<div className={Margins.bottom16}>
<NewPluginsSection
newPlugins={newPlugins}
onPluginToggle={(pluginName, enabled) => {
// Handle plugin toggle if needed
}}
/>
</div>
)}
{/* Updated Plugins Section */}
{updatedPlugins.length > 0 && (
<div className={Margins.bottom16}>
<Forms.FormTitle
tag="h6"
className={Margins.bottom8}
>
Updated Plugins ({updatedPlugins.length})
</Forms.FormTitle>
<NewPluginsCompact newPlugins={updatedPlugins} />
</div>
)}
{/* Code Changes */}
{changelog.length > 0 && (
<div>
<Forms.FormTitle
tag="h6"
className={Margins.bottom8}
>
Code Changes ({changelog.length}{" "}
{changelog.length === 1 ? "commit" : "commits"})
</Forms.FormTitle>
<div className="vc-changelog-commits-list">
{changelog.map(entry => (
<ChangelogCard
key={entry.hash}
entry={entry}
repo={repo}
repoPending={repoPending}
/>
))}
</div>
</div>
)}
</div>
) : (
!isLoading &&
!error && (
<Card className="vc-changelog-empty">
<Forms.FormText>
No commits available ahead of your current version.
Click "Fetch from Repository" to check for new
changes.
</Forms.FormText>
</Card>
)
)}
{/* History Section */}
{showHistory && changelogHistory.length > 0 && (
<div className="vc-changelog-history">
<Forms.FormDivider
className={Margins.top16}
style={{ marginBottom: "1em" }}
/>
<Forms.FormTitle tag="h5" className={Margins.bottom8}>
Update Logs ({changelogHistory.length}{" "}
{changelogHistory.length === 1 ? "log" : "logs"})
</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>
Previous update sessions with their commit history and
plugin changes.
</Forms.FormText>
<div className="vc-changelog-history-list">
{changelogHistory.map(log => (
<UpdateLogCard
key={log.id}
log={log}
repo={repo}
repoPending={repoPending}
isExpanded={expandedLogs.has(log.id)}
onToggleExpand={() => toggleLogExpanded(log.id)}
onClearLog={logId => {
Alerts.show({
title: "Clear Log",
body: "Are you sure you would like to clear this log? This can't be undone.",
confirmText: "Clear Log",
confirmColor: "danger",
cancelText: "Cancel",
onConfirm: async () => {
await clearIndividualLog(logId);
await loadChangelogHistory();
setExpandedLogs(
new Set(
Array.from(
expandedLogs,
).filter(
id => id !== logId,
),
),
);
Toasts.show({
message: "Log has been cleared",
id: Toasts.genId(),
type: Toasts.Type.SUCCESS,
options: {
position:
Toasts.Position.BOTTOM,
},
});
},
});
}}
/>
))}
</div>
</div>
)}
</>
);
}
function ChangelogTab() {
return (
<SettingsTab title="Changelog">
<ChangelogContent />
</SettingsTab>
);
}
export default wrapTab(ChangelogTab, "Changelog");

View file

@ -0,0 +1,572 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/* Existing changelog styles */
.vc-changelog-entry {
padding: 0.75em;
margin-bottom: 0.5em;
background-color: var(--background-base-lower-alt);
border-radius: 8px;
transition: 0.1s ease-out;
transition-property: box-shadow, transform, background, opacity;
}
.vc-changelog-entry:hover {
background-color: var(--background-tertiary);
transform: translateY(-1px);
box-shadow: var(--elevation-high);
}
.vc-changelog-entry.required {
border-left: 4px solid var(--status-warning);
background-color: var(--background-base-lower-alt);
padding-left: calc(0.75em - 4px);
}
.vc-changelog-entry.required:hover {
background-color: var(--background-tertiary);
border-left-color: var(--status-warning-foreground);
}
.vc-changelog-entry-header {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0.25em;
}
.vc-changelog-entry-hash {
font-size: 0.875em;
font-family: var(--font-code);
color: var(--text-muted);
}
.vc-changelog-entry-author {
font-size: 0.875em;
color: var(--text-muted);
font-weight: 500;
}
.vc-changelog-entry-message {
color: var(--text-default);
font-size: 0.95em;
line-height: 1.4;
}
.vc-changelog-entry.required .vc-changelog-entry-hash {
color: var(--text-muted);
font-weight: 600;
}
.vc-changelog-loading {
text-align: center;
padding: 2em;
color: var(--text-muted);
}
.vc-changelog-empty {
text-align: center;
padding: 2em;
background-color: var(--background-base-lower-alt);
border-radius: 8px;
color: var(--text-muted);
}
.vc-changelog-controls {
margin-bottom: 1em;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5em;
}
.vc-changelog-repo-info {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 1em;
}
.vc-changelog-current-hash {
font-family: var(--font-code);
background-color: var(--background-base-lowest);
padding: 0.125em 0.25em;
border-radius: 3px;
font-size: 0.875em;
}
/* New Plugins Section Styles */
.vc-changelog-new-plugins-section {
margin-bottom: 1.5em;
padding: 1em;
background-color: var(--background-base-lower-alt);
border-radius: 8px;
border-left: 4px solid var(--brand);
}
.vc-changelog-new-plugins-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 0.75em;
}
.vc-changelog-new-plugin-card {
background-color: var(--background-tertiary);
border-radius: 6px;
padding: 0.5em;
transition: all 0.1s ease-out;
}
.vc-changelog-new-plugin-card:hover {
background-color: var(--background-base-lowest);
transform: translateY(-1px);
box-shadow: var(--elevation-medium);
}
.vc-changelog-new-plugin-card.required {
border-left: 4px solid var(--status-warning);
background-color: var(--background-base-lower-alt);
}
.vc-changelog-new-plugin-card.required:hover {
background-color: var(--background-tertiary);
border-left-color: var(--status-warning-foreground);
}
.vc-changelog-new-plugins-compact {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin-bottom: 0;
padding-top: 0;
}
.vc-changelog-new-plugins-list {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
}
.vc-changelog-new-plugin-tag {
display: inline-flex;
align-items: center;
padding: 0.25em 0.5em;
background-color: var(--brand);
color: var(--white);
border-radius: 4px;
font-size: 0.875em;
font-weight: 500;
text-decoration: none;
transition: background-color 0.1s ease;
}
.vc-changelog-new-plugin-tag:hover {
background-color: var(--brand-700);
}
.vc-changelog-new-plugin-tag.more {
background-color: var(--background-base-lower-alt);
color: var(--text-muted);
}
.vc-changelog-new-plugin-tag.required {
background-color: var(--status-warning);
color: var(--white);
border: 1px solid var(--status-warning-foreground);
}
.vc-changelog-new-plugin-tag.required:hover {
background-color: var(--status-warning-foreground);
}
.vc-changelog-restart-notice {
margin-top: 1em;
padding: 0.75em;
background-color: var(--info-warning-background);
border-radius: 6px;
border-left: 4px solid var(--status-warning);
}
/* Update Logs Styles */
.vc-changelog-history {
margin-top: 1.5em;
}
.vc-changelog-history-list {
display: flex;
flex-direction: column;
gap: 0.75em;
padding-right: 0.5em;
min-height: auto !important;
max-height: none !important;
height: auto !important;
}
.vc-changelog-log {
background-color: var(--background-base-lower-alt);
border-radius: 8px;
overflow: hidden;
transition: all 0.1s ease-out;
}
.vc-changelog-log:hover {
box-shadow: var(--elevation-medium);
}
.vc-changelog-log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1em;
cursor: pointer;
transition: background-color 0.1s ease;
user-select: none;
gap: 0.5em;
position: relative;
}
.vc-changelog-log-header:hover {
background-color: var(--background-tertiary);
}
.vc-changelog-log-info {
flex: 1;
}
.vc-changelog-log-title {
font-weight: 600;
font-size: 0.95em;
color: var(--text-default);
margin-bottom: 0.25em;
font-family: var(--font-code);
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.vc-changelog-log-meta {
font-size: 0.875em;
color: var(--text-muted);
line-height: 1.3;
}
.vc-changelog-log-toggle {
transition: transform 0.2s ease;
color: var(--text-muted);
font-size: 0.75em;
flex-shrink: 0;
}
.vc-changelog-log-toggle.expanded {
transform: rotate(180deg);
}
.vc-changelog-log-content {
padding: 0 1em 1em;
border-top: 1px solid var(--background-base-lowest);
animation: expand-content 0.2s ease-out;
/* allow content to expand naturally (fixes cramming of elements) */
min-height: auto !important;
max-height: none !important;
overflow: visible !important;
}
.vc-changelog-log-plugins {
margin-bottom: 1em;
padding-top: 0.75em;
}
.vc-changelog-log-plugins:last-child {
margin-bottom: 0;
}
.vc-changelog-log-commits {
margin-bottom: 1em;
padding-top: 0.75em;
}
.vc-changelog-log-commits:last-child {
margin-bottom: 0;
}
.vc-changelog-log-commits-list {
display: flex;
flex-direction: column;
gap: 0.5em;
padding-right: 0.5em;
padding-top: 0;
}
/* fix plugin entry padding to match commit entries (this took 3 different tries bro) */
.vc-changelog-plugins-list .vc-changelog-entry {
padding: 0.75em;
margin-bottom: 0.5em;
}
/* Visual refresh support */
.visual-refresh .vc-changelog-entry {
background-color: var(--card-primary-bg);
border: 1px solid var(--border-subtle);
}
.vc-changelog-log-commits-list .vc-changelog-entry:last-child {
margin-bottom: 0;
}
.vc-changelog-plugins-list {
display: flex;
flex-direction: column;
gap: 0.5em;
padding-right: 0.5em;
padding-top: 0;
}
.visual-refresh .vc-changelog-entry.required {
border-left: 4px solid var(--status-warning);
background-color: var(--card-primary-bg);
border: 1px solid var(--border-subtle);
padding-left: calc(0.75em - 4px);
}
.vc-changelog-plugins-list .vc-changelog-entry:last-child {
margin-bottom: 0;
}
/* consistent padding for required plugin entries in logs */
.vc-changelog-plugins-list .vc-changelog-entry.required {
padding-left: calc(0.75em - 4px);
margin-bottom: 0.5em;
}
.vc-changelog-plugins-list .vc-changelog-entry.required:last-child {
margin-bottom: 0;
}
.vc-changelog-log-commits-list::-webkit-scrollbar {
width: 6px;
}
.vc-changelog-log-commits-list::-webkit-scrollbar-track {
background: var(--background-base-lower-alt);
border-radius: 3px;
}
.vc-changelog-log-commits-list::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 3px;
}
.vc-changelog-log-commits-list::-webkit-scrollbar-thumb:hover {
background: var(--text-default);
}
/* Current changes section */
.vc-changelog-current {
margin-bottom: 1.5em;
}
.vc-changelog-commits-list {
display: flex;
flex-direction: column;
gap: 0.5em;
}
/* Scrollbar styling for history list and log content */
.vc-changelog-history-list::-webkit-scrollbar,
.vc-changelog-log-content::-webkit-scrollbar {
width: 6px;
}
.vc-changelog-history-list::-webkit-scrollbar-track,
.vc-changelog-log-content::-webkit-scrollbar-track {
background: var(--background-base-lower-alt);
border-radius: 3px;
}
.vc-changelog-history-list::-webkit-scrollbar-thumb,
.vc-changelog-log-content::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 3px;
}
.vc-changelog-history-list::-webkit-scrollbar-thumb:hover,
.vc-changelog-log-content::-webkit-scrollbar-thumb:hover {
background: var(--text-default);
}
.visual-refresh .vc-changelog-entry:hover {
background-color: var(--card-primary-bg);
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.visual-refresh .vc-changelog-empty {
background-color: var(--card-primary-bg);
border: 1px solid var(--border-subtle);
}
.visual-refresh .vc-changelog-current-hash {
background-color: var(--input-background);
border: 1px solid var(--border-subtle);
}
.visual-refresh .vc-changelog-new-plugins-section {
background-color: var(--card-primary-bg);
border: 1px solid var(--border-subtle);
border-left: 4px solid var(--brand);
}
.visual-refresh .vc-changelog-new-plugin-card {
background-color: var(--card-secondary-bg);
border: 1px solid var(--border-subtle);
}
.visual-refresh .vc-changelog-log {
background-color: var(--card-primary-bg);
border: 1px solid var(--border-subtle);
}
.visual-refresh .vc-changelog-log-header:hover {
background-color: var(--card-secondary-bg);
}
.visual-refresh .vc-changelog-log-content {
border-top: 1px solid var(--border-subtle);
}
.visual-refresh .vc-changelog-restart-notice {
background-color: var(--card-primary-bg);
border: 1px solid var(--status-warning);
border-left: 4px solid var(--status-warning);
}
/* Responsive design */
@media (width <=768px) {
.vc-changelog-new-plugins-grid {
grid-template-columns: 1fr;
}
.vc-changelog-controls {
flex-direction: column;
align-items: stretch;
}
.vc-changelog-session-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5em;
}
.vc-changelog-log-toggle {
align-self: flex-end;
margin-top: -1.5em;
}
.vc-changelog-log-title {
flex-wrap: wrap;
gap: 4px;
}
.vc-changelog-delete-button {
order: 1;
}
}
/* Animation for expanding/collapsing logs */
@keyframes expand-content {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Badge styles for plugin counts */
.vc-changelog-badge {
display: inline-flex;
align-items: center;
padding: 0.125em 0.375em;
background-color: var(--brand);
color: var(--white);
border-radius: 12px;
font-size: 0.75em;
font-weight: 600;
margin-left: 0.5em;
}
.vc-changelog-badge.warning {
background-color: var(--status-warning);
}
.vc-changelog-badge.success {
background-color: var(--status-positive);
}
/* Required plugin styling */
.vc-changelog-dep-text {
font-size: 0.875em;
color: var(--text-muted);
margin-left: 1em;
font-style: italic;
}
/* Visual refresh support for required plugins */
.visual-refresh .vc-changelog-new-plugin-tag.required {
background-color: var(--status-warning);
border: 1px solid var(--status-warning-foreground);
}
.visual-refresh .vc-changelog-new-plugin-card.required {
border-left: 4px solid var(--status-warning);
background-color: var(--card-secondary-bg);
}
/* Global overrides for changelog in settings modal */
.vc-settings-tab [class*="changelog"] {
min-height: auto !important;
max-height: none !important;
overflow: visible !important;
}
/* Ensure Forms.FormSection doesn't constrain changelog */
.vc-settings-tab .vc-changelog-history,
.vc-settings-tab .vc-changelog-history-list,
.vc-settings-tab .vc-changelog-log-content {
min-height: auto !important;
max-height: none !important;
height: auto !important;
overflow: visible !important;
}
.visual-refresh .vc-changelog-entry.required:hover {
background-color: var(--card-secondary-bg);
}
/* Trash icon styling */
.vc-changelog-delete-button {
transition:
opacity 0.2s ease,
color 0.2s ease,
background-color 0.2s ease;
border-radius: 4px;
flex-shrink: 0;
}
.vc-changelog-delete-button:hover {
opacity: 1 !important;
color: var(--status-danger) !important;
background-color: var(--background-modifier-hover);
}
.vc-changelog-log-header:hover .vc-changelog-delete-button {
opacity: 0.8;
}

View file

@ -16,3 +16,4 @@ export { default as BackupAndRestoreTab } from "./sync/BackupAndRestoreTab";
export { default as CloudTab } from "./sync/CloudTab";
export { openUpdaterModal, default as UpdaterTab } from "./updater";
export { default as VencordTab } from "./vencord";
export { default as ChangelogTab } from "./changelog";

View file

@ -1,20 +0,0 @@
.vc-newPluginsManager-restart-tooltip {
max-width: 280px;
}
.vc-newPluginsManager-restart-list {
padding-left: 1.25em;
}
.vc-newPluginsManager-restart-list li {
list-style: disc;
}
.vc-newPluginsManager-close > div {
display: flex;
}
.vc-newPluginsManager-disable-wrapper {
display: flex;
margin: auto 0;
}

View file

@ -1,205 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./NewPluginsModal.css";
import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary";
import { PluginCard } from "@components/settings/tabs/plugins/PluginCard";
import { ChangeList } from "@utils/ChangeList";
import { classes, Margins } from "@utils/index";
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { useForceUpdater } from "@utils/react";
import { findComponentByCodeLazy } from "@webpack";
import { Button, Flex, Forms, React, Text, Tooltip, useMemo } from "@webpack/common";
import { JSX } from "react";
import Plugins from "~plugins";
import { getNewPlugins, getNewSettings, KnownPluginSettingsMap, writeKnownSettings } from "./knownSettings";
const cl = classNameFactory("vc-plugins-");
const Checkbox = findComponentByCodeLazy(".checkboxWrapperDisabled:");
let hasSeen = false;
// Most of this was stolen from PluginSettings directly.
export function NewPluginsModal({ modalProps, newPlugins, newSettings }: { modalProps: ModalProps; newPlugins: Set<string>; newSettings: KnownPluginSettingsMap; }) {
const settings = useSettings();
const changes = React.useMemo(() => new ChangeList<string>(), []);
let updateContinueButton = () => { };
const depMap = React.useMemo(() => {
const o = {} as Record<string, string[]>;
for (const plugin in Plugins) {
const deps = Plugins[plugin].dependencies;
if (deps) {
for (const dep of deps) {
o[dep] ??= [];
o[dep].push(plugin);
}
}
}
return o;
}, []);
const mapPlugins = (array: string[]) => array.map(pn => Plugins[pn])
.sort((a, b) => a.name.localeCompare(b.name));
const sortedPlugins = useMemo(() => [
...mapPlugins([...newPlugins]),
...mapPlugins([...newSettings.keys()].filter(p => !newPlugins.has(p)))
], []);
const plugins = [] as JSX.Element[];
const requiredPlugins = [] as JSX.Element[];
for (const p of sortedPlugins) {
if (p.hidden)
continue;
const isRequired = p.required || depMap[p.name]?.some(d => settings.plugins[d].enabled);
if (isRequired) {
const tooltipText = p.required
? "This plugin is required for Vencord to function."
: makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled));
requiredPlugins.push(
<Tooltip text={tooltipText} key={p.name}>
{({ onMouseLeave, onMouseEnter }) => (
<PluginCard
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onRestartNeeded={name => {
changes.handleChange(name);
updateContinueButton();
}}
disabled={true}
plugin={p}
key={p.name}
isNew={newPlugins.has(p.name)}
/>
)}
</Tooltip>
);
} else {
plugins.push(
<PluginCard
onRestartNeeded={name => {
changes.handleChange(name);
updateContinueButton();
}}
disabled={false}
plugin={p}
key={p.name}
isNew={newPlugins.has(p.name)}
/>
);
}
}
return <ModalRoot {...modalProps} size={ModalSize.MEDIUM} >
<ModalHeader>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>New Plugins and Settings ({[...plugins, ...requiredPlugins].length})</Text>
<Tooltip text="Dismiss for this session">
{tooltipProps =>
<div {...tooltipProps}>
<ModalCloseButton
onClick={modalProps.onClose}
className={classes(cl("close"), "vc-newPluginsManager-close")}
/>
</div>
}
</Tooltip>
</ModalHeader>
<ModalContent>
<div className={cl("grid")}>
{[...plugins, ...requiredPlugins]}
</div>
</ModalContent>
<ModalFooter>
<Flex direction={Flex.Direction.HORIZONTAL_REVERSE}>
<ContinueButton
close={modalProps.onClose}
changes={changes}
callback={(v: () => void) => updateContinueButton = v}
/>
</Flex>
<Flex direction={Flex.Direction.HORIZONTAL}>
<div className="vc-newPluginsManager-disable-wrapper">
<Checkbox
type="inverted"
value={!settings?.plugins?.NewPluginsManager?.enabled}
onChange={() => {
Settings.plugins.NewPluginsManager.enabled = !settings?.plugins?.NewPluginsManager?.enabled;
}}
>
<Text>Don't show this again</Text>
</Checkbox>
</div>
</Flex>
</ModalFooter>
</ModalRoot>;
}
function ContinueButton(props: { callback: (update: () => void) => void; changes: ChangeList<string>; close: () => any; }) {
const update = useForceUpdater();
props.callback(update);
return <Tooltip
tooltipClassName="vc-newPluginsManager-restart-tooltip"
text={<>
The following plugins require a restart:
<div className={Margins.bottom8} />
<ul className="vc-newPluginsManager-restart-list">
{props.changes.map(p => <li key={p}>{p}</li>)}
</ul>
</>}
shouldShow={props.changes.hasChanges}
>
{tooltipProps =>
<Button
{...tooltipProps}
color={Button.Colors.GREEN}
onClick={async () => {
await writeKnownSettings();
props.changes.hasChanges ? location.reload() : props.close();
}}
>
{props.changes.hasChanges ? "Restart" : "Continue"}
</Button>
}
</Tooltip>;
}
function makeDependencyList(deps: string[]) {
return (
<React.Fragment>
<Forms.FormText>This plugin is required by:</Forms.FormText>
{deps.map((dep: string) => <Forms.FormText key={dep} className={cl("dep-text")}>{dep}</Forms.FormText>)}
</React.Fragment>
);
}
export async function openNewPluginsModal() {
const newPlugins = await getNewPlugins();
const newSettings = await getNewSettings();
if ((newPlugins.size || newSettings.size) && !hasSeen) {
hasSeen = true;
const modalKey = openModal(modalProps => <ErrorBoundary noop onError={() => { closeModal(modalKey); }}>
<NewPluginsModal
modalProps={modalProps}
newPlugins={newPlugins}
newSettings={newSettings}
/>
</ErrorBoundary>);
}
}

View file

@ -1,28 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import * as KnownSettings from "./knownSettings";
import { KNOWN_PLUGINS_LEGACY_DATA_KEY, KNOWN_SETTINGS_DATA_KEY } from "./knownSettings";
import { openNewPluginsModal } from "./NewPluginsModal";
export default definePlugin({
name: "NewPluginsManager",
description: "Utility that notifies you when new plugins are added to Equicord",
authors: [Devs.Sqaaakoi],
enabledByDefault: true,
flux: {
async POST_CONNECTION_OPEN() {
openNewPluginsModal();
}
},
openNewPluginsModal,
KNOWN_PLUGINS_LEGACY_DATA_KEY,
KNOWN_SETTINGS_DATA_KEY,
KnownSettings
});

View file

@ -1,91 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import plugins from "~plugins";
export type KnownPluginSettingsMap = Map<string, Set<string>>;
export const KNOWN_PLUGINS_LEGACY_DATA_KEY = "NewPluginsManager_KnownPlugins";
export const KNOWN_SETTINGS_DATA_KEY = "NewPluginsManager_KnownSettings";
function getSettingsSetForPlugin(plugin: string): Set<string> {
const settings = plugins[plugin]?.settings?.def || {};
return new Set(Object.keys(settings).filter(setting => setting !== "enabled"));
}
function getCurrentSettings(pluginList: string[]): KnownPluginSettingsMap {
return new Map(pluginList.map(name => [
name,
getSettingsSetForPlugin(name)
]));
}
export async function getKnownSettings(): Promise<KnownPluginSettingsMap> {
let map = await DataStore.get(KNOWN_SETTINGS_DATA_KEY) as KnownPluginSettingsMap;
if (map === undefined) {
const knownPlugins = await DataStore.get(KNOWN_PLUGINS_LEGACY_DATA_KEY) ?? [] as string[];
const Plugins = [...Object.keys(plugins), ...knownPlugins];
map = getCurrentSettings(Plugins);
DataStore.set(KNOWN_SETTINGS_DATA_KEY, map);
}
return map;
}
export async function getNewSettings(): Promise<KnownPluginSettingsMap> {
const map = getCurrentSettings(Object.keys(plugins));
const knownSettings = await getKnownSettings();
map.forEach((settings, plugin) => {
const filteredSettings = [...settings].filter(setting => !knownSettings.get(plugin)?.has(setting));
if (!filteredSettings.length) return map.delete(plugin);
map.set(plugin, new Set(filteredSettings));
});
return map;
}
export async function getKnownPlugins(): Promise<Set<string>> {
const knownSettings = await getKnownSettings();
return new Set(knownSettings.keys());
}
export async function getNewPlugins(): Promise<Set<string>> {
const currentPlugins = Object.keys(plugins);
const knownPlugins = await getKnownPlugins();
return new Set(currentPlugins.filter(p => !knownPlugins.has(p)));
}
export async function writeKnownSettings() {
const currentSettings = getCurrentSettings(Object.keys(plugins));
const knownSettings = await getKnownSettings();
const allSettings = new Map();
new Set([...currentSettings.keys(), ...knownSettings.keys()]).forEach(plugin => {
allSettings.set(plugin, new Set([
...(currentSettings.get(plugin) || []),
...(knownSettings.get(plugin) || [])
]));
});
await DataStore.set(KNOWN_SETTINGS_DATA_KEY, allSettings);
}
export async function debugWipeSomeData() {
const settings = await getKnownSettings();
settings.forEach((value, key) => {
if (Math.random() > 0.8) {
if (Math.random() > 0.5) return settings.set(key, new Set([...value].filter(() => Math.random() > 0.5)));
return settings.delete(key);
}
});
await DataStore.set(KNOWN_SETTINGS_DATA_KEY, settings);
}
export async function editRawData(patcher: (data: KnownPluginSettingsMap) => (Promise<any> | any)) {
if (!patcher) return;
const map = await DataStore.get(KNOWN_SETTINGS_DATA_KEY) as KnownPluginSettingsMap;
const newMap = new Map(map);
await patcher(newMap);
await DataStore.set(KNOWN_SETTINGS_DATA_KEY, newMap ?? map);
}

View file

@ -1,23 +1,19 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and Megumin
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Settings } from "@api/Settings";
import { BackupAndRestoreTab, CloudTab, PatchHelperTab, PluginsTab, UpdaterTab, VencordTab } from "@components/settings";
import {
BackupAndRestoreTab,
ChangelogTab,
CloudTab,
PatchHelperTab,
PluginsTab,
UpdaterTab,
VencordTab,
} from "@components/settings";
import ThemesTab from "@components/ThemeSettings/ThemesTab";
import { Devs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
@ -25,7 +21,6 @@ import definePlugin, { OptionType } from "@utils/types";
import { shortGitHash } from "@utils/updater";
import { React } from "@webpack/common";
type SectionType = "HEADER" | "DIVIDER" | "CUSTOM";
type SectionTypes = Record<SectionType, SectionType>;
@ -44,26 +39,34 @@ export default definePlugin({
replace: (m, component, props) => {
props = props.replace(/children:\[.+\]/, "");
return `${m},$self.makeInfoElements(${component}, ${props})`;
}
},
},
{
match: /copyValue:\i\.join\(" "\)/,
replace: "$& + $self.getInfoString()"
}
]
replace: "$& + $self.getInfoString()",
},
],
},
{
find: ".SEARCH_NO_RESULTS&&0===",
replacement: [
{
match: /(?<=section:(.{0,50})\.DIVIDER\}\))([,;])(?=.{0,200}(\i)\.push.{0,100}label:(\i)\.header)/,
replace: (_, sectionTypes, commaOrSemi, elements, element) => `${commaOrSemi} $self.addSettings(${elements}, ${element}, ${sectionTypes}) ${commaOrSemi}`
replace: (
_,
sectionTypes,
commaOrSemi,
elements,
element,
) =>
`${commaOrSemi} $self.addSettings(${elements}, ${element}, ${sectionTypes}) ${commaOrSemi}`,
},
{
match: /({(?=.+?function (\i).{0,160}(\i)=\i\.useMemo.{0,140}return \i\.useMemo\(\(\)=>\i\(\3).+?\(\)=>)\2/,
replace: (_, rest, settingsHook) => `${rest}$self.wrapSettingsHook(${settingsHook})`
}
]
replace: (_, rest, settingsHook) =>
`${rest}$self.wrapSettingsHook(${settingsHook})`,
},
],
},
{
find: ".DEVELOPER_SECTION,",
@ -78,9 +81,9 @@ export default definePlugin({
find: "#{intl::USER_SETTINGS_ACTIONS_MENU_LABEL}",
replacement: {
match: /(?<=function\((\i),\i\)\{)(?=let \i=Object.values\(\i.\i\).*?(\i\.\i)\.open\()/,
replace: "$2.open($1);return;"
}
}
replace: "$2.open($1);return;",
},
},
],
customSections: [] as ((SectionTypes: SectionTypes) => any)[],
@ -90,72 +93,87 @@ export default definePlugin({
{
section: SectionTypes.HEADER,
label: "Equicord",
className: "vc-settings-header"
className: "vc-settings-header",
},
{
section: "EquicordSettings",
label: "Equicord",
element: VencordTab,
className: "vc-settings"
className: "vc-settings",
},
{
section: "EquicordPlugins",
label: "Plugins",
searchableTitles: ["Plugins"],
element: PluginsTab,
className: "vc-plugins"
className: "vc-plugins",
},
{
section: "EquicordThemes",
label: "Themes",
searchableTitles: ["Themes"],
element: ThemesTab,
className: "vc-themes"
className: "vc-themes",
},
!IS_UPDATER_DISABLED && {
section: "EquicordUpdater",
label: "Updater",
searchableTitles: ["Updater"],
element: UpdaterTab,
className: "vc-updater"
className: "vc-updater",
},
{
section: "EquicordChangelog",
label: "Changelog",
searchableTitles: ["Changelog"],
element: ChangelogTab,
className: "vc-changelog",
},
{
section: "EquicordCloud",
label: "Cloud",
searchableTitles: ["Cloud"],
element: CloudTab,
className: "vc-cloud"
className: "vc-cloud",
},
{
section: "settings/tabsSync",
label: "Backup & Restore",
searchableTitles: ["Backup & Restore"],
element: BackupAndRestoreTab,
className: "vc-backup-restore"
className: "vc-backup-restore",
},
IS_DEV && {
section: "EquicordPatchHelper",
label: "Patch Helper",
searchableTitles: ["Patch Helper"],
element: PatchHelperTab,
className: "vc-patch-helper"
className: "vc-patch-helper",
},
...this.customSections.map(func => func(SectionTypes)),
{
section: SectionTypes.DIVIDER
}
section: SectionTypes.DIVIDER,
},
].filter(Boolean);
},
isRightSpot({ header, settings }: { header?: string; settings?: string[]; }) {
isRightSpot({
header,
settings,
}: {
header?: string;
settings?: string[];
}) {
const firstChild = settings?.[0];
// lowest two elements... sanity backup
if (firstChild === "LOGOUT" || firstChild === "SOCIAL_LINKS") return true;
if (firstChild === "LOGOUT" || firstChild === "SOCIAL_LINKS")
return true;
const { settingsLocation } = Settings.plugins.Settings;
if (settingsLocation === "bottom") return firstChild === "LOGOUT";
if (settingsLocation === "belowActivity") return firstChild === "CHANGELOG";
if (settingsLocation === "belowActivity")
return firstChild === "CHANGELOG";
if (!header) return;
@ -164,10 +182,13 @@ export default definePlugin({
top: getIntlMessage("USER_SETTINGS"),
aboveNitro: getIntlMessage("BILLING_SETTINGS"),
belowNitro: getIntlMessage("APP_SETTINGS"),
aboveActivity: getIntlMessage("ACTIVITY_SETTINGS")
aboveActivity: getIntlMessage("ACTIVITY_SETTINGS"),
};
if (!names[settingsLocation] || names[settingsLocation].endsWith("_SETTINGS"))
if (
!names[settingsLocation] ||
names[settingsLocation].endsWith("_SETTINGS")
)
return firstChild === "PREMIUM";
return header === names[settingsLocation];
@ -178,23 +199,32 @@ export default definePlugin({
patchedSettings: new WeakSet(),
addSettings(elements: any[], element: { header?: string; settings: string[]; }, sectionTypes: SectionTypes) {
if (this.patchedSettings.has(elements) || !this.isRightSpot(element)) return;
addSettings(
elements: any[],
element: { header?: string; settings: string[]; },
sectionTypes: SectionTypes,
) {
if (this.patchedSettings.has(elements) || !this.isRightSpot(element))
return;
this.patchedSettings.add(elements);
elements.push(...this.makeSettingsCategories(sectionTypes));
},
wrapSettingsHook(originalHook: (...args: any[]) => Record<string, unknown>[]) {
wrapSettingsHook(
originalHook: (...args: any[]) => Record<string, unknown>[],
) {
return (...args: any[]) => {
const elements = originalHook(...args);
if (!this.patchedSettings.has(elements))
elements.unshift(...this.makeSettingsCategories({
HEADER: "HEADER",
DIVIDER: "DIVIDER",
CUSTOM: "CUSTOM"
}));
elements.unshift(
...this.makeSettingsCategories({
HEADER: "HEADER",
DIVIDER: "DIVIDER",
CUSTOM: "CUSTOM",
}),
);
return elements;
};
@ -211,20 +241,29 @@ export default definePlugin({
{ label: "Above Activity Settings", value: "aboveActivity" },
{ label: "Below Activity Settings", value: "belowActivity" },
{ label: "At the very bottom", value: "bottom" },
]
],
},
},
get electronVersion() {
return VencordNative.native.getVersions().electron || window.legcord?.electron || null;
return (
VencordNative.native.getVersions().electron ||
window.legcord?.electron ||
null
);
},
get chromiumVersion() {
try {
return VencordNative.native.getVersions().chrome
return (
VencordNative.native.getVersions().chrome ||
// @ts-expect-error Typescript will add userAgentData IMMEDIATELY
|| navigator.userAgentData?.brands?.find(b => b.brand === "Chromium" || b.brand === "Google Chrome")?.version
|| null;
navigator.userAgentData?.brands?.find(
b =>
b.brand === "Chromium" || b.brand === "Google Chrome",
)?.version ||
null
);
} catch {
// inb4 some stupid browser throws unsupported error for navigator.userAgentData, it's only in chromium
return null;
@ -258,9 +297,14 @@ export default definePlugin({
return "\n" + this.getInfoRows().join("\n");
},
makeInfoElements(Component: React.ComponentType<React.PropsWithChildren>, props: React.PropsWithChildren) {
return this.getInfoRows().map((text, i) =>
<Component key={i} {...props}>{text}</Component>
);
}
makeInfoElements(
Component: React.ComponentType<React.PropsWithChildren>,
props: React.PropsWithChildren,
) {
return this.getInfoRows().map((text, i) => (
<Component key={i} {...props}>
{text}
</Component>
));
},
});

View file

@ -87,7 +87,7 @@ export default definePlugin({
},
makeSubtext(user: User, origSubtext: any) {
const since = this.getSince(user);
const since = getSince(user);
if (isNaN(since.getTime())) {
return null;
}