Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-slack-statuses.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: `ComposeConfig.status` and `CreateLinkDraft.status` are now optional/nullable so status-less link types can still compose. Added: `NewLink.todo` / `NewLink.todoDate` to mark a thread as the connection owner's to-do atomically at create time.
95 changes: 93 additions & 2 deletions connectors/gmail/src/gmail.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recipientsFor } from "./gmail";
import { describe, expect, it, vi } from "vitest";
import { Gmail, recipientsFor } from "./gmail";
import {
type GmailMessage,
type GmailThread,
Expand DownExpand Up@@ -215,3 +215,94 @@ describe("transformGmailThread — mailing-list From rewrite", () => {
expect(accessContactFor(thread, "jane@example.com")?.name).toBe("Jane Doe");
});
});

describe("processEmailThreads — no status set", () => {
/**
* Build a minimal Gmail thread with the given labelIds on its single message.
* The payload provides headers required by transformGmailThread (From/To/Subject).
*/
function makeGmailThread(labelIds: string[]): GmailThread {
const message: GmailMessage = {
id: "msg-archived",
threadId: "thread-archived",
labelIds,
snippet: "archived message",
historyId: "42",
internalDate: "1700000000000",
sizeEstimate: 100,
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "sender@example.com" },
{ name: "To", value: "me@example.com" },
{ name: "Subject", value: "Test archived" },
{ name: "Message-ID", value: "<msg-archived@example.com>" },
{ name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" },
],
body: { size: 16, data: btoa("archived message") },
},
};
return {
id: "thread-archived",
historyId: "42",
messages: [message],
};
}

function makeGmail(): { gmail: Gmail; saveLink: ReturnType<typeof vi.fn> } {
const storeMap = new Map<string, unknown>([
["enabled_channels", ["INBOX"]],
]);
const store = {
get: vi.fn(async (key: string) =>
storeMap.has(key) ? storeMap.get(key) : null
),
set: vi.fn(async (key: string, value: unknown) => {
storeMap.set(key, value);
}),
clear: vi.fn(async (key: string) => {
storeMap.delete(key);
}),
list: vi.fn(async (prefix: string) =>
[...storeMap.keys()].filter((k) => k.startsWith(prefix))
),
};

const saveLink = vi.fn().mockResolvedValue("thread-archived");
const tools = {
store,
integrations: {
get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }),
saveLink,
setThreadToDo: vi.fn().mockResolvedValue(undefined),
},
network: { createWebhook: vi.fn() },
files: {},
};
const gmail = new Gmail(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);
return { gmail, saveLink };
}

it("saves an archived thread (IMPORTANT only, no INBOX) with no status", async () => {
// IMPORTANT only — not in INBOX, not STARRED, not SENT.
// Old code would have set status="archived"; new code must leave it unset.
const { gmail, saveLink } = makeGmail();
const thread = makeGmailThread(["IMPORTANT"]);

await (gmail as unknown as {
processEmailThreads: (
threads: GmailThread[],
initialSync: boolean,
forceChannelId?: string
) => Promise<void>;
}).processEmailThreads([thread], false, "INBOX");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
// status must be absent (undefined) — not "archived", not any other value
expect(saved.status).toBeUndefined();
});
});
74 changes: 6 additions & 68 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
type ToolBuilder,
} from "@plotday/twister";
import { ActionType } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread, Link } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -210,12 +210,6 @@ export class Gmail extends Connector<Gmail> {
supportsFileAttachments: true,
logo: "https://api.iconify.design/logos/google-gmail.svg",
logoMono: "https://api.iconify.design/simple-icons/gmail.svg",
statuses: [
{ status: "inbox", label: "Inbox" },
{ status: "starred", label: "Starred", active: true },
{ status: "sent", label: "Sent" },
{ status: "archived", label: "Archived", done: true },
],
contactRoles: [
{ id: "to", label: "To", default: true },
{ id: "cc", label: "CC" },
Expand All@@ -229,7 +223,6 @@ export class Gmail extends Connector<Gmail> {
// `contact.email` otherwise.
compose: {
targets: "addresses" as const,
status: "sent",
},
},
];
Expand DownExpand Up@@ -1357,40 +1350,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Star ↔ todo sync: detect star changes and update Plot todo status
// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
const isStarred = GmailApi.isStarred(thread);
const isInInbox = thread.messages?.some((m) =>
m.labelIds?.includes("INBOX")
);
// "Sent" is meaningful only when the thread isn't ALSO in the inbox
// (e.g. self-CC, or recipient replied) — those should appear under
// "inbox" so the user actions them like any other incoming thread.
const isSentOnly = !isInInbox && thread.messages?.some((m) =>
m.labelIds?.includes("SENT")
);

// Set status based on labels
if (isStarred) {
plotThread.status = "starred";
} else if (isSentOnly) {
// Plot-composed thread that just sent, or organic Gmail-sent
// thread the user hasn't archived yet. Stays at "sent" until it
// returns to inbox (reply) or the user archives it.
plotThread.status = "sent";
} else if (!isInInbox) {
plotThread.status = "archived";
} else {
plotThread.status = "inbox";
}

// Save link directly via integrations
const savedThreadId = await this.tools.integrations.saveLink(plotThread);
if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) — skip star sync

const wasStarred = await this.get<boolean>(`starred:${thread.id}`);

// Echo suppression relies entirely on the `starred` state: when
// Plot→Gmail writes STARRED, onThreadToDo/onLinkUpdated update this
// Plot→Gmail writes STARRED, onThreadToDo updates this
// state *before* the API call. The resulting Gmail webhook sees
// isStarred === wasStarred and this branch doesn't run.
if (isStarred !== !!wasStarred) {
Expand DownExpand Up@@ -1645,38 +1616,6 @@ export class Gmail extends Connector<Gmail> {
}
}

async onLinkUpdated(link: Link): Promise<void> {
const threadId = link.meta?.threadId as string | undefined;
const channelId = (link.meta?.channelId ?? link.meta?.syncableId) as
| string
| undefined;
if (!threadId || !channelId) return;

// Loop prevention: skip if this change originated from Gmail star sync
if (await this.get(`skip_todo_writeback:${threadId}`)) {
await this.clear(`skip_todo_writeback:${threadId}`);
return;
}

const status = link.status;

// Update local state BEFORE calling Gmail, so the webhook fired by our
// own write sees isStarred === wasStarred and doesn't re-propagate.
await this.set(`starred:${threadId}`, status === "starred");

const api = await this.getApi(channelId);

if (status === "starred") {
await api.modifyThread(threadId, ["STARRED"]);
} else if (status === "archived") {
// Archive = remove from INBOX. Also unstar.
await api.modifyThread(threadId, undefined, ["INBOX", "STARRED"]);
} else if (status === "inbox") {
// Back to inbox, unstar.
await api.modifyThread(threadId, ["INBOX"], ["STARRED"]);
}
}

/**
* Creates a new outbound email from Plot.
*
Expand DownExpand Up@@ -1761,7 +1700,7 @@ export class Gmail extends Connector<Gmail> {
source: canonicalUrl,
type: "email",
title: subject || undefined,
status: draft.status,
status: null,
created: new Date(),
sourceUrl: canonicalUrl,
channelId,
Expand All@@ -1782,7 +1721,6 @@ export class Gmail extends Connector<Gmail> {
const dedupKey = `compose:${fnv1aHex(
JSON.stringify([
draft.type,
draft.status,
subject,
body,
[...toEmails].sort(),
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,9 +688,9 @@ export class Linear extends Connector<Linear> {
// statuses from the static linkTypes fallback) we look up the team's
// states directly.
let stateId: string | null = null;
if (draft.status.length > 20) {
if (draft.status && draft.status.length > 20) {
stateId = draft.status;
} else {
} else if (draft.status) {
const team = await client.team(draft.channelId);
if (team) {
const states = await team.states();
Expand Down
33 changes: 33 additions & 0 deletions connectors/slack/src/slack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,39 @@ function makeSlack(opts: {
return new Slack("twist-instance-1" as never, toolShed as never);
}

describe("saveStarredThread", () => {
it("saves the link with todo:true and no status", async () => {
const store = makeStore({ auth_actor_id: "actor-1" });
const saveLink = vi.fn().mockResolvedValue("thread-1");
const tools = {
store,
integrations: { get: vi.fn(), saveLink },
network: { createWebhook: vi.fn() },
files: {},
};
const slack = new Slack(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);

const api = {
getThread: vi.fn().mockResolvedValue([
{ ts: "111.000", thread_ts: "111.000", user: "U1", text: "hello", reactions: [] },
]),
getUser: vi.fn().mockResolvedValue(null),
};

await (slack as unknown as {
saveStarredThread: (a: unknown, c: string, t: string) => Promise<void>;
}).saveStarredThread(api, "C123", "111.000");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
expect(saved.todo).toBe(true);
expect(saved.status).toBeUndefined();
});
});

describe("setupChannelWebhook", () => {
const channelId = "C123";
const auth: Authorization = {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-slack-statuses.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: `ComposeConfig.status` and `CreateLinkDraft.status` are now optional/nullable so status-less link types can still compose. Added: `NewLink.todo` / `NewLink.todoDate` to mark a thread as the connection owner's to-do atomically at create time.
95 changes: 93 additions & 2 deletions connectors/gmail/src/gmail.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recipientsFor } from "./gmail";
import { describe, expect, it, vi } from "vitest";
import { Gmail, recipientsFor } from "./gmail";
import {
type GmailMessage,
type GmailThread,
Expand DownExpand Up@@ -215,3 +215,94 @@ describe("transformGmailThread — mailing-list From rewrite", () => {
expect(accessContactFor(thread, "jane@example.com")?.name).toBe("Jane Doe");
});
});

describe("processEmailThreads — no status set", () => {
/**
* Build a minimal Gmail thread with the given labelIds on its single message.
* The payload provides headers required by transformGmailThread (From/To/Subject).
*/
function makeGmailThread(labelIds: string[]): GmailThread {
const message: GmailMessage = {
id: "msg-archived",
threadId: "thread-archived",
labelIds,
snippet: "archived message",
historyId: "42",
internalDate: "1700000000000",
sizeEstimate: 100,
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "sender@example.com" },
{ name: "To", value: "me@example.com" },
{ name: "Subject", value: "Test archived" },
{ name: "Message-ID", value: "<msg-archived@example.com>" },
{ name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" },
],
body: { size: 16, data: btoa("archived message") },
},
};
return {
id: "thread-archived",
historyId: "42",
messages: [message],
};
}

function makeGmail(): { gmail: Gmail; saveLink: ReturnType<typeof vi.fn> } {
const storeMap = new Map<string, unknown>([
["enabled_channels", ["INBOX"]],
]);
const store = {
get: vi.fn(async (key: string) =>
storeMap.has(key) ? storeMap.get(key) : null
),
set: vi.fn(async (key: string, value: unknown) => {
storeMap.set(key, value);
}),
clear: vi.fn(async (key: string) => {
storeMap.delete(key);
}),
list: vi.fn(async (prefix: string) =>
[...storeMap.keys()].filter((k) => k.startsWith(prefix))
),
};

const saveLink = vi.fn().mockResolvedValue("thread-archived");
const tools = {
store,
integrations: {
get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }),
saveLink,
setThreadToDo: vi.fn().mockResolvedValue(undefined),
},
network: { createWebhook: vi.fn() },
files: {},
};
const gmail = new Gmail(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);
return { gmail, saveLink };
}

it("saves an archived thread (IMPORTANT only, no INBOX) with no status", async () => {
// IMPORTANT only — not in INBOX, not STARRED, not SENT.
// Old code would have set status="archived"; new code must leave it unset.
const { gmail, saveLink } = makeGmail();
const thread = makeGmailThread(["IMPORTANT"]);

await (gmail as unknown as {
processEmailThreads: (
threads: GmailThread[],
initialSync: boolean,
forceChannelId?: string
) => Promise<void>;
}).processEmailThreads([thread], false, "INBOX");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
// status must be absent (undefined) — not "archived", not any other value
expect(saved.status).toBeUndefined();
});
});
74 changes: 6 additions & 68 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
type ToolBuilder,
} from "@plotday/twister";
import { ActionType } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread, Link } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -210,12 +210,6 @@ export class Gmail extends Connector<Gmail> {
supportsFileAttachments: true,
logo: "https://api.iconify.design/logos/google-gmail.svg",
logoMono: "https://api.iconify.design/simple-icons/gmail.svg",
statuses: [
{ status: "inbox", label: "Inbox" },
{ status: "starred", label: "Starred", active: true },
{ status: "sent", label: "Sent" },
{ status: "archived", label: "Archived", done: true },
],
contactRoles: [
{ id: "to", label: "To", default: true },
{ id: "cc", label: "CC" },
Expand All@@ -229,7 +223,6 @@ export class Gmail extends Connector<Gmail> {
// `contact.email` otherwise.
compose: {
targets: "addresses" as const,
status: "sent",
},
},
];
Expand DownExpand Up@@ -1357,40 +1350,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Star ↔ todo sync: detect star changes and update Plot todo status
// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
const isStarred = GmailApi.isStarred(thread);
const isInInbox = thread.messages?.some((m) =>
m.labelIds?.includes("INBOX")
);
// "Sent" is meaningful only when the thread isn't ALSO in the inbox
// (e.g. self-CC, or recipient replied) — those should appear under
// "inbox" so the user actions them like any other incoming thread.
const isSentOnly = !isInInbox && thread.messages?.some((m) =>
m.labelIds?.includes("SENT")
);

// Set status based on labels
if (isStarred) {
plotThread.status = "starred";
} else if (isSentOnly) {
// Plot-composed thread that just sent, or organic Gmail-sent
// thread the user hasn't archived yet. Stays at "sent" until it
// returns to inbox (reply) or the user archives it.
plotThread.status = "sent";
} else if (!isInInbox) {
plotThread.status = "archived";
} else {
plotThread.status = "inbox";
}

// Save link directly via integrations
const savedThreadId = await this.tools.integrations.saveLink(plotThread);
if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) — skip star sync

const wasStarred = await this.get<boolean>(`starred:${thread.id}`);

// Echo suppression relies entirely on the `starred` state: when
// Plot→Gmail writes STARRED, onThreadToDo/onLinkUpdated update this
// Plot→Gmail writes STARRED, onThreadToDo updates this
// state *before* the API call. The resulting Gmail webhook sees
// isStarred === wasStarred and this branch doesn't run.
if (isStarred !== !!wasStarred) {
Expand DownExpand Up@@ -1645,38 +1616,6 @@ export class Gmail extends Connector<Gmail> {
}
}

async onLinkUpdated(link: Link): Promise<void> {
const threadId = link.meta?.threadId as string | undefined;
const channelId = (link.meta?.channelId ?? link.meta?.syncableId) as
| string
| undefined;
if (!threadId || !channelId) return;

// Loop prevention: skip if this change originated from Gmail star sync
if (await this.get(`skip_todo_writeback:${threadId}`)) {
await this.clear(`skip_todo_writeback:${threadId}`);
return;
}

const status = link.status;

// Update local state BEFORE calling Gmail, so the webhook fired by our
// own write sees isStarred === wasStarred and doesn't re-propagate.
await this.set(`starred:${threadId}`, status === "starred");

const api = await this.getApi(channelId);

if (status === "starred") {
await api.modifyThread(threadId, ["STARRED"]);
} else if (status === "archived") {
// Archive = remove from INBOX. Also unstar.
await api.modifyThread(threadId, undefined, ["INBOX", "STARRED"]);
} else if (status === "inbox") {
// Back to inbox, unstar.
await api.modifyThread(threadId, ["INBOX"], ["STARRED"]);
}
}

/**
* Creates a new outbound email from Plot.
*
Expand DownExpand Up@@ -1761,7 +1700,7 @@ export class Gmail extends Connector<Gmail> {
source: canonicalUrl,
type: "email",
title: subject || undefined,
status: draft.status,
status: null,
created: new Date(),
sourceUrl: canonicalUrl,
channelId,
Expand All@@ -1782,7 +1721,6 @@ export class Gmail extends Connector<Gmail> {
const dedupKey = `compose:${fnv1aHex(
JSON.stringify([
draft.type,
draft.status,
subject,
body,
[...toEmails].sort(),
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,9 +688,9 @@ export class Linear extends Connector<Linear> {
// statuses from the static linkTypes fallback) we look up the team's
// states directly.
let stateId: string | null = null;
if (draft.status.length > 20) {
if (draft.status && draft.status.length > 20) {
stateId = draft.status;
} else {
} else if (draft.status) {
const team = await client.team(draft.channelId);
if (team) {
const states = await team.states();
Expand Down
33 changes: 33 additions & 0 deletions connectors/slack/src/slack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,39 @@ function makeSlack(opts: {
return new Slack("twist-instance-1" as never, toolShed as never);
}

describe("saveStarredThread", () => {
it("saves the link with todo:true and no status", async () => {
const store = makeStore({ auth_actor_id: "actor-1" });
const saveLink = vi.fn().mockResolvedValue("thread-1");
const tools = {
store,
integrations: { get: vi.fn(), saveLink },
network: { createWebhook: vi.fn() },
files: {},
};
const slack = new Slack(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);

const api = {
getThread: vi.fn().mockResolvedValue([
{ ts: "111.000", thread_ts: "111.000", user: "U1", text: "hello", reactions: [] },
]),
getUser: vi.fn().mockResolvedValue(null),
};

await (slack as unknown as {
saveStarredThread: (a: unknown, c: string, t: string) => Promise<void>;
}).saveStarredThread(api, "C123", "111.000");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
expect(saved.todo).toBe(true);
expect(saved.status).toBeUndefined();
});
});

describe("setupChannelWebhook", () => {
const channelId = "C123";
const auth: Authorization = {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-slack-statuses.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: `ComposeConfig.status` and `CreateLinkDraft.status` are now optional/nullable so status-less link types can still compose. Added: `NewLink.todo` / `NewLink.todoDate` to mark a thread as the connection owner's to-do atomically at create time.
95 changes: 93 additions & 2 deletions connectors/gmail/src/gmail.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recipientsFor } from "./gmail";
import { describe, expect, it, vi } from "vitest";
import { Gmail, recipientsFor } from "./gmail";
import {
type GmailMessage,
type GmailThread,
Expand DownExpand Up@@ -215,3 +215,94 @@ describe("transformGmailThread — mailing-list From rewrite", () => {
expect(accessContactFor(thread, "jane@example.com")?.name).toBe("Jane Doe");
});
});

describe("processEmailThreads — no status set", () => {
/**
* Build a minimal Gmail thread with the given labelIds on its single message.
* The payload provides headers required by transformGmailThread (From/To/Subject).
*/
function makeGmailThread(labelIds: string[]): GmailThread {
const message: GmailMessage = {
id: "msg-archived",
threadId: "thread-archived",
labelIds,
snippet: "archived message",
historyId: "42",
internalDate: "1700000000000",
sizeEstimate: 100,
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "sender@example.com" },
{ name: "To", value: "me@example.com" },
{ name: "Subject", value: "Test archived" },
{ name: "Message-ID", value: "<msg-archived@example.com>" },
{ name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" },
],
body: { size: 16, data: btoa("archived message") },
},
};
return {
id: "thread-archived",
historyId: "42",
messages: [message],
};
}

function makeGmail(): { gmail: Gmail; saveLink: ReturnType<typeof vi.fn> } {
const storeMap = new Map<string, unknown>([
["enabled_channels", ["INBOX"]],
]);
const store = {
get: vi.fn(async (key: string) =>
storeMap.has(key) ? storeMap.get(key) : null
),
set: vi.fn(async (key: string, value: unknown) => {
storeMap.set(key, value);
}),
clear: vi.fn(async (key: string) => {
storeMap.delete(key);
}),
list: vi.fn(async (prefix: string) =>
[...storeMap.keys()].filter((k) => k.startsWith(prefix))
),
};

const saveLink = vi.fn().mockResolvedValue("thread-archived");
const tools = {
store,
integrations: {
get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }),
saveLink,
setThreadToDo: vi.fn().mockResolvedValue(undefined),
},
network: { createWebhook: vi.fn() },
files: {},
};
const gmail = new Gmail(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);
return { gmail, saveLink };
}

it("saves an archived thread (IMPORTANT only, no INBOX) with no status", async () => {
// IMPORTANT only — not in INBOX, not STARRED, not SENT.
// Old code would have set status="archived"; new code must leave it unset.
const { gmail, saveLink } = makeGmail();
const thread = makeGmailThread(["IMPORTANT"]);

await (gmail as unknown as {
processEmailThreads: (
threads: GmailThread[],
initialSync: boolean,
forceChannelId?: string
) => Promise<void>;
}).processEmailThreads([thread], false, "INBOX");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
// status must be absent (undefined) — not "archived", not any other value
expect(saved.status).toBeUndefined();
});
});
74 changes: 6 additions & 68 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
type ToolBuilder,
} from "@plotday/twister";
import { ActionType } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread, Link } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -210,12 +210,6 @@ export class Gmail extends Connector<Gmail> {
supportsFileAttachments: true,
logo: "https://api.iconify.design/logos/google-gmail.svg",
logoMono: "https://api.iconify.design/simple-icons/gmail.svg",
statuses: [
{ status: "inbox", label: "Inbox" },
{ status: "starred", label: "Starred", active: true },
{ status: "sent", label: "Sent" },
{ status: "archived", label: "Archived", done: true },
],
contactRoles: [
{ id: "to", label: "To", default: true },
{ id: "cc", label: "CC" },
Expand All@@ -229,7 +223,6 @@ export class Gmail extends Connector<Gmail> {
// `contact.email` otherwise.
compose: {
targets: "addresses" as const,
status: "sent",
},
},
];
Expand DownExpand Up@@ -1357,40 +1350,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Star ↔ todo sync: detect star changes and update Plot todo status
// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
const isStarred = GmailApi.isStarred(thread);
const isInInbox = thread.messages?.some((m) =>
m.labelIds?.includes("INBOX")
);
// "Sent" is meaningful only when the thread isn't ALSO in the inbox
// (e.g. self-CC, or recipient replied) — those should appear under
// "inbox" so the user actions them like any other incoming thread.
const isSentOnly = !isInInbox && thread.messages?.some((m) =>
m.labelIds?.includes("SENT")
);

// Set status based on labels
if (isStarred) {
plotThread.status = "starred";
} else if (isSentOnly) {
// Plot-composed thread that just sent, or organic Gmail-sent
// thread the user hasn't archived yet. Stays at "sent" until it
// returns to inbox (reply) or the user archives it.
plotThread.status = "sent";
} else if (!isInInbox) {
plotThread.status = "archived";
} else {
plotThread.status = "inbox";
}

// Save link directly via integrations
const savedThreadId = await this.tools.integrations.saveLink(plotThread);
if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) — skip star sync

const wasStarred = await this.get<boolean>(`starred:${thread.id}`);

// Echo suppression relies entirely on the `starred` state: when
// Plot→Gmail writes STARRED, onThreadToDo/onLinkUpdated update this
// Plot→Gmail writes STARRED, onThreadToDo updates this
// state *before* the API call. The resulting Gmail webhook sees
// isStarred === wasStarred and this branch doesn't run.
if (isStarred !== !!wasStarred) {
Expand DownExpand Up@@ -1645,38 +1616,6 @@ export class Gmail extends Connector<Gmail> {
}
}

async onLinkUpdated(link: Link): Promise<void> {
const threadId = link.meta?.threadId as string | undefined;
const channelId = (link.meta?.channelId ?? link.meta?.syncableId) as
| string
| undefined;
if (!threadId || !channelId) return;

// Loop prevention: skip if this change originated from Gmail star sync
if (await this.get(`skip_todo_writeback:${threadId}`)) {
await this.clear(`skip_todo_writeback:${threadId}`);
return;
}

const status = link.status;

// Update local state BEFORE calling Gmail, so the webhook fired by our
// own write sees isStarred === wasStarred and doesn't re-propagate.
await this.set(`starred:${threadId}`, status === "starred");

const api = await this.getApi(channelId);

if (status === "starred") {
await api.modifyThread(threadId, ["STARRED"]);
} else if (status === "archived") {
// Archive = remove from INBOX. Also unstar.
await api.modifyThread(threadId, undefined, ["INBOX", "STARRED"]);
} else if (status === "inbox") {
// Back to inbox, unstar.
await api.modifyThread(threadId, ["INBOX"], ["STARRED"]);
}
}

/**
* Creates a new outbound email from Plot.
*
Expand DownExpand Up@@ -1761,7 +1700,7 @@ export class Gmail extends Connector<Gmail> {
source: canonicalUrl,
type: "email",
title: subject || undefined,
status: draft.status,
status: null,
created: new Date(),
sourceUrl: canonicalUrl,
channelId,
Expand All@@ -1782,7 +1721,6 @@ export class Gmail extends Connector<Gmail> {
const dedupKey = `compose:${fnv1aHex(
JSON.stringify([
draft.type,
draft.status,
subject,
body,
[...toEmails].sort(),
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,9 +688,9 @@ export class Linear extends Connector<Linear> {
// statuses from the static linkTypes fallback) we look up the team's
// states directly.
let stateId: string | null = null;
if (draft.status.length > 20) {
if (draft.status && draft.status.length > 20) {
stateId = draft.status;
} else {
} else if (draft.status) {
const team = await client.team(draft.channelId);
if (team) {
const states = await team.states();
Expand Down
33 changes: 33 additions & 0 deletions connectors/slack/src/slack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,39 @@ function makeSlack(opts: {
return new Slack("twist-instance-1" as never, toolShed as never);
}

describe("saveStarredThread", () => {
it("saves the link with todo:true and no status", async () => {
const store = makeStore({ auth_actor_id: "actor-1" });
const saveLink = vi.fn().mockResolvedValue("thread-1");
const tools = {
store,
integrations: { get: vi.fn(), saveLink },
network: { createWebhook: vi.fn() },
files: {},
};
const slack = new Slack(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);

const api = {
getThread: vi.fn().mockResolvedValue([
{ ts: "111.000", thread_ts: "111.000", user: "U1", text: "hello", reactions: [] },
]),
getUser: vi.fn().mockResolvedValue(null),
};

await (slack as unknown as {
saveStarredThread: (a: unknown, c: string, t: string) => Promise<void>;
}).saveStarredThread(api, "C123", "111.000");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
expect(saved.todo).toBe(true);
expect(saved.status).toBeUndefined();
});
});

describe("setupChannelWebhook", () => {
const channelId = "C123";
const auth: Authorization = {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-slack-statuses.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: `ComposeConfig.status` and `CreateLinkDraft.status` are now optional/nullable so status-less link types can still compose. Added: `NewLink.todo` / `NewLink.todoDate` to mark a thread as the connection owner's to-do atomically at create time.
95 changes: 93 additions & 2 deletions connectors/gmail/src/gmail.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recipientsFor } from "./gmail";
import { describe, expect, it, vi } from "vitest";
import { Gmail, recipientsFor } from "./gmail";
import {
type GmailMessage,
type GmailThread,
Expand DownExpand Up@@ -215,3 +215,94 @@ describe("transformGmailThread — mailing-list From rewrite", () => {
expect(accessContactFor(thread, "jane@example.com")?.name).toBe("Jane Doe");
});
});

describe("processEmailThreads — no status set", () => {
/**
* Build a minimal Gmail thread with the given labelIds on its single message.
* The payload provides headers required by transformGmailThread (From/To/Subject).
*/
function makeGmailThread(labelIds: string[]): GmailThread {
const message: GmailMessage = {
id: "msg-archived",
threadId: "thread-archived",
labelIds,
snippet: "archived message",
historyId: "42",
internalDate: "1700000000000",
sizeEstimate: 100,
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "sender@example.com" },
{ name: "To", value: "me@example.com" },
{ name: "Subject", value: "Test archived" },
{ name: "Message-ID", value: "<msg-archived@example.com>" },
{ name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" },
],
body: { size: 16, data: btoa("archived message") },
},
};
return {
id: "thread-archived",
historyId: "42",
messages: [message],
};
}

function makeGmail(): { gmail: Gmail; saveLink: ReturnType<typeof vi.fn> } {
const storeMap = new Map<string, unknown>([
["enabled_channels", ["INBOX"]],
]);
const store = {
get: vi.fn(async (key: string) =>
storeMap.has(key) ? storeMap.get(key) : null
),
set: vi.fn(async (key: string, value: unknown) => {
storeMap.set(key, value);
}),
clear: vi.fn(async (key: string) => {
storeMap.delete(key);
}),
list: vi.fn(async (prefix: string) =>
[...storeMap.keys()].filter((k) => k.startsWith(prefix))
),
};

const saveLink = vi.fn().mockResolvedValue("thread-archived");
const tools = {
store,
integrations: {
get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }),
saveLink,
setThreadToDo: vi.fn().mockResolvedValue(undefined),
},
network: { createWebhook: vi.fn() },
files: {},
};
const gmail = new Gmail(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);
return { gmail, saveLink };
}

it("saves an archived thread (IMPORTANT only, no INBOX) with no status", async () => {
// IMPORTANT only — not in INBOX, not STARRED, not SENT.
// Old code would have set status="archived"; new code must leave it unset.
const { gmail, saveLink } = makeGmail();
const thread = makeGmailThread(["IMPORTANT"]);

await (gmail as unknown as {
processEmailThreads: (
threads: GmailThread[],
initialSync: boolean,
forceChannelId?: string
) => Promise<void>;
}).processEmailThreads([thread], false, "INBOX");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
// status must be absent (undefined) — not "archived", not any other value
expect(saved.status).toBeUndefined();
});
});
74 changes: 6 additions & 68 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
type ToolBuilder,
} from "@plotday/twister";
import { ActionType } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread, Link } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -210,12 +210,6 @@ export class Gmail extends Connector<Gmail> {
supportsFileAttachments: true,
logo: "https://api.iconify.design/logos/google-gmail.svg",
logoMono: "https://api.iconify.design/simple-icons/gmail.svg",
statuses: [
{ status: "inbox", label: "Inbox" },
{ status: "starred", label: "Starred", active: true },
{ status: "sent", label: "Sent" },
{ status: "archived", label: "Archived", done: true },
],
contactRoles: [
{ id: "to", label: "To", default: true },
{ id: "cc", label: "CC" },
Expand All@@ -229,7 +223,6 @@ export class Gmail extends Connector<Gmail> {
// `contact.email` otherwise.
compose: {
targets: "addresses" as const,
status: "sent",
},
},
];
Expand DownExpand Up@@ -1357,40 +1350,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Star ↔ todo sync: detect star changes and update Plot todo status
// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
const isStarred = GmailApi.isStarred(thread);
const isInInbox = thread.messages?.some((m) =>
m.labelIds?.includes("INBOX")
);
// "Sent" is meaningful only when the thread isn't ALSO in the inbox
// (e.g. self-CC, or recipient replied) — those should appear under
// "inbox" so the user actions them like any other incoming thread.
const isSentOnly = !isInInbox && thread.messages?.some((m) =>
m.labelIds?.includes("SENT")
);

// Set status based on labels
if (isStarred) {
plotThread.status = "starred";
} else if (isSentOnly) {
// Plot-composed thread that just sent, or organic Gmail-sent
// thread the user hasn't archived yet. Stays at "sent" until it
// returns to inbox (reply) or the user archives it.
plotThread.status = "sent";
} else if (!isInInbox) {
plotThread.status = "archived";
} else {
plotThread.status = "inbox";
}

// Save link directly via integrations
const savedThreadId = await this.tools.integrations.saveLink(plotThread);
if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) — skip star sync

const wasStarred = await this.get<boolean>(`starred:${thread.id}`);

// Echo suppression relies entirely on the `starred` state: when
// Plot→Gmail writes STARRED, onThreadToDo/onLinkUpdated update this
// Plot→Gmail writes STARRED, onThreadToDo updates this
// state *before* the API call. The resulting Gmail webhook sees
// isStarred === wasStarred and this branch doesn't run.
if (isStarred !== !!wasStarred) {
Expand DownExpand Up@@ -1645,38 +1616,6 @@ export class Gmail extends Connector<Gmail> {
}
}

async onLinkUpdated(link: Link): Promise<void> {
const threadId = link.meta?.threadId as string | undefined;
const channelId = (link.meta?.channelId ?? link.meta?.syncableId) as
| string
| undefined;
if (!threadId || !channelId) return;

// Loop prevention: skip if this change originated from Gmail star sync
if (await this.get(`skip_todo_writeback:${threadId}`)) {
await this.clear(`skip_todo_writeback:${threadId}`);
return;
}

const status = link.status;

// Update local state BEFORE calling Gmail, so the webhook fired by our
// own write sees isStarred === wasStarred and doesn't re-propagate.
await this.set(`starred:${threadId}`, status === "starred");

const api = await this.getApi(channelId);

if (status === "starred") {
await api.modifyThread(threadId, ["STARRED"]);
} else if (status === "archived") {
// Archive = remove from INBOX. Also unstar.
await api.modifyThread(threadId, undefined, ["INBOX", "STARRED"]);
} else if (status === "inbox") {
// Back to inbox, unstar.
await api.modifyThread(threadId, ["INBOX"], ["STARRED"]);
}
}

/**
* Creates a new outbound email from Plot.
*
Expand DownExpand Up@@ -1761,7 +1700,7 @@ export class Gmail extends Connector<Gmail> {
source: canonicalUrl,
type: "email",
title: subject || undefined,
status: draft.status,
status: null,
created: new Date(),
sourceUrl: canonicalUrl,
channelId,
Expand All@@ -1782,7 +1721,6 @@ export class Gmail extends Connector<Gmail> {
const dedupKey = `compose:${fnv1aHex(
JSON.stringify([
draft.type,
draft.status,
subject,
body,
[...toEmails].sort(),
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,9 +688,9 @@ export class Linear extends Connector<Linear> {
// statuses from the static linkTypes fallback) we look up the team's
// states directly.
let stateId: string | null = null;
if (draft.status.length > 20) {
if (draft.status && draft.status.length > 20) {
stateId = draft.status;
} else {
} else if (draft.status) {
const team = await client.team(draft.channelId);
if (team) {
const states = await team.states();
Expand Down
33 changes: 33 additions & 0 deletions connectors/slack/src/slack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,39 @@ function makeSlack(opts: {
return new Slack("twist-instance-1" as never, toolShed as never);
}

describe("saveStarredThread", () => {
it("saves the link with todo:true and no status", async () => {
const store = makeStore({ auth_actor_id: "actor-1" });
const saveLink = vi.fn().mockResolvedValue("thread-1");
const tools = {
store,
integrations: { get: vi.fn(), saveLink },
network: { createWebhook: vi.fn() },
files: {},
};
const slack = new Slack(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);

const api = {
getThread: vi.fn().mockResolvedValue([
{ ts: "111.000", thread_ts: "111.000", user: "U1", text: "hello", reactions: [] },
]),
getUser: vi.fn().mockResolvedValue(null),
};

await (slack as unknown as {
saveStarredThread: (a: unknown, c: string, t: string) => Promise<void>;
}).saveStarredThread(api, "C123", "111.000");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
expect(saved.todo).toBe(true);
expect(saved.status).toBeUndefined();
});
});

describe("setupChannelWebhook", () => {
const channelId = "C123";
const auth: Authorization = {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-slack-statuses.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: `ComposeConfig.status` and `CreateLinkDraft.status` are now optional/nullable so status-less link types can still compose. Added: `NewLink.todo` / `NewLink.todoDate` to mark a thread as the connection owner's to-do atomically at create time.
95 changes: 93 additions & 2 deletions connectors/gmail/src/gmail.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recipientsFor } from "./gmail";
import { describe, expect, it, vi } from "vitest";
import { Gmail, recipientsFor } from "./gmail";
import {
type GmailMessage,
type GmailThread,
Expand DownExpand Up@@ -215,3 +215,94 @@ describe("transformGmailThread — mailing-list From rewrite", () => {
expect(accessContactFor(thread, "jane@example.com")?.name).toBe("Jane Doe");
});
});

describe("processEmailThreads — no status set", () => {
/**
* Build a minimal Gmail thread with the given labelIds on its single message.
* The payload provides headers required by transformGmailThread (From/To/Subject).
*/
function makeGmailThread(labelIds: string[]): GmailThread {
const message: GmailMessage = {
id: "msg-archived",
threadId: "thread-archived",
labelIds,
snippet: "archived message",
historyId: "42",
internalDate: "1700000000000",
sizeEstimate: 100,
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "sender@example.com" },
{ name: "To", value: "me@example.com" },
{ name: "Subject", value: "Test archived" },
{ name: "Message-ID", value: "<msg-archived@example.com>" },
{ name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" },
],
body: { size: 16, data: btoa("archived message") },
},
};
return {
id: "thread-archived",
historyId: "42",
messages: [message],
};
}

function makeGmail(): { gmail: Gmail; saveLink: ReturnType<typeof vi.fn> } {
const storeMap = new Map<string, unknown>([
["enabled_channels", ["INBOX"]],
]);
const store = {
get: vi.fn(async (key: string) =>
storeMap.has(key) ? storeMap.get(key) : null
),
set: vi.fn(async (key: string, value: unknown) => {
storeMap.set(key, value);
}),
clear: vi.fn(async (key: string) => {
storeMap.delete(key);
}),
list: vi.fn(async (prefix: string) =>
[...storeMap.keys()].filter((k) => k.startsWith(prefix))
),
};

const saveLink = vi.fn().mockResolvedValue("thread-archived");
const tools = {
store,
integrations: {
get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }),
saveLink,
setThreadToDo: vi.fn().mockResolvedValue(undefined),
},
network: { createWebhook: vi.fn() },
files: {},
};
const gmail = new Gmail(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);
return { gmail, saveLink };
}

it("saves an archived thread (IMPORTANT only, no INBOX) with no status", async () => {
// IMPORTANT only — not in INBOX, not STARRED, not SENT.
// Old code would have set status="archived"; new code must leave it unset.
const { gmail, saveLink } = makeGmail();
const thread = makeGmailThread(["IMPORTANT"]);

await (gmail as unknown as {
processEmailThreads: (
threads: GmailThread[],
initialSync: boolean,
forceChannelId?: string
) => Promise<void>;
}).processEmailThreads([thread], false, "INBOX");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
// status must be absent (undefined) — not "archived", not any other value
expect(saved.status).toBeUndefined();
});
});
74 changes: 6 additions & 68 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
type ToolBuilder,
} from "@plotday/twister";
import { ActionType } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread, Link } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -210,12 +210,6 @@ export class Gmail extends Connector<Gmail> {
supportsFileAttachments: true,
logo: "https://api.iconify.design/logos/google-gmail.svg",
logoMono: "https://api.iconify.design/simple-icons/gmail.svg",
statuses: [
{ status: "inbox", label: "Inbox" },
{ status: "starred", label: "Starred", active: true },
{ status: "sent", label: "Sent" },
{ status: "archived", label: "Archived", done: true },
],
contactRoles: [
{ id: "to", label: "To", default: true },
{ id: "cc", label: "CC" },
Expand All@@ -229,7 +223,6 @@ export class Gmail extends Connector<Gmail> {
// `contact.email` otherwise.
compose: {
targets: "addresses" as const,
status: "sent",
},
},
];
Expand DownExpand Up@@ -1357,40 +1350,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Star ↔ todo sync: detect star changes and update Plot todo status
// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
const isStarred = GmailApi.isStarred(thread);
const isInInbox = thread.messages?.some((m) =>
m.labelIds?.includes("INBOX")
);
// "Sent" is meaningful only when the thread isn't ALSO in the inbox
// (e.g. self-CC, or recipient replied) — those should appear under
// "inbox" so the user actions them like any other incoming thread.
const isSentOnly = !isInInbox && thread.messages?.some((m) =>
m.labelIds?.includes("SENT")
);

// Set status based on labels
if (isStarred) {
plotThread.status = "starred";
} else if (isSentOnly) {
// Plot-composed thread that just sent, or organic Gmail-sent
// thread the user hasn't archived yet. Stays at "sent" until it
// returns to inbox (reply) or the user archives it.
plotThread.status = "sent";
} else if (!isInInbox) {
plotThread.status = "archived";
} else {
plotThread.status = "inbox";
}

// Save link directly via integrations
const savedThreadId = await this.tools.integrations.saveLink(plotThread);
if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) — skip star sync

const wasStarred = await this.get<boolean>(`starred:${thread.id}`);

// Echo suppression relies entirely on the `starred` state: when
// Plot→Gmail writes STARRED, onThreadToDo/onLinkUpdated update this
// Plot→Gmail writes STARRED, onThreadToDo updates this
// state *before* the API call. The resulting Gmail webhook sees
// isStarred === wasStarred and this branch doesn't run.
if (isStarred !== !!wasStarred) {
Expand DownExpand Up@@ -1645,38 +1616,6 @@ export class Gmail extends Connector<Gmail> {
}
}

async onLinkUpdated(link: Link): Promise<void> {
const threadId = link.meta?.threadId as string | undefined;
const channelId = (link.meta?.channelId ?? link.meta?.syncableId) as
| string
| undefined;
if (!threadId || !channelId) return;

// Loop prevention: skip if this change originated from Gmail star sync
if (await this.get(`skip_todo_writeback:${threadId}`)) {
await this.clear(`skip_todo_writeback:${threadId}`);
return;
}

const status = link.status;

// Update local state BEFORE calling Gmail, so the webhook fired by our
// own write sees isStarred === wasStarred and doesn't re-propagate.
await this.set(`starred:${threadId}`, status === "starred");

const api = await this.getApi(channelId);

if (status === "starred") {
await api.modifyThread(threadId, ["STARRED"]);
} else if (status === "archived") {
// Archive = remove from INBOX. Also unstar.
await api.modifyThread(threadId, undefined, ["INBOX", "STARRED"]);
} else if (status === "inbox") {
// Back to inbox, unstar.
await api.modifyThread(threadId, ["INBOX"], ["STARRED"]);
}
}

/**
* Creates a new outbound email from Plot.
*
Expand DownExpand Up@@ -1761,7 +1700,7 @@ export class Gmail extends Connector<Gmail> {
source: canonicalUrl,
type: "email",
title: subject || undefined,
status: draft.status,
status: null,
created: new Date(),
sourceUrl: canonicalUrl,
channelId,
Expand All@@ -1782,7 +1721,6 @@ export class Gmail extends Connector<Gmail> {
const dedupKey = `compose:${fnv1aHex(
JSON.stringify([
draft.type,
draft.status,
subject,
body,
[...toEmails].sort(),
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,9 +688,9 @@ export class Linear extends Connector<Linear> {
// statuses from the static linkTypes fallback) we look up the team's
// states directly.
let stateId: string | null = null;
if (draft.status.length > 20) {
if (draft.status && draft.status.length > 20) {
stateId = draft.status;
} else {
} else if (draft.status) {
const team = await client.team(draft.channelId);
if (team) {
const states = await team.states();
Expand Down
33 changes: 33 additions & 0 deletions connectors/slack/src/slack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,39 @@ function makeSlack(opts: {
return new Slack("twist-instance-1" as never, toolShed as never);
}

describe("saveStarredThread", () => {
it("saves the link with todo:true and no status", async () => {
const store = makeStore({ auth_actor_id: "actor-1" });
const saveLink = vi.fn().mockResolvedValue("thread-1");
const tools = {
store,
integrations: { get: vi.fn(), saveLink },
network: { createWebhook: vi.fn() },
files: {},
};
const slack = new Slack(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);

const api = {
getThread: vi.fn().mockResolvedValue([
{ ts: "111.000", thread_ts: "111.000", user: "U1", text: "hello", reactions: [] },
]),
getUser: vi.fn().mockResolvedValue(null),
};

await (slack as unknown as {
saveStarredThread: (a: unknown, c: string, t: string) => Promise<void>;
}).saveStarredThread(api, "C123", "111.000");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
expect(saved.todo).toBe(true);
expect(saved.status).toBeUndefined();
});
});

describe("setupChannelWebhook", () => {
const channelId = "C123";
const auth: Authorization = {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-slack-statuses.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: `ComposeConfig.status` and `CreateLinkDraft.status` are now optional/nullable so status-less link types can still compose. Added: `NewLink.todo` / `NewLink.todoDate` to mark a thread as the connection owner's to-do atomically at create time.
95 changes: 93 additions & 2 deletions connectors/gmail/src/gmail.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recipientsFor } from "./gmail";
import { describe, expect, it, vi } from "vitest";
import { Gmail, recipientsFor } from "./gmail";
import {
type GmailMessage,
type GmailThread,
Expand DownExpand Up@@ -215,3 +215,94 @@ describe("transformGmailThread — mailing-list From rewrite", () => {
expect(accessContactFor(thread, "jane@example.com")?.name).toBe("Jane Doe");
});
});

describe("processEmailThreads — no status set", () => {
/**
* Build a minimal Gmail thread with the given labelIds on its single message.
* The payload provides headers required by transformGmailThread (From/To/Subject).
*/
function makeGmailThread(labelIds: string[]): GmailThread {
const message: GmailMessage = {
id: "msg-archived",
threadId: "thread-archived",
labelIds,
snippet: "archived message",
historyId: "42",
internalDate: "1700000000000",
sizeEstimate: 100,
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "sender@example.com" },
{ name: "To", value: "me@example.com" },
{ name: "Subject", value: "Test archived" },
{ name: "Message-ID", value: "<msg-archived@example.com>" },
{ name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" },
],
body: { size: 16, data: btoa("archived message") },
},
};
return {
id: "thread-archived",
historyId: "42",
messages: [message],
};
}

function makeGmail(): { gmail: Gmail; saveLink: ReturnType<typeof vi.fn> } {
const storeMap = new Map<string, unknown>([
["enabled_channels", ["INBOX"]],
]);
const store = {
get: vi.fn(async (key: string) =>
storeMap.has(key) ? storeMap.get(key) : null
),
set: vi.fn(async (key: string, value: unknown) => {
storeMap.set(key, value);
}),
clear: vi.fn(async (key: string) => {
storeMap.delete(key);
}),
list: vi.fn(async (prefix: string) =>
[...storeMap.keys()].filter((k) => k.startsWith(prefix))
),
};

const saveLink = vi.fn().mockResolvedValue("thread-archived");
const tools = {
store,
integrations: {
get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }),
saveLink,
setThreadToDo: vi.fn().mockResolvedValue(undefined),
},
network: { createWebhook: vi.fn() },
files: {},
};
const gmail = new Gmail(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);
return { gmail, saveLink };
}

it("saves an archived thread (IMPORTANT only, no INBOX) with no status", async () => {
// IMPORTANT only — not in INBOX, not STARRED, not SENT.
// Old code would have set status="archived"; new code must leave it unset.
const { gmail, saveLink } = makeGmail();
const thread = makeGmailThread(["IMPORTANT"]);

await (gmail as unknown as {
processEmailThreads: (
threads: GmailThread[],
initialSync: boolean,
forceChannelId?: string
) => Promise<void>;
}).processEmailThreads([thread], false, "INBOX");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
// status must be absent (undefined) — not "archived", not any other value
expect(saved.status).toBeUndefined();
});
});
74 changes: 6 additions & 68 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
type ToolBuilder,
} from "@plotday/twister";
import { ActionType } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread, Link } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -210,12 +210,6 @@ export class Gmail extends Connector<Gmail> {
supportsFileAttachments: true,
logo: "https://api.iconify.design/logos/google-gmail.svg",
logoMono: "https://api.iconify.design/simple-icons/gmail.svg",
statuses: [
{ status: "inbox", label: "Inbox" },
{ status: "starred", label: "Starred", active: true },
{ status: "sent", label: "Sent" },
{ status: "archived", label: "Archived", done: true },
],
contactRoles: [
{ id: "to", label: "To", default: true },
{ id: "cc", label: "CC" },
Expand All@@ -229,7 +223,6 @@ export class Gmail extends Connector<Gmail> {
// `contact.email` otherwise.
compose: {
targets: "addresses" as const,
status: "sent",
},
},
];
Expand DownExpand Up@@ -1357,40 +1350,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Star ↔ todo sync: detect star changes and update Plot todo status
// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
const isStarred = GmailApi.isStarred(thread);
const isInInbox = thread.messages?.some((m) =>
m.labelIds?.includes("INBOX")
);
// "Sent" is meaningful only when the thread isn't ALSO in the inbox
// (e.g. self-CC, or recipient replied) — those should appear under
// "inbox" so the user actions them like any other incoming thread.
const isSentOnly = !isInInbox && thread.messages?.some((m) =>
m.labelIds?.includes("SENT")
);

// Set status based on labels
if (isStarred) {
plotThread.status = "starred";
} else if (isSentOnly) {
// Plot-composed thread that just sent, or organic Gmail-sent
// thread the user hasn't archived yet. Stays at "sent" until it
// returns to inbox (reply) or the user archives it.
plotThread.status = "sent";
} else if (!isInInbox) {
plotThread.status = "archived";
} else {
plotThread.status = "inbox";
}

// Save link directly via integrations
const savedThreadId = await this.tools.integrations.saveLink(plotThread);
if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) — skip star sync

const wasStarred = await this.get<boolean>(`starred:${thread.id}`);

// Echo suppression relies entirely on the `starred` state: when
// Plot→Gmail writes STARRED, onThreadToDo/onLinkUpdated update this
// Plot→Gmail writes STARRED, onThreadToDo updates this
// state *before* the API call. The resulting Gmail webhook sees
// isStarred === wasStarred and this branch doesn't run.
if (isStarred !== !!wasStarred) {
Expand DownExpand Up@@ -1645,38 +1616,6 @@ export class Gmail extends Connector<Gmail> {
}
}

async onLinkUpdated(link: Link): Promise<void> {
const threadId = link.meta?.threadId as string | undefined;
const channelId = (link.meta?.channelId ?? link.meta?.syncableId) as
| string
| undefined;
if (!threadId || !channelId) return;

// Loop prevention: skip if this change originated from Gmail star sync
if (await this.get(`skip_todo_writeback:${threadId}`)) {
await this.clear(`skip_todo_writeback:${threadId}`);
return;
}

const status = link.status;

// Update local state BEFORE calling Gmail, so the webhook fired by our
// own write sees isStarred === wasStarred and doesn't re-propagate.
await this.set(`starred:${threadId}`, status === "starred");

const api = await this.getApi(channelId);

if (status === "starred") {
await api.modifyThread(threadId, ["STARRED"]);
} else if (status === "archived") {
// Archive = remove from INBOX. Also unstar.
await api.modifyThread(threadId, undefined, ["INBOX", "STARRED"]);
} else if (status === "inbox") {
// Back to inbox, unstar.
await api.modifyThread(threadId, ["INBOX"], ["STARRED"]);
}
}

/**
* Creates a new outbound email from Plot.
*
Expand DownExpand Up@@ -1761,7 +1700,7 @@ export class Gmail extends Connector<Gmail> {
source: canonicalUrl,
type: "email",
title: subject || undefined,
status: draft.status,
status: null,
created: new Date(),
sourceUrl: canonicalUrl,
channelId,
Expand All@@ -1782,7 +1721,6 @@ export class Gmail extends Connector<Gmail> {
const dedupKey = `compose:${fnv1aHex(
JSON.stringify([
draft.type,
draft.status,
subject,
body,
[...toEmails].sort(),
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,9 +688,9 @@ export class Linear extends Connector<Linear> {
// statuses from the static linkTypes fallback) we look up the team's
// states directly.
let stateId: string | null = null;
if (draft.status.length > 20) {
if (draft.status && draft.status.length > 20) {
stateId = draft.status;
} else {
} else if (draft.status) {
const team = await client.team(draft.channelId);
if (team) {
const states = await team.states();
Expand Down
33 changes: 33 additions & 0 deletions connectors/slack/src/slack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,39 @@ function makeSlack(opts: {
return new Slack("twist-instance-1" as never, toolShed as never);
}

describe("saveStarredThread", () => {
it("saves the link with todo:true and no status", async () => {
const store = makeStore({ auth_actor_id: "actor-1" });
const saveLink = vi.fn().mockResolvedValue("thread-1");
const tools = {
store,
integrations: { get: vi.fn(), saveLink },
network: { createWebhook: vi.fn() },
files: {},
};
const slack = new Slack(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);

const api = {
getThread: vi.fn().mockResolvedValue([
{ ts: "111.000", thread_ts: "111.000", user: "U1", text: "hello", reactions: [] },
]),
getUser: vi.fn().mockResolvedValue(null),
};

await (slack as unknown as {
saveStarredThread: (a: unknown, c: string, t: string) => Promise<void>;
}).saveStarredThread(api, "C123", "111.000");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
expect(saved.todo).toBe(true);
expect(saved.status).toBeUndefined();
});
});

describe("setupChannelWebhook", () => {
const channelId = "C123";
const auth: Authorization = {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-slack-statuses.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: `ComposeConfig.status` and `CreateLinkDraft.status` are now optional/nullable so status-less link types can still compose. Added: `NewLink.todo` / `NewLink.todoDate` to mark a thread as the connection owner's to-do atomically at create time.
95 changes: 93 additions & 2 deletions connectors/gmail/src/gmail.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recipientsFor } from "./gmail";
import { describe, expect, it, vi } from "vitest";
import { Gmail, recipientsFor } from "./gmail";
import {
type GmailMessage,
type GmailThread,
Expand DownExpand Up@@ -215,3 +215,94 @@ describe("transformGmailThread — mailing-list From rewrite", () => {
expect(accessContactFor(thread, "jane@example.com")?.name).toBe("Jane Doe");
});
});

describe("processEmailThreads — no status set", () => {
/**
* Build a minimal Gmail thread with the given labelIds on its single message.
* The payload provides headers required by transformGmailThread (From/To/Subject).
*/
function makeGmailThread(labelIds: string[]): GmailThread {
const message: GmailMessage = {
id: "msg-archived",
threadId: "thread-archived",
labelIds,
snippet: "archived message",
historyId: "42",
internalDate: "1700000000000",
sizeEstimate: 100,
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "sender@example.com" },
{ name: "To", value: "me@example.com" },
{ name: "Subject", value: "Test archived" },
{ name: "Message-ID", value: "<msg-archived@example.com>" },
{ name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" },
],
body: { size: 16, data: btoa("archived message") },
},
};
return {
id: "thread-archived",
historyId: "42",
messages: [message],
};
}

function makeGmail(): { gmail: Gmail; saveLink: ReturnType<typeof vi.fn> } {
const storeMap = new Map<string, unknown>([
["enabled_channels", ["INBOX"]],
]);
const store = {
get: vi.fn(async (key: string) =>
storeMap.has(key) ? storeMap.get(key) : null
),
set: vi.fn(async (key: string, value: unknown) => {
storeMap.set(key, value);
}),
clear: vi.fn(async (key: string) => {
storeMap.delete(key);
}),
list: vi.fn(async (prefix: string) =>
[...storeMap.keys()].filter((k) => k.startsWith(prefix))
),
};

const saveLink = vi.fn().mockResolvedValue("thread-archived");
const tools = {
store,
integrations: {
get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }),
saveLink,
setThreadToDo: vi.fn().mockResolvedValue(undefined),
},
network: { createWebhook: vi.fn() },
files: {},
};
const gmail = new Gmail(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);
return { gmail, saveLink };
}

it("saves an archived thread (IMPORTANT only, no INBOX) with no status", async () => {
// IMPORTANT only — not in INBOX, not STARRED, not SENT.
// Old code would have set status="archived"; new code must leave it unset.
const { gmail, saveLink } = makeGmail();
const thread = makeGmailThread(["IMPORTANT"]);

await (gmail as unknown as {
processEmailThreads: (
threads: GmailThread[],
initialSync: boolean,
forceChannelId?: string
) => Promise<void>;
}).processEmailThreads([thread], false, "INBOX");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
// status must be absent (undefined) — not "archived", not any other value
expect(saved.status).toBeUndefined();
});
});
74 changes: 6 additions & 68 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
type ToolBuilder,
} from "@plotday/twister";
import { ActionType } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread, Link } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -210,12 +210,6 @@ export class Gmail extends Connector<Gmail> {
supportsFileAttachments: true,
logo: "https://api.iconify.design/logos/google-gmail.svg",
logoMono: "https://api.iconify.design/simple-icons/gmail.svg",
statuses: [
{ status: "inbox", label: "Inbox" },
{ status: "starred", label: "Starred", active: true },
{ status: "sent", label: "Sent" },
{ status: "archived", label: "Archived", done: true },
],
contactRoles: [
{ id: "to", label: "To", default: true },
{ id: "cc", label: "CC" },
Expand All@@ -229,7 +223,6 @@ export class Gmail extends Connector<Gmail> {
// `contact.email` otherwise.
compose: {
targets: "addresses" as const,
status: "sent",
},
},
];
Expand DownExpand Up@@ -1357,40 +1350,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Star ↔ todo sync: detect star changes and update Plot todo status
// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
const isStarred = GmailApi.isStarred(thread);
const isInInbox = thread.messages?.some((m) =>
m.labelIds?.includes("INBOX")
);
// "Sent" is meaningful only when the thread isn't ALSO in the inbox
// (e.g. self-CC, or recipient replied) — those should appear under
// "inbox" so the user actions them like any other incoming thread.
const isSentOnly = !isInInbox && thread.messages?.some((m) =>
m.labelIds?.includes("SENT")
);

// Set status based on labels
if (isStarred) {
plotThread.status = "starred";
} else if (isSentOnly) {
// Plot-composed thread that just sent, or organic Gmail-sent
// thread the user hasn't archived yet. Stays at "sent" until it
// returns to inbox (reply) or the user archives it.
plotThread.status = "sent";
} else if (!isInInbox) {
plotThread.status = "archived";
} else {
plotThread.status = "inbox";
}

// Save link directly via integrations
const savedThreadId = await this.tools.integrations.saveLink(plotThread);
if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) — skip star sync

const wasStarred = await this.get<boolean>(`starred:${thread.id}`);

// Echo suppression relies entirely on the `starred` state: when
// Plot→Gmail writes STARRED, onThreadToDo/onLinkUpdated update this
// Plot→Gmail writes STARRED, onThreadToDo updates this
// state *before* the API call. The resulting Gmail webhook sees
// isStarred === wasStarred and this branch doesn't run.
if (isStarred !== !!wasStarred) {
Expand DownExpand Up@@ -1645,38 +1616,6 @@ export class Gmail extends Connector<Gmail> {
}
}

async onLinkUpdated(link: Link): Promise<void> {
const threadId = link.meta?.threadId as string | undefined;
const channelId = (link.meta?.channelId ?? link.meta?.syncableId) as
| string
| undefined;
if (!threadId || !channelId) return;

// Loop prevention: skip if this change originated from Gmail star sync
if (await this.get(`skip_todo_writeback:${threadId}`)) {
await this.clear(`skip_todo_writeback:${threadId}`);
return;
}

const status = link.status;

// Update local state BEFORE calling Gmail, so the webhook fired by our
// own write sees isStarred === wasStarred and doesn't re-propagate.
await this.set(`starred:${threadId}`, status === "starred");

const api = await this.getApi(channelId);

if (status === "starred") {
await api.modifyThread(threadId, ["STARRED"]);
} else if (status === "archived") {
// Archive = remove from INBOX. Also unstar.
await api.modifyThread(threadId, undefined, ["INBOX", "STARRED"]);
} else if (status === "inbox") {
// Back to inbox, unstar.
await api.modifyThread(threadId, ["INBOX"], ["STARRED"]);
}
}

/**
* Creates a new outbound email from Plot.
*
Expand DownExpand Up@@ -1761,7 +1700,7 @@ export class Gmail extends Connector<Gmail> {
source: canonicalUrl,
type: "email",
title: subject || undefined,
status: draft.status,
status: null,
created: new Date(),
sourceUrl: canonicalUrl,
channelId,
Expand All@@ -1782,7 +1721,6 @@ export class Gmail extends Connector<Gmail> {
const dedupKey = `compose:${fnv1aHex(
JSON.stringify([
draft.type,
draft.status,
subject,
body,
[...toEmails].sort(),
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,9 +688,9 @@ export class Linear extends Connector<Linear> {
// statuses from the static linkTypes fallback) we look up the team's
// states directly.
let stateId: string | null = null;
if (draft.status.length > 20) {
if (draft.status && draft.status.length > 20) {
stateId = draft.status;
} else {
} else if (draft.status) {
const team = await client.team(draft.channelId);
if (team) {
const states = await team.states();
Expand Down
33 changes: 33 additions & 0 deletions connectors/slack/src/slack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,39 @@ function makeSlack(opts: {
return new Slack("twist-instance-1" as never, toolShed as never);
}

describe("saveStarredThread", () => {
it("saves the link with todo:true and no status", async () => {
const store = makeStore({ auth_actor_id: "actor-1" });
const saveLink = vi.fn().mockResolvedValue("thread-1");
const tools = {
store,
integrations: { get: vi.fn(), saveLink },
network: { createWebhook: vi.fn() },
files: {},
};
const slack = new Slack(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);

const api = {
getThread: vi.fn().mockResolvedValue([
{ ts: "111.000", thread_ts: "111.000", user: "U1", text: "hello", reactions: [] },
]),
getUser: vi.fn().mockResolvedValue(null),
};

await (slack as unknown as {
saveStarredThread: (a: unknown, c: string, t: string) => Promise<void>;
}).saveStarredThread(api, "C123", "111.000");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
expect(saved.todo).toBe(true);
expect(saved.status).toBeUndefined();
});
});

describe("setupChannelWebhook", () => {
const channelId = "C123";
const auth: Authorization = {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-slack-statuses.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Changed: `ComposeConfig.status` and `CreateLinkDraft.status` are now optional/nullable so status-less link types can still compose. Added: `NewLink.todo` / `NewLink.todoDate` to mark a thread as the connection owner's to-do atomically at create time.
95 changes: 93 additions & 2 deletions connectors/gmail/src/gmail.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recipientsFor } from "./gmail";
import { describe, expect, it, vi } from "vitest";
import { Gmail, recipientsFor } from "./gmail";
import {
type GmailMessage,
type GmailThread,
Expand DownExpand Up@@ -215,3 +215,94 @@ describe("transformGmailThread — mailing-list From rewrite", () => {
expect(accessContactFor(thread, "jane@example.com")?.name).toBe("Jane Doe");
});
});

describe("processEmailThreads — no status set", () => {
/**
* Build a minimal Gmail thread with the given labelIds on its single message.
* The payload provides headers required by transformGmailThread (From/To/Subject).
*/
function makeGmailThread(labelIds: string[]): GmailThread {
const message: GmailMessage = {
id: "msg-archived",
threadId: "thread-archived",
labelIds,
snippet: "archived message",
historyId: "42",
internalDate: "1700000000000",
sizeEstimate: 100,
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "sender@example.com" },
{ name: "To", value: "me@example.com" },
{ name: "Subject", value: "Test archived" },
{ name: "Message-ID", value: "<msg-archived@example.com>" },
{ name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" },
],
body: { size: 16, data: btoa("archived message") },
},
};
return {
id: "thread-archived",
historyId: "42",
messages: [message],
};
}

function makeGmail(): { gmail: Gmail; saveLink: ReturnType<typeof vi.fn> } {
const storeMap = new Map<string, unknown>([
["enabled_channels", ["INBOX"]],
]);
const store = {
get: vi.fn(async (key: string) =>
storeMap.has(key) ? storeMap.get(key) : null
),
set: vi.fn(async (key: string, value: unknown) => {
storeMap.set(key, value);
}),
clear: vi.fn(async (key: string) => {
storeMap.delete(key);
}),
list: vi.fn(async (prefix: string) =>
[...storeMap.keys()].filter((k) => k.startsWith(prefix))
),
};

const saveLink = vi.fn().mockResolvedValue("thread-archived");
const tools = {
store,
integrations: {
get: vi.fn().mockResolvedValue({ token: "tok", scopes: [] }),
saveLink,
setThreadToDo: vi.fn().mockResolvedValue(undefined),
},
network: { createWebhook: vi.fn() },
files: {},
};
const gmail = new Gmail(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);
return { gmail, saveLink };
}

it("saves an archived thread (IMPORTANT only, no INBOX) with no status", async () => {
// IMPORTANT only — not in INBOX, not STARRED, not SENT.
// Old code would have set status="archived"; new code must leave it unset.
const { gmail, saveLink } = makeGmail();
const thread = makeGmailThread(["IMPORTANT"]);

await (gmail as unknown as {
processEmailThreads: (
threads: GmailThread[],
initialSync: boolean,
forceChannelId?: string
) => Promise<void>;
}).processEmailThreads([thread], false, "INBOX");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
// status must be absent (undefined) — not "archived", not any other value
expect(saved.status).toBeUndefined();
});
});
74 changes: 6 additions & 68 deletions connectors/gmail/src/gmail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import {
type ToolBuilder,
} from "@plotday/twister";
import { ActionType } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread, Link } from "@plotday/twister/plot";
import type { Actor, ActorId, NewLinkWithNotes, Note, Thread } from "@plotday/twister/plot";
import {
AuthProvider,
type AuthToken,
Expand DownExpand Up@@ -210,12 +210,6 @@ export class Gmail extends Connector<Gmail> {
supportsFileAttachments: true,
logo: "https://api.iconify.design/logos/google-gmail.svg",
logoMono: "https://api.iconify.design/simple-icons/gmail.svg",
statuses: [
{ status: "inbox", label: "Inbox" },
{ status: "starred", label: "Starred", active: true },
{ status: "sent", label: "Sent" },
{ status: "archived", label: "Archived", done: true },
],
contactRoles: [
{ id: "to", label: "To", default: true },
{ id: "cc", label: "CC" },
Expand All@@ -229,7 +223,6 @@ export class Gmail extends Connector<Gmail> {
// `contact.email` otherwise.
compose: {
targets: "addresses" as const,
status: "sent",
},
},
];
Expand DownExpand Up@@ -1357,40 +1350,18 @@ export class Gmail extends Connector<Gmail> {
syncableId: channelId,
};

// Star ↔ todo sync: detect star changes and update Plot todo status
// Star ↔ todo sync: detect star changes and sync to Plot todo status.
// Statuses have been removed; every thread (including archived) is saved
// with no status and treated like any other thread.
const isStarred = GmailApi.isStarred(thread);
const isInInbox = thread.messages?.some((m) =>
m.labelIds?.includes("INBOX")
);
// "Sent" is meaningful only when the thread isn't ALSO in the inbox
// (e.g. self-CC, or recipient replied) — those should appear under
// "inbox" so the user actions them like any other incoming thread.
const isSentOnly = !isInInbox && thread.messages?.some((m) =>
m.labelIds?.includes("SENT")
);

// Set status based on labels
if (isStarred) {
plotThread.status = "starred";
} else if (isSentOnly) {
// Plot-composed thread that just sent, or organic Gmail-sent
// thread the user hasn't archived yet. Stays at "sent" until it
// returns to inbox (reply) or the user archives it.
plotThread.status = "sent";
} else if (!isInInbox) {
plotThread.status = "archived";
} else {
plotThread.status = "inbox";
}

// Save link directly via integrations
const savedThreadId = await this.tools.integrations.saveLink(plotThread);
if (!savedThreadId) continue; // Link was filtered (e.g., older than sync history) — skip star sync

const wasStarred = await this.get<boolean>(`starred:${thread.id}`);

// Echo suppression relies entirely on the `starred` state: when
// Plot→Gmail writes STARRED, onThreadToDo/onLinkUpdated update this
// Plot→Gmail writes STARRED, onThreadToDo updates this
// state *before* the API call. The resulting Gmail webhook sees
// isStarred === wasStarred and this branch doesn't run.
if (isStarred !== !!wasStarred) {
Expand DownExpand Up@@ -1645,38 +1616,6 @@ export class Gmail extends Connector<Gmail> {
}
}

async onLinkUpdated(link: Link): Promise<void> {
const threadId = link.meta?.threadId as string | undefined;
const channelId = (link.meta?.channelId ?? link.meta?.syncableId) as
| string
| undefined;
if (!threadId || !channelId) return;

// Loop prevention: skip if this change originated from Gmail star sync
if (await this.get(`skip_todo_writeback:${threadId}`)) {
await this.clear(`skip_todo_writeback:${threadId}`);
return;
}

const status = link.status;

// Update local state BEFORE calling Gmail, so the webhook fired by our
// own write sees isStarred === wasStarred and doesn't re-propagate.
await this.set(`starred:${threadId}`, status === "starred");

const api = await this.getApi(channelId);

if (status === "starred") {
await api.modifyThread(threadId, ["STARRED"]);
} else if (status === "archived") {
// Archive = remove from INBOX. Also unstar.
await api.modifyThread(threadId, undefined, ["INBOX", "STARRED"]);
} else if (status === "inbox") {
// Back to inbox, unstar.
await api.modifyThread(threadId, ["INBOX"], ["STARRED"]);
}
}

/**
* Creates a new outbound email from Plot.
*
Expand DownExpand Up@@ -1761,7 +1700,7 @@ export class Gmail extends Connector<Gmail> {
source: canonicalUrl,
type: "email",
title: subject || undefined,
status: draft.status,
status: null,
created: new Date(),
sourceUrl: canonicalUrl,
channelId,
Expand All@@ -1782,7 +1721,6 @@ export class Gmail extends Connector<Gmail> {
const dedupKey = `compose:${fnv1aHex(
JSON.stringify([
draft.type,
draft.status,
subject,
body,
[...toEmails].sort(),
Expand Down
4 changes: 2 additions & 2 deletions connectors/linear/src/linear.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,9 +688,9 @@ export class Linear extends Connector<Linear> {
// statuses from the static linkTypes fallback) we look up the team's
// states directly.
let stateId: string | null = null;
if (draft.status.length > 20) {
if (draft.status && draft.status.length > 20) {
stateId = draft.status;
} else {
} else if (draft.status) {
const team = await client.team(draft.channelId);
if (team) {
const states = await team.states();
Expand Down
33 changes: 33 additions & 0 deletions connectors/slack/src/slack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,6 +39,39 @@ function makeSlack(opts: {
return new Slack("twist-instance-1" as never, toolShed as never);
}

describe("saveStarredThread", () => {
it("saves the link with todo:true and no status", async () => {
const store = makeStore({ auth_actor_id: "actor-1" });
const saveLink = vi.fn().mockResolvedValue("thread-1");
const tools = {
store,
integrations: { get: vi.fn(), saveLink },
network: { createWebhook: vi.fn() },
files: {},
};
const slack = new Slack(
"twist-instance-1" as never,
{ getTools: () => tools } as never
);

const api = {
getThread: vi.fn().mockResolvedValue([
{ ts: "111.000", thread_ts: "111.000", user: "U1", text: "hello", reactions: [] },
]),
getUser: vi.fn().mockResolvedValue(null),
};

await (slack as unknown as {
saveStarredThread: (a: unknown, c: string, t: string) => Promise<void>;
}).saveStarredThread(api, "C123", "111.000");

expect(saveLink).toHaveBeenCalledTimes(1);
const saved = saveLink.mock.calls[0][0];
expect(saved.todo).toBe(true);
expect(saved.status).toBeUndefined();
});
});

describe("setupChannelWebhook", () => {
const channelId = "C123";
const auth: Authorization = {
Expand Down
Loading
Loading