Compare commits

..

4 Commits

Author SHA1 Message Date
Joca 83113a563a
Chat: Fix message duplication by removing optimistic UI for sent messages
- Remove optimistic message rendering that was causing duplicates
- Messages now only appear after server broadcast confirmation
- Simplify appendMessage() to check for existing message IDs and prevent duplicates
- Remove message delivery status indicators (sending/sent/failed)
- Add user alert when attempting to send while disconnected

This fixes the issue where messages appeared twice - once as an optimistic
temporary message and again when the server broadcast was received.
2026-01-15 23:05:00 -03:00
Joca 935446280f
Chat: Add real-time connection status, typing indicators, smart scrolling, and message delivery status
Features added:
- Connection status indicator (green=connected, yellow=connecting, red=disconnected)
- Exponential backoff reconnection (1s → 2s → 4s → ... → 30s max)
- Typing indicators showing who is typing (up to 3 users, with overflow count)
- Message status (⋯ sending, ✓ sent, ✗ failed with retry option)
- Jump-to-bottom button with unread message count badge
- Smart scroll preservation (stays in place unless already at bottom)
- Optimistic UI for sent messages (appears immediately, updates with real status)
- Backend support for broadcasting typing events to other users
2026-01-15 23:00:31 -03:00
Joca d36d0d46fd
UI: Add breadcrumb navigation to chat page
- Breadcrumb bar above chat header (Home › Boards › Chat Name)
- Styled to match chat container with themed colors
- Includes dark mode support
2026-01-15 22:50:58 -03:00
Joca f7b8055062
UI: Add navigation improvements with breadcrumbs, back buttons, scroll-to-top, and keyboard shortcuts
- Breadcrumb navigation on board and thread pages (Home › Boards › Board › Thread)
- Back buttons to return to parent page (with arrow icon and hover effects)
- Scroll-to-top button appears after scrolling 300px (smooth animation)
- Keyboard shortcuts: Ctrl+Enter submits forms from textarea, Esc clears focus and closes notifications
- Optimistic UI for like/dislike buttons (immediate visual feedback with checkmark)
- Updated thread handler to pass board data for breadcrumb context
2026-01-15 22:49:54 -03:00
7 changed files with 714 additions and 249 deletions

View File

@ -83,9 +83,10 @@ func init() {
}
type IncomingChatMessage struct {
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
Username string `json:"username"`
}
func ChatHandler(app *App) http.HandlerFunc {
@ -170,7 +171,19 @@ func ChatHandler(app *App) http.HandlerFunc {
continue
}
if chatMsg.Type == "message" {
if chatMsg.Type == "typing" {
// Broadcast typing indicator to other clients
typingMsg := map[string]interface{}{
"type": "typing",
"username": chatMsg.Username,
}
typingJSON, _ := json.Marshal(typingMsg)
for c := range hub.clients {
if c.boardID == boardID && c.userID != userID {
c.conn.WriteMessage(websocket.TextMessage, typingJSON)
}
}
} else if chatMsg.Type == "message" {
if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil {
log.Printf("Error saving chat message: %v", err)
continue

View File

@ -1,135 +1,137 @@
package handlers
import (
"log"
"net/http"
"strconv"
"threadr/models"
"github.com/gorilla/sessions"
"github.com/gorilla/sessions"
"log"
"net/http"
"strconv"
"threadr/models"
)
func ThreadHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
userID, _ := session.Values["user_id"].(int)
cookie, _ := r.Cookie("threadr_cookie_banner")
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
loggedIn := session.Values["user_id"] != nil
userID, _ := session.Values["user_id"].(int)
cookie, _ := r.Cookie("threadr_cookie_banner")
threadIDStr := r.URL.Query().Get("id")
threadID, err := strconv.Atoi(threadIDStr)
if err != nil {
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
return
}
threadIDStr := r.URL.Query().Get("id")
threadID, err := strconv.Atoi(threadIDStr)
if err != nil {
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
return
}
thread, err := models.GetThreadByID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching thread: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if thread == nil {
http.Error(w, "Thread not found", http.StatusNotFound)
return
}
thread, err := models.GetThreadByID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching thread: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if thread == nil {
http.Error(w, "Thread not found", http.StatusNotFound)
return
}
board, err := models.GetBoardByID(app.DB, thread.BoardID)
if err != nil {
log.Printf("Error fetching board: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, 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 board", http.StatusForbidden)
return
}
}
board, err := models.GetBoardByID(app.DB, thread.BoardID)
if err != nil {
log.Printf("Error fetching board: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if board.Private {
if !loggedIn {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
return
}
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, 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 board", http.StatusForbidden)
return
}
}
if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action")
if action == "submit" {
content := r.FormValue("content")
replyToStr := r.URL.Query().Get("to")
replyTo := -1
if replyToStr != "" {
replyTo, err = strconv.Atoi(replyToStr)
if err != nil {
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
return
}
}
if content == "" {
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
return
}
if board.Private {
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
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 post in this board", http.StatusForbidden)
return
}
}
post := models.Post{
ThreadID: threadID,
UserID: userID,
Content: content,
ReplyTo: replyTo,
}
err = models.CreatePost(app.DB, post)
if err != nil {
log.Printf("Error creating post: %v", err)
http.Error(w, "Failed to create post", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
return
}
}
if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action")
if action == "submit" {
content := r.FormValue("content")
replyToStr := r.URL.Query().Get("to")
replyTo := -1
if replyToStr != "" {
replyTo, err = strconv.Atoi(replyToStr)
if err != nil {
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
return
}
}
if content == "" {
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
return
}
if board.Private {
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
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 post in this board", http.StatusForbidden)
return
}
}
post := models.Post{
ThreadID: threadID,
UserID: userID,
Content: content,
ReplyTo: replyTo,
}
err = models.CreatePost(app.DB, post)
if err != nil {
log.Printf("Error creating post: %v", err)
http.Error(w, "Failed to create post", http.StatusInternalServerError)
return
}
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
return
}
}
posts, err := models.GetPostsByThreadID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching posts: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
posts, err := models.GetPostsByThreadID(app.DB, threadID)
if err != nil {
log.Printf("Error fetching posts: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := struct {
PageData
Thread models.Thread
Posts []models.Post
}{
PageData: PageData{
Title: "ThreadR - " + thread.Title,
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Thread: *thread,
Posts: posts,
}
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
log.Printf("Error executing template in ThreadHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
data := struct {
PageData
Thread models.Thread
Board models.Board
Posts []models.Post
}{
PageData: PageData{
Title: "ThreadR - " + thread.Title,
Navbar: "boards",
LoggedIn: loggedIn,
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.Path,
},
Thread: *thread,
Board: *board,
Posts: posts,
}
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
log.Printf("Error executing template in ThreadHandler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

View File

@ -45,125 +45,21 @@ function enableEnterToSubmit(input, form) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
}
// Auto-resize textarea as user types
function autoResizeTextarea(textarea) {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
// Add character counter to textarea
function addCharacterCounter(textarea, maxLength) {
const counter = document.createElement('div');
counter.className = 'char-counter';
textarea.parentNode.insertBefore(counter, textarea.nextSibling);
function updateCounter() {
const length = textarea.value.length;
counter.textContent = `${length}${maxLength ? '/' + maxLength : ''} characters`;
if (maxLength && length > maxLength * 0.9) {
counter.classList.add('warning');
} else {
counter.classList.remove('warning');
}
}
textarea.addEventListener('input', updateCounter);
updateCounter();
}
// Client-side validation helpers
function validateUsername(username) {
if (username.length < 3) {
return 'Username must be at least 3 characters';
}
if (username.length > 30) {
return 'Username must be less than 30 characters';
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return 'Username can only contain letters, numbers, underscores, and hyphens';
}
return null;
}
function validatePassword(password) {
if (password.length < 8) {
return 'Password must be at least 8 characters';
}
if (password.length > 128) {
return 'Password is too long';
}
return null;
}
function validateRequired(value, fieldName) {
if (!value || value.trim() === '') {
return `${fieldName} is required`;
}
return null;
}
// Show field error
function showFieldError(input, message) {
input.classList.add('error');
// Remove existing error message
const existingError = input.parentNode.querySelector('.field-error');
if (existingError) {
existingError.remove();
}
if (message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'field-error';
errorDiv.textContent = message;
input.parentNode.insertBefore(errorDiv, input.nextSibling);
}
}
// Clear field error
function clearFieldError(input) {
input.classList.remove('error');
const errorDiv = input.parentNode.querySelector('.field-error');
if (errorDiv) {
errorDiv.remove();
}
}
// Relative time formatting
function formatRelativeTime(date) {
const now = new Date();
const diff = now - date;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (seconds < 60) return 'just now';
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
if (days < 30) return `${days} day${days !== 1 ? 's' : ''} ago`;
if (months < 12) return `${months} month${months !== 1 ? 's' : ''} ago`;
return `${years} year${years !== 1 ? 's' : ''} ago`;
}
// Convert timestamps to relative time
function initRelativeTimestamps() {
document.querySelectorAll('[data-timestamp]').forEach(element => {
const timestamp = element.getAttribute('data-timestamp');
const date = new Date(timestamp);
const originalText = element.textContent;
element.textContent = formatRelativeTime(date);
element.title = originalText;
element.style.cursor = 'help';
}
});
}
// Optimistic UI for like/dislike buttons
document.querySelectorAll('form[action*="/like/"]').forEach(form => {
form.addEventListener('submit', (e) => {
const button = form.querySelector('button[type="submit"]');
if (button) {
button.style.opacity = '0.5';
button.textContent = button.textContent + ' ✓';
}
});
});
});
}
// Initialize on DOM ready
@ -173,8 +69,70 @@ if (document.readyState === 'loading') {
initRelativeTimestamps();
}
// Scroll to top button functionality
function initScrollToTop() {
const scrollButton = document.createElement('button');
scrollButton.className = 'scroll-to-top';
scrollButton.innerHTML = '↑';
scrollButton.setAttribute('aria-label', 'Scroll to top');
scrollButton.title = 'Scroll to top';
document.body.appendChild(scrollButton);
// Show/hide button based on scroll position
window.addEventListener('scroll', () => {
if (window.pageYOffset > 300) {
scrollButton.classList.add('visible');
} else {
scrollButton.classList.remove('visible');
}
});
// Scroll to top when clicked
scrollButton.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
}
// Keyboard shortcuts
function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl+Enter or Cmd+Enter to submit forms
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
const activeElement = document.activeElement;
if (activeElement && activeElement.tagName === 'TEXTAREA') {
const form = activeElement.closest('form');
if (form) {
e.preventDefault();
form.requestSubmit();
}
}
}
// Escape to cancel/close things
if (e.key === 'Escape') {
// Clear any active focus from textareas
if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') {
document.activeElement.blur();
}
// Hide notifications
document.querySelectorAll('.notification').forEach(notif => {
notif.classList.add('hiding');
setTimeout(() => notif.remove(), 300);
});
}
});
}
// Add form submission handlers
document.addEventListener('DOMContentLoaded', () => {
// Initialize scroll to top and keyboard shortcuts
initScrollToTop();
initKeyboardShortcuts();
// Handle all form submissions
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', (e) => {

View File

@ -435,6 +435,128 @@ p.thread-info {
}
}
/* Breadcrumb navigation */
.breadcrumb {
padding: 8px 0;
margin-bottom: 1em;
font-size: 0.95em;
}
.breadcrumb a {
color: #001858;
text-decoration: none;
transition: color 0.2s ease;
}
.breadcrumb a:hover {
color: #f582ae;
text-decoration: underline;
}
.breadcrumb-separator {
margin: 0 8px;
color: #001858;
opacity: 0.6;
}
.breadcrumb-current {
color: #001858;
opacity: 0.7;
}
/* Back button */
.back-button {
display: inline-block;
padding: 8px 16px;
margin-bottom: 1em;
background-color: #f3d2c1;
color: #001858;
text-decoration: none;
border: 1px solid #001858;
border-radius: 5px;
transition: all 0.2s ease;
font-size: 0.9em;
}
.back-button:hover {
background-color: #8bd3dd;
transform: translateY(-2px);
box-shadow: 0px 4px 8px rgba(0,0,0,0.15);
}
.back-button::before {
content: "← ";
}
/* Scroll to top button */
.scroll-to-top {
position: fixed;
bottom: 30px;
right: 30px;
width: 50px;
height: 50px;
background-color: #001858;
color: #fef6e4;
border: 2px solid #001858;
border-radius: 50%;
cursor: pointer;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.2s ease;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5em;
font-weight: bold;
}
.scroll-to-top.visible {
opacity: 0.8;
visibility: visible;
}
.scroll-to-top:hover {
opacity: 1;
transform: translateY(-3px);
background-color: #8bd3dd;
}
@media (prefers-color-scheme: dark) {
.breadcrumb a {
color: #fef6e4;
}
.breadcrumb-separator {
color: #fef6e4;
}
.breadcrumb-current {
color: #fef6e4;
}
.back-button {
background-color: #555;
color: #fef6e4;
border-color: #fef6e4;
}
.back-button:hover {
background-color: #8bd3dd;
color: #001858;
}
.scroll-to-top {
background-color: #fef6e4;
color: #001858;
border-color: #fef6e4;
}
.scroll-to-top:hover {
background-color: #8bd3dd;
}
}
/* Loading spinner */
.spinner {
display: inline-block;

View File

@ -9,6 +9,14 @@
<body>
{{template "navbar" .}}
<main>
<div class="breadcrumb">
<a href="{{.BasePath}}/">Home</a>
<span class="breadcrumb-separator"></span>
<a href="{{.BasePath}}/boards/">Boards</a>
<span class="breadcrumb-separator"></span>
<span class="breadcrumb-current">{{.Board.Name}}</span>
</div>
<a href="{{.BasePath}}/boards/" class="back-button">Back to Boards</a>
<header>
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
<style>
body {
margin: 0;
@ -33,6 +34,145 @@
padding: 10px;
text-align: center;
border-bottom: 1px solid #001858;
position: relative;
}
.connection-status {
position: absolute;
top: 10px;
right: 10px;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
padding: 4px 10px;
border-radius: 12px;
background-color: rgba(0,0,0,0.1);
}
.connection-dot {
width: 10px;
height: 10px;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
.connection-dot.connected {
background-color: #4ade80;
}
.connection-dot.connecting {
background-color: #fbbf24;
}
.connection-dot.disconnected {
background-color: #f87171;
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.typing-indicator {
padding: 8px 12px;
background-color: #f3d2c1;
border-radius: 5px;
font-size: 0.85em;
font-style: italic;
color: #001858;
margin-bottom: 8px;
display: none;
}
.typing-indicator.visible {
display: block;
}
.typing-dots {
display: inline-block;
margin-left: 4px;
}
.typing-dots span {
animation: typing-blink 1.4s infinite;
display: inline-block;
}
.typing-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-blink {
0%, 60%, 100% { opacity: 0.3; }
30% { opacity: 1; }
}
.jump-to-bottom {
position: absolute;
bottom: 70px;
right: 20px;
background-color: #001858;
color: #fef6e4;
border: 2px solid #001858;
border-radius: 50%;
width: 45px;
height: 45px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 1.3em;
box-shadow: 0px 4px 12px rgba(0,0,0,0.3);
transition: transform 0.2s ease;
z-index: 100;
}
.jump-to-bottom.visible {
display: flex;
}
.jump-to-bottom:hover {
transform: translateY(2px);
background-color: #8bd3dd;
}
.jump-to-bottom .unread-badge {
position: absolute;
top: -5px;
right: -5px;
background-color: #f582ae;
color: #fef6e4;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7em;
font-weight: bold;
}
.message-status {
font-size: 0.7em;
color: #666;
margin-left: 8px;
}
.message-status.sending {
color: #fbbf24;
}
.message-status.sent {
color: #4ade80;
}
.message-status.failed {
color: #f87171;
cursor: pointer;
}
.chat-breadcrumb {
background-color: #f3d2c1;
padding: 8px 12px;
border-bottom: 1px solid #001858;
font-size: 0.85em;
text-align: left;
}
.chat-breadcrumb a {
color: #001858;
text-decoration: none;
}
.chat-breadcrumb a:hover {
color: #f582ae;
text-decoration: underline;
}
.chat-breadcrumb-separator {
margin: 0 6px;
opacity: 0.6;
}
.chat-messages {
flex: 1;
@ -195,6 +335,24 @@
.chat-container {
background-color: #444;
}
.chat-breadcrumb {
background-color: #555;
}
.chat-breadcrumb a {
color: #fef6e4;
}
.typing-indicator {
background-color: #555;
color: #fef6e4;
}
.jump-to-bottom {
background-color: #fef6e4;
color: #001858;
border-color: #fef6e4;
}
.jump-to-bottom:hover {
background-color: #8bd3dd;
}
.chat-header {
border-color: #fef6e4;
}
@ -259,11 +417,25 @@
{{template "navbar" .}}
<main>
<div class="chat-container">
<div class="chat-breadcrumb">
<a href="{{.BasePath}}/">Home</a>
<span class="chat-breadcrumb-separator"></span>
<a href="{{.BasePath}}/boards/">Boards</a>
<span class="chat-breadcrumb-separator"></span>
<span>{{.Board.Name}}</span>
</div>
<header class="chat-header">
<div class="connection-status">
<div class="connection-dot connecting" id="connection-dot"></div>
<span id="connection-text">Connecting...</span>
</div>
<h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p>
</header>
<div class="chat-messages" id="chat-messages">
<div class="typing-indicator" id="typing-indicator">
<span id="typing-users"></span><span class="typing-dots"><span>.</span><span>.</span><span>.</span></span>
</div>
{{range .Messages}}
<div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}">
<div class="chat-message-header">
@ -285,6 +457,10 @@
</div>
{{end}}
</div>
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom">
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
</button>
<div class="chat-input">
<div id="reply-indicator" class="reply-indicator">
<span id="reply-username">Replying to </span>
@ -301,45 +477,149 @@
let ws;
let autocompleteActive = false;
let replyToId = -1;
let reconnectAttempts = 0;
let reconnectTimeout;
let typingTimeout;
let isUserAtBottom = true;
let unreadCount = 0;
const allUsernames = {{.AllUsernames}};
const currentUsername = "{{.CurrentUsername}}";
const maxReconnectDelay = 30000; // 30 seconds max
// Update connection status
function updateConnectionStatus(status) {
const dot = document.getElementById('connection-dot');
const text = document.getElementById('connection-text');
dot.className = 'connection-dot ' + status;
if (status === 'connected') {
text.textContent = 'Connected';
reconnectAttempts = 0;
} else if (status === 'connecting') {
text.textContent = 'Connecting...';
} else if (status === 'disconnected') {
text.textContent = 'Disconnected';
}
}
function connectWebSocket() {
const boardID = {{.Board.ID}};
updateConnectionStatus('connecting');
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
ws.onopen = function() {
updateConnectionStatus('connected');
reconnectAttempts = 0;
};
ws.onmessage = function(event) {
const msg = JSON.parse(event.data);
// Handle typing indicator
if (msg.type === 'typing') {
showTypingIndicator(msg.username);
return;
}
// Handle regular message
appendMessage(msg);
};
ws.onclose = function() {
updateConnectionStatus('disconnected');
console.log("WebSocket closed, reconnecting...");
setTimeout(connectWebSocket, 5000);
scheduleReconnect();
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
updateConnectionStatus('disconnected');
};
}
// Reconnect with exponential backoff
function scheduleReconnect() {
if (reconnectTimeout) clearTimeout(reconnectTimeout);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
reconnectAttempts++;
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`);
reconnectTimeout = setTimeout(connectWebSocket, delay);
}
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 = '';
cancelReply();
} else {
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
alert("Cannot send message: Not connected to chat server");
}
}
// Typing indicator
let typingUsers = new Set();
function sendTypingIndicator() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'typing', username: currentUsername }));
}
}
function showTypingIndicator(username) {
if (username === currentUsername) return;
typingUsers.add(username);
updateTypingDisplay();
// Clear after 3 seconds
setTimeout(() => {
typingUsers.delete(username);
updateTypingDisplay();
}, 3000);
}
function updateTypingDisplay() {
const indicator = document.getElementById('typing-indicator');
const usersSpan = document.getElementById('typing-users');
if (typingUsers.size === 0) {
indicator.classList.remove('visible');
} else {
const users = Array.from(typingUsers);
if (users.length === 1) {
usersSpan.textContent = `${users[0]} is typing`;
} else if (users.length === 2) {
usersSpan.textContent = `${users[0]} and ${users[1]} are typing`;
} else {
usersSpan.textContent = `${users[0]}, ${users[1]}, and ${users.length - 2} other${users.length - 2 > 1 ? 's' : ''} are typing`;
}
indicator.classList.add('visible');
}
}
function appendMessage(msg) {
const messages = document.getElementById('chat-messages');
// Check if message already exists (prevent duplicates)
if (document.getElementById('msg-' + msg.id)) {
return; // Don't add duplicate messages
}
// Check if user is at bottom before adding message
const wasAtBottom = isUserAtBottom;
const msgDiv = document.createElement('div');
let highlightClass = '';
if (msg.mentions && msg.mentions.includes(currentUsername)) {
@ -366,9 +646,60 @@
</div>
`;
messages.appendChild(msgDiv);
messages.scrollTop = messages.scrollHeight;
// Scroll handling
if (wasAtBottom) {
messages.scrollTop = messages.scrollHeight;
unreadCount = 0;
updateUnreadBadge();
} else {
// User is not at bottom, increment unread
if (msg.username !== currentUsername) {
unreadCount++;
updateUnreadBadge();
}
}
}
// Jump to bottom functionality
function updateUnreadBadge() {
const badge = document.getElementById('unread-badge');
if (unreadCount > 0) {
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function jumpToBottom() {
const messages = document.getElementById('chat-messages');
messages.scrollTo({
top: messages.scrollHeight,
behavior: 'smooth'
});
unreadCount = 0;
updateUnreadBadge();
}
// Detect if user is at bottom
function checkScrollPosition() {
const messages = document.getElementById('chat-messages');
const jumpButton = document.getElementById('jump-to-bottom');
const threshold = 100;
isUserAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < threshold;
if (isUserAtBottom) {
jumpButton.classList.remove('visible');
unreadCount = 0;
updateUnreadBadge();
} else {
jumpButton.classList.add('visible');
}
}
function replyToMessage(id, username) {
replyToId = id;
const replyIndicator = document.getElementById('reply-indicator');
@ -500,7 +831,28 @@
window.onload = function() {
connectWebSocket();
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
const messagesContainer = document.getElementById('chat-messages');
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Add scroll event listener
messagesContainer.addEventListener('scroll', checkScrollPosition);
// Add jump to bottom button click handler
document.getElementById('jump-to-bottom').addEventListener('click', jumpToBottom);
// Add typing indicator on input
const chatInput = document.getElementById('chat-input-text');
let lastTypingTime = 0;
chatInput.addEventListener('input', () => {
const now = Date.now();
if (now - lastTypingTime > 2000) {
sendTypingIndicator();
lastTypingTime = now;
}
});
// Initial check for scroll position
checkScrollPosition();
};
</script>
</body>

View File

@ -9,6 +9,16 @@
<body>
{{template "navbar" .}}
<main>
<div class="breadcrumb">
<a href="{{.BasePath}}/">Home</a>
<span class="breadcrumb-separator"></span>
<a href="{{.BasePath}}/boards/">Boards</a>
<span class="breadcrumb-separator"></span>
<a href="{{.BasePath}}/board/?id={{.Board.ID}}">{{.Board.Name}}</a>
<span class="breadcrumb-separator"></span>
<span class="breadcrumb-current">{{.Thread.Title}}</span>
</div>
<a href="{{.BasePath}}/board/?id={{.Board.ID}}" class="back-button">Back to {{.Board.Name}}</a>
<header>
<h2>{{.Thread.Title}}</h2>
</header>