@gitping/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +77262 -0
- package/dist/keytar-337c03ca19a5bf84.node +0 -0
- package/package.json +34 -0
- package/src/api/client.ts +53 -0
- package/src/api/pings.ts +79 -0
- package/src/api/threads.ts +14 -0
- package/src/api/users.ts +43 -0
- package/src/auth/keychain.ts +28 -0
- package/src/auth/pkce.ts +24 -0
- package/src/commands/accept.ts +43 -0
- package/src/commands/block.ts +48 -0
- package/src/commands/chat.ts +181 -0
- package/src/commands/ignore.ts +43 -0
- package/src/commands/inbox.ts +98 -0
- package/src/commands/leaderboard.ts +51 -0
- package/src/commands/login.ts +117 -0
- package/src/commands/logout.ts +29 -0
- package/src/commands/ping.ts +57 -0
- package/src/commands/search.ts +64 -0
- package/src/commands/status.ts +116 -0
- package/src/commands/whoami.ts +57 -0
- package/src/config.ts +39 -0
- package/src/index.ts +147 -0
- package/src/ui/ChatView.tsx +195 -0
- package/src/ui/InboxWatch.tsx +134 -0
- package/tsconfig.json +21 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import { loginCommand } from './commands/login.ts'
|
|
4
|
+
import { logoutCommand } from './commands/logout.ts'
|
|
5
|
+
import { whoamiCommand } from './commands/whoami.ts'
|
|
6
|
+
import { inboxCommand } from './commands/inbox.ts'
|
|
7
|
+
import { pingCommand } from './commands/ping.ts'
|
|
8
|
+
import { acceptCommand } from './commands/accept.ts'
|
|
9
|
+
import { ignoreCommand } from './commands/ignore.ts'
|
|
10
|
+
import { blockCommand } from './commands/block.ts'
|
|
11
|
+
import { chatCommand } from './commands/chat.ts'
|
|
12
|
+
import { statusCommand } from './commands/status.ts'
|
|
13
|
+
import { searchCommand } from './commands/search.ts'
|
|
14
|
+
import { leaderboardCommand } from './commands/leaderboard.ts'
|
|
15
|
+
|
|
16
|
+
const program = new Command()
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name('gitping')
|
|
20
|
+
.description('Contact any developer using their GitHub identity')
|
|
21
|
+
.version('0.0.1')
|
|
22
|
+
.action(() => {
|
|
23
|
+
program.help()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// ─── Auth ───────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command('login')
|
|
30
|
+
.description('Authenticate with GitHub (opens browser)')
|
|
31
|
+
.option('--json', 'output as JSON')
|
|
32
|
+
.action(withExitCode(loginCommand))
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command('logout')
|
|
36
|
+
.description('Clear stored credentials and config')
|
|
37
|
+
.option('--json', 'output as JSON')
|
|
38
|
+
.action(withExitCode(logoutCommand))
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command('whoami')
|
|
42
|
+
.description('Show your profile, rep score, and availability')
|
|
43
|
+
.option('--json', 'output as JSON')
|
|
44
|
+
.action(withExitCode(whoamiCommand))
|
|
45
|
+
|
|
46
|
+
// ─── Inbox ───────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
program
|
|
49
|
+
.command('inbox')
|
|
50
|
+
.description('List your received pings')
|
|
51
|
+
.option('--unread', 'show unread pings only')
|
|
52
|
+
.option('--watch', 'live mode via WebSocket')
|
|
53
|
+
.option('--json', 'output as JSON')
|
|
54
|
+
.action(withExitCode(inboxCommand))
|
|
55
|
+
|
|
56
|
+
// ─── Ping ────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
program
|
|
59
|
+
.command('ping <recipient> <message>')
|
|
60
|
+
.description('Send a ping to a developer (e.g. gitping ping @torvalds "Hey!")')
|
|
61
|
+
.option('--cat <category>', 'category: job | collab | oss | feedback', 'collab')
|
|
62
|
+
.option('--json', 'output as JSON')
|
|
63
|
+
.action(withExitCode(pingCommand))
|
|
64
|
+
|
|
65
|
+
program
|
|
66
|
+
.command('accept <target>')
|
|
67
|
+
.description('Accept a ping by @username or ping-id')
|
|
68
|
+
.option('--json', 'output as JSON')
|
|
69
|
+
.action(withExitCode(acceptCommand))
|
|
70
|
+
|
|
71
|
+
program
|
|
72
|
+
.command('ignore <target>')
|
|
73
|
+
.description('Ignore a ping by @username or ping-id')
|
|
74
|
+
.option('--json', 'output as JSON')
|
|
75
|
+
.action(withExitCode(ignoreCommand))
|
|
76
|
+
|
|
77
|
+
program
|
|
78
|
+
.command('block <target>')
|
|
79
|
+
.description('Block a sender by @username or ping-id')
|
|
80
|
+
.option('--json', 'output as JSON')
|
|
81
|
+
.action(withExitCode(blockCommand))
|
|
82
|
+
|
|
83
|
+
// ─── Chat ────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
program
|
|
86
|
+
.command('chat <recipient>')
|
|
87
|
+
.description('Open an interactive real-time thread with a developer')
|
|
88
|
+
.option('--json', 'output as JSON')
|
|
89
|
+
.action(withExitCode(chatCommand))
|
|
90
|
+
|
|
91
|
+
// ─── Status ──────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
program
|
|
94
|
+
.command('status')
|
|
95
|
+
.description('Show or update your availability status')
|
|
96
|
+
.option(
|
|
97
|
+
'--set <mode>',
|
|
98
|
+
'set availability: open-to-collab | open-to-work | selective | heads-down',
|
|
99
|
+
)
|
|
100
|
+
.option('--message <msg>', 'set a custom status message')
|
|
101
|
+
.option('--json', 'output as JSON')
|
|
102
|
+
.action(withExitCode(statusCommand))
|
|
103
|
+
|
|
104
|
+
// ─── Discovery ───────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
program
|
|
107
|
+
.command('search <username>')
|
|
108
|
+
.description('Look up a developer by GitHub username')
|
|
109
|
+
.option('--json', 'output as JSON')
|
|
110
|
+
.action(withExitCode(searchCommand))
|
|
111
|
+
|
|
112
|
+
program
|
|
113
|
+
.command('leaderboard')
|
|
114
|
+
.description('Show the most pinged developers')
|
|
115
|
+
.option('--rising', 'show developers with the biggest spike this week')
|
|
116
|
+
.option('--json', 'output as JSON')
|
|
117
|
+
.action(withExitCode(leaderboardCommand))
|
|
118
|
+
|
|
119
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
120
|
+
const code = (err as { exitCode?: number }).exitCode ?? 1
|
|
121
|
+
process.exit(code)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Wraps a command handler so that thrown errors with an `exitCode` property
|
|
126
|
+
* cause the process to exit with the correct code.
|
|
127
|
+
* Per AGENTS.md: never call process.exit inside a command handler.
|
|
128
|
+
*/
|
|
129
|
+
function withExitCode<T extends unknown[]>(
|
|
130
|
+
fn: (...args: T) => Promise<void>,
|
|
131
|
+
): (...args: T) => Promise<void> {
|
|
132
|
+
return async (...args: T) => {
|
|
133
|
+
try {
|
|
134
|
+
await fn(...args)
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const code = (err as { exitCode?: number }).exitCode
|
|
137
|
+
if (code !== undefined) {
|
|
138
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
139
|
+
if (code !== 0 && message && message !== 'Not authenticated' && message !== 'Not found') {
|
|
140
|
+
// Most errors already printed by the command, but surface unexpected ones
|
|
141
|
+
}
|
|
142
|
+
process.exit(code)
|
|
143
|
+
}
|
|
144
|
+
throw err
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { Box, Text, useInput, useApp } from 'ink'
|
|
3
|
+
import tty from 'node:tty'
|
|
4
|
+
import WebSocket from 'ws'
|
|
5
|
+
import { getApiBaseUrl } from '../config.ts'
|
|
6
|
+
import { getThread, sendMessage } from '../api/threads.ts'
|
|
7
|
+
import type { Message, WSEvent } from '@gitping/shared'
|
|
8
|
+
import { WSEventType } from '@gitping/shared'
|
|
9
|
+
|
|
10
|
+
interface ChatViewProps {
|
|
11
|
+
threadId: string
|
|
12
|
+
token: string
|
|
13
|
+
myUserId: string
|
|
14
|
+
/** The tty.ReadStream opened on /dev/tty — passed in so we can destroy it on exit. */
|
|
15
|
+
ttyStream: tty.ReadStream
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function ChatView({
|
|
19
|
+
threadId,
|
|
20
|
+
token,
|
|
21
|
+
myUserId,
|
|
22
|
+
ttyStream,
|
|
23
|
+
}: ChatViewProps): React.JSX.Element {
|
|
24
|
+
const { exit } = useApp()
|
|
25
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
26
|
+
const [input, setInput] = useState('')
|
|
27
|
+
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>(
|
|
28
|
+
'connecting',
|
|
29
|
+
)
|
|
30
|
+
const [error, setError] = useState<string | null>(null)
|
|
31
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
32
|
+
|
|
33
|
+
// sendingRef tracks an in-flight send so we can show a visual indicator without
|
|
34
|
+
// blocking keypresses — the useInput handler is kept synchronous to avoid
|
|
35
|
+
// Ink's input lock being held across an await.
|
|
36
|
+
const sendingRef = useRef(false)
|
|
37
|
+
const mountedRef = useRef(true)
|
|
38
|
+
|
|
39
|
+
// Load existing messages on mount
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
getThread(threadId)
|
|
42
|
+
.then(({ messages: msgs }) => {
|
|
43
|
+
if (mountedRef.current) setMessages(msgs)
|
|
44
|
+
})
|
|
45
|
+
.catch((err) => {
|
|
46
|
+
if (mountedRef.current) setError(String(err))
|
|
47
|
+
})
|
|
48
|
+
}, [threadId])
|
|
49
|
+
|
|
50
|
+
// Connect WebSocket
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const baseUrl = getApiBaseUrl().replace(/^http/, 'ws')
|
|
53
|
+
const ws = new WebSocket(`${baseUrl}/v1/ws?token=${encodeURIComponent(token)}`)
|
|
54
|
+
wsRef.current = ws
|
|
55
|
+
|
|
56
|
+
ws.on('open', () => {
|
|
57
|
+
if (mountedRef.current) setStatus('connected')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
ws.on('error', () => {
|
|
61
|
+
if (mountedRef.current) {
|
|
62
|
+
setStatus('error')
|
|
63
|
+
setError('WebSocket connection failed')
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
ws.on('close', () => {
|
|
68
|
+
if (mountedRef.current) setStatus('disconnected')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
ws.on('message', (data) => {
|
|
72
|
+
try {
|
|
73
|
+
const event = JSON.parse(data.toString()) as WSEvent
|
|
74
|
+
if (event.type === WSEventType.MESSAGE_RECEIVED) {
|
|
75
|
+
const msg = event.payload as Message
|
|
76
|
+
if (msg.thread_id === threadId && mountedRef.current) {
|
|
77
|
+
setMessages((prev) => [...prev, msg])
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore malformed events */
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
mountedRef.current = false
|
|
87
|
+
ws.close()
|
|
88
|
+
}
|
|
89
|
+
}, [threadId, token])
|
|
90
|
+
|
|
91
|
+
// doSend fires the network call without blocking useInput (which must stay sync).
|
|
92
|
+
const doSend = useCallback(
|
|
93
|
+
(body: string) => {
|
|
94
|
+
sendingRef.current = true
|
|
95
|
+
sendMessage(threadId, body)
|
|
96
|
+
.then((msg) => {
|
|
97
|
+
if (mountedRef.current) setMessages((prev) => [...prev, msg])
|
|
98
|
+
})
|
|
99
|
+
.catch((err) => {
|
|
100
|
+
if (mountedRef.current) setError(String(err))
|
|
101
|
+
})
|
|
102
|
+
.finally(() => {
|
|
103
|
+
sendingRef.current = false
|
|
104
|
+
})
|
|
105
|
+
},
|
|
106
|
+
[threadId],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// IMPORTANT: useInput handler MUST be synchronous.
|
|
110
|
+
// Ink wraps each call in reconciler.batchedUpdates() — if the callback is async,
|
|
111
|
+
// the batchedUpdates scope exits before state setters run and subsequent keypresses
|
|
112
|
+
// pile up in the event queue while the previous await is still pending, causing
|
|
113
|
+
// the terminal to appear completely frozen.
|
|
114
|
+
useInput((inputChar, key) => {
|
|
115
|
+
if (key.ctrl && inputChar === 'c') {
|
|
116
|
+
// Flag unmounted first so no in-flight async callbacks write state after this.
|
|
117
|
+
mountedRef.current = false
|
|
118
|
+
// Close the WebSocket before tearing down the UI.
|
|
119
|
+
wsRef.current?.close()
|
|
120
|
+
// exit() resolves waitUntilExit() in chat.ts, which then destroys ttyStream
|
|
121
|
+
// and restores the terminal. We do NOT touch ttyStream here because Ink's
|
|
122
|
+
// App.componentWillUnmount() calls setRawMode(false) on it — if we destroy
|
|
123
|
+
// it first that call would throw.
|
|
124
|
+
exit()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (key.return) {
|
|
129
|
+
const body = input.trim()
|
|
130
|
+
if (!body || sendingRef.current) return
|
|
131
|
+
setInput('')
|
|
132
|
+
doSend(body)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (key.backspace || key.delete) {
|
|
137
|
+
setInput((prev) => prev.slice(0, -1))
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!key.ctrl && !key.meta && inputChar) {
|
|
142
|
+
setInput((prev) => prev + inputChar)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const statusColor =
|
|
147
|
+
status === 'connected'
|
|
148
|
+
? 'green'
|
|
149
|
+
: status === 'error' || status === 'disconnected'
|
|
150
|
+
? 'red'
|
|
151
|
+
: 'yellow'
|
|
152
|
+
|
|
153
|
+
const statusLabel = status === 'disconnected' ? 'disconnected' : status
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Box flexDirection="column" padding={1}>
|
|
157
|
+
<Box borderStyle="single" borderColor="cyan" paddingX={1} marginBottom={1}>
|
|
158
|
+
<Text bold color="cyan">
|
|
159
|
+
GitPing Chat
|
|
160
|
+
</Text>
|
|
161
|
+
<Text> — thread </Text>
|
|
162
|
+
<Text dimColor>{threadId.slice(0, 8)}</Text>
|
|
163
|
+
<Text> </Text>
|
|
164
|
+
<Text color={statusColor}>● {statusLabel}</Text>
|
|
165
|
+
</Box>
|
|
166
|
+
|
|
167
|
+
<Box flexDirection="column" flexGrow={1} marginBottom={1}>
|
|
168
|
+
{messages.length === 0 && <Text dimColor>No messages yet. Start the conversation!</Text>}
|
|
169
|
+
{messages.map((msg) => (
|
|
170
|
+
<Box key={msg.id} marginBottom={0}>
|
|
171
|
+
<Text color={msg.sender_id === myUserId ? 'cyan' : 'white'} bold>
|
|
172
|
+
{msg.sender_id === myUserId ? 'You' : 'Them'}
|
|
173
|
+
</Text>
|
|
174
|
+
<Text dimColor> {new Date(msg.created_at).toLocaleTimeString()} </Text>
|
|
175
|
+
<Text>{msg.body}</Text>
|
|
176
|
+
</Box>
|
|
177
|
+
))}
|
|
178
|
+
</Box>
|
|
179
|
+
|
|
180
|
+
{error && (
|
|
181
|
+
<Box marginBottom={1}>
|
|
182
|
+
<Text color="red">Error: {error}</Text>
|
|
183
|
+
</Box>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<Box borderStyle="round" borderColor="gray" paddingX={1}>
|
|
187
|
+
<Text color="cyan">{'> '}</Text>
|
|
188
|
+
<Text>{input}</Text>
|
|
189
|
+
{sendingRef.current ? <Text dimColor> sending…</Text> : <Text dimColor>█</Text>}
|
|
190
|
+
</Box>
|
|
191
|
+
|
|
192
|
+
<Text dimColor> Press Enter to send · Ctrl+C to quit</Text>
|
|
193
|
+
</Box>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react'
|
|
2
|
+
import { Box, Text, useApp } from 'ink'
|
|
3
|
+
import WebSocket from 'ws'
|
|
4
|
+
import { getApiBaseUrl } from '../config.ts'
|
|
5
|
+
import { listPings } from '../api/pings.ts'
|
|
6
|
+
import type { Ping, WSEvent } from '@gitping/shared'
|
|
7
|
+
import { WSEventType } from '@gitping/shared'
|
|
8
|
+
|
|
9
|
+
interface InboxWatchProps {
|
|
10
|
+
token: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function InboxWatch({ token }: InboxWatchProps): React.JSX.Element {
|
|
14
|
+
const { exit } = useApp()
|
|
15
|
+
const [pings, setPings] = useState<Ping[]>([])
|
|
16
|
+
const [status, setStatus] = useState<'loading' | 'live' | 'error'>('loading')
|
|
17
|
+
const [error, setError] = useState<string | null>(null)
|
|
18
|
+
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
|
|
19
|
+
|
|
20
|
+
// Initial load
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
listPings()
|
|
23
|
+
.then((page) => {
|
|
24
|
+
setPings(page.data)
|
|
25
|
+
setStatus('live')
|
|
26
|
+
})
|
|
27
|
+
.catch((err) => {
|
|
28
|
+
setStatus('error')
|
|
29
|
+
setError(String(err))
|
|
30
|
+
})
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
// WebSocket live updates
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const baseUrl = getApiBaseUrl().replace(/^http/, 'ws')
|
|
36
|
+
const ws = new WebSocket(`${baseUrl}/v1/ws?token=${encodeURIComponent(token)}`)
|
|
37
|
+
|
|
38
|
+
ws.on('open', () => setStatus('live'))
|
|
39
|
+
ws.on('error', () => {
|
|
40
|
+
setStatus('error')
|
|
41
|
+
setError('WebSocket connection failed — showing cached data')
|
|
42
|
+
})
|
|
43
|
+
ws.on('message', (data) => {
|
|
44
|
+
try {
|
|
45
|
+
const event = JSON.parse(data.toString()) as WSEvent
|
|
46
|
+
if (event.type === WSEventType.PING_RECEIVED) {
|
|
47
|
+
const newPing = event.payload as Ping
|
|
48
|
+
setPings((prev) => [newPing, ...prev])
|
|
49
|
+
setLastUpdate(new Date())
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
/* ignore malformed events */
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const handleExit = (): void => {
|
|
57
|
+
ws.close()
|
|
58
|
+
exit()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.on('SIGINT', handleExit)
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
ws.close()
|
|
65
|
+
process.off('SIGINT', handleExit)
|
|
66
|
+
}
|
|
67
|
+
}, [token, exit])
|
|
68
|
+
|
|
69
|
+
const statusColor = status === 'live' ? 'green' : status === 'error' ? 'red' : 'yellow'
|
|
70
|
+
const statusLabel = status === 'live' ? 'LIVE' : status === 'error' ? 'ERROR' : 'LOADING'
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Box flexDirection="column" padding={1}>
|
|
74
|
+
<Box borderStyle="single" borderColor="cyan" paddingX={1} marginBottom={1}>
|
|
75
|
+
<Text bold color="cyan">
|
|
76
|
+
GitPing Inbox
|
|
77
|
+
</Text>
|
|
78
|
+
<Text> </Text>
|
|
79
|
+
<Text color={statusColor}>● {statusLabel}</Text>
|
|
80
|
+
<Text dimColor> last updated {lastUpdate.toLocaleTimeString()}</Text>
|
|
81
|
+
</Box>
|
|
82
|
+
|
|
83
|
+
{error && (
|
|
84
|
+
<Box marginBottom={1}>
|
|
85
|
+
<Text color="yellow">⚠ {error}</Text>
|
|
86
|
+
</Box>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{pings.length === 0 && status !== 'loading' && (
|
|
90
|
+
<Text dimColor>No pings yet. Waiting for new pings…</Text>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{status === 'loading' && <Text color="yellow">Loading…</Text>}
|
|
94
|
+
|
|
95
|
+
{pings.map((ping) => (
|
|
96
|
+
<Box key={ping.id} flexDirection="column" marginBottom={1} paddingLeft={1}>
|
|
97
|
+
<Box>
|
|
98
|
+
<Text bold color="cyan">
|
|
99
|
+
@{ping.sender_username}
|
|
100
|
+
</Text>
|
|
101
|
+
<Text dimColor> → </Text>
|
|
102
|
+
<Text bold>@{ping.recipient_username}</Text>
|
|
103
|
+
<Text> </Text>
|
|
104
|
+
<Text color={statusBadgeColor(ping.status)}>[{ping.status}]</Text>
|
|
105
|
+
<Text> </Text>
|
|
106
|
+
<Text color="yellow">{ping.category}</Text>
|
|
107
|
+
<Text dimColor> {ping.id.slice(0, 8)}</Text>
|
|
108
|
+
</Box>
|
|
109
|
+
<Box paddingLeft={2}>
|
|
110
|
+
<Text italic>{ping.message}</Text>
|
|
111
|
+
</Box>
|
|
112
|
+
<Box paddingLeft={2}>
|
|
113
|
+
<Text dimColor>{new Date(ping.created_at).toLocaleString()}</Text>
|
|
114
|
+
</Box>
|
|
115
|
+
</Box>
|
|
116
|
+
))}
|
|
117
|
+
|
|
118
|
+
<Box marginTop={1}>
|
|
119
|
+
<Text dimColor>Ctrl+C to exit</Text>
|
|
120
|
+
</Box>
|
|
121
|
+
</Box>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function statusBadgeColor(status: string): string {
|
|
126
|
+
const map: Record<string, string> = {
|
|
127
|
+
pending: 'gray',
|
|
128
|
+
delivered: 'blue',
|
|
129
|
+
accepted: 'green',
|
|
130
|
+
ignored: 'yellow',
|
|
131
|
+
blocked: 'red',
|
|
132
|
+
}
|
|
133
|
+
return map[status] ?? 'white'
|
|
134
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext", "DOM"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"noFallthroughCasesInSwitch": true,
|
|
16
|
+
"noUnusedLocals": false,
|
|
17
|
+
"noUnusedParameters": false,
|
|
18
|
+
"noPropertyAccessFromIndexSignature": false
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*"]
|
|
21
|
+
}
|