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:
parent
40beaf20d9
commit
c024f5bc78
5 changed files with 97 additions and 50 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue