delete} type="info">
@@ -188,7 +210,7 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages,
export const ThreadView = () => {
const searchParams = useSearchParams();
const isTrashView = searchParams.get('has_trashed') === '1';
- const { selectedMailbox, selectedThread, messages, queryStates } = useMailboxContext();
+ const { selectedMailbox, selectedThread, messages, threadItems, queryStates } = useMailboxContext();
const [showTrashedMessages, setShowTrashedMessages] = useState(isTrashView);
// Nest draft messages under their parent messages
const messagesWithDraftChildren = useMemo(() => {
@@ -215,13 +237,38 @@ export const ThreadView = () => {
return messagesWithDraftChildren.filter((m) => m.is_trashed === isTrashView);
}, [messagesWithDraftChildren, isTrashView, showTrashedMessages]);
+ // Filter timeline items to match filtered messages and include all events
+ const filteredThreadItems = useMemo(() => {
+ if (!threadItems) return [];
+ const filteredMessageIds = new Set(filteredMessages.map((m) => m.id));
+ return threadItems.filter((item: TimelineItem) => {
+ if (item.type === 'message') {
+ return filteredMessageIds.has((item.data as MessageWithDraftChild).id);
+ }
+ // Always include events
+ return true;
+ }).map((item: TimelineItem) => {
+ // Replace message data with the filtered version that has draft children
+ if (item.type === 'message') {
+ const message = filteredMessages.find((m) => m.id === (item.data as Message).id);
+ if (message) {
+ return {
+ ...item,
+ data: message,
+ };
+ }
+ }
+ return item;
+ });
+ }, [threadItems, filteredMessages]);
+
useEffect(() => () => {
setShowTrashedMessages(isTrashView);
}, [selectedThread]);
if (!selectedMailbox || !selectedThread) return null
- if (queryStates.messages.isLoading) {
+ if (queryStates.messages.isLoading || !threadItems) {
return (
@@ -234,6 +281,7 @@ export const ThreadView = () => {
;
}
+
+type TimelineItem = {
+ type: 'message' | 'event';
+ data: Message | ThreadEvent;
+ created_at: string;
+};
+
type MailboxContextType = {
mailboxes: readonly Mailbox[] | null;
threads: PaginatedThreadList | null;
messages: PaginatedMessageList | null;
+ threadItems: TimelineItem[] | null;
selectedMailbox: Mailbox | null;
selectedThread: Thread | null;
unselectThread: () => void;
@@ -53,6 +61,7 @@ const MailboxContext = createContext({
mailboxes: null,
threads: null,
messages: null,
+ threadItems: null,
selectedMailbox: null,
selectedThread: null,
loadNextThreads: async () => {},
@@ -180,6 +189,50 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
}
});
+ const threadEventsQuery = useThreadsEventsList(
+ selectedThread?.id ?? '',
+ undefined,
+ {
+ query: {
+ enabled: !!selectedThread,
+ }
+ }
+ );
+
+ // Merge messages and events into a single timeline sorted by created_at
+ const threadItems = useMemo(() => {
+ if (!messagesQuery.data?.data?.results || !selectedThread) return null;
+
+ const items: TimelineItem[] = [];
+
+ // Add messages
+ messagesQuery.data.data.results.forEach((message) => {
+ items.push({
+ type: 'message',
+ data: message,
+ created_at: message.created_at,
+ });
+ });
+
+ // Add events
+ if (threadEventsQuery.data?.data?.results) {
+ threadEventsQuery.data.data.results.forEach((event) => {
+ items.push({
+ type: 'event',
+ data: event,
+ created_at: event.created_at,
+ });
+ });
+ }
+
+ // Sort by created_at
+ return items.sort((a, b) => {
+ const dateA = new Date(a.created_at).getTime();
+ const dateB = new Date(b.created_at).getTime();
+ return dateA - dateB;
+ });
+ }, [messagesQuery.data?.data?.results, threadEventsQuery.data?.data?.results, selectedThread]);
+
const labelsQuery = useLabelsList({ mailbox_id: selectedMailbox?.id ?? '' }, {
query: {
enabled: !!selectedMailbox,
@@ -233,6 +286,9 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
}
if (selectedThread) {
await queryClient.invalidateQueries({ queryKey: ['messages', selectedThread.id] });
+ await queryClient.invalidateQueries({
+ queryKey: getThreadsEventsListQueryKey(selectedThread.id)
+ });
if (source && ((source.metadata.ids ?? []).length ?? 0) > 0) {
_updateThreadMessagesQueryData(selectedThread.id, source);
}
@@ -271,6 +327,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
mailboxes: mailboxQuery.data?.data ?? null,
threads: flattenThreads ?? null,
messages: messagesQuery.data?.data ?? null,
+ threadItems,
selectedMailbox,
selectedThread,
unselectThread,
@@ -279,7 +336,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
invalidateThreadsStats,
invalidateLabels,
refetchMailboxes: mailboxQuery.refetch,
- isPending: mailboxQuery.isPending || threadsQuery.isPending || messagesQuery.isPending,
+ isPending: mailboxQuery.isPending || threadsQuery.isPending || messagesQuery.isPending || threadEventsQuery.isPending,
queryStates: {
mailboxes: {
status: mailboxQuery.status,
@@ -311,6 +368,8 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
mailboxQuery,
threadsQuery,
messagesQuery,
+ threadEventsQuery,
+ threadItems,
selectedMailbox,
selectedThread,
]);
diff --git a/src/frontend/src/styles/main.scss b/src/frontend/src/styles/main.scss
index 8d2a9c16a..435cc8707 100644
--- a/src/frontend/src/styles/main.scss
+++ b/src/frontend/src/styles/main.scss
@@ -41,6 +41,7 @@
@use "./../features/layouts/components/thread-view";
@use "./../features/layouts/components/thread-view/components/thread-action-bar";
@use "./../features/layouts/components/thread-view/components/thread-message";
+@use "./../features/layouts/components/thread-view/components/thread-event";
@use "./../features/layouts/components/thread-view/components/thread-summary";
@use "./../features/layouts/components/thread-view/components/message-reply-form";
@use "./../features/layouts/components/thread-view/components/thread-attachment-list";