From c1112872b12c9723b0ceff1295a75c96c4d186ea Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 1 Jun 2026 23:42:33 +0300 Subject: [PATCH] feat(discord): show server member count badge --- src/main/ipc/utility.ts | 82 +++++++++++++++++-- src/preload/index.ts | 1 + src/renderer/api/httpClient.ts | 4 + .../components/layout/TabBarActions.tsx | 45 +++++++++- src/shared/types/api.ts | 1 + 5 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index 182beca9..1de8dd75 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -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 | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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). diff --git a/src/preload/index.ts b/src/preload/index.ts index c921733d..d338db9a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 61522556..ee67e546 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => {}, maximize: async (): Promise => {}, diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx index 41bf7205..bc014a7a 100644 --- a/src/renderer/components/layout/TabBarActions.tsx +++ b/src/renderer/components/layout/TabBarActions.tsx @@ -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(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 (
{ }} 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} > + {discordMemberCount !== null && ( + + {formatDiscordMemberCount(discordMemberCount)} + + )} - {t('layout.discord')} + {discordTooltip} {/* More menu (Teams, Settings, Extensions, Search, Schedules, Docs, Export, Analyze) */} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index babea1bf..2c61f257 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -922,6 +922,7 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec ) => Promise<{ success: boolean; error?: string }>; showInFolder: (filePath: string) => Promise; 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: {