mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
Merge pull request #631 from warmbly/fix/inbox-awaiting-reply-actions
feat: make Archive leave every working unibox view, fix Awaiting reply's address match, and add row and multi-select triage actions
This commit is contained in:
@@ -209,7 +209,13 @@ A source's `column_mapping` is validated when the source is written, not on its
|
||||
| POST | `/unibox/agent-drafts/:id/approve` | `WRITE_UNIBOX` |
|
||||
| POST | `/unibox/agent-drafts/:id/discard` | `WRITE_UNIBOX` |
|
||||
|
||||
`PATCH /unibox/folder` re-files up to 500 messages into `inbox`, `archive` or `trash` at once. The move is Warmbly's own: the copy at the mail provider stays where it is, and the next sync will not undo it, because the provider's placement is tracked separately and followed only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements a provider reaches rather than somewhere a person files mail, so they are rejected with a `400`. See [filing a conversation](/guides/unibox/#filing-a-conversation).
|
||||
`PATCH /unibox/folder` re-files into `inbox`, `archive` or `trash`. Name the messages with `email_ids`, the conversations with `thread_ids`, or both; up to 500 of each. Prefer `thread_ids` when you have one: filing part of a conversation leaves it listed, because a thread shows wherever any message of it still sits. The move is Warmbly's own: the copy at the mail provider stays where it is, and the next sync will not undo it, because the provider's placement is tracked separately and followed only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements a provider reaches rather than somewhere a person files mail, so they are rejected with a `400`. See [filing a conversation](/guides/unibox/#filing-a-conversation).
|
||||
|
||||
`PATCH /unibox/seen` takes the same two forms: `email_ids` for individual messages, `thread_ids` for whole conversations. `folder` sweeps one folder instead and cannot be combined with either.
|
||||
|
||||
`GET /unibox` lists every working folder by default. Spam, trash and archive stay out, so a conversation you file leaves every view rather than only the Inbox folder; pass `include_archived=true` for the All mail behaviour, or `folder=archive` to read the folder itself.
|
||||
|
||||
`POST /unibox/snooze` accepts `thread_id` for one conversation or `thread_ids` for up to 500. The single form answers with the snooze row, as before; the bulk form answers with `data`. `DELETE /unibox/snooze?thread_id=` accepts a comma-separated list.
|
||||
|
||||
`POST /unibox/reply/draft` returns an AI-drafted reply (it never sends) grounded in the thread, the contact, and your [voice profile](/guides/unibox/#ai-reply-drafts). It charges AI credits; see [AI credits](/guides/ai-credits/).
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ The conversation list takes the keyboard, so a triage pass never needs the mouse
|
||||
| `j` / `k` | Move down and up the list. From no selection, `j` takes the first conversation and `k` the last, whichever way the list is sorted. |
|
||||
| `g g` / `G` | Jump to the first or last conversation. |
|
||||
| `Enter` | Open the top conversation when nothing is selected yet. |
|
||||
| `Escape` | Clear the selection. Inside the search box it gives the keyboard back to the list. |
|
||||
| `x` | Tick or untick the row you are on, to build a selection. |
|
||||
| `e` | Archive the selection, or the row you are on when nothing is ticked. |
|
||||
| `Escape` | Clear the ticked rows, then the open conversation. Inside the search box it gives the keyboard back to the list. |
|
||||
| `/` | Put the caret in the list's search box. |
|
||||
| `c` | Label the open conversation. |
|
||||
|
||||
@@ -55,7 +57,7 @@ Opening **Inbox** from the main navigation starts in the Inbox folder. Sent mess
|
||||
|
||||
Each message starts in the folder the provider has it in: IMAP special-use folder attributes, Gmail labels, and Outlook well-known folders all map to the same six. Moves at the provider (junking a message, clearing it out of spam) follow on the next sync. Drafts are also reconciled: a draft the mailbox no longer holds is removed here too, so the fresh copy Gmail saves on every autosave does not leave the previous ones behind. You can also file a conversation yourself, which moves it here without moving it at the provider; see [filing a conversation](#filing-a-conversation). The active row is highlighted grey, unread counts sit on the right in blue, and hovering a folder reveals a three-dot menu with **Mark all as read**.
|
||||
|
||||
Spam and Trash stay out of every other view: the **All mail** view and the unread badge only count the folders you actually work.
|
||||
Spam and Trash stay out of every other view, and so does Archive: filing a conversation is how you take it out of the way, so it leaves Unread, Awaiting reply, every mailbox, label and tag view, and the unread badge. **All mail** and the **Archive** folder are the two places it stays, which is where you go to find it again.
|
||||
|
||||
<Callout type="info" title="Spam and Trash start from when you connect">
|
||||
The initial import that runs when a mailbox is first connected covers Inbox, Sent, Archive, and Drafts. It deliberately skips the existing contents of Spam and Trash, whose history would consume the import budget that belongs to real conversations. Both folders fill normally from the moment the mailbox is connected, so they show what arrives from then on rather than what was already there.
|
||||
@@ -65,9 +67,9 @@ The initial import that runs when a mailbox is first connected covers Inbox, Sen
|
||||
|
||||
| View | Shows |
|
||||
| --- | --- |
|
||||
| All mail | Every conversation across every mailbox (except Spam and Trash) |
|
||||
| All mail | Every conversation across every mailbox, archived ones included (except Spam and Trash) |
|
||||
| Unread | At least one unread message |
|
||||
| Awaiting reply | The other side is waiting on you |
|
||||
| Awaiting reply | You wrote last and they have not answered |
|
||||
| Agent drafts | Conversations where the [inbox agent](/guides/inbox-agent/) has a reply waiting for review |
|
||||
| Snoozed | Snoozed for later |
|
||||
| Scheduled | Replies queued to send later |
|
||||
@@ -134,7 +136,7 @@ Categories label conversations. Tags label the mailboxes themselves (grouping ac
|
||||
|
||||
## Read state
|
||||
|
||||
A conversation is unread when any message inside is unseen, marked by a blue dot in the margin, bolder text, and a blue timestamp. Opening it marks its messages seen and updates the counts.
|
||||
A conversation is unread when any message inside is unseen, marked by a blue dot in the margin, bolder text, and a blue timestamp. Opening it marks its messages seen and updates the counts, and so does **Mark as read** on the row or on a selection, without opening anything.
|
||||
|
||||
Read state travels both ways. A message arriving already read is stored that way, and reading a conversation here marks it read in the mailbox itself, so Gmail, Outlook and any IMAP mailbox stop showing it bold too. **Mark as unread** does the same in reverse, and **Mark all as read** on a folder carries the whole sweep across. Only a real change travels: opening a conversation you have already read costs nothing at the provider.
|
||||
|
||||
@@ -152,6 +154,18 @@ The thread header carries three filing actions, on the row above the message on
|
||||
|
||||
Archive and Delete both offer **Undo** on the confirmation toast. Open the Trash or Archive folder and the same header offers **Move to inbox**, so nothing filed by accident is stuck.
|
||||
|
||||
### From the list, without opening anything
|
||||
|
||||
You do not have to open a conversation to deal with it. Hover a row (or just look at it on a phone, where they are always shown) and the right of the first line carries **Archive** and a three-dot menu with **Mark as read / unread**, **Snooze**, **Archive** or **Move to inbox**, and **Delete**. In Archive and Trash the same controls offer the way back rather than the way out.
|
||||
|
||||
Archiving from a row files the whole conversation, not the message the row is showing, so it really does leave the view you filed it from.
|
||||
|
||||
### Working on several at once
|
||||
|
||||
Each row has a tick box in its left gutter, in place of the unread dot. Hover a row to see it, or press **Select** in the list header, which shows every box at once and adds an **All** box that takes everything currently loaded. `x` ticks the row you are on from the keyboard, and holding `Shift` while ticking takes the whole run between that row and the last one you ticked.
|
||||
|
||||
With anything ticked, a bar appears along the bottom of the screen with the count and the actions that apply to all of them: **Mark read**, **Mark unread**, **Snooze**, **Archive** (or **Move to inbox**) and **Delete**. Each one is a single request, so filing a screenful is as quick as filing one. **Clear** drops the selection, and so does `Escape`; changing view drops it too, since the rows it applied to are no longer the rows on screen.
|
||||
|
||||
<Callout type="info" title="Filing here does not move the message at the provider">
|
||||
Unlike read state, Archive and Delete are Warmbly's own filing. The message keeps its place in Gmail, Outlook, or whatever mail client the mailbox belongs to, and deleting a conversation here never deletes mail there. The next sync will not undo your filing either: Warmbly records where the provider has each message separately from where you filed it, and follows the provider only when the provider itself moves the message. So junking a message in Gmail still reaches Warmbly, and an ordinary sync pass does not.
|
||||
</Callout>
|
||||
|
||||
+48
-16
@@ -8140,7 +8140,7 @@
|
||||
"get": {
|
||||
"operationId": "unibox_list",
|
||||
"summary": "List incoming mail",
|
||||
"description": "Org-wide inbox list, collapsed to one row per thread (newest message), with filtering and cursor pagination. Excludes snoozed threads unless `snoozed=true`, and the `spam` and `trash` folders unless `folder` selects one of them.",
|
||||
"description": "Org-wide inbox list, collapsed to one row per thread (newest message), with filtering and cursor pagination. Excludes snoozed threads unless `snoozed=true`, and the `spam`, `trash` and `archive` folders unless `folder` selects one of them or `include_archived=true` is passed.",
|
||||
"tags": [
|
||||
"unibox"
|
||||
],
|
||||
@@ -8192,7 +8192,7 @@
|
||||
"name": "folder",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Canonical folder scope. Omit for every folder except `spam` and `trash`.",
|
||||
"description": "Canonical folder scope. Omit for every working folder: `spam`, `trash` and `archive` stay out.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -8205,6 +8205,15 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "include_archived",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "`true` puts archived conversations back into the result, which is what the All mail view does. Ignored when `folder` is set.",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unseen",
|
||||
"in": "query",
|
||||
@@ -8218,7 +8227,7 @@
|
||||
"name": "awaiting_reply",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "`true` returns only threads whose latest message was sent by you.",
|
||||
"description": "`true` returns only threads whose latest message was sent by one of your mailboxes.",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -8751,7 +8760,7 @@
|
||||
"patch": {
|
||||
"operationId": "unibox_mark_seen",
|
||||
"summary": "Mark messages seen",
|
||||
"description": "Marks a batch of messages as read or unread, org-wide. Up to 500 ids per call.",
|
||||
"description": "Marks messages as read or unread, org-wide. Name them with `email_ids`, whole conversations with `thread_ids`, or sweep a folder with `folder`. Up to 500 ids of each per call.",
|
||||
"tags": [
|
||||
"unibox"
|
||||
],
|
||||
@@ -8832,8 +8841,8 @@
|
||||
"/unibox/folder": {
|
||||
"patch": {
|
||||
"operationId": "unibox_move_folder",
|
||||
"summary": "Move messages between folders",
|
||||
"description": "Re-files a batch of messages into Inbox, Archive or Trash, org-wide. Up to 500 ids per call.\n\nThis is a move in Warmbly only. The copy at the mail provider stays where it is, and a later sync will not undo the move: Warmbly tracks the provider's own placement separately and follows it only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements the provider reaches, so they are rejected here with a `400`.",
|
||||
"summary": "Move conversations between folders",
|
||||
"description": "Re-files messages into Inbox, Archive or Trash, org-wide. Name them with `email_ids`, whole conversations with `thread_ids`, or both; up to 500 of each. Prefer `thread_ids`: filing part of a conversation leaves it listed.\n\nThis is a move in Warmbly only. The copy at the mail provider stays where it is, and a later sync will not undo the move: Warmbly tracks the provider's own placement separately and follows it only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements the provider reaches, so they are rejected here with a `400`.",
|
||||
"tags": [
|
||||
"unibox"
|
||||
],
|
||||
@@ -9063,8 +9072,8 @@
|
||||
"/unibox/snooze": {
|
||||
"post": {
|
||||
"operationId": "unibox_snooze",
|
||||
"summary": "Snooze a thread",
|
||||
"description": "Hides a thread from your inbox until `snoozed_until` passes. Upsert semantics: a second call on the same thread updates the time in place.",
|
||||
"summary": "Snooze one or more threads",
|
||||
"description": "Hides threads from your inbox until `snoozed_until` passes. Upsert semantics: a second call on the same thread updates the time in place. A request naming one thread answers with that row; one naming several answers with `data`.",
|
||||
"tags": [
|
||||
"unibox"
|
||||
],
|
||||
@@ -9143,8 +9152,8 @@
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "unibox_unsnooze",
|
||||
"summary": "Unsnooze a thread",
|
||||
"description": "Un-snoozes a thread immediately. Idempotent: deleting a snooze that does not exist still succeeds with 204.",
|
||||
"summary": "Unsnooze one or more threads",
|
||||
"description": "Un-snoozes threads immediately. Idempotent: deleting a snooze that does not exist still succeeds with 204.",
|
||||
"tags": [
|
||||
"unibox"
|
||||
],
|
||||
@@ -9158,7 +9167,7 @@
|
||||
"name": "thread_id",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "The thread to un-snooze.",
|
||||
"description": "The thread to un-snooze, or a comma-separated list of them.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -26192,7 +26201,7 @@
|
||||
},
|
||||
"UniboxMarkSeenRequest": {
|
||||
"type": "object",
|
||||
"description": "Also the echoed response body. Send one of `email_ids` or `folder`, not both.",
|
||||
"description": "Also the echoed response body. Name the messages with `email_ids`, whole conversations with `thread_ids`, or both. `folder` sweeps one folder instead and cannot be combined with either.",
|
||||
"properties": {
|
||||
"email_ids": {
|
||||
"type": "array",
|
||||
@@ -26203,6 +26212,14 @@
|
||||
"maxItems": 500,
|
||||
"description": "Message UUIDs to update (max 500)."
|
||||
},
|
||||
"thread_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 500,
|
||||
"description": "Thread ids whose every message to update (max 500). Use the `thread_id` of a list row, or its `id` for mail that has none."
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -26223,9 +26240,8 @@
|
||||
},
|
||||
"UniboxMoveFolderRequest": {
|
||||
"type": "object",
|
||||
"description": "Also the echoed response body.",
|
||||
"description": "Also the echoed response body. Name the messages with `email_ids`, whole conversations with `thread_ids`, or both. Prefer `thread_ids`: filing part of a conversation leaves it listed, because a thread shows wherever any message of it still sits.",
|
||||
"required": [
|
||||
"email_ids",
|
||||
"folder"
|
||||
],
|
||||
"properties": {
|
||||
@@ -26238,6 +26254,14 @@
|
||||
"maxItems": 500,
|
||||
"description": "Message UUIDs to move (max 500)."
|
||||
},
|
||||
"thread_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 500,
|
||||
"description": "Thread ids whose every message to re-file (max 500). Use the `thread_id` of a list row, or its `id` for mail that has none."
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -26348,7 +26372,6 @@
|
||||
"UniboxSnoozeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"thread_id",
|
||||
"snoozed_until"
|
||||
],
|
||||
"properties": {
|
||||
@@ -26356,12 +26379,21 @@
|
||||
"type": "string",
|
||||
"description": "The thread to snooze."
|
||||
},
|
||||
"thread_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 500,
|
||||
"description": "Threads to snooze (max 500), for filing a whole selection in one call."
|
||||
},
|
||||
"snoozed_until": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "RFC 3339 timestamp to un-hide the thread."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Name one conversation with `thread_id` or up to 500 with `thread_ids`. The single form answers with the snooze row; the bulk form answers with `data`."
|
||||
},
|
||||
"UniboxSnooze": {
|
||||
"type": "object",
|
||||
|
||||
@@ -80,8 +80,9 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) {
|
||||
params.Direction = &direction
|
||||
}
|
||||
|
||||
// Folder scope. Absent = every folder except spam and trash; an
|
||||
// unknown value is a 400 rather than silently widening the result.
|
||||
// Folder scope. Absent = every working folder (spam, trash and archive
|
||||
// stay out); an unknown value is a 400 rather than silently widening the
|
||||
// result.
|
||||
if folder := c.Query("folder"); folder != "" {
|
||||
if !models.ValidFolder(folder) {
|
||||
errx.Handle(c, errx.ErrUniboxFolder)
|
||||
@@ -90,6 +91,12 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) {
|
||||
params.Folder = &folder
|
||||
}
|
||||
|
||||
// All mail and reference reads ask for filed conversations back.
|
||||
if c.Query("include_archived") == "true" {
|
||||
v := true
|
||||
params.IncludeArchived = &v
|
||||
}
|
||||
|
||||
// Parse subject filter
|
||||
if subject := c.Query("subject"); subject != "" {
|
||||
params.Subject = &subject
|
||||
@@ -413,6 +420,7 @@ func (h *Handler) UniboxMoveFolder(c *gin.Context) {
|
||||
"action": "move_folder",
|
||||
"folder": data.Folder,
|
||||
"messages": strconv.Itoa(len(data.EmailIDs)),
|
||||
"threads": strconv.Itoa(len(data.ThreadIDs)),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
@@ -559,12 +567,26 @@ func (h *Handler) GetUniboxOverview(c *gin.Context) {
|
||||
}
|
||||
|
||||
type UniboxSnoozeRequest struct {
|
||||
ThreadID string `json:"thread_id" binding:"required"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
// ThreadIDs snoozes a whole selection in one call. Either field will do;
|
||||
// naming both snoozes the union.
|
||||
ThreadIDs []string `json:"thread_ids"`
|
||||
SnoozedUntil time.Time `json:"snoozed_until" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateUniboxSnooze hides a thread until snoozed_until passes.
|
||||
// threads is every conversation the request names, in either field.
|
||||
func (r UniboxSnoozeRequest) threads() []string {
|
||||
out := r.ThreadIDs
|
||||
if r.ThreadID != "" {
|
||||
out = append([]string{r.ThreadID}, out...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CreateUniboxSnooze hides conversations until snoozed_until passes.
|
||||
// Upsert semantics: a second call on the same thread updates the time.
|
||||
// A request naming one thread answers with that row, as it always has; one
|
||||
// naming several answers with `data`.
|
||||
// POST /unibox/snooze
|
||||
func (h *Handler) CreateUniboxSnooze(c *gin.Context) {
|
||||
if !h.gateUnibox(c) {
|
||||
@@ -583,18 +605,23 @@ func (h *Handler) CreateUniboxSnooze(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, xerr := h.UniboxService.Snooze(c.Request.Context(), uid, req.ThreadID, req.SnoozedUntil)
|
||||
threads := req.threads()
|
||||
rows, xerr := h.UniboxService.Snooze(c.Request.Context(), uid, threads, req.SnoozedUntil)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityUnibox, nil, nil, map[string]string{
|
||||
"action": "snooze",
|
||||
"thread_id": req.ThreadID,
|
||||
"action": "snooze",
|
||||
"threads": strconv.Itoa(len(threads)),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
if len(threads) == 1 && len(rows) == 1 {
|
||||
c.JSON(http.StatusOK, rows[0])
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
}
|
||||
|
||||
// DeleteUniboxSnooze un-snoozes a thread immediately. Idempotent —
|
||||
@@ -611,20 +638,27 @@ func (h *Handler) DeleteUniboxSnooze(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
threadID := c.Query("thread_id")
|
||||
if threadID == "" {
|
||||
// One id, or a comma-separated selection. Same shape as email_ids on the
|
||||
// other list endpoints.
|
||||
var threads []string
|
||||
for _, raw := range strings.Split(c.Query("thread_id"), ",") {
|
||||
if id := strings.TrimSpace(raw); id != "" {
|
||||
threads = append(threads, id)
|
||||
}
|
||||
}
|
||||
if len(threads) == 0 {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "thread_id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if xerr := h.UniboxService.Unsnooze(c.Request.Context(), uid, threadID); xerr != nil {
|
||||
if xerr := h.UniboxService.Unsnooze(c.Request.Context(), uid, threads); xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
|
||||
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityUnibox, nil, nil, map[string]string{
|
||||
"action": "snooze",
|
||||
"thread_id": threadID,
|
||||
"action": "snooze",
|
||||
"threads": strconv.Itoa(len(threads)),
|
||||
})
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
|
||||
@@ -174,7 +174,7 @@ func (d Deps) snoozeThread(ctx context.Context, inv Invocation, args json.RawMes
|
||||
if perr != nil {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
snooze, xerr := d.Unibox.Snooze(ctx, inv.UserID, in.ThreadID, until)
|
||||
snooze, xerr := d.Unibox.Snooze(ctx, inv.UserID, []string{in.ThreadID}, until)
|
||||
if xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func (d Deps) unsnoozeThread(ctx context.Context, inv Invocation, args json.RawM
|
||||
if in.ThreadID == "" {
|
||||
return "", ErrInvalidArgs
|
||||
}
|
||||
if xerr := d.Unibox.Unsnooze(ctx, inv.UserID, in.ThreadID); xerr != nil {
|
||||
if xerr := d.Unibox.Unsnooze(ctx, inv.UserID, []string{in.ThreadID}); xerr != nil {
|
||||
return "", fromErrx(xerr)
|
||||
}
|
||||
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityUnibox, nil, nil)
|
||||
|
||||
@@ -16,12 +16,13 @@ func (d Deps) registerUniboxTools(r *Registry) {
|
||||
Name: "list_threads",
|
||||
Description: "List unified-inbox conversation threads (received/synced mail), newest first, with optional filters. The inbox only contains synced mailbox mail; to find leads that never replied or went cold, use list_campaign_leads instead. If a filtered call returns 0, retry without filters before concluding the inbox is empty.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"subject": strProp("Optional subject contains filter."),
|
||||
"sender": strProp("Optional sender email filter."),
|
||||
"folder": strProp("Optional folder: inbox, sent, drafts, archive, spam or trash. Omit for every folder except spam and trash."),
|
||||
"unseen_only": boolProp("Only threads with unread messages."),
|
||||
"awaiting_reply": boolProp("Only threads whose LATEST message was sent by one of our mailboxes (we spoke last, still waiting on them)."),
|
||||
"limit": intProp("Max threads (1-50, default 20)."),
|
||||
"subject": strProp("Optional subject contains filter."),
|
||||
"sender": strProp("Optional sender email filter."),
|
||||
"folder": strProp("Optional folder: inbox, sent, drafts, archive, spam or trash. Omit for every working folder (spam, trash and archive stay out)."),
|
||||
"include_archived": boolProp("Put archived conversations back into an unscoped list, the way the All mail view does. Ignored when folder is set."),
|
||||
"unseen_only": boolProp("Only threads with unread messages."),
|
||||
"awaiting_reply": boolProp("Only threads whose LATEST message was sent by one of our mailboxes (we spoke last, still waiting on them)."),
|
||||
"limit": intProp("Max threads (1-50, default 20)."),
|
||||
}),
|
||||
Risk: generation.RiskRead,
|
||||
RequiredOrgPerm: models.PermAccessUnibox,
|
||||
@@ -78,12 +79,13 @@ func (d Deps) listThreads(ctx context.Context, inv Invocation, args json.RawMess
|
||||
return "", err
|
||||
}
|
||||
in, err := decodeArgs[struct {
|
||||
Subject string `json:"subject"`
|
||||
Sender string `json:"sender"`
|
||||
Folder string `json:"folder"`
|
||||
UnseenOnly bool `json:"unseen_only"`
|
||||
AwaitingReply bool `json:"awaiting_reply"`
|
||||
Limit int `json:"limit"`
|
||||
Subject string `json:"subject"`
|
||||
Sender string `json:"sender"`
|
||||
Folder string `json:"folder"`
|
||||
IncludeArchived bool `json:"include_archived"`
|
||||
UnseenOnly bool `json:"unseen_only"`
|
||||
AwaitingReply bool `json:"awaiting_reply"`
|
||||
Limit int `json:"limit"`
|
||||
}](args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -105,6 +107,10 @@ func (d Deps) listThreads(ctx context.Context, inv Invocation, args json.RawMess
|
||||
if in.Folder != "" {
|
||||
params.Folder = &in.Folder
|
||||
}
|
||||
if in.IncludeArchived {
|
||||
t := true
|
||||
params.IncludeArchived = &t
|
||||
}
|
||||
if in.UnseenOnly {
|
||||
t := true
|
||||
params.Unseen = &t
|
||||
|
||||
@@ -23,4 +23,8 @@ const (
|
||||
// Anything longer is functionally "mute" — we'd rather expose a
|
||||
// dedicated mute action than let snooze drift into archive.
|
||||
SnoozeMaxHorizon = 90 * 24 * time.Hour
|
||||
|
||||
// SnoozeMaxThreads bounds one selection-bar press, matching the id cap
|
||||
// the read-state and filing endpoints apply.
|
||||
SnoozeMaxThreads = 500
|
||||
)
|
||||
|
||||
@@ -21,14 +21,14 @@ func (s *uniboxService) MarkSeen(ctx context.Context, userID, emailID uuid.UUID,
|
||||
}
|
||||
|
||||
func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error) {
|
||||
if len(data.EmailIDs) > 500 {
|
||||
if len(data.EmailIDs) > 500 || len(data.ThreadIDs) > 500 {
|
||||
return nil, errx.ErrSeenMax
|
||||
}
|
||||
|
||||
// A folder sweep and an id list are different requests; refuse the
|
||||
// ambiguous combination instead of guessing which one was meant.
|
||||
if data.Folder != "" {
|
||||
if len(data.EmailIDs) > 0 {
|
||||
if len(data.EmailIDs) > 0 || len(data.ThreadIDs) > 0 {
|
||||
return nil, errx.ErrSeenFolderAndIDs
|
||||
}
|
||||
if !models.ValidFolder(data.Folder) {
|
||||
@@ -43,10 +43,24 @@ func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data
|
||||
return data, nil
|
||||
}
|
||||
|
||||
changed, err := s.uniboxRepository.MarkSeenBulk(ctx, orgID, data.EmailIDs, data.Seen)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
// Conversations and ids can arrive together: the list marks a row read by
|
||||
// thread, the reader marks the messages it has open by id.
|
||||
var changed []uuid.UUID
|
||||
if len(data.ThreadIDs) > 0 {
|
||||
byThread, err := s.uniboxRepository.MarkSeenByThreads(ctx, orgID, data.ThreadIDs, data.Seen)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
changed = append(changed, byThread...)
|
||||
}
|
||||
if len(data.EmailIDs) > 0 {
|
||||
byID, err := s.uniboxRepository.MarkSeenBulk(ctx, orgID, data.EmailIDs, data.Seen)
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
changed = append(changed, byID...)
|
||||
}
|
||||
s.relaySeen(ctx, orgID, changed)
|
||||
|
||||
@@ -139,7 +153,7 @@ func (s *uniboxService) publishSeenRelay(ctx context.Context, orgID uuid.UUID, c
|
||||
// Store-side only: the provider copy stays where it is, and provider_folder is
|
||||
// left alone so the sync can still tell a real provider move from a flag scan.
|
||||
func (s *uniboxService) MoveFolderBulk(ctx context.Context, orgID uuid.UUID, data *models.MoveFolder) (*models.MoveFolder, *errx.Error) {
|
||||
if len(data.EmailIDs) > 500 {
|
||||
if len(data.EmailIDs) > 500 || len(data.ThreadIDs) > 500 {
|
||||
return nil, errx.ErrSeenMax
|
||||
}
|
||||
// Only the three a user can file into. sent/drafts/spam are verdicts the
|
||||
@@ -147,6 +161,12 @@ func (s *uniboxService) MoveFolderBulk(ctx context.Context, orgID uuid.UUID, dat
|
||||
if !models.FilableFolder(data.Folder) {
|
||||
return nil, errx.ErrUniboxFilableFolder
|
||||
}
|
||||
// Filing by conversation is what the list rows use; the reader still names
|
||||
// the messages it has loaded.
|
||||
if err := s.uniboxRepository.MoveThreadsToFolder(ctx, orgID, data.ThreadIDs, data.Folder); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if err := s.uniboxRepository.MoveToFolderBulk(ctx, orgID, data.EmailIDs, data.Folder); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
|
||||
@@ -47,9 +47,10 @@ type UniboxService interface {
|
||||
MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error)
|
||||
MoveFolderBulk(ctx context.Context, orgID uuid.UUID, data *models.MoveFolder) (*models.MoveFolder, *errx.Error)
|
||||
|
||||
// Snooze hides a thread until `until`. Unsnooze drops the row.
|
||||
Snooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, *errx.Error)
|
||||
Unsnooze(ctx context.Context, userID uuid.UUID, threadID string) *errx.Error
|
||||
// Snooze hides conversations until `until`. Unsnooze drops the rows. Both
|
||||
// take a set so the list's selection bar is one call, not one per row.
|
||||
Snooze(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, *errx.Error)
|
||||
Unsnooze(ctx context.Context, userID uuid.UUID, threadIDs []string) *errx.Error
|
||||
ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error)
|
||||
|
||||
// Overview powers the scope rail + top metric strip in one call.
|
||||
|
||||
@@ -2,6 +2,7 @@ package unibox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -10,10 +11,16 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
)
|
||||
|
||||
func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, *errx.Error) {
|
||||
if threadID == "" {
|
||||
// Snooze takes a set of conversations so the list's selection bar is one call.
|
||||
// A single thread is the one-element case.
|
||||
func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, *errx.Error) {
|
||||
threadIDs = nonEmpty(threadIDs)
|
||||
if len(threadIDs) == 0 {
|
||||
return nil, errx.New(errx.BadRequest, "thread_id is required")
|
||||
}
|
||||
if len(threadIDs) > SnoozeMaxThreads {
|
||||
return nil, errx.ErrSeenMax
|
||||
}
|
||||
now := time.Now()
|
||||
// Tiny lead-time grace so a click that takes a few hundred ms
|
||||
// over the wire doesn't blow up validation when the user picked
|
||||
@@ -25,25 +32,41 @@ func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadID s
|
||||
return nil, errx.New(errx.BadRequest, "snoozed_until is too far in the future (max 90 days)")
|
||||
}
|
||||
|
||||
row, err := s.uniboxRepository.UpsertSnooze(ctx, userID, threadID, until.UTC())
|
||||
rows, err := s.uniboxRepository.UpsertSnoozes(ctx, userID, threadIDs, until.UTC())
|
||||
if err != nil {
|
||||
errs.CaptureException(err)
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return row, nil
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *uniboxService) Unsnooze(ctx context.Context, userID uuid.UUID, threadID string) *errx.Error {
|
||||
if threadID == "" {
|
||||
func (s *uniboxService) Unsnooze(ctx context.Context, userID uuid.UUID, threadIDs []string) *errx.Error {
|
||||
threadIDs = nonEmpty(threadIDs)
|
||||
if len(threadIDs) == 0 {
|
||||
return errx.New(errx.BadRequest, "thread_id is required")
|
||||
}
|
||||
if err := s.uniboxRepository.DeleteSnooze(ctx, userID, threadID); err != nil {
|
||||
if len(threadIDs) > SnoozeMaxThreads {
|
||||
return errx.ErrSeenMax
|
||||
}
|
||||
if err := s.uniboxRepository.DeleteSnoozes(ctx, userID, threadIDs); err != nil {
|
||||
errs.CaptureException(err)
|
||||
return errx.InternalError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nonEmpty drops blank ids, so one empty string in a list is not a request to
|
||||
// snooze a conversation that does not exist.
|
||||
func nonEmpty(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *uniboxService) ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error) {
|
||||
rows, err := s.uniboxRepository.ListSnoozes(ctx, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -332,15 +332,24 @@ type MailSearchParams struct {
|
||||
// conversation labels at all. nil = no filter.
|
||||
Uncategorized *bool
|
||||
// Folder narrows to one canonical folder (inbox/sent/drafts/archive/
|
||||
// spam/trash). nil = every folder except spam and trash, so junk never
|
||||
// bleeds into the combined view.
|
||||
Folder *string
|
||||
PageSize int
|
||||
Cursor string
|
||||
// spam/trash). nil = every working folder, so junk and filed mail never
|
||||
// bleed into the combined view.
|
||||
Folder *string
|
||||
// IncludeArchived puts archived conversations back into an unscoped
|
||||
// result. Filing is how a conversation leaves the working views, so it
|
||||
// has to leave all of them; only "All mail" and reference reads (compose
|
||||
// history) ask for it. Ignored when Folder names one.
|
||||
IncludeArchived *bool
|
||||
PageSize int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
type MarkSeen struct {
|
||||
EmailIDs []uuid.UUID `json:"email_ids"`
|
||||
// ThreadIDs marks whole conversations, so a caller holding a list row
|
||||
// does not have to fetch the thread to learn its message ids. Each entry
|
||||
// is a thread id, or a message id for mail that never got one.
|
||||
ThreadIDs []string `json:"thread_ids,omitempty"`
|
||||
// Folder, when set, marks every unread message in that folder for the
|
||||
// whole workspace instead of the explicit id list.
|
||||
Folder string `json:"folder,omitempty"`
|
||||
@@ -352,7 +361,12 @@ type MarkSeen struct {
|
||||
// is not moved, so the message stays where it is in the user's mail client.
|
||||
type MoveFolder struct {
|
||||
EmailIDs []uuid.UUID `json:"email_ids"`
|
||||
Folder string `json:"folder"`
|
||||
// ThreadIDs files whole conversations. A row in the list knows its thread
|
||||
// but not the ids inside it, and filing half a conversation leaves it in
|
||||
// the view it was filed out of. Each entry is a thread id, or a message id
|
||||
// for mail that never got one.
|
||||
ThreadIDs []string `json:"thread_ids,omitempty"`
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
|
||||
// UniboxSnooze hides a thread from the user's inbox until SnoozedUntil
|
||||
|
||||
@@ -51,12 +51,19 @@ type UniboxRepository interface {
|
||||
// MarkSeenBulk flips the read state of the given messages and returns the
|
||||
// ids that actually changed, which is what gets relayed to the provider.
|
||||
MarkSeenBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, seen bool) ([]uuid.UUID, error)
|
||||
// MarkSeenByThreads is MarkSeenBulk addressed by conversation, for callers
|
||||
// that hold a list row rather than the ids inside it.
|
||||
MarkSeenByThreads(ctx context.Context, orgID uuid.UUID, threadIDs []string, seen bool) ([]uuid.UUID, error)
|
||||
// MarkSeenByFolder flips the read state of every message in one canonical
|
||||
// folder for the whole workspace (the sidebar's "mark all as read").
|
||||
MarkSeenByFolder(ctx context.Context, orgID uuid.UUID, folder string, seen bool) ([]uuid.UUID, error)
|
||||
// MoveToFolderBulk re-files the given messages into one canonical folder,
|
||||
// org-scoped like MarkSeenBulk.
|
||||
MoveToFolderBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, folder string) error
|
||||
// MoveThreadsToFolder files whole conversations. Filing part of one leaves
|
||||
// it in the view it was filed out of, which reads as the action having
|
||||
// done nothing.
|
||||
MoveThreadsToFolder(ctx context.Context, orgID uuid.UUID, threadIDs []string, folder string) error
|
||||
// SeenRelayTargets names the given messages the way their provider does,
|
||||
// with the worker holding each mailbox. Rows whose mailbox has no worker
|
||||
// are left out: there is nothing to relay through.
|
||||
@@ -76,8 +83,10 @@ type UniboxRepository interface {
|
||||
// snoozed_until even if one already exists; DeleteSnooze removes
|
||||
// the row outright (instant un-snooze). ListSnoozes returns the
|
||||
// active set for the user.
|
||||
UpsertSnooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, error)
|
||||
DeleteSnooze(ctx context.Context, userID uuid.UUID, threadID string) error
|
||||
// The bulk forms back the conversation list's selection bar, so filing a
|
||||
// screenful of mail is one round trip rather than one per row.
|
||||
UpsertSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, error)
|
||||
DeleteSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string) error
|
||||
ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, error)
|
||||
|
||||
// Overview powers the scope rail + top metric strip. Single call
|
||||
@@ -145,6 +154,37 @@ var mailFieldsPreview = []string{
|
||||
"subject", "snippet", "internal_date", "seen",
|
||||
}
|
||||
|
||||
// Folders a view never shows unless it names one. Junk is junk; archive is
|
||||
// where a conversation goes to leave the views the workspace works out of, so
|
||||
// filing one has to take it out of all of them and not only the Inbox folder.
|
||||
// "All mail" and reference reads opt archive back in.
|
||||
const (
|
||||
foldersOutsideWorkingViews = `('spam', 'trash', 'archive')`
|
||||
foldersOutsideAllMail = `('spam', 'trash')`
|
||||
)
|
||||
|
||||
// bareAddrSQL extracts the exact address out of a raw header entry: the
|
||||
// bracketed form every provider writes ("Name <a@b.com>"), the parenthesised
|
||||
// form older IMAP rows carry, else the trimmed value. Lowercased, and never a
|
||||
// substring contains, so a@b.com matches neither xa@b.com nor a@b.com.evil.
|
||||
func bareAddrSQL(expr string) string {
|
||||
return fmt.Sprintf(
|
||||
`lower(coalesce(substring(%[1]s from '<([^>]*)>'), substring(%[1]s from '\(([^()]*)\)\s*$'), btrim(%[1]s)))`,
|
||||
expr,
|
||||
)
|
||||
}
|
||||
|
||||
// ownAddressSQL is the predicate "this header entry names a mailbox of the
|
||||
// organization in $1". The addresses are compared exactly; matching them by
|
||||
// containment is what made Awaiting reply nearly empty, since a mailbox sends
|
||||
// as "Name <addr>" and almost never as a bare address.
|
||||
func ownAddressSQL(arrayExpr, orgArg string) string {
|
||||
return fmt.Sprintf(`EXISTS (
|
||||
SELECT 1 FROM unnest(%s) AS own(addr)
|
||||
WHERE %s IN (SELECT lower(ea.email) FROM email_accounts ea WHERE ea.organization_id = %s)
|
||||
)`, arrayExpr, bareAddrSQL("own.addr"), orgArg)
|
||||
}
|
||||
|
||||
func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e *models.EmailMessageStoreData) error {
|
||||
query := `
|
||||
INSERT INTO unibox_emails (
|
||||
@@ -460,14 +500,17 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *
|
||||
FROM unibox_emails ue
|
||||
WHERE ue.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`, strings.Join(previewCols, ", "))
|
||||
|
||||
// Folder scoping. nil = every folder except spam and trash, so junk
|
||||
// never bleeds into the combined view.
|
||||
if params.Folder != nil {
|
||||
// Folder scoping. nil = every working folder; IncludeArchived widens that
|
||||
// to everything except junk, which is what "All mail" means.
|
||||
switch {
|
||||
case params.Folder != nil:
|
||||
inner += fmt.Sprintf(` AND ue.folder = $%d`, argPos)
|
||||
args = append(args, *params.Folder)
|
||||
argPos++
|
||||
} else {
|
||||
inner += ` AND ue.folder NOT IN ('spam', 'trash')`
|
||||
case params.IncludeArchived != nil && *params.IncludeArchived:
|
||||
inner += ` AND ue.folder NOT IN ` + foldersOutsideAllMail
|
||||
default:
|
||||
inner += ` AND ue.folder NOT IN ` + foldersOutsideWorkingViews
|
||||
}
|
||||
|
||||
// Snooze handling. nil = exclude snoozed (the inbox default), so
|
||||
@@ -579,22 +622,13 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *
|
||||
argPos++
|
||||
}
|
||||
|
||||
// Direction resolves against the org's own mailbox addresses. from_addr
|
||||
// entries can be raw headers ("Name <addr>"), so match by containment
|
||||
// rather than exact ANY().
|
||||
// Direction resolves against the org's own mailbox addresses, read out of
|
||||
// the raw header entry rather than compared to it whole.
|
||||
switch {
|
||||
case params.Direction != nil && *params.Direction == "sent":
|
||||
inner += ` AND EXISTS (
|
||||
SELECT 1 FROM unnest(ue.from_addr) AS f(addr)
|
||||
JOIN email_accounts ea2 ON ea2.organization_id = $1
|
||||
WHERE f.addr ILIKE '%' || ea2.email || '%'
|
||||
)`
|
||||
inner += ` AND ` + ownAddressSQL("ue.from_addr", "$1")
|
||||
case params.Direction != nil && *params.Direction == "received":
|
||||
inner += ` AND NOT EXISTS (
|
||||
SELECT 1 FROM unnest(ue.from_addr) AS f(addr)
|
||||
JOIN email_accounts ea2 ON ea2.organization_id = $1
|
||||
WHERE f.addr ILIKE '%' || ea2.email || '%'
|
||||
)`
|
||||
inner += ` AND NOT ` + ownAddressSQL("ue.from_addr", "$1")
|
||||
}
|
||||
|
||||
if len(params.EmailAccountIDs) > 0 {
|
||||
@@ -627,11 +661,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *
|
||||
// the user's own mailboxes — i.e. they're waiting on the recipient.
|
||||
if params.AwaitingReply != nil && *params.AwaitingReply {
|
||||
query += `
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM email_accounts ea
|
||||
WHERE ea.organization_id = $1
|
||||
AND ea.email = ANY(b.from_addr)
|
||||
)`
|
||||
AND ` + ownAddressSQL("b.from_addr", "$1")
|
||||
}
|
||||
|
||||
// Agent drafts: threads with a pending inbox-agent draft awaiting review.
|
||||
@@ -695,7 +725,7 @@ func (r *uniboxRepository) GetUnseenCount(ctx context.Context, orgID uuid.UUID,
|
||||
FROM unibox_emails
|
||||
WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
|
||||
AND email_id = $2 AND seen = FALSE
|
||||
AND folder NOT IN ('spam', 'trash')`,
|
||||
AND folder NOT IN `+foldersOutsideWorkingViews,
|
||||
orgID, *emailAccountID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
@@ -705,7 +735,7 @@ func (r *uniboxRepository) GetUnseenCount(ctx context.Context, orgID uuid.UUID,
|
||||
`SELECT COUNT(DISTINCT COALESCE(NULLIF(thread_id, ''), id::text))
|
||||
FROM unibox_emails
|
||||
WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) AND seen = FALSE
|
||||
AND folder NOT IN ('spam', 'trash')`,
|
||||
AND folder NOT IN `+foldersOutsideWorkingViews,
|
||||
orgID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
@@ -753,6 +783,36 @@ func (r *uniboxRepository) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, id
|
||||
return changed, rows.Err()
|
||||
}
|
||||
|
||||
// MarkSeenByThreads is MarkSeenBulk addressed by conversation. The key is the
|
||||
// same one the list collapses on, so an id that never got a thread still
|
||||
// resolves to its own single message.
|
||||
func (r *uniboxRepository) MarkSeenByThreads(ctx context.Context, orgID uuid.UUID, threadIDs []string, seen bool) ([]uuid.UUID, error) {
|
||||
if len(threadIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.db.Query(ctx,
|
||||
`UPDATE unibox_emails SET seen = $1, updated_at = NOW()
|
||||
WHERE COALESCE(NULLIF(thread_id, ''), id::text) = ANY($3) AND seen <> $1
|
||||
AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2)
|
||||
RETURNING id`,
|
||||
seen, orgID, threadIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
changed := make([]uuid.UUID, 0, len(threadIDs))
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed = append(changed, id)
|
||||
}
|
||||
return changed, rows.Err()
|
||||
}
|
||||
|
||||
// MarkSeenByFolder flips the read state of every message in one folder,
|
||||
// org-scoped like MarkSeenBulk (the sidebar's "mark all as read").
|
||||
func (r *uniboxRepository) MarkSeenByFolder(ctx context.Context, orgID uuid.UUID, folder string, seen bool) ([]uuid.UUID, error) {
|
||||
@@ -826,6 +886,22 @@ func (r *uniboxRepository) MoveToFolderBulk(ctx context.Context, orgID uuid.UUID
|
||||
return err
|
||||
}
|
||||
|
||||
// MoveThreadsToFolder files every message in the named conversations. Filing
|
||||
// by thread rather than by id is what makes a list row able to archive what it
|
||||
// shows: the row knows the conversation, not the messages inside it.
|
||||
func (r *uniboxRepository) MoveThreadsToFolder(ctx context.Context, orgID uuid.UUID, threadIDs []string, folder string) error {
|
||||
if len(threadIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(ctx,
|
||||
`UPDATE unibox_emails SET folder = $1, updated_at = NOW()
|
||||
WHERE COALESCE(NULLIF(thread_id, ''), id::text) = ANY($3)
|
||||
AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2)`,
|
||||
folder, orgID, threadIDs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *uniboxRepository) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -1108,30 +1184,41 @@ func (r *uniboxRepository) LatestMessageIDInThread(ctx context.Context, orgID uu
|
||||
|
||||
// ── Snoozes ────────────────────────────────────────────────────────────
|
||||
|
||||
func (r *uniboxRepository) UpsertSnooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, error) {
|
||||
if threadID == "" {
|
||||
return nil, errors.New("threadID required")
|
||||
func (r *uniboxRepository) UpsertSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, error) {
|
||||
if len(threadIDs) == 0 {
|
||||
return nil, errors.New("threadIDs required")
|
||||
}
|
||||
row := r.db.QueryRow(ctx, `
|
||||
rows, err := r.db.Query(ctx, `
|
||||
INSERT INTO unibox_snoozes (user_id, thread_id, snoozed_until, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
SELECT $1, t, $3, NOW(), NOW() FROM unnest($2::text[]) AS t
|
||||
ON CONFLICT (user_id, thread_id) DO UPDATE SET
|
||||
snoozed_until = EXCLUDED.snoozed_until,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, thread_id, snoozed_until, created_at, updated_at
|
||||
`, userID, threadID, until)
|
||||
|
||||
var s models.UniboxSnooze
|
||||
if err := row.Scan(&s.ID, &s.UserID, &s.ThreadID, &s.SnoozedUntil, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
`, userID, threadIDs, until)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]models.UniboxSnooze, 0, len(threadIDs))
|
||||
for rows.Next() {
|
||||
var s models.UniboxSnooze
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.ThreadID, &s.SnoozedUntil, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *uniboxRepository) DeleteSnooze(ctx context.Context, userID uuid.UUID, threadID string) error {
|
||||
func (r *uniboxRepository) DeleteSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string) error {
|
||||
if len(threadIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(ctx,
|
||||
`DELETE FROM unibox_snoozes WHERE user_id = $1 AND thread_id = $2`,
|
||||
userID, threadID,
|
||||
`DELETE FROM unibox_snoozes WHERE user_id = $1 AND thread_id = ANY($2)`,
|
||||
userID, threadIDs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -1181,6 +1268,11 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
// the rail numbers line up with the rows the user sees. Empty
|
||||
// thread_ids fall back to row id so unthreaded mail counts as its
|
||||
// own conversation.
|
||||
//
|
||||
// Archived mail counts for All mail and nowhere else, so every other
|
||||
// counter is computed over the thread's unarchived messages only. A rail
|
||||
// number that counts rows its own view then hides is what made Archive
|
||||
// look like it had done nothing.
|
||||
err := r.db.QueryRow(ctx, `
|
||||
WITH ue AS (
|
||||
SELECT
|
||||
@@ -1188,6 +1280,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
e.from_addr,
|
||||
e.internal_date,
|
||||
e.seen,
|
||||
e.folder = 'archive' AS is_archived,
|
||||
EXISTS (
|
||||
SELECT 1 FROM unibox_snoozes s
|
||||
WHERE s.user_id = e.user_id
|
||||
@@ -1196,36 +1289,33 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
) AS is_snoozed
|
||||
FROM unibox_emails e
|
||||
WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
|
||||
AND e.folder NOT IN ('spam', 'trash')
|
||||
AND e.folder NOT IN `+foldersOutsideAllMail+`
|
||||
),
|
||||
threads AS (
|
||||
SELECT
|
||||
tkey,
|
||||
bool_or(is_snoozed) AS is_snoozed,
|
||||
bool_or(NOT seen) AS has_unread,
|
||||
max(internal_date) AS last_date
|
||||
bool_or(is_snoozed) AS is_snoozed,
|
||||
bool_or(NOT is_archived) AS working,
|
||||
bool_or(NOT seen) FILTER (WHERE NOT is_archived) AS has_unread,
|
||||
max(internal_date) FILTER (WHERE NOT is_archived) AS last_date
|
||||
FROM ue
|
||||
GROUP BY tkey
|
||||
),
|
||||
latest_per_thread AS (
|
||||
SELECT DISTINCT ON (tkey) tkey, from_addr
|
||||
FROM ue
|
||||
WHERE NOT is_snoozed
|
||||
WHERE NOT is_snoozed AND NOT is_archived
|
||||
ORDER BY tkey, internal_date DESC
|
||||
),
|
||||
user_mailbox_emails AS (
|
||||
SELECT email FROM email_accounts WHERE organization_id = $1
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE NOT t.is_snoozed) AS total,
|
||||
COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.has_unread) AS unread,
|
||||
COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.last_date >= $2) AS today,
|
||||
COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.last_date >= $3) AS week,
|
||||
COUNT(*) FILTER (WHERE t.is_snoozed) AS snoozed,
|
||||
(SELECT COUNT(*) FROM latest_per_thread l
|
||||
WHERE EXISTS (SELECT 1 FROM user_mailbox_emails u WHERE u.email = ANY(l.from_addr))) AS awaiting,
|
||||
COUNT(*) FILTER (WHERE NOT t.is_snoozed) AS total,
|
||||
COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.working AND t.has_unread) AS unread,
|
||||
COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.working AND t.last_date >= $2) AS today,
|
||||
COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.working AND t.last_date >= $3) AS week,
|
||||
COUNT(*) FILTER (WHERE t.is_snoozed AND t.working) AS snoozed,
|
||||
(SELECT COUNT(*) FROM latest_per_thread l WHERE `+ownAddressSQL("l.from_addr", "$1")+`) AS awaiting,
|
||||
(SELECT COUNT(*) FROM ai_thread_drafts d
|
||||
WHERE d.organization_id = $1 AND d.status = 'pending') AS awaiting_agent_draft
|
||||
WHERE d.organization_id = $1 AND d.status = 'pending') AS awaiting_agent_draft
|
||||
FROM threads t
|
||||
`, orgID, todayStart, weekStart).Scan(
|
||||
&overview.Total,
|
||||
@@ -1299,7 +1389,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
)) AS total
|
||||
FROM email_accounts ea
|
||||
LEFT JOIN unibox_emails ue ON ue.email_id = ea.id AND ue.user_id = ea.user_id
|
||||
AND ue.folder NOT IN ('spam', 'trash')
|
||||
AND ue.folder NOT IN `+foldersOutsideWorkingViews+`
|
||||
WHERE ea.organization_id = $1
|
||||
GROUP BY ea.id, ea.email, ea.name
|
||||
ORDER BY ea.email ASC
|
||||
@@ -1342,7 +1432,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
LEFT JOIN email_tags et ON et.tag_id = t.id
|
||||
LEFT JOIN email_accounts ea ON ea.id = et.email_id AND ea.organization_id = t.organization_id
|
||||
LEFT JOIN unibox_emails ue ON ue.email_id = ea.id
|
||||
AND ue.folder NOT IN ('spam', 'trash')
|
||||
AND ue.folder NOT IN `+foldersOutsideWorkingViews+`
|
||||
WHERE t.organization_id = $1
|
||||
GROUP BY t.id, t.title, t.color, t.position
|
||||
ORDER BY t.position ASC, t.title ASC
|
||||
@@ -1375,7 +1465,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode
|
||||
SELECT e.thread_id, bool_or(NOT e.seen) AS has_unread
|
||||
FROM unibox_emails e
|
||||
WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
|
||||
AND e.folder NOT IN ('spam', 'trash')
|
||||
AND e.folder NOT IN `+foldersOutsideWorkingViews+`
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM unibox_snoozes s
|
||||
WHERE s.user_id = e.user_id AND s.thread_id = e.thread_id AND s.snoozed_until > NOW()
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// What a view shows after a conversation is filed, and who "we spoke last"
|
||||
// resolves to. Both are decided entirely in SQL, which is why they run against
|
||||
// a real schema:
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
||||
// go test ./internal/repository/ -run LiveUniboxScope -v
|
||||
//
|
||||
// Archive used to leave a conversation in every view but Inbox, so pressing it
|
||||
// from Awaiting reply removed the row and the next read put it straight back.
|
||||
// Awaiting reply compared a mailbox address against the whole From header,
|
||||
// which is "Name <addr>" from every provider, so it matched almost nothing.
|
||||
|
||||
// message writes one message into the fixture's mailbox with the from header
|
||||
// and folder the case needs. threadID groups them into a conversation.
|
||||
func (f *uniboxFolderFixture) scopedMessage(t *testing.T, repo UniboxRepository, threadID, from, folder string, at time.Time) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
err := repo.CreateEntry(context.Background(), f.user, &models.EmailMessageStoreData{
|
||||
ID: id, EmailID: f.mailbox, Folder: folder,
|
||||
ThreadID: threadID, MessageID: "<" + id.String() + "@test.local>",
|
||||
FromAddr: []string{from},
|
||||
ToAddr: []string{"them@example.com"},
|
||||
Subject: "Scope", Snippet: "Scope",
|
||||
InternalDate: at, SentDate: at, CreatedAt: at, UpdatedAt: at,
|
||||
Seen: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntry: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *uniboxFolderFixture) mailboxAddress(t *testing.T, repo UniboxRepository) string {
|
||||
t.Helper()
|
||||
ov, err := repo.Overview(context.Background(), f.org)
|
||||
if err != nil {
|
||||
t.Fatalf("Overview: %v", err)
|
||||
}
|
||||
if len(ov.Mailboxes) != 1 {
|
||||
t.Fatalf("fixture has %d mailboxes, want 1", len(ov.Mailboxes))
|
||||
}
|
||||
return ov.Mailboxes[0].Email
|
||||
}
|
||||
|
||||
func threadIDs(res *models.MailSearchResult) []string {
|
||||
out := make([]string, 0, len(res.Data))
|
||||
for _, row := range res.Data {
|
||||
out = append(out, row.ThreadID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listsThread(res *models.MailSearchResult, want string) bool {
|
||||
for _, id := range threadIDs(res) {
|
||||
if id == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// A mailbox sends as "Name <addr>". Comparing that against the address whole
|
||||
// is what made Awaiting reply look empty on a workspace that had been sending
|
||||
// all week.
|
||||
func TestLiveUniboxScopeAwaitingReplyReadsTheAddressOutOfTheHeader(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
ours := f.mailboxAddress(t, repo)
|
||||
now := time.Now().UTC()
|
||||
|
||||
f.scopedMessage(t, repo, "thread-wrapped", "Alex at Acme <"+ours+">", models.FolderSent, now)
|
||||
f.scopedMessage(t, repo, "thread-theirs", "them@example.com", models.FolderInbox, now)
|
||||
|
||||
awaiting := true
|
||||
res, err := repo.Search(ctx, f.org, &models.MailSearchParams{AwaitingReply: &awaiting, PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if !listsThread(res, "thread-wrapped") {
|
||||
t.Fatalf("awaiting reply = %v, want the thread we spoke last on", threadIDs(res))
|
||||
}
|
||||
if listsThread(res, "thread-theirs") {
|
||||
t.Fatalf("awaiting reply = %v, want no thread the other side spoke last on", threadIDs(res))
|
||||
}
|
||||
|
||||
ov, err := repo.Overview(ctx, f.org)
|
||||
if err != nil {
|
||||
t.Fatalf("Overview: %v", err)
|
||||
}
|
||||
if ov.AwaitingReply != 1 {
|
||||
t.Fatalf("overview awaiting_reply = %d, want 1 (the rail has to agree with the rows)", ov.AwaitingReply)
|
||||
}
|
||||
}
|
||||
|
||||
// An address that merely contains ours is not ours.
|
||||
func TestLiveUniboxScopeAwaitingReplyIsNotASubstringMatch(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
ours := f.mailboxAddress(t, repo)
|
||||
|
||||
f.scopedMessage(t, repo, "thread-lookalike", "Impostor <x"+ours+">", models.FolderInbox, time.Now().UTC())
|
||||
|
||||
awaiting := true
|
||||
res, err := repo.Search(ctx, f.org, &models.MailSearchParams{AwaitingReply: &awaiting, PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if listsThread(res, "thread-lookalike") {
|
||||
t.Fatalf("awaiting reply = %v, want an address that only contains ours left out", threadIDs(res))
|
||||
}
|
||||
}
|
||||
|
||||
// Filing takes the conversation out of every view a workspace works from, and
|
||||
// leaves it in All mail and in Archive. Anything less and the row comes back on
|
||||
// the next read, which is what "Archive does nothing" looked like.
|
||||
func TestLiveUniboxScopeArchiveLeavesTheWorkingViews(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
ours := f.mailboxAddress(t, repo)
|
||||
now := time.Now().UTC()
|
||||
|
||||
f.scopedMessage(t, repo, "thread-filed", "Alex at Acme <"+ours+">", models.FolderSent, now)
|
||||
|
||||
awaiting, unseen, yes := true, true, true
|
||||
before, err := repo.Search(ctx, f.org, &models.MailSearchParams{AwaitingReply: &awaiting, PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if !listsThread(before, "thread-filed") {
|
||||
t.Fatal("the conversation is not in Awaiting reply to begin with")
|
||||
}
|
||||
|
||||
if err := repo.MoveThreadsToFolder(ctx, f.org, []string{"thread-filed"}, models.FolderArchive); err != nil {
|
||||
t.Fatalf("MoveThreadsToFolder: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
view string
|
||||
params *models.MailSearchParams
|
||||
}{
|
||||
{"unscoped", &models.MailSearchParams{PageSize: 50}},
|
||||
{"awaiting reply", &models.MailSearchParams{AwaitingReply: &awaiting, PageSize: 50}},
|
||||
{"unread", &models.MailSearchParams{Unseen: &unseen, PageSize: 50}},
|
||||
} {
|
||||
res, err := repo.Search(ctx, f.org, tc.params)
|
||||
if err != nil {
|
||||
t.Fatalf("Search %s: %v", tc.view, err)
|
||||
}
|
||||
if listsThread(res, "thread-filed") {
|
||||
t.Errorf("a filed conversation is still in %s", tc.view)
|
||||
}
|
||||
}
|
||||
|
||||
allMail, err := repo.Search(ctx, f.org, &models.MailSearchParams{IncludeArchived: &yes, PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Search all mail: %v", err)
|
||||
}
|
||||
if !listsThread(allMail, "thread-filed") {
|
||||
t.Error("a filed conversation has left All mail too; Archive is where it went, not nowhere")
|
||||
}
|
||||
|
||||
archive := models.FolderArchive
|
||||
filed, err := repo.Search(ctx, f.org, &models.MailSearchParams{Folder: &archive, PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Search archive: %v", err)
|
||||
}
|
||||
if !listsThread(filed, "thread-filed") {
|
||||
t.Error("a filed conversation is not in the Archive folder")
|
||||
}
|
||||
|
||||
ov, err := repo.Overview(ctx, f.org)
|
||||
if err != nil {
|
||||
t.Fatalf("Overview: %v", err)
|
||||
}
|
||||
if ov.AwaitingReply != 0 || ov.Unread != 0 {
|
||||
t.Errorf("overview awaiting=%d unread=%d, want both 0 once the only conversation is filed", ov.AwaitingReply, ov.Unread)
|
||||
}
|
||||
if ov.Total != 1 {
|
||||
t.Errorf("overview total = %d, want 1: All mail still holds it", ov.Total)
|
||||
}
|
||||
unseenCount, err := repo.GetUnseenCount(ctx, f.org, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnseenCount: %v", err)
|
||||
}
|
||||
if unseenCount != 0 {
|
||||
t.Errorf("unread badge = %d, want 0", unseenCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Filing by conversation has to take every message in it. Leaving one behind
|
||||
// puts the whole row back, because a thread is shown by any message of it that
|
||||
// is still in a working folder.
|
||||
func TestLiveUniboxScopeArchiveTakesTheWholeConversation(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
f.scopedMessage(t, repo, "thread-pair", "them@example.com", models.FolderInbox, now.Add(-time.Hour))
|
||||
f.scopedMessage(t, repo, "thread-pair", "them@example.com", models.FolderInbox, now)
|
||||
|
||||
if err := repo.MoveThreadsToFolder(ctx, f.org, []string{"thread-pair"}, models.FolderArchive); err != nil {
|
||||
t.Fatalf("MoveThreadsToFolder: %v", err)
|
||||
}
|
||||
|
||||
res, err := repo.Search(ctx, f.org, &models.MailSearchParams{PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if listsThread(res, "thread-pair") {
|
||||
t.Fatal("filing the conversation left part of it behind, so the row is still listed")
|
||||
}
|
||||
}
|
||||
|
||||
// Another organization's conversations are not this one's to file.
|
||||
func TestLiveUniboxScopeMoveThreadsIsOrgScoped(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
mine := newUniboxFolderFixture(t, handle.Pool)
|
||||
theirs := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
|
||||
id := theirs.scopedMessage(t, repo, "thread-theirs-only", "them@example.com", models.FolderInbox, time.Now().UTC())
|
||||
if err := repo.MoveThreadsToFolder(ctx, mine.org, []string{"thread-theirs-only"}, models.FolderArchive); err != nil {
|
||||
t.Fatalf("MoveThreadsToFolder: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, theirs.user, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Folder != models.FolderInbox {
|
||||
t.Fatalf("folder = %q, want another org's conversation left at %q", got.Folder, models.FolderInbox)
|
||||
}
|
||||
}
|
||||
|
||||
// Marking a conversation read from a list row addresses it by thread, because
|
||||
// the row knows the conversation and not the ids inside it.
|
||||
func TestLiveUniboxScopeMarkSeenByThreadsTakesTheWholeConversation(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
f.scopedMessage(t, repo, "thread-unread", "them@example.com", models.FolderInbox, now.Add(-time.Hour))
|
||||
f.scopedMessage(t, repo, "thread-unread", "them@example.com", models.FolderInbox, now)
|
||||
|
||||
changed, err := repo.MarkSeenByThreads(ctx, f.org, []string{"thread-unread"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkSeenByThreads: %v", err)
|
||||
}
|
||||
if len(changed) != 2 {
|
||||
t.Fatalf("changed %d messages, want both in the conversation", len(changed))
|
||||
}
|
||||
|
||||
count, err := repo.GetUnseenCount(ctx, f.org, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnseenCount: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("unread = %d, want 0", count)
|
||||
}
|
||||
|
||||
// Already read costs nothing at the provider: only real changes relay.
|
||||
again, err := repo.MarkSeenByThreads(ctx, f.org, []string{"thread-unread"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkSeenByThreads again: %v", err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("changed %d messages on a no-op mark, want 0", len(again))
|
||||
}
|
||||
}
|
||||
|
||||
// The selection bar snoozes a whole screenful in one call, so the repository
|
||||
// takes the set. Upsert semantics hold per row: re-snoozing a conversation
|
||||
// already in the set moves its time rather than failing the batch.
|
||||
func TestLiveUniboxScopeSnoozeTakesASet(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
f.scopedMessage(t, repo, "thread-a", "them@example.com", models.FolderInbox, now)
|
||||
f.scopedMessage(t, repo, "thread-b", "them@example.com", models.FolderInbox, now)
|
||||
t.Cleanup(func() {
|
||||
_ = repo.DeleteSnoozes(context.Background(), f.user, []string{"thread-a", "thread-b"})
|
||||
})
|
||||
|
||||
rows, err := repo.UpsertSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}, now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertSnoozes: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("snoozed %d conversations, want 2", len(rows))
|
||||
}
|
||||
|
||||
res, err := repo.Search(ctx, f.org, &models.MailSearchParams{PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if listsThread(res, "thread-a") || listsThread(res, "thread-b") {
|
||||
t.Fatalf("a snoozed conversation is still listed: %v", threadIDs(res))
|
||||
}
|
||||
|
||||
// A second pass over the same set moves the time instead of erroring.
|
||||
if _, err := repo.UpsertSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}, now.Add(2*time.Hour)); err != nil {
|
||||
t.Fatalf("UpsertSnoozes again: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}); err != nil {
|
||||
t.Fatalf("DeleteSnoozes: %v", err)
|
||||
}
|
||||
back, err := repo.Search(ctx, f.org, &models.MailSearchParams{PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Search after un-snooze: %v", err)
|
||||
}
|
||||
if !listsThread(back, "thread-a") || !listsThread(back, "thread-b") {
|
||||
t.Fatalf("un-snoozing did not bring the conversations back: %v", threadIDs(back))
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,12 @@ export default function UniboxPage() {
|
||||
// mailbox.
|
||||
next.categoryIds = [scope.categoryId];
|
||||
break;
|
||||
case "all":
|
||||
// All mail is the one view a filed conversation stays in, so
|
||||
// Archive means "out of the way", not "gone". Every other scope
|
||||
// leaves it out, which is what makes the button do something.
|
||||
next.includeArchived = true;
|
||||
break;
|
||||
case "view": {
|
||||
// A premade view is a set of automatic labels, resolved to the
|
||||
// workspace's category rows by slug.
|
||||
|
||||
@@ -167,7 +167,7 @@ describe("unibox list shortcuts (#484)", SUITE, () => {
|
||||
).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(ROWS[0].subject).closest("button")!);
|
||||
fireEvent.click(screen.getByText(ROWS[0].subject).closest('[role="button"]')!);
|
||||
});
|
||||
await settle();
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ async function drag(fromX: number, toX: number) {
|
||||
|
||||
async function openThread(subject: string) {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(subject).closest("button")!);
|
||||
fireEvent.click(screen.getByText(subject).closest('[role="button"]')!);
|
||||
});
|
||||
await settle();
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("unibox scroll position", SUITE, () => {
|
||||
await scrollTo(1200);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Subject 4").closest("button")!);
|
||||
fireEvent.click(screen.getByText("Subject 4").closest('[role="button"]')!);
|
||||
});
|
||||
await settle();
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("unibox scroll position", SUITE, () => {
|
||||
await settle();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Subject 2").closest("button")!);
|
||||
fireEvent.click(screen.getByText("Subject 2").closest('[role="button"]')!);
|
||||
});
|
||||
await settle();
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("unibox scroll position", SUITE, () => {
|
||||
await scrollTo(700);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Subject 3").closest("button")!);
|
||||
fireEvent.click(screen.getByText("Subject 3").closest('[role="button"]')!);
|
||||
});
|
||||
await settle();
|
||||
scroller().scrollTop = 0;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// Triage from the conversation list: the row's own actions, and the selection
|
||||
// bar behind the checkboxes.
|
||||
//
|
||||
// Archive used to look like it had done nothing. The row left the list on the
|
||||
// optimistic removal and the refetch that followed put it straight back,
|
||||
// because every scope but Inbox listed archived mail too. The server half of
|
||||
// that is pinned in Go; this pins the client half: the scopes that must ask
|
||||
// for filed conversations back, and the ones that must not.
|
||||
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
import { screen, act, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import {
|
||||
installLayoutShims,
|
||||
mount,
|
||||
resetScrollTops,
|
||||
ROWS,
|
||||
setViewportWidth,
|
||||
settle,
|
||||
SUITE,
|
||||
} from "./uniboxHarness";
|
||||
|
||||
type Call = { method?: string; url?: string; data?: Record<string, unknown> };
|
||||
|
||||
const calls = vi.hoisted((): Call[] => []);
|
||||
|
||||
beforeAll(() => {
|
||||
installLayoutShims();
|
||||
setViewportWidth(1512);
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api/client/Request", () => ({
|
||||
default: async (cfg: Call) => {
|
||||
calls.push({ method: cfg.method, url: cfg.url, data: cfg.data });
|
||||
const { route } = await import("./uniboxHarness");
|
||||
return route(String(cfg?.url ?? ""));
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/helper/getToken", () => ({
|
||||
default: () => ({
|
||||
access_token: "a",
|
||||
refresh_token: "r",
|
||||
access_token_expires_at: new Date(Date.now() + 3600e3).toISOString(),
|
||||
refresh_token_expires_at: new Date(Date.now() + 3600e3).toISOString(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/SocketProvider", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/hooks/context/socket", async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useSocket: () => ({
|
||||
isConnected: false,
|
||||
subscribeToChannel: () => () => {},
|
||||
pushToChannel: () => {},
|
||||
socket: null,
|
||||
status: "closed",
|
||||
}),
|
||||
useChannel: () => ({ state: "closed", push: () => {}, channel: null }),
|
||||
useChannelEvent: () => {},
|
||||
useChannelSubscription: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
const listUrls = () =>
|
||||
calls
|
||||
.filter((c) => c.url === "/unibox" || c.url?.startsWith("/unibox?"))
|
||||
.map((c) => String(c.url));
|
||||
|
||||
const patches = (path: string) =>
|
||||
calls.filter((c) => c.method === "PATCH" && c.url === path);
|
||||
|
||||
// The rows carry Archive buttons of their own, so the bar's controls are
|
||||
// always reached through the bar.
|
||||
const selectionBar = () =>
|
||||
screen.getByRole("toolbar", { name: "Selection actions" });
|
||||
|
||||
describe("unibox triage", SUITE, () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
resetScrollTops();
|
||||
setViewportWidth(1512);
|
||||
});
|
||||
|
||||
// All mail is the one view a filed conversation stays in.
|
||||
it("asks for filed conversations in All mail", async () => {
|
||||
await mount("/app/unibox/all");
|
||||
await settle();
|
||||
expect(listUrls().some((u) => u.includes("include_archived=true"))).toBe(true);
|
||||
});
|
||||
|
||||
// Every other scope has to leave them out, or Archive removes a row that
|
||||
// the next read returns.
|
||||
it("leaves filed conversations out of a working view", async () => {
|
||||
await mount("/app/unibox/awaiting");
|
||||
await settle();
|
||||
const awaiting = listUrls();
|
||||
expect(awaiting.some((u) => u.includes("awaiting_reply=true"))).toBe(true);
|
||||
expect(awaiting.some((u) => u.includes("include_archived"))).toBe(false);
|
||||
});
|
||||
|
||||
// The row knows its conversation and not the message ids inside it, so it
|
||||
// files by thread; filing part of one leaves the row where it was.
|
||||
it("archives from the row, addressing the whole conversation", async () => {
|
||||
await mount("/app/unibox/awaiting");
|
||||
await settle();
|
||||
|
||||
const row = screen.getByText(ROWS[0].subject).closest('[role="button"]')!;
|
||||
await act(async () => {
|
||||
fireEvent.click(row.querySelector('[aria-label="Archive"]')!);
|
||||
});
|
||||
await settle();
|
||||
|
||||
const [filed] = patches("/unibox/folder");
|
||||
expect(filed).toBeTruthy();
|
||||
expect(filed.data).toMatchObject({
|
||||
folder: "archive",
|
||||
thread_ids: [ROWS[0].thread_id],
|
||||
});
|
||||
});
|
||||
|
||||
// Opening a conversation is not what the row's own actions are for.
|
||||
it("does not open the conversation when a row action is used", async () => {
|
||||
const router = await mount("/app/unibox/awaiting");
|
||||
await settle();
|
||||
|
||||
const row = screen.getByText(ROWS[1].subject).closest('[role="button"]')!;
|
||||
await act(async () => {
|
||||
fireEvent.click(row.querySelector('[aria-label="Conversation actions"]')!);
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(router.state.location.pathname).toBe("/app/unibox/awaiting");
|
||||
expect(screen.getByText("Mark as unread")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("marks a whole selection read from the selection bar", async () => {
|
||||
await mount("/app/unibox/awaiting");
|
||||
await settle();
|
||||
|
||||
for (const i of [0, 1]) {
|
||||
const row = screen.getByText(ROWS[i].subject).closest('[role="button"]')!;
|
||||
await act(async () => {
|
||||
fireEvent.click(row.querySelector('input[type="checkbox"]')!);
|
||||
});
|
||||
}
|
||||
await settle();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2 selected")).toBeTruthy());
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => {
|
||||
fireEvent.click(within(selectionBar()).getByTitle("Mark read"));
|
||||
});
|
||||
await settle();
|
||||
|
||||
const [seen] = patches("/unibox/seen");
|
||||
expect(seen).toBeTruthy();
|
||||
expect(seen.data).toMatchObject({
|
||||
seen: true,
|
||||
thread_ids: [ROWS[0].thread_id, ROWS[1].thread_id],
|
||||
});
|
||||
// The rows the bar acted on are no longer ticked: a count that outlives
|
||||
// what it applied to is a lie.
|
||||
await waitFor(() => expect(screen.queryByText("2 selected")).toBeNull());
|
||||
});
|
||||
|
||||
it("archives the whole selection in one call", async () => {
|
||||
await mount("/app/unibox/awaiting");
|
||||
await settle();
|
||||
|
||||
for (const i of [0, 1, 2]) {
|
||||
const row = screen.getByText(ROWS[i].subject).closest('[role="button"]')!;
|
||||
await act(async () => {
|
||||
fireEvent.click(row.querySelector('input[type="checkbox"]')!);
|
||||
});
|
||||
}
|
||||
await settle();
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => {
|
||||
fireEvent.click(within(selectionBar()).getByTitle("Archive"));
|
||||
});
|
||||
await settle();
|
||||
|
||||
const filed = patches("/unibox/folder");
|
||||
expect(filed).toHaveLength(1);
|
||||
expect(filed[0].data).toMatchObject({
|
||||
folder: "archive",
|
||||
thread_ids: [ROWS[0].thread_id, ROWS[1].thread_id, ROWS[2].thread_id],
|
||||
});
|
||||
});
|
||||
|
||||
// Shift picks the run between the two, the way a file list does.
|
||||
it("extends a selection with shift", async () => {
|
||||
await mount("/app/unibox/awaiting");
|
||||
await settle();
|
||||
|
||||
const box = (i: number) =>
|
||||
screen
|
||||
.getByText(ROWS[i].subject)
|
||||
.closest('[role="button"]')!
|
||||
.querySelector('input[type="checkbox"]')!;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(box(0));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(box(3), { shiftKey: true });
|
||||
});
|
||||
await settle();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("4 selected")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,38 @@
|
||||
// the room without a coloured bar. Labels sit at the end of the subject line
|
||||
// as small tinted chips; the owning mailbox shows only when the workspace
|
||||
// has more than one, quietly at the end of the preview.
|
||||
//
|
||||
// Triage happens on the row: Archive and a three-dot menu sit where the
|
||||
// timestamp is, so clearing a conversation never means opening it first. The
|
||||
// row is a div rather than a button because those controls nest inside it and
|
||||
// nested buttons are invalid HTML; each of them stops propagation so acting on
|
||||
// a row never also opens it.
|
||||
|
||||
import React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
InboxIcon,
|
||||
MailCheckIcon,
|
||||
MailOpenIcon,
|
||||
MoonIcon,
|
||||
MoreHorizontalIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { useResourceViewers } from "@/hooks/PresenceProvider";
|
||||
import type { ConversationActions } from "@/hooks/useConversationActions";
|
||||
import { SNOOZE_PRESETS } from "@/lib/unibox/snooze";
|
||||
import {
|
||||
PopoverMenu,
|
||||
PopoverMenuContent,
|
||||
PopoverMenuItem,
|
||||
PopoverMenuLabel,
|
||||
PopoverMenuSeparator,
|
||||
PopoverMenuTrigger,
|
||||
} from "@/components/ui/popover-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nameFromAddr } from "@/lib/helper/emailAddress";
|
||||
|
||||
@@ -32,9 +60,35 @@ function fromName(s: string): string {
|
||||
|
||||
interface ConversationItemProps {
|
||||
email: UniboxEmail;
|
||||
/**
|
||||
* The scope the row is being listed in. Archive and Trash offer the way
|
||||
* back instead of the way out, and Trash has nowhere further to file to.
|
||||
*/
|
||||
scope?: string;
|
||||
/** Ticked. */
|
||||
selected?: boolean;
|
||||
/**
|
||||
* Show the checkbox regardless of hover. The list turns this on while
|
||||
* anything is selected, and on touch, where there is no hover to reveal it.
|
||||
*/
|
||||
selecting?: boolean;
|
||||
/** Shift extends from the last row ticked, the way a file list does. */
|
||||
onToggleSelect?: (threadId: string, next: boolean, extend: boolean) => void;
|
||||
/**
|
||||
* Filing, read state and snooze. Passed in rather than taken from the hook
|
||||
* here so a long list holds one set of mutations, not one per row.
|
||||
*/
|
||||
actions: ConversationActions;
|
||||
}
|
||||
|
||||
export function ConversationItem({ email }: ConversationItemProps) {
|
||||
export function ConversationItem({
|
||||
email,
|
||||
scope,
|
||||
selected = false,
|
||||
selecting = false,
|
||||
onToggleSelect,
|
||||
actions,
|
||||
}: ConversationItemProps) {
|
||||
const selectedThreadId = useAppStore((s) => s.selectedThreadId);
|
||||
const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId);
|
||||
const setSelectedAccountId = useAppStore((s) => s.setSelectedAccountId);
|
||||
@@ -62,22 +116,75 @@ export function ConversationItem({ email }: ConversationItemProps) {
|
||||
const viewers = useResourceViewers(`thread:${threadId}`);
|
||||
const replierName = viewers.find((v) => v.action === "replying")?.name;
|
||||
|
||||
const open = () => {
|
||||
setSelectedThreadId(threadId);
|
||||
setSelectedAccountId(email.account_id ?? null);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedThreadId(threadId);
|
||||
setSelectedAccountId(email.account_id ?? null);
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={open}
|
||||
onKeyDown={(e) => {
|
||||
// Only the row itself activates: keydown from a nested control bubbles
|
||||
// here, and Enter on the three-dot button must open its menu.
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
aria-selected={onToggleSelect ? selected : undefined}
|
||||
className={cn(
|
||||
"group w-full text-left pl-3 pr-4 py-2.5 flex items-start gap-2 transition-colors",
|
||||
isSelected ? "bg-sky-50" : "hover:bg-slate-50",
|
||||
// The actions overlay the timestamp on a pointer device; on touch they
|
||||
// are always shown, so the row reserves the width instead.
|
||||
"group relative w-full cursor-pointer text-left pl-3 pr-11 md:pr-4 py-2.5 flex items-start gap-2 transition-colors",
|
||||
selected
|
||||
? "bg-sky-100/60"
|
||||
: isSelected
|
||||
? "bg-sky-50"
|
||||
: "hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
{/* Gutter: the unread dot, or the same space when read so text aligns. */}
|
||||
<span className="w-2 shrink-0 flex items-center justify-center h-[18px]">
|
||||
{unread && (
|
||||
<span aria-hidden className="block size-2 rounded-full bg-sky-500" />
|
||||
{/* Gutter: the tick box, or the unread dot when nothing is being
|
||||
selected. One fixed width, so turning selection on never shifts the
|
||||
column of names sideways. */}
|
||||
<span className="w-4 shrink-0 flex items-center justify-center h-[18px]">
|
||||
{onToggleSelect && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
aria-label={`Select ${sender}`}
|
||||
// The click does the work, not the change: only the click carries
|
||||
// shiftKey, and a checkbox has already flipped its own `checked`
|
||||
// by the time it is dispatched. Space on a focused box dispatches
|
||||
// one too, so the keyboard is covered. onChange is React's price
|
||||
// for a controlled input and has nothing to do.
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect(threadId, e.currentTarget.checked, e.shiftKey);
|
||||
}}
|
||||
onChange={() => {}}
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 rounded accent-sky-600 cursor-pointer",
|
||||
selected || selecting
|
||||
? "block"
|
||||
: "hidden md:group-hover:block md:group-focus-within:block",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{unread && !selected && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-2 rounded-full bg-sky-500",
|
||||
selecting ? "hidden" : "block md:group-hover:hidden md:group-focus-within:hidden",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -121,9 +228,13 @@ export function ConversationItem({ email }: ConversationItemProps) {
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{/* The actions take this corner on hover, so the time steps aside
|
||||
there. On touch they sit in the width the row reserved and the
|
||||
time keeps its place. */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] tabular-nums shrink-0 ml-auto",
|
||||
"md:group-hover:invisible md:group-focus-within:invisible",
|
||||
unread ? "text-sky-700 font-medium" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
@@ -172,6 +283,208 @@ export function ConversationItem({ email }: ConversationItemProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RowActions
|
||||
threadId={threadId}
|
||||
unread={unread}
|
||||
scope={scope}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The row's own triage controls: Archive inline, everything else one click
|
||||
// deeper. Shown on hover from md up and always on touch, where there is no
|
||||
// hover to reveal them with.
|
||||
function RowActions({
|
||||
threadId,
|
||||
unread,
|
||||
scope,
|
||||
actions,
|
||||
}: {
|
||||
threadId: string;
|
||||
unread: boolean;
|
||||
scope?: string;
|
||||
actions: ConversationActions;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [snoozeMode, setSnoozeMode] = React.useState(false);
|
||||
|
||||
const filed = scope === "archive" || scope === "trash";
|
||||
const snoozedScope = scope === "snoozed";
|
||||
|
||||
const stop = (e: React.MouseEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={stop}
|
||||
className={cn(
|
||||
"absolute right-2 top-1.5 flex items-center gap-0.5 rounded-md bg-inherit",
|
||||
"opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
<RowButton
|
||||
label={filed ? "Move to inbox" : "Archive"}
|
||||
onClick={() => actions.file([threadId], filed ? "inbox" : "archive")}
|
||||
disabled={actions.filing}
|
||||
// Touch gets the menu only: two always-on buttons would crowd the row
|
||||
// at the width a phone has.
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
{filed ? (
|
||||
<InboxIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ArchiveIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</RowButton>
|
||||
|
||||
<PopoverMenu
|
||||
align="end"
|
||||
open={menuOpen}
|
||||
onOpenChange={(o) => {
|
||||
setMenuOpen(o);
|
||||
if (!o) setSnoozeMode(false);
|
||||
}}
|
||||
>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Conversation actions"
|
||||
className="size-6 rounded inline-flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-slate-200/70 transition-colors"
|
||||
>
|
||||
<MoreHorizontalIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{snoozeMode ? (
|
||||
<motion.div
|
||||
key="snooze"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<PopoverMenuLabel>Snooze until</PopoverMenuLabel>
|
||||
{SNOOZE_PRESETS.map((p) => (
|
||||
<PopoverMenuItem
|
||||
key={p.label}
|
||||
onSelect={() => actions.snooze([threadId], p.until())}
|
||||
>
|
||||
{p.label}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
<PopoverMenuSeparator />
|
||||
<PopoverMenuItem
|
||||
onSelect={() => setSnoozeMode(false)}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
Back
|
||||
</PopoverMenuItem>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="actions"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<PopoverMenuItem
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpenIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<MailCheckIcon className="w-3.5 h-3.5" />
|
||||
)
|
||||
}
|
||||
onSelect={() => actions.setSeen([threadId], unread)}
|
||||
>
|
||||
{unread ? "Mark as read" : "Mark as unread"}
|
||||
</PopoverMenuItem>
|
||||
{snoozedScope ? (
|
||||
<PopoverMenuItem
|
||||
icon={<MoonIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={() => actions.unsnooze([threadId])}
|
||||
>
|
||||
Un-snooze now
|
||||
</PopoverMenuItem>
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
icon={<MoonIcon className="w-3.5 h-3.5" />}
|
||||
onSelect={() => setSnoozeMode(true)}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
Snooze…
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
<PopoverMenuSeparator />
|
||||
{filed ? (
|
||||
<PopoverMenuItem
|
||||
icon={<InboxIcon className="w-3.5 h-3.5" />}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => actions.file([threadId], "inbox")}
|
||||
>
|
||||
Move to inbox
|
||||
</PopoverMenuItem>
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
icon={<ArchiveIcon className="w-3.5 h-3.5" />}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => actions.file([threadId], "archive")}
|
||||
>
|
||||
Archive
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
{scope !== "trash" && (
|
||||
<PopoverMenuItem
|
||||
danger
|
||||
icon={<TrashIcon className="w-3.5 h-3.5" />}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => actions.file([threadId], "trash")}
|
||||
>
|
||||
Delete
|
||||
</PopoverMenuItem>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className={cn(
|
||||
"size-6 rounded inline-flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-slate-200/70 disabled:opacity-50 transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { PanelLeftIcon, PenLineIcon, SearchIcon } from "lucide-react";
|
||||
import { ConversationItem } from "./ConversationItem";
|
||||
import { SelectionBar } from "./SelectionBar";
|
||||
import { useConversationActions } from "@/hooks/useConversationActions";
|
||||
import useUniboxSearch from "@/lib/api/hooks/app/unibox/useUniboxSearch";
|
||||
import { useShortcutActions } from "@/hooks/useShortcutActions";
|
||||
import useDebouncedValue from "@/hooks/useDebouncedValue";
|
||||
@@ -112,6 +114,84 @@ export function ConversationList({
|
||||
const totalShown = emails.length;
|
||||
const activeFilters = countUserFilters(params, baseParams);
|
||||
|
||||
// ── Multi-select ───────────────────────────────────────────────
|
||||
// Thread ids, not row indexes: the list re-orders under a refetch, and an
|
||||
// index would then name a different conversation than the one ticked.
|
||||
const actions = useConversationActions();
|
||||
const [picked, setPicked] = React.useState<ReadonlySet<string>>(
|
||||
() => new Set<string>(),
|
||||
);
|
||||
// `selectMode` is the touch entry point, where there is no hover to reveal a
|
||||
// checkbox with. It stays on after the last row is unticked, so unticking
|
||||
// one by mistake does not take every box off screen.
|
||||
const [selectMode, setSelectMode] = React.useState(false);
|
||||
// A new scope is a new set of rows; carrying a selection across would apply
|
||||
// an action to conversations the user can no longer see.
|
||||
const [pickedScope, setPickedScope] = React.useState(scopeKey);
|
||||
if (pickedScope !== scopeKey) {
|
||||
setPickedScope(scopeKey);
|
||||
setPicked(new Set<string>());
|
||||
setSelectMode(false);
|
||||
}
|
||||
|
||||
const rowKey = React.useCallback(
|
||||
(row: (typeof emails)[number]) => row.thread_id || row.id,
|
||||
[],
|
||||
);
|
||||
// What the bar acts on: only rows still on screen. A conversation that has
|
||||
// left the list (filed by a teammate, snoozed, filtered out) must not be
|
||||
// counted, or the bar promises an action on something nobody can see.
|
||||
const selectedIds = React.useMemo(
|
||||
() => emails.map(rowKey).filter((id) => picked.has(id)),
|
||||
[emails, picked, rowKey],
|
||||
);
|
||||
const allSelected = emails.length > 0 && selectedIds.length === emails.length;
|
||||
const selecting = selectMode || selectedIds.length > 0;
|
||||
const clearSelection = React.useCallback(() => {
|
||||
setPicked(new Set<string>());
|
||||
setSelectMode(false);
|
||||
}, []);
|
||||
|
||||
// Shift extends from the last row ticked, the way a file list does.
|
||||
const lastPicked = React.useRef<string | null>(null);
|
||||
const toggleSelect = React.useCallback(
|
||||
(threadId: string, next: boolean, extend: boolean) => {
|
||||
// Read the anchor before the updater, not inside it. React may defer an
|
||||
// updater to render time, by which point the assignment below has
|
||||
// already moved the anchor onto the row being clicked, and a shift-click
|
||||
// would extend a range from a row to itself.
|
||||
const anchor = lastPicked.current;
|
||||
setPicked((prev) => {
|
||||
const out = new Set(prev);
|
||||
const from = anchor ? emails.findIndex((r) => rowKey(r) === anchor) : -1;
|
||||
const to = emails.findIndex((r) => rowKey(r) === threadId);
|
||||
if (extend && from >= 0 && to >= 0) {
|
||||
const [lo, hi] = from < to ? [from, to] : [to, from];
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
if (next) out.add(rowKey(emails[i]));
|
||||
else out.delete(rowKey(emails[i]));
|
||||
}
|
||||
} else if (next) {
|
||||
out.add(threadId);
|
||||
} else {
|
||||
out.delete(threadId);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
lastPicked.current = threadId;
|
||||
},
|
||||
[emails, rowKey],
|
||||
);
|
||||
|
||||
const toggleAll = React.useCallback(() => {
|
||||
setPicked((prev) => {
|
||||
const everything = emails.map(rowKey);
|
||||
const all = everything.length > 0 && everything.every((id) => prev.has(id));
|
||||
return all ? new Set<string>() : new Set(everything);
|
||||
});
|
||||
lastPicked.current = null;
|
||||
}, [emails, rowKey]);
|
||||
|
||||
// A search or filter change keeps the previous rows on screen while
|
||||
// the new ones load (placeholderData). That is the moment to show progress:
|
||||
// a bar along the top and the stale rows dimmed. Background refetches from
|
||||
@@ -229,10 +309,34 @@ export function ConversationList({
|
||||
if (currentIndex() < 0) selectRow(emails[0]);
|
||||
},
|
||||
listDeselect: () => {
|
||||
// The ticks are the innermost thing Escape can clear: taking the open
|
||||
// conversation away first would leave a selection bar with no obvious
|
||||
// way to dismiss it.
|
||||
if (selecting) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
if (!selectedThreadId) return;
|
||||
setSelectedThreadId(null);
|
||||
setSelectedAccountId(null);
|
||||
},
|
||||
listToggleSelect: () => {
|
||||
const row = emails[currentIndex()];
|
||||
if (!row) return;
|
||||
const id = rowKey(row);
|
||||
toggleSelect(id, !picked.has(id), false);
|
||||
},
|
||||
listArchive: () => {
|
||||
// The ticked rows when there are any, otherwise the focused one.
|
||||
const target = selectedIds.length > 0 ? selectedIds : [];
|
||||
if (target.length === 0) {
|
||||
const row = emails[currentIndex()];
|
||||
if (!row) return;
|
||||
void actions.file([rowKey(row)], "archive");
|
||||
return;
|
||||
}
|
||||
void actions.file(target, "archive").finally(clearSelection);
|
||||
},
|
||||
focusSearch: () => searchRef.current?.focus(),
|
||||
},
|
||||
// The filter popover owns the keyboard while it is open.
|
||||
@@ -240,6 +344,9 @@ export function ConversationList({
|
||||
);
|
||||
|
||||
const filtering = activeFilters > 0 || !!search.trim();
|
||||
const rowScope = scopeKey.startsWith("folder:")
|
||||
? scopeKey.slice("folder:".length)
|
||||
: scopeKey;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-white">
|
||||
@@ -270,6 +377,29 @@ export function ConversationList({
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{/* Select is the touch entry point into multi-select; on a pointer
|
||||
device hovering a row already shows its box, but the control is
|
||||
kept everywhere so the feature is discoverable at all. */}
|
||||
{selecting ? (
|
||||
<label className="h-7 px-2 rounded-md inline-flex items-center gap-1.5 text-[11.5px] text-slate-600 hover:bg-slate-100 cursor-pointer transition-colors shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-3.5 h-3.5 rounded accent-sky-600"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
aria-label={allSelected ? "Deselect all" : "Select all loaded"}
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectMode(true)}
|
||||
className="h-7 px-2 rounded-md text-[11.5px] text-slate-500 hover:text-slate-900 hover:bg-slate-100 transition-colors shrink-0"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
)}
|
||||
<UniboxFilterButton
|
||||
params={params}
|
||||
base={baseParams}
|
||||
@@ -406,6 +536,13 @@ export function ConversationList({
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<ConversationItem
|
||||
// The row's actions read the scope: Archive and Trash
|
||||
// offer the way back rather than the way out.
|
||||
scope={rowScope}
|
||||
selected={picked.has(e.thread_id || e.id)}
|
||||
selecting={selecting}
|
||||
onToggleSelect={toggleSelect}
|
||||
actions={actions}
|
||||
email={{
|
||||
id: e.id,
|
||||
from: e.from_addr?.[0] ?? "",
|
||||
@@ -454,6 +591,12 @@ export function ConversationList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SelectionBar
|
||||
threadIds={selectedIds}
|
||||
actions={actions}
|
||||
scope={rowScope}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Floating bar for a multi-row selection in the conversation list.
|
||||
//
|
||||
// Same shape as the contacts table's: fixed to the bottom centre, the count on
|
||||
// the left, the actions in the middle, Clear on the right. Fixed rather than
|
||||
// absolute, so it cannot park itself below the fold of a long list.
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CheckIcon,
|
||||
InboxIcon,
|
||||
Loader2Icon,
|
||||
MailCheckIcon,
|
||||
MailOpenIcon,
|
||||
MoonIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
PopoverMenu,
|
||||
PopoverMenuContent,
|
||||
PopoverMenuItem,
|
||||
PopoverMenuLabel,
|
||||
PopoverMenuTrigger,
|
||||
} from "@/components/ui/popover-menu";
|
||||
import { SNOOZE_PRESETS } from "@/lib/unibox/snooze";
|
||||
import type { ConversationActions } from "@/hooks/useConversationActions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SelectionBarProps {
|
||||
threadIds: string[];
|
||||
actions: ConversationActions;
|
||||
/** The scope the rows are listed in, which decides the filing direction. */
|
||||
scope?: string;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function SelectionBar({
|
||||
threadIds,
|
||||
actions,
|
||||
scope,
|
||||
onClear,
|
||||
}: SelectionBarProps) {
|
||||
const count = threadIds.length;
|
||||
// Every action clears the selection: the rows it applied to have left the
|
||||
// list, and a count that outlives its rows is a lie.
|
||||
const run = React.useCallback(
|
||||
(fn: () => void | Promise<void>) => {
|
||||
void Promise.resolve(fn()).finally(onClear);
|
||||
},
|
||||
[onClear],
|
||||
);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
const filed = scope === "archive" || scope === "trash";
|
||||
const snoozedScope = scope === "snoozed";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Selection actions"
|
||||
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30 flex items-center max-w-[calc(100vw-16px)] flex-wrap justify-center md:max-w-none md:flex-nowrap gap-1.5 rounded-md border border-slate-200 bg-white shadow-[0_6px_20px_-4px_rgba(15,23,42,0.12),0_2px_4px_rgba(15,23,42,0.04)] px-2 py-1.5"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1.5 px-2 h-7 rounded bg-sky-50 text-sky-700 text-[12px] font-medium">
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
<span>{count.toLocaleString()} selected</span>
|
||||
</div>
|
||||
|
||||
<BarButton
|
||||
icon={<MailCheckIcon className="w-3 h-3" />}
|
||||
label="Mark read"
|
||||
onClick={() => run(() => actions.setSeen(threadIds, true))}
|
||||
/>
|
||||
<BarButton
|
||||
icon={<MailOpenIcon className="w-3 h-3" />}
|
||||
label="Mark unread"
|
||||
onClick={() => run(() => actions.setSeen(threadIds, false))}
|
||||
/>
|
||||
|
||||
{snoozedScope ? (
|
||||
<BarButton
|
||||
icon={<MoonIcon className="w-3 h-3" />}
|
||||
label="Un-snooze"
|
||||
onClick={() => run(() => actions.unsnooze(threadIds))}
|
||||
/>
|
||||
) : (
|
||||
<PopoverMenu side="top" align="center">
|
||||
<PopoverMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 px-2.5 rounded text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<MoonIcon className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Snooze</span>
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent>
|
||||
<PopoverMenuLabel>
|
||||
Snooze {count.toLocaleString()} until
|
||||
</PopoverMenuLabel>
|
||||
{SNOOZE_PRESETS.map((p) => (
|
||||
<PopoverMenuItem
|
||||
key={p.label}
|
||||
onSelect={() => run(() => actions.snooze(threadIds, p.until()))}
|
||||
>
|
||||
{p.label}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
)}
|
||||
|
||||
<BarButton
|
||||
icon={
|
||||
filed ? (
|
||||
<InboxIcon className="w-3 h-3" />
|
||||
) : (
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
)
|
||||
}
|
||||
label={filed ? "Move to inbox" : "Archive"}
|
||||
busy={actions.filing}
|
||||
onClick={() =>
|
||||
run(() => actions.file(threadIds, filed ? "inbox" : "archive"))
|
||||
}
|
||||
/>
|
||||
|
||||
{scope !== "trash" && (
|
||||
<BarButton
|
||||
icon={<TrashIcon className="w-3 h-3" />}
|
||||
label="Delete"
|
||||
danger
|
||||
busy={actions.filing}
|
||||
onClick={() => run(() => actions.file(threadIds, "trash"))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="h-4 w-px bg-slate-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="h-7 px-2.5 rounded text-[12px] text-slate-500 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
busy,
|
||||
danger,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
busy?: boolean;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
title={label}
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60",
|
||||
danger
|
||||
? "text-red-600 hover:text-white hover:bg-red-600"
|
||||
: "text-slate-700 hover:text-slate-900 hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
{busy ? <Loader2Icon className="w-3 h-3 animate-spin" /> : icon}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -43,9 +43,9 @@ import ContactContextPanel from "./ContactContextPanel";
|
||||
import { CategoryChip } from "@/components/app/contacts/CategoryPicker";
|
||||
import useThread from "@/lib/api/hooks/app/unibox/useThread";
|
||||
import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen";
|
||||
import useMoveFolder from "@/lib/api/hooks/app/unibox/useMoveFolder";
|
||||
import { removeThreadsFromLists } from "@/lib/api/hooks/app/unibox/listCache";
|
||||
import moveFolderRequest, { type FilableFolder } from "@/lib/api/client/app/unibox/moveFolder";
|
||||
import { useConversationActions } from "@/hooks/useConversationActions";
|
||||
import { SNOOZE_PRESETS, offsetHours } from "@/lib/unibox/snooze";
|
||||
import type { FilableFolder } from "@/lib/api/client/app/unibox/moveFolder";
|
||||
import { bareEmail, nameFromAddr, wrappedEmail } from "@/lib/helper/emailAddress";
|
||||
import useThreadLabels from "@/lib/api/hooks/app/unibox/useThreadLabels";
|
||||
import useThreadScheduled from "@/lib/api/hooks/app/unibox/useThreadScheduled";
|
||||
@@ -64,10 +64,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
snoozeThread,
|
||||
unsnoozeThread,
|
||||
} from "@/lib/api/client/app/unibox/snoozeThread";
|
||||
import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail";
|
||||
import type UniboxScheduledItem from "@/lib/api/models/app/unibox/UniboxScheduled";
|
||||
import type { UniboxThreadMessage } from "@/lib/api/models/app/unibox/UniboxThread";
|
||||
@@ -92,47 +88,6 @@ function toUniboxEmail(m: UniboxThreadMessage): UniboxEmail {
|
||||
};
|
||||
}
|
||||
|
||||
// Filing copy, per destination. "Deleted" is deliberately not said anywhere:
|
||||
// the message is moved to Trash here and still sits in the mail client.
|
||||
const FILE_COPY: Record<FilableFolder, { done: string; failed: string }> = {
|
||||
archive: { done: "Archived", failed: "Couldn't archive" },
|
||||
trash: { done: "Moved to Trash", failed: "Couldn't move to Trash" },
|
||||
inbox: { done: "Moved to Inbox", failed: "Couldn't move to Inbox" },
|
||||
};
|
||||
|
||||
const SNOOZE_PRESETS: { label: string; until: () => Date }[] = [
|
||||
{ label: "In 1 hour", until: () => offsetHours(1) },
|
||||
{ label: "In 3 hours", until: () => offsetHours(3) },
|
||||
{ label: "Tomorrow 9:00", until: () => atHour(1, 9) },
|
||||
{ label: "Monday 9:00", until: () => nextMonday9() },
|
||||
{ label: "Next week", until: () => offsetDays(7) },
|
||||
];
|
||||
|
||||
function offsetHours(h: number): Date {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + h);
|
||||
return d;
|
||||
}
|
||||
function offsetDays(d: number): Date {
|
||||
const x = new Date();
|
||||
x.setDate(x.getDate() + d);
|
||||
return x;
|
||||
}
|
||||
function atHour(dayOffset: number, hour: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + dayOffset);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
function nextMonday9(): Date {
|
||||
const d = new Date();
|
||||
const dow = d.getDay();
|
||||
const delta = (1 - dow + 7) % 7 || 7;
|
||||
d.setDate(d.getDate() + delta);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
// Local datetime → ISO string. The native <input type="datetime-local">
|
||||
// hands back "YYYY-MM-DDTHH:mm" (no zone), interpreted as the user's
|
||||
// local clock : perfectly fine here since we round-trip to UTC on send.
|
||||
@@ -282,64 +237,29 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
.filter((m) => !m.seen)
|
||||
.map((m) => m.id);
|
||||
if (unseenIds.length === 0) return;
|
||||
markSeenMutate({ ids: unseenIds, threadId });
|
||||
markSeenMutate({ ids: unseenIds, threadIds: [threadId] });
|
||||
}, [threadId, q.data, markSeenMutate]);
|
||||
|
||||
// Header actions. Each one closes the thread: the effect above would
|
||||
// otherwise re-mark an "unread" thread as seen on the next refetch, and a
|
||||
// filed thread has left the list the reader is looking at.
|
||||
const moveFolder = useMoveFolder();
|
||||
const actions = useConversationActions();
|
||||
const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId);
|
||||
const threadIds = () => (q.data?.data ?? []).map((m) => m.id);
|
||||
const markUnread = () => {
|
||||
markSeenMutate({ ids: threadIds(), seen: false, threadId });
|
||||
markSeenMutate({ ids: threadIds(), seen: false, threadIds: [threadId] });
|
||||
setSelectedThreadId(null);
|
||||
};
|
||||
|
||||
// One click and the conversation is gone from the list, so the way back
|
||||
// belongs on screen; the Trash scope's Move to inbox is the slow path. This
|
||||
// pane has already closed by the time Undo is clicked, so it calls the
|
||||
// endpoint directly: react-query drops an unmounted observer's callbacks,
|
||||
// and the invalidation is the whole point.
|
||||
const offerUndo = (message: string, ids: string[]) => {
|
||||
toast((t) => (
|
||||
<span className="flex items-center gap-3 text-[12.5px] text-slate-700">
|
||||
{message}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toast.dismiss(t.id);
|
||||
moveFolderRequest({ ids, folder: "inbox" })
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox"] });
|
||||
toast.success("Moved back to Inbox");
|
||||
})
|
||||
.catch(() => toast.error("Couldn't undo"));
|
||||
}}
|
||||
className="h-6 px-2 rounded-md border border-slate-200 hover:border-slate-300 text-[11.5px] font-medium text-sky-700 hover:bg-sky-50 transition-colors"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
// Filing is store-side: the message keeps its place at the provider, and
|
||||
// the sync knows not to undo this (migration 000146).
|
||||
// The row leaves the list and the reader closes at once; the request runs
|
||||
// behind the toast, and a failure re-reads the list, which brings it back.
|
||||
// The copy, the undo and the cache handling are shared with the list row.
|
||||
const fileThread = async (folder: FilableFolder) => {
|
||||
const ids = threadIds();
|
||||
if (ids.length === 0 || moveFolder.isPending) return;
|
||||
const copy = FILE_COPY[folder];
|
||||
if (actions.filing) return;
|
||||
setSelectedThreadId(null);
|
||||
try {
|
||||
await moveFolder.mutateAsync({ ids, folder, threadId });
|
||||
if (folder === "inbox") toast.success(copy.done);
|
||||
else offerUndo(copy.done, ids);
|
||||
} catch {
|
||||
toast.error(copy.failed);
|
||||
}
|
||||
await actions.file([threadId], folder, threadIds());
|
||||
};
|
||||
|
||||
// Restoring is only offered where the user can see what they are restoring.
|
||||
@@ -347,33 +267,16 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
const filed = urlScope === "trash" || urlScope === "archive";
|
||||
|
||||
const snooze = useMutation({
|
||||
mutationFn: (until: Date) =>
|
||||
snoozeThread({ thread_id: threadId, snoozed_until: until.toISOString() }),
|
||||
// Gone from the list the moment it is snoozed; the refetch confirms it.
|
||||
onMutate: () => removeThreadsFromLists(queryClient, [threadId]),
|
||||
onSuccess: () => {
|
||||
toast.success("Snoozed");
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox", "search"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox", "overview"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox", "unseen-count"] });
|
||||
mutationFn: (until: Date) => actions.snooze([threadId], until),
|
||||
onSettled: () => {
|
||||
setSnoozeOpen(false);
|
||||
setCustomMode(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Couldn't snooze this thread");
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox", "search"] });
|
||||
},
|
||||
});
|
||||
|
||||
const unsnooze = useMutation({
|
||||
mutationFn: () => unsnoozeThread(threadId),
|
||||
onSuccess: () => {
|
||||
toast.success("Un-snoozed");
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox", "search"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox", "overview"] });
|
||||
setSnoozeOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Couldn't un-snooze"),
|
||||
mutationFn: () => actions.unsnooze([threadId]),
|
||||
onSettled: () => setSnoozeOpen(false),
|
||||
});
|
||||
|
||||
// Built once per fetch, not once per render. Every consumer holds these
|
||||
@@ -595,14 +498,14 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
<IconAction
|
||||
label="Move to inbox"
|
||||
icon={<InboxIcon className="w-[15px] h-[15px]" />}
|
||||
disabled={moveFolder.isPending}
|
||||
disabled={actions.filing}
|
||||
onClick={() => fileThread("inbox")}
|
||||
/>
|
||||
) : (
|
||||
<IconAction
|
||||
label="Archive thread"
|
||||
icon={<ArchiveIcon className="w-[15px] h-[15px]" />}
|
||||
disabled={moveFolder.isPending}
|
||||
disabled={actions.filing}
|
||||
onClick={() => fileThread("archive")}
|
||||
/>
|
||||
)}
|
||||
@@ -611,7 +514,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
label="Delete thread"
|
||||
danger
|
||||
icon={<TrashIcon className="w-[15px] h-[15px]" />}
|
||||
disabled={moveFolder.isPending}
|
||||
disabled={actions.filing}
|
||||
onClick={() => fileThread("trash")}
|
||||
/>
|
||||
)}
|
||||
@@ -655,7 +558,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
{filed ? (
|
||||
<PopoverMenuItem
|
||||
icon={<InboxIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => fileThread("inbox")}
|
||||
>
|
||||
Move to inbox
|
||||
@@ -663,7 +566,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
icon={<ArchiveIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => fileThread("archive")}
|
||||
>
|
||||
Archive thread
|
||||
@@ -673,7 +576,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
|
||||
<PopoverMenuItem
|
||||
danger
|
||||
icon={<TrashIcon className="w-3.5 h-3.5" />}
|
||||
disabled={moveFolder.isPending}
|
||||
disabled={actions.filing}
|
||||
onSelect={() => fileThread("trash")}
|
||||
>
|
||||
Delete thread
|
||||
|
||||
@@ -59,8 +59,10 @@ export default function ComposeHistoryPanel({
|
||||
address,
|
||||
direction: tab === "sent" ? "sent" : undefined,
|
||||
query: search.trim() || undefined,
|
||||
// History is reference material: include snoozed threads too.
|
||||
// History is reference material: include snoozed and filed
|
||||
// threads too.
|
||||
snoozed: "any",
|
||||
includeArchived: true,
|
||||
},
|
||||
`history:${address}:${tab}`,
|
||||
!!address,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// The actions a conversation carries, wherever it is shown.
|
||||
//
|
||||
// The thread header, the list row and the selection bar offer the same set, so
|
||||
// the copy, the undo toast and the cache handling live here once rather than
|
||||
// three times. Everything takes a SET of thread ids: one row is the
|
||||
// one-element case, and a selection is one round trip rather than one per row.
|
||||
//
|
||||
// Addressing by thread rather than by message id is deliberate. A list row
|
||||
// knows its conversation and not the ids inside it, and filing part of a
|
||||
// conversation leaves it in the view it was filed out of, which is exactly
|
||||
// what "Archive does nothing" looked like.
|
||||
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen";
|
||||
import useMoveFolder from "@/lib/api/hooks/app/unibox/useMoveFolder";
|
||||
import moveFolderRequest, {
|
||||
type FilableFolder,
|
||||
} from "@/lib/api/client/app/unibox/moveFolder";
|
||||
import { removeThreadsFromLists } from "@/lib/api/hooks/app/unibox/listCache";
|
||||
import {
|
||||
snoozeThreads,
|
||||
unsnoozeThreads,
|
||||
} from "@/lib/api/client/app/unibox/snoozeThread";
|
||||
import { SNOOZE_MAX_MS } from "@/lib/unibox/snooze";
|
||||
|
||||
// Filing copy, per destination. "Deleted" is deliberately not said anywhere:
|
||||
// the message is moved to Trash here and still sits in the mail client.
|
||||
const FILE_COPY: Record<FilableFolder, { done: string; failed: string }> = {
|
||||
archive: { done: "Archived", failed: "Couldn't archive" },
|
||||
trash: { done: "Moved to Trash", failed: "Couldn't move to Trash" },
|
||||
inbox: { done: "Moved to Inbox", failed: "Couldn't move to Inbox" },
|
||||
};
|
||||
|
||||
function plural(n: number, one: string): string {
|
||||
return n === 1 ? one : `${n.toLocaleString()} conversations`;
|
||||
}
|
||||
|
||||
export interface ConversationActions {
|
||||
/** Archive / Trash / Move to inbox. Optional ids cover a loaded thread. */
|
||||
file: (threadIds: string[], folder: FilableFolder, ids?: string[]) => Promise<void>;
|
||||
setSeen: (threadIds: string[], seen: boolean) => void;
|
||||
snooze: (threadIds: string[], until: Date) => Promise<void>;
|
||||
unsnooze: (threadIds: string[]) => Promise<void>;
|
||||
filing: boolean;
|
||||
}
|
||||
|
||||
export function useConversationActions(): ConversationActions {
|
||||
const queryClient = useQueryClient();
|
||||
const moveFolder = useMoveFolder();
|
||||
const markSeen = useMarkSeen();
|
||||
|
||||
// One click and the conversations are gone from the list, so the way back
|
||||
// belongs on screen. The undo calls the endpoint directly rather than the
|
||||
// mutation: whatever offered it may have unmounted by the time it is
|
||||
// clicked, and react-query drops an unmounted observer's callbacks.
|
||||
const offerUndo = React.useCallback(
|
||||
(message: string, threadIds: string[]) => {
|
||||
toast((t) => (
|
||||
<span className="flex items-center gap-3 text-[12.5px] text-slate-700">
|
||||
{message}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toast.dismiss(t.id);
|
||||
moveFolderRequest({ threadIds, folder: "inbox" })
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox"] });
|
||||
toast.success("Moved back to Inbox");
|
||||
})
|
||||
.catch(() => toast.error("Couldn't undo"));
|
||||
}}
|
||||
className="h-6 px-2 rounded-md border border-slate-200 hover:border-slate-300 text-[11.5px] font-medium text-sky-700 hover:bg-sky-50 transition-colors"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</span>
|
||||
));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const file = React.useCallback(
|
||||
async (threadIds: string[], folder: FilableFolder, ids?: string[]) => {
|
||||
if (threadIds.length === 0) return;
|
||||
const copy = FILE_COPY[folder];
|
||||
const done =
|
||||
threadIds.length === 1
|
||||
? copy.done
|
||||
: `${copy.done} ${threadIds.length.toLocaleString()}`;
|
||||
try {
|
||||
await moveFolder.mutateAsync({ ids, folder, threadIds });
|
||||
if (folder === "inbox") toast.success(done);
|
||||
else offerUndo(done, threadIds);
|
||||
} catch {
|
||||
toast.error(copy.failed);
|
||||
}
|
||||
},
|
||||
[moveFolder, offerUndo],
|
||||
);
|
||||
|
||||
const setSeen = React.useCallback(
|
||||
(threadIds: string[], seen: boolean) => {
|
||||
if (threadIds.length === 0) return;
|
||||
markSeen.mutate({ threadIds, seen });
|
||||
if (threadIds.length > 1) {
|
||||
toast.success(
|
||||
`${plural(threadIds.length, "Conversation")} marked as ${seen ? "read" : "unread"}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[markSeen],
|
||||
);
|
||||
|
||||
const snooze = React.useCallback(
|
||||
async (threadIds: string[], until: Date) => {
|
||||
if (threadIds.length === 0) return;
|
||||
if (
|
||||
Number.isNaN(until.getTime()) ||
|
||||
until.getTime() <= Date.now() + 5_000
|
||||
) {
|
||||
toast.error("Pick a future time (a few seconds out, please)");
|
||||
return;
|
||||
}
|
||||
if (until.getTime() - Date.now() > SNOOZE_MAX_MS) {
|
||||
toast.error("Snooze can't be more than 90 days out");
|
||||
return;
|
||||
}
|
||||
// Gone from the list the moment it is snoozed; the refetch confirms
|
||||
// it, and on error that same refetch is the rollback.
|
||||
await removeThreadsFromLists(queryClient, threadIds);
|
||||
try {
|
||||
await snoozeThreads(threadIds, until);
|
||||
toast.success(
|
||||
threadIds.length === 1
|
||||
? "Snoozed"
|
||||
: `Snoozed ${threadIds.length.toLocaleString()}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Couldn't snooze");
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox"] });
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const unsnooze = React.useCallback(
|
||||
async (threadIds: string[]) => {
|
||||
if (threadIds.length === 0) return;
|
||||
try {
|
||||
await unsnoozeThreads(threadIds);
|
||||
toast.success("Un-snoozed");
|
||||
} catch {
|
||||
toast.error("Couldn't un-snooze");
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox"] });
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return { file, setSeen, snooze, unsnooze, filing: moveFolder.isPending };
|
||||
}
|
||||
@@ -184,6 +184,22 @@ export const globalShortcuts: GlobalShortcut[] = [
|
||||
match: (e) => e.key === 'Escape' && !layerOpen(),
|
||||
run: () => shortcutAction('listDeselect')?.(),
|
||||
},
|
||||
{
|
||||
keys: ['x'],
|
||||
description: 'Select / deselect the focused row',
|
||||
group: 'list',
|
||||
needs: 'listToggleSelect',
|
||||
match: plain('x'),
|
||||
run: () => shortcutAction('listToggleSelect')?.(),
|
||||
},
|
||||
{
|
||||
keys: ['e'],
|
||||
description: 'Archive the selection',
|
||||
group: 'list',
|
||||
needs: 'listArchive',
|
||||
match: plain('e'),
|
||||
run: () => shortcutAction('listArchive')?.(),
|
||||
},
|
||||
{
|
||||
keys: ['c'],
|
||||
description: 'Label the open conversation',
|
||||
|
||||
@@ -22,6 +22,10 @@ export type ShortcutActions = Partial<{
|
||||
listOpen: () => void;
|
||||
/** Drop the selection. */
|
||||
listDeselect: () => void;
|
||||
/** Tick or untick the focused row, for a list that supports multi-select. */
|
||||
listToggleSelect: () => void;
|
||||
/** Archive the ticked rows, or the focused one when nothing is ticked. */
|
||||
listArchive: () => void;
|
||||
/** Put the caret in this screen's own search box. */
|
||||
focusSearch: () => void;
|
||||
/** Open the label menu for the open conversation. */
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import Request from "../../Request";
|
||||
|
||||
// PATCH /unibox/seen marks unibox emails seen/unseen. The backend body is
|
||||
// { email_ids, folder, seen } (models.MarkSeen); callers pass { ids } for an
|
||||
// explicit list or { folder } to sweep a whole folder, and seen defaults to
|
||||
// true (mark as read). Sending the wrong field names makes the server bind an
|
||||
// empty list and silently no-op, which is why the unread bar never cleared
|
||||
// before.
|
||||
export default async function markSeen(data: { ids?: string[]; folder?: string; seen?: boolean }): Promise<void> {
|
||||
// { email_ids, thread_ids, folder, seen } (models.MarkSeen); callers pass
|
||||
// { ids } for an explicit list, { threadIds } for whole conversations, or
|
||||
// { folder } to sweep a whole folder, and seen defaults to true (mark as
|
||||
// read). Sending the wrong field names makes the server bind an empty list and
|
||||
// silently no-op, which is why the unread bar never cleared before.
|
||||
export default async function markSeen(data: {
|
||||
ids?: string[];
|
||||
threadIds?: string[];
|
||||
folder?: string;
|
||||
seen?: boolean;
|
||||
}): Promise<void> {
|
||||
return await Request<void>({
|
||||
method: "PATCH",
|
||||
url: `/unibox/seen`,
|
||||
data: { email_ids: data.ids ?? [], folder: data.folder, seen: data.seen ?? true },
|
||||
data: {
|
||||
email_ids: data.ids ?? [],
|
||||
thread_ids: data.threadIds ?? [],
|
||||
folder: data.folder,
|
||||
seen: data.seen ?? true,
|
||||
},
|
||||
authorization: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,11 +7,23 @@ export type FilableFolder = "inbox" | "archive" | "trash";
|
||||
// PATCH /unibox/folder re-files messages. Archive in the thread header is
|
||||
// "archive", Delete is "trash", Move to inbox is "inbox". Store-side only: the
|
||||
// provider copy stays put, and the sync knows not to undo it.
|
||||
export default async function moveFolder(data: { ids: string[]; folder: FilableFolder }): Promise<void> {
|
||||
//
|
||||
// Address it by thread wherever the caller has one. A list row knows its
|
||||
// conversation and not the message ids inside it, and filing part of a
|
||||
// conversation leaves it in the view it was filed out of.
|
||||
export default async function moveFolder(data: {
|
||||
ids?: string[];
|
||||
threadIds?: string[];
|
||||
folder: FilableFolder;
|
||||
}): Promise<void> {
|
||||
return await Request<void>({
|
||||
method: "PATCH",
|
||||
url: `/unibox/folder`,
|
||||
data: { email_ids: data.ids, folder: data.folder },
|
||||
data: {
|
||||
email_ids: data.ids ?? [],
|
||||
thread_ids: data.threadIds ?? [],
|
||||
folder: data.folder,
|
||||
},
|
||||
authorization: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export default async function searchIncoming(
|
||||
usp.set("email_ids", p.accountIds.join(","));
|
||||
}
|
||||
if (p.folder) usp.set("folder", p.folder);
|
||||
if (p.includeArchived) usp.set("include_archived", "true");
|
||||
if (p.unseen) usp.set("unseen", "true");
|
||||
if (p.snoozed === true) usp.set("snoozed", "true");
|
||||
else if (p.snoozed === "any") usp.set("snoozed", "any");
|
||||
|
||||
@@ -21,8 +21,26 @@ export async function snoozeThread(req: SnoozeRequest): Promise<SnoozeResponse>
|
||||
});
|
||||
}
|
||||
|
||||
// The selection bar's form: one call for a whole selection rather than one per
|
||||
// row. The server answers with `data` when several are named.
|
||||
export async function snoozeThreads(
|
||||
threadIds: string[],
|
||||
until: Date,
|
||||
): Promise<void> {
|
||||
await Request<{ data: SnoozeResponse[] }>({
|
||||
method: "POST",
|
||||
url: "/unibox/snooze",
|
||||
authorization: true,
|
||||
data: { thread_ids: threadIds, snoozed_until: until.toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function unsnoozeThread(threadId: string): Promise<void> {
|
||||
const usp = new URLSearchParams({ thread_id: threadId });
|
||||
return await unsnoozeThreads([threadId]);
|
||||
}
|
||||
|
||||
export async function unsnoozeThreads(threadIds: string[]): Promise<void> {
|
||||
const usp = new URLSearchParams({ thread_id: threadIds.join(",") });
|
||||
await Request<void>({
|
||||
method: "DELETE",
|
||||
url: `/unibox/snooze?${usp.toString()}`,
|
||||
|
||||
@@ -12,22 +12,26 @@ interface MarkSeenInput {
|
||||
ids?: string[];
|
||||
folder?: string;
|
||||
seen?: boolean;
|
||||
/** Conversation the ids belong to, so the open list can be patched in place. */
|
||||
threadId?: string;
|
||||
/**
|
||||
* Conversations to flip. The open list is patched in place, and the server
|
||||
* flips every message in each, which a list row could not name.
|
||||
*/
|
||||
threadIds?: string[];
|
||||
}
|
||||
|
||||
export default function useMarkSeen() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: MarkSeenInput) => markSeen(data),
|
||||
mutationFn: ({ ids, folder, seen, threadIds }: MarkSeenInput) =>
|
||||
markSeen({ ids, threadIds, folder, seen }),
|
||||
// Reading a thread must not move the list the user is reading from.
|
||||
// Refetching every loaded page of ["unibox","search"] would re-order
|
||||
// rows around whatever arrived since, so the read/unread flip is
|
||||
// written straight into the cache instead; only the counters, which no
|
||||
// pointer is aimed at, are refetched.
|
||||
onMutate: async ({ ids, threadId, seen = true, folder }) => {
|
||||
if (folder || (!threadId && !ids?.length)) return;
|
||||
onMutate: async ({ ids, threadIds, seen = true, folder }) => {
|
||||
if (folder || (!threadIds?.length && !ids?.length)) return;
|
||||
// A refetch already in flight would land on top of the patch below
|
||||
// and put the row back to unread. Only refetches: cancelling a
|
||||
// first load would leave that list with no data and nothing queued
|
||||
@@ -37,8 +41,11 @@ export default function useMarkSeen() {
|
||||
predicate: (query) => query.state.data !== undefined,
|
||||
});
|
||||
const idSet = new Set(ids ?? []);
|
||||
// Rows with no thread id of their own are keyed by message id, the
|
||||
// same fallback the list and the server collapse on.
|
||||
const threadSet = new Set(threadIds ?? []);
|
||||
const matches = (row: { id: string; thread_id?: string }) =>
|
||||
(threadId != null && row.thread_id === threadId) || idSet.has(row.id);
|
||||
threadSet.has(row.thread_id || row.id) || idSet.has(row.id);
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<SearchPage>>(
|
||||
{ queryKey: ["unibox", "search"] },
|
||||
@@ -58,7 +65,7 @@ export default function useMarkSeen() {
|
||||
},
|
||||
);
|
||||
|
||||
if (threadId) {
|
||||
for (const threadId of threadIds ?? []) {
|
||||
queryClient.setQueriesData<UniboxThread>(
|
||||
{ queryKey: ["unibox", "thread", threadId] },
|
||||
(old) =>
|
||||
|
||||
@@ -3,20 +3,25 @@ import moveFolder, { type FilableFolder } from "@/lib/api/client/app/unibox/move
|
||||
import { removeThreadsFromLists } from "./listCache";
|
||||
|
||||
interface MoveFolderInput {
|
||||
ids: string[];
|
||||
/** Explicit message ids, for a caller that has the thread loaded. */
|
||||
ids?: string[];
|
||||
folder: FilableFolder;
|
||||
/** Conversation the ids belong to, so its row leaves the open list at once. */
|
||||
threadId?: string;
|
||||
/**
|
||||
* Conversations to file. Their rows leave the open list at once, and the
|
||||
* server files every message in each, which a list row could not name.
|
||||
*/
|
||||
threadIds?: string[];
|
||||
}
|
||||
|
||||
export default function useMoveFolder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ ids, folder }: MoveFolderInput) => moveFolder({ ids, folder }),
|
||||
// The row goes now; the refetch below confirms it.
|
||||
onMutate: async ({ threadId }) => {
|
||||
if (threadId) await removeThreadsFromLists(queryClient, [threadId]);
|
||||
mutationFn: ({ ids, folder, threadIds }: MoveFolderInput) =>
|
||||
moveFolder({ ids, threadIds, folder }),
|
||||
// The rows go now; the refetch below confirms it.
|
||||
onMutate: async ({ threadIds }) => {
|
||||
if (threadIds?.length) await removeThreadsFromLists(queryClient, threadIds);
|
||||
},
|
||||
// A move changes which scopes the thread belongs to and every folder's
|
||||
// counts, so the whole unibox tree is re-read rather than patched. On
|
||||
|
||||
@@ -53,10 +53,17 @@ export interface UniboxSearchParams {
|
||||
*/
|
||||
snoozed?: true | "any";
|
||||
/**
|
||||
* Folder scope. Undefined = every folder except spam and trash (the
|
||||
* combined view never shows junk).
|
||||
* Folder scope. Undefined = every working folder: spam, trash and archive
|
||||
* all stay out, so filing a conversation takes it out of every view the
|
||||
* workspace reads from and not only the Inbox folder.
|
||||
*/
|
||||
folder?: UniboxFolder;
|
||||
/**
|
||||
* Put filed conversations back into an unscoped list. Only "All mail" and
|
||||
* reference reads (the composer's history panel) ask for it; a working view
|
||||
* that included archived mail would make Archive look like it did nothing.
|
||||
*/
|
||||
includeArchived?: boolean;
|
||||
/** Awaiting reply: threads where the last message was from us. */
|
||||
awaitingReply?: boolean;
|
||||
/** Agent drafts: threads with a pending inbox-agent reply draft. */
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Snooze times, shared by the thread header and the conversation row's menu.
|
||||
// One definition so the two offer the same presets and the same ceiling.
|
||||
|
||||
/** Server ceiling, matching SnoozeMaxHorizon in internal/app/unibox/config.go. */
|
||||
export const SNOOZE_MAX_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function offsetHours(h: number): Date {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + h);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function offsetDays(days: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function atHour(dayOffset: number, hour: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + dayOffset);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function nextMonday9(): Date {
|
||||
const d = new Date();
|
||||
const dow = d.getDay();
|
||||
const delta = (1 - dow + 7) % 7 || 7;
|
||||
d.setDate(d.getDate() + delta);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export const SNOOZE_PRESETS: { label: string; until: () => Date }[] = [
|
||||
{ label: "In 1 hour", until: () => offsetHours(1) },
|
||||
{ label: "In 3 hours", until: () => offsetHours(3) },
|
||||
{ label: "Tomorrow 9:00", until: () => atHour(1, 9) },
|
||||
{ label: "Monday 9:00", until: () => nextMonday9() },
|
||||
{ label: "Next week", until: () => offsetDays(7) },
|
||||
];
|
||||
Reference in New Issue
Block a user