fix: give user folders a folder icon even when their name looks like a system folder #80

This commit is contained in:
Linus Rath
2026-10-29 03:50:08 +01:00
parent ada20598d4
commit 96c7c0455d
3 changed files with 89 additions and 37 deletions
+25 -37
View File
@@ -46,6 +46,7 @@ import {
type NotificationMutes, type NotificationMutes,
} from '../lib/notification-mutes'; } from '../lib/notification-mutes';
import { serverSupportsEmailPush } from '../lib/push-notifications'; import { serverSupportsEmailPush } from '../lib/push-notifications';
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
const CHEVRON_SLOT = 20; const CHEVRON_SLOT = 20;
const INDENT_STEP = 12; const INDENT_STEP = 12;
@@ -64,41 +65,28 @@ const UNIFIED_ROLES: UnifiedRole[] = ['sent', 'drafts', 'archive', 'junk', 'tras
// in for them (#495). // in for them (#495).
const HIDDEN_WITH_VIRTUAL_ROW = new Set(['scheduled']); const HIDDEN_WITH_VIRTUAL_ROW = new Set(['scheduled']);
// Distinct icons for the special-use roles (#288), then a name-based guess // Distinct icons for the special-use roles (#288), else a plain folder: by
// for servers that don't set roles, then a plain folder. // role only, never by name (lib/mailbox-icon.ts, #80).
function iconFor( const ICONS: Record<MailboxIconKind, AppIcon> = {
role: string | null | undefined, inbox: Inbox,
name: string | undefined, sent: Send,
hasChildren: boolean, drafts: FileIcon,
isExpanded: boolean, trash: Trash2,
): AppIcon { junk: Ban,
switch (role) { archive: Archive,
case 'inbox': return Inbox; important: Flag,
case 'sent': return Send; flagged: Star,
case 'drafts': return FileIcon; all: Mails,
case 'trash': return Trash2; memos: StickyNote,
case 'junk': scheduled: Clock,
case 'spam': return Ban; snoozed: AlarmClock,
case 'archive': return Archive; shared: Users,
case 'important': return Flag; folder: Folder,
case 'flagged': return Star; 'folder-open': FolderOpen,
case 'all': return Mails; };
case 'memos': return StickyNote;
case 'scheduled': return Clock; function iconFor(role: string | null | undefined, hasChildren: boolean, isExpanded: boolean): AppIcon {
case 'snoozed': return AlarmClock; return ICONS[mailboxIconKind(role, hasChildren, isExpanded)];
case 'shared': return Users;
default: break;
}
const lower = (name ?? '').toLowerCase();
if (lower.includes('inbox')) return Inbox;
if (lower.includes('sent')) return Send;
if (lower.includes('draft')) return FileIcon;
if (lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (lower.includes('junk') || lower.includes('spam')) return Ban;
if (lower.includes('archive')) return Archive;
if (lower.includes('star') || lower.includes('flag')) return Star;
if (hasChildren) return isExpanded ? FolderOpen : Folder;
return Folder;
} }
const ROLE_COLOR_FIXED: Record<string, string> = { const ROLE_COLOR_FIXED: Record<string, string> = {
@@ -745,7 +733,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
}; };
}, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]); }, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]);
const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, undefined, false, false); const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, false, false);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -1139,7 +1127,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
// tapping it only opens or closes that account's folders. // tapping it only opens or closes that account's folders.
const Icon = node.isAccountNode const Icon = node.isAccountNode
? Users ? Users
: iconFor(node.role, node.name, hasChildren, isExpanded); : iconFor(node.role, hasChildren, isExpanded);
const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId; const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId;
return ( return (
<SidebarRow <SidebarRow
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
import { mailboxIconKind } from '../mailbox-icon';
describe('mailboxIconKind', () => {
it('gives each special-use role its icon', () => {
expect(mailboxIconKind('sent', false, false)).toBe('sent');
expect(mailboxIconKind('junk', false, false)).toBe('junk');
expect(mailboxIconKind('spam', false, false)).toBe('junk');
expect(mailboxIconKind('trash', true, true)).toBe('trash');
});
// native #80: a sieve-sorted folder "web/sentry" got the Sent icon.
it('gives a user folder a folder icon whatever its name says', () => {
for (const name of ['sentry', 'Sent Items', 'Drafts old', 'memory', 'spam-reports', 'archive 2019']) {
expect(mailboxIconKind(null, false, false), name).toBe('folder');
}
expect(mailboxIconKind(undefined, true, true)).toBe('folder-open');
expect(mailboxIconKind(null, true, false)).toBe('folder');
});
it('is what the sidebar uses, with no guess from the folder name', () => {
const source = readFileSync(path.join(process.cwd(), 'src/components/SidebarDrawer.tsx'), 'utf8');
expect(source).toContain('mailboxIconKind(');
expect(source).not.toMatch(/lower\.includes\('(?:sent|inbox|draft|trash|junk|archive|star)'\)/);
});
});
+36
View File
@@ -0,0 +1,36 @@
// Which icon a folder in the sidebar gets: one per special-use role (#288),
// else a plain folder. By role only, as the webmail does
// (components/layout/sidebar.tsx): a guess from the name gave a user folder
// "web/sentry" the Sent icon (native #80, webmail #1235), and a folder that
// holds no sent mail must not look like one that does.
export type MailboxIconKind =
| 'inbox' | 'sent' | 'drafts' | 'trash' | 'junk' | 'archive' | 'important' | 'flagged'
| 'all' | 'memos' | 'scheduled' | 'snoozed' | 'shared' | 'folder' | 'folder-open';
const ROLE_ICONS: Record<string, MailboxIconKind> = {
inbox: 'inbox',
sent: 'sent',
drafts: 'drafts',
trash: 'trash',
junk: 'junk',
spam: 'junk',
archive: 'archive',
important: 'important',
flagged: 'flagged',
all: 'all',
memos: 'memos',
scheduled: 'scheduled',
snoozed: 'snoozed',
shared: 'shared',
};
export function mailboxIconKind(
role: string | null | undefined,
hasChildren: boolean,
isExpanded: boolean,
): MailboxIconKind {
const byRole = role ? ROLE_ICONS[role] : undefined;
if (byRole) return byRole;
return hasChildren && isExpanded ? 'folder-open' : 'folder';
}