feat: Search pagination and UX improvements
- Add pagination support to search (previously limited to 50 results) - Search now scoped to current mailbox/folder - Support shared mailbox folders in search - Display total results count (e.g., "50 of 1400 conversations") - Add clear (X) button to search input - Re-run search when changing folders during active search - Remove unused sidebar screenshot
This commit is contained in:
@@ -183,14 +183,6 @@ npm start
|
|||||||
**Settings**
|
**Settings**
|
||||||
<img src="screenshots/06-settings.png" width="100%" alt="Settings">
|
<img src="screenshots/06-settings.png" width="100%" alt="Settings">
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2">
|
|
||||||
|
|
||||||
**Sidebar Navigation**
|
|
||||||
<img src="screenshots/07-inbox-sidebar.png" width="50%" alt="Sidebar">
|
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+17
-1
@@ -67,6 +67,8 @@ export default function Home() {
|
|||||||
toggleStar,
|
toggleStar,
|
||||||
moveToMailbox,
|
moveToMailbox,
|
||||||
searchEmails,
|
searchEmails,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
isLoading,
|
isLoading,
|
||||||
isLoadingEmail,
|
isLoadingEmail,
|
||||||
setLoadingEmail,
|
setLoadingEmail,
|
||||||
@@ -469,7 +471,12 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
await fetchEmails(client, mailboxId);
|
// If there's an active search, re-run it in the new mailbox
|
||||||
|
if (searchQuery) {
|
||||||
|
await searchEmails(client, searchQuery);
|
||||||
|
} else {
|
||||||
|
await fetchEmails(client, mailboxId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -483,6 +490,13 @@ export default function Home() {
|
|||||||
await searchEmails(client, query);
|
await searchEmails(client, query);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearSearch = async () => {
|
||||||
|
setSearchQuery("");
|
||||||
|
if (client && selectedMailbox) {
|
||||||
|
await fetchEmails(client, selectedMailbox);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
@@ -645,6 +659,8 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
|
onClearSearch={handleClearSearch}
|
||||||
|
activeSearchQuery={searchQuery}
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function EmailList({
|
|||||||
loadMoreEmails,
|
loadMoreEmails,
|
||||||
hasMoreEmails,
|
hasMoreEmails,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
|
totalEmails,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
selectedMailbox,
|
selectedMailbox,
|
||||||
expandedThreadIds,
|
expandedThreadIds,
|
||||||
@@ -260,7 +261,13 @@ export function EmailList({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-sm font-medium text-foreground">
|
<h2 className="text-sm font-medium text-foreground">
|
||||||
{isLoading ? 'Loading...' : threadGroups.length > 0 ? `${threadGroups.length} conversations` : 'No conversations'}
|
{isLoading ? 'Loading...' : threadGroups.length > 0
|
||||||
|
? (totalEmails > threadGroups.length
|
||||||
|
? `${threadGroups.length} of ${totalEmails} conversations`
|
||||||
|
: hasMoreEmails
|
||||||
|
? `${threadGroups.length}+ conversations`
|
||||||
|
: `${threadGroups.length} conversations`)
|
||||||
|
: 'No conversations'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
Users,
|
Users,
|
||||||
User,
|
User,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
@@ -37,6 +38,8 @@ interface SidebarProps {
|
|||||||
onCompose?: () => void;
|
onCompose?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
|
onClearSearch?: () => void;
|
||||||
|
activeSearchQuery?: string;
|
||||||
quota?: { used: number; total: number } | null;
|
quota?: { used: number; total: number } | null;
|
||||||
isPushConnected?: boolean;
|
isPushConnected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -206,6 +209,8 @@ export function Sidebar({
|
|||||||
onCompose,
|
onCompose,
|
||||||
onLogout,
|
onLogout,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
onClearSearch,
|
||||||
|
activeSearchQuery = "",
|
||||||
quota,
|
quota,
|
||||||
isPushConnected = false,
|
isPushConnected = false,
|
||||||
className,
|
className,
|
||||||
@@ -215,6 +220,11 @@ export function Sidebar({
|
|||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
|
|
||||||
|
// Sync local search query with store's active search query
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchQuery(activeSearchQuery);
|
||||||
|
}, [activeSearchQuery]);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -334,9 +344,22 @@ export function Sidebar({
|
|||||||
placeholder={t("search_placeholder")}
|
placeholder={t("search_placeholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-9"
|
className={cn("pl-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
/>
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
onClearSearch?.();
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+26
-11
@@ -753,19 +753,27 @@ export class JMAPClient {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchEmails(query: string, limit: number = 50): Promise<Email[]> {
|
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||||
try {
|
try {
|
||||||
|
// Use provided accountId or fallback to primary account
|
||||||
|
const targetAccountId = accountId || this.accountId;
|
||||||
|
|
||||||
|
// Build filter with text search, optionally scoped to a mailbox
|
||||||
|
const filter: Record<string, unknown> = { text: query };
|
||||||
|
if (mailboxId) {
|
||||||
|
filter.inMailbox = mailboxId;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Email/query", {
|
["Email/query", {
|
||||||
accountId: this.accountId,
|
accountId: targetAccountId,
|
||||||
filter: {
|
filter: filter,
|
||||||
text: query,
|
|
||||||
},
|
|
||||||
sort: [{ property: "receivedAt", isAscending: false }],
|
sort: [{ property: "receivedAt", isAscending: false }],
|
||||||
limit: limit,
|
limit: limit,
|
||||||
|
position: position,
|
||||||
}, "0"],
|
}, "0"],
|
||||||
["Email/get", {
|
["Email/get", {
|
||||||
accountId: this.accountId,
|
accountId: targetAccountId,
|
||||||
"#ids": {
|
"#ids": {
|
||||||
resultOf: "0",
|
resultOf: "0",
|
||||||
name: "Email/query",
|
name: "Email/query",
|
||||||
@@ -788,14 +796,21 @@ export class JMAPClient {
|
|||||||
}, "1"],
|
}, "1"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (response.methodResponses?.[1]?.[0] === "Email/get") {
|
const queryResponse = response.methodResponses?.[0]?.[1];
|
||||||
return response.methodResponses[1][1].list || [];
|
const emails = response.methodResponses?.[1]?.[1]?.list || [];
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
// Stalwart doesn't always return 'total', so we use a different strategy:
|
||||||
|
// If we got exactly 'limit' emails, there might be more
|
||||||
|
// If we got fewer, we've reached the end
|
||||||
|
const total = queryResponse?.total || 0;
|
||||||
|
const hasMore = total > 0
|
||||||
|
? (position + emails.length) < total // Use total if available
|
||||||
|
: emails.length === limit; // Otherwise, check if we got a full page
|
||||||
|
|
||||||
|
return { emails, hasMore, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
return [];
|
return { emails: [], hasMore: false, total: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 154 KiB |
+42
-12
@@ -200,25 +200,39 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadMoreEmails: async (client) => {
|
loadMoreEmails: async (client) => {
|
||||||
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox } = get();
|
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery } = get();
|
||||||
|
|
||||||
// Don't load if already loading or no more emails
|
// Don't load if already loading or no more emails
|
||||||
if (isLoadingMore || !hasMoreEmails) return;
|
if (isLoadingMore || !hasMoreEmails) return;
|
||||||
|
|
||||||
set({ isLoadingMore: true, error: null });
|
set({ isLoadingMore: true, error: null });
|
||||||
try {
|
try {
|
||||||
// Find the mailbox to get its accountId (for shared folder support)
|
|
||||||
const mailboxes = get().mailboxes;
|
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
|
||||||
// Only pass accountId for shared mailboxes, not for primary account
|
|
||||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
|
||||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
|
||||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
|
||||||
|
|
||||||
// Get emails per page from settings
|
// Get emails per page from settings
|
||||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
|
||||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
|
let result;
|
||||||
|
|
||||||
|
// Check if we're in search mode
|
||||||
|
if (searchQuery) {
|
||||||
|
// Load more search results (scoped to current mailbox)
|
||||||
|
const mailboxes = get().mailboxes;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
|
// Only pass accountId for shared mailboxes
|
||||||
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
|
||||||
|
} else {
|
||||||
|
// Load more from mailbox
|
||||||
|
// Find the mailbox to get its accountId (for shared folder support)
|
||||||
|
const mailboxes = get().mailboxes;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
// Only pass accountId for shared mailboxes, not for primary account
|
||||||
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||||
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
|
|
||||||
|
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
emails: [...emails, ...result.emails],
|
emails: [...emails, ...result.emails],
|
||||||
@@ -565,8 +579,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
searchEmails: async (client, query) => {
|
searchEmails: async (client, query) => {
|
||||||
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
|
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
|
||||||
try {
|
try {
|
||||||
const emails = await client.searchEmails(query);
|
// Get the current mailbox to scope the search
|
||||||
set({ emails, isLoading: false, hasMoreEmails: false, totalEmails: emails.length });
|
const selectedMailbox = get().selectedMailbox;
|
||||||
|
const mailboxes = get().mailboxes;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
// Use originalId for shared mailboxes
|
||||||
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
|
// Only pass accountId for shared mailboxes, not for primary account
|
||||||
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
|
||||||
|
// Get emails per page from settings
|
||||||
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
|
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
||||||
|
set({
|
||||||
|
emails: result.emails,
|
||||||
|
hasMoreEmails: result.hasMore,
|
||||||
|
totalEmails: result.total,
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||||
|
|||||||
Reference in New Issue
Block a user