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**
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -67,6 +67,8 @@ export default function Home() {
|
||||
toggleStar,
|
||||
moveToMailbox,
|
||||
searchEmails,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
isLoading,
|
||||
isLoadingEmail,
|
||||
setLoadingEmail,
|
||||
@@ -469,8 +471,13 @@ export default function Home() {
|
||||
}
|
||||
|
||||
if (client) {
|
||||
// If there's an active search, re-run it in the new mailbox
|
||||
if (searchQuery) {
|
||||
await searchEmails(client, searchQuery);
|
||||
} else {
|
||||
await fetchEmails(client, mailboxId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -483,6 +490,13 @@ export default function Home() {
|
||||
await searchEmails(client, query);
|
||||
};
|
||||
|
||||
const handleClearSearch = async () => {
|
||||
setSearchQuery("");
|
||||
if (client && selectedMailbox) {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -645,6 +659,8 @@ export default function Home() {
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
onSearch={handleSearch}
|
||||
onClearSearch={handleClearSearch}
|
||||
activeSearchQuery={searchQuery}
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
/>
|
||||
|
||||
@@ -60,6 +60,7 @@ export function EmailList({
|
||||
loadMoreEmails,
|
||||
hasMoreEmails,
|
||||
isLoadingMore,
|
||||
totalEmails,
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
expandedThreadIds,
|
||||
@@ -260,7 +261,13 @@ export function EmailList({
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ChevronUp,
|
||||
Users,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
@@ -37,6 +38,8 @@ interface SidebarProps {
|
||||
onCompose?: () => void;
|
||||
onLogout?: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
onClearSearch?: () => void;
|
||||
activeSearchQuery?: string;
|
||||
quota?: { used: number; total: number } | null;
|
||||
isPushConnected?: boolean;
|
||||
className?: string;
|
||||
@@ -206,6 +209,8 @@ export function Sidebar({
|
||||
onCompose,
|
||||
onLogout,
|
||||
onSearch,
|
||||
onClearSearch,
|
||||
activeSearchQuery = "",
|
||||
quota,
|
||||
isPushConnected = false,
|
||||
className,
|
||||
@@ -215,6 +220,11 @@ export function Sidebar({
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
// Sync local search query with store's active search query
|
||||
useEffect(() => {
|
||||
setSearchQuery(activeSearchQuery);
|
||||
}, [activeSearchQuery]);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -334,9 +344,22 @@ export function Sidebar({
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
className={cn("pl-9", searchQuery && "pr-8")}
|
||||
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>
|
||||
</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 {
|
||||
// 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([
|
||||
["Email/query", {
|
||||
accountId: this.accountId,
|
||||
filter: {
|
||||
text: query,
|
||||
},
|
||||
accountId: targetAccountId,
|
||||
filter: filter,
|
||||
sort: [{ property: "receivedAt", isAscending: false }],
|
||||
limit: limit,
|
||||
position: position,
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
accountId: this.accountId,
|
||||
accountId: targetAccountId,
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Email/query",
|
||||
@@ -788,14 +796,21 @@ export class JMAPClient {
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "Email/get") {
|
||||
return response.methodResponses[1][1].list || [];
|
||||
}
|
||||
const queryResponse = response.methodResponses?.[0]?.[1];
|
||||
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) {
|
||||
console.error('Search failed:', error);
|
||||
return [];
|
||||
return { emails: [], hasMore: false, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 154 KiB |
+37
-7
@@ -200,13 +200,29 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
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
|
||||
if (isLoadingMore || !hasMoreEmails) return;
|
||||
|
||||
set({ isLoadingMore: true, error: null });
|
||||
try {
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
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);
|
||||
@@ -215,10 +231,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
|
||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||
|
||||
// Get emails per page from settings
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
|
||||
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
|
||||
}
|
||||
|
||||
set({
|
||||
emails: [...emails, ...result.emails],
|
||||
@@ -565,8 +579,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
searchEmails: async (client, query) => {
|
||||
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
|
||||
try {
|
||||
const emails = await client.searchEmails(query);
|
||||
set({ emails, isLoading: false, hasMoreEmails: false, totalEmails: emails.length });
|
||||
// Get the current mailbox to scope the search
|
||||
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) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||
|
||||
Reference in New Issue
Block a user