Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/openmessages
/om-bin
.claude/launch.json
*.db
session.json
.vercel/
Expand Down
7 changes: 7 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions internal/app/backfill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
15 changes: 10 additions & 5 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
})
}
}
Expand Down
7 changes: 6 additions & 1 deletion internal/client/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions internal/client/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
46 changes: 42 additions & 4 deletions internal/db/conversations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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+`
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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
}
Expand Down
70 changes: 70 additions & 0 deletions internal/db/recency.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading