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
|
* - read-mentioned-file: Validates mentioned files for context injection
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AgentConfig } from '@shared/types/api';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
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 * as fsp from 'fs/promises';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,10 +20,6 @@ import {
|
||||||
readAllClaudeMdFiles,
|
readAllClaudeMdFiles,
|
||||||
readDirectoryClaudeMd,
|
readDirectoryClaudeMd,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
|
||||||
import type { AgentConfig } from '@shared/types/api';
|
|
||||||
|
|
||||||
const logger = createLogger('IPC:utility');
|
|
||||||
import {
|
import {
|
||||||
validateFilePath,
|
validateFilePath,
|
||||||
validateOpenPath,
|
validateOpenPath,
|
||||||
|
|
@ -29,6 +27,12 @@ import {
|
||||||
} from '../utils/pathValidation';
|
} from '../utils/pathValidation';
|
||||||
import { countTokens } from '../utils/tokenizer';
|
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.
|
* Registers all utility-related IPC handlers.
|
||||||
*/
|
*/
|
||||||
|
|
@ -37,6 +41,7 @@ export function registerUtilityHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle('shell:openPath', handleShellOpenPath);
|
ipcMain.handle('shell:openPath', handleShellOpenPath);
|
||||||
ipcMain.handle('shell:showInFolder', handleShellShowInFolder);
|
ipcMain.handle('shell:showInFolder', handleShellShowInFolder);
|
||||||
ipcMain.handle('shell:openExternal', handleShellOpenExternal);
|
ipcMain.handle('shell:openExternal', handleShellOpenExternal);
|
||||||
|
ipcMain.handle('discord:getMemberCount', handleGetDiscordMemberCount);
|
||||||
ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles);
|
ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles);
|
||||||
ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd);
|
ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd);
|
||||||
ipcMain.handle('read-mentioned-file', handleReadMentionedFile);
|
ipcMain.handle('read-mentioned-file', handleReadMentionedFile);
|
||||||
|
|
@ -53,6 +58,7 @@ export function removeUtilityHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler('shell:openPath');
|
ipcMain.removeHandler('shell:openPath');
|
||||||
ipcMain.removeHandler('shell:showInFolder');
|
ipcMain.removeHandler('shell:showInFolder');
|
||||||
ipcMain.removeHandler('shell:openExternal');
|
ipcMain.removeHandler('shell:openExternal');
|
||||||
|
ipcMain.removeHandler('discord:getMemberCount');
|
||||||
ipcMain.removeHandler('read-claude-md-files');
|
ipcMain.removeHandler('read-claude-md-files');
|
||||||
ipcMain.removeHandler('read-directory-claude-md');
|
ipcMain.removeHandler('read-directory-claude-md');
|
||||||
ipcMain.removeHandler('read-mentioned-file');
|
ipcMain.removeHandler('read-mentioned-file');
|
||||||
|
|
@ -73,6 +79,72 @@ function handleGetAppVersion(): string {
|
||||||
return app.getVersion();
|
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.
|
* Handler for 'shell:showInFolder' IPC call.
|
||||||
* Reveals a file in the system file manager (Finder/Explorer).
|
* Reveals a file in the system file manager (Finder/Explorer).
|
||||||
|
|
|
||||||
|
|
@ -759,6 +759,7 @@ const electronAPI: ElectronAPI = {
|
||||||
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog),
|
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog),
|
||||||
showInFolder: (filePath: string) => ipcRenderer.invoke('shell:showInFolder', filePath),
|
showInFolder: (filePath: string) => ipcRenderer.invoke('shell:showInFolder', filePath),
|
||||||
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||||
|
getDiscordMemberCount: () => ipcRenderer.invoke('discord:getMemberCount'),
|
||||||
|
|
||||||
// Window controls (when title bar is hidden, e.g. Windows / Linux)
|
// Window controls (when title bar is hidden, e.g. Windows / Linux)
|
||||||
windowControls: {
|
windowControls: {
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getDiscordMemberCount = async (): Promise<{ count: number | null; error?: string }> => {
|
||||||
|
return { count: null, error: 'Not available in browser mode' };
|
||||||
|
};
|
||||||
|
|
||||||
windowControls = {
|
windowControls = {
|
||||||
minimize: async (): Promise<void> => {},
|
minimize: async (): Promise<void> => {},
|
||||||
maximize: async (): Promise<void> => {},
|
maximize: async (): Promise<void> => {},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Reads focused pane data from root store selectors (auto-synced via syncRootState).
|
* 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 { useAppTranslation } from '@features/localization/renderer';
|
||||||
import { isElectronMode } from '@renderer/api';
|
import { isElectronMode } from '@renderer/api';
|
||||||
|
|
@ -15,6 +15,12 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { MoreMenu } from './MoreMenu';
|
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 => {
|
export const TabBarActions = (): React.JSX.Element => {
|
||||||
const { t } = useAppTranslation('common');
|
const { t } = useAppTranslation('common');
|
||||||
const {
|
const {
|
||||||
|
|
@ -47,6 +53,7 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
const [discordHover, setDiscordHover] = useState(false);
|
const [discordHover, setDiscordHover] = useState(false);
|
||||||
const [expandHover, setExpandHover] = useState(false);
|
const [expandHover, setExpandHover] = useState(false);
|
||||||
const [updateHover, setUpdateHover] = useState(false);
|
const [updateHover, setUpdateHover] = useState(false);
|
||||||
|
const [discordMemberCount, setDiscordMemberCount] = useState<number | null>(null);
|
||||||
|
|
||||||
// Derive active tab and session detail for MoreMenu
|
// Derive active tab and session detail for MoreMenu
|
||||||
const activeTab = useMemo(
|
const activeTab = useMemo(
|
||||||
|
|
@ -56,6 +63,31 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
const activeTabSessionDetail = activeTabId
|
const activeTabSessionDetail = activeTabId
|
||||||
? (tabSessionData[activeTabId]?.sessionDetail ?? null)
|
? (tabSessionData[activeTabId]?.sessionDetail ?? null)
|
||||||
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -163,19 +195,24 @@ export const TabBarActions = (): React.JSX.Element => {
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setDiscordHover(true)}
|
onMouseEnter={() => setDiscordHover(true)}
|
||||||
onMouseLeave={() => setDiscordHover(false)}
|
onMouseLeave={() => setDiscordHover(false)}
|
||||||
className="rounded-md p-2 transition-colors"
|
className="relative rounded-md p-2 transition-colors"
|
||||||
style={{
|
style={{
|
||||||
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||||
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
|
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">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">{t('layout.discord')}</TooltipContent>
|
<TooltipContent side="bottom">{discordTooltip}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* More menu (Teams, Settings, Extensions, Search, Schedules, Docs, Export, Analyze) */}
|
{/* 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 }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
showInFolder: (filePath: string) => Promise<void>;
|
showInFolder: (filePath: string) => Promise<void>;
|
||||||
openExternal: (url: string) => Promise<{ success: boolean; error?: string }>;
|
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)
|
// Window controls (when title bar is hidden, e.g. Windows / Linux)
|
||||||
windowControls: {
|
windowControls: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue