feat(discord): show server member count badge

This commit is contained in:
777genius 2026-06-01 23:42:33 +03:00
parent 7c0e3db0b7
commit c1112872b1
5 changed files with 124 additions and 9 deletions

View file

@ -8,8 +8,10 @@
* - read-mentioned-file: Validates mentioned files for context injection
*/
import type { AgentConfig } from '@shared/types/api';
import { createLogger } from '@shared/utils/logger';
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
import { app, type IpcMain, type IpcMainInvokeEvent, net, shell } from 'electron';
import * as fsp from 'fs/promises';
import {
@ -18,10 +20,6 @@ import {
readAllClaudeMdFiles,
readDirectoryClaudeMd,
} from '../services';
import type { AgentConfig } from '@shared/types/api';
const logger = createLogger('IPC:utility');
import {
validateFilePath,
validateOpenPath,
@ -29,6 +27,12 @@ import {
} from '../utils/pathValidation';
import { countTokens } from '../utils/tokenizer';
const logger = createLogger('IPC:utility');
const DISCORD_INVITE_COUNT_URL = 'https://discord.com/api/v10/invites/qtqSZSyuEc?with_counts=true';
const DISCORD_MEMBER_COUNT_CACHE_TTL_MS = 10 * 60 * 1000;
const DISCORD_MEMBER_COUNT_TIMEOUT_MS = 5000;
let discordMemberCountCache: { count: number; fetchedAtMs: number } | null = null;
/**
* Registers all utility-related IPC handlers.
*/
@ -37,6 +41,7 @@ export function registerUtilityHandlers(ipcMain: IpcMain): void {
ipcMain.handle('shell:openPath', handleShellOpenPath);
ipcMain.handle('shell:showInFolder', handleShellShowInFolder);
ipcMain.handle('shell:openExternal', handleShellOpenExternal);
ipcMain.handle('discord:getMemberCount', handleGetDiscordMemberCount);
ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles);
ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd);
ipcMain.handle('read-mentioned-file', handleReadMentionedFile);
@ -53,6 +58,7 @@ export function removeUtilityHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('shell:openPath');
ipcMain.removeHandler('shell:showInFolder');
ipcMain.removeHandler('shell:openExternal');
ipcMain.removeHandler('discord:getMemberCount');
ipcMain.removeHandler('read-claude-md-files');
ipcMain.removeHandler('read-directory-claude-md');
ipcMain.removeHandler('read-mentioned-file');
@ -73,6 +79,72 @@ function handleGetAppVersion(): string {
return app.getVersion();
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asCount(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? Math.floor(value)
: null;
}
function parseDiscordMemberCount(payload: unknown): number | null {
const root = asRecord(payload);
if (!root) return null;
const directCount = asCount(root.approximate_member_count);
if (directCount !== null) return directCount;
const profile = asRecord(root.profile);
return profile ? asCount(profile.member_count) : null;
}
/**
* Handler for 'discord:getMemberCount' IPC call.
* Fetches through Electron main process to avoid renderer CORS restrictions.
*/
async function handleGetDiscordMemberCount(): Promise<{ count: number | null; error?: string }> {
const now = Date.now();
if (
discordMemberCountCache &&
now - discordMemberCountCache.fetchedAtMs < DISCORD_MEMBER_COUNT_CACHE_TTL_MS
) {
return { count: discordMemberCountCache.count };
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DISCORD_MEMBER_COUNT_TIMEOUT_MS);
try {
const response = await net.fetch(DISCORD_INVITE_COUNT_URL, {
method: 'GET',
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Discord invite count request failed with ${response.status}`);
}
const count = parseDiscordMemberCount(await response.json());
if (count === null) {
throw new Error('Discord invite count response did not include a member count');
}
discordMemberCountCache = { count, fetchedAtMs: now };
return { count };
} catch (error) {
logger.warn('Error in discord:getMemberCount:', error);
return {
count: discordMemberCountCache?.count ?? null,
error: String(error),
};
} finally {
clearTimeout(timeout);
}
}
/**
* Handler for 'shell:showInFolder' IPC call.
* Reveals a file in the system file manager (Finder/Explorer).

View file

@ -759,6 +759,7 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog),
showInFolder: (filePath: string) => ipcRenderer.invoke('shell:showInFolder', filePath),
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
getDiscordMemberCount: () => ipcRenderer.invoke('discord:getMemberCount'),
// Window controls (when title bar is hidden, e.g. Windows / Linux)
windowControls: {

View file

@ -653,6 +653,10 @@ export class HttpAPIClient implements ElectronAPI {
return { success: true };
};
getDiscordMemberCount = async (): Promise<{ count: number | null; error?: string }> => {
return { count: null, error: 'Not available in browser mode' };
};
windowControls = {
minimize: async (): Promise<void> => {},
maximize: async (): Promise<void> => {},

View file

@ -4,7 +4,7 @@
* Reads focused pane data from root store selectors (auto-synced via syncRootState).
*/
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
@ -15,6 +15,12 @@ import { useShallow } from 'zustand/react/shallow';
import { MoreMenu } from './MoreMenu';
function formatDiscordMemberCount(count: number): string {
if (count >= 10_000) return `${Math.floor(count / 1000)}k`;
if (count >= 1000) return `${Math.floor(count / 100) / 10}k`;
return String(count);
}
export const TabBarActions = (): React.JSX.Element => {
const { t } = useAppTranslation('common');
const {
@ -47,6 +53,7 @@ export const TabBarActions = (): React.JSX.Element => {
const [discordHover, setDiscordHover] = useState(false);
const [expandHover, setExpandHover] = useState(false);
const [updateHover, setUpdateHover] = useState(false);
const [discordMemberCount, setDiscordMemberCount] = useState<number | null>(null);
// Derive active tab and session detail for MoreMenu
const activeTab = useMemo(
@ -56,6 +63,31 @@ export const TabBarActions = (): React.JSX.Element => {
const activeTabSessionDetail = activeTabId
? (tabSessionData[activeTabId]?.sessionDetail ?? null)
: null;
const discordTooltip =
discordMemberCount !== null
? `${t('layout.discord')} - ${discordMemberCount} members`
: t('layout.discord');
useEffect(() => {
const api = window.electronAPI;
if (!api?.getDiscordMemberCount) return;
let cancelled = false;
void api
.getDiscordMemberCount()
.then(({ count }) => {
if (!cancelled && typeof count === 'number' && Number.isFinite(count)) {
setDiscordMemberCount(count);
}
})
.catch(() => {
// The Discord button stays usable if the public invite count is unavailable.
});
return () => {
cancelled = true;
};
}, []);
return (
<div
@ -163,19 +195,24 @@ export const TabBarActions = (): React.JSX.Element => {
}}
onMouseEnter={() => setDiscordHover(true)}
onMouseLeave={() => setDiscordHover(false)}
className="rounded-md p-2 transition-colors"
className="relative rounded-md p-2 transition-colors"
style={{
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
}}
aria-label={t('layout.discord')}
aria-label={discordTooltip}
>
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.3698A19.791 19.791 0 0 0 15.4319 3.0a13.873 13.873 0 0 0-.6242 1.2757 18.27 18.27 0 0 0-5.6154 0A13.872 13.872 0 0 0 8.5681 3 19.736 19.736 0 0 0 3.683 4.3698C.5334 9.1048-.319 13.7216.099 18.272a19.9 19.9 0 0 0 6.0892 3.1157 14.96 14.96 0 0 0 1.303-2.1356 12.46 12.46 0 0 1-1.9352-.9351c.1624-.1218.3217-.2462.4763-.3736 3.7294 1.7014 7.772 1.7014 11.4572 0 .1546.1274.3139.2518.4763.3736-.6163.3622-1.2638.6754-1.9352.9351.3654.7439.8041 1.4554 1.303 2.1356A19.9 19.9 0 0 0 23.901 18.272c.5003-5.2737-.8381-9.8482-3.584-13.9022ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Zm7.96 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Z" />
</svg>
{discordMemberCount !== null && (
<span className="pointer-events-none absolute -right-1.5 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#5865F2] px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-1 ring-[var(--color-surface-sidebar)]">
{formatDiscordMemberCount(discordMemberCount)}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('layout.discord')}</TooltipContent>
<TooltipContent side="bottom">{discordTooltip}</TooltipContent>
</Tooltip>
{/* More menu (Teams, Settings, Extensions, Search, Schedules, Docs, Export, Analyze) */}

View file

@ -922,6 +922,7 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
) => Promise<{ success: boolean; error?: string }>;
showInFolder: (filePath: string) => Promise<void>;
openExternal: (url: string) => Promise<{ success: boolean; error?: string }>;
getDiscordMemberCount: () => Promise<{ count: number | null; error?: string }>;
// Window controls (when title bar is hidden, e.g. Windows / Linux)
windowControls: {