diff --git a/handlers/board.go b/handlers/board.go
index 374cf5d..8486949 100644
--- a/handlers/board.go
+++ b/handlers/board.go
@@ -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)
diff --git a/handlers/boards.go b/handlers/boards.go
index 54141c4..99d272f 100644
--- a/handlers/boards.go
+++ b/handlers/boards.go
@@ -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
}
diff --git a/handlers/chat.go b/handlers/chat.go
index 9553dba..d9b800f 100644
--- a/handlers/chat.go
+++ b/handlers/chat.go
@@ -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,21 +49,26 @@ 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()
- delete(h.clients, client)
+ 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 err != nil {
- log.Printf("Error broadcasting message: %v", err)
- client.Close()
- delete(h.clients, client)
+ 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.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),
}
diff --git a/main.go b/main.go
index 0fc557c..06ac7eb 100644
--- a/main.go
+++ b/main.go
@@ -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)
@@ -387,4 +390,4 @@ func main() {
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
-}
+}
\ No newline at end of file
diff --git a/models/board.go b/models/board.go
index fedbab5..375a35d 100644
--- a/models/board.go
+++ b/models/board.go
@@ -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
}
diff --git a/models/chat.go b/models/chat.go
index 3138e5a..8bcdb8f 100644
--- a/models/chat.go
+++ b/models/chat.go
@@ -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, ×tampStr, &msg.Username, &msg.PfpFileID)
+ err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID)
if err == sql.ErrNoRows {
return nil, nil
}
diff --git a/models/user.go b/models/user.go
index 8016a05..281867c 100644
--- a/models/user.go
+++ b/models/user.go
@@ -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
}
diff --git a/templates/pages/boards.html b/templates/pages/boards.html
index d4350e6..bbeaf0e 100644
--- a/templates/pages/boards.html
+++ b/templates/pages/boards.html
@@ -17,7 +17,11 @@
{{range .PublicBoards}}
-
+ {{if eq .Type "chat"}}
+ {{.Name}} (Chat)
+ {{else}}
{{.Name}}
+ {{end}}
{{.Description}}
{{end}}
@@ -33,7 +37,11 @@
{{range .PrivateBoards}}
-
+ {{if eq .Type "chat"}}
+ {{.Name}} (Chat)
+ {{else}}
{{.Name}}
+ {{end}}
{{.Description}}
{{end}}
@@ -51,6 +59,11 @@
+
+
diff --git a/templates/pages/chat.html b/templates/pages/chat.html
index cb44143..a932d62 100644
--- a/templates/pages/chat.html
+++ b/templates/pages/chat.html
@@ -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 @@
{{template "navbar" .}}
-
+
{{range .Messages}}
@@ -233,7 +241,7 @@
{{.Timestamp.Format "02/01/2006 15:04"}}
{{if gt .ReplyTo 0}}
-
Replying to {{.Username}}
+
Replying to message...
{{end}}
{{.Content | html}}
@@ -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 = `
.getTime()})
`;
}
- let replyHTML = msg.replyTo > 0 ? `
Replying...
` : '';
+ let replyHTML = msg.replyTo > 0 ? `
Replying to message...
` : '';
let content = msg.content.replace(/@(\w+)/g, '
@$1');
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, '
@$1');
el.innerHTML = newHTML;
});
diff --git a/templates/partials/navbar.html b/templates/partials/navbar.html
index 3d85f26..0f6af1d 100644
--- a/templates/partials/navbar.html
+++ b/templates/partials/navbar.html
@@ -4,7 +4,6 @@
{{if .LoggedIn}}
- User Home
- Profile
-
- Chat
- Logout
{{else}}
- Login