diff --git a/js/app/packages/app/component/UnifiedListView.tsx b/js/app/packages/app/component/UnifiedListView.tsx index d0b6abc5d53..79b967736c5 100644 --- a/js/app/packages/app/component/UnifiedListView.tsx +++ b/js/app/packages/app/component/UnifiedListView.tsx @@ -3,6 +3,7 @@ import { useGlobalNotificationSource, } from '@app/component/GlobalAppState'; import { URL_PARAMS as CHANNEL_PARAMS } from '@block-channel/constants'; +import { URL_PARAMS as EMAIL_PARAMS } from '@block-email/constants'; import { URL_PARAMS as MD_PARAMS } from '@block-md/constants'; import { URL_PARAMS as PDF_PARAMS } from '@block-pdf/signal/location'; import { Button } from '@core/component/FormControls/Button'; @@ -938,13 +939,26 @@ export function UnifiedListView(props: UnifiedListViewProps) { handle?.activate(); - if (entity.type === 'channel' && isSearchEntity(entity)) { - const location = entity.search.contentHitData?.at(0)?.location; - if (!location) return; - const blockHandle = await blockOrchestrator.getBlockHandle(entity.id); - await blockHandle?.goToLocationFromParams({ - [CHANNEL_PARAMS.message]: location.messageId, - }); + if (!isSearchEntity(entity)) return; + + const location = entity.search.contentHitData?.at(0)?.location; + if (!location) return; + + switch (location.type) { + case 'channel': { + const blockHandle = await blockOrchestrator.getBlockHandle(entity.id); + await blockHandle?.goToLocationFromParams({ + [CHANNEL_PARAMS.message]: location.messageId, + }); + break; + } + case 'email': { + const blockHandle = await blockOrchestrator.getBlockHandle(entity.id); + await blockHandle?.goToLocationFromParams({ + [EMAIL_PARAMS.messageId]: location.messageId, + }); + break; + } } }; diff --git a/js/app/packages/block-channel/component/MessageList/MessageList.tsx b/js/app/packages/block-channel/component/MessageList/MessageList.tsx index 9ccc8252522..25fc3b57dd9 100644 --- a/js/app/packages/block-channel/component/MessageList/MessageList.tsx +++ b/js/app/packages/block-channel/component/MessageList/MessageList.tsx @@ -15,6 +15,7 @@ import { import { CustomScrollbar } from '@core/component/CustomScrollbar'; import { TextButton } from '@core/component/TextButton'; import { observedSize } from '@core/directive/observedSize'; +import { createActiveTarget } from '@core/signal/activeTarget'; import type { InputAttachment } from '@core/store/cacheChannelInput'; import SunIcon from '@icon/duotone/sun-horizon-duotone.svg'; import ArrowDownIcon from '@icon/regular/arrow-down.svg'; @@ -138,34 +139,10 @@ export function MessageList(props: MessageListProps) { }) ); - const [activeTargetMessage, setActiveTargetMessage] = createSignal< - | { - messageId: string; - threadId?: string; - } - | undefined - >(); - - let targetTimeoutId: ReturnType | undefined; - - createEffect(() => { - const target = props.targetMessage(); - - if (targetTimeoutId) { - clearTimeout(targetTimeoutId); - targetTimeoutId = undefined; - } - - if (target) { - setActiveTargetMessage(target); - targetTimeoutId = setTimeout(() => { - setActiveTargetMessage(undefined); - targetTimeoutId = undefined; - }, TARGET_MESSAGE_ACTIVE_TIME); - } else { - setActiveTargetMessage(undefined); - } - }); + const activeTargetMessage = createActiveTarget( + props.targetMessage, + TARGET_MESSAGE_ACTIVE_TIME + ); let scrollTimeoutId: ReturnType | undefined; let scrollHintTimeoutId: ReturnType | undefined; diff --git a/js/app/packages/block-channel/constants.ts b/js/app/packages/block-channel/constants.ts index e29f1651cc3..ec3215161a1 100644 --- a/js/app/packages/block-channel/constants.ts +++ b/js/app/packages/block-channel/constants.ts @@ -1,3 +1,5 @@ +import { DEFAULT_ACTIVE_TARGET_TIME } from '@core/signal/activeTarget'; + export const URL_PARAMS = { thread: 'thread_id', message: 'message_id', @@ -7,4 +9,4 @@ export const URL_PARAMS = { export const COLLAPSED_THREAD_INDEX_CUTOFF = 2; // The time in milliseconds that a target message will be active before it is considered stale. -export const TARGET_MESSAGE_ACTIVE_TIME = 800; +export const TARGET_MESSAGE_ACTIVE_TIME = DEFAULT_ACTIVE_TARGET_TIME; diff --git a/js/app/packages/block-email/component/Email.tsx b/js/app/packages/block-email/component/Email.tsx index 0b683480932..5be0ed5707b 100644 --- a/js/app/packages/block-email/component/Email.tsx +++ b/js/app/packages/block-email/component/Email.tsx @@ -1,10 +1,13 @@ import { useGlobalNotificationSource } from '@app/component/GlobalAppState'; import { TOKENS } from '@core/hotkey/tokens'; import { registerScopeSignalHotkey } from '@core/hotkey/utils'; +import { createMethodRegistration } from '@core/orchestrator'; +import { createActiveTarget } from '@core/signal/activeTarget'; import { blockElementSignal, blockHotkeyScopeSignal, } from '@core/signal/blockElement'; +import { blockHandleSignal } from '@core/signal/load'; import { type ContactInfo, isPersonEmailContact, @@ -32,6 +35,7 @@ import { untrack, } from 'solid-js'; import { createStore } from 'solid-js/store'; +import { URL_PARAMS } from '../constants'; import { isScrollingToMessage } from '../signal/scrollState'; import type { createThreadMessagesResource } from '../signal/threadMessages'; import { useThreadNavigation } from '../signal/threadNavigation'; @@ -64,7 +68,17 @@ export function Email(props: EmailProps) { const blockElement = blockElementSignal.get; const [searchParams] = useSearchParams(); - const targetMessageId = () => searchParams.message_id; // reactive + const searchParamsMessageId = () => { + if (typeof searchParams.message_id === 'string') { + return searchParams.message_id; + } else if (Array.isArray(searchParams.message_id)) { + return searchParams.message_id[0]; + } + return undefined; + }; + const [targetMessageId, setTargetMessageId] = createSignal< + string | undefined + >(searchParamsMessageId()); const filteredMessages = createMemo(() => { return ( @@ -198,6 +212,21 @@ export function Email(props: EmailProps) { const [isContainerFilled, setIsContainerFilled] = createSignal(false); const [hasHandledTarget, setHasHandledTarget] = createSignal(false); + const activeTargetMessageId = createActiveTarget(targetMessageId); + + const blockHandle = blockHandleSignal.get; + createMethodRegistration(blockHandle, { + goToLocationFromParams: (params: Record) => { + if (params[URL_PARAMS.messageId]) { + setTargetMessageId(undefined); + setTimeout(() => { + setTargetMessageId(params[URL_PARAMS.messageId]); + setHasHandledTarget(false); + }, 0); + } + }, + }); + // ============================================ // SCROLLING LOGIC HELPER FUNCTIONS // ============================================ @@ -360,7 +389,7 @@ export function Email(props: EmailProps) { // This effect handles scrolling to a specific message (if provided via URL) or scrolling to the last message by default // This effect should only run once. createEffect(() => { - if (untrack(hasHandledTarget)) return; + if (hasHandledTarget()) return; const resource = props.threadMessagesResource(); if (!resource) return; // Check if initial loading is complete @@ -383,8 +412,8 @@ export function Email(props: EmailProps) { // Mark as handled to prevent re-running setHasHandledTarget(true); - // Check for target message in URL - const targetMessageId_ = untrack(targetMessageId); + // Check for target message + const targetMessageId_ = targetMessageId(); if (targetMessageId_ && typeof targetMessageId_ !== 'string') return; if (targetMessageId_) { @@ -582,6 +611,7 @@ export function Email(props: EmailProps) { filteredMessages, threadData: props.threadData, archiveThread, + activeTargetMessageId, }} > diff --git a/js/app/packages/block-email/component/EmailContext.tsx b/js/app/packages/block-email/component/EmailContext.tsx index f05d9fa0995..76db4248edb 100644 --- a/js/app/packages/block-email/component/EmailContext.tsx +++ b/js/app/packages/block-email/component/EmailContext.tsx @@ -30,7 +30,8 @@ export type EmailContextValue = { setFocusedMessageId: Setter; filteredMessages: Accessor; threadData: Accessor; - archiveThread: () => boolean; + archiveThread: Accessor; + activeTargetMessageId: Accessor; }; const EmailContext = createContext(); diff --git a/js/app/packages/block-email/component/MessageContainer.tsx b/js/app/packages/block-email/component/MessageContainer.tsx index 0baf6e7cdeb..77231305459 100644 --- a/js/app/packages/block-email/component/MessageContainer.tsx +++ b/js/app/packages/block-email/component/MessageContainer.tsx @@ -2,14 +2,7 @@ import { Message } from '@core/component/Message'; import { useDisplayName } from '@core/user'; import type { MessageWithBodyReplyless } from '@service-email/generated/schemas'; import { useUserId } from '@service-gql/client'; -import { - type Accessor, - createEffect, - createMemo, - createSignal, - For, - Show, -} from 'solid-js'; +import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'; import type { SetStoreFunction } from 'solid-js/store'; import { Portal } from 'solid-js/web'; import { EmailAttachmentPill } from './AttachmentPill'; @@ -20,9 +13,12 @@ import { EmailMessageTopBar } from './EmailMessageTopBar'; interface MessageContainerProps { message: MessageWithBodyReplyless; - index: Accessor; expandedMessageBodyIds: Record; setExpandedMessageBodyIds: SetStoreFunction>; + isFirstMessage: boolean; + isLastMessage: boolean; + isFocused: boolean; + isTarget: boolean; } export function MessageContainer(props: MessageContainerProps) { @@ -47,18 +43,6 @@ export function MessageContainer(props: MessageContainerProps) { return props.expandedMessageBodyIds[props.message.db_id ?? '']; }); - const isFocused = createMemo(() => { - return props.message.db_id === context.focusedMessageId(); - }); - - const isFirstMessage = createMemo(() => { - return props.index() === 0; - }); - - const isLastMessage = createMemo(() => { - return props.index() === (context.filteredMessages().length ?? 0) - 1; - }); - const isNewMessage = createMemo(() => { return ( props.message.labels.find((l) => l.provider_label_id === 'UNREAD') !== @@ -96,7 +80,7 @@ export function MessageContainer(props: MessageContainerProps) { // expand appropriate messages createEffect(() => { const id = props.message.db_id; - if (isLastMessage() && id) { + if (props.isLastMessage && id) { props.setExpandedMessageBodyIds(id, true); } if (isNewMessage() && id) { @@ -108,23 +92,25 @@ export function MessageContainer(props: MessageContainerProps) {
@@ -148,7 +134,7 @@ export function MessageContainer(props: MessageContainerProps) {
- + >({}); - - const isScrollingToMessage = props.isScrollingToMessage; + const isFocusedSelector = createSelector( + context.focusedMessageId, + (a, b) => !!a && !!b && a === b + ); + const isTargetSelector = createSelector( + context.activeTargetMessageId, + (a, b) => !!a && !!b && a === b + ); return (
{ // Don't load more if we're programmatically scrolling to a message - if (isScrollingToMessage() || !props.initialLoadComplete) return; + if (props.isScrollingToMessage() || !props.initialLoadComplete) return; const threshold = 300; const isNearBeginning = e.currentTarget.scrollTop <= threshold; @@ -46,8 +52,13 @@ export function MessageList(props: MessageListProps) { {(message, index) => { return ( diff --git a/js/app/packages/block-email/constants.ts b/js/app/packages/block-email/constants.ts new file mode 100644 index 00000000000..ccbb4be222f --- /dev/null +++ b/js/app/packages/block-email/constants.ts @@ -0,0 +1,3 @@ +export const URL_PARAMS = { + messageId: 'message_id', +}; diff --git a/js/app/packages/core/component/Message.tsx b/js/app/packages/core/component/Message.tsx index bf2a0b298de..456497a95f6 100644 --- a/js/app/packages/core/component/Message.tsx +++ b/js/app/packages/core/component/Message.tsx @@ -211,7 +211,7 @@ const Root: Component = (props) => {
setHover(true)} onMouseLeave={() => setHover(false)} @@ -224,7 +224,7 @@ const Root: Component = (props) => { ( + source: Accessor, + duration = DEFAULT_ACTIVE_TARGET_TIME +): Accessor { + const [active, setActive] = createSignal(); + let timeoutId: ReturnType | undefined; + + createEffect( + on(source, (target) => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + + if (target !== undefined) { + setActive(() => target); + timeoutId = setTimeout(() => { + setActive(undefined); + timeoutId = undefined; + }, duration); + } else { + setActive(undefined); + } + }) + ); + + onCleanup(() => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }); + + return active; +} diff --git a/js/app/packages/macro-entity/src/queries/search.ts b/js/app/packages/macro-entity/src/queries/search.ts index 40f9594ec19..1eaddff5a3b 100644 --- a/js/app/packages/macro-entity/src/queries/search.ts +++ b/js/app/packages/macro-entity/src/queries/search.ts @@ -37,7 +37,8 @@ type TypedInnerSearchResult = | { results: InnerSearchResult[]; type?: undefined } | { results: DocumentSearchResult[]; type: 'pdf'; searchQuery: string } | { results: DocumentSearchResult[]; type: 'md' } - | { results: ChannelSearchResult[]; type: 'channel' }; + | { results: ChannelSearchResult[]; type: 'channel' } + | { results: EmailSearchResult[]; type: 'email' }; export const isSearchEntity = ( entity: T @@ -95,6 +96,20 @@ const getSearchData = (data: TypedInnerSearchResult): SearchData => { }); break; } + case 'email': { + contentHitData = data.results.flatMap((r) => { + const contents = r.highlight.content ?? []; + return contents.map((content) => ({ + type: 'email' as const, + content: mergeAdjacentMacroEmTags(content), + location: { + type: 'email' as const, + messageId: r.message_id, + }, + })); + }); + break; + } default: { contentHitData = data.results.flatMap((r) => { const contents = r.highlight.content ?? []; @@ -163,13 +178,13 @@ const useMapSearchResponseItem = () => { } case 'email': { const emailResult = result.email_message_search_results.at(0); - // TODO: distinguish email message result from thread result if (!emailResult) { console.error('Email result not found', result); return; } const search = getSearchData({ results: result.email_message_search_results, + type: 'email', }); return { type: 'email', diff --git a/js/app/packages/macro-entity/src/types/search.ts b/js/app/packages/macro-entity/src/types/search.ts index 9c48c160715..353e42479b9 100644 --- a/js/app/packages/macro-entity/src/types/search.ts +++ b/js/app/packages/macro-entity/src/types/search.ts @@ -18,10 +18,16 @@ type ChannelMessageHighlightLocation = { messageId: string; }; +type EmailMessageHighlightLocation = { + type: 'email'; + messageId: string; +}; + export type SearchLocation = | MarkdownHighlightLocation | PdfHighlightLocation - | ChannelMessageHighlightLocation; + | ChannelMessageHighlightLocation + | EmailMessageHighlightLocation; export type ChannelContentHitData = { type: 'channel'; @@ -50,29 +56,29 @@ type GenericContentHitData = { location?: never; }; +type EmailContentHitData = { + type: 'email'; + content: string; + location: EmailMessageHighlightLocation; +}; + export type DocumentContentHitData = | MdContentHitData | PdfContentHitData | GenericContentHitData; -export type ContentHitData = T extends { - type: 'channel'; -} - ? ChannelContentHitData - : T extends { type: 'document'; fileType: 'md' } - ? MdContentHitData - : T extends { type: 'document'; fileType: 'pdf' | 'docx' } - ? PdfContentHitData - : T extends { type: 'document' } - ? DocumentContentHitData - : GenericContentHitData; +export type ContentHitData = + | DocumentContentHitData + | ChannelContentHitData + | EmailContentHitData + | GenericContentHitData; -export type SearchData = { +export type SearchData = { nameHighlight: string | null; - contentHitData: ContentHitData[] | null; + contentHitData: ContentHitData[] | null; source: 'local' | 'service'; }; export type WithSearch = T & { - search: SearchData; + search: SearchData; }; diff --git a/js/app/tsconfig.json b/js/app/tsconfig.json index f989b1a574d..900cd6efb1e 100644 --- a/js/app/tsconfig.json +++ b/js/app/tsconfig.json @@ -127,6 +127,9 @@ "@block-channel/*": [ "./packages/block-channel/*" ], + "@block-email/*": [ + "./packages/block-email/*" + ], "@block-md/*": [ "./packages/block-md/*" ],