Merge pull request #1003 from Mail-0/feat/important

feat: add important toogle api and UI
This commit is contained in:
nizzy
2025-05-18 02:30:10 -04:00
committed by GitHub
6 changed files with 171 additions and 3 deletions

View File

@@ -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'),

View File

@@ -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>
);

View File

@@ -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');
}}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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({