feat: enhance notifications and task management features
- Added a section for task completion notifications in the settings, allowing users to receive native OS notifications when tasks are completed. - Implemented a button to install the `claude-notifications-go` plugin for enhanced notification capabilities. - Updated the `TeamDetailView` to include a "Mark all as read" feature, improving message management. - Refactored task creation dialog to support an optional immediate start parameter, enhancing task scheduling flexibility. - Improved Kanban board functionality by allowing task addition with pre-set start conditions based on the column context.
This commit is contained in:
parent
63c7204c1a
commit
3a05f113bc
9 changed files with 150 additions and 45 deletions
|
|
@ -64,6 +64,34 @@ export const NotificationsSection = ({
|
||||||
}: NotificationsSectionProps): React.JSX.Element => {
|
}: NotificationsSectionProps): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Task Completion Notifications */}
|
||||||
|
<SettingsSectionHeader title="Task Completion Notifications" />
|
||||||
|
<div
|
||||||
|
className="mb-4 rounded-lg border p-4"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
|
||||||
|
badges. Works on macOS, Linux, and Windows.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void api.openExternal('https://github.com/777genius/claude-notifications-go')
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-border-emphasis)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
Install claude-notifications-go plugin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notification Triggers */}
|
{/* Notification Triggers */}
|
||||||
<NotificationTriggerSettings
|
<NotificationTriggerSettings
|
||||||
triggers={safeConfig.notifications.triggers || []}
|
triggers={safeConfig.notifications.triggers || []}
|
||||||
|
|
@ -131,33 +159,6 @@ export const NotificationsSection = ({
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
<SettingsSectionHeader title="Task Completion Notifications" />
|
|
||||||
<div
|
|
||||||
className="mb-4 rounded-lg border p-4"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
|
|
||||||
badges. Works on macOS, Linux, and Windows.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
void api.openExternal('https://github.com/777genius/claude-notifications-go')
|
|
||||||
}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-border-emphasis)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="size-3.5" />
|
|
||||||
Install claude-notifications-go plugin
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsSectionHeader title="Ignored Repositories" />
|
<SettingsSectionHeader title="Ignored Repositories" />
|
||||||
<p className="mb-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<p className="mb-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Notifications from these repositories will be ignored
|
Notifications from these repositories will be ignored
|
||||||
|
|
|
||||||
|
|
@ -49,17 +49,13 @@ export const CollapsibleTeamSection = ({
|
||||||
{badge}
|
{badge}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{secondaryBadge != null && secondaryBadge >= 0 && (
|
{secondaryBadge != null && secondaryBadge > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={
|
className="bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-400"
|
||||||
secondaryBadge > 0
|
title={`${secondaryBadge} unread`}
|
||||||
? 'bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-400'
|
|
||||||
: 'px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]'
|
|
||||||
}
|
|
||||||
title={secondaryBadge > 0 ? `${secondaryBadge} unread` : undefined}
|
|
||||||
>
|
>
|
||||||
{secondaryBadge}
|
{secondaryBadge} new
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,17 @@ import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { GitBranch, Pencil, Play, Plus, Search, Trash2, UserPlus, X } from 'lucide-react';
|
import {
|
||||||
|
CheckCheck,
|
||||||
|
GitBranch,
|
||||||
|
Pencil,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
||||||
|
|
@ -56,6 +66,7 @@ interface CreateTaskDialogState {
|
||||||
defaultSubject: string;
|
defaultSubject: string;
|
||||||
defaultDescription: string;
|
defaultDescription: string;
|
||||||
defaultOwner: string;
|
defaultOwner: string;
|
||||||
|
defaultStartImmediately?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimeWindow {
|
interface TimeWindow {
|
||||||
|
|
@ -342,15 +353,21 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return list;
|
return list;
|
||||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
|
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||||
|
|
||||||
const { readSet, markRead } = useTeamMessagesRead(teamName ?? '');
|
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
||||||
const messagesUnreadCount = useMemo(
|
const messagesUnreadCount = useMemo(
|
||||||
() => filteredMessages.filter((m) => !readSet.has(toMessageKey(m))).length,
|
() => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length,
|
||||||
[filteredMessages, readSet]
|
[filteredMessages, readSet]
|
||||||
);
|
);
|
||||||
const handleMessageVisible = useCallback(
|
const handleMessageVisible = useCallback(
|
||||||
(message: InboxMessage) => markRead(toMessageKey(message)),
|
(message: InboxMessage) => markRead(toMessageKey(message)),
|
||||||
[markRead]
|
[markRead]
|
||||||
);
|
);
|
||||||
|
const handleMarkAllRead = useCallback(() => {
|
||||||
|
const keys = filteredMessages
|
||||||
|
.filter((m) => !m.read && !readSet.has(toMessageKey(m)))
|
||||||
|
.map((m) => toMessageKey(m));
|
||||||
|
markAllRead(keys);
|
||||||
|
}, [filteredMessages, readSet, markAllRead]);
|
||||||
|
|
||||||
const kanbanDisplayTasks = useMemo(() => {
|
const kanbanDisplayTasks = useMemo(() => {
|
||||||
const query = kanbanSearch.trim();
|
const query = kanbanSearch.trim();
|
||||||
|
|
@ -385,12 +402,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
if (changed) setPendingRepliesByMember(next);
|
if (changed) setPendingRepliesByMember(next);
|
||||||
}, [data, pendingRepliesByMember]);
|
}, [data, pendingRepliesByMember]);
|
||||||
|
|
||||||
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
|
const openCreateTaskDialog = (
|
||||||
|
subject = '',
|
||||||
|
description = '',
|
||||||
|
owner = '',
|
||||||
|
startImmediately?: boolean
|
||||||
|
): void => {
|
||||||
setCreateTaskDialog({
|
setCreateTaskDialog({
|
||||||
open: true,
|
open: true,
|
||||||
defaultSubject: subject,
|
defaultSubject: subject,
|
||||||
defaultDescription: description,
|
defaultDescription: description,
|
||||||
defaultOwner: owner,
|
defaultOwner: owner,
|
||||||
|
defaultStartImmediately: startImmediately,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -400,6 +423,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
defaultSubject: '',
|
defaultSubject: '',
|
||||||
defaultDescription: '',
|
defaultDescription: '',
|
||||||
defaultOwner: '',
|
defaultOwner: '',
|
||||||
|
defaultStartImmediately: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -768,6 +792,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTaskClick={(task) => setSelectedTask(task)}
|
onTaskClick={(task) => setSelectedTask(task)}
|
||||||
|
onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
@ -780,6 +805,23 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
defaultOpen
|
defaultOpen
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-2 pl-2">
|
<div className="flex items-center gap-2 pl-2">
|
||||||
|
{messagesUnreadCount > 0 && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMarkAllRead();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCheck size={12} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Mark all as read</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
<input
|
<input
|
||||||
|
|
@ -915,6 +957,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
defaultSubject={createTaskDialog.defaultSubject}
|
defaultSubject={createTaskDialog.defaultSubject}
|
||||||
defaultDescription={createTaskDialog.defaultDescription}
|
defaultDescription={createTaskDialog.defaultDescription}
|
||||||
defaultOwner={createTaskDialog.defaultOwner}
|
defaultOwner={createTaskDialog.defaultOwner}
|
||||||
|
defaultStartImmediately={createTaskDialog.defaultStartImmediately}
|
||||||
onClose={closeCreateTaskDialog}
|
onClose={closeCreateTaskDialog}
|
||||||
onSubmit={handleCreateTask}
|
onSubmit={handleCreateTask}
|
||||||
submitting={creatingTask}
|
submitting={creatingTask}
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export const ActivityTimeline = ({
|
||||||
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
|
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
|
||||||
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
||||||
const isUnread = readState
|
const isUnread = readState
|
||||||
? !readState.readSet.has(readState.getMessageKey(message))
|
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||||
: !message.read;
|
: !message.read;
|
||||||
return (
|
return (
|
||||||
<MessageRowWithObserver
|
<MessageRowWithObserver
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ interface CreateTaskDialogProps {
|
||||||
defaultSubject?: string;
|
defaultSubject?: string;
|
||||||
defaultDescription?: string;
|
defaultDescription?: string;
|
||||||
defaultOwner?: string;
|
defaultOwner?: string;
|
||||||
|
defaultStartImmediately?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
subject: string,
|
subject: string,
|
||||||
|
|
@ -60,6 +61,7 @@ export const CreateTaskDialog = ({
|
||||||
defaultSubject = '',
|
defaultSubject = '',
|
||||||
defaultDescription = '',
|
defaultDescription = '',
|
||||||
defaultOwner = '',
|
defaultOwner = '',
|
||||||
|
defaultStartImmediately,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting = false,
|
submitting = false,
|
||||||
|
|
@ -86,7 +88,7 @@ export const CreateTaskDialog = ({
|
||||||
setOwner(defaultOwner);
|
setOwner(defaultOwner);
|
||||||
setBlockedBy([]);
|
setBlockedBy([]);
|
||||||
setRelated([]);
|
setRelated([]);
|
||||||
setStartImmediately(isTeamAlive);
|
setStartImmediately(defaultStartImmediately ?? isTeamAlive);
|
||||||
promptDraft.clearDraft();
|
promptDraft.clearDraft();
|
||||||
setBlockedBySearch('');
|
setBlockedBySearch('');
|
||||||
setRelatedSearch('');
|
setRelatedSearch('');
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ interface KanbanBoardProps {
|
||||||
onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
|
onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
|
||||||
/** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
|
/** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
|
||||||
toolbarLeft?: React.ReactNode;
|
toolbarLeft?: React.ReactNode;
|
||||||
|
/** Opens the create-task dialog with pre-set startImmediately value. */
|
||||||
|
onAddTask?: (startImmediately: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type KanbanViewMode = 'grid' | 'columns';
|
type KanbanViewMode = 'grid' | 'columns';
|
||||||
|
|
@ -218,6 +220,7 @@ export const KanbanBoard = ({
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onColumnOrderChange,
|
onColumnOrderChange,
|
||||||
toolbarLeft,
|
toolbarLeft,
|
||||||
|
onAddTask,
|
||||||
}: KanbanBoardProps): React.JSX.Element => {
|
}: KanbanBoardProps): React.JSX.Element => {
|
||||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||||
|
|
||||||
|
|
@ -395,6 +398,12 @@ export const KanbanBoard = ({
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||||
const accent = COLUMN_ACCENTS[column.id];
|
const accent = COLUMN_ACCENTS[column.id];
|
||||||
|
const addHandler =
|
||||||
|
onAddTask && column.id === 'todo'
|
||||||
|
? () => onAddTask(false)
|
||||||
|
: onAddTask && column.id === 'in_progress'
|
||||||
|
? () => onAddTask(true)
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
key={column.id}
|
key={column.id}
|
||||||
|
|
@ -403,6 +412,7 @@ export const KanbanBoard = ({
|
||||||
icon={accent.icon}
|
icon={accent.icon}
|
||||||
headerBg={accent.headerBg}
|
headerBg={accent.headerBg}
|
||||||
bodyBg={accent.bodyBg}
|
bodyBg={accent.bodyBg}
|
||||||
|
onAddTask={addHandler}
|
||||||
>
|
>
|
||||||
{renderCards(column.id, columnTasks)}
|
{renderCards(column.id, columnTasks)}
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
|
|
@ -414,6 +424,12 @@ export const KanbanBoard = ({
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||||
const accent = COLUMN_ACCENTS[column.id];
|
const accent = COLUMN_ACCENTS[column.id];
|
||||||
|
const addHandler =
|
||||||
|
onAddTask && column.id === 'todo'
|
||||||
|
? () => onAddTask(false)
|
||||||
|
: onAddTask && column.id === 'in_progress'
|
||||||
|
? () => onAddTask(true)
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<div key={column.id} className="w-64 shrink-0">
|
<div key={column.id} className="w-64 shrink-0">
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
|
|
@ -422,6 +438,7 @@ export const KanbanBoard = ({
|
||||||
icon={accent.icon}
|
icon={accent.icon}
|
||||||
headerBg={accent.headerBg}
|
headerBg={accent.headerBg}
|
||||||
bodyBg={accent.bodyBg}
|
bodyBg={accent.bodyBg}
|
||||||
|
onAddTask={addHandler}
|
||||||
>
|
>
|
||||||
{renderCards(column.id, columnTasks)}
|
{renderCards(column.id, columnTasks)}
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -7,6 +8,7 @@ interface KanbanColumnProps {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
headerBg?: string;
|
headerBg?: string;
|
||||||
bodyBg?: string;
|
bodyBg?: string;
|
||||||
|
onAddTask?: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,6 +18,7 @@ export const KanbanColumn = ({
|
||||||
icon,
|
icon,
|
||||||
headerBg,
|
headerBg,
|
||||||
bodyBg,
|
bodyBg,
|
||||||
|
onAddTask,
|
||||||
children,
|
children,
|
||||||
}: KanbanColumnProps): React.JSX.Element => {
|
}: KanbanColumnProps): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,9 +37,21 @@ export const KanbanColumn = ({
|
||||||
{icon}
|
{icon}
|
||||||
{title}
|
{title}
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
<div className="flex items-center gap-1.5">
|
||||||
{count}
|
{onAddTask ? (
|
||||||
</Badge>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddTask}
|
||||||
|
className="inline-flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] transition-colors hover:bg-white/10 hover:text-[var(--color-text)]"
|
||||||
|
aria-label={`Add task to ${title}`}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div>
|
<div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getReadSet as getReadSetStorage,
|
getReadSet as getReadSetStorage,
|
||||||
|
markBulkRead as markBulkReadStorage,
|
||||||
markRead as markReadStorage,
|
markRead as markReadStorage,
|
||||||
} from '@renderer/utils/teamMessageReadStorage';
|
} from '@renderer/utils/teamMessageReadStorage';
|
||||||
|
|
||||||
export function useTeamMessagesRead(teamName: string): {
|
export function useTeamMessagesRead(teamName: string): {
|
||||||
readSet: Set<string>;
|
readSet: Set<string>;
|
||||||
markRead: (messageKey: string) => void;
|
markRead: (messageKey: string) => void;
|
||||||
|
markAllRead: (messageKeys: string[]) => void;
|
||||||
} {
|
} {
|
||||||
const [version, setVersion] = useState(0);
|
const [version, setVersion] = useState(0);
|
||||||
const readSet = useMemo(() => {
|
const readSet = useMemo(() => {
|
||||||
|
|
@ -27,6 +29,24 @@ export function useTeamMessagesRead(teamName: string): {
|
||||||
[teamName]
|
[teamName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const markAllRead = useCallback(
|
||||||
|
(messageKeys: string[]) => {
|
||||||
|
if (!teamName || messageKeys.length === 0) return;
|
||||||
|
const existing = getReadSetStorage(teamName);
|
||||||
|
let changed = false;
|
||||||
|
for (const key of messageKeys) {
|
||||||
|
if (!existing.has(key)) {
|
||||||
|
existing.add(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) return;
|
||||||
|
markBulkReadStorage(teamName, existing);
|
||||||
|
setVersion((v) => v + 1);
|
||||||
|
},
|
||||||
|
[teamName]
|
||||||
|
);
|
||||||
|
|
||||||
const effectiveReadSet = !teamName ? new Set<string>() : readSet;
|
const effectiveReadSet = !teamName ? new Set<string>() : readSet;
|
||||||
return { readSet: effectiveReadSet, markRead };
|
return { readSet: effectiveReadSet, markRead, markAllRead };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,3 +36,14 @@ export function markRead(teamName: string, messageKey: string, fullSet?: Set<str
|
||||||
// quota or disabled
|
// quota or disabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a full set of read keys at once (bulk mark-all-as-read).
|
||||||
|
*/
|
||||||
|
export function markBulkRead(teamName: string, fullSet: Set<string>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey(teamName), JSON.stringify([...fullSet]));
|
||||||
|
} catch {
|
||||||
|
// quota or disabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue