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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 50 additions & 19 deletions js/app/packages/block-email/component/BaseInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,10 @@ export function BaseInput(props: {

// Register a callback so stale undoSend closures from a previous mount can
// restore state into this (the live) component instance.
restoreUndoCallback = (snapshot, draftId) => {
const ownRestoreUndoCallback: typeof restoreUndoCallback = (
snapshot,
draftId
) => {
setSavedDraftId(draftId);
const currentEditor = editor();
if (currentEditor && snapshot.bodyHtml) {
Expand All @@ -511,8 +514,14 @@ export function BaseInput(props: {
form().attachments.add(attachment);
}
};
restoreUndoCallback = ownRestoreUndoCallback;
onCleanup(() => {
restoreUndoCallback = null;
// Multiple BaseInputs can be mounted at once (bottom input + inline
// reply); only clear the slot if this instance still owns it so
// unmounting one doesn't break the other's undo-send restore.
if (restoreUndoCallback === ownRestoreUndoCallback) {
restoreUndoCallback = null;
}
});

let pendingMentions: { documentId: string }[] = [];
Expand Down Expand Up @@ -626,8 +635,11 @@ export function BaseInput(props: {
}
}

// Attach side-effect handlers on mount; they replay against current state
onMount(() => {
// Attach side-effect handlers; they replay against current state. Runs in
// an effect (not onMount) because form() re-keys when the reply context
// changes (e.g. after a send) and the new form instance needs the
// callbacks too.
createEffect(() => {
form().setOnDirty(() => {
scheduleDraftSave();
});
Expand Down Expand Up @@ -944,7 +956,11 @@ export function BaseInput(props: {
return;
}

let linkId: string | undefined = currentThread?.link_id;
// Same precedence as activeLinkId(): a user-selected "from" inbox wins
// over the thread's inbox, so sends go out from the inbox shown in the UI
// (and match where persistDraftOnSenderSwitch saved the draft).
let linkId: string | undefined =
form().selectedLinkId() ?? currentThread?.link_id ?? props.draft?.link_id;
if (newMessage || !linkId) {
if (emailLinksQuery.isPending) {
toast.alert('Loading email accounts...');
Expand All @@ -963,7 +979,7 @@ export function BaseInput(props: {
logger.error('No links found');
return;
}
linkId = primaryLinkId() ?? linksData.links[0].id;
linkId = linkId ?? primaryLinkId() ?? linksData.links[0].id;
}

const currentEditor = editor();
Expand Down Expand Up @@ -1324,27 +1340,41 @@ export function BaseInput(props: {
// Ensure draft is saved before scheduling
const draftID = currentDraft ?? (await executeSaveDraft());
if (!draftID) {
// Clear the send time so a failed schedule doesn't leave the
// composer stuck in a phantom "scheduled" state with Send disabled
form().setSendTime(null);
toast.failure('Failed to schedule message', {
subtext: 'Draft required',
});
return;
}

await emailClient.scheduleMessage(
{
draftID,
send_time: date.toISOString(),
},
headerLinkId()
);
try {
await emailClient.scheduleMessage(
{
draftID,
send_time: date.toISOString(),
},
headerLinkId()
);
} catch (error) {
form().setSendTime(null);
logger.error(error);
toast.failure('Failed to schedule message');
return;
}

// Mark the thread as done
const threadID = ctx.thread()?.db_id;
if (threadID) {
await emailClient.flagArchived(
{ id: threadID, value: true },
headerLinkId()
);
try {
await emailClient.flagArchived(
{ id: threadID, value: true },
headerLinkId()
);
} catch (error) {
logger.error(error);
}
}
}
};
Expand Down Expand Up @@ -1798,8 +1828,9 @@ export function BaseInput(props: {
size="icon-sm"
pressed={form().replyAppended()}
onChange={() => {
const replyingToID = props.replyingTo()?.replying_to_id;
if (!replyingToID) return;
// Guard on db_id: a thread's root message has no
// replying_to_id but its quoted text can still be toggled
if (!props.replyingTo()?.db_id) return;

const currentlyAppended = form().replyAppended();
form().setReplyAppended(!currentlyAppended);
Expand Down
7 changes: 4 additions & 3 deletions js/app/packages/block-email/component/Block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ export default function BlockEmail() {
const title = () => {
const data = threadQuery.data;
if (!data || !data.thread || data.thread.messages.length === 0) return '';
if (data.thread.messages[0].subject?.length === 0) return '[No subject]';
const subject = data.thread.messages[0].subject;
if (!subject) return '[No subject]';
// remove "re:" prefix(es)
return data.thread.messages[0].subject!.replace(/^(re:\s*)+/i, '');
return subject.replace(/^(re:\s*)+/i, '');
};

return (
<Suspense>
<DocumentBlockContainer title={title() ?? 'Email'}>
<DocumentBlockContainer title={title() || 'Email'}>
<div class="size-full" tabIndex={-1}>
<Show when={blockData()}>
<Show when={threadId()}>
Expand Down
43 changes: 20 additions & 23 deletions js/app/packages/block-email/component/Email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
createMemo,
createSignal,
Match,
onCleanup,
onMount,
Show,
Switch,
Expand Down Expand Up @@ -80,13 +81,18 @@ function EmailContent(props: EmailViewProps) {
setIsScrolled(scrollFromTop > 1);
};

let disposed = false;
onCleanup(() => {
disposed = true;
});

/**
* Waits for the query to finish fetching
*/
const waitForQueryLoad = (): Promise<void> => {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!context.query.isFetching()) {
if (disposed || !context.query.isFetching()) {
clearInterval(checkInterval);
resolve();
}
Expand All @@ -100,7 +106,7 @@ function EmailContent(props: EmailViewProps) {
const loadMessagesUntilFound = async (
targetMessageId: string
): Promise<boolean> => {
while (true) {
while (!disposed) {
const messages = context.messages.unfiltered();

// Check if message exists in current batch
Expand All @@ -117,6 +123,7 @@ function EmailContent(props: EmailViewProps) {
context.query.fetchNextPage();
await waitForQueryLoad();
}
return false;
};

/**
Expand Down Expand Up @@ -187,21 +194,12 @@ function EmailContent(props: EmailViewProps) {
performScrollToMessage(lastMessage.db_id, { behavior, focus });
};

// context.messages.list() is already sorted oldest-first (see EmailContext)
const firstUnreadMessageId = createMemo(() => {
const messages = context.messages.list().toSorted((a, b) => {
if (a.internal_date_ts && b.internal_date_ts) {
return (
new Date(a.internal_date_ts).getTime() -
new Date(b.internal_date_ts).getTime()
);
} else if (a.sent_at && b.sent_at) {
return new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime();
}
return 0;
});
return messages?.find((m) =>
m.labels.some((l) => l.provider_label_id === 'UNREAD')
)?.db_id;
return context.messages
.list()
.find((m) => m.labels.some((l) => l.provider_label_id === 'UNREAD'))
?.db_id;
});

const canRunInitialEmailScroll = () =>
Expand All @@ -220,8 +218,6 @@ function EmailContent(props: EmailViewProps) {
// Check for target message
const targetMessageId_ = context.messages.targetMessageID();

if (targetMessageId_ && typeof targetMessageId_ !== 'string') return true;

if (targetMessageId_) {
handleTargetMessage(targetMessageId_);
} else {
Expand Down Expand Up @@ -279,11 +275,12 @@ function EmailContent(props: EmailViewProps) {
performScrollToMessage(messageId, { behavior: 'instant' })
);
}

// Case 3: Message is in current batch with sufficient context
setTimeout(() =>
performScrollToMessage(messageId, { behavior: 'instant' })
);
else {
setTimeout(() =>
performScrollToMessage(messageId, { behavior: 'instant' })
);
}
}

// If there is a focused message id, but it does not currently exist in the message list, it is because the user has just sent a message. When it does come into existence, we want to scroll to the bottom.
Expand Down Expand Up @@ -527,7 +524,7 @@ function EmailContent(props: EmailViewProps) {
title={props.title}
isDraft={
emailReplyInfo()?.replyingTo == null &&
emailReplyInfo()?.draft !== null
emailReplyInfo()?.draft != null
}
/>
<div
Expand Down
34 changes: 10 additions & 24 deletions js/app/packages/block-email/component/EmailContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,22 +124,16 @@ export function EmailProvider(props: FlowProps<{ threadID: string }>) {
select(data) {
const messages = data.pages.flatMap((t) => t.messages);

// Sort all messages by recency
messages.sort((a, b) => {
if (a.internal_date_ts && b.internal_date_ts) {
return (
new Date(a.internal_date_ts).getTime() -
new Date(b.internal_date_ts).getTime()
);
}
// Below is fallback for when internal_date_ts is not set
else if (a.sent_at && b.sent_at) {
return (
new Date(a.sent_at).getTime() - new Date(b.sent_at).getTime()
);
}
return 0;
});
// Sort all messages by recency, falling back to sent_at when
// internal_date_ts is not set. Comparing per-message keys (instead of
// requiring both messages to have the same field) keeps the
// comparator transitive when timestamp availability is mixed.
const messageTime = (m: ApiMessage) => {
const ts = m.internal_date_ts ?? m.sent_at;
// Messages with no timestamp (e.g. fresh drafts) sort newest
return ts ? new Date(ts).getTime() : Number.POSITIVE_INFINITY;
};
messages.sort((a, b) => messageTime(a) - messageTime(b));

const filtered = [];
const messageDraftMap: Record<string, ApiMessage> = {};
Expand Down Expand Up @@ -341,14 +335,6 @@ export function EmailProvider(props: FlowProps<{ threadID: string }>) {

if (!thread?.db_id) return false;

archiveMutation.mutate({
threadId: thread.db_id,
archive: thread.inbox_visible,
linkId: toHeaderLinkId(thread.link_id),
});

if (!props) return false;

const selectedRow = soup?.items.get(thread.db_id);

if (selectedRow) {
Expand Down
14 changes: 6 additions & 8 deletions js/app/packages/block-email/component/EmailMessageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,11 @@ export function EmailMessageBody(props: EmailMessageBodyProps) {
const styleTags = Array.from(doc.head?.querySelectorAll('style') ?? [])
.map((style) => style.outerHTML)
.join('\n');
const quoted = doc.body.querySelector('.macro_quote');
if (quoted) {
quoted?.remove();
return styleTags
? `${styleTags}\n${doc.body.innerHTML}`
: doc.body.innerHTML;
}
// If there is no quoted reply, the whole message is the replyless body
doc.body.querySelector('.macro_quote')?.remove();
return styleTags
? `${styleTags}\n${doc.body.innerHTML}`
: doc.body.innerHTML;
}
}
return replyless;
Expand Down Expand Up @@ -327,7 +325,7 @@ export function EmailMessageBody(props: EmailMessageBodyProps) {
</Match>
<Match when={isPlaintext()}>
<StaticMarkdown
markdown={props.message.body_text!}
markdown={props.message.body_text ?? ''}
theme={channelTheme}
target="internal"
/>
Expand Down
4 changes: 2 additions & 2 deletions js/app/packages/block-email/component/MessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export function MessageActions(props: {
const canShowActions = () => {
if (!props.showActions) return false;

const allActionsHidden = props.hiddenActions?.every((a) =>
EMAIL_MESSAGE_ACTIONS.includes(a)
const allActionsHidden = EMAIL_MESSAGE_ACTIONS.every((a) =>
props.hiddenActions?.includes(a)
);

return !allActionsHidden;
Expand Down
36 changes: 25 additions & 11 deletions js/app/packages/block-email/component/compose/Compose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -496,26 +496,40 @@ export function EmailCompose(props: EmailComposeProps) {
if (date) {
const draftID = currentDraft ?? (await executeSaveDraft());
if (!draftID) {
// Clear the send time so a failed schedule doesn't leave the
// composer stuck in a phantom "scheduled" state with Send disabled
form.setSendTime(null);
toast.failure('Failed to schedule message', {
subtext: 'Draft required',
});
return;
}

await emailClient.scheduleMessage(
{
draftID,
send_time: date.toISOString(),
},
headerLinkId()
);
try {
await emailClient.scheduleMessage(
{
draftID,
send_time: date.toISOString(),
},
headerLinkId()
);
} catch (error) {
form.setSendTime(null);
logger.error(error);
toast.failure('Failed to schedule message');
return;
}

const threadID = saveDraftMutation.data?.draft.thread_db_id;
if (threadID) {
await emailClient.flagArchived(
{ id: threadID, value: true },
headerLinkId()
);
try {
await emailClient.flagArchived(
{ id: threadID, value: true },
headerLinkId()
);
} catch (error) {
logger.error(error);
}
}
}
};
Expand Down
Loading
Loading