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

View file

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

View file

@ -116,18 +116,6 @@ export const TaskCommentsSection = ({
: formatDistanceToNow(date, { addSuffix: true });
})()}
</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
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"
@ -142,23 +130,68 @@ export const TaskCommentsSection = ({
Reply
</button>
</div>
<div className="text-xs">
{(() => {
const reply = parseMessageReply(comment.text);
const expanded = expandedCommentIds.has(comment.id);
return reply ? (
<ReplyQuoteBlock
reply={reply}
bodyMaxHeight={expanded ? undefined : 'max-h-56'}
/>
) : (
<MarkdownViewer
content={comment.text}
maxHeight={expanded ? undefined : 'max-h-[120px]'}
/>
);
})()}
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const expanded = expandedCommentIds.has(comment.id);
const collapsedHeight = 'max-h-[120px]';
return (
<div className="relative text-xs">
<div
className={
expanded ? undefined : `relative ${collapsedHeight} overflow-hidden`
}
>
{reply ? (
<ReplyQuoteBlock
reply={reply}
bodyMaxHeight={expanded ? undefined : 'max-h-56'}
/>
) : (
<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>

View file

@ -28,7 +28,7 @@ export function useTeamMessagesRead(teamName: string): {
if (prev.has(messageKey)) return prev;
const next = new Set(prev);
next.add(messageKey);
markReadStorage(teamName, messageKey);
markReadStorage(teamName, messageKey, 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);
if (set.has(messageKey)) return;
set.add(messageKey);
/**
* Mark a message as read and persist. If `fullSet` is provided, that set is written
* (avoids losing keys when a previous write failed). Otherwise reads from localStorage and adds one key.
*/
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 {
localStorage.setItem(storageKey(teamName), JSON.stringify([...set]));
localStorage.setItem(storageKey(teamName), JSON.stringify([...toWrite]));
} catch {
// quota or disabled
}