Skip to content

Commit cc3503e

Browse files
committed
feat: ux improvements for reader, reply, compose, and folder sidebar
1 parent 9e200e1 commit cc3503e

6 files changed

Lines changed: 119 additions & 15 deletions

File tree

src/main.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,16 @@ if (composeRoot) {
460460
props: {
461461
toasts,
462462
mailboxView: composeMailboxView,
463+
onSent: (result?: { archive?: boolean; queued?: boolean }) => {
464+
if (result?.archive) {
465+
const msg = get(selectedMessage);
466+
if (msg) {
467+
mailboxActions.archiveMessage(msg).catch((err) => {
468+
console.error('[Compose] Failed to archive after send:', err);
469+
});
470+
}
471+
}
472+
},
463473
registerApi: (api: typeof composeApi) => {
464474
if (api) {
465475
composeApi = api;

src/stores/settingsRegistry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@ export const SETTINGS_REGISTRY: Record<string, SettingDefinition> = {
126126
localParse: (raw) => parseBoolean(raw, true),
127127
localSerialize: (value) => serializeBoolean(Boolean(value)),
128128
},
129+
default_reply_all: {
130+
id: 'default_reply_all',
131+
label: 'Default Reply All',
132+
scope: SETTING_SCOPES.DEVICE,
133+
localKey: 'default_reply_all',
134+
valueType: 'boolean',
135+
defaultValue: false,
136+
localParse: (raw) => parseBoolean(raw, false),
137+
localSerialize: (value) => serializeBoolean(Boolean(value)),
138+
},
129139
messages_per_page: {
130140
id: 'messages_per_page',
131141
label: 'Messages Per Page',

src/svelte/Compose.svelte

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
import AlignRight from '@lucide/svelte/icons/align-right';
125125
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
126126
import ChevronDown from '@lucide/svelte/icons/chevron-down';
127+
import Archive from '@lucide/svelte/icons/archive';
127128
import RemoveFormatting from '@lucide/svelte/icons/remove-formatting';
128129
129130
interface ToastApi {
@@ -388,6 +389,7 @@
388389
let draftStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
389390
let draftStatusDetail = $state('');
390391
let replyBodyLoading = $state(false);
392+
let archiveAfterSend = $state(false);
391393
let replyBodyError = $state<string | null>(null);
392394
let pendingReplyBody = $state('');
393395
let replyPrefillData = $state<unknown>(null);
@@ -1094,6 +1096,7 @@
10941096
linkUrl = '';
10951097
showAttachmentReminderModal = false;
10961098
attachmentReminderKeyword = '';
1099+
archiveAfterSend = false;
10971100
showMobileMenu = false;
10981101
showScheduleModal = false;
10991102
showScheduleConfirm = false;
@@ -1971,8 +1974,9 @@
19711974
}
19721975
toasts?.show?.('Message queued - will send when online', 'info');
19731976
setVisible(false);
1977+
const shouldArchive = archiveAfterSend;
19741978
reset();
1975-
onSent?.({ queued: true });
1979+
onSent?.({ queued: true, archive: shouldArchive });
19761980
} catch (err) {
19771981
error = 'Failed to queue message';
19781982
toasts?.show?.(error, 'error');
@@ -2009,8 +2013,9 @@
20092013
...bccList.map((e) => ({ email: e })),
20102014
]).catch(() => {});
20112015
setVisible(false);
2016+
const shouldArchive = archiveAfterSend;
20122017
reset();
2013-
onSent?.();
2018+
onSent?.({ archive: shouldArchive });
20142019
} catch (err) {
20152020
const e = err as { message?: string; status?: number };
20162021
if (e.message?.includes('network') || e.message?.includes('fetch') || e.status === 0) {
@@ -2034,8 +2039,9 @@
20342039
}
20352040
toasts?.show?.('Network error - message queued for retry', 'warning');
20362041
setVisible(false);
2042+
const shouldArchive = archiveAfterSend;
20372043
reset();
2038-
onSent?.({ queued: true });
2044+
onSent?.({ queued: true, archive: shouldArchive });
20392045
} catch (queueErr) {
20402046
error = e?.message || 'Send failed';
20412047
toasts?.show?.(error, 'error');
@@ -2837,6 +2843,17 @@
28372843
</button>
28382844
{#if showSendDropdown}
28392845
<div class="absolute bottom-full left-0 mb-1 min-w-[160px] border border-border bg-popover p-1 shadow-lg z-[100]">
2846+
{#if inReplyTo}
2847+
<button
2848+
type="button"
2849+
class="w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none"
2850+
disabled={sending}
2851+
onclick={() => { showSendDropdown = false; archiveAfterSend = true; send(); }}
2852+
>
2853+
<Archive class="h-4 w-4" />
2854+
Send &amp; Archive
2855+
</button>
2856+
{/if}
28402857
<button
28412858
type="button"
28422859
class="w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none"

src/svelte/Mailbox.svelte

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@
173173
const isBodyPrefetchEnabled = () =>
174174
getEffectiveSettingValue('cache_prefetch_enabled') !== false;
175175

176+
const isDefaultReplyAll = () =>
177+
Boolean(getEffectiveSettingValue('default_reply_all'));
178+
176179
interface MailboxApi {
177180
open?: () => void;
178181
refresh?: () => void;
@@ -3253,7 +3256,11 @@ const stopVerticalResize = () => {
32533256

32543257
const contextReply = () => {
32553258
if (!contextMenuMessage) return;
3256-
mailboxView?.replyTo?.(contextMenuMessage);
3259+
if (isDefaultReplyAll()) {
3260+
mailboxView?.replyAll?.(contextMenuMessage);
3261+
} else {
3262+
mailboxView?.replyTo?.(contextMenuMessage);
3263+
}
32573264
closeContextMenu();
32583265
};
32593266

@@ -3412,7 +3419,7 @@ const stopVerticalResize = () => {
34123419
!readerIsDraftFolder &&
34133420
!readerIsTrashFolder &&
34143421
!readerIsSpamOrJunk);
3415-
const canReply = $derived(!readerIsSentFolder && !readerIsDraftFolder);
3422+
const canReply = $derived(!readerIsDraftFolder);
34163423
const canForward = $derived(!readerIsDraftFolder);
34173424
const canDownloadOriginal = $derived(!readerIsDraftFolder);
34183425
const canViewOriginal = $derived(!readerIsDraftFolder);
@@ -4565,19 +4572,17 @@ const stopVerticalResize = () => {
45654572
onclick={() => handleSelectFolder(folder.path)}
45664573
onkeydown={(e) => activateOnKeys(e, () => handleSelectFolder(folder.path))}
45674574
>
4568-
<span class="flex items-center gap-2 min-w-0 flex-1" style={`padding-left: ${(folder.level || 0) * 12}px`}>
4575+
<span class={`flex items-center gap-1.5 min-w-0 flex-1 ${(folder.level || 0) > 0 ? 'border-l border-border pl-1.5' : ''}`} style={`${(folder.level || 0) > 0 ? `margin-left: ${(folder.level) * 6}px` : ''}`}>
45694576
<!-- Chevron for expand/collapse -->
45704577
{#if hasChildren(folder)}
45714578
<button
45724579
type="button"
4573-
class="p-0.5 hover:bg-accent transition-colors shrink-0"
4580+
class="hover:bg-accent transition-colors shrink-0"
45744581
onclick={(e) => { e.stopPropagation(); toggleFolderExpansion(folder.path); }}
45754582
aria-label={$expandedFolders.has(folder.path) ? 'Collapse' : 'Expand'}
45764583
>
4577-
<ChevronRight class={`h-4 w-4 transition-transform ${$expandedFolders.has(folder.path) ? 'rotate-90' : ''}`} />
4584+
<ChevronRight class={`h-3.5 w-3.5 transition-transform ${$expandedFolders.has(folder.path) ? 'rotate-90' : ''}`} />
45784585
</button>
4579-
{:else if (folder.level || 0) > 0}
4580-
<span class="w-4 shrink-0" aria-hidden="true"></span>
45814586
{/if}
45824587

45834588
<svelte:component this={getFolderIcon(folder)} class="h-5 w-5 text-primary shrink-0" />
@@ -5645,7 +5650,7 @@ const stopVerticalResize = () => {
56455650
{/if}
56465651
{:else if $selectedMessage}
56475652
{#if isProductivityLayout || $mobileReader}
5648-
<div class="flex items-center gap-2 p-2 border-b border-border">
5653+
<div class="sticky top-0 z-10 bg-background flex items-center gap-2 p-2 border-b border-border">
56495654
<button
56505655
class="inline-flex items-center justify-center h-11 w-11 hover:bg-accent hover:text-accent-foreground"
56515656
type="button"
@@ -5732,7 +5737,7 @@ const stopVerticalResize = () => {
57325737
</div>
57335738
</div>
57345739
{/if}
5735-
<div class="p-4 border-b border-border">
5740+
<div class="sticky top-0 z-10 bg-background p-4 border-b border-border">
57365741
<div class="flex items-start justify-between gap-4 mb-2">
57375742
<div class="flex-1 min-w-0">
57385743
<strong class="text-lg font-semibold">{threadSubject || $selectedMessage.subject}</strong>
@@ -5768,10 +5773,10 @@ const stopVerticalResize = () => {
57685773
<button
57695774
class="inline-flex items-center justify-center h-9 w-9 hover:bg-accent hover:text-accent-foreground"
57705775
type="button"
5771-
aria-label="Reply"
5772-
data-tooltip="Reply (R)"
5776+
aria-label={isDefaultReplyAll() ? 'Reply All' : 'Reply'}
5777+
data-tooltip={isDefaultReplyAll() ? 'Reply All (R)' : 'Reply (R)'}
57735778
data-tooltip-position="bottom"
5774-
onclick={() => mailboxView?.replyTo?.($selectedMessage)}
5779+
onclick={() => isDefaultReplyAll() ? mailboxView?.replyAll?.($selectedMessage) : mailboxView?.replyTo?.($selectedMessage)}
57755780
>
57765781
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"></polyline><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path></svg>
57775782
</button>

src/svelte/Settings.svelte

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
let section = $state('general');
137137
let composePlainDefault = $state(false);
138138
let attachmentReminderEnabled = $state(false);
139+
let defaultReplyAll = $state(false);
139140
let messagesPerPage = $state(20);
140141
let archiveFolder = $state('');
141142
let sentFolder = $state('');
@@ -453,6 +454,9 @@
453454
composePlainDefault = Boolean(
454455
getEffectiveSettingValue('compose_plain_default', { account: currentAcct }),
455456
);
457+
defaultReplyAll = Boolean(
458+
getEffectiveSettingValue('default_reply_all', { account: currentAcct }),
459+
);
456460
messagesPerPage = Number.parseInt(
457461
getEffectiveSettingValue('messages_per_page', { account: currentAcct }) || '20',
458462
10,
@@ -782,6 +786,20 @@
782786
}
783787
};
784788
789+
const saveDefaultReplyAll = async () => {
790+
try {
791+
await setSettingValue('default_reply_all', defaultReplyAll, {
792+
account: getAccountId(),
793+
});
794+
toasts?.show?.(
795+
defaultReplyAll ? 'Reply All set as default' : 'Reply set as default',
796+
'success',
797+
);
798+
} catch (err) {
799+
toasts?.show?.((err as Error)?.message || 'Failed to save reply default', 'error');
800+
}
801+
};
802+
785803
const toggleBlockRemoteImages = () => {
786804
try {
787805
setSettingValue('block_remote_images', blockRemoteImages, { account: getAccountId() });
@@ -1297,6 +1315,13 @@
12971315
<p class="text-sm text-muted-foreground">
12981316
Get a reminder if you mention attachments but forget to add them.
12991317
</p>
1318+
<label class="flex items-center gap-3">
1319+
<Checkbox bind:checked={defaultReplyAll} onCheckedChange={saveDefaultReplyAll} />
1320+
<span>Reply All by default</span>
1321+
</label>
1322+
<p class="text-sm text-muted-foreground">
1323+
Use Reply All as the default reply action instead of Reply.
1324+
</p>
13001325
</Card.Content>
13011326
</Card.Root>
13021327

src/utils/address.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,46 @@ export interface MessageWithHeaders {
5252

5353
type AddressInput = string | AddressObject | AddressObject[] | null | undefined;
5454

55+
/**
56+
* Split a comma-separated address string into individual addresses,
57+
* respecting quoted strings and angle brackets.
58+
* e.g. "jake@x.com, Vicky <vicky@x.com>" → ["jake@x.com", "Vicky <vicky@x.com>"]
59+
*/
60+
const splitAddressString = (str: string): string[] => {
61+
const parts: string[] = [];
62+
let current = '';
63+
let inQuotes = false;
64+
let inAngle = 0;
65+
for (let i = 0; i < str.length; i++) {
66+
const ch = str[i];
67+
if (ch === '"' && str[i - 1] !== '\\') {
68+
inQuotes = !inQuotes;
69+
current += ch;
70+
} else if (!inQuotes && ch === '<') {
71+
inAngle++;
72+
current += ch;
73+
} else if (!inQuotes && ch === '>' && inAngle > 0) {
74+
inAngle--;
75+
current += ch;
76+
} else if (!inQuotes && inAngle === 0 && ch === ',') {
77+
const trimmed = current.trim();
78+
if (trimmed) parts.push(trimmed);
79+
current = '';
80+
} else {
81+
current += ch;
82+
}
83+
}
84+
const trimmed = current.trim();
85+
if (trimmed) parts.push(trimmed);
86+
return parts;
87+
};
88+
5589
export const recipientsToList = (value: AddressInput): (string | AddressObject)[] => {
5690
if (!value) return [];
5791
if (Array.isArray(value)) return value.filter(Boolean);
92+
if (typeof value === 'string' && value.includes(',')) {
93+
return splitAddressString(value).filter(Boolean);
94+
}
5895
return [value].filter(Boolean);
5996
};
6097

0 commit comments

Comments
 (0)