Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,234 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MessagingPlatformInfo } from '@/types/hermes'
|
||||
|
||||
const getMessagingPlatforms = vi.fn()
|
||||
const updateMessagingPlatform = vi.fn()
|
||||
const getPairing = vi.fn()
|
||||
const approvePairing = vi.fn()
|
||||
const revokePairing = vi.fn()
|
||||
const openExternalLink = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
approvePairing: (platformId: string, requestId: string, profile?: null | string) =>
|
||||
approvePairing(platformId, requestId, profile),
|
||||
getMessagingPlatforms: (profile?: null | string) => getMessagingPlatforms(profile),
|
||||
getPairing: (profile?: null | string) => getPairing(profile),
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
revokePairing: (platformId: string, userId: string, profile?: null | string) =>
|
||||
revokePairing(platformId, userId, profile),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
updateMessagingPlatform: (id: string, body: unknown, profile?: null | string) =>
|
||||
updateMessagingPlatform(id, body, profile)
|
||||
}))
|
||||
|
||||
// Keep store/profile's side-effecting imports inert (pulled in via the shared
|
||||
// settings scope store) — same seam as store/profile.test.ts.
|
||||
vi.mock('@/store/gateway', () => ({
|
||||
$gateway: { get: () => null, subscribe: () => () => {} },
|
||||
ensureGatewayForAgent: vi.fn(async () => undefined),
|
||||
ensureGatewayForProfile: vi.fn(async () => undefined),
|
||||
openGatewayForProfile: vi.fn(async () => undefined)
|
||||
}))
|
||||
vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }))
|
||||
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/external-link', () => ({
|
||||
openExternalLink: (href: string) => openExternalLink(href)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/system-actions', () => ({
|
||||
runGatewayRestart: vi.fn()
|
||||
}))
|
||||
|
||||
function platform(patch: Partial<MessagingPlatformInfo> = {}): MessagingPlatformInfo {
|
||||
return {
|
||||
configured: false,
|
||||
description: 'A platform.',
|
||||
docs_url: '',
|
||||
enabled: false,
|
||||
env_vars: [],
|
||||
gateway_running: true,
|
||||
id: 'teams',
|
||||
name: 'Microsoft Teams',
|
||||
state: 'disabled',
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateMessagingPlatform.mockResolvedValue({ ok: true, platform: 'teams' })
|
||||
getPairing.mockResolvedValue({ approved: [], pending: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderMessaging() {
|
||||
const { MessagingView } = await import('./index')
|
||||
let result: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<MemoryRouter>
|
||||
<MessagingView />
|
||||
</MemoryRouter>
|
||||
)
|
||||
})
|
||||
|
||||
return result!
|
||||
}
|
||||
|
||||
describe('MessagingView profile scope', () => {
|
||||
it('follows the active profile instead of targeting primary when there is no override', async () => {
|
||||
const { $settingsScopeOverride } = await import('@/store/settings-scope')
|
||||
|
||||
$settingsScopeOverride.set(null)
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
await waitFor(() => expect(getMessagingPlatforms).toHaveBeenCalledWith(undefined))
|
||||
expect(getPairing).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessagingView setup-guide link', () => {
|
||||
it('hides the setup-guide button for a plugin platform with no docs URL', async () => {
|
||||
// Teams (and other plugin platforms) ship an empty docs_url. Rendering an
|
||||
// anchor with href="" let Electron resolve it to the app's own packaged
|
||||
// index.html and fail with an OS "file not found" dialog. The button must
|
||||
// simply not appear when there is no guide to open.
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform({ docs_url: '' })] })
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Open setup guide')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a real docs URL through the validated external opener', async () => {
|
||||
const docsUrl = 'https://hermes-agent.nousresearch.com/docs/user-guide/messaging/teams'
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform({ docs_url: docsUrl })] })
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
const link = await screen.findByText('Open setup guide')
|
||||
await act(async () => {
|
||||
fireEvent.click(link)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessagingView pairing', () => {
|
||||
const pendingUser = {
|
||||
age_minutes: 3,
|
||||
platform: 'teams',
|
||||
request_id: 'a1b2c3d4e5f60718',
|
||||
user_id: '7712345',
|
||||
user_name: 'Bee'
|
||||
}
|
||||
|
||||
it('approves the listed request by its request id, never by a code', async () => {
|
||||
// The whole point of the request-id grant path: the UI can only ever send
|
||||
// the server-side row id, because the one-time code is never returned by
|
||||
// the API. Posting anything derived from the code could not be approved.
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
|
||||
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
|
||||
approvePairing.mockResolvedValue({ ok: true, user: { user_id: '7712345', user_name: 'Bee' } })
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
const approve = await screen.findByRole('button', { name: 'Approve' })
|
||||
await act(async () => {
|
||||
fireEvent.click(approve)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(approvePairing).toHaveBeenCalledWith('teams', 'a1b2c3d4e5f60718', undefined))
|
||||
})
|
||||
|
||||
it('restores the pending row when approval fails', async () => {
|
||||
// Optimistic removal must not silently swallow the request: a failed
|
||||
// approve has to leave the operator something to retry.
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
|
||||
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
|
||||
approvePairing.mockRejectedValue(new Error('500 boom'))
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Approve' }))
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
|
||||
expect(screen.getByText('Bee')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no pairing affordance when nobody is waiting', async () => {
|
||||
// Approvals are rare; an always-present empty state would be permanent
|
||||
// chrome on a page that is otherwise about credentials.
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
|
||||
getPairing.mockResolvedValue({ approved: [], pending: [] })
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
|
||||
expect(screen.queryByText(/Pending requests/)).toBeNull()
|
||||
})
|
||||
|
||||
it('still renders platforms when the pairing endpoint fails', async () => {
|
||||
// An older backend without the endpoint must not blank the page.
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
|
||||
getPairing.mockRejectedValue(new Error('404 not found'))
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
|
||||
})
|
||||
|
||||
it('refetches pending rows on pairing.changed, not on platforms.changed', async () => {
|
||||
// The two signals are not interchangeable: platforms.changed tracks
|
||||
// connect/disconnect health via gateway_state.json, which a new pairing
|
||||
// request never moves. Riding it would leave someone invisible in the
|
||||
// pending list until an unrelated reconnect happened to fire.
|
||||
const { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } = await import('@/store/live-sync')
|
||||
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
|
||||
getPairing.mockResolvedValue({ approved: [], pending: [] })
|
||||
|
||||
await renderMessaging()
|
||||
await act(async () => {
|
||||
$changeEventsAvailable.set(true)
|
||||
})
|
||||
getPairing.mockClear()
|
||||
|
||||
// Someone DMs the bot: the store moves, the watcher ticks pairing.changed.
|
||||
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
|
||||
await act(async () => {
|
||||
$pairingChangeTick.set($pairingChangeTick.get() + 1)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(getPairing).toHaveBeenCalled())
|
||||
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
|
||||
|
||||
// A platform health tick alone must not be what fetches pairing.
|
||||
getPairing.mockClear()
|
||||
await act(async () => {
|
||||
$platformsChangeTick.set($platformsChangeTick.get() + 1)
|
||||
})
|
||||
expect(getPairing).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,962 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { ErrorBanner } from '@/components/ui/error-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import {
|
||||
approvePairing,
|
||||
getMessagingPlatforms,
|
||||
getPairing,
|
||||
type MessagingEnvVarInfo,
|
||||
type MessagingPlatformInfo,
|
||||
type PairingUser,
|
||||
revokePairing,
|
||||
updateMessagingPlatform
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
import { ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } from '@/store/live-sync'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $settingsRequestProfile } from '@/store/settings-scope'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { DetailColumn, ListColumn, MasterDetail } from '../master-detail'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
|
||||
import { ListRow } from '../settings/primitives'
|
||||
import { SettingsProfileScope } from '../settings/profile-scope'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { PlatformAvatar } from './platform-icon'
|
||||
|
||||
interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
type EditMap = Record<string, Record<string, string>>
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
|
||||
state ? m.states[state] || state.replace(/_/g, ' ') : m.unknown
|
||||
|
||||
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
|
||||
if (!enabled) {
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
if (state === 'connected') {
|
||||
return 'good'
|
||||
}
|
||||
|
||||
if (state === 'fatal' || state === 'startup_failed') {
|
||||
return 'bad'
|
||||
}
|
||||
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
const trimEdits = (edits: Record<string, string>): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(edits)
|
||||
.map(([k, v]) => [k, v.trim()])
|
||||
.filter(([, v]) => v)
|
||||
)
|
||||
|
||||
/** Stable row identity: a user id is only unique within its platform. */
|
||||
const pairingKey = (user: PairingUser) => `${user.platform}:${user.user_id}`
|
||||
|
||||
const pairingLabel = (user: PairingUser) => user.user_name || user.user_id
|
||||
|
||||
/** Group pairing rows by platform id so a detail pane can slice its own. */
|
||||
function byPlatform(rows: PairingUser[]): Record<string, PairingUser[]> {
|
||||
const grouped: Record<string, PairingUser[]> = {}
|
||||
|
||||
for (const row of rows) {
|
||||
;(grouped[row.platform] ||= []).push(row)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
const FIELD_COPY: Record<string, { advanced?: boolean }> = {
|
||||
TELEGRAM_PROXY: { advanced: true },
|
||||
DISCORD_REPLY_TO_MODE: { advanced: true },
|
||||
DISCORD_ALLOW_ALL_USERS: { advanced: true },
|
||||
DISCORD_HOME_CHANNEL: { advanced: true },
|
||||
DISCORD_HOME_CHANNEL_NAME: { advanced: true },
|
||||
BLUEBUBBLES_ALLOW_ALL_USERS: { advanced: true },
|
||||
MATTERMOST_ALLOW_ALL_USERS: { advanced: true },
|
||||
MATTERMOST_HOME_CHANNEL: { advanced: true },
|
||||
QQ_ALLOW_ALL_USERS: { advanced: true },
|
||||
QQBOT_HOME_CHANNEL: { advanced: true },
|
||||
QQBOT_HOME_CHANNEL_NAME: { advanced: true },
|
||||
WHATSAPP_ENABLED: { advanced: true },
|
||||
WHATSAPP_MODE: { advanced: true }
|
||||
}
|
||||
|
||||
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
|
||||
const copy = FIELD_COPY[field.key] || {}
|
||||
const localized = m.fieldCopy[field.key] || {}
|
||||
|
||||
return {
|
||||
label: localized.label || field.prompt || field.key,
|
||||
help: localized.help || field.description,
|
||||
placeholder: localized.placeholder || field.prompt,
|
||||
advanced: Boolean(copy.advanced || field.advanced)
|
||||
}
|
||||
}
|
||||
|
||||
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
// Shared settings "Applies to" scope, request-shaped (undefined → follow
|
||||
// the active profile; the API helpers treat null as "target primary").
|
||||
const scopeProfile = useStore($settingsRequestProfile)
|
||||
// Both save/toggle toasts offer the same one-click restart.
|
||||
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
|
||||
|
||||
const [pairing, setPairing] = useState<{ approved: PairingUser[]; pending: PairingUser[] }>({
|
||||
approved: [],
|
||||
pending: []
|
||||
})
|
||||
|
||||
const [approving, setApproving] = useState<null | string>(null)
|
||||
const [pendingRevoke, setPendingRevoke] = useState<null | PairingUser>(null)
|
||||
const [edits, setEdits] = useState<EditMap>({})
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
|
||||
const [selectedId, setSelectedId] = useRouteEnumParam('platform', platformIds, platformIds[0] ?? '')
|
||||
|
||||
const refreshPlatforms = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!silent) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getMessagingPlatforms(scopeProfile)
|
||||
setPlatforms(result.platforms)
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, m.loadFailed)
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[m, scopeProfile]
|
||||
)
|
||||
|
||||
// Pairing has its own signal. platforms.changed tracks connect/disconnect
|
||||
// health via gateway_state.json, which a new pairing request never moves —
|
||||
// riding it would leave a pending row invisible until something unrelated
|
||||
// reconnected. Failures stay silent: an older backend without the endpoint
|
||||
// should show no rows, not an error banner over a working page.
|
||||
const refreshPairing = useCallback(async () => {
|
||||
try {
|
||||
const result = await getPairing(scopeProfile)
|
||||
setPairing({ approved: result.approved ?? [], pending: result.pending ?? [] })
|
||||
} catch {
|
||||
// Leave the last known rows in place rather than blanking them.
|
||||
}
|
||||
}, [scopeProfile])
|
||||
|
||||
const refreshAll = useCallback(
|
||||
async (silent = false) => {
|
||||
await Promise.all([refreshPlatforms(silent), refreshPairing()])
|
||||
},
|
||||
[refreshPairing, refreshPlatforms]
|
||||
)
|
||||
|
||||
useRefreshHotkey(() => void refreshAll())
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAll()
|
||||
}, [refreshAll])
|
||||
|
||||
// Scope switch: the mounted list still shows the PREVIOUS profile's
|
||||
// platforms/pairing while the new fetch is in flight — blank it so stale
|
||||
// rows can't be toggled against the wrong backend.
|
||||
const scopeSeenRef = useRef(scopeProfile)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (scope-change guard)
|
||||
useEffect(() => {
|
||||
if (scopeSeenRef.current === scopeProfile) {
|
||||
return
|
||||
}
|
||||
|
||||
scopeSeenRef.current = scopeProfile
|
||||
setPlatforms(null)
|
||||
setPairing({ approved: [], pending: [] })
|
||||
setEdits({})
|
||||
}, [scopeProfile])
|
||||
|
||||
const changeEventsAvailable = useStore($changeEventsAvailable)
|
||||
const platformsChangeTick = useStore($platformsChangeTick)
|
||||
const pairingChangeTick = useStore($pairingChangeTick)
|
||||
|
||||
// A new pending request (or a grant from another surface) moves the pairing
|
||||
// store on disk; the change watcher turns that into pairing.changed.
|
||||
useEffect(() => {
|
||||
if (!changeEventsAvailable || pairingChangeTick === 0 || document.hidden) {
|
||||
return
|
||||
}
|
||||
|
||||
void refreshPairing()
|
||||
}, [changeEventsAvailable, pairingChangeTick, refreshPairing])
|
||||
|
||||
// Connection status updates without a manual "check" click. platforms.changed
|
||||
// (the gateway persisting connect/disconnect/health to gateway_state.json)
|
||||
// drives the refresh on event-capable backends — no timer; older backends
|
||||
// keep the legacy visible-tab poll.
|
||||
useEffect(() => {
|
||||
if (!changeEventsAvailable || platformsChangeTick === 0 || document.hidden) {
|
||||
return
|
||||
}
|
||||
|
||||
void refreshPlatforms(true)
|
||||
}, [changeEventsAvailable, platformsChangeTick, refreshPlatforms])
|
||||
|
||||
useEffect(() => {
|
||||
if (changeEventsAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
function tick() {
|
||||
if (cancelled || document.hidden) {
|
||||
return
|
||||
}
|
||||
|
||||
void refreshAll(true)
|
||||
}
|
||||
|
||||
const id = window.setInterval(tick, 6000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [changeEventsAvailable, refreshAll])
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!platforms) {
|
||||
return null
|
||||
}
|
||||
|
||||
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
|
||||
}, [platforms, selectedId])
|
||||
|
||||
const pendingByPlatform = useMemo(() => byPlatform(pairing.pending), [pairing.pending])
|
||||
const approvedByPlatform = useMemo(() => byPlatform(pairing.approved), [pairing.approved])
|
||||
|
||||
const visiblePlatforms = useMemo(() => {
|
||||
if (!platforms) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = normalize(query)
|
||||
|
||||
if (!q) {
|
||||
return platforms
|
||||
}
|
||||
|
||||
return platforms.filter(platform =>
|
||||
[platform.id, platform.name, platform.description, platform.state]
|
||||
.filter(Boolean)
|
||||
.some(value => String(value).toLowerCase().includes(q))
|
||||
)
|
||||
}, [platforms, query])
|
||||
|
||||
async function handleToggle(platform: MessagingPlatformInfo, enabled: boolean) {
|
||||
setSaving(`enabled:${platform.id}`)
|
||||
|
||||
try {
|
||||
await updateMessagingPlatform(platform.id, { enabled }, scopeProfile)
|
||||
setPlatforms(
|
||||
current =>
|
||||
current?.map(row =>
|
||||
row.id === platform.id
|
||||
? {
|
||||
...row,
|
||||
enabled,
|
||||
state: enabled ? (row.configured ? 'pending_restart' : 'not_configured') : 'disabled'
|
||||
}
|
||||
: row
|
||||
) ?? current
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
|
||||
message: m.restartToApply,
|
||||
action: restartGatewayAction
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedUpdate(platform.name))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(platform: MessagingPlatformInfo) {
|
||||
const env = trimEdits(edits[platform.id] || {})
|
||||
|
||||
if (Object.keys(env).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(`env:${platform.id}`)
|
||||
|
||||
try {
|
||||
await updateMessagingPlatform(platform.id, { env }, scopeProfile)
|
||||
setEdits(current => ({ ...current, [platform.id]: {} }))
|
||||
await refreshPlatforms()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: m.setupSaved(platform.name),
|
||||
message: m.restartToReconnect,
|
||||
action: restartGatewayAction
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedSave(platform.name))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(platform: MessagingPlatformInfo, key: string) {
|
||||
setSaving(`clear:${key}`)
|
||||
|
||||
try {
|
||||
await updateMessagingPlatform(platform.id, { clear_env: [key] }, scopeProfile)
|
||||
setEdits(current => ({
|
||||
...current,
|
||||
[platform.id]: {
|
||||
...(current[platform.id] || {}),
|
||||
[key]: ''
|
||||
}
|
||||
}))
|
||||
await refreshPlatforms()
|
||||
notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedClear(key))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Approve/revoke paint from a snapshot immediately, then let the
|
||||
// authoritative refresh have the last word. A failed write restores the
|
||||
// snapshot so the row never silently disappears on an error.
|
||||
async function handleApprove(user: PairingUser) {
|
||||
if (!user.request_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = pairingKey(user)
|
||||
const snapshot = pairing
|
||||
setApproving(key)
|
||||
setPairing(current => ({
|
||||
approved: current.approved,
|
||||
pending: current.pending.filter(row => pairingKey(row) !== key)
|
||||
}))
|
||||
|
||||
try {
|
||||
await approvePairing(user.platform, user.request_id, scopeProfile)
|
||||
notify({ kind: 'success', title: m.approvedUser(pairingLabel(user)), message: m.approvedHint })
|
||||
await refreshPairing()
|
||||
} catch (err) {
|
||||
setPairing(snapshot)
|
||||
// 429 is the code path's brute-force lockout — a distinct condition the
|
||||
// operator can only wait out, so it gets its own message.
|
||||
const lockedOut = err instanceof Error && err.message.includes('429')
|
||||
notifyError(err, lockedOut ? m.pairingLockedOut : m.failedApprove(pairingLabel(user)))
|
||||
} finally {
|
||||
setApproving(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ConfirmDialog owns the pending → done → close beat and shows an inline
|
||||
// error when onConfirm throws, so this rethrows instead of swallowing.
|
||||
async function handleRevoke(user: PairingUser) {
|
||||
const key = pairingKey(user)
|
||||
const snapshot = pairing
|
||||
setPairing(current => ({
|
||||
approved: current.approved.filter(row => pairingKey(row) !== key),
|
||||
pending: current.pending
|
||||
}))
|
||||
|
||||
try {
|
||||
await revokePairing(user.platform, user.user_id, scopeProfile)
|
||||
notify({ kind: 'success', title: m.revokedUser(pairingLabel(user)), message: user.platform })
|
||||
await refreshPairing()
|
||||
} catch (err) {
|
||||
setPairing(snapshot)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={(platforms?.length ?? 0) === 0}
|
||||
searchHints={platforms?.slice(0, 5).map(platform => t.common.tryHint(platform.name.toLowerCase()))}
|
||||
searchPlaceholder={m.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{!platforms ? (
|
||||
<PageLoader label={m.loading} />
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Which profile's gateway this page configures (hidden for
|
||||
single-profile users). */}
|
||||
<SettingsProfileScope className="border-b border-(--ui-stroke-secondary) px-3 py-2" />
|
||||
<div className="min-h-0 flex-1">
|
||||
<MasterDetail>
|
||||
<ListColumn>
|
||||
<ul className="space-y-1">
|
||||
{visiblePlatforms.map(platform => (
|
||||
<li key={platform.id}>
|
||||
<PlatformRow
|
||||
active={selected?.id === platform.id}
|
||||
onSelect={() => setSelectedId(platform.id)}
|
||||
pendingCount={pendingByPlatform[platform.id]?.length ?? 0}
|
||||
platform={platform}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ListColumn>
|
||||
|
||||
<DetailColumn
|
||||
actionBar={
|
||||
selected && (
|
||||
<PlatformActionBar
|
||||
hasEdits={Object.keys(trimEdits(edits[selected.id] || {})).length > 0}
|
||||
onSave={() => void handleSave(selected)}
|
||||
onToggle={enabled => void handleToggle(selected, enabled)}
|
||||
platform={selected}
|
||||
saving={saving}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected && (
|
||||
<PlatformDetail
|
||||
approved={approvedByPlatform[selected.id] ?? []}
|
||||
approving={approving}
|
||||
edits={edits[selected.id] || {}}
|
||||
onApprove={user => void handleApprove(user)}
|
||||
onClear={key => void handleClear(selected, key)}
|
||||
onEdit={(key, value) =>
|
||||
setEdits(current => ({
|
||||
...current,
|
||||
[selected.id]: {
|
||||
...(current[selected.id] || {}),
|
||||
[key]: value
|
||||
}
|
||||
}))
|
||||
}
|
||||
onRevoke={setPendingRevoke}
|
||||
pending={pendingByPlatform[selected.id] ?? []}
|
||||
platform={selected}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</DetailColumn>
|
||||
</MasterDetail>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
busyLabel={m.revoking}
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={m.revoke}
|
||||
description={pendingRevoke ? m.revokeDesc(pairingLabel(pendingRevoke)) : null}
|
||||
destructive
|
||||
onClose={() => setPendingRevoke(null)}
|
||||
onConfirm={() => (pendingRevoke ? handleRevoke(pendingRevoke) : undefined)}
|
||||
open={Boolean(pendingRevoke)}
|
||||
title={m.revokeTitle}
|
||||
/>
|
||||
</PageSearchShell>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformRow({
|
||||
active,
|
||||
onSelect,
|
||||
pendingCount,
|
||||
platform
|
||||
}: {
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
pendingCount: number
|
||||
platform: MessagingPlatformInfo
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'row-hover flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:text-foreground',
|
||||
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{/* Someone is waiting to be let in — the only way this page tells
|
||||
you so before you open the platform. */}
|
||||
{pendingCount > 0 && (
|
||||
<span
|
||||
aria-label={t.messaging.pendingAria(pendingCount)}
|
||||
className={cn(
|
||||
'inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[0.66rem] font-medium tabular-nums',
|
||||
PILL_TONE.warn
|
||||
)}
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
<StatusDot tone={stateTone(platform)} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformDetail({
|
||||
approved,
|
||||
approving,
|
||||
edits,
|
||||
onApprove,
|
||||
onClear,
|
||||
onEdit,
|
||||
onRevoke,
|
||||
pending,
|
||||
platform,
|
||||
saving
|
||||
}: {
|
||||
approved: PairingUser[]
|
||||
approving: null | string
|
||||
edits: Record<string, string>
|
||||
onApprove: (user: PairingUser) => void
|
||||
onClear: (key: string) => void
|
||||
onEdit: (key: string, value: string) => void
|
||||
onRevoke: (user: PairingUser) => void
|
||||
pending: PairingUser[]
|
||||
platform: MessagingPlatformInfo
|
||||
saving: string | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const requiredFields = platform.env_vars.filter(field => field.required)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
|
||||
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
|
||||
const hiddenCount = advancedFields.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-start gap-3">
|
||||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
|
||||
{/* Resting states earn no pill — only actionable ones. */}
|
||||
{!platform.configured && <SetupPill active={false}>{m.needsSetup}</SetupPill>}
|
||||
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
|
||||
</div>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{platform.description}
|
||||
</p>
|
||||
<PlatformHint platform={platform} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{platform.error_message && <ErrorBanner>{platform.error_message}</ErrorBanner>}
|
||||
|
||||
{/* Pending pairing requests. Rendered only when someone is actually
|
||||
waiting — an empty-state card here would be permanent chrome on a
|
||||
page that is usually about credentials, not approvals. */}
|
||||
{pending.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>{m.pendingRequests(pending.length)}</SectionTitle>
|
||||
<div className="mt-1 grid gap-1">
|
||||
{pending.map(user => {
|
||||
const busy = approving === pairingKey(user)
|
||||
const waited = typeof user.age_minutes === 'number' ? m.waitingSince(user.age_minutes) : null
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Button
|
||||
disabled={busy || !user.request_id}
|
||||
onClick={() => onApprove(user)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
{busy ? m.approving : m.approve}
|
||||
</Button>
|
||||
}
|
||||
// An unnamed requester is only a user id — showing it as
|
||||
// both title and description just repeats itself.
|
||||
description={[user.user_name ? user.user_id : null, waited].filter(Boolean).join(' · ')}
|
||||
key={pairingKey(user)}
|
||||
title={pairingLabel(user)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{approved.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>{m.approvedUsers(approved.length)}</SectionTitle>
|
||||
<div className="mt-1 grid gap-1">
|
||||
{approved.map(user => (
|
||||
<ListRow
|
||||
action={
|
||||
<Button
|
||||
aria-label={m.revokeAria(pairingLabel(user))}
|
||||
onClick={() => onRevoke(user)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{m.revoke}
|
||||
</Button>
|
||||
}
|
||||
description={user.user_name ? user.user_id : undefined}
|
||||
key={pairingKey(user)}
|
||||
title={pairingLabel(user)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionTitle>{m.getCredentials}</SectionTitle>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{introCopy(platform, m)}
|
||||
</p>
|
||||
{platform.docs_url && (
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a
|
||||
href={platform.docs_url}
|
||||
onClick={event => {
|
||||
// Route through the validated external opener instead of
|
||||
// letting Electron resolve the anchor. A packaged build's
|
||||
// empty/relative href resolves to the app's own
|
||||
// index.html file path, which shell.openPath then fails to
|
||||
// open ("file not found"). Plugin platforms (Teams, etc.)
|
||||
// ship no docs_url, so this guard + handler keeps the
|
||||
// button from ever pointing at a local bundle path.
|
||||
event.preventDefault()
|
||||
openExternalLink(platform.docs_url)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{m.openSetupGuide}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>{m.required}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{requiredFields.length > 0 ? (
|
||||
requiredFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{m.noTokenNeeded}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>{m.recommended}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<section>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setShowAdvanced(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>{m.advanced(hiddenCount)}</span>
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 grid gap-1">
|
||||
{advancedFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformActionBar({
|
||||
hasEdits,
|
||||
onSave,
|
||||
onToggle,
|
||||
platform,
|
||||
saving
|
||||
}: {
|
||||
hasEdits: boolean
|
||||
onSave: () => void
|
||||
onToggle: (enabled: boolean) => void
|
||||
platform: MessagingPlatformInfo
|
||||
saving: string | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const isSavingEnv = saving === `env:${platform.id}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
|
||||
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
|
||||
<Save />
|
||||
{isSavingEnv ? m.saving : m.saveChanges}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PLATFORM_INTRO: Record<string, string> = {
|
||||
telegram:
|
||||
'In Telegram, talk to @BotFather, run /newbot, and copy the token it gives you. Then grab your numeric user ID from @userinfobot.',
|
||||
discord:
|
||||
'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.',
|
||||
slack:
|
||||
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the bot token and app-level token.',
|
||||
mattermost:
|
||||
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
|
||||
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
|
||||
signal:
|
||||
'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
|
||||
whatsapp:
|
||||
'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.',
|
||||
bluebubbles:
|
||||
'Run BlueBubbles Server on a Mac with iMessage, expose its API, then point Hermes at the URL with the server password.',
|
||||
homeassistant:
|
||||
'In Home Assistant, open your profile and create a long-lived access token. Paste it here along with your HA URL.',
|
||||
email:
|
||||
'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.',
|
||||
sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.',
|
||||
dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
|
||||
feishu:
|
||||
'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.',
|
||||
wecom:
|
||||
'Add a group robot in WeCom and copy its webhook key as WECOM_BOT_ID. Send-only — use the WeCom (app) option for two-way.',
|
||||
wecom_callback:
|
||||
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
|
||||
weixin:
|
||||
"Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent's iLink Bot API and saves the credentials.",
|
||||
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
|
||||
api_server:
|
||||
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
|
||||
webhook:
|
||||
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
|
||||
}
|
||||
|
||||
const introCopy = (platform: MessagingPlatformInfo, m: Translations['messaging']) =>
|
||||
m.platformIntro[platform.id] || PLATFORM_INTRO[platform.id] || platform.description
|
||||
|
||||
function MessagingField({
|
||||
edits,
|
||||
field,
|
||||
onClear,
|
||||
onEdit,
|
||||
saving
|
||||
}: {
|
||||
edits: Record<string, string>
|
||||
field: MessagingEnvVarInfo
|
||||
onClear: (key: string) => void
|
||||
onEdit: (key: string, value: string) => void
|
||||
saving: string | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const copy = fieldCopy(field, m)
|
||||
const fieldId = `messaging-field-${field.key}`
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className={CREDENTIAL_CONTROL_CLASS}
|
||||
id={fieldId}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || m.replaceValue : copy.placeholder}
|
||||
type={field.is_password ? 'password' : 'text'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Tip label={m.openDocs}>
|
||||
<Button asChild className="size-8 shrink-0" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
{field.is_set && (
|
||||
<Tip label={m.clearField(field.key)}>
|
||||
<Button
|
||||
className="size-8 shrink-0"
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={copy.help}
|
||||
title={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor={fieldId}>{copy.label}</label>
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">{m.saved}</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
|
||||
}
|
||||
|
||||
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!platform.enabled || platform.state === 'connected') {
|
||||
return null
|
||||
}
|
||||
|
||||
const hint =
|
||||
platform.state === 'pending_restart'
|
||||
? t.messaging.hintPendingRestart
|
||||
: platform.gateway_running
|
||||
? null
|
||||
: t.messaging.hintGatewayStopped
|
||||
|
||||
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[tone]
|
||||
)}
|
||||
>
|
||||
<StatusDot tone={tone} />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[active ? 'good' : 'muted']
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
SiApple,
|
||||
SiBilibili,
|
||||
SiDiscord,
|
||||
SiGmail,
|
||||
SiHomeassistant,
|
||||
SiMatrix,
|
||||
SiMattermost,
|
||||
SiQq,
|
||||
SiSignal,
|
||||
SiTelegram,
|
||||
SiWechat,
|
||||
SiWhatsapp
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
import type { ComponentPropsWithoutRef, ComponentType, SVGProps } from 'react'
|
||||
import { forwardRef, memo } from 'react'
|
||||
|
||||
import { AvatarChip } from '@/components/ui/avatar-chip'
|
||||
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Photon brand icon — three diagonal rounded bars (the Photon logo mark).
|
||||
// Rendered at ~14 px inside the PlatformAvatar so the bars are kept thick
|
||||
// enough to stay legible. At small sizes the bars blend into a distinctive
|
||||
// silhouette; the wide triangular spacing preserves the logo's identity.
|
||||
// ---------------------------------------------------------------------------
|
||||
function PhotonIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
|
||||
<rect height="10" rx="1.25" transform="rotate(15 14 7.5)" width="2.5" x="12.75" y="2.5" />
|
||||
<rect height="10" rx="1.25" transform="rotate(15 8 13)" width="2.5" x="6.75" y="8" />
|
||||
<rect height="10" rx="1.25" transform="rotate(15 16 18)" width="2.5" x="14.75" y="13" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// We render simpleicons.org brand glyphs for platforms whose owners publish a
|
||||
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
|
||||
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
|
||||
// request, so we fall back to a colored letter monogram for those.
|
||||
//
|
||||
// `iconColor` is the brand's hex from simpleicons.org so we can paint each
|
||||
// glyph in its native color on top of a soft tint. The fallback monogram uses
|
||||
// the same hex to keep visual consistency.
|
||||
type IconKind = 'brand' | 'generic'
|
||||
|
||||
interface PlatformIconSpec {
|
||||
Icon?: ComponentType<SVGProps<SVGSVGElement>>
|
||||
color: string
|
||||
kind: IconKind
|
||||
monogram?: string
|
||||
}
|
||||
|
||||
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
|
||||
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
|
||||
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
|
||||
// Slack removed from Simple Icons by Salesforce request — letter monogram.
|
||||
slack: { color: '#4A154B', kind: 'brand', monogram: 'S' },
|
||||
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
|
||||
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
|
||||
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
|
||||
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
|
||||
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
|
||||
photon: { Icon: PhotonIcon, color: '#6366F1', kind: 'brand' },
|
||||
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
|
||||
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
|
||||
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
|
||||
webhook: { Icon: LinkIcon, color: '#71717A', kind: 'generic' },
|
||||
api_server: { Icon: Globe, color: '#64748B', kind: 'generic' },
|
||||
weixin: { Icon: SiWechat, color: '#07C160', kind: 'brand' },
|
||||
qqbot: { Icon: SiQq, color: '#EB1923', kind: 'brand' },
|
||||
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
|
||||
}
|
||||
|
||||
interface PlatformAvatarProps extends Omit<ComponentPropsWithoutRef<'span'>, 'children'> {
|
||||
platformId: string
|
||||
platformName: string
|
||||
}
|
||||
|
||||
// forwardRef + spreading ...rest is required so a wrapping <Tip> (Radix
|
||||
// Tooltip's `asChild`) can actually attach its trigger: asChild clones this
|
||||
// component and injects a ref plus pointer/focus/aria handlers onto it. A
|
||||
// plain function component with no ref/rest forwarding drops all of that
|
||||
// silently — the tooltip renders but never opens (#67500).
|
||||
export const PlatformAvatar = memo(
|
||||
forwardRef<HTMLSpanElement, PlatformAvatarProps>(function PlatformAvatar(
|
||||
{ className, platformId, platformName, ...rest },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<AvatarChip
|
||||
aria-hidden="true"
|
||||
brand={PLATFORM_ICONS[platformId]}
|
||||
className={className}
|
||||
name={platformName}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)
|
||||
Reference in new issue
Block a user