refactor: enhance message handling and UI components

- Updated `TeamDetailView` to include `InboxMessage` type and refactored message visibility handling to use a dedicated callback.
- Simplified `ActivityTimeline` by removing unnecessary `useCallback` for message visibility, improving performance.
- Refactored `TaskCommentsSection` to enhance comment expansion logic and improve user interaction with a clearer UI for expanding and collapsing comments.
- Modified `markRead` function to accept an optional full set of messages for better state management and persistence.
This commit is contained in:
iliya 2026-02-23 15:08:11 +02:00
parent 40beaf20d9
commit c024f5bc78
5 changed files with 97 additions and 50 deletions

View file

@ -31,7 +31,7 @@ import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { ResolvedTeamMember, TeamTask } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember, TeamTask } from '@shared/types';
interface TeamDetailViewProps { interface TeamDetailViewProps {
teamName: string; teamName: string;
@ -294,6 +294,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
() => filteredMessages.filter((m) => !readSet.has(toMessageKey(m))).length, () => filteredMessages.filter((m) => !readSet.has(toMessageKey(m))).length,
[filteredMessages, readSet] [filteredMessages, readSet]
); );
const handleMessageVisible = useCallback(
(message: InboxMessage) => markRead(toMessageKey(message)),
[markRead]
);
const kanbanDisplayTasks = useMemo(() => { const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim(); const query = kanbanSearch.trim();
@ -691,7 +695,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setReplyQuote({ from: message.from, text: message.text }); setReplyQuote({ from: message.from, text: message.text });
setSendDialogOpen(true); setSendDialogOpen(true);
}} }}
onMessageVisible={(message) => markRead(toMessageKey(message))} onMessageVisible={handleMessageVisible}
/> />
</CollapsibleTeamSection> </CollapsibleTeamSection>

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { getMemberColorByName } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
@ -39,16 +39,10 @@ const MessageRowWithObserver = ({
}): React.JSX.Element => { }): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false); const reportedRef = useRef(false);
const messageRef = useRef(message);
const handleIntersect = useCallback( const onVisibleRef = useRef(onVisible);
(entry: IntersectionObserverEntry) => { messageRef.current = message;
if (!entry.isIntersecting || !onVisible) return; onVisibleRef.current = onVisible;
if (reportedRef.current) return;
reportedRef.current = true;
onVisible(message);
},
[message, onVisible]
);
useEffect(() => { useEffect(() => {
if (!onVisible) return; if (!onVisible) return;
@ -56,13 +50,19 @@ const MessageRowWithObserver = ({
if (!el) return; if (!el) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (entry) handleIntersect(entry); if (!entry?.isIntersecting) return;
if (reportedRef.current) return;
const cb = onVisibleRef.current;
const msg = messageRef.current;
if (!cb) return;
reportedRef.current = true;
cb(msg);
}, },
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
); );
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [onVisible, handleIntersect]); }, [onVisible]);
return ( return (
<div ref={ref} className="min-h-px"> <div ref={ref} className="min-h-px">

View file

@ -116,18 +116,6 @@ export const TaskCommentsSection = ({
: formatDistanceToNow(date, { addSuffix: true }); : formatDistanceToNow(date, { addSuffix: true });
})()} })()}
</span> </span>
<button
type="button"
className="flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => toggleCommentExpanded(comment.id)}
title={expandedCommentIds.has(comment.id) ? 'Свернуть' : 'Развернуть'}
>
{expandedCommentIds.has(comment.id) ? (
<ChevronUp size={12} />
) : (
<ChevronDown size={12} />
)}
</button>
<button <button
type="button" type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100" className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
@ -142,23 +130,68 @@ export const TaskCommentsSection = ({
Reply Reply
</button> </button>
</div> </div>
<div className="text-xs"> {(() => {
{(() => { const reply = parseMessageReply(comment.text);
const reply = parseMessageReply(comment.text); const expanded = expandedCommentIds.has(comment.id);
const expanded = expandedCommentIds.has(comment.id); const collapsedHeight = 'max-h-[120px]';
return reply ? ( return (
<ReplyQuoteBlock <div className="relative text-xs">
reply={reply} <div
bodyMaxHeight={expanded ? undefined : 'max-h-56'} className={
/> expanded ? undefined : `relative ${collapsedHeight} overflow-hidden`
) : ( }
<MarkdownViewer >
content={comment.text} {reply ? (
maxHeight={expanded ? undefined : 'max-h-[120px]'} <ReplyQuoteBlock
/> reply={reply}
); bodyMaxHeight={expanded ? undefined : 'max-h-56'}
})()} />
</div> ) : (
<MarkdownViewer
content={comment.text}
maxHeight={expanded ? undefined : collapsedHeight}
/>
)}
{!expanded && (
<>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
style={{
background:
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
}}
aria-hidden
/>
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Развернуть"
>
<ChevronDown size={12} />
Развернуть
</button>
</div>
</>
)}
</div>
{expanded && (
<div className="flex justify-center pt-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Свернуть"
>
<ChevronUp size={12} />
Свернуть
</button>
</div>
)}
</div>
);
})()}
</div> </div>
))} ))}
</div> </div>

View file

@ -28,7 +28,7 @@ export function useTeamMessagesRead(teamName: string): {
if (prev.has(messageKey)) return prev; if (prev.has(messageKey)) return prev;
const next = new Set(prev); const next = new Set(prev);
next.add(messageKey); next.add(messageKey);
markReadStorage(teamName, messageKey); markReadStorage(teamName, messageKey, next);
return next; return next;
}); });
}, },

View file

@ -16,12 +16,22 @@ export function getReadSet(teamName: string): Set<string> {
} }
} }
export function markRead(teamName: string, messageKey: string): void { /**
const set = getReadSet(teamName); * Mark a message as read and persist. If `fullSet` is provided, that set is written
if (set.has(messageKey)) return; * (avoids losing keys when a previous write failed). Otherwise reads from localStorage and adds one key.
set.add(messageKey); */
export function markRead(teamName: string, messageKey: string, fullSet?: Set<string>): void {
const toWrite =
fullSet ??
(() => {
const set = getReadSet(teamName);
if (set.has(messageKey)) return null;
set.add(messageKey);
return set;
})();
if (!toWrite) return;
try { try {
localStorage.setItem(storageKey(teamName), JSON.stringify([...set])); localStorage.setItem(storageKey(teamName), JSON.stringify([...toWrite]));
} catch { } catch {
// quota or disabled // quota or disabled
} }