feat(discord): show server member count badge
This commit is contained in:
parent
7c0e3db0b7
commit
c1112872b1
5 changed files with 124 additions and 9 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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> => {},
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue