diff --git a/.gitignore b/.gitignore index b67d282..b95d981 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /openmessages +/om-bin +.claude/launch.json *.db session.json .vercel/ diff --git a/internal/app/app.go b/internal/app/app.go index 746d707..827820d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -223,6 +223,13 @@ func New(logger zerolog.Logger) (*App, error) { Msg("Repaired legacy Google Messages outgoing attribution rows") } } + // Drop conversations that a contentless stub (e.g. a group reaction arriving + // as an empty message in a 1:1 thread) wrongly floated to the top of recents. + if fixed, err := store.RepairContentlessRecency(); err != nil { + logger.Warn().Err(err).Msg("Failed to repair contentless conversation recency") + } else if fixed > 0 { + logger.Info().Int("fixed", fixed).Msg("Repaired conversations floated up by contentless messages") + } if !Sandboxed() { if mediaRepair, err := (&importer.WhatsAppNative{}).RepairLegacyMediaPlaceholders(store); err != nil { logger.Warn().Err(err).Msg("Failed to repair legacy WhatsApp media placeholders") diff --git a/internal/app/backfill.go b/internal/app/backfill.go index a58bb47..33e839f 100644 --- a/internal/app/backfill.go +++ b/internal/app/backfill.go @@ -583,6 +583,7 @@ func (a *App) storeConversation(conv *gmproto.Conversation) error { Name string `json:"name"` Number string `json:"number"` IsMe bool `json:"is_me,omitempty"` + ID string `json:"id,omitempty"` // participant ID, used to resolve reaction actors to names } var infos []pInfo for _, p := range ps { @@ -592,6 +593,7 @@ func (a *App) storeConversation(conv *gmproto.Conversation) error { } if id := p.GetID(); id != nil { info.Number = id.GetNumber() + info.ID = id.GetParticipantID() } if info.Number == "" { info.Number = p.GetFormattedNumber() diff --git a/internal/client/client.go b/internal/client/client.go index 386980b..26e8f57 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -118,10 +118,13 @@ func ExtractMediaInfo(msg *gmproto.Message) *MediaInfo { return nil } -// Reaction holds an emoji and how many people reacted with it. +// Reaction holds an emoji, how many people reacted with it, and the participant +// IDs of those reactors. Actors are Google Messages participant IDs that resolve +// to names via the conversation's participant list (see SmallInfo.ParticipantID). type Reaction struct { - Emoji string `json:"emoji"` - Count int `json:"count"` + Emoji string `json:"emoji"` + Count int `json:"count"` + Actors []string `json:"actors,omitempty"` } // ExtractReactions extracts reaction data from a protobuf Message. @@ -138,9 +141,11 @@ func ExtractReactions(msg *gmproto.Message) []Reaction { if emoji == "" { continue } + participantIDs := entry.GetParticipantIDs() reactions = append(reactions, Reaction{ - Emoji: emoji, - Count: len(entry.GetParticipantIDs()), + Emoji: emoji, + Count: len(participantIDs), + Actors: participantIDs, }) } } diff --git a/internal/client/events.go b/internal/client/events.go index ac4f32e..25256ff 100644 --- a/internal/client/events.go +++ b/internal/client/events.go @@ -120,7 +120,10 @@ func (h *EventHandler) handleMessage(evt *libgm.WrappedMessage) { h.Logger.Error().Err(err).Str("msg_id", dbMsg.MessageID).Msg("Failed to store message") return } - if err := h.Store.BumpConversationTimestamp(dbMsg.ConversationID, dbMsg.TimestampMS); err != nil { + // Only real content advances inbox recency. A contentless stub (e.g. an + // emoji reaction made in a group that arrives as an empty message in the + // reactor's 1:1 thread) must not float that conversation to the top. + if err := h.Store.AdvanceConversationRecency(dbMsg); err != nil { h.Logger.Warn().Err(err).Str("conv_id", dbMsg.ConversationID).Msg("Failed to update conversation timestamp from message") } @@ -170,6 +173,7 @@ func (h *EventHandler) storeConversation(conv *gmproto.Conversation) bool { Name string `json:"name"` Number string `json:"number"` IsMe bool `json:"is_me,omitempty"` + ID string `json:"id,omitempty"` // participant ID, used to resolve reaction actors to names } var infos []pInfo for _, p := range ps { @@ -179,6 +183,7 @@ func (h *EventHandler) storeConversation(conv *gmproto.Conversation) bool { } if id := p.GetID(); id != nil { info.Number = id.GetNumber() + info.ID = id.GetParticipantID() } if info.Number == "" { info.Number = p.GetFormattedNumber() diff --git a/internal/client/extract_test.go b/internal/client/extract_test.go index 69f0d0b..f33ad9f 100644 --- a/internal/client/extract_test.go +++ b/internal/client/extract_test.go @@ -129,6 +129,13 @@ func TestExtractReactions_WithEmojis(t *testing.T) { if reactions[1].Count != 1 { t.Errorf("expected count 1, got %d", reactions[1].Count) } + // Actors carry the reactor participant IDs so the UI can name who reacted. + if got := reactions[0].Actors; len(got) != 3 || got[0] != "p1" || got[1] != "p2" || got[2] != "p3" { + t.Errorf("expected actors [p1 p2 p3], got %v", got) + } + if got := reactions[1].Actors; len(got) != 1 || got[0] != "p1" { + t.Errorf("expected actors [p1], got %v", got) + } } func TestExtractReplyToID_None(t *testing.T) { diff --git a/internal/db/conversations.go b/internal/db/conversations.go index c17bafb..e2d1ffe 100644 --- a/internal/db/conversations.go +++ b/internal/db/conversations.go @@ -7,7 +7,7 @@ import ( ) // conversationColumns is the canonical column list for SELECT queries on conversations. -const conversationColumns = `conversation_id, name, is_group, participants, last_message_ts, unread_count, source_platform, notification_mode` +const conversationColumns = `conversation_id, name, is_group, participants, last_message_ts, unread_count, source_platform, notification_mode, tab` const ( NotificationModeAll = "all" @@ -76,7 +76,7 @@ func (s *Store) GetConversation(id string) (*Conversation, error) { err := s.db.QueryRow(` SELECT `+conversationColumns+` FROM conversations WHERE conversation_id = ? - `, id).Scan(&c.ConversationID, &c.Name, &c.IsGroup, &c.Participants, &c.LastMessageTS, &c.UnreadCount, &c.SourcePlatform, &c.NotificationMode) + `, id).Scan(&c.ConversationID, &c.Name, &c.IsGroup, &c.Participants, &c.LastMessageTS, &c.UnreadCount, &c.SourcePlatform, &c.NotificationMode, &c.Tab) if err != nil { return nil, err } @@ -180,6 +180,44 @@ func (s *Store) SetConversationNotificationMode(id, mode string) error { return err } +// Built-in tab identifiers. "" is the implicit Recent (inbox) tab. +const ( + TabInbox = "" // Recent threads + TabArchive = "archive" // Archived threads +) + +// SetConversationTab moves a single conversation into the given tab. +// An empty tab returns it to Recent (inbox). +func (s *Store) SetConversationTab(id, tab string) error { + _, err := s.db.Exec(`UPDATE conversations SET tab = ? WHERE conversation_id = ?`, strings.TrimSpace(tab), id) + return err +} + +// SetConversationsTab moves multiple conversations into the given tab in one transaction. +func (s *Store) SetConversationsTab(ids []string, tab string) error { + if len(ids) == 0 { + return nil + } + tab = strings.TrimSpace(tab) + tx, err := s.db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare(`UPDATE conversations SET tab = ? WHERE conversation_id = ?`) + if err != nil { + tx.Rollback() + return err + } + defer stmt.Close() + for _, id := range ids { + if _, err := stmt.Exec(tab, id); err != nil { + tx.Rollback() + return err + } + } + return tx.Commit() +} + func (s *Store) ListConversations(limit int) ([]*Conversation, error) { rows, err := s.db.Query(` SELECT `+conversationColumns+` @@ -257,7 +295,7 @@ func scanConversations(rows interface { var convs []*Conversation for rows.Next() { c := &Conversation{} - if err := rows.Scan(&c.ConversationID, &c.Name, &c.IsGroup, &c.Participants, &c.LastMessageTS, &c.UnreadCount, &c.SourcePlatform, &c.NotificationMode); err != nil { + if err := rows.Scan(&c.ConversationID, &c.Name, &c.IsGroup, &c.Participants, &c.LastMessageTS, &c.UnreadCount, &c.SourcePlatform, &c.NotificationMode, &c.Tab); err != nil { return nil, err } c.NotificationMode = normalizeStoredNotificationMode(c.NotificationMode) @@ -271,7 +309,7 @@ func getConversationTx(tx *sql.Tx, id string) (*Conversation, error) { err := tx.QueryRow(` SELECT `+conversationColumns+` FROM conversations WHERE conversation_id = ? - `, id).Scan(&c.ConversationID, &c.Name, &c.IsGroup, &c.Participants, &c.LastMessageTS, &c.UnreadCount, &c.SourcePlatform, &c.NotificationMode) + `, id).Scan(&c.ConversationID, &c.Name, &c.IsGroup, &c.Participants, &c.LastMessageTS, &c.UnreadCount, &c.SourcePlatform, &c.NotificationMode, &c.Tab) if err != nil { if err == sql.ErrNoRows { return nil, nil diff --git a/internal/db/db.go b/internal/db/db.go index 6247d1d..e5d98d9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -22,6 +22,7 @@ type Conversation struct { UnreadCount int SourcePlatform string `json:"source_platform,omitempty"` // sms, gchat, imessage, whatsapp, signal, telegram NotificationMode string `json:"notification_mode,omitempty"` // all, mentions, muted + Tab string `json:"tab,omitempty"` // "" = Recent (inbox), "archive", or a custom tab id } type Message struct { @@ -230,7 +231,15 @@ func (s *Store) migrate() error { participants TEXT NOT NULL DEFAULT '[]', last_message_ts INTEGER NOT NULL DEFAULT 0, unread_count INTEGER NOT NULL DEFAULT 0, - notification_mode TEXT NOT NULL DEFAULT 'all' + notification_mode TEXT NOT NULL DEFAULT 'all', + tab TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS tabs ( + tab_id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( @@ -282,6 +291,7 @@ func (s *Store) migrate() error { "ALTER TABLE messages ADD COLUMN source_id TEXT NOT NULL DEFAULT ''", "ALTER TABLE conversations ADD COLUMN source_platform TEXT NOT NULL DEFAULT 'sms'", "ALTER TABLE conversations ADD COLUMN notification_mode TEXT NOT NULL DEFAULT 'all'", + "ALTER TABLE conversations ADD COLUMN tab TEXT NOT NULL DEFAULT ''", } { s.db.Exec(col) // ignore "duplicate column" errors } diff --git a/internal/db/recency.go b/internal/db/recency.go new file mode 100644 index 0000000..7b61176 --- /dev/null +++ b/internal/db/recency.go @@ -0,0 +1,70 @@ +package db + +import "strings" + +// MessageHasContent reports whether a message carries real conversational +// content. Empty placeholder/reaction-artifact messages β e.g. an emoji +// reaction made in a group that arrives as an empty stub in the reactor's 1:1 +// thread, read receipts, or auto-download placeholders β have no body, media, +// or reactions of their own and must never advance a conversation's recency. +func MessageHasContent(m *Message) bool { + if m == nil { + return false + } + return strings.TrimSpace(m.Body) != "" || + strings.TrimSpace(m.MediaID) != "" || + strings.TrimSpace(m.Reactions) != "" +} + +// AdvanceConversationRecency raises a conversation's last_message_ts to the +// message's timestamp, but only when the message carries real content (see +// MessageHasContent) and only ever forwards in time. Contentless messages are +// ignored so they cannot float a conversation to the top of the inbox. +func (s *Store) AdvanceConversationRecency(m *Message) error { + if !MessageHasContent(m) { + return nil + } + return s.BumpConversationTimestamp(m.ConversationID, m.TimestampMS) +} + +// RepairContentlessRecency repairs conversations already corrupted by a +// contentless message having advanced their recency. For each conversation +// whose newest stored message is contentless AND is currently setting the +// conversation's last_message_ts, it lowers last_message_ts to the newest +// content-bearing message. +// +// Conversations whose recency comes from metadata (last_message_ts newer than +// any stored message β typical when recent history hasn't been backfilled yet) +// are intentionally left untouched, because that recency is legitimate. It +// returns the number of conversations changed. +func (s *Store) RepairContentlessRecency() (int, error) { + const contentPredicate = `(TRIM(body) != '' OR TRIM(COALESCE(media_id,'')) != '' OR TRIM(COALESCE(reactions,'')) != '')` + res, err := s.db.Exec(` + UPDATE conversations + SET last_message_ts = ( + SELECT MAX(timestamp_ms) FROM messages m + WHERE m.conversation_id = conversations.conversation_id AND ` + contentPredicate + ` + ) + WHERE + -- a stored message is currently setting recency... + last_message_ts = ( + SELECT MAX(timestamp_ms) FROM messages m + WHERE m.conversation_id = conversations.conversation_id + ) + -- ...and that newest message is contentless (its ts is above the newest content message)... + AND last_message_ts > ( + SELECT MAX(timestamp_ms) FROM messages m + WHERE m.conversation_id = conversations.conversation_id AND ` + contentPredicate + ` + ) + -- ...and at least one content-bearing message exists to drop down to. + AND EXISTS ( + SELECT 1 FROM messages m + WHERE m.conversation_id = conversations.conversation_id AND ` + contentPredicate + ` + ) + `) + if err != nil { + return 0, err + } + n, err := res.RowsAffected() + return int(n), err +} diff --git a/internal/db/recency_test.go b/internal/db/recency_test.go new file mode 100644 index 0000000..299b204 --- /dev/null +++ b/internal/db/recency_test.go @@ -0,0 +1,124 @@ +package db + +import "testing" + +// TestMessageHasContent pins down what counts as "real" conversational content. +// Empty placeholder/reaction-artifact messages (no body, no media, no reactions) +// must not be treated as content, so they never advance a conversation's recency. +func TestMessageHasContent(t *testing.T) { + cases := []struct { + name string + msg *Message + want bool + }{ + {"nil", nil, false}, + {"empty", &Message{}, false}, + {"whitespace body only", &Message{Body: " \n\t"}, false}, + {"body", &Message{Body: "hello"}, true}, + {"media only", &Message{MediaID: "media-123"}, true}, + {"reactions only", &Message{Reactions: `[{"emoji":"π","count":1}]`}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := MessageHasContent(tc.msg); got != tc.want { + t.Errorf("MessageHasContent = %v, want %v", got, tc.want) + } + }) + } +} + +// TestAdvanceConversationRecency is the core rule for the recents-ordering bug: +// a contentless message (e.g. an emoji reaction in a *group* arriving as an +// empty stub in the reactor's 1:1 thread) must NOT float that conversation up, +// while a real message must, and recency must never move backwards. +func TestAdvanceConversationRecency(t *testing.T) { + store := newTestStore(t) + if err := store.UpsertConversation(&Conversation{ConversationID: "c1", LastMessageTS: 1000}); err != nil { + t.Fatalf("seed conversation: %v", err) + } + + tsOf := func() int64 { + c, err := store.GetConversation("c1") + if err != nil { + t.Fatalf("get conversation: %v", err) + } + return c.LastMessageTS + } + + // A newer but contentless message must not advance recency. + if err := store.AdvanceConversationRecency(&Message{ConversationID: "c1", TimestampMS: 5000}); err != nil { + t.Fatalf("advance (contentless): %v", err) + } + if got := tsOf(); got != 1000 { + t.Errorf("contentless message advanced recency to %d, want 1000", got) + } + + // A newer message with real content must advance recency. + if err := store.AdvanceConversationRecency(&Message{ConversationID: "c1", TimestampMS: 6000, Body: "hi"}); err != nil { + t.Fatalf("advance (content): %v", err) + } + if got := tsOf(); got != 6000 { + t.Errorf("content message did not advance recency: got %d, want 6000", got) + } + + // Recency must never move backwards, even for a real older message. + if err := store.AdvanceConversationRecency(&Message{ConversationID: "c1", TimestampMS: 2000, Body: "old"}); err != nil { + t.Fatalf("advance (older content): %v", err) + } + if got := tsOf(); got != 6000 { + t.Errorf("older message moved recency backwards: got %d, want 6000", got) + } +} + +// TestRepairContentlessRecency fixes conversations already corrupted by the bug: +// when a conversation's recency is being held up by a contentless top message, +// it should drop to its newest content-bearing message. Conversations whose +// recency comes from metadata (newer than any stored message β e.g. history not +// yet backfilled) must be left untouched. +func TestRepairContentlessRecency(t *testing.T) { + store := newTestStore(t) + + seed := func(convID string, lastTS int64, msgs []*Message) { + if err := store.UpsertConversation(&Conversation{ConversationID: convID, LastMessageTS: lastTS}); err != nil { + t.Fatalf("seed conversation %s: %v", convID, err) + } + for _, m := range msgs { + if err := store.UpsertMessage(m); err != nil { + t.Fatalf("seed message: %v", err) + } + } + } + + // "phantom": top message is contentless and is setting recency -> should drop. + seed("phantom", 5000, []*Message{ + {MessageID: "p-real", ConversationID: "phantom", Body: "hey", TimestampMS: 1000}, + {MessageID: "p-empty", ConversationID: "phantom", Body: "", TimestampMS: 5000}, + }) + // "metadata": recency is ahead of all stored messages (backfill lag) -> untouched. + seed("metadata", 9000, []*Message{ + {MessageID: "m-real", ConversationID: "metadata", Body: "yo", TimestampMS: 2000}, + }) + // "normal": recency already matches newest content -> unchanged. + seed("normal", 3000, []*Message{ + {MessageID: "n-real", ConversationID: "normal", Body: "sup", TimestampMS: 3000}, + }) + + n, err := store.RepairContentlessRecency() + if err != nil { + t.Fatalf("repair: %v", err) + } + if n != 1 { + t.Errorf("expected 1 conversation repaired, got %d", n) + } + + want := map[string]int64{"phantom": 1000, "metadata": 9000, "normal": 3000} + for id, exp := range want { + c, err := store.GetConversation(id) + if err != nil { + t.Fatalf("get %s: %v", id, err) + } + if c.LastMessageTS != exp { + t.Errorf("%s: last_message_ts = %d, want %d", id, c.LastMessageTS, exp) + } + } +} diff --git a/internal/db/tabs.go b/internal/db/tabs.go new file mode 100644 index 0000000..8f89f9c --- /dev/null +++ b/internal/db/tabs.go @@ -0,0 +1,100 @@ +package db + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" +) + +// Tab is a user-created folder that conversations can be filed under. +// The built-in Recent (inbox) and Archive tabs are implicit and not stored here. +type Tab struct { + TabID string `json:"tab_id"` + Name string `json:"name"` + Position int `json:"position"` + CreatedAt int64 `json:"created_at"` +} + +func newTabID() (string, error) { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return "tab_" + hex.EncodeToString(buf), nil +} + +// CreateTab creates a new custom tab, appended after existing tabs. +func (s *Store) CreateTab(name string) (*Tab, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("tab name is required") + } + id, err := newTabID() + if err != nil { + return nil, err + } + var maxPos *int + if err := s.db.QueryRow(`SELECT MAX(position) FROM tabs`).Scan(&maxPos); err != nil { + return nil, err + } + pos := 0 + if maxPos != nil { + pos = *maxPos + 1 + } + now := time.Now().UnixMilli() + if _, err := s.db.Exec( + `INSERT INTO tabs (tab_id, name, position, created_at) VALUES (?, ?, ?, ?)`, + id, name, pos, now, + ); err != nil { + return nil, err + } + return &Tab{TabID: id, Name: name, Position: pos, CreatedAt: now}, nil +} + +// ListTabs returns custom tabs ordered by position. +func (s *Store) ListTabs() ([]*Tab, error) { + rows, err := s.db.Query(`SELECT tab_id, name, position, created_at FROM tabs ORDER BY position ASC, created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var tabs []*Tab + for rows.Next() { + t := &Tab{} + if err := rows.Scan(&t.TabID, &t.Name, &t.Position, &t.CreatedAt); err != nil { + return nil, err + } + tabs = append(tabs, t) + } + return tabs, rows.Err() +} + +// RenameTab updates a custom tab's display name. +func (s *Store) RenameTab(tabID, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("tab name is required") + } + _, err := s.db.Exec(`UPDATE tabs SET name = ? WHERE tab_id = ?`, name, tabID) + return err +} + +// DeleteTab removes a custom tab and returns any conversations filed under it to +// Recent (inbox), so threads are never orphaned in a tab that no longer exists. +func (s *Store) DeleteTab(tabID string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + if _, err := tx.Exec(`UPDATE conversations SET tab = '' WHERE tab = ?`, tabID); err != nil { + tx.Rollback() + return err + } + if _, err := tx.Exec(`DELETE FROM tabs WHERE tab_id = ?`, tabID); err != nil { + tx.Rollback() + return err + } + return tx.Commit() +} diff --git a/internal/web/api.go b/internal/web/api.go index d64f7d2..fe7e27e 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -446,6 +446,31 @@ func APIHandlerWithOptions(store *db.Store, cli *client.Client, logger zerolog.L writeJSON(w, convo) return } + if action == "tab" { + if r.Method != http.MethodPost && r.Method != http.MethodPatch { + httpError(w, "method not allowed", 405) + return + } + var req struct { + Tab string `json:"tab"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpError(w, "invalid JSON: "+err.Error(), 400) + return + } + if err := store.SetConversationTab(convID, req.Tab); err != nil { + httpError(w, "set tab: "+err.Error(), 400) + return + } + convo, err := store.GetConversation(convID) + if err != nil { + httpError(w, "get conversation: "+err.Error(), 500) + return + } + publishConversations() + writeJSON(w, convo) + return + } if action != "messages" { httpError(w, "not found", 404) return @@ -486,6 +511,99 @@ func APIHandlerWithOptions(store *db.Store, cli *client.Client, logger zerolog.L writeJSON(w, msgs) }) + // Bulk-move multiple conversations into a tab ("" = Recent, "archive", or a custom tab id). + mux.HandleFunc("/api/conversations/move", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httpError(w, "method not allowed", 405) + return + } + var req struct { + IDs []string `json:"ids"` + Tab string `json:"tab"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpError(w, "invalid JSON: "+err.Error(), 400) + return + } + if len(req.IDs) == 0 { + httpError(w, "ids is required", 400) + return + } + if err := store.SetConversationsTab(req.IDs, req.Tab); err != nil { + httpError(w, "move conversations: "+err.Error(), 400) + return + } + publishConversations() + writeJSON(w, map[string]any{"moved": len(req.IDs), "tab": strings.TrimSpace(req.Tab)}) + }) + + // List (GET) or create (POST) custom tabs. + mux.HandleFunc("/api/tabs", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + tabs, err := store.ListTabs() + if err != nil { + httpError(w, "list tabs: "+err.Error(), 500) + return + } + if tabs == nil { + tabs = []*db.Tab{} + } + writeJSON(w, tabs) + case http.MethodPost: + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpError(w, "invalid JSON: "+err.Error(), 400) + return + } + tab, err := store.CreateTab(req.Name) + if err != nil { + httpError(w, "create tab: "+err.Error(), 400) + return + } + publishConversations() + writeJSON(w, tab) + default: + httpError(w, "method not allowed", 405) + } + }) + + // Rename (POST) or delete (DELETE) a custom tab by id. + mux.HandleFunc("/api/tabs/", func(w http.ResponseWriter, r *http.Request) { + tabID := strings.TrimPrefix(r.URL.Path, "/api/tabs/") + if tabID == "" { + httpError(w, "tab id required", 400) + return + } + switch r.Method { + case http.MethodPost, http.MethodPatch: + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpError(w, "invalid JSON: "+err.Error(), 400) + return + } + if err := store.RenameTab(tabID, req.Name); err != nil { + httpError(w, "rename tab: "+err.Error(), 400) + return + } + publishConversations() + writeJSON(w, map[string]any{"tab_id": tabID, "name": strings.TrimSpace(req.Name)}) + case http.MethodDelete: + if err := store.DeleteTab(tabID); err != nil { + httpError(w, "delete tab: "+err.Error(), 400) + return + } + publishConversations() + writeJSON(w, map[string]any{"deleted": tabID}) + default: + httpError(w, "method not allowed", 405) + } + }) + mux.HandleFunc("/api/contacts", func(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) limit := queryInt(r, "limit", 20) diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 8c50a61..4ce1f98 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -2524,6 +2524,206 @@ color: var(--text-muted); } +/* βββ Tab bar (Recent / Archive / custom / +) βββ */ +.sidebar-tabs-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 20px 12px; +} + +.sidebar-tabs { + display: flex; + gap: 6px; + flex: 1 1 auto; + overflow-x: auto; + scrollbar-width: none; +} + +.sidebar-tabs::-webkit-scrollbar { display: none; } + +.sidebar-tab { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-elevated); + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease; +} + +.sidebar-tab:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.sidebar-tab.active { + background: var(--accent-dim); + border-color: var(--border-accent); + color: var(--text-primary); +} + +.sidebar-tab-count { + color: var(--text-muted); + font-size: 11px; +} + +.sidebar-tab-add { + flex: 0 0 auto; + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px dashed var(--border); + border-radius: 50%; + background: transparent; + color: var(--text-secondary); + font-size: 16px; + line-height: 1; + cursor: pointer; + transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease; +} + +.sidebar-tab-add:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border-accent); +} + +.sidebar-tab-input { + flex: 0 0 auto; + width: 120px; + padding: 7px 12px; + border: 1px solid var(--border-accent); + border-radius: 999px; + background: var(--bg-elevated); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 600; + outline: none; +} + +.select-toggle-btn { + flex: 0 0 auto; + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-elevated); + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease; +} + +.select-toggle-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.select-toggle-btn.active { + background: var(--accent-dim); + border-color: var(--border-accent); + color: var(--text-primary); +} + +/* βββ Bulk action bar βββ */ +.bulk-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 20px 12px; + padding: 10px 14px; + border: 1px solid var(--border-accent); + border-radius: 14px; + background: var(--accent-dim); +} + +.bulk-bar[hidden] { display: none; } + +.bulk-count { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.bulk-bar-actions { + display: flex; + gap: 8px; +} + +.bulk-btn { + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-elevated); + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease; +} + +.bulk-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.bulk-btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.bulk-btn.primary:hover { + background: var(--accent-strong); + border-color: var(--accent-strong); + color: #fff; +} + +/* βββ Multi-select checkboxes on rows βββ */ +.convo-select-box { + flex: 0 0 auto; + width: 20px; + height: 20px; + margin-right: 10px; + border: 1.5px solid var(--text-muted); + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + color: transparent; + font-size: 12px; + font-weight: 700; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; +} + +.convo-item.selecting { cursor: pointer; } + +.convo-item.selected .convo-select-box { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.convo-item.selected { + background: var(--accent-dim); +} + .sidebar-source-filters { display: flex; gap: 8px; @@ -3122,7 +3322,9 @@ .context-menu-section-label { padding: 4px 8px 8px; - color: var(--text-muted); + /* Menu background is always dark, so pin label text to a dim smokey white + instead of theme vars that flip to near-black in light mode. */ + color: rgba(230, 233, 240, 0.55); font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; @@ -3139,30 +3341,31 @@ border: 0; border-radius: 10px; background: transparent; - color: var(--text-primary); + /* Smokey white β readable on the menu's always-dark background. */ + color: #e6e9f0; font: inherit; text-align: left; cursor: pointer; } .context-menu-item:hover:not(:disabled) { - background: var(--bg-hover); + background: rgba(255, 255, 255, 0.07); } .context-menu-item:disabled { - color: var(--text-muted); + color: rgba(230, 233, 240, 0.38); cursor: default; } .context-menu-item-meta { - color: var(--text-muted); + color: rgba(230, 233, 240, 0.55); font-size: 12px; } .context-menu-divider { height: 1px; margin: 8px 4px; - background: var(--border); + background: rgba(255, 255, 255, 0.1); } .platform-chip { @@ -4055,7 +4258,17 @@