chore: add jwz-go and bubble-overlay (#1379)

Drew Smirnoff created

## What?

Replaces the `internal/threading` with our library
[`jwz-go`](https://github.com/floatpane/jwz-go).

Replaces helper `overlay.go` with a library
[`bubble-overlay`](https://github.com/floatpane/bubble-overlay)

## Why?

These libraries may be used by others, and it will make it easier to
maintain them separate from our project.

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

config/folder_cache.go         |   2 
go.mod                         |   2 
go.sum                         |   4 
internal/threading/jwz.go      | 365 ------------------------------------
internal/threading/jwz_test.go | 154 ---------------
internal/threading/subject.go  |  20 -
tui/composer.go                |   3 
tui/inbox.go                   |   2 
tui/overlay.go                 |  58 -----
9 files changed, 10 insertions(+), 600 deletions(-)

Detailed changes

config/folder_cache.go πŸ”—

@@ -9,7 +9,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/floatpane/matcha/internal/threading"
+	threading "github.com/floatpane/jwz-go"
 )
 
 // CachedFolders stores folder names for a single account.

go.mod πŸ”—

@@ -19,7 +19,9 @@ require (
 	github.com/emersion/go-message v0.18.2
 	github.com/emersion/go-pgpmail v0.2.2
 	github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
+	github.com/floatpane/bubble-overlay v0.0.1
 	github.com/floatpane/go-uds-jsonrpc v0.0.1
+	github.com/floatpane/jwz-go v0.0.1
 	github.com/floatpane/termimage v0.2.1
 	github.com/google/uuid v1.6.0
 	github.com/hashicorp/golang-lru/v2 v2.0.7

go.sum πŸ”—

@@ -68,6 +68,10 @@ github.com/emersion/go-pgpmail v0.2.2/go.mod h1:mRB5P7QKiAuOvcT36tdRZvm7nSt7V+f6
 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
 github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
+github.com/floatpane/bubble-overlay v0.0.1 h1:5xU8cNigDPYegvgGMfOG23fIDXhrqXPvLTaEB7uHGK4=
+github.com/floatpane/bubble-overlay v0.0.1/go.mod h1:Csi1byxb9L8EAb8X13XdWF5aX5YiBD5C9WEWACyGa8A=
+github.com/floatpane/jwz-go v0.0.1 h1:OAl/vaUYn+/8zFR47WaCybBGoQItb1ZkFplNrmeO3ps=
+github.com/floatpane/jwz-go v0.0.1/go.mod h1:1b/JE4K+LBIBGtWbFdM1BXpN6ZbGWIv7IXPRbXD4oRE=
 github.com/floatpane/go-uds-jsonrpc v0.0.1 h1:/sBlCXVAP9SyLWLj0wlFI07dX/SfXeUM67B4tRwK2QA=
 github.com/floatpane/go-uds-jsonrpc v0.0.1/go.mod h1:G/YeDIocGkPIU+uyhJ/e8ynn9wIEMIkJ74d3VUuC4rM=
 github.com/floatpane/termimage v0.2.1 h1:jYPBg+SSl5PHFpN96tccBYXG4ZERoJ61ALyRDJMqonU=

internal/threading/jwz.go πŸ”—

@@ -1,365 +0,0 @@
-package threading
-
-import (
-	"regexp"
-	"sort"
-	"strings"
-	"time"
-)
-
-type EmailHeader struct {
-	ID         string
-	InReplyTo  string
-	References []string
-	Subject    string
-	Date       time.Time
-	EmailID    string
-	Sender     string
-}
-
-type Thread struct {
-	Root     *ThreadNode
-	LatestAt time.Time
-	Count    int
-	Subject  string
-	Senders  []string
-}
-
-type ThreadNode struct {
-	EmailID  string
-	Children []*ThreadNode
-	Date     time.Time
-	Sender   string
-	Subject  string
-}
-
-type container struct {
-	id       string
-	node     *ThreadNode
-	parent   *container
-	children []*container
-}
-
-var messageIDRE = regexp.MustCompile(`<[^>]+>`)
-
-func Build(headers []EmailHeader) []Thread {
-	containers := make(map[string]*container)
-	ordered := make([]*container, 0, len(headers))
-
-	get := func(id string) *container {
-		if c := containers[id]; c != nil {
-			return c
-		}
-		c := &container{id: id}
-		containers[id] = c
-		ordered = append(ordered, c)
-		return c
-	}
-
-	for _, h := range headers {
-		msgID := normalizeMessageID(h.ID)
-		if msgID == "" {
-			msgID = "email:" + h.EmailID
-		}
-		c := get(msgID)
-		if c.node != nil {
-			msgID = msgID + "#email:" + h.EmailID
-			c = get(msgID)
-		}
-		c.node = &ThreadNode{
-			EmailID: h.EmailID,
-			Date:    h.Date,
-			Sender:  h.Sender,
-			Subject: h.Subject,
-		}
-
-		var prev *container
-		refs := normalizeReferences(h.References)
-		for _, ref := range refs {
-			refc := get(ref)
-			if prev != nil {
-				link(prev, refc)
-			}
-			prev = refc
-		}
-
-		parentID := normalizeMessageID(h.InReplyTo)
-		if parentID == "" && len(refs) > 0 {
-			parentID = refs[len(refs)-1]
-		}
-		if parentID != "" {
-			link(get(parentID), c)
-		}
-	}
-
-	var roots []*container
-	for _, c := range ordered {
-		if c.parent == nil {
-			if root := prune(c); root != nil {
-				roots = append(roots, root)
-			}
-		}
-	}
-	roots = groupBySubject(roots)
-
-	threads := make([]Thread, 0, len(roots))
-	for _, root := range roots {
-		sortContainer(root)
-		thread := buildThread(root)
-		if thread.Count > 0 {
-			threads = append(threads, thread)
-		}
-	}
-
-	sort.SliceStable(threads, func(i, j int) bool {
-		if !threads[i].LatestAt.Equal(threads[j].LatestAt) {
-			return threads[i].LatestAt.After(threads[j].LatestAt)
-		}
-		return threadKey(threads[i].Root) < threadKey(threads[j].Root)
-	})
-
-	return threads
-}
-
-func normalizeReferences(refs []string) []string {
-	seen := make(map[string]bool)
-	var out []string
-	for _, ref := range refs {
-		for _, id := range extractMessageIDs(ref) {
-			if !seen[id] {
-				out = append(out, id)
-				seen[id] = true
-			}
-		}
-	}
-	return out
-}
-
-func extractMessageIDs(s string) []string {
-	matches := messageIDRE.FindAllString(s, -1)
-	if len(matches) == 0 {
-		if id := normalizeMessageID(s); id != "" {
-			return []string{id}
-		}
-		return nil
-	}
-	ids := make([]string, 0, len(matches))
-	for _, match := range matches {
-		if id := normalizeMessageID(match); id != "" {
-			ids = append(ids, id)
-		}
-	}
-	return ids
-}
-
-func normalizeMessageID(id string) string {
-	id = strings.TrimSpace(id)
-	if id == "" {
-		return ""
-	}
-	if matches := messageIDRE.FindAllString(id, -1); len(matches) > 0 {
-		id = matches[len(matches)-1]
-	}
-	id = strings.TrimSpace(id)
-	id = strings.TrimPrefix(id, "<")
-	id = strings.TrimSuffix(id, ">")
-	id = strings.TrimSpace(id)
-	return strings.ToLower(id)
-}
-
-func link(parent, child *container) {
-	if parent == nil || child == nil || parent == child {
-		return
-	}
-	if child.parent != nil || child.hasDescendant(parent) {
-		return
-	}
-	child.parent = parent
-	for _, existing := range parent.children {
-		if existing == child {
-			return
-		}
-	}
-	parent.children = append(parent.children, child)
-}
-
-func (c *container) hasDescendant(target *container) bool {
-	for _, child := range c.children {
-		if child == target || child.hasDescendant(target) {
-			return true
-		}
-	}
-	return false
-}
-
-func prune(c *container) *container {
-	if c == nil {
-		return nil
-	}
-	var children []*container
-	for _, child := range c.children {
-		if pruned := prune(child); pruned != nil {
-			pruned.parent = c
-			children = append(children, pruned)
-		}
-	}
-	c.children = children
-
-	if c.node != nil {
-		return c
-	}
-	switch len(c.children) {
-	case 0:
-		return nil
-	case 1:
-		child := c.children[0]
-		child.parent = c.parent
-		return child
-	default:
-		return c
-	}
-}
-
-func groupBySubject(roots []*container) []*container {
-	subjects := make(map[string]*container)
-	var grouped []*container
-	for _, root := range roots {
-		subject := firstSubject(root)
-		if subject == "" {
-			grouped = append(grouped, root)
-			continue
-		}
-		if existing := subjects[subject]; existing != nil {
-			link(existing, root)
-			continue
-		}
-		subjects[subject] = root
-		grouped = append(grouped, root)
-	}
-	return grouped
-}
-
-func firstSubject(c *container) string {
-	if c == nil {
-		return ""
-	}
-	if c.node != nil {
-		return canonicalSubject(c.node.Subject)
-	}
-	for _, child := range c.children {
-		if subject := firstSubject(child); subject != "" {
-			return subject
-		}
-	}
-	return ""
-}
-
-func sortContainer(c *container) {
-	for _, child := range c.children {
-		sortContainer(child)
-	}
-	sort.SliceStable(c.children, func(i, j int) bool {
-		a, b := c.children[i], c.children[j]
-		ad, bd := containerDate(a), containerDate(b)
-		if !ad.Equal(bd) {
-			return ad.Before(bd)
-		}
-		return containerKey(a) < containerKey(b)
-	})
-}
-
-func buildThread(root *container) Thread {
-	node := toThreadNode(root)
-	thread := Thread{Root: node, Subject: canonicalSubject(firstDisplaySubject(node))}
-	seenSenders := make(map[string]bool)
-	walkThread(node, &thread, seenSenders)
-	return thread
-}
-
-func toThreadNode(c *container) *ThreadNode {
-	node := &ThreadNode{}
-	if c.node != nil {
-		*node = *c.node
-		node.Children = nil
-	}
-	for _, child := range c.children {
-		node.Children = append(node.Children, toThreadNode(child))
-	}
-	return node
-}
-
-func walkThread(node *ThreadNode, thread *Thread, seenSenders map[string]bool) {
-	if node == nil {
-		return
-	}
-	if node.EmailID != "" {
-		thread.Count++
-		if node.Date.After(thread.LatestAt) {
-			thread.LatestAt = node.Date
-		}
-		if node.Sender != "" && !seenSenders[node.Sender] {
-			thread.Senders = append(thread.Senders, node.Sender)
-			seenSenders[node.Sender] = true
-		}
-	}
-	for _, child := range node.Children {
-		walkThread(child, thread, seenSenders)
-	}
-}
-
-func containerDate(c *container) time.Time {
-	if c == nil {
-		return time.Time{}
-	}
-	if c.node != nil {
-		return c.node.Date
-	}
-	var earliest time.Time
-	for _, child := range c.children {
-		date := containerDate(child)
-		if earliest.IsZero() || (!date.IsZero() && date.Before(earliest)) {
-			earliest = date
-		}
-	}
-	return earliest
-}
-
-func containerKey(c *container) string {
-	if c == nil {
-		return ""
-	}
-	if c.node != nil && c.node.EmailID != "" {
-		return c.node.EmailID
-	}
-	return c.id
-}
-
-func threadKey(n *ThreadNode) string {
-	if n == nil {
-		return ""
-	}
-	if n.EmailID != "" {
-		return n.EmailID
-	}
-	for _, child := range n.Children {
-		if key := threadKey(child); key != "" {
-			return key
-		}
-	}
-	return ""
-}
-
-func firstDisplaySubject(node *ThreadNode) string {
-	if node == nil {
-		return ""
-	}
-	if node.Subject != "" {
-		return node.Subject
-	}
-	for _, child := range node.Children {
-		if subject := firstDisplaySubject(child); subject != "" {
-			return subject
-		}
-	}
-	return ""
-}

internal/threading/jwz_test.go πŸ”—

@@ -1,154 +0,0 @@
-package threading
-
-import (
-	"reflect"
-	"testing"
-	"time"
-)
-
-func TestBuildThreeMessageChain(t *testing.T) {
-	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
-	threads := Build([]EmailHeader{
-		{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1", Sender: "a"},
-		{ID: "<b@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2", Sender: "b"},
-		{ID: "<c@example>", References: []string{"<a@example>", "<b@example>"}, Subject: "Re: Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3", Sender: "c"}, //nolint:dupword
-	})
-
-	if len(threads) != 1 {
-		t.Fatalf("got %d threads, want 1", len(threads))
-	}
-	if threads[0].Count != 3 {
-		t.Fatalf("got count %d, want 3", threads[0].Count)
-	}
-	if got := threads[0].Root.Children[0].Children[0].EmailID; got != "3" {
-		t.Fatalf("got chain leaf %q, want 3", got)
-	}
-}
-
-func TestBuildForkedThread(t *testing.T) {
-	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
-	threads := Build([]EmailHeader{
-		{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
-		{ID: "<c@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"},
-		{ID: "<b@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2"},
-	})
-
-	if len(threads) != 1 {
-		t.Fatalf("got %d threads, want 1", len(threads))
-	}
-	children := threads[0].Root.Children
-	if len(children) != 2 {
-		t.Fatalf("got %d children, want 2", len(children))
-	}
-	if children[0].EmailID != "2" || children[1].EmailID != "3" {
-		t.Fatalf("got child order %q, %q; want 2, 3", children[0].EmailID, children[1].EmailID)
-	}
-}
-
-func TestBuildMissingParentPlaceholderRoot(t *testing.T) {
-	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
-	threads := Build([]EmailHeader{
-		{ID: "<child@example>", References: []string{"<missing@example>"}, Subject: "Re: Foo", Date: base, EmailID: "child"},
-		{ID: "<other@example>", References: []string{"<missing@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "other"},
-	})
-
-	if len(threads) != 1 {
-		t.Fatalf("got %d threads, want 1", len(threads))
-	}
-	if threads[0].Root.EmailID != "" {
-		t.Fatalf("got root EmailID %q, want placeholder", threads[0].Root.EmailID)
-	}
-	if len(threads[0].Root.Children) != 2 {
-		t.Fatalf("got %d placeholder children, want 2", len(threads[0].Root.Children))
-	}
-}
-
-func TestBuildSubjectFallbackGroupingForOrphans(t *testing.T) {
-	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
-	threads := Build([]EmailHeader{
-		{ID: "<a@example>", Subject: "Re: Foo", Date: base, EmailID: "1"},
-		{ID: "<b@example>", Subject: "Fwd: foo", Date: base.Add(time.Minute), EmailID: "2"},
-		{ID: "<c@example>", Subject: "Bar", Date: base.Add(2 * time.Minute), EmailID: "3"},
-	})
-
-	if len(threads) != 2 {
-		t.Fatalf("got %d threads, want 2", len(threads))
-	}
-	var grouped Thread
-	for _, thread := range threads {
-		if thread.Subject == "foo" {
-			grouped = thread
-			break
-		}
-	}
-	if grouped.Count != 2 {
-		t.Fatalf("got grouped count %d, want 2", grouped.Count)
-	}
-}
-
-func TestBuildSubjectFallbackGroupsLocalePrefixes(t *testing.T) {
-	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
-	threads := Build([]EmailHeader{
-		{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
-		{ID: "<b@example>", Subject: "SV: Foo", Date: base.Add(time.Minute), EmailID: "2"},
-		{ID: "<c@example>", Subject: "RV: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"},
-		{ID: "<d@example>", Subject: "Antw: Foo", Date: base.Add(3 * time.Minute), EmailID: "4"},
-	})
-
-	if len(threads) != 1 {
-		t.Fatalf("got %d threads, want 1", len(threads))
-	}
-	if threads[0].Subject != "foo" {
-		t.Fatalf("got subject %q, want foo", threads[0].Subject)
-	}
-	if threads[0].Count != 4 {
-		t.Fatalf("got grouped count %d, want 4", threads[0].Count)
-	}
-}
-
-func TestBuildEmptyReferencesList(t *testing.T) {
-	threads := Build([]EmailHeader{
-		{ID: "<a@example>", References: nil, Subject: "Foo", Date: time.Now(), EmailID: "1"},
-	})
-
-	if len(threads) != 1 {
-		t.Fatalf("got %d threads, want 1", len(threads))
-	}
-	if threads[0].Root.EmailID != "1" {
-		t.Fatalf("got root %q, want 1", threads[0].Root.EmailID)
-	}
-}
-
-func TestBuildStableOrderingAcrossCalls(t *testing.T) {
-	base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
-	headers := []EmailHeader{
-		{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
-		{ID: "<b@example>", Subject: "Bar", Date: base, EmailID: "2"},
-		{ID: "<c@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base, EmailID: "3"},
-	}
-
-	first := Build(headers)
-	second := Build(headers)
-	if !reflect.DeepEqual(first, second) {
-		t.Fatalf("Build output differed across calls:\n%#v\n%#v", first, second)
-	}
-}
-
-func TestCanonicalSubjectNormalizesReplyAndForwardPrefixes(t *testing.T) {
-	tests := map[string]string{
-		"Re: Re: Foo":     "foo", //nolint:dupword
-		"Fwd: FW: Foo":    "foo",
-		"AW: WG: Tr: Foo": "foo",
-		"ReΓ©: Resp: Foo":  "foo",
-		"SV: VS: RV: Foo": "foo",
-		"ENC: Antw: Foo":  "foo",
-		"Odp: R: I: Foo":  "foo",
-		"  Foo  ":         "foo",
-	}
-
-	for in, want := range tests {
-		if got := canonicalSubject(in); got != want {
-			t.Fatalf("canonicalSubject(%q) = %q, want %q", in, got, want)
-		}
-	}
-}

internal/threading/subject.go πŸ”—

@@ -1,20 +0,0 @@
-package threading
-
-import (
-	"regexp"
-	"strings"
-)
-
-var subjectPrefixRE = regexp.MustCompile(`(?i)^(Re|Fwd|Fw|AW|WG|Tr|ReΓ©|Resp|SV|VS|RV|ENC|Antw|Odp|R|I)\s*:\s*`)
-
-func canonicalSubject(s string) string {
-	s = strings.TrimSpace(s)
-	for {
-		next := subjectPrefixRE.ReplaceAllString(s, "")
-		if next == s {
-			break
-		}
-		s = strings.TrimSpace(next)
-	}
-	return strings.ToLower(strings.TrimSpace(s))
-}

tui/composer.go πŸ”—

@@ -13,6 +13,7 @@ import (
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	overlay "github.com/floatpane/bubble-overlay"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/spellcheck"
 	"github.com/google/uuid"
@@ -1337,7 +1338,7 @@ func (m *Composer) overlaySpellPopup(view string, elementsBeforeBody []string) s
 		anchorCol = max(0, m.width-popupWidth)
 	}
 
-	return overlayBlock(view, popup, anchorRow, anchorCol)
+	return overlay.Block(view, popup, anchorRow, anchorCol)
 }
 
 // renderSpellPopupLines builds the styled, bordered suggestion box and

tui/inbox.go πŸ”—

@@ -11,9 +11,9 @@ import (
 	"charm.land/bubbles/v2/list"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	threading "github.com/floatpane/jwz-go"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
-	"github.com/floatpane/matcha/internal/threading"
 	"github.com/floatpane/matcha/theme"
 )
 

tui/overlay.go πŸ”—

@@ -1,58 +0,0 @@
-package tui
-
-import (
-	"strings"
-
-	"github.com/charmbracelet/x/ansi"
-)
-
-// overlayBlock paints the lines of block on top of base starting at the
-// (row, col) cell position. Lines that extend past the bottom of base are
-// appended. The result preserves existing ANSI styling around the
-// overlaid region.
-func overlayBlock(base string, block []string, row, col int) string {
-	if len(block) == 0 {
-		return base
-	}
-	lines := strings.Split(base, "\n")
-	for i, overlay := range block {
-		r := row + i
-		for r >= len(lines) {
-			lines = append(lines, "")
-		}
-		lines[r] = overlayLine(lines[r], overlay, col)
-	}
-	return strings.Join(lines, "\n")
-}
-
-// overlayLine returns base with overlay painted starting at column col.
-// Existing cells under the overlay are removed; cells to the left and
-// right of the overlay are preserved with their ANSI styling intact.
-// When col exceeds the visible width of base the gap is padded with
-// spaces.
-func overlayLine(base, overlay string, col int) string {
-	if overlay == "" {
-		return base
-	}
-	overlayWidth := ansi.StringWidth(overlay)
-	baseWidth := ansi.StringWidth(base)
-
-	left := ansi.Truncate(base, col, "")
-	leftWidth := ansi.StringWidth(left)
-
-	var pad string
-	if leftWidth < col {
-		pad = strings.Repeat(" ", col-leftWidth)
-	}
-
-	var right string
-	rightStart := col + overlayWidth
-	if rightStart < baseWidth {
-		right = ansi.Cut(base, rightStart, baseWidth)
-	}
-
-	// Reset SGR after the overlay so the overlay's styles don't bleed
-	// into the surrounding cells (the rest of the row may inherit ANSI
-	// from earlier in the string).
-	return left + pad + overlay + "\x1b[0m" + right
-}