Skip to content

Commit 1b093ef

Browse files
committed
implemented hybrid fuzzy matching for group mention/tag autocomplete
1 parent 050cb67 commit 1b093ef

6 files changed

Lines changed: 379 additions & 14 deletions

File tree

‎PLAN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Current Stage
44

5-
Implementation is past the local-shell phase and currently sits at a DB-first, live WhatsApp client with remote history/media support, outbound text, single-attachment media send, recent-sticker send, protocol-backed read receipts, reactions with a quick picker, own-message text editing, own-message delete-for-everybody, replies, quote-jump, a right-edge reply gesture in the message pane, typing presence, group-member mention autocomplete, accent-aware search, visible-first chat-avatar sync/rendering with full profile images, first-class sticker receive/render/download behavior, app-state-backed muted/pinned chat settings, stable pending/paused/resumed pixel overlays for media/avatar/sticker rendering, direct-chat PN/LID canonicalization plus split-thread repair, desktop notifications with cached chat-avatar icons where supported plus a local global mute key/right-side configurable status-bar indicator, immediate DB catch-up sync overlay blocking with non-blocking startup app-state sync, async store/protocol-backed TUI actions for sends, lazy loads, filters, read receipts, reactions, retries, stickers, drafts, and mentions, denormalized chat-list previews, media retry UX, first-use-state logout reset, temp-backed non-exported chat media caches, clipboard attachment paste with Windows file-drop support, a persistent in-chat new-message divider, hardened Windows inline preview defaults, composer tail-follow scrolling, detached Shift+Enter external media launch, and the planned media/export CLI helpers. The next major gaps are live validation/polish of the notification and media-send paths on real chats, especially desktop delivery on fresh Linux and Windows installs, notification backend default resolution on a fresh config, audio/document fallback behavior, the new avatar/sticker behavior under daily use, plus attachment draft persistence and follow-on resend polish for failed rows.
5+
Implementation is past the local-shell phase and currently sits at a DB-first, live WhatsApp client with remote history/media support, outbound text, single-attachment media send, recent-sticker send, protocol-backed read receipts, reactions with a quick picker, own-message text editing, own-message delete-for-everybody, replies, quote-jump, a right-edge reply gesture in the message pane, typing presence, hybrid fuzzy group-member mention autocomplete, accent-aware search, visible-first chat-avatar sync/rendering with full profile images, first-class sticker receive/render/download behavior, app-state-backed muted/pinned chat settings, stable pending/paused/resumed pixel overlays for media/avatar/sticker rendering, direct-chat PN/LID canonicalization plus split-thread repair, desktop notifications with cached chat-avatar icons where supported plus a local global mute key/right-side configurable status-bar indicator, immediate DB catch-up sync overlay blocking with non-blocking startup app-state sync, async store/protocol-backed TUI actions for sends, lazy loads, filters, read receipts, reactions, retries, stickers, drafts, and mentions, denormalized chat-list previews, media retry UX, first-use-state logout reset, temp-backed non-exported chat media caches, clipboard attachment paste with Windows file-drop support, a persistent in-chat new-message divider, hardened Windows inline preview defaults, composer tail-follow scrolling, detached Shift+Enter external media launch, and the planned media/export CLI helpers. The next major gaps are live validation/polish of the notification and media-send paths on real chats, especially desktop delivery on fresh Linux and Windows installs, notification backend default resolution on a fresh config, audio/document fallback behavior, the new avatar/sticker behavior under daily use, plus attachment draft persistence and follow-on resend polish for failed rows.
66

77
### Implemented now
88

@@ -23,7 +23,7 @@ Implementation is past the local-shell phase and currently sits at a DB-first, l
2323
- Real outbound send from the inline composer for plain text plus one local attachment per message, with precomputed WhatsApp message IDs, local `sending`/`sent`/`failed` status updates, draft preservation on failure, captions for image/video/document sends, audio-caption rejection before queueing, ffprobe-backed generic audio send, and document fallback when audio metadata is unavailable.
2424
- CLI helpers for persisted data: `vimwhat media open <message-id>` reuses the normal opener flow and auto-downloads remote media when possible, while `vimwhat export chat <jid>` writes a local-only Markdown transcript into the configured downloads directory.
2525
- Protocol-backed message interactions: auto/manual mark-read, monotonic outgoing receipt status updates with seen/read receipts rendered as boxed ticks, reaction send/clear through `:react` and a configurable quick picker plus reaction rendering, own outgoing text edit via WhatsApp edit with local rewrite after ACK, inbound edit ingestion, own outgoing message delete-for-everybody via WhatsApp revoke with local deletion after ACK, inbound revoke ingestion, text replies with quoted metadata, quote-jump into loaded history, a right-edge `l` reply gesture from the message pane when no further pane exists to the right, direct-chat typing/online/last-seen presence in a two-line active chat header, and best-effort composing/paused presence send while typing.
26-
- Group-member mention autocomplete in the insert composer: typing `@` in group chats opens participant suggestions, typed text filters them with accent-aware matching, `Enter` or configurable `key_insert_mention_select_alt` completes the highlighted contact, local contact names are preferred across PN/LID aliases, and outgoing text/media captions send WhatsApp-compatible mention wire text plus `mentioned_jid` context while preserving readable local display text.
26+
- Group-member mention autocomplete in the insert composer: typing `@` in group chats opens participant suggestions, typed text filters them with hybrid fuzzy, accent-aware matching, `Enter` or configurable `key_insert_mention_select_alt` completes the highlighted contact, local contact names are preferred across PN/LID aliases, and outgoing text/media captions send WhatsApp-compatible mention wire text plus `mentioned_jid` context while preserving readable local display text.
2727
- Cross-platform desktop notifications for new incoming messages in inactive, unmuted chats, with native Linux/macOS/Windows backends, WhatsApp mute-state sync from app-state patches, a persistent local global mute toggle plus right-side configurable status-bar indicator, cached chat-avatar icons on Linux when available, resilient Linux helper fallback, safe command override support, notification preview formatting for media-only messages, backend diagnostics in `doctor`, and active-chat suppression that only applies while the app window is known to be focused.
2828
- Retry/resend UX for failed outgoing media rows in the TUI via `R` and `:retry-message`, keeping the original failed row in chat and queueing a brand-new send attempt when the local attachment file still exists.
2929
- Chat title quality tracking with source precedence, group/contact metadata refresh, and safe placeholders so group JIDs/phone-like IDs are not treated as real names.

‎internal/store/repository.go‎

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,6 +1561,7 @@ func (s *Store) SearchMentionCandidates(ctx context.Context, chatID, query strin
15611561
}
15621562
defer rows.Close()
15631563

1564+
query = strings.TrimSpace(query)
15641565
var candidates []MentionCandidate
15651566
for rows.Next() {
15661567
var (
@@ -1612,7 +1613,7 @@ func (s *Store) SearchMentionCandidates(ctx context.Context, chatID, query strin
16121613
if candidate.JID == "" {
16131614
continue
16141615
}
1615-
if strings.TrimSpace(query) != "" && !textmatch.Contains(candidate.SearchText, query) {
1616+
if query != "" && !mentionCandidateMatches(candidate, query) {
16161617
continue
16171618
}
16181619
candidates = append(candidates, candidate)
@@ -1703,7 +1704,22 @@ func mentionCandidateForParticipant(participant GroupParticipant, contacts []Con
17031704
SearchText: strings.Join(searchParts, " "),
17041705
IsAdmin: participant.IsAdmin,
17051706
IsSuperAdmin: participant.IsSuperAdmin,
1707+
searchParts: compactSearchParts(searchParts),
1708+
}
1709+
}
1710+
1711+
func compactSearchParts(values []string) []string {
1712+
parts := make([]string, 0, len(values))
1713+
seen := map[string]bool{}
1714+
for _, value := range values {
1715+
value = strings.TrimSpace(value)
1716+
if value == "" || seen[value] {
1717+
continue
1718+
}
1719+
seen[value] = true
1720+
parts = append(parts, value)
17061721
}
1722+
return parts
17071723
}
17081724

17091725
func firstNonEmptyString(values ...string) string {
@@ -1729,8 +1745,8 @@ func mentionJIDUser(jid string) string {
17291745
func sortMentionCandidates(candidates []MentionCandidate, query string) {
17301746
query = strings.TrimSpace(query)
17311747
slices.SortStableFunc(candidates, func(left, right MentionCandidate) int {
1732-
leftRank := mentionCandidateRank(left, query)
1733-
rightRank := mentionCandidateRank(right, query)
1748+
leftRank, _ := mentionCandidateRank(left, query)
1749+
rightRank, _ := mentionCandidateRank(right, query)
17341750
if leftRank != rightRank {
17351751
return leftRank - rightRank
17361752
}
@@ -1746,19 +1762,38 @@ func sortMentionCandidates(candidates []MentionCandidate, query string) {
17461762
})
17471763
}
17481764

1749-
func mentionCandidateRank(candidate MentionCandidate, query string) int {
1765+
func mentionCandidateMatches(candidate MentionCandidate, query string) bool {
1766+
_, ok := mentionCandidateRank(candidate, query)
1767+
return ok
1768+
}
1769+
1770+
func mentionCandidateRank(candidate MentionCandidate, query string) (int, bool) {
17501771
if query == "" {
1751-
return 0
1772+
return 0, true
17521773
}
1753-
foldedName := textmatch.Fold(candidate.DisplayName)
1754-
foldedQuery := textmatch.Fold(query)
1755-
if strings.HasPrefix(foldedName, foldedQuery) {
1756-
return 0
1774+
if score, ok := textmatch.FuzzyScore(candidate.DisplayName, query); ok {
1775+
return score, true
17571776
}
1758-
if textmatch.Contains(candidate.DisplayName, query) {
1759-
return 1
1777+
best := -1
1778+
for _, part := range mentionCandidateSearchParts(candidate) {
1779+
if score, ok := textmatch.FuzzyScore(part, query); ok && (best < 0 || score < best) {
1780+
best = score
1781+
}
1782+
}
1783+
if best >= 0 {
1784+
return 10000 + best, true
1785+
}
1786+
return 0, false
1787+
}
1788+
1789+
func mentionCandidateSearchParts(candidate MentionCandidate) []string {
1790+
if len(candidate.searchParts) > 0 {
1791+
return candidate.searchParts
1792+
}
1793+
if strings.TrimSpace(candidate.SearchText) == "" {
1794+
return nil
17601795
}
1761-
return 2
1796+
return []string{candidate.SearchText}
17621797
}
17631798

17641799
func replaceMessageMentions(ctx context.Context, execer groupParticipantExecer, messageID string, mentions []MessageMention) error {

‎internal/store/store_test.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,34 @@ func TestGroupMentionCandidatesAndMessageMentions(t *testing.T) {
482482
if len(candidates) != 1 || candidates[0].JID != "333333@lid" || candidates[0].DisplayName != "J Otávio" {
483483
t.Fatalf("SearchMentionCandidates(Otavio) = %+v, want phone contact display name", candidates)
484484
}
485+
candidates, err = db.SearchMentionCandidates(ctx, "group@g.us", "js", 10)
486+
if err != nil {
487+
t.Fatalf("SearchMentionCandidates(js) error = %v", err)
488+
}
489+
if len(candidates) == 0 || candidates[0].JID != "111@s.whatsapp.net" {
490+
t.Fatalf("SearchMentionCandidates(js) = %+v, want fuzzy José first", candidates)
491+
}
492+
candidates, err = db.SearchMentionCandidates(ctx, "group@g.us", "otv", 10)
493+
if err != nil {
494+
t.Fatalf("SearchMentionCandidates(otv) error = %v", err)
495+
}
496+
if len(candidates) != 1 || candidates[0].JID != "333333@lid" {
497+
t.Fatalf("SearchMentionCandidates(otv) = %+v, want fuzzy Otávio", candidates)
498+
}
499+
candidates, err = db.SearchMentionCandidates(ctx, "group@g.us", "Jsoe", 10)
500+
if err != nil {
501+
t.Fatalf("SearchMentionCandidates(Jsoe) error = %v", err)
502+
}
503+
if len(candidates) == 0 || candidates[0].JID != "111@s.whatsapp.net" {
504+
t.Fatalf("SearchMentionCandidates(Jsoe) = %+v, want typo-tolerant José first", candidates)
505+
}
506+
candidates, err = db.SearchMentionCandidates(ctx, "group@g.us", "zz", 10)
507+
if err != nil {
508+
t.Fatalf("SearchMentionCandidates(zz) error = %v", err)
509+
}
510+
if len(candidates) != 0 {
511+
t.Fatalf("SearchMentionCandidates(zz) = %+v, want no candidates", candidates)
512+
}
485513
if err := db.ReplaceGroupParticipants(ctx, "group@g.us", []GroupParticipant{
486514
{ChatID: "group@g.us", JID: "222@s.whatsapp.net", DisplayName: "Ana"},
487515
}); err != nil {

‎internal/store/types.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type MentionCandidate struct {
8585
SearchText string
8686
IsAdmin bool
8787
IsSuperAdmin bool
88+
searchParts []string
8889
}
8990

9091
type MessageMention struct {

0 commit comments

Comments
 (0)