| … | … | @@ -151,7 +151,13 @@ async function main () { |
| 151 | 151 | _display: [], // what's actually on screen: pinned + rest |
| 152 | 152 | threadId: null, // opId hex when viewing a thread |
| 153 | 153 | threadPost: null, |
| 154 | | threadComments: [] |
| 154 | threadComments: [], |
| 155 | |
| 156 | // Compose mode — when set, stdin lines are collected into the body |
| 157 | // instead of being parsed as commands. Used for multi-line posts/replies. |
| 158 | compose: null // { kind: 'post'|'reply', step: 'title'|'body', |
| 159 | // title?: string, bodyLines: string[], |
| 160 | // parentOpId?: string } |
| 155 | 161 | } |
| 156 | 162 | |
| 157 | 163 | // ---- rendering ---- |
| … | … | @@ -205,7 +211,7 @@ async function main () { |
| 205 | 211 | rest.forEach((p, i) => renderPostLine(p, pinned.length + i + 1, false)) |
| 206 | 212 | } |
| 207 | 213 | |
| 208 | | process.stdout.write(`\n${ANSI.dim}commands: submit | open <n> | up/down <n> | delete <n> | sort <hot|new|top> | refresh | help | quit${ANSI.reset}\n`) |
| 214 | process.stdout.write(`\n${ANSI.dim}commands: submit · open <n> · up/down <n> · delete <n> · sort <hot|new|top> · r · help · quit${ANSI.reset}\n`) |
| 209 | 215 | } |
| 210 | 216 | |
| 211 | 217 | function renderThread () { |
| … | … | @@ -256,7 +262,11 @@ async function main () { |
| 256 | 262 | node.on('track', scheduleRedraw) |
| 257 | 263 | |
| 258 | 264 | // ---- REPL (stdin line buffer, portable between Bare and Node) ---- |
| 259 | | const prompt = () => { process.stdout.write(`${ANSI.orange}›${ANSI.reset} `) } |
| 265 | const composePrompt = () => { process.stdout.write(`${ANSI.dim}… ${ANSI.reset}`) } |
| 266 | const prompt = () => { |
| 267 | if (state.compose) return composePrompt() |
| 268 | process.stdout.write(`${ANSI.orange}›${ANSI.reset} `) |
| 269 | } |
| 260 | 270 | function printLine (s) { process.stdout.write(s + '\n'); prompt() } |
| 261 | 271 | |
| 262 | 272 | let stdinBuf = '' |
| … | … | @@ -269,7 +279,8 @@ async function main () { |
| 269 | 279 | while ((nl = stdinBuf.indexOf('\n')) >= 0) { |
| 270 | 280 | const line = stdinBuf.slice(0, nl).replace(/\r$/, '') |
| 271 | 281 | stdinBuf = stdinBuf.slice(nl + 1) |
| 272 | | handle(line) |
| 282 | if (state.compose) handleCompose(line) |
| 283 | else handle(line) |
| 273 | 284 | } |
| 274 | 285 | }) |
| 275 | 286 | const shutdown = async () => { |
| … | … | @@ -303,6 +314,88 @@ async function main () { |
| 303 | 314 | |
| 304 | 315 | const isUrl = (s) => /^https?:\/\//.test(s) |
| 305 | 316 | |
| 317 | // ---- compose mode: accumulates a multi-line body for submit/reply ---- |
| 318 | |
| 319 | function enterCompose (kind, { parentOpId = null } = {}) { |
| 320 | state.compose = { |
| 321 | kind, // 'post' or 'reply' |
| 322 | step: kind === 'post' ? 'title' : 'body', |
| 323 | title: '', |
| 324 | bodyLines: [], |
| 325 | parentOpId |
| 326 | } |
| 327 | process.stdout.write( |
| 328 | `\n${ANSI.orange}─── ${kind === 'post' ? 'new post' : 'new reply'} ───${ANSI.reset}\n` + |
| 329 | `${ANSI.dim}type ${ANSI.reset}.${ANSI.dim} on a blank line to submit · type ${ANSI.reset}:q${ANSI.dim} to cancel${ANSI.reset}\n` |
| 330 | ) |
| 331 | if (kind === 'post') process.stdout.write(`${ANSI.dim}title:${ANSI.reset} `) |
| 332 | else process.stdout.write(`${ANSI.dim}write your reply…${ANSI.reset}\n`) |
| 333 | } |
| 334 | |
| 335 | function exitCompose (msg) { |
| 336 | state.compose = null |
| 337 | if (msg) process.stdout.write(msg + '\n') |
| 338 | prompt() |
| 339 | } |
| 340 | |
| 341 | async function handleCompose (line) { |
| 342 | const c = state.compose |
| 343 | if (!c) { prompt(); return } |
| 344 | |
| 345 | const trimmed = line.trim() |
| 346 | if (trimmed === ':q' || trimmed === 'cancel') { |
| 347 | return exitCompose(`${ANSI.dim}cancelled.${ANSI.reset}`) |
| 348 | } |
| 349 | |
| 350 | if (c.step === 'title') { |
| 351 | if (!trimmed) { |
| 352 | process.stdout.write(`${ANSI.red}title can't be empty · ${ANSI.reset}${ANSI.dim}title:${ANSI.reset} `) |
| 353 | return |
| 354 | } |
| 355 | c.title = trimmed |
| 356 | c.step = 'body' |
| 357 | process.stdout.write(`${ANSI.dim}body (multi-line OK):${ANSI.reset}\n`) |
| 358 | composePrompt() |
| 359 | return |
| 360 | } |
| 361 | |
| 362 | // c.step === 'body' |
| 363 | if (line === '.') { |
| 364 | if (c.bodyLines.length === 0) { |
| 365 | process.stdout.write(`${ANSI.red}body is empty · write something or type ':q' to cancel${ANSI.reset}\n`) |
| 366 | composePrompt() |
| 367 | return |
| 368 | } |
| 369 | const body = c.bodyLines.join('\n').replace(/\s+$/, '') |
| 370 | process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`) |
| 371 | const t0 = Date.now() |
| 372 | try { |
| 373 | if (c.kind === 'post') { |
| 374 | // Detect if the whole body is just a URL (single line) — treat as link post. |
| 375 | const trim = body.trim() |
| 376 | const isSingleLineLink = !trim.includes('\n') && isUrl(trim) |
| 377 | const link = isSingleLineLink ? trim : null |
| 378 | const text = link ? '' : body |
| 379 | const { opId } = await rpc.dispatch('createPost', { title: c.title, body: text, link }) |
| 380 | await refreshFeed() |
| 381 | process.stdout.write(`${ANSI.green}posted in ${Date.now() - t0}ms${ANSI.reset}\n`) |
| 382 | process.stdout.write(`${ANSI.dim}opId: ${ANSI.reset}${opId}\n`) |
| 383 | } else { |
| 384 | await rpc.dispatch('createComment', { parentOpId: c.parentOpId, body }) |
| 385 | await refreshThread() |
| 386 | process.stdout.write(`${ANSI.green}replied in ${Date.now() - t0}ms${ANSI.reset}\n`) |
| 387 | } |
| 388 | } catch (err) { |
| 389 | process.stdout.write(`\n${ANSI.red}error: ${err.message}${ANSI.reset}\n`) |
| 390 | } |
| 391 | return exitCompose() |
| 392 | } |
| 393 | |
| 394 | // Normal line — buffer it and stay in body mode. |
| 395 | c.bodyLines.push(line) |
| 396 | composePrompt() |
| 397 | } |
| 398 | |
| 306 | 399 | async function handle (line) { |
| 307 | 400 | const parsed = parseCmd(line) |
| 308 | 401 | if (!parsed) { prompt(); return } |
| … | … | @@ -313,17 +406,24 @@ async function main () { |
| 313 | 406 | case '?': |
| 314 | 407 | printLine(` |
| 315 | 408 | ${ANSI.bold}feed commands${ANSI.reset} |
| 316 | | submit "title" <body-or-url> submit a post |
| 409 | submit new post (multi-line editor) |
| 410 | submit "title" <body> new post (one-liner) |
| 317 | 411 | open <n> open thread for post #n |
| 318 | 412 | up <n> down <n> vote on post #n |
| 319 | 413 | sort <hot|new|top> change feed sort |
| 320 | 414 | refresh r reload feed |
| 321 | 415 | |
| 322 | 416 | ${ANSI.bold}thread commands${ANSI.reset} |
| 323 | | reply <body> comment on current thread |
| 417 | reply reply (multi-line editor) |
| 418 | reply <body> reply (one-liner) |
| 324 | 419 | up down vote on the post (in thread) |
| 325 | 420 | back b back to feed |
| 326 | 421 | |
| 422 | ${ANSI.bold}compose editor${ANSI.reset} |
| 423 | <your text> any line joins the body |
| 424 | . submit (on its own line) |
| 425 | :q cancel |
| 426 | |
| 327 | 427 | ${ANSI.bold}moderation${ANSI.reset} |
| 328 | 428 | delete <n> delete a post (author or admin) |
| 329 | 429 | delete delete current thread (in thread) |
| … | … | @@ -370,8 +470,14 @@ ${amAdmin ? '\n ' + ANSI.orange + '(admin) ' + ANSI.reset + 'you can delete any |
| 370 | 470 | prompt() |
| 371 | 471 | return |
| 372 | 472 | case 'submit': { |
| 473 | // `submit` with no args → enter multi-line compose mode. |
| 474 | // `submit "title" body` → classic one-liner still works. |
| 475 | if (!rest.trim()) { |
| 476 | enterCompose('post') |
| 477 | return |
| 478 | } |
| 373 | 479 | const { title, rest: body } = parseQuoted(rest) |
| 374 | | if (!title) { printLine('usage: submit "title" <body-or-url>'); break } |
| 480 | if (!title) { printLine('usage: submit (multi-line) | submit "title" <body-or-url>'); break } |
| 375 | 481 | const link = isUrl(body.trim()) ? body.trim() : null |
| 376 | 482 | const text = link ? '' : body |
| 377 | 483 | process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`) |
| … | … | @@ -384,6 +490,11 @@ ${amAdmin ? '\n ' + ANSI.orange + '(admin) ' + ANSI.reset + 'you can delete any |
| 384 | 490 | prompt() |
| 385 | 491 | return |
| 386 | 492 | } |
| 493 | case 'compose': |
| 494 | case 'c': { |
| 495 | enterCompose('post') |
| 496 | return |
| 497 | } |
| 387 | 498 | case 'open': { |
| 388 | 499 | const n = parseInt(rest, 10) |
| 389 | 500 | if (!n || !state._display[n - 1]) { printLine(`no post #${n}`); break } |
| … | … | @@ -465,7 +576,11 @@ ${amAdmin ? '\n ' + ANSI.orange + '(admin) ' + ANSI.reset + 'you can delete any |
| 465 | 576 | } |
| 466 | 577 | case 'reply': { |
| 467 | 578 | if (!state.threadId) { printLine('no thread open. use: open <n>'); break } |
| 468 | | if (!rest.trim()) { printLine('usage: reply <body>'); break } |
| 579 | // reply with no args → enter multi-line compose mode (as a reply) |
| 580 | if (!rest.trim()) { |
| 581 | enterCompose('reply', { parentOpId: state.threadId }) |
| 582 | return |
| 583 | } |
| 469 | 584 | process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`) |
| 470 | 585 | const t0 = Date.now() |
| 471 | 586 | await rpc.dispatch('createComment', { parentOpId: state.threadId, body: rest }) |