All: Migrate boards from global to board specific

Massive commit. Rewrites some of the chat logic to work individually
rather than a global chat.
jocadbz
Joca 2025-08-22 00:07:13 -03:00
parent 5370611265
commit 3a82e2a0d1
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
10 changed files with 158 additions and 65 deletions

View File

@ -33,6 +33,11 @@ func BoardHandler(app *App) http.HandlerFunc {
return
}
if board.Type == "chat" {
http.Redirect(w, r, app.Config.ThreadrDir+"/chat/?id="+boardIDStr, http.StatusFound)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)

View File

@ -28,25 +28,39 @@ func BoardsHandler(app *App) http.HandlerFunc {
if r.Method == http.MethodPost && loggedIn && isAdmin {
name := r.FormValue("name")
description := r.FormValue("description")
boardType := r.FormValue("type")
if name == "" {
http.Error(w, "Board name is required", http.StatusBadRequest)
return
}
if boardType != "classic" && boardType != "chat" {
boardType = "classic"
}
board := models.Board{
Name: name,
Description: description,
Private: false,
PublicVisible: true,
Type: boardType,
}
query := "INSERT INTO boards (name, description, private, public_visible) VALUES (?, ?, ?, ?)"
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible)
query := "INSERT INTO boards (name, description, private, public_visible, type) VALUES (?, ?, ?, ?, ?)"
result, err := app.DB.Exec(query, board.Name, board.Description, board.Private, board.PublicVisible, board.Type)
if err != nil {
log.Printf("Error creating board: %v", err)
http.Error(w, "Failed to create board", http.StatusInternalServerError)
return
}
boardID, _ := result.LastInsertId()
http.Redirect(w, r, app.Config.ThreadrDir+"/board/?id="+strconv.FormatInt(boardID, 10), http.StatusFound)
var redirectURL string
if boardType == "chat" {
redirectURL = app.Config.ThreadrDir + "/chat/?id=" + strconv.FormatInt(boardID, 10)
} else {
redirectURL = app.Config.ThreadrDir + "/board/?id=" + strconv.FormatInt(boardID, 10)
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}

View File

@ -5,6 +5,7 @@ import (
"html/template"
"log"
"net/http"
"strconv"
"sync"
"threadr/models"
@ -20,21 +21,26 @@ var upgrader = websocket.Upgrader{
},
}
// ChatHub manages WebSocket connections and broadcasts messages
type Client struct {
conn *websocket.Conn
userID int
boardID int
}
type ChatHub struct {
clients map[*websocket.Conn]int // Map of connections to user IDs
broadcast chan []byte
register chan *websocket.Conn
unregister chan *websocket.Conn
clients map[*Client]bool
broadcast chan models.ChatMessage
register chan *Client
unregister chan *Client
mutex sync.Mutex
}
func NewChatHub() *ChatHub {
return &ChatHub{
clients: make(map[*websocket.Conn]int),
broadcast: make(chan []byte),
register: make(chan *websocket.Conn),
unregister: make(chan *websocket.Conn),
clients: make(map[*Client]bool),
broadcast: make(chan models.ChatMessage),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
@ -43,23 +49,28 @@ func (h *ChatHub) Run() {
select {
case client := <-h.register:
h.mutex.Lock()
h.clients[client] = 0 // UserID set later
h.clients[client] = true
h.mutex.Unlock()
case client := <-h.unregister:
h.mutex.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
client.conn.Close()
}
h.mutex.Unlock()
client.Close()
case message := <-h.broadcast:
h.mutex.Lock()
for client := range h.clients {
err := client.WriteMessage(websocket.TextMessage, message)
if client.boardID == message.BoardID {
response, _ := json.Marshal(message)
err := client.conn.WriteMessage(websocket.TextMessage, response)
if err != nil {
log.Printf("Error broadcasting message: %v", err)
client.Close()
client.conn.Close()
delete(h.clients, client)
}
}
}
h.mutex.Unlock()
}
}
@ -81,20 +92,52 @@ func ChatHandler(app *App) http.HandlerFunc {
}
cookie, _ := r.Cookie("threadr_cookie_banner")
boardIDStr := r.URL.Query().Get("id")
boardID, err := strconv.Atoi(boardIDStr)
if err != nil {
http.Error(w, "Invalid board ID", http.StatusBadRequest)
return
}
board, err := models.GetBoardByID(app.DB, boardID)
if err != nil {
log.Printf("Error fetching board: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if board == nil {
http.Error(w, "Chat board not found", http.StatusNotFound)
return
}
if board.Type != "chat" {
http.Error(w, "This is not a chat board", http.StatusBadRequest)
return
}
if board.Private {
hasPerm, err := models.HasBoardPermission(app.DB, userID, boardID, models.PermViewBoard)
if err != nil {
log.Printf("Error checking permission: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !hasPerm {
http.Error(w, "You do not have permission to view this chat", http.StatusForbidden)
return
}
}
if r.URL.Query().Get("ws") == "true" {
// Handle WebSocket connection
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Error upgrading to WebSocket: %v", err)
return
}
hub.register <- ws
hub.mutex.Lock()
hub.clients[ws] = userID
hub.mutex.Unlock()
client := &Client{conn: ws, userID: userID, boardID: boardID}
hub.register <- client
defer func() {
hub.unregister <- ws
hub.unregister <- client
}()
for {
@ -115,6 +158,7 @@ func ChatHandler(app *App) http.HandlerFunc {
if chatMsg.Type == "message" {
msgObj := models.ChatMessage{
BoardID: boardID,
UserID: userID,
Content: chatMsg.Content,
ReplyTo: chatMsg.ReplyTo,
@ -123,7 +167,6 @@ func ChatHandler(app *App) http.HandlerFunc {
log.Printf("Error saving chat message: %v", err)
continue
}
// Fetch the saved message with timestamp and user details
var msgID int
app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
@ -131,47 +174,46 @@ func ChatHandler(app *App) http.HandlerFunc {
log.Printf("Error fetching saved message: %v", err)
continue
}
response, _ := json.Marshal(savedMsg)
hub.broadcast <- response
hub.broadcast <- *savedMsg
}
}
return
}
// Render chat page
messages, err := models.GetRecentChatMessages(app.DB, 50)
messages, err := models.GetRecentChatMessages(app.DB, boardID, 50)
if err != nil {
log.Printf("Error fetching chat messages: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Reverse messages to show oldest first
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
allUsernames, err := models.GetAllUsernames(app.DB)
allUsernames, err := models.GetUsernamesInBoard(app.DB, boardID)
if err != nil {
log.Printf("Error fetching all usernames: %v", err)
allUsernames = []string{} // Proceed without autocomplete on error
log.Printf("Error fetching usernames for board: %v", err)
allUsernames = []string{}
}
allUsernamesJSON, _ := json.Marshal(allUsernames)
data := struct {
PageData
Board models.Board
Messages []models.ChatMessage
AllUsernames template.JS
}{
PageData: PageData{
Title: "ThreadR - Chat",
Navbar: "chat",
Title: "ThreadR Chat - " + board.Name,
Navbar: "boards",
LoggedIn: true,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Board: *board,
Messages: messages,
AllUsernames: template.JS(allUsernamesJSON),
}

View File

@ -43,13 +43,14 @@ func createTablesIfNotExist(db *sql.DB) error {
public_visible BOOLEAN DEFAULT TRUE,
pinned_threads TEXT,
custom_landing_page TEXT,
color_scheme VARCHAR(255)
color_scheme VARCHAR(255),
type VARCHAR(20) DEFAULT 'classic' NOT NULL
)`)
if err != nil {
return fmt.Errorf("error creating boards table: %v", err)
}
// Create threads table (without type field)
// Create threads table
_, err = db.Exec(`
CREATE TABLE threads (
id INT AUTO_INCREMENT PRIMARY KEY,
@ -170,10 +171,12 @@ func createTablesIfNotExist(db *sql.DB) error {
_, err = db.Exec(`
CREATE TABLE chat_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
board_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL,
reply_to INT DEFAULT -1,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
)`)
if err != nil {
return fmt.Errorf("error creating chat_messages table: %v", err)

View File

@ -14,17 +14,18 @@ type Board struct {
PinnedThreads []int // Stored as JSON
CustomLandingPage string
ColorScheme string
Type string
}
func GetBoardByID(db *sql.DB, id int) (*Board, error) {
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE id = ?"
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE id = ?"
row := db.QueryRow(query, id)
board := &Board{}
var pinnedThreadsJSON sql.NullString
var customLandingPage sql.NullString
var colorScheme sql.NullString
var description sql.NullString
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
err := row.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
if err == sql.ErrNoRows {
return nil, nil
}
@ -56,7 +57,7 @@ func GetBoardByID(db *sql.DB, id int) (*Board, error) {
}
func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme FROM boards WHERE private = ? ORDER BY id ASC"
query := "SELECT id, name, description, private, public_visible, pinned_threads, custom_landing_page, color_scheme, type FROM boards WHERE private = ? ORDER BY id ASC"
rows, err := db.Query(query, private)
if err != nil {
return nil, err
@ -70,7 +71,7 @@ func GetAllBoards(db *sql.DB, private bool) ([]Board, error) {
var customLandingPage sql.NullString
var colorScheme sql.NullString
var description sql.NullString
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme)
err := rows.Scan(&board.ID, &board.Name, &description, &board.Private, &board.PublicVisible, &pinnedThreadsJSON, &customLandingPage, &colorScheme, &board.Type)
if err != nil {
return nil, err
}

View File

@ -8,6 +8,7 @@ import (
type ChatMessage struct {
ID int `json:"id"`
BoardID int `json:"boardId"`
UserID int `json:"userId"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
@ -18,19 +19,20 @@ type ChatMessage struct {
}
func CreateChatMessage(db *sql.DB, msg ChatMessage) error {
query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())"
_, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo)
query := "INSERT INTO chat_messages (board_id, user_id, content, reply_to, timestamp) VALUES (?, ?, ?, ?, NOW())"
_, err := db.Exec(query, msg.BoardID, msg.UserID, msg.Content, msg.ReplyTo)
return err
}
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, error) {
query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
FROM chat_messages cm
JOIN users u ON cm.user_id = u.id
WHERE cm.board_id = ?
ORDER BY cm.timestamp DESC
LIMIT ?`
rows, err := db.Query(query, limit)
rows, err := db.Query(query, boardID, limit)
if err != nil {
return nil, err
}
@ -44,6 +46,7 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
if err != nil {
return nil, err
}
msg.BoardID = boardID
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
if err != nil {
msg.Timestamp = time.Time{}
@ -56,14 +59,14 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
query := `
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
SELECT cm.id, cm.board_id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_file_id
FROM chat_messages cm
JOIN users u ON cm.user_id = u.id
WHERE cm.id = ?`
row := db.QueryRow(query, id)
var msg ChatMessage
var timestampStr string
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err == sql.ErrNoRows {
return nil, nil
}

View File

@ -154,9 +154,14 @@ func HasGlobalPermission(user *User, perm int64) bool {
return user.Permissions&perm != 0
}
func GetAllUsernames(db *sql.DB) ([]string, error) {
query := "SELECT username FROM users ORDER BY username ASC"
rows, err := db.Query(query)
func GetUsernamesInBoard(db *sql.DB, boardID int) ([]string, error) {
query := `
SELECT DISTINCT u.username
FROM users u
JOIN chat_messages cm ON u.id = cm.user_id
WHERE cm.board_id = ?
ORDER BY u.username ASC`
rows, err := db.Query(query, boardID)
if err != nil {
return nil, err
}

View File

@ -17,7 +17,11 @@
<ul class="board-list">
{{range .PublicBoards}}
<li class="board-item">
{{if eq .Type "chat"}}
<a href="{{$.BasePath}}/chat/?id={{.ID}}">{{.Name}} (Chat)</a>
{{else}}
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
{{end}}
<p class="board-desc">{{.Description}}</p>
</li>
{{end}}
@ -33,7 +37,11 @@
<ul class="board-list">
{{range .PrivateBoards}}
<li class="board-item">
{{if eq .Type "chat"}}
<a href="{{$.BasePath}}/chat/?id={{.ID}}">{{.Name}} (Chat)</a>
{{else}}
<a href="{{$.BasePath}}/board/?id={{.ID}}">{{.Name}}</a>
{{end}}
<p class="board-desc">{{.Description}}</p>
</li>
{{end}}
@ -51,6 +59,11 @@
<input type="text" id="name" name="name" required><br>
<label for="description">Description:</label>
<textarea id="description" name="description"></textarea><br>
<label for="type">Board Type:</label>
<select id="type" name="type">
<option value="classic">Classic Board</option>
<option value="chat">Chat Board</option>
</select><br>
<input type="submit" value="Create Board">
</form>
</section>

View File

@ -21,7 +21,7 @@
}
.chat-container {
width: 100%;
height: calc(100% - 2em); /* Adjust for header */
height: 100%;
display: flex;
flex-direction: column;
border: none;
@ -29,6 +29,11 @@
background-color: #fef6e4;
box-shadow: none;
}
.chat-header {
padding: 10px;
text-align: center;
border-bottom: 1px solid #001858;
}
.chat-messages {
flex: 1;
overflow-y: auto;
@ -170,6 +175,8 @@
@media (prefers-color-scheme: dark) {
.chat-container {
background-color: #444;
}
.chat-header {
border-color: #fef6e4;
}
.chat-message-username {
@ -216,10 +223,11 @@
<body>
{{template "navbar" .}}
<main>
<header style="display: none;">
<h2>General Chat</h2>
</header>
<div class="chat-container">
<header class="chat-header">
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>
</header>
<div class="chat-messages" id="chat-messages">
{{range .Messages}}
<div class="chat-message" id="msg-{{.ID}}">
@ -233,7 +241,7 @@
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
</div>
{{if gt .ReplyTo 0}}
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to {{.Username}}</div>
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div>
{{end}}
<div class="chat-message-content">{{.Content | html}}</div>
<div class="post-actions">
@ -261,14 +269,15 @@
const allUsernames = {{.AllUsernames}};
function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true');
const boardID = {{.Board.ID}};
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
ws.onmessage = function(event) {
const msg = JSON.parse(event.data);
appendMessage(msg);
};
ws.onclose = function() {
console.log("WebSocket closed, reconnecting...");
setTimeout(connectWebSocket, 5000); // Reconnect after 5s
setTimeout(connectWebSocket, 5000);
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
@ -302,7 +311,7 @@
if (msg.pfpFileId && msg.pfpFileId.Valid) {
pfpHTML = `<img src="{{.BasePath}}/file?id=${msg.pfpFileId.Int64}&t=${new Date().getTime()}" alt="PFP" class="chat-message-pfp">`;
}
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying...</div>` : '';
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
let content = msg.content.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
msgDiv.innerHTML = `
@ -454,9 +463,8 @@
window.onload = function() {
connectWebSocket();
// Highlight mentions in pre-loaded messages
document.querySelectorAll('.chat-message-content').forEach(function(el) {
const text = el.innerHTML; // The Go template already escaped it for security
const text = el.innerHTML;
const newHTML = text.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
el.innerHTML = newHTML;
});

View File

@ -4,7 +4,6 @@
{{if .LoggedIn}}
<li><a {{if eq .Navbar "userhome"}}class="active"{{end}} href="{{.BasePath}}/userhome/">User Home</a></li>
<li><a {{if eq .Navbar "profile"}}class="active"{{end}} href="{{.BasePath}}/profile/">Profile</a></li>
<li><a {{if eq .Navbar "chat"}}class="active"{{end}} href="{{.BasePath}}/chat/">Chat</a></li>
<li><a href="{{.BasePath}}/logout/">Logout</a></li>
{{else}}
<li><a {{if eq .Navbar "login"}}class="active"{{end}} href="{{.BasePath}}/login/">Login</a></li>