mirror of
https://github.com/Mail-0/Zero.git
synced 2026-07-01 08:16:28 +00:00
Merge pull request #1003 from Mail-0/feat/important
feat: add important toogle api and UI
This commit is contained in:
@@ -32,6 +32,7 @@ import { useThread, useThreads } from '@/hooks/use-threads';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { ExclamationCircle } from '../icons/icons';
|
||||
import { useLabels } from '@/hooks/use-labels';
|
||||
import { LABELS, FOLDERS } from '@/lib/utils';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
@@ -142,6 +143,7 @@ export function ThreadContextMenu({
|
||||
trpc.mail.markAsUnread.mutationOptions({ onSuccess: () => invalidateCount() }),
|
||||
);
|
||||
const { mutateAsync: toggleStar } = useMutation(trpc.mail.toggleStar.mutationOptions());
|
||||
const { mutateAsync: toggleImportant } = useMutation(trpc.mail.toggleImportant.mutationOptions());
|
||||
const { mutateAsync: deleteThread } = useMutation(trpc.mail.delete.mutationOptions());
|
||||
|
||||
const selectedThreads = useMemo(() => {
|
||||
@@ -162,6 +164,12 @@ export function ThreadContextMenu({
|
||||
);
|
||||
}, [threadData]);
|
||||
|
||||
const isImportant = useMemo(() => {
|
||||
return threadData?.messages.some((message) =>
|
||||
message.tags?.some((tag) => tag.name.toLowerCase() === 'important'),
|
||||
);
|
||||
}, [threadData]);
|
||||
|
||||
const noopAction = () => async () => {
|
||||
toast.info(t('common.actions.featureNotImplemented'));
|
||||
};
|
||||
@@ -212,6 +220,13 @@ export function ThreadContextMenu({
|
||||
return await Promise.allSettled([refetchThread(), refetch()]);
|
||||
};
|
||||
|
||||
const handleToggleImportant = async () => {
|
||||
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
|
||||
await toggleImportant({ ids: targets });
|
||||
setMail((prev) => ({ ...prev, bulkSelected: [] }));
|
||||
return await Promise.allSettled([refetchThread(), refetch()]);
|
||||
};
|
||||
|
||||
const handleReadUnread = () => {
|
||||
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
|
||||
const action = isUnread ? markAsRead : markAsUnread;
|
||||
@@ -399,6 +414,12 @@ export function ThreadContextMenu({
|
||||
action: handleReadUnread,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: 'toggle-important',
|
||||
label: isImportant ? t('common.mail.removeFromImportant') : t('common.mail.markAsImportant'),
|
||||
icon: <ExclamationCircle className={'mr-2.5 h-4 w-4'} />,
|
||||
action: handleToggleImportant,
|
||||
},
|
||||
{
|
||||
id: 'favorite',
|
||||
label: isStarred ? t('common.mail.removeFavorite') : t('common.mail.addFavorite'),
|
||||
|
||||
@@ -1490,3 +1490,20 @@ export const Search = ({ className }: { className?: string }) => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Folders = ({ className }: { className?: string }) => (
|
||||
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path
|
||||
d="M0.273071 2.62524C1.06638 1.92494 2.10851 1.5 3.24989 1.5H16.7499C17.8913 1.5 18.9334 1.92494 19.7267 2.62524C19.5423 1.14526 18.2798 0 16.7499 0H3.24989C1.71995 0 0.457504 1.14525 0.273071 2.62524Z"
|
||||
|
||||
/>
|
||||
<path
|
||||
d="M0.273071 5.62524C1.06638 4.92494 2.10851 4.5 3.24989 4.5H16.7499C17.8913 4.5 18.9334 4.92494 19.7267 5.62524C19.5423 4.14526 18.2798 3 16.7499 3H3.24989C1.71995 3 0.457504 4.14525 0.273071 5.62524Z"
|
||||
|
||||
/>
|
||||
<path
|
||||
d="M3.25 6C1.59315 6 0.25 7.34315 0.25 9V15C0.25 16.6569 1.59315 18 3.25 18H16.75C18.4069 18 19.75 16.6569 19.75 15V9C19.75 7.34315 18.4069 6 16.75 6H13C12.5858 6 12.25 6.33579 12.25 6.75C12.25 7.99264 11.2426 9 10 9C8.75736 9 7.75 7.99264 7.75 6.75C7.75 6.33579 7.41421 6 7 6H3.25Z"
|
||||
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Archive2,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
ExclamationCircle,
|
||||
GroupPeople,
|
||||
Lightning,
|
||||
People,
|
||||
@@ -183,16 +184,22 @@ const Thread = memo(
|
||||
refetch: refetchThread,
|
||||
} = useThread(demo ? null : message.id);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [isImportant, setIsImportant] = useState(false);
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: toggleStar } = useMutation(trpc.mail.toggleStar.mutationOptions());
|
||||
const { mutateAsync: toggleImportant } = useMutation(
|
||||
trpc.mail.toggleImportant.mutationOptions(),
|
||||
);
|
||||
const [id, setThreadId] = useQueryState('threadId');
|
||||
const [activeReplyId, setActiveReplyId] = useQueryState('activeReplyId');
|
||||
const [focusedIndex, setFocusedIndex] = useAtom(focusedIndexAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (getThreadData?.latest?.tags) {
|
||||
console.log(getThreadData.latest.tags);
|
||||
setIsStarred(getThreadData.latest.tags.some((tag) => tag.name === 'STARRED'));
|
||||
setIsImportant(getThreadData.latest.tags.some((tag) => tag.name === 'IMPORTANT'));
|
||||
}
|
||||
}, [getThreadData?.latest?.tags]);
|
||||
|
||||
@@ -214,6 +221,23 @@ const Thread = memo(
|
||||
[getThreadData, message.id, isStarred, refetchThreads, t],
|
||||
);
|
||||
|
||||
const handleToggleImportant = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!getThreadData || !message.id) return;
|
||||
const newImportantState = !isImportant;
|
||||
setIsImportant(newImportantState);
|
||||
await toggleImportant({ ids: [message.id] });
|
||||
if (newImportantState) {
|
||||
toast.success(t('common.actions.addedToImportant'));
|
||||
} else {
|
||||
toast.success(t('common.actions.removedFromImportant'));
|
||||
}
|
||||
await refetchThread();
|
||||
},
|
||||
[getThreadData, message.id, refetchThreads],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(
|
||||
(id: string) => {
|
||||
if (!id || !threads.length || focusedIndex === null) return setThreadId(null);
|
||||
@@ -469,13 +493,28 @@ const Thread = memo(
|
||||
{isStarred ? t('common.threadDisplay.unstar') : t('common.threadDisplay.star')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 [&_svg]:size-3.5"
|
||||
onClick={handleToggleImportant}
|
||||
>
|
||||
<ExclamationCircle className={cn(isImportant ? '' : 'opacity-50')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="mb-1 bg-white dark:bg-[#1A1A1A]">
|
||||
{t('common.mail.toggleImportant')}
|
||||
</TooltipContent>
|
||||
</Tooltip> */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 [&_svg]:size-3.5"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveThreadTo('archive');
|
||||
}}
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
Forward,
|
||||
ReplyAll,
|
||||
Star,
|
||||
ExclamationCircle,
|
||||
Lightning,
|
||||
Folders,
|
||||
} from '../icons/icons';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -25,7 +28,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import { CircleAlertIcon, Inbox, ShieldAlertIcon, SidebarOpen, StopCircleIcon } from 'lucide-react';
|
||||
import { CircleAlertIcon, Inbox, ShieldAlertIcon, SidebarOpen, StopCircleIcon, Zap } from 'lucide-react';
|
||||
import { moveThreadsTo, type ThreadDestination } from '@/lib/thread-actions';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -158,6 +161,7 @@ export function ThreadDisplay() {
|
||||
const [{ refetch: mutateThreads }, items] = useThreads();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [isImportant, setIsImportant] = useState(false);
|
||||
const t = useTranslations();
|
||||
const { refetch: refetchStats } = useStats();
|
||||
const [mode, setMode] = useQueryState('mode');
|
||||
@@ -169,6 +173,7 @@ export function ThreadDisplay() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: toggleStar } = useMutation(trpc.mail.toggleStar.mutationOptions());
|
||||
const { mutateAsync: toggleImportant } = useMutation(trpc.mail.toggleImportant.mutationOptions());
|
||||
const invalidateCount = () =>
|
||||
queryClient.invalidateQueries({ queryKey: trpc.mail.count.queryKey() });
|
||||
const { mutateAsync: markAsRead } = useMutation(
|
||||
@@ -301,11 +306,23 @@ export function ThreadDisplay() {
|
||||
await refetchThread();
|
||||
}, [emailData, id, isStarred]);
|
||||
|
||||
const handleToggleImportant = useCallback(async () => {
|
||||
if (!emailData || !id) return;
|
||||
await toggleImportant({ ids: [id] });
|
||||
await refetchThread();
|
||||
if (isImportant) {
|
||||
toast.success(t('common.mail.markedAsImportant'));
|
||||
} else {
|
||||
toast.error("Failed to mark as important");
|
||||
}
|
||||
}, [emailData, id]);
|
||||
|
||||
// Set initial star state based on email data
|
||||
useEffect(() => {
|
||||
if (emailData?.latest?.tags) {
|
||||
// Check if any tag has the name 'STARRED'
|
||||
setIsStarred(emailData.latest.tags.some((tag) => tag.name === 'STARRED'));
|
||||
setIsImportant(emailData.latest.tags.some((tag) => tag.name === 'IMPORTANT'));
|
||||
}
|
||||
}, [emailData?.latest?.tags]);
|
||||
|
||||
@@ -514,6 +531,7 @@ export function ThreadDisplay() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -578,12 +596,18 @@ export function ThreadDisplay() {
|
||||
{emailData.latest?.listUnsubscribe ||
|
||||
emailData.latest?.listUnsubscribePost ? (
|
||||
<DropdownMenuItem onClick={handleUnsubscribeProcess}>
|
||||
<ShieldAlertIcon className="fill-iconLight dark:fill-iconDark mr-2" />
|
||||
<Folders className="fill-iconLight dark:fill-iconDark mr-2" />
|
||||
<span>Unsubscribe</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{!isImportant && (
|
||||
<DropdownMenuItem onClick={handleToggleImportant}>
|
||||
<Lightning className="fill-iconLight dark:fill-iconDark mr-2" />
|
||||
{t('common.mail.markAsImportant')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"addingToFavorites": "Adding to favorites...",
|
||||
"removingFromFavorites": "Removing from favorites...",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
"addedToImportant": "Added to important",
|
||||
"removedFromFavorites": "Removed from favorites",
|
||||
"removedFromImportant": "Removed from important",
|
||||
"failedToAddToFavorites": "Failed to add to favorites",
|
||||
"failedToRemoveFromFavorites": "Failed to remove from favorites",
|
||||
"failedToModifyFavorites": "Failed to modify favorites",
|
||||
@@ -284,6 +286,10 @@
|
||||
"noSearchResults": "No search results found",
|
||||
"clearSearch": "Clear search",
|
||||
"markAsImportant": "Mark as Important",
|
||||
"removeFromImportant": "Remove from Important",
|
||||
"markedAsImportant": "Marked as Important",
|
||||
"markedAsUnimportant": "Unmarked as Unimportant",
|
||||
"toggleImportant": "Toggle important",
|
||||
"unSubscribeFromAll": "Unsubscribe from all",
|
||||
"starAll": "Star all",
|
||||
"mute": "Mute",
|
||||
|
||||
@@ -157,6 +157,47 @@ export const mailRouter = router({
|
||||
removeLabels: shouldStar ? [] : ['STARRED'],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
toggleImportant: activeDriverProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.string().array(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { driver } = ctx;
|
||||
const { threadIds } = driver.normalizeIds(input.ids);
|
||||
|
||||
if (!threadIds.length) {
|
||||
return { success: false, error: 'No thread IDs provided' };
|
||||
}
|
||||
|
||||
const threadResults = await Promise.allSettled(threadIds.map((id) => driver.get(id)));
|
||||
|
||||
let anyImportant = false;
|
||||
let processedThreads = 0;
|
||||
|
||||
for (const result of threadResults) {
|
||||
if (result.status === 'fulfilled' && result.value && result.value.messages.length > 0) {
|
||||
processedThreads++;
|
||||
const isThreadImportant = result.value.messages.some((message) =>
|
||||
message.tags?.some((tag) => tag.name.toLowerCase().startsWith('important')),
|
||||
);
|
||||
if (isThreadImportant) {
|
||||
anyImportant = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shouldMarkImportant = processedThreads > 0 && !anyImportant;
|
||||
|
||||
await driver.modifyLabels(threadIds, {
|
||||
addLabels: shouldMarkImportant ? ['IMPORTANT'] : [],
|
||||
removeLabels: shouldMarkImportant ? [] : ['IMPORTANT'],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
bulkStar: activeDriverProcedure
|
||||
@@ -169,6 +210,16 @@ export const mailRouter = router({
|
||||
const { driver } = ctx;
|
||||
return driver.modifyLabels(input.ids, { addLabels: ['STARRED'], removeLabels: [] });
|
||||
}),
|
||||
bulkMarkImportant: activeDriverProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.string().array(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { driver } = ctx;
|
||||
return driver.modifyLabels(input.ids, { addLabels: ['IMPORTANT'], removeLabels: [] });
|
||||
}),
|
||||
bulkUnstar: activeDriverProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -179,6 +230,16 @@ export const mailRouter = router({
|
||||
const { driver } = ctx;
|
||||
return driver.modifyLabels(input.ids, { addLabels: [], removeLabels: ['STARRED'] });
|
||||
}),
|
||||
bulkUnmarkImportant: activeDriverProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.string().array(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { driver } = ctx;
|
||||
return driver.modifyLabels(input.ids, { addLabels: [], removeLabels: ['IMPORTANT'] });
|
||||
}),
|
||||
send: activeDriverProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
Reference in New Issue
Block a user