903 lines
24 KiB
Go
903 lines
24 KiB
Go
// messages.go implements the message board subsystem.
|
|
//
|
|
// This replaces MCOM.C from the original TAG-BBS. The original had
|
|
// three nested menu levels:
|
|
//
|
|
// MCom() → Board selection: N>ew, S>ome, A>ll, L>ist, Q>uit
|
|
// Msg_Prompt(board) → Per-board: N>ew, R>ead, I>mmediate, W>rite, Q>uit
|
|
// Between_Msg_Prompt → Between messages: A>gain, N>ext, L>ast, R>eply, D>elete
|
|
//
|
|
// The message storage is completely different from the original:
|
|
//
|
|
// Original: Board_Header (linked list in memory) + Board.Keys (flat file
|
|
// of Board_Data structs with slot numbers) + Board.Data (flat file with
|
|
// fixed 2500-byte message bodies at lseek offsets).
|
|
//
|
|
// Modern: SQLite tables (boards + messages) with foreign keys, variable-
|
|
// length text bodies, and automatic numbering via the store layer.
|
|
//
|
|
// The user-facing experience is preserved: single-keypress navigation,
|
|
// read-new-since-last-login, sequential message display with between-
|
|
// message prompts, and the same access control model (ReadLow/ReadHigh,
|
|
// WriteLow/WriteHigh ranges compared against the user's SecBoard level).
|
|
package menu
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/urit/urit/internal/models"
|
|
"github.com/urit/urit/internal/session"
|
|
)
|
|
|
|
// cmdMessages is the entry point for the message board subsystem.
|
|
// Replaces MCom() from MCOM.C — the top-level board selection menu.
|
|
func cmdMessages(ctx *Context) error {
|
|
boards, err := ctx.Store.ListBoards()
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error loading boards.\r\n")
|
|
return nil
|
|
}
|
|
|
|
// Filter boards to those the user can see (read OR write access)
|
|
var visible []*models.Board
|
|
for _, b := range boards {
|
|
if b.CanRead(ctx.User.SecBoard) || b.CanWrite(ctx.User.SecBoard) {
|
|
visible = append(visible, b)
|
|
}
|
|
}
|
|
|
|
if len(visible) == 0 {
|
|
ctx.Sess.WriteString(" No message boards available.\r\n\r\n")
|
|
return nil
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
|
|
for {
|
|
ctx.Sess.CheckTime()
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("N>ew S>ome A>ll L>ist Q>uit\r\n")
|
|
ctx.Sess.Printf("%s Message Base> ", ctx.Cfg.System.Name)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
switch toUpper(ch) {
|
|
case 'L':
|
|
ctx.Sess.WriteString("List\r\n\r\n")
|
|
msgBoardList(ctx, visible)
|
|
|
|
case 'A':
|
|
ctx.Sess.WriteString("All Boards\r\n\r\n")
|
|
for i, b := range visible {
|
|
ctx.Sess.Printf("[%d] %s\r\n", i+1, b.Name)
|
|
quit := msgBoardPrompt(ctx, b, i+1)
|
|
if quit {
|
|
goto done
|
|
}
|
|
}
|
|
ctx.Sess.WriteString("Completed visiting ALL\r\n")
|
|
|
|
case 'N':
|
|
ctx.Sess.WriteString("New Messages\r\n\r\n")
|
|
found := false
|
|
for i, b := range visible {
|
|
if !b.CanRead(ctx.User.SecBoard) {
|
|
continue
|
|
}
|
|
// Check if there are new messages since last login
|
|
if ctx.User.LastOn != nil && b.LatestPost != nil &&
|
|
!b.LatestPost.After(*ctx.User.LastOn) {
|
|
continue
|
|
}
|
|
found = true
|
|
ctx.Sess.Printf("\r\n[%d] %s\r\n", i+1, b.Name)
|
|
// Read new messages first, then show prompt
|
|
msgReadNew(ctx, b)
|
|
quit := msgBoardPrompt(ctx, b, i+1)
|
|
if quit {
|
|
goto done
|
|
}
|
|
}
|
|
if !found {
|
|
ctx.Sess.WriteString("Nothing new.\r\n")
|
|
}
|
|
ctx.Sess.WriteString("Completed visiting NEW\r\n")
|
|
|
|
case 'S':
|
|
ctx.Sess.WriteString("Some Boards\r\n\r\n")
|
|
for i, b := range visible {
|
|
ctx.Sess.Printf("Visit [%d] %s? ", i+1, b.Name)
|
|
ych, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
switch toUpper(ych) {
|
|
case 'Y':
|
|
ctx.Sess.WriteString("Yes\r\n")
|
|
quit := msgBoardPrompt(ctx, b, i+1)
|
|
if quit {
|
|
goto done
|
|
}
|
|
case 'Q':
|
|
ctx.Sess.WriteString("Quit\r\n")
|
|
goto done
|
|
default:
|
|
ctx.Sess.WriteString("No\r\n")
|
|
}
|
|
}
|
|
ctx.Sess.WriteString("Completed visiting SOME\r\n")
|
|
|
|
case 'Q', '\x1b':
|
|
ctx.Sess.WriteString("Quit\r\n")
|
|
goto done
|
|
|
|
case '?', '/':
|
|
ctx.Sess.WriteString("Help\r\n")
|
|
msgBoardList(ctx, visible)
|
|
}
|
|
}
|
|
|
|
done:
|
|
return nil
|
|
}
|
|
|
|
// msgBoardList displays the list of visible boards with post counts.
|
|
// Replaces the 'L' (list) case in MCom().
|
|
func msgBoardList(ctx *Context, boards []*models.Board) {
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("Message Boards\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
|
|
|
|
for i, b := range boards {
|
|
access := ""
|
|
if b.CanRead(ctx.User.SecBoard) && b.CanWrite(ctx.User.SecBoard) {
|
|
access = "RW"
|
|
} else if b.CanRead(ctx.User.SecBoard) {
|
|
access = "R "
|
|
} else if b.CanWrite(ctx.User.SecBoard) {
|
|
access = " W"
|
|
}
|
|
|
|
latest := "no posts"
|
|
if b.LatestPost != nil {
|
|
latest = b.LatestPost.Format("Jan 02")
|
|
}
|
|
|
|
ctx.Sess.Printf(" [%d] %-20s %3d msgs %s %s\r\n",
|
|
i+1, b.Name, b.PostCount, access, latest)
|
|
}
|
|
ctx.Sess.NewLine()
|
|
}
|
|
|
|
// msgBoardPrompt is the per-board command loop.
|
|
// Replaces Msg_Prompt() from MCOM.C — the N>ew R>ead I>mmediate W>rite menu.
|
|
// Returns true if the user wants to return to the main menu (@).
|
|
func msgBoardPrompt(ctx *Context, board *models.Board, num int) bool {
|
|
canRead := board.CanRead(ctx.User.SecBoard)
|
|
canWrite := board.CanWrite(ctx.User.SecBoard)
|
|
|
|
for {
|
|
ctx.Sess.CheckTime()
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
// Build the prompt options based on access
|
|
opts := ""
|
|
if canRead {
|
|
opts += "N>ew R>ead I>mmediate "
|
|
}
|
|
if canWrite {
|
|
opts += "W>rite "
|
|
}
|
|
if ctx.User.SecStatus >= 100 {
|
|
opts += "D>elete "
|
|
}
|
|
opts += "Q>uit @>Main"
|
|
ctx.Sess.WriteString(opts + "\r\n")
|
|
ctx.Sess.Printf("[%d] %s> ", num, board.Name)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
|
|
switch toUpper(ch) {
|
|
case 'N':
|
|
if !canRead {
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("New\r\n")
|
|
msgReadNew(ctx, board)
|
|
|
|
case 'R':
|
|
if !canRead {
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("Read\r\n")
|
|
msgReadFrontend(ctx, board)
|
|
|
|
case 'I':
|
|
if !canRead {
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("Immediate\r\n")
|
|
msgImmediate(ctx, board)
|
|
|
|
case 'W':
|
|
if !canWrite {
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("Write\r\n")
|
|
msgWrite(ctx, board, 0)
|
|
|
|
case 'D':
|
|
if ctx.User.SecStatus < 100 {
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("Delete\r\n")
|
|
msgDeleteFrontend(ctx, board)
|
|
|
|
case 'Q':
|
|
ctx.Sess.WriteString("Quit\r\n")
|
|
return false
|
|
|
|
case '@':
|
|
ctx.Sess.WriteString("Main Menu\r\n")
|
|
return true
|
|
|
|
case '?', '/':
|
|
ctx.Sess.WriteString("Help\r\n")
|
|
ctx.Sess.WriteString(" N - Read new messages since your last login\r\n")
|
|
ctx.Sess.WriteString(" R - Read messages by number range\r\n")
|
|
ctx.Sess.WriteString(" I - Read a single message by number\r\n")
|
|
ctx.Sess.WriteString(" W - Write a new message\r\n")
|
|
if ctx.User.SecStatus >= 100 {
|
|
ctx.Sess.WriteString(" D - Delete messages\r\n")
|
|
}
|
|
ctx.Sess.WriteString(" Q - Return to board selection\r\n")
|
|
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// msgReadNew reads messages posted since the user's last login.
|
|
// Replaces Msg_ReadNew() from MCOM.C.
|
|
func msgReadNew(ctx *Context, board *models.Board) {
|
|
if board.PostCount == 0 {
|
|
ctx.Sess.WriteString(" No messages in this board.\r\n")
|
|
return
|
|
}
|
|
|
|
// Get messages posted since last login
|
|
var sinceUnix int64
|
|
if ctx.User.LastOn != nil {
|
|
sinceUnix = ctx.User.LastOn.Unix()
|
|
}
|
|
|
|
msgs, err := ctx.Store.ListMessagesSince(board.ID, sinceUnix)
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error loading messages.\r\n")
|
|
return
|
|
}
|
|
|
|
if len(msgs) == 0 {
|
|
ctx.Sess.WriteString(" Nothing new.\r\n")
|
|
return
|
|
}
|
|
|
|
ctx.Sess.Printf(" %d new message(s)\r\n", len(msgs))
|
|
msgReadSequence(ctx, board, msgs)
|
|
}
|
|
|
|
// msgReadFrontend prompts for a FROM/TO range and reads those messages.
|
|
// Replaces Msg_Read_Frontend() from MCOM.C.
|
|
func msgReadFrontend(ctx *Context, board *models.Board) {
|
|
if board.PostCount == 0 {
|
|
ctx.Sess.WriteString(" No messages in this board.\r\n")
|
|
return
|
|
}
|
|
|
|
ctx.Sess.Printf(" Read FROM [1-%d]: ", board.PostCount)
|
|
fromStr, err := ctx.Sess.ReadLine("1", 5, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
from := parseIntDefault(fromStr, 1)
|
|
if from < 1 || from > board.PostCount {
|
|
ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", board.PostCount)
|
|
return
|
|
}
|
|
|
|
ctx.Sess.Printf(" Read TO [%d-%d]: ", from, board.PostCount)
|
|
toStr, err := ctx.Sess.ReadLine(fmt.Sprintf("%d", board.PostCount), 5, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
to := parseIntDefault(toStr, board.PostCount)
|
|
if to < from || to > board.PostCount {
|
|
ctx.Sess.Printf(" Invalid. Range is %d-%d\r\n", from, board.PostCount)
|
|
return
|
|
}
|
|
|
|
// Load the range of messages
|
|
msgs, err := ctx.Store.ListMessages(board.ID, from-1, to-from+1)
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error loading messages.\r\n")
|
|
return
|
|
}
|
|
|
|
msgReadSequence(ctx, board, msgs)
|
|
}
|
|
|
|
// msgImmediate reads a single message by number.
|
|
// Replaces Msg_Immediate() from MCOM.C.
|
|
func msgImmediate(ctx *Context, board *models.Board) {
|
|
if board.PostCount == 0 {
|
|
ctx.Sess.WriteString(" No messages in this board.\r\n")
|
|
return
|
|
}
|
|
|
|
ctx.Sess.Printf(" Read which [1-%d]: ", board.PostCount)
|
|
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
num := parseIntDefault(numStr, 0)
|
|
if num < 1 || num > board.PostCount {
|
|
ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", board.PostCount)
|
|
return
|
|
}
|
|
|
|
msgs, err := ctx.Store.ListMessages(board.ID, num-1, 1)
|
|
if err != nil || len(msgs) == 0 {
|
|
ctx.Sess.WriteString(" Message not found.\r\n")
|
|
return
|
|
}
|
|
|
|
msgReadSequence(ctx, board, msgs)
|
|
}
|
|
|
|
// msgReadSequence displays messages in order with the between-message
|
|
// navigation prompt. This is the core reading loop that replaces
|
|
// Msg_Read() from MCOM.C.
|
|
//
|
|
// The original used a for loop with index manipulation:
|
|
// number++ (next), number-- (again), number-=2 (last)
|
|
//
|
|
// We use the same approach with a slice index.
|
|
func msgReadSequence(ctx *Context, board *models.Board, msgs []*models.Message) {
|
|
if len(msgs) == 0 {
|
|
return
|
|
}
|
|
|
|
idx := 0
|
|
for idx >= 0 && idx < len(msgs) {
|
|
msg := msgs[idx]
|
|
|
|
// Display the message (replaces Send_Message)
|
|
msgDisplay(ctx, board, msg)
|
|
|
|
// Between-message prompt (replaces Between_Msg_Prompt)
|
|
action := msgBetweenPrompt(ctx, board, msg)
|
|
|
|
switch action {
|
|
case msgActionNext:
|
|
idx++
|
|
case msgActionAgain:
|
|
// idx stays the same — re-display
|
|
case msgActionLast:
|
|
if idx > 0 {
|
|
idx--
|
|
}
|
|
// At first message, "last" re-reads it (matches original)
|
|
case msgActionQuit:
|
|
return
|
|
case msgActionMainMenu:
|
|
return
|
|
case msgActionDeleted:
|
|
// Message was deleted; refresh the list and adjust
|
|
refreshed, err := ctx.Store.ListMessages(board.ID, 0, board.MaxPosts)
|
|
if err != nil {
|
|
return
|
|
}
|
|
msgs = refreshed
|
|
// Refresh the board too (post count changed)
|
|
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
|
|
*board = *updated
|
|
}
|
|
if idx >= len(msgs) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// msgDisplay renders a single message to the terminal.
|
|
// Replaces Send_Message() from MCOM.C.
|
|
func msgDisplay(ctx *Context, board *models.Board, msg *models.Message) {
|
|
ctx.Sess.NewLine()
|
|
|
|
// Header — matches original format:
|
|
// Number: [N] of [Total]
|
|
// Title: ...
|
|
// Author: Name [ID]
|
|
// Time: ...
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.Printf("Number: [%d] of [%d]\r\n", msg.Number, board.PostCount)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
if msg.Title != "" {
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.Printf(" Title: %s\r\n", msg.Title)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
ctx.Sess.Printf("Author: %s [%d]\r\n", msg.Author, msg.AuthorID)
|
|
ctx.Sess.Printf(" Time: %s\r\n", msg.CreatedAt.Format("Mon Jan 02 15:04:05 2006"))
|
|
|
|
if msg.ReplyTo > 0 {
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" (reply to #%d)\r\n", msg.ReplyTo)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
if msg.Locked {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString(" [LOCKED]\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
|
|
// Body — paginate if long (replaces the More.. logic in Send_Message)
|
|
ctx.Sess.Paginate(msg.Body, idleTimeout)
|
|
}
|
|
|
|
// msgAction represents the user's choice at the between-message prompt.
|
|
type msgAction int
|
|
|
|
const (
|
|
msgActionNext msgAction = iota // Continue to next message
|
|
msgActionAgain // Re-read current message
|
|
msgActionLast // Go back one message
|
|
msgActionQuit // Return to board prompt
|
|
msgActionMainMenu // Return to main menu
|
|
msgActionDeleted // Message was deleted
|
|
)
|
|
|
|
// msgBetweenPrompt shows navigation options between messages.
|
|
// Replaces Between_Msg_Prompt() from MCOM.C.
|
|
//
|
|
// The original's return codes:
|
|
// 0 = quit, 1 = continue, 2 = again, 3 = backwards, '@' = main menu
|
|
//
|
|
// We use named constants instead.
|
|
func msgBetweenPrompt(ctx *Context, board *models.Board, msg *models.Message) msgAction {
|
|
for {
|
|
ctx.Sess.CheckTime()
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
|
|
opts := "N>ext A>gain L>ast R>eply Q>uit @>Main"
|
|
|
|
// Delete — original required SecStatus >= 100 OR being the author
|
|
canDelete := ctx.User.SecStatus >= 100 || ctx.User.ID == msg.AuthorID
|
|
if canDelete {
|
|
opts += " D>elete"
|
|
}
|
|
if ctx.User.SecStatus >= 100 {
|
|
if msg.Locked {
|
|
opts += " ->Unlock"
|
|
} else {
|
|
opts += " +>Lock"
|
|
}
|
|
}
|
|
|
|
ctx.Sess.WriteString("\r\n" + opts + "\r\n")
|
|
ctx.Sess.WriteString("Message> ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return msgActionQuit
|
|
}
|
|
|
|
switch toUpper(ch) {
|
|
case 'N', 'C', '\r':
|
|
ctx.Sess.WriteString("Next\r\n")
|
|
return msgActionNext
|
|
|
|
case 'A':
|
|
ctx.Sess.WriteString("Again\r\n")
|
|
return msgActionAgain
|
|
|
|
case 'L':
|
|
ctx.Sess.WriteString("Last\r\n")
|
|
return msgActionLast
|
|
|
|
case 'R':
|
|
ctx.Sess.WriteString("Reply\r\n")
|
|
if board.CanWrite(ctx.User.SecBoard) {
|
|
msgWrite(ctx, board, msg.Number)
|
|
} else {
|
|
ctx.Sess.WriteString(" You don't have write access to this board.\r\n")
|
|
}
|
|
return msgActionNext
|
|
|
|
case 'D':
|
|
if !canDelete {
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("Delete\r\n")
|
|
yes, err := ctx.Sess.Confirm(" Delete this message? ", inputTimeout)
|
|
if err != nil {
|
|
return msgActionQuit
|
|
}
|
|
if yes {
|
|
if err := ctx.Store.DeleteMessage(msg.ID); err != nil {
|
|
ctx.Sess.WriteString(" Error deleting message.\r\n")
|
|
} else {
|
|
// Update the board's post count
|
|
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
|
|
*board = *updated
|
|
}
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.WriteString(" Message deleted.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
return msgActionDeleted
|
|
}
|
|
}
|
|
|
|
case '+':
|
|
if ctx.User.SecStatus < 100 {
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("Lock\r\n")
|
|
msg.Locked = true
|
|
// We need an UpdateMessage — for now toggle in memory.
|
|
// The locked state will persist if the store supports it.
|
|
ctx.Sess.WriteString(" Message locked.\r\n")
|
|
|
|
case '-':
|
|
if ctx.User.SecStatus < 100 {
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("Unlock\r\n")
|
|
msg.Locked = false
|
|
ctx.Sess.WriteString(" Message unlocked.\r\n")
|
|
|
|
case 'Q':
|
|
ctx.Sess.WriteString("Quit\r\n")
|
|
return msgActionQuit
|
|
|
|
case '@':
|
|
ctx.Sess.WriteString("Main Menu\r\n")
|
|
return msgActionMainMenu
|
|
|
|
case '?', '/':
|
|
ctx.Sess.WriteString("Help\r\n")
|
|
ctx.Sess.WriteString(" N/C/Enter - Next message\r\n")
|
|
ctx.Sess.WriteString(" A - Read again\r\n")
|
|
ctx.Sess.WriteString(" L - Previous message\r\n")
|
|
ctx.Sess.WriteString(" R - Reply to this message\r\n")
|
|
ctx.Sess.WriteString(" D - Delete this message\r\n")
|
|
ctx.Sess.WriteString(" Q - Return to board prompt\r\n")
|
|
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// msgWrite composes and saves a new message.
|
|
// Replaces Msg_Write() and Msg_Public_Reply() from MCOM.C.
|
|
//
|
|
// The original used the full-screen editor from EDIT.C with word wrap,
|
|
// line editing, and a 2500-byte buffer. Our version uses a simpler
|
|
// multi-line editor that collects lines until a blank line, then offers
|
|
// Save/Abort/Continue/List/Edit — matching the original's edit menu.
|
|
//
|
|
// replyToNum is the message number being replied to (0 for new posts).
|
|
func msgWrite(ctx *Context, board *models.Board, replyToNum int) {
|
|
ctx.Sess.NewLine()
|
|
|
|
// Check if the board is full
|
|
if board.PostCount >= board.MaxPosts {
|
|
ctx.Sess.WriteString(" Board is full — no room for another message.\r\n")
|
|
return
|
|
}
|
|
|
|
// Title
|
|
defaultTitle := ""
|
|
if replyToNum > 0 {
|
|
// Try to prefill with "Re: original title"
|
|
origMsgs, _ := ctx.Store.ListMessages(board.ID, replyToNum-1, 1)
|
|
if len(origMsgs) > 0 && origMsgs[0].Title != "" {
|
|
t := origMsgs[0].Title
|
|
if !strings.HasPrefix(strings.ToLower(t), "re:") {
|
|
defaultTitle = "Re: " + t
|
|
} else {
|
|
defaultTitle = t
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Enter a Title (Q to quit): ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
title, err := ctx.Sess.ReadLine(defaultTitle, 60, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
title = strings.TrimSpace(title)
|
|
if strings.EqualFold(title, "Q") || title == "" {
|
|
ctx.Sess.WriteString(" Cancelled.\r\n")
|
|
return
|
|
}
|
|
|
|
// Author name — sysops/high-sec users can override (like original)
|
|
author := ctx.User.Name
|
|
if ctx.User.SecStatus >= 100 {
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Name to use: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
nameInput, err := ctx.Sess.ReadLine(author, 30, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
nameInput = strings.TrimSpace(nameInput)
|
|
if nameInput != "" {
|
|
author = nameInput
|
|
}
|
|
}
|
|
|
|
// Compose body using the line editor
|
|
ctx.Sess.WriteString("\r\nEnter your message (blank line when done):\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.WriteString(" Ctrl-X: erase line | Ctrl-W: erase word | Blank line: finish\r\n\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
lines := msgEditLoop(ctx)
|
|
if lines == nil {
|
|
return // disconnected
|
|
}
|
|
|
|
// Edit menu — matches EDIT.C's post-entry options
|
|
for {
|
|
ctx.Sess.WriteString("\r\nA>bort S>ave C>ontinue L>ist E>dit-line D>elete-line\r\n")
|
|
ctx.Sess.WriteString("Edit> ")
|
|
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
switch toUpper(ch) {
|
|
case 'S':
|
|
ctx.Sess.WriteString("Save\r\n")
|
|
goto save
|
|
|
|
case 'A', 'Q':
|
|
ctx.Sess.WriteString("Abort\r\n")
|
|
yes, err := ctx.Sess.Confirm(" Discard this message? ", inputTimeout)
|
|
if err != nil || yes {
|
|
ctx.Sess.WriteString(" Message discarded.\r\n")
|
|
return
|
|
}
|
|
|
|
case 'C':
|
|
ctx.Sess.WriteString("Continue\r\n")
|
|
more := msgEditLoop(ctx)
|
|
if more == nil {
|
|
return
|
|
}
|
|
lines = append(lines, more...)
|
|
|
|
case 'L':
|
|
ctx.Sess.WriteString("List\r\n\r\n")
|
|
for i, l := range lines {
|
|
ctx.Sess.Printf("%3d> %s\r\n", i+1, l)
|
|
}
|
|
|
|
case 'E':
|
|
ctx.Sess.WriteString("Edit\r\n")
|
|
if len(lines) == 0 {
|
|
ctx.Sess.WriteString(" Nothing to edit.\r\n")
|
|
continue
|
|
}
|
|
ctx.Sess.Printf(" Line number [1-%d]: ", len(lines))
|
|
numStr, err := ctx.Sess.ReadLine("", 4, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
n := parseIntDefault(numStr, 0)
|
|
if n < 1 || n > len(lines) {
|
|
ctx.Sess.WriteString(" Invalid line number.\r\n")
|
|
continue
|
|
}
|
|
ctx.Sess.Printf(" Old: %s\r\n", lines[n-1])
|
|
ctx.Sess.WriteString(" New: ")
|
|
newLine, err := ctx.Sess.ReadLine(lines[n-1], 75, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
lines[n-1] = newLine
|
|
|
|
case 'D':
|
|
ctx.Sess.WriteString("Delete\r\n")
|
|
if len(lines) == 0 {
|
|
ctx.Sess.WriteString(" Nothing to delete.\r\n")
|
|
continue
|
|
}
|
|
ctx.Sess.Printf(" Line number [1-%d]: ", len(lines))
|
|
numStr, err := ctx.Sess.ReadLine("", 4, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
n := parseIntDefault(numStr, 0)
|
|
if n < 1 || n > len(lines) {
|
|
ctx.Sess.WriteString(" Invalid line number.\r\n")
|
|
continue
|
|
}
|
|
lines = append(lines[:n-1], lines[n:]...)
|
|
ctx.Sess.Printf(" Line %d deleted.\r\n", n)
|
|
|
|
case '?', '/':
|
|
ctx.Sess.WriteString("Help\r\n")
|
|
ctx.Sess.WriteString(" S - Save the message\r\n")
|
|
ctx.Sess.WriteString(" A - Abort and discard\r\n")
|
|
ctx.Sess.WriteString(" C - Continue writing\r\n")
|
|
ctx.Sess.WriteString(" L - List what you've written\r\n")
|
|
ctx.Sess.WriteString(" E - Edit a line\r\n")
|
|
ctx.Sess.WriteString(" D - Delete a line\r\n")
|
|
}
|
|
}
|
|
|
|
save:
|
|
body := strings.Join(lines, "\n")
|
|
if strings.TrimSpace(body) == "" {
|
|
ctx.Sess.WriteString(" Empty message — not saved.\r\n")
|
|
return
|
|
}
|
|
|
|
// Resolve replyTo — if replying, store the original message's DB ID
|
|
var replyToID int64
|
|
if replyToNum > 0 {
|
|
origMsgs, _ := ctx.Store.ListMessages(board.ID, replyToNum-1, 1)
|
|
if len(origMsgs) > 0 {
|
|
replyToID = origMsgs[0].ID
|
|
}
|
|
}
|
|
|
|
msg := &models.Message{
|
|
BoardID: board.ID,
|
|
Title: title,
|
|
Author: author,
|
|
AuthorID: ctx.User.ID,
|
|
Body: body,
|
|
ReplyTo: replyToID,
|
|
}
|
|
|
|
ctx.Sess.WriteString(" Saving...\r\n")
|
|
if err := ctx.Store.CreateMessage(msg); err != nil {
|
|
ctx.Sess.WriteString(" Error saving message.\r\n")
|
|
return
|
|
}
|
|
|
|
// Update stats
|
|
ctx.User.MessagesPosted++
|
|
ctx.Store.UpdateUser(ctx.User)
|
|
ctx.Store.IncrementStat("messages_posted", 1)
|
|
|
|
// Refresh the board's post count
|
|
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
|
|
*board = *updated
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.Printf(" Message #%d saved.\r\n", msg.Number)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
// msgEditLoop reads lines of input until a blank line is entered.
|
|
// Replaces the Enter() function from EDIT.C.
|
|
//
|
|
// The original had elaborate word-wrap logic because terminals ran at
|
|
// 75 columns over serial. We still do line-at-a-time with numbered
|
|
// prompts, but rely on the terminal's own wrapping for long lines.
|
|
func msgEditLoop(ctx *Context) []string {
|
|
var lines []string
|
|
lineNum := len(lines) + 1
|
|
|
|
for {
|
|
ctx.Sess.Printf("%3d> ", lineNum)
|
|
line, err := ctx.Sess.ReadLine("", 75, inputTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(line) == "" {
|
|
break
|
|
}
|
|
lines = append(lines, line)
|
|
lineNum++
|
|
|
|
// Safety limit — original had MAXLINES=100 and Size=2500 bytes
|
|
if lineNum > 100 {
|
|
ctx.Sess.WriteString(" Maximum lines reached.\r\n")
|
|
break
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// msgDeleteFrontend prompts for a range and deletes messages.
|
|
// Replaces Msg_Delete_Frontend() from MCOM.C.
|
|
func msgDeleteFrontend(ctx *Context, board *models.Board) {
|
|
if board.PostCount == 0 {
|
|
ctx.Sess.WriteString(" No messages to delete.\r\n")
|
|
return
|
|
}
|
|
|
|
ctx.Sess.Printf(" Delete which [1-%d] (0 to cancel): ", board.PostCount)
|
|
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
num := parseIntDefault(numStr, 0)
|
|
if num < 1 || num > board.PostCount {
|
|
ctx.Sess.WriteString(" Cancelled.\r\n")
|
|
return
|
|
}
|
|
|
|
msgs, err := ctx.Store.ListMessages(board.ID, num-1, 1)
|
|
if err != nil || len(msgs) == 0 {
|
|
ctx.Sess.WriteString(" Message not found.\r\n")
|
|
return
|
|
}
|
|
|
|
msg := msgs[0]
|
|
if msg.Locked && ctx.User.SecStatus < 255 {
|
|
ctx.Sess.WriteString(" That message is locked.\r\n")
|
|
return
|
|
}
|
|
|
|
ctx.Sess.Printf(" Delete #%d \"%s\" by %s? ", msg.Number, msg.Title, msg.Author)
|
|
yes, err := ctx.Sess.Confirm("", inputTimeout)
|
|
if err != nil || !yes {
|
|
ctx.Sess.WriteString(" Not deleted.\r\n")
|
|
return
|
|
}
|
|
|
|
if err := ctx.Store.DeleteMessage(msg.ID); err != nil {
|
|
ctx.Sess.WriteString(" Error deleting message.\r\n")
|
|
return
|
|
}
|
|
|
|
// Refresh board
|
|
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
|
|
*board = *updated
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.WriteString(" Message deleted.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
// parseIntDefault parses a string as int, returning def on failure.
|
|
func parseIntDefault(s string, def int) int {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return def
|
|
}
|
|
var n int
|
|
_, err := fmt.Sscanf(s, "%d", &n)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return n
|
|
}
|