feat: realtime status, mentions, push notifications
This commit is contained in:
+1
-1
@@ -93,7 +93,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auth handler
|
// Auth handler
|
||||||
authHandler := auth.NewHandler(database.DB, cfg)
|
authHandler := auth.NewHandler(database.DB, cfg, hub)
|
||||||
|
|
||||||
// Permissions checker
|
// Permissions checker
|
||||||
permissionsChecker := permissions.NewChecker(database.DB)
|
permissionsChecker := permissions.NewChecker(database.DB)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -19,13 +20,15 @@ type Handler struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
sessions *SessionStore
|
sessions *SessionStore
|
||||||
|
hub *gateway.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *sql.DB, cfg *config.Config) *Handler {
|
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
sessions: NewSessionStore(db, cfg),
|
sessions: NewSessionStore(db, cfg),
|
||||||
|
hub: hub,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ type updateProfileRequest struct {
|
|||||||
Bio string `json:"bio"`
|
Bio string `json:"bio"`
|
||||||
AccentColor string `json:"accent_color"`
|
AccentColor string `json:"accent_color"`
|
||||||
StatusText string `json:"status_text"`
|
StatusText string `json:"status_text"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type changePasswordRequest struct {
|
type changePasswordRequest struct {
|
||||||
@@ -279,17 +283,29 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.Status != "" && !isValidStatus(req.Status) {
|
||||||
|
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_, err := h.db.ExecContext(r.Context(), `
|
_, err := h.db.ExecContext(r.Context(), `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5
|
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status)
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText)
|
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Status != "" && h.hub != nil {
|
||||||
|
user, err := h.getProfile(r.Context(), userID)
|
||||||
|
if err == nil {
|
||||||
|
h.hub.SetUserStatus(userID, req.Status)
|
||||||
|
h.hub.BroadcastPresence(userID, user.Username, req.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user, err := h.getProfile(r.Context(), userID)
|
user, err := h.getProfile(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
@@ -300,6 +316,14 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(user)
|
json.NewEncoder(w).Encode(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isValidStatus(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case "online", "idle", "dnd", "offline":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, error) {
|
func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, error) {
|
||||||
var user UserProfile
|
var user UserProfile
|
||||||
return user, h.db.QueryRowContext(ctx, `
|
return user, h.db.QueryRowContext(ctx, `
|
||||||
|
|||||||
+38
-10
@@ -19,6 +19,7 @@ const (
|
|||||||
// PresenceData is the data payload for PRESENCE_UPDATE events.
|
// PresenceData is the data payload for PRESENCE_UPDATE events.
|
||||||
type PresenceData struct {
|
type PresenceData struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,16 +163,7 @@ func (h *Hub) checkIdleUsers() {
|
|||||||
|
|
||||||
// setUserStatus persists the user's status to the database.
|
// setUserStatus persists the user's status to the database.
|
||||||
func (h *Hub) setUserStatus(userID, status string) {
|
func (h *Hub) setUserStatus(userID, status string) {
|
||||||
if h.db == nil {
|
setUserStatus(h.db, h.logger, userID, status)
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := h.db.ExecContext(ctx, `UPDATE users SET status = $1 WHERE id = $2`, status, userID)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("failed to update user status", "user_id", userID, "status", status, "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastPresence sends a PRESENCE_UPDATE event to all connected clients.
|
// broadcastPresence sends a PRESENCE_UPDATE event to all connected clients.
|
||||||
@@ -185,6 +177,29 @@ func (h *Hub) broadcastPresence(userID, status string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserStatus is the exported form of setUserStatus, used by non-gateway
|
||||||
|
// handlers that need to persist a status change.
|
||||||
|
func (h *Hub) SetUserStatus(userID, status string) {
|
||||||
|
h.setUserStatus(userID, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastPresence sends a PRESENCE_UPDATE event including the username.
|
||||||
|
func (h *Hub) BroadcastPresence(userID, username, status string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.presence[userID] = status
|
||||||
|
h.lastActivity[userID] = time.Now()
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
h.BroadcastEvent(Event{
|
||||||
|
Type: EventPresenceUpdate,
|
||||||
|
Data: PresenceData{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Status: status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Register queues a client for registration with the hub.
|
// Register queues a client for registration with the hub.
|
||||||
func (h *Hub) Register(client *Client) {
|
func (h *Hub) Register(client *Client) {
|
||||||
h.register <- client
|
h.register <- client
|
||||||
@@ -273,3 +288,16 @@ func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string,
|
|||||||
err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
|
err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
|
||||||
return serverID, err
|
return serverID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setUserStatus(db *sql.DB, logger *slog.Logger, userID, status string) {
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := db.ExecContext(ctx, `UPDATE users SET status = $1 WHERE id = $2`, status, userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to update user status", "user_id", userID, "status", status, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ type Handler struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
hub *gateway.Hub
|
hub *gateway.Hub
|
||||||
mentions *MentionHandler
|
mentions *MentionHandler
|
||||||
|
pushHandler *push.Handler
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler_old struct {
|
||||||
|
db *sql.DB
|
||||||
|
hub *gateway.Hub
|
||||||
|
mentions *MentionHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
|
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
|
||||||
@@ -137,6 +145,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Dispatch push notifications for @mentions
|
// Dispatch push notifications for @mentions
|
||||||
go h.mentions.ParseAndNotify(r.Context(), channelID, userID, req.Content)
|
go h.mentions.ParseAndNotify(r.Context(), channelID, userID, req.Content)
|
||||||
|
|
||||||
|
// Dispatch push notifications to channel members
|
||||||
|
go func() {
|
||||||
|
var channelName string
|
||||||
|
_ = h.db.QueryRowContext(context.Background(), `SELECT name FROM channels WHERE id = $1`, channelID).Scan(&channelName)
|
||||||
|
if channelName == "" {
|
||||||
|
channelName = channelID
|
||||||
|
}
|
||||||
|
h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content)
|
||||||
|
}()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(msg)
|
json.NewEncoder(w).Encode(msg)
|
||||||
|
|||||||
@@ -183,6 +183,77 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// SendChannelNotification sends a push notification to all server members (except the author)
|
||||||
|
// when a new message is posted in a channel.
|
||||||
|
func (h *Handler) SendChannelNotification(ctx context.Context, channelID, authorID, channelName, content string) {
|
||||||
|
if h.vapidPub == "" || h.vapidPriv == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverID string
|
||||||
|
err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to find channel server", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := content
|
||||||
|
if len(body) > 200 {
|
||||||
|
body = body[:200] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"title": "New message in #" + channelName,
|
||||||
|
"body": body,
|
||||||
|
"url": "/channels/" + channelID,
|
||||||
|
})
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth
|
||||||
|
FROM push_subscriptions ps
|
||||||
|
JOIN members m ON m.user_id = ps.user_id
|
||||||
|
WHERE m.server_id = $1 AND ps.user_id != $2
|
||||||
|
`, serverID, authorID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to query push subscriptions", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var userID, endpoint, p256dh, auth string
|
||||||
|
if err := rows.Scan(&userID, &endpoint, &p256dh, &auth); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := &webpush.Subscription{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Keys: webpush.Keys{
|
||||||
|
P256dh: p256dh,
|
||||||
|
Auth: auth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := webpush.SendNotification(payload, sub, &webpush.Options{
|
||||||
|
Subscriber: h.vapidSubj,
|
||||||
|
VAPIDPublicKey: h.vapidPub,
|
||||||
|
VAPIDPrivateKey: h.vapidPriv,
|
||||||
|
TTL: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to send push", "error", err, "user_id", userID)
|
||||||
|
if resp != nil && resp.StatusCode == 410 {
|
||||||
|
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// GenerateVAPIDKeys generates a new VAPID key pair.
|
// GenerateVAPIDKeys generates a new VAPID key pair.
|
||||||
func GenerateVAPIDKeys() (publicKey, privateKey string, err error) {
|
func GenerateVAPIDKeys() (publicKey, privateKey string, err error) {
|
||||||
privateKeyBytes := make([]byte, 32)
|
privateKeyBytes := make([]byte, 32)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
import { useMessageStore } from "../stores/message.ts";
|
import { useMessageStore } from "../stores/message.ts";
|
||||||
import { useChannelStore } from "../stores/channel.ts";
|
import { useChannelStore } from "../stores/channel.ts";
|
||||||
import { useServerStore } from "../stores/server.ts";
|
import { useServerStore } from "../stores/server.ts";
|
||||||
|
import { useMemberStore } from "../stores/member.ts";
|
||||||
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
||||||
|
import { MentionDropdown } from "./MentionDropdown";
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
@@ -11,6 +13,41 @@ function formatTime(iso: string): string {
|
|||||||
return `${hours}:${minutes}`;
|
return `${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderContent(content: string, memberUsernames: Set<string>) {
|
||||||
|
// Split on @username, preserving mentions.
|
||||||
|
const parts: (string | { mention: string })[] = [];
|
||||||
|
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
||||||
|
let last = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = mentionRe.exec(content)) !== null) {
|
||||||
|
if (match.index > last) {
|
||||||
|
parts.push(content.slice(last, match.index));
|
||||||
|
}
|
||||||
|
const username = match[1];
|
||||||
|
if (memberUsernames.has(username)) {
|
||||||
|
parts.push({ mention: username });
|
||||||
|
} else {
|
||||||
|
parts.push(match[0]);
|
||||||
|
}
|
||||||
|
last = mentionRe.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < content.length) {
|
||||||
|
parts.push(content.slice(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.map((part, idx) => {
|
||||||
|
if (typeof part === "string") {
|
||||||
|
return <span key={idx}>{part}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span key={idx} className="text-gb-aqua">
|
||||||
|
@{part.mention}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatArea() {
|
export function ChatArea() {
|
||||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||||
@@ -21,15 +58,23 @@ export function ChatArea() {
|
|||||||
const isLoading = useMessageStore((s) => s.isLoading);
|
const isLoading = useMessageStore((s) => s.isLoading);
|
||||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
const sendMessage = useMessageStore((s) => s.sendMessage);
|
||||||
|
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showGifPicker, setShowGifPicker] = useState(false);
|
const [showGifPicker, setShowGifPicker] = useState(false);
|
||||||
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const channels = activeServerId
|
const channels = activeServerId
|
||||||
? channelsByServer[activeServerId] || []
|
? channelsByServer[activeServerId] || []
|
||||||
: [];
|
: [];
|
||||||
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
||||||
|
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||||
|
const memberUsernames = useMemo(
|
||||||
|
() => new Set(members.map((m) => m.username)),
|
||||||
|
[members],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeChannelId) {
|
if (activeChannelId) {
|
||||||
@@ -42,6 +87,49 @@ export function ChatArea() {
|
|||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const cursor = e.target.selectionStart ?? value.length;
|
||||||
|
setInput(value);
|
||||||
|
|
||||||
|
// Detect an unfinished mention at cursor position.
|
||||||
|
const beforeCursor = value.slice(0, cursor);
|
||||||
|
const atIndex = beforeCursor.lastIndexOf("@");
|
||||||
|
if (atIndex === -1) {
|
||||||
|
setMentionQuery(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const between = beforeCursor.slice(atIndex + 1);
|
||||||
|
if (between.includes(" ") || between.includes("\n")) {
|
||||||
|
setMentionQuery(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMentionQuery(between);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMentionSelect = (username: string) => {
|
||||||
|
const inputEl = inputRef.current;
|
||||||
|
if (!inputEl) {
|
||||||
|
setInput((prev) => `${prev}${username} `);
|
||||||
|
setMentionQuery(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cursor = inputEl.selectionStart ?? input.length;
|
||||||
|
const beforeCursor = input.slice(0, cursor);
|
||||||
|
const atIndex = beforeCursor.lastIndexOf("@");
|
||||||
|
if (atIndex === -1) return;
|
||||||
|
const before = input.slice(0, atIndex);
|
||||||
|
const after = input.slice(cursor);
|
||||||
|
const next = `${before}@${username} ${after}`;
|
||||||
|
setInput(next);
|
||||||
|
setMentionQuery(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const pos = atIndex + username.length + 2; // @username + space
|
||||||
|
inputEl.focus();
|
||||||
|
inputEl.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeChannelId || !input.trim()) return;
|
if (!activeChannelId || !input.trim()) return;
|
||||||
@@ -49,6 +137,7 @@ export function ChatArea() {
|
|||||||
try {
|
try {
|
||||||
await sendMessage(activeChannelId, input.trim());
|
await sendMessage(activeChannelId, input.trim());
|
||||||
setInput("");
|
setInput("");
|
||||||
|
setMentionQuery(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to send");
|
setError(err instanceof Error ? err.message : "Failed to send");
|
||||||
}
|
}
|
||||||
@@ -87,7 +176,9 @@ export function ChatArea() {
|
|||||||
<span className="text-gb-aqua">
|
<span className="text-gb-aqua">
|
||||||
<{message.author_username}>
|
<{message.author_username}>
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-gb-fg">{message.content}</span>
|
<span className="text-gb-fg">
|
||||||
|
{renderContent(message.content, memberUsernames)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
@@ -105,16 +196,26 @@ export function ChatArea() {
|
|||||||
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
|
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
|
||||||
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
|
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
|
||||||
|
<div className="flex-1 min-w-0 relative">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={handleInputChange}
|
||||||
placeholder="type a message..."
|
placeholder="type a message..."
|
||||||
className="terminal-input flex-1 min-w-0"
|
className="terminal-input w-full"
|
||||||
disabled={!activeChannelId}
|
disabled={!activeChannelId}
|
||||||
/>
|
/>
|
||||||
|
{mentionQuery !== null && (
|
||||||
|
<MentionDropdown
|
||||||
|
query={mentionQuery}
|
||||||
|
members={members}
|
||||||
|
onSelect={handleMentionSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowGifPicker((prev) => !prev)}
|
onClick={() => setShowGifPicker((prev) => !prev)}
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../stores/auth.ts';
|
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
|
||||||
import { useWebSocketStore } from '../stores/ws.ts';
|
import { useWebSocketStore } from '../stores/ws.ts';
|
||||||
import { ServerBar } from './ServerBar.tsx';
|
import { ServerBar } from './ServerBar.tsx';
|
||||||
import { ChannelList } from './ChannelList.tsx';
|
import { ChannelList } from './ChannelList.tsx';
|
||||||
import { MemberList } from './MemberList.tsx';
|
import { MemberList } from './MemberList.tsx';
|
||||||
import { VoicePanel } from './VoicePanel.tsx';
|
import { VoicePanel } from './VoicePanel.tsx';
|
||||||
|
|
||||||
|
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||||
|
|
||||||
|
function statusColor(status: UserStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return 'bg-gb-green';
|
||||||
|
case 'idle':
|
||||||
|
return 'bg-gb-yellow';
|
||||||
|
case 'dnd':
|
||||||
|
return 'bg-gb-red';
|
||||||
|
default:
|
||||||
|
return 'bg-gb-gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: UserStatus): string {
|
||||||
|
return status.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const isLoading = useAuthStore((state) => state.isLoading);
|
const isLoading = useAuthStore((state) => state.isLoading);
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const fetchMe = useAuthStore((state) => state.fetchMe);
|
const fetchMe = useAuthStore((state) => state.fetchMe);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||||
const wsConnect = useWebSocketStore((s) => s.connect);
|
const wsConnect = useWebSocketStore((s) => s.connect);
|
||||||
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMe();
|
fetchMe();
|
||||||
@@ -30,6 +51,17 @@ export function Layout() {
|
|||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
||||||
|
|
||||||
|
const handleStatusChange = async (status: UserStatus) => {
|
||||||
|
setShowStatusMenu(false);
|
||||||
|
try {
|
||||||
|
await updateProfile({ status });
|
||||||
|
} catch (err) {
|
||||||
|
// Error is surfaced via auth store; menu closes optimistically.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStatus = user?.status ?? 'offline';
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
|
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
|
||||||
@@ -48,9 +80,33 @@ export function Layout() {
|
|||||||
<div className="flex items-center justify-between px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s">
|
<div className="flex items-center justify-between px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s">
|
||||||
<span className="text-gb-orange font-bold">DUMPSTER</span>
|
<span className="text-gb-orange font-bold">DUMPSTER</span>
|
||||||
<div className="flex items-center gap-4 text-xs text-gb-fg-s">
|
<div className="flex items-center gap-4 text-xs text-gb-fg-s">
|
||||||
<span>
|
<div className="relative">
|
||||||
USER: <span className="text-gb-aqua">{user?.username || 'unknown'}</span>
|
<button
|
||||||
</span>
|
type="button"
|
||||||
|
onClick={() => setShowStatusMenu((prev) => !prev)}
|
||||||
|
className="flex items-center gap-2 hover:text-gb-fg transition-colors"
|
||||||
|
title="Change status"
|
||||||
|
>
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full ${statusColor(currentStatus)}`} />
|
||||||
|
<span className="text-gb-aqua">{user?.username || 'unknown'}</span>
|
||||||
|
<span className="text-gb-fg-f">[{statusLabel(currentStatus)}]</span>
|
||||||
|
</button>
|
||||||
|
{showStatusMenu && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 w-32 bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
||||||
|
{STATUS_CYCLE.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStatusChange(s)}
|
||||||
|
className="w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${statusColor(s)}`} />
|
||||||
|
<span>{statusLabel(s)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
|
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
|
||||||
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
||||||
[LOGOUT]
|
[LOGOUT]
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Member } from "../stores/member.ts";
|
||||||
|
|
||||||
|
interface MentionDropdownProps {
|
||||||
|
query: string;
|
||||||
|
members: Member[];
|
||||||
|
onSelect: (username: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentionDropdown({ query, members, onSelect }: MentionDropdownProps) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const filtered = members
|
||||||
|
.filter(
|
||||||
|
(m) =>
|
||||||
|
m.username.toLowerCase().includes(q) ||
|
||||||
|
m.display_name?.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-full left-0 mb-1 z-50 w-64 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
||||||
|
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
|
||||||
|
MENTION
|
||||||
|
</div>
|
||||||
|
{filtered.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(m.username)}
|
||||||
|
className="w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-gb-green">●</span>
|
||||||
|
<span className="truncate">{m.display_name || m.username}</span>
|
||||||
|
{m.display_name && m.display_name !== m.username && (
|
||||||
|
<span className="text-gb-fg-f text-xs">({m.username})</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export interface UpdateProfilePayload {
|
|||||||
bio?: string;
|
bio?: string;
|
||||||
accent_color?: string;
|
accent_color?: string;
|
||||||
status_text?: string;
|
status_text?: string;
|
||||||
|
status?: UserStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface MemberState {
|
|||||||
membersByServer: Record<string, Member[]>;
|
membersByServer: Record<string, Member[]>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
fetchMembers: (serverId: string) => Promise<void>;
|
fetchMembers: (serverId: string) => Promise<void>;
|
||||||
|
updateMemberStatus: (userId: string, status: Member["status"]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMemberStore = create<MemberState>((set) => ({
|
export const useMemberStore = create<MemberState>((set) => ({
|
||||||
@@ -34,4 +35,15 @@ export const useMemberStore = create<MemberState>((set) => ({
|
|||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateMemberStatus: (userId, status) =>
|
||||||
|
set((state) => {
|
||||||
|
const next: Record<string, Member[]> = {};
|
||||||
|
for (const [serverId, list] of Object.entries(state.membersByServer)) {
|
||||||
|
next[serverId] = list.map((m) =>
|
||||||
|
m.id === userId ? { ...m, status } : m,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { membersByServer: next };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user