refactor(list-messages): switch from page-based to cursor-based pagination

- Changed `list-messages` API from page/page_size to cursor/next_page_token
- Aligns pagination approach with Gmail API for easier integration
- Frontend updates to handle cursor-based navigation
This commit is contained in:
rustmailer
2025-09-28 07:39:40 +08:00
parent 08ef77c23c
commit 16c84abe67
15 changed files with 401 additions and 145 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
*/
export interface PaginatedResponse<S> {
current_page: number | null;
next_page_token: string | null;
page_size: number | null;
total_items: number;
items: S[];
+30 -4
View File
@@ -9,13 +9,39 @@ import axiosInstance from "@/api/axiosInstance";
import { EmailEnvelope } from "@/features/mailbox/data/schema";
import { saveAs } from 'file-saver';
export const list_messages = async (accountId: number, mailbox: string, page: number, page_size: number, remote: boolean) => {
const response = await axiosInstance.get<PaginatedResponse<EmailEnvelope>>(`/api/v1/list-messages/${accountId}?mailbox=${mailbox}&page=${page}&page_size=${page_size}&desc=true&remote=${remote}`);
export const list_messages = async (accountId: number, mailbox: string, page_size: number, remote: boolean, next_page_token?: string) => {
const params = new URLSearchParams({
mailbox,
page_size: String(page_size),
desc: "true",
remote: String(remote),
});
if (next_page_token) {
params.append("next_page_token", next_page_token);
}
const response = await axiosInstance.get<PaginatedResponse<EmailEnvelope>>(
`/api/v1/list-messages/${accountId}?${params.toString()}`
);
return response.data;
};
export const search_messages = async (accountId: number, page: number, page_size: number, remote: boolean, data: Record<string, any>) => {
const response = await axiosInstance.post<PaginatedResponse<EmailEnvelope>>(`/api/v1/search-message/${accountId}?page=${page}&page_size=${page_size}&desc=true&remote=${remote}`, data);
export const search_messages = async (accountId: number, page_size: number, remote: boolean, data: Record<string, any>, next_page_token?: string) => {
const params = new URLSearchParams({
page_size: String(page_size),
desc: "true",
remote: String(remote),
});
if (next_page_token) {
params.append("next_page_token", next_page_token);
}
const response = await axiosInstance.post<PaginatedResponse<EmailEnvelope>>(
`/api/v1/search-message/${accountId}?${params.toString()}`,
data
);
return response.data;
};
@@ -68,7 +68,7 @@ export function MailList({
return (
<div className="grid grid-cols-1 gap-1.5 p-1 sm:p-2">
{items.map((item, index) => {
{items.map((item) => {
const isUnread = item.labels && item.labels.length > 0
? gmail_unread(item)
: !seen(item);
@@ -77,7 +77,7 @@ export function MailList({
return (
<div
key={index}
key={item.mid ?? item.uid}
className={cn(
"flex flex-col gap-1.5 p-2 rounded-lg border transition-all cursor-pointer",
"hover:bg-accent/50",
@@ -91,7 +91,7 @@ export function MailList({
checked={selectedUids.includes(item.uid)}
onCheckedChange={(checked) => handleCheckboxChange(checked, item.uid)}
onClick={(e) => e.stopPropagation()}
className="h-3 w-3 shrink-0"
className="h-4 w-3 shrink-0"
/>
<div className="flex items-center gap-1 min-w-0 flex-1">
+97 -31
View File
@@ -40,6 +40,7 @@ import { EnvelopeDeleteDialog } from "./delete-dialog"
import { useFlagMessageMutation } from "@/hooks/use-flag-messages"
import { EnvelopeFilterDialog } from "./envelope-filter-dialog"
import Logo from '@/assets/logo.svg'
import { PaginatedResponse } from "@/api"
interface MailProps {
defaultLayout: number[] | undefined
@@ -51,7 +52,7 @@ interface MailProps {
interface ListMessagesOptions {
accountId: number | undefined;
mailbox: string | undefined;
page: number;
next_page_token: string | undefined;
page_size: number;
remote: boolean;
filter?: FilterForm;
@@ -114,22 +115,44 @@ function buildPayload(filterForm: FilterForm): any {
};
}
const useListMessages = ({ accountId, mailbox, page, page_size, remote, filter }: ListMessagesOptions) => {
return useQuery({
queryKey: ['mailbox-list-messages', `${accountId}`, mailbox, page, page_size, remote, filter],
queryFn: () => {
if (filter) {
const payload = {
mailbox: mailbox!,
search: buildPayload(filter)
};
return search_messages(accountId!, page, page_size, remote, payload);
}
return list_messages(accountId!, mailbox!, page, page_size, remote);
},
enabled: !!accountId && !!mailbox,
});
};
// const useListMessages = ({ accountId, mailbox, page, page_size, remote, filter }: ListMessagesOptions) => {
// return useQuery({
// queryKey: ['mailbox-list-messages', `${accountId}`, mailbox, page, page_size, remote, filter],
// queryFn: () => {
// if (filter) {
// const payload = {
// mailbox: mailbox!,
// search: buildPayload(filter)
// };
// return search_messages(accountId!, page, page_size, remote, payload);
// }
// return list_messages(accountId!, mailbox!, page, page_size, remote);
// },
// enabled: !!accountId && !!mailbox,
// });
// };
export async function listMessagesAPI({
accountId,
mailbox,
next_page_token,
page_size,
remote,
filter
}: ListMessagesOptions) {
if (filter) {
const payload = {
mailbox,
search: buildPayload(filter)
};
return await search_messages(accountId!, page_size, remote, payload, next_page_token);
}
return await list_messages(accountId!, mailbox!, page_size, remote, next_page_token);
}
export function Mail({
defaultLayout = [20, 80],
@@ -143,6 +166,14 @@ export function Mail({
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(lastSelectedAccountId);
const [selectedEvelope, setSelectedEvelope] = React.useState<EmailEnvelope | undefined>(undefined);
const [useIMAP, setUseIMAP] = React.useState<boolean>(false);
const [envelopes, setEnvelopes] = React.useState<PaginatedResponse<EmailEnvelope> | undefined>(undefined);
const [isMessagesLoading, setIsMessagesLoading] = React.useState<boolean>(false);
const [isError, setIsError] = React.useState<boolean>(false);
const [error, setError] = React.useState<any>(undefined);
// const [pageTokenMap, setPageTokenMap] = React.useState<Record<number, string | undefined>>({});
const pageTokenMapRef = React.useRef<Record<number, string | undefined>>({});
const [page, setPage] = React.useState(0);
const [pageSize, setPageSize] = React.useState(10);
const [selectedUids, setSelectedUids] = React.useState<number[]>([]);
@@ -159,14 +190,48 @@ export function Mail({
enabled: !!selectedAccountId,
})
const { data: envelopes, isLoading: isMessagesLoading, isError, error } = useListMessages({
accountId: selectedAccountId,
mailbox: selectedMailbox?.name,
page: page + 1,
page_size: pageSize,
remote: useIMAP,
filter: currentFilter
});
React.useEffect(() => {
if (!selectedAccountId || !selectedMailbox) {
return;
}
(async () => {
setIsMessagesLoading(true);
setIsError(false);
setError(undefined);
const next_page_token = pageTokenMapRef.current[page];
try {
const data = await listMessagesAPI({
accountId: selectedAccountId,
mailbox: selectedMailbox?.name,
page_size: pageSize,
remote: useIMAP,
filter: currentFilter,
next_page_token
});
setEnvelopes(data);
pageTokenMapRef.current = {
...pageTokenMapRef.current,
[page + 1]: data.next_page_token ?? undefined,
};
} catch (error) {
setIsError(true);
setError(error);
} finally {
setIsMessagesLoading(false)
}
})();
}, [selectedAccountId, selectedMailbox, page, pageSize, useIMAP, currentFilter])
// const { data: envelopes, isLoading: isMessagesLoading, isError, error } = useListMessages({
// accountId: selectedAccountId,
// mailbox: selectedMailbox?.name,
// page: page + 1,
// page_size: pageSize,
// remote: useIMAP,
// filter: currentFilter
// });
const triggerUpdate = (mailbox: string) => {
queryClient.refetchQueries({
@@ -187,11 +252,8 @@ export function Mail({
mailbox: selectedMailbox?.name,
search: buildPayload(data)
};
const result = await search_messages(selectedAccountId, 1, 10, useIMAP, payload);
queryClient.setQueryData(
['mailbox-list-messages', selectedAccountId, selectedMailbox?.name, 1, 10, useIMAP, data],
result
);
const result = await search_messages(selectedAccountId, 10, useIMAP, payload);
setEnvelopes(result);
} catch (error) {
console.error('Error fetching messages:', error);
toast({
@@ -212,7 +274,10 @@ export function Mail({
setSelectedUids([]);
}
const hasNextPage = () => {
return !!pageTokenMapRef.current[page + 1];
}
const handlePageChange = (newPage: number) => {
setPage(newPage);
setSelectedUids([]);
@@ -468,6 +533,7 @@ export function Mail({
{selectedMailbox && <div className="flex justify-center mt-4">
<EnvelopeListPagination
totalItems={envelopes?.total_items ?? 0}
hasNextPage={hasNextPage}
pageIndex={page}
pageSize={pageSize}
setPageIndex={handlePageChange}
@@ -7,8 +7,6 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Button } from '@/components/ui/button'
import {
@@ -23,6 +21,7 @@ interface PaginationProps {
totalItems: number
pageIndex: number,
pageSize: number,
hasNextPage: () => boolean,
setPageIndex: (pageIndex: number) => void,
setPageSize: (pageSize: number) => void,
}
@@ -31,6 +30,7 @@ export function EnvelopeListPagination({
totalItems,
pageIndex,
pageSize,
hasNextPage,
setPageIndex,
setPageSize,
}: PaginationProps) {
@@ -42,9 +42,9 @@ export function EnvelopeListPagination({
setPageIndex(0) // Reset to the first page when page size changes
}
const goToFirstPage = () => {
setPageIndex(0)
}
// const goToFirstPage = () => {
// setPageIndex(0)
// }
const goToPreviousPage = () => {
const newPageIndex = Math.max(pageIndex - 1, 0)
@@ -56,10 +56,10 @@ export function EnvelopeListPagination({
setPageIndex(newPageIndex)
}
const goToLastPage = () => {
const newPageIndex = pageCount - 1
setPageIndex(newPageIndex)
}
// const goToLastPage = () => {
// const newPageIndex = pageCount - 1
// setPageIndex(newPageIndex)
// }
return (
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
@@ -89,7 +89,7 @@ export function EnvelopeListPagination({
Page {pageIndex + 1} of {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
{/* <Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={goToFirstPage}
@@ -97,7 +97,7 @@ export function EnvelopeListPagination({
>
<span className='sr-only'>Go to first page</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
</Button> */}
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -111,12 +111,12 @@ export function EnvelopeListPagination({
variant='outline'
className='h-8 w-8 p-0'
onClick={goToNextPage}
disabled={pageIndex === pageCount - 1}
disabled={!hasNextPage()}
>
<span className='sr-only'>Go to next page</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
{/* <Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={goToLastPage}
@@ -124,7 +124,7 @@ export function EnvelopeListPagination({
>
<span className='sr-only'>Go to last page</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</Button> */}
</div>
</div>
</div>
+3 -2
View File
@@ -231,10 +231,11 @@ export default function License() {
const FormSchema = z.object({
license: z
.string({ required_error: 'License key is required.' })
.min(300, { message: "This is not a valid license key." })
.max(500, { message: "This is not a valid license key." }),
.min(300, { message: "License key is too short. Please check your license." })
.max(800, { message: "License key is too long. Please check your license." }),
});
function UploadLicenseForm({ className, close }: React.ComponentProps<"form"> & { close: () => void }) {
const [open, setOpen] = useState(false);
const mutation = useMutation({