Initial implementation of the chat feature.
parent
e6f097d35c
commit
3b56c7e831
1
go.mod
1
go.mod
|
@ -5,6 +5,7 @@ go 1.24.0
|
|||
require (
|
||||
github.com/go-sql-driver/mysql v1.9.0
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
2
go.sum
2
go.sum
|
@ -8,3 +8,5 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
|
|||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
|
|
@ -41,14 +41,22 @@ func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
|
|||
session, err := app.Store.Get(r, "session-name")
|
||||
if err != nil {
|
||||
session = sessions.NewSession(app.Store, "session-name")
|
||||
session.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 30, // 30 days
|
||||
HttpOnly: true,
|
||||
}
|
||||
}
|
||||
if _, ok := session.Values["user_id"].(int); ok {
|
||||
if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() {
|
||||
session.Values = make(map[interface{}]interface{})
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
|
||||
return
|
||||
// Skip IP and User-Agent check for WebSocket connections
|
||||
if r.URL.Query().Get("ws") != "true" {
|
||||
if session.Values["user_ip"] != r.RemoteAddr || session.Values["user_agent"] != r.UserAgent() {
|
||||
session.Values = make(map[interface{}]interface{})
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=session", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "session", session)
|
||||
r = r.WithContext(ctx)
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"threadr/models"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins for now; restrict in production
|
||||
},
|
||||
}
|
||||
|
||||
// ChatHub manages WebSocket connections and broadcasts messages
|
||||
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
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ChatHub) Run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mutex.Lock()
|
||||
h.clients[client] = 0 // UserID set later
|
||||
h.mutex.Unlock()
|
||||
case client := <-h.unregister:
|
||||
h.mutex.Lock()
|
||||
delete(h.clients, client)
|
||||
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)
|
||||
}
|
||||
}
|
||||
h.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hub = NewChatHub()
|
||||
|
||||
func init() {
|
||||
go hub.Run()
|
||||
}
|
||||
|
||||
func ChatHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||
|
||||
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()
|
||||
|
||||
defer func() {
|
||||
hub.unregister <- ws
|
||||
}()
|
||||
|
||||
for {
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("Error reading WebSocket message: %v", err)
|
||||
break
|
||||
}
|
||||
var chatMsg struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
ReplyTo int `json:"replyTo"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &chatMsg); err != nil {
|
||||
log.Printf("Error unmarshaling message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if chatMsg.Type == "message" {
|
||||
msgObj := models.ChatMessage{
|
||||
UserID: userID,
|
||||
Content: chatMsg.Content,
|
||||
ReplyTo: chatMsg.ReplyTo,
|
||||
}
|
||||
if err := models.CreateChatMessage(app.DB, msgObj); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching saved message: %v", err)
|
||||
continue
|
||||
}
|
||||
response, _ := json.Marshal(savedMsg)
|
||||
hub.broadcast <- response
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("autocomplete") == "true" {
|
||||
// Handle autocomplete for mentions
|
||||
prefix := r.URL.Query().Get("prefix")
|
||||
usernames, err := models.GetUsernamesMatching(app.DB, prefix)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching usernames for autocomplete: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response, _ := json.Marshal(usernames)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(response)
|
||||
return
|
||||
}
|
||||
|
||||
// Render chat page
|
||||
messages, err := models.GetRecentChatMessages(app.DB, 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]
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Messages []models.ChatMessage
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Chat",
|
||||
Navbar: "chat",
|
||||
LoggedIn: true,
|
||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Messages: messages,
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
|
||||
log.Printf("Error executing template in ChatHandler: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,6 +27,11 @@ func LoginHandler(app *App) http.HandlerFunc {
|
|||
session.Values["user_id"] = user.ID
|
||||
session.Values["user_ip"] = r.RemoteAddr
|
||||
session.Values["user_agent"] = r.UserAgent()
|
||||
session.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 30, // 30 days
|
||||
HttpOnly: true,
|
||||
}
|
||||
if err := session.Save(r, w); err != nil {
|
||||
log.Printf("Error saving session: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
|
15
main.go
15
main.go
|
@ -182,6 +182,19 @@ func createTablesIfNotExist(db *sql.DB) error {
|
|||
return fmt.Errorf("error creating news table: %v", err)
|
||||
}
|
||||
|
||||
// Create chat_messages table
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
reply_to INT DEFAULT -1,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating chat_messages table: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Database tables created or already exist")
|
||||
return nil
|
||||
}
|
||||
|
@ -281,6 +294,7 @@ func main() {
|
|||
filepath.Join(dir, "templates/pages/signup.html"),
|
||||
filepath.Join(dir, "templates/pages/thread.html"),
|
||||
filepath.Join(dir, "templates/pages/userhome.html"),
|
||||
filepath.Join(dir, "templates/pages/chat.html"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Error parsing page templates:", err)
|
||||
|
@ -312,6 +326,7 @@ func main() {
|
|||
http.HandleFunc(config.ThreadrDir+"/news/", app.SessionMW(handlers.NewsHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/signup/", app.SessionMW(handlers.SignupHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/accept_cookie/", app.SessionMW(handlers.AcceptCookieHandler(app)))
|
||||
http.HandleFunc(config.ThreadrDir+"/chat/", app.SessionMW(app.RequireLoginMW(handlers.ChatHandler(app))))
|
||||
|
||||
log.Println("Server starting on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
ID int
|
||||
UserID int
|
||||
Content string
|
||||
ReplyTo int // -1 if not a reply
|
||||
Timestamp time.Time
|
||||
Username string // For display, fetched from user
|
||||
PfpURL string // For display, fetched from user
|
||||
Mentions []string // List of mentioned usernames
|
||||
}
|
||||
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) {
|
||||
query := `
|
||||
SELECT cm.id, cm.user_id, cm.content, cm.reply_to, cm.timestamp, u.username, u.pfp_url
|
||||
FROM chat_messages cm
|
||||
JOIN users u ON cm.user_id = u.id
|
||||
ORDER BY cm.timestamp DESC
|
||||
LIMIT ?`
|
||||
rows, err := db.Query(query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []ChatMessage
|
||||
for rows.Next() {
|
||||
var msg ChatMessage
|
||||
var timestampStr string
|
||||
var pfpURL sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
|
||||
if err != nil {
|
||||
msg.Timestamp = time.Time{}
|
||||
}
|
||||
if pfpURL.Valid {
|
||||
msg.PfpURL = pfpURL.String
|
||||
}
|
||||
// Parse mentions from content (simple @username detection)
|
||||
msg.Mentions = extractMentions(msg.Content)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
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_url
|
||||
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
|
||||
var pfpURL sql.NullString
|
||||
err := row.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &pfpURL)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Timestamp, err = time.Parse("2006-01-02 15:04:05", timestampStr)
|
||||
if err != nil {
|
||||
msg.Timestamp = time.Time{}
|
||||
}
|
||||
if pfpURL.Valid {
|
||||
msg.PfpURL = pfpURL.String
|
||||
}
|
||||
msg.Mentions = extractMentions(msg.Content)
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
|
||||
query := "SELECT username FROM users WHERE username LIKE ? LIMIT 10"
|
||||
rows, err := db.Query(query, prefix+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var usernames []string
|
||||
for rows.Next() {
|
||||
var username string
|
||||
if err := rows.Scan(&username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usernames = append(usernames, username)
|
||||
}
|
||||
return usernames, nil
|
||||
}
|
||||
|
||||
// Simple utility to extract mentions from content
|
||||
func extractMentions(content string) []string {
|
||||
var mentions []string
|
||||
var currentMention string
|
||||
inMention := false
|
||||
|
||||
for _, char := range content {
|
||||
if char == '@' {
|
||||
inMention = true
|
||||
currentMention = "@"
|
||||
} else if inMention && (char == ' ' || char == '\n' || char == '\t') {
|
||||
if len(currentMention) > 1 {
|
||||
mentions = append(mentions, currentMention)
|
||||
}
|
||||
inMention = false
|
||||
currentMention = ""
|
||||
} else if inMention {
|
||||
currentMention += string(char)
|
||||
}
|
||||
}
|
||||
if inMention && len(currentMention) > 1 {
|
||||
mentions = append(mentions, currentMention)
|
||||
}
|
||||
return mentions
|
||||
}
|
|
@ -0,0 +1,383 @@
|
|||
{{define "chat"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
padding: 0;
|
||||
margin-top: 3em; /* Space for navbar */
|
||||
height: calc(100vh - 3em);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
height: calc(100% - 2em); /* Adjust for header */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: #fef6e4;
|
||||
box-shadow: none;
|
||||
}
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-message {
|
||||
margin-bottom: 8px;
|
||||
max-width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
.chat-message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.chat-message-pfp {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.chat-message-username {
|
||||
font-weight: bold;
|
||||
color: #001858;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.chat-message-timestamp {
|
||||
font-size: 0.7em;
|
||||
color: #666;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.chat-message-content {
|
||||
background-color: #f3d2c1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.3;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.chat-message-reply {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 3px;
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-message-mention {
|
||||
color: #f582ae;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chat-input {
|
||||
padding: 8px;
|
||||
border-top: 1px solid #001858;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-input textarea {
|
||||
resize: none;
|
||||
height: 50px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.chat-input button {
|
||||
align-self: flex-end;
|
||||
width: auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.post-actions {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.chat-message:hover .post-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.post-actions a {
|
||||
color: #001858;
|
||||
text-decoration: none;
|
||||
font-size: 0.8em;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.post-actions a:hover {
|
||||
background-color: #8bd3dd;
|
||||
color: #fef6e4;
|
||||
}
|
||||
.autocomplete-popup {
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
border: 1px solid #001858;
|
||||
border-radius: 5px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
.autocomplete-item {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.autocomplete-item:hover {
|
||||
background-color: #f3d2c1;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-container {
|
||||
background-color: #444;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
.chat-message-username {
|
||||
color: #fef6e4;
|
||||
}
|
||||
.chat-message-timestamp {
|
||||
color: #aaa;
|
||||
}
|
||||
.chat-message-content {
|
||||
background-color: #555;
|
||||
}
|
||||
.chat-input {
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
.autocomplete-popup {
|
||||
background-color: #444;
|
||||
border-color: #fef6e4;
|
||||
color: #fef6e4;
|
||||
}
|
||||
.autocomplete-item:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
.post-actions a {
|
||||
color: #fef6e4;
|
||||
border-color: #fef6e4;
|
||||
}
|
||||
.post-actions a:hover {
|
||||
background-color: #8bd3dd;
|
||||
color: #001858;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
<header style="display: none;">
|
||||
<h2>General Chat</h2>
|
||||
</header>
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
{{range .Messages}}
|
||||
<div class="chat-message" id="msg-{{.ID}}">
|
||||
<div class="chat-message-header">
|
||||
{{if .PfpURL}}
|
||||
<img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp">
|
||||
{{else}}
|
||||
<div class="chat-message-pfp" style="background-color: #001858;"></div>
|
||||
{{end}}
|
||||
<span class="chat-message-username">{{.Username}}</span>
|
||||
<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 message #{{.ReplyTo}}</div>
|
||||
{{end}}
|
||||
<div class="chat-message-content">{{.Content | html}}</div>
|
||||
<div class="post-actions">
|
||||
<a href="javascript:void(0)" onclick="replyToMessage({{.ID}})">Reply</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
||||
<button onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
<script>
|
||||
let ws;
|
||||
let autocompleteActive = false;
|
||||
let autocompletePrefix = '';
|
||||
let replyToId = -1;
|
||||
|
||||
function connectWebSocket() {
|
||||
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' });
|
||||
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
|
||||
};
|
||||
ws.onerror = function(error) {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('chat-input-text');
|
||||
const content = input.value.trim();
|
||||
if (content === '') return;
|
||||
const msg = {
|
||||
type: 'message',
|
||||
content: content,
|
||||
replyTo: replyToId
|
||||
};
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
input.value = '';
|
||||
replyToId = -1;
|
||||
// Optionally, clear reply indicator UI here
|
||||
} else {
|
||||
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
const messages = document.getElementById('chat-messages');
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.className = 'chat-message';
|
||||
msgDiv.id = 'msg-' + msg.ID;
|
||||
let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP" class="chat-message-pfp">` : `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
|
||||
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to message #${msg.ReplyTo}</div>` : '';
|
||||
// Process content for mentions
|
||||
let content = msg.Content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`);
|
||||
msgDiv.innerHTML = `
|
||||
<div class="chat-message-header">
|
||||
${pfpHTML}
|
||||
<span class="chat-message-username">${msg.Username}</span>
|
||||
<span class="chat-message-timestamp">${new Date(msg.Timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
${replyHTML}
|
||||
<div class="chat-message-content">${content}</div>
|
||||
<div class="post-actions">
|
||||
<a href="javascript:void(0)" onclick="replyToMessage(${msg.ID})">Reply</a>
|
||||
</div>
|
||||
`;
|
||||
messages.appendChild(msgDiv);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function replyToMessage(id) {
|
||||
replyToId = id;
|
||||
// Optionally, show a UI indicator that a reply is set
|
||||
document.getElementById('chat-input-text').focus();
|
||||
}
|
||||
|
||||
function scrollToMessage(id) {
|
||||
const msgElement = document.getElementById('msg-' + id);
|
||||
if (msgElement) {
|
||||
msgElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function showAutocompletePopup(usernames, x, y) {
|
||||
const popup = document.getElementById('autocomplete-popup');
|
||||
popup.innerHTML = '';
|
||||
popup.style.left = x + 'px';
|
||||
popup.style.top = y + 'px';
|
||||
popup.style.display = 'block';
|
||||
autocompleteActive = true;
|
||||
usernames.forEach(username => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'autocomplete-item';
|
||||
item.textContent = username;
|
||||
item.onclick = () => {
|
||||
completeMention(username);
|
||||
popup.style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
};
|
||||
popup.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function completeMention(username) {
|
||||
const input = document.getElementById('chat-input-text');
|
||||
const text = input.value;
|
||||
const atIndex = text.lastIndexOf('@', input.selectionStart - 1);
|
||||
if (atIndex !== -1) {
|
||||
const before = text.substring(0, atIndex);
|
||||
const after = text.substring(input.selectionStart);
|
||||
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('chat-input-text').addEventListener('input', async (e) => {
|
||||
const text = e.target.value;
|
||||
const caretPos = e.target.selectionStart;
|
||||
const atIndex = text.lastIndexOf('@', caretPos - 1);
|
||||
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) {
|
||||
const prefix = text.substring(atIndex + 1, caretPos);
|
||||
autocompletePrefix = prefix;
|
||||
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
|
||||
const usernames = await response.json();
|
||||
if (usernames.length > 0) {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
// Approximate caret position (this is a rough estimate)
|
||||
const charWidth = 8; // Rough estimate of character width in pixels
|
||||
const caretX = rect.left + (caretPos - text.lastIndexOf('\n', caretPos - 1) - 1) * charWidth;
|
||||
showAutocompletePopup(usernames, caretX, rect.top - 10);
|
||||
} else {
|
||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
}
|
||||
} else {
|
||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
|
||||
if (autocompleteActive) {
|
||||
const popup = document.getElementById('autocomplete-popup');
|
||||
const items = popup.getElementsByClassName('autocomplete-item');
|
||||
if (e.key === 'Enter' && items.length > 0) {
|
||||
items[0].click();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowDown' && items.length > 0) {
|
||||
items[0].focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
sendMessage();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) {
|
||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Connect WebSocket on page load
|
||||
window.onload = function() {
|
||||
connectWebSocket();
|
||||
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
|
@ -4,6 +4,7 @@
|
|||
{{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>
|
||||
|
|
Loading…
Reference in New Issue