From ee88385c9575c782f187bb3dcd304c96003b2f60 Mon Sep 17 00:00:00 2001 From: Christian Sanchez Date: Wed, 3 Jun 2026 16:34:17 -0700 Subject: [PATCH] Add inbox tabs, reaction reactor names, and recents-ordering fix Web UI + backend improvements from a working session: - Tabs: organize threads into Recent / Archive / custom tabs. Adds a `tab` column on conversations and a `tabs` table, tab CRUD + bulk-move API endpoints, a sidebar tab bar, and a multi-select mode to move threads (archive included) in bulk. - Reaction tooltips now name who reacted (cross-platform) instead of a bare count. Captures reactor participant IDs for Google Messages and resolves actors (incl. WhatsApp/Signal) to names, falling back to a count for historical reactions with no reactor data. - Recents ordering: contentless messages (e.g. an emoji reaction made in a group that arrives as an empty stub in the reactor's 1:1 thread) no longer float a conversation to the top. Adds MessageHasContent / AdvanceConversationRecency, a startup repair for already-affected conversations, and TDD coverage. - UI fix: right-click context-menu text is now readable in light mode. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + internal/app/app.go | 7 + internal/app/backfill.go | 2 + internal/client/client.go | 15 +- internal/client/events.go | 7 +- internal/client/extract_test.go | 7 + internal/db/conversations.go | 46 ++- internal/db/db.go | 12 +- internal/db/recency.go | 70 ++++ internal/db/recency_test.go | 124 +++++++ internal/db/tabs.go | 100 ++++++ internal/web/api.go | 118 +++++++ internal/web/static/index.html | 596 +++++++++++++++++++++++++++++++- 13 files changed, 1081 insertions(+), 25 deletions(-) create mode 100644 internal/db/recency.go create mode 100644 internal/db/recency_test.go create mode 100644 internal/db/tabs.go 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 @@

OpenMessage

- + +
@@ -4262,6 +4475,11 @@

No conversations yet

let allConversations = []; let activeConversation = null; let currentPlatformFilter = 'all'; + let customTabs = []; // [{tab_id, name, position, created_at}] + let currentTab = ''; // '' = Recent (inbox), 'archive', or a custom tab id + const TAB_ARCHIVE = 'archive'; + let selectionMode = false; + const selectedIds = new Set(); let conversationsRefreshTimer = null; let eventSource = null; let threadFeedbackTimer = null; @@ -4334,6 +4552,12 @@

No conversations yet

// ─── DOM refs ─── const $convoList = document.getElementById('conversation-list'); const $sourceFilters = document.getElementById('sidebar-source-filters'); + const $sidebarTabs = document.getElementById('sidebar-tabs'); + const $selectToggleBtn = document.getElementById('select-toggle-btn'); + const $bulkBar = document.getElementById('bulk-bar'); + const $bulkCount = document.getElementById('bulk-count'); + const $bulkMoveBtn = document.getElementById('bulk-move-btn'); + const $bulkCancelBtn = document.getElementById('bulk-cancel-btn'); const $emptyState = document.getElementById('empty-state'); const $chatHeader = document.getElementById('chat-header'); const $chatHeaderAvatar = document.getElementById('chat-header-avatar'); @@ -4629,6 +4853,77 @@

No conversations yet

.trim(); } + // Strip platform suffixes (e.g. "15551234567@s.whatsapp.net" -> "15551234567") + // for a last-resort human-ish label when no name can be resolved. + function formatReactorFallback(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + const head = raw.split('@')[0].trim(); + return head || raw; + } + + // Resolve a single reaction actor identifier to a display name. + // Actors are platform-specific: Google Messages participant IDs, WhatsApp JIDs, + // Signal accounts/numbers, or the literal "me". + function reactionActorName(actorId, convo) { + const raw = String(actorId || '').trim(); + if (!raw) return ''; + if (raw.toLowerCase() === 'me') return 'You'; + + const norm = normalizeParticipantIdentifier(raw); + const myWa = normalizeParticipantIdentifier((whatsAppStatus && whatsAppStatus.account_jid) || ''); + const mySignal = normalizeParticipantIdentifier((signalStatus && signalStatus.account) || ''); + if (norm && (norm === myWa || norm === mySignal)) return 'You'; + + const participants = conversationParticipants(convo); + // 1) Google Messages: exact participant-ID match. + for (const p of participants) { + if (p && p.id && String(p.id).trim() === raw) { + if (p.is_me) return 'You'; + return p.name || formatReactorFallback(p.number) || formatReactorFallback(raw); + } + } + // 2) Match by number/JID digits. + if (norm) { + for (const p of participants) { + const pn = normalizeParticipantIdentifier((p && (p.number || p.id)) || ''); + if (pn && pn === norm) { + if (p.is_me) return 'You'; + return p.name || formatReactorFallback(p.number) || formatReactorFallback(raw); + } + } + } + // 3) 1:1 fallback: the only non-me reactor is the other party. + if (convo && !convo.IsGroup) { + const others = participants.filter(p => p && !p.is_me); + if (others.length === 1 && (others[0].name || others[0].number)) { + return others[0].name || formatReactorFallback(others[0].number); + } + const convoName = String(convo.Name || '').trim(); + if (convoName) return convoName; + } + // 4) Last resort. + return formatReactorFallback(raw) || 'Someone'; + } + + // Build the hover tooltip text for a reaction pill: the reactor names, or a + // count fallback when reactor identities aren't available (older/historical data). + function reactionTooltip(reaction, convo) { + const count = Number(reaction && reaction.count) || 0; + const actors = Array.isArray(reaction && reaction.actors) ? reaction.actors : []; + if (actors.length) { + const names = []; + const seen = new Set(); + actors.forEach(a => { + const name = reactionActorName(a, convo); + const key = name.toLowerCase(); + if (name && !seen.has(key)) { seen.add(key); names.push(name); } + }); + if (names.length) return names.join(', '); + } + return `${count} reaction${count !== 1 ? 's' : ''}`; + } + function normalizeParticipantIdentifier(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) return ''; @@ -5196,6 +5491,232 @@

No conversations yet

return convos.filter(c => sourcePlatformOf(c) === currentPlatformFilter); } + // ─── Tabs (Recent / Archive / custom folders) ─── + function conversationTabOf(convo) { + if (!convo) return ''; + return String(convo.Tab || convo.tab || '').trim(); + } + + function filterConversationsByTab(convos) { + return convos.filter(c => conversationTabOf(c) === currentTab); + } + + function tabById(id) { + return customTabs.find(t => t.tab_id === id) || null; + } + + function tabDisplayName(tab) { + if (tab === '') return 'Recent'; + if (tab === TAB_ARCHIVE) return 'Archive'; + const t = tabById(tab); + return t ? t.name : 'tab'; + } + + function renderTabs() { + if (!$sidebarTabs) return; + const counts = new Map(); + allConversations.forEach(c => { + const t = conversationTabOf(c); + counts.set(t, (counts.get(t) || 0) + 1); + }); + const tabs = [ + { id: '', label: 'Recent' }, + { id: TAB_ARCHIVE, label: 'Archive' }, + ...customTabs.map(t => ({ id: t.tab_id, label: t.name, custom: true })), + ]; + $sidebarTabs.innerHTML = tabs.map(tab => { + const count = counts.get(tab.id) || 0; + const countHTML = count ? `${count}` : ''; + return ``; + }).join('') + ``; + + $sidebarTabs.querySelectorAll('.sidebar-tab').forEach(btn => { + btn.addEventListener('click', () => setCurrentTab(btn.dataset.tab || '')); + if (btn.dataset.custom === '1') { + btn.addEventListener('contextmenu', (event) => { + event.preventDefault(); + openContextMenu(event, [ + { label: 'Rename tab', action: 'rename-tab' }, + { label: 'Delete tab', action: 'delete-tab' }, + ], { type: 'tab', tabId: btn.dataset.tab || '' }); + }); + } + }); + const addBtn = document.getElementById('sidebar-tab-add'); + if (addBtn) addBtn.addEventListener('click', startCreateTab); + } + + function setCurrentTab(tab) { + if (currentTab === tab) return; + currentTab = tab; + if (activeConversation && conversationTabOf(activeConversation) !== currentTab) { + deselectConversation(); + } + if (selectionMode) exitSelectionMode(); + const q = $searchInput.value.trim(); + if (q) { renderSearchResults(q).catch(err => console.error('Search failed:', err)); return; } + renderConversations(allConversations); + } + + async function loadTabs() { + try { + const tabs = await fetchJSON('/api/tabs'); + customTabs = Array.isArray(tabs) ? tabs : []; + } catch (err) { + console.error('Failed to load tabs:', err); + customTabs = []; + } + if (currentTab && currentTab !== TAB_ARCHIVE && !tabById(currentTab)) { + currentTab = ''; + } + renderTabs(); + } + + function startCreateTab() { + const addBtn = document.getElementById('sidebar-tab-add'); + if (!addBtn) return; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'sidebar-tab-input'; + input.placeholder = 'Tab name'; + input.maxLength = 40; + addBtn.replaceWith(input); + input.focus(); + let done = false; + const commit = async () => { + if (done) return; + done = true; + const name = input.value.trim(); + if (!name) { renderTabs(); return; } + try { + const tab = await postJSON('/api/tabs', { name }); + if (tab && tab.tab_id) { + customTabs.push(tab); + currentTab = tab.tab_id; + } + } catch (err) { + console.error('Failed to create tab:', err); + showThreadFeedback(err.message || 'Failed to create tab.'); + } + renderConversations(allConversations); + }; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + else if (e.key === 'Escape') { done = true; renderTabs(); } + }); + input.addEventListener('blur', commit); + } + + async function renameTabFlow(tabId) { + const tab = tabById(tabId); + if (!tab) return; + const name = window.prompt('Rename tab', tab.name); + if (name === null) return; + const trimmed = name.trim(); + if (!trimmed) return; + try { + await postJSON(`/api/tabs/${encodeURIComponent(tabId)}`, { name: trimmed }); + tab.name = trimmed; + renderTabs(); + } catch (err) { + console.error('Failed to rename tab:', err); + showThreadFeedback(err.message || 'Failed to rename tab.'); + } + } + + async function deleteTabFlow(tabId) { + const tab = tabById(tabId); + if (!tab) return; + if (!window.confirm(`Delete "${tab.name}"? Its threads return to Recent.`)) return; + try { + const r = await fetch(API + `/api/tabs/${encodeURIComponent(tabId)}`, { method: 'DELETE' }); + if (!r.ok) throw new Error(await responseError(r)); + customTabs = customTabs.filter(t => t.tab_id !== tabId); + if (currentTab === tabId) currentTab = ''; + await loadConversations(); + } catch (err) { + console.error('Failed to delete tab:', err); + showThreadFeedback(err.message || 'Failed to delete tab.'); + } + } + + // ─── Moving conversations between tabs ─── + function tabMoveMenuItems(excludeTab) { + const targets = [ + { id: '', label: 'Move to Recent' }, + { id: TAB_ARCHIVE, label: 'Archive' }, + ...customTabs.map(t => ({ id: t.tab_id, label: `Move to: ${t.name}` })), + ]; + return targets + .filter(t => t.id !== excludeTab) + .map(t => ({ label: t.label, action: `move-tab:${t.id}` })); + } + + async function moveConversationToTab(convo, tab) { + if (!convo) return; + await postJSON(`/api/conversations/${encodeURIComponent(convo.ConversationID)}/tab`, { tab }); + applyConversationPatch(convo.ConversationID, { Tab: tab, tab }); + if (activeConversation && activeConversation.ConversationID === convo.ConversationID && tab !== currentTab) { + deselectConversation(); + } + renderConversations(allConversations); + showThreadFeedback(tab === TAB_ARCHIVE ? 'Moved to Archive.' : `Moved to ${tabDisplayName(tab)}.`); + } + + async function moveSelectedToTab(tab) { + const ids = Array.from(selectedIds); + if (!ids.length) return; + try { + await postJSON('/api/conversations/move', { ids, tab }); + ids.forEach(id => applyConversationPatch(id, { Tab: tab, tab })); + const n = ids.length; + exitSelectionMode(); + showThreadFeedback(`Moved ${n} thread${n === 1 ? '' : 's'} to ${tabDisplayName(tab)}.`); + } catch (err) { + console.error('Failed to move conversations:', err); + showThreadFeedback(err.message || 'Failed to move threads.'); + } + } + + // ─── Multi-select ─── + function enterSelectionMode() { + selectionMode = true; + selectedIds.clear(); + if ($selectToggleBtn) $selectToggleBtn.classList.add('active'); + updateBulkBar(); + renderConversations(allConversations); + } + + function exitSelectionMode() { + selectionMode = false; + selectedIds.clear(); + if ($selectToggleBtn) $selectToggleBtn.classList.remove('active'); + updateBulkBar(); + renderConversations(allConversations); + } + + function toggleSelectionMode() { + if (selectionMode) exitSelectionMode(); + else enterSelectionMode(); + } + + function updateBulkBar() { + if (!$bulkBar) return; + if (!selectionMode) { $bulkBar.hidden = true; return; } + $bulkBar.hidden = false; + const n = selectedIds.size; + if ($bulkCount) $bulkCount.textContent = `${n} selected`; + if ($bulkMoveBtn) $bulkMoveBtn.disabled = n === 0; + } + + function openBulkMoveMenu() { + if (!$bulkMoveBtn || selectedIds.size === 0) return; + const rect = $bulkMoveBtn.getBoundingClientRect(); + openContextMenuAt(rect.left, rect.bottom + 8, tabMoveMenuItems(currentTab), { type: 'bulk-move' }); + } + function platformFilterOptions(convos) { const counts = new Map(); convos.forEach(c => { @@ -5242,7 +5763,7 @@

No conversations yet

} function buildConversationRenderItems(convos, isSearch) { - if (isSearch) { + if (isSearch || selectionMode) { return convos.map(convo => ({ type: 'conversation', convo })); } const items = []; @@ -5289,6 +5810,10 @@

No conversations yet

+ (c.ConversationID === activeConvoId ? ' active' : '') + (hasUnread ? ' unread' : ''); el.dataset.id = c.ConversationID; + if (selectionMode) { + el.classList.add('selecting'); + if (selectedIds.has(c.ConversationID)) el.classList.add('selected'); + } if (clustered) { el.innerHTML = `
${platformBadgeHTML(platform)}
@@ -5320,6 +5845,23 @@

No conversations yet

${hasUnread ? '
' + c.UnreadCount + '
' : ''} `; } + if (selectionMode) { + const box = document.createElement('div'); + box.className = 'convo-select-box'; + box.textContent = 'βœ“'; + el.insertBefore(box, el.firstChild); + el.addEventListener('click', () => { + const id = c.ConversationID; + if (selectedIds.has(id)) selectedIds.delete(id); + else selectedIds.add(id); + el.classList.toggle('selected', selectedIds.has(id)); + updateBulkBar(); + }); + el.addEventListener('contextmenu', (event) => { + openContextMenu(event, conversationContextMenuItems(c), { type: 'conversation', conversation: c }); + }); + return el; + } el.addEventListener('click', () => selectConversation(c)); el.addEventListener('contextmenu', (event) => { openContextMenu(event, conversationContextMenuItems(c), { type: 'conversation', conversation: c }); @@ -6001,6 +6543,10 @@

No conversations yet

action: 'leave-whatsapp-group', }); } + items.push( + { type: 'divider' }, + ...tabMoveMenuItems(conversationTabOf(convo)), + ); items.push( { type: 'divider' }, ...conversationNotificationMenuItems(convo), @@ -6190,13 +6736,19 @@

No conversations yet

allConversations = convos; } renderEmptyState(); - renderPlatformFilters(convos); - const visibleConvos = filterConversationsByPlatform(convos); + renderTabs(); + const tabConvos = isSearch ? convos : filterConversationsByTab(convos); + renderPlatformFilters(tabConvos); + const visibleConvos = filterConversationsByPlatform(tabConvos); conversations = visibleConvos; $convoList.innerHTML = ''; if (!visibleConvos.length) { let emptyMsg = isSearch ? 'No results found' : 'No conversations yet'; - if (currentPlatformFilter !== 'all') { + if (!isSearch && currentTab) { + emptyMsg = currentTab === TAB_ARCHIVE + ? 'No archived threads' + : `No threads in β€œ${tabDisplayName(currentTab)}” yet`; + } else if (currentPlatformFilter !== 'all') { emptyMsg = isSearch ? `No ${platformMeta(currentPlatformFilter).label} results found` : `No ${platformMeta(currentPlatformFilter).label} conversations`; @@ -6929,8 +7481,8 @@

No conversations yet

reactions.forEach(r => { const count = Number(r.count) || 0; const emoji = escapeHtml(r.emoji || ''); - const countLabel = escapeHtml(`${count} reaction${count > 1 ? 's' : ''}`); - html += `${emoji}${count > 1 ? count : ''}`; + const tip = escapeHtml(reactionTooltip(r, activeConversation)); + html += `${emoji}${count > 1 ? count : ''}`; }); html += ''; } @@ -8270,7 +8822,7 @@

No conversations yet

document.querySelectorAll('.emoji-picker.show').forEach(p => p.classList.remove('show')); document.querySelectorAll('.emoji-full-panel.show').forEach(p => p.classList.remove('show')); } - if ($contextMenu && !$contextMenu.hidden && !e.target.closest('.context-menu') && !e.target.closest('.chat-header-action-btn')) { + if ($contextMenu && !$contextMenu.hidden && !e.target.closest('.context-menu') && !e.target.closest('.chat-header-action-btn') && !e.target.closest('#bulk-move-btn')) { closeContextMenu(); } }); @@ -8306,6 +8858,18 @@

No conversations yet

showThreadFeedback(`Notifications set to ${notificationModeLabel(mode).toLowerCase()}.`); return; } + if (action.startsWith('move-tab:')) { + await moveConversationToTab(context.conversation, action.slice('move-tab:'.length)); + return; + } + } + if (context.type === 'bulk-move' && action.startsWith('move-tab:')) { + await moveSelectedToTab(action.slice('move-tab:'.length)); + return; + } + if (context.type === 'tab' && context.tabId) { + if (action === 'rename-tab') { await renameTabFlow(context.tabId); return; } + if (action === 'delete-tab') { await deleteTabFlow(context.tabId); return; } } if (context.type === 'message' && context.message) { const message = context.message; @@ -8869,7 +9433,11 @@

No conversations yet

.then(() => syncNotificationButton()) .catch(err => console.error('Failed to initialize native notifications:', err)); renderEmptyState(); + if ($selectToggleBtn) $selectToggleBtn.addEventListener('click', toggleSelectionMode); + if ($bulkCancelBtn) $bulkCancelBtn.addEventListener('click', exitSelectionMode); + if ($bulkMoveBtn) $bulkMoveBtn.addEventListener('click', openBulkMoveMenu); startEventStream(); + loadTabs(); loadConversations(); checkStatus();