Loading frontend/src/email/TeamEmails/ThreadHeaderCard/index.tsx +24 −6 Original line number Diff line number Diff line Loading @@ -3,7 +3,7 @@ import { css, cx } from '@emotion/css' import type { Milestone } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' import { breakWord } from '@inject/shared' import type { FC, ReactNode } from 'react' import { useMemo, type FC, type ReactNode } from 'react' import { HIGHLIGHT_MILESTONE_EVENT, type HighlightInstructorMilestoneActivityEvent, Loading Loading @@ -69,6 +69,23 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({ }) => { const { t } = useTranslationFrontend() const isSmallScreen = useMediaQuery('(max-width: 60rem)') const milestoneActivities = useMemo( () => milestones.reduce<NonNullable<Milestone['activity']>[]>( (acc, milestone) => { if ( milestone.activity && !acc.some(activity => activity.id === milestone.activity?.id) ) { acc.push(milestone.activity) } return acc }, [] ), [milestones] ) const replyText = ( <span className={replyButtonLabel}>{t('emails.reply')}</span> ) Loading Loading @@ -132,11 +149,11 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({ )} </ButtonGroup> </div> {milestones.length > 0 && ( {milestoneActivities.length > 0 && ( <div className={tagsRow}> {milestones.map(milestone => ( {milestoneActivities.map(activity => ( <Tag key={`${milestone.activity?.id}:${milestone.activity?.name}`} key={activity.id} minimal interactive intent='primary' Loading @@ -144,13 +161,14 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({ window.dispatchEvent( new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, { detail: { activityId: milestone.activity?.id ?? '0', activityIds: milestoneActivities.map(item => item.id), scrollToActivityId: activity.id, } satisfies HighlightInstructorMilestoneActivityEvent, }) ) } > {milestone.activity?.name} {activity.name} </Tag> ))} </div> Loading frontend/src/instructor/InstructorMilestones/events.ts +2 −1 Original line number Diff line number Diff line export const HIGHLIGHT_MILESTONE_EVENT = 'highlightInstructorMilestoneActivity' export interface HighlightInstructorMilestoneActivityEvent { activityId: string activityIds: string[] scrollToActivityId?: string | null } declare global { Loading frontend/src/instructor/InstructorMilestones/index.tsx +25 −40 Original line number Diff line number Diff line Loading @@ -61,9 +61,16 @@ interface InstructorMilestonesProps { const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => { const [isOpen, setIsOpen] = useState(true) const [isUncategorizedOpen, setIsUncategorizedOpen] = useState(false) const [highlightedActivityId, setHighlightedActivityId] = useState< string | null >(null) const [highlightedActivityIds, setHighlightedActivityIds] = useState< string[] >([]) const [scrollRequest, setScrollRequest] = useState<{ activityId: string | null requestId: number }>({ activityId: null, requestId: 0, }) const [{ data: learningObjectivesData }] = useTypedQuery({ query: TeamLearningObjectivesQuery, variables: { teamIds: teamIds }, Loading Loading @@ -128,8 +135,19 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => { const handleHighlight = ( event: WindowEventMap[typeof HIGHLIGHT_MILESTONE_EVENT] ) => { const activityIds = [...new Set(event.detail.activityIds.filter(Boolean))] const scrollToActivityId = event.detail.scrollToActivityId ?? null if (scrollToActivityId) { setIsOpen(true) setHighlightedActivityId(event.detail.activityId) } setHighlightedActivityIds(activityIds) if (scrollToActivityId) { setScrollRequest(current => ({ activityId: scrollToActivityId, requestId: current.requestId + 1, })) } } window.addEventListener(HIGHLIGHT_MILESTONE_EVENT, handleHighlight) Loading @@ -138,37 +156,6 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => { window.removeEventListener(HIGHLIGHT_MILESTONE_EVENT, handleHighlight) }, []) useEffect(() => { if (!highlightedActivityId || !isOpen) { return } requestAnimationFrame(() => { document .querySelector<HTMLElement>( `[data-activity-id="${highlightedActivityId}"]` ) ?.scrollIntoView({ block: 'nearest', behavior: 'smooth', }) }) }, [highlightedActivityId, isOpen]) useEffect(() => { if (!highlightedActivityId) { return } const timeout = window.setTimeout(() => { setHighlightedActivityId(current => current === highlightedActivityId ? null : current ) }, 10000) return () => window.clearTimeout(timeout) }, [highlightedActivityId]) return ( <div className={cx(container, { Loading Loading @@ -209,10 +196,8 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => { teamId={teamIds.length === 1 ? teamIds[0] : undefined} teamIds={teamIds} states={data.milestoneStates} highlighted={objective.activities.some( activity => activity.activity.id === highlightedActivityId )} highlightedActivityId={highlightedActivityId} highlightedActivityIds={highlightedActivityIds} scrollRequest={scrollRequest} /> ))} {uncategorizedMilestones.length > 0 && ( Loading frontend/src/instructor/LearningObjectives/LearningObjective.tsx +113 −54 Original line number Diff line number Diff line import { Section, SectionCard } from '@blueprintjs/core' import { Colors, Section, SectionCard } from '@blueprintjs/core' import { css, cx } from '@emotion/css' import type { MilestoneState, TeamLearningObjective } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' Loading @@ -10,6 +10,7 @@ import { reached, subtitle, title } from './classes' import LearningActivity from './LearningActivity' const objectiveClass = css` position: relative; flex-shrink: 0; margin-bottom: 1px; Loading @@ -30,13 +31,30 @@ const activities = css` margin: 1rem; ` const highlightedCount = css` position: absolute; top: 0.35rem; right: 0.6rem; z-index: 1; background-color: ${Colors.ORANGE3} !important; color: ${Colors.WHITE} !important; font-size: 0.7rem !important; line-height: 1 !important; min-height: 1.125rem !important; padding: 0 0.45rem !important; pointer-events: none; ` interface LearningObjectiveProps { objective: TeamLearningObjective teamId?: string teamIds?: string[] states?: MilestoneState[] highlighted?: boolean highlightedActivityId?: string | null highlightedActivityIds?: string[] scrollRequest?: { activityId: string | null requestId: number } disabled?: boolean } Loading @@ -45,27 +63,65 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({ teamId, teamIds, states, highlighted = false, highlightedActivityId, highlightedActivityIds = [], scrollRequest, disabled, }) => { const [expanded, setExpanded] = useState(false) const { t } = useTranslationFrontend() const didAutoExpandForHighlight = useRef(false) const handledScrollRequestId = useRef<number | null>(null) const activitiesWithMilestones = objective.activities.filter( iTeamLearningActivityCheck ) const highlightedActivitiesCount = activitiesWithMilestones.filter(activity => highlightedActivityIds.includes(activity.activity.id) ).length const containsScrollTarget = scrollRequest?.activityId ? activitiesWithMilestones.some( activity => activity.activity.id === scrollRequest.activityId ) : false useEffect(() => { if (highlighted && !didAutoExpandForHighlight.current) { didAutoExpandForHighlight.current = true setExpanded(true) if (!containsScrollTarget || !scrollRequest?.activityId) { return } if (handledScrollRequestId.current === scrollRequest.requestId) { return } if (!highlighted) { didAutoExpandForHighlight.current = false handledScrollRequestId.current = scrollRequest.requestId if (!expanded) { setExpanded(true) } }, [highlighted]) requestAnimationFrame(() => { requestAnimationFrame(() => { document .querySelector<HTMLElement>( `[data-activity-id="${scrollRequest.activityId}"]` ) ?.scrollIntoView({ block: 'nearest', behavior: 'smooth', }) }) }) }, [containsScrollTarget, expanded, scrollRequest]) return ( <div className={cx({ [objectiveClass]: true, [reached]: objective.reached })} > {highlightedActivitiesCount > 0 && ( <StyledTag content={`${highlightedActivitiesCount}`} className={highlightedCount} /> )} <Section title={<div className={title}>{objective.objective.name}</div>} subtitle={ Loading @@ -92,21 +148,21 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({ onToggle: () => setExpanded(!expanded), }} compact className={cx({ [objectiveClass]: true, [reached]: objective.reached })} rightElement={ objective.reached ? ( <StyledTag content={t('milestones.reached')} isAchieved /> ) : ( <StyledTag content={t('milestones.notReached')} isAchieved={false} /> <StyledTag content={t('milestones.notReached')} isAchieved={false} /> ) } > <SectionCard padded className={activities}> {/* currently, this component is only rendered in instructor view */} {/* TODO: change this when implementing trainee view summary */} {objective.activities .filter(iTeamLearningActivityCheck) .map(activity => ( {activitiesWithMilestones.map(activity => ( <LearningActivity key={activity.id} activity={activity} Loading @@ -114,10 +170,13 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({ teamIds={teamIds} states={states} disabled={disabled} highlighted={highlightedActivityId === activity.activity.id} highlighted={highlightedActivityIds.includes( activity.activity.id )} /> ))} </SectionCard> </Section> </div> ) } frontend/src/routes/_protected/instructor/$exerciseId/$teamId/email/$tab/$threadId.tsx +65 −16 Original line number Diff line number Diff line Loading @@ -9,7 +9,7 @@ import { import { useTranslationFrontend } from '@inject/locale' import { EmailSelection } from '@inject/shared' import { createFileRoute } from '@tanstack/react-router' import { useMemo } from 'react' import { useEffect, useMemo } from 'react' import { InstructorLandingPageRoute } from '../../..' import { OPEN_REPLY_EVENT_TYPE } from '../../../../../../../email/EmailFormOverlay/events' import { EmailCard } from '../../../../../../../email/TeamEmails/EmailCard' Loading @@ -19,6 +19,10 @@ import type { EmailThreadChecked, ExtendedEmail, } from '../../../../../../../email/typing' import { HIGHLIGHT_MILESTONE_EVENT, type HighlightInstructorMilestoneActivityEvent, } from '../../../../../../../instructor/InstructorMilestones/events' import { InstructorFilePageRoute } from '../../../file.$fileId' const emailThread = css` Loading Loading @@ -86,30 +90,75 @@ const RouteComponent = () => { ], [selectedEmailThread] ) if ( const invalidSelectedThread = !selectedEmailThread || selectedEmailThread.archived !== (tab === EmailSelection.ARCHIVED) ) { return <NonIdealState title={t('emails.selectThread')} /> const { matchingMilestones } = useMemo(() => { if (invalidSelectedThread) { return { matchingMilestones: [], } } const replyDisabled = selectedEmailThread !== undefined && !selectedEmailThread.participants.some( participant => participant.definitionAddress const participantAddressSet = new Set( selectedEmailThread.participants.map( participant => participant.definitionAddress?.address ) ) const participantAddresses = selectedEmailThread.participants .map(participant => participant.definitionAddress?.address) .filter((address): address is string => Boolean(address)) const matchingMilestones = (milestones?.milestones ?? []).filter( milestone => milestone.activity && !!milestone.activity && milestone.activity.addresses.some(address => participantAddresses.includes(address.address) participantAddressSet.has(address.address) ) ) return { matchingMilestones, } }, [invalidSelectedThread, milestones?.milestones, selectedEmailThread]) useEffect(() => { if (invalidSelectedThread) { return } const activityIds = [ ...new Set( matchingMilestones .map(milestone => milestone.activity?.id) .filter((activityId): activityId is string => Boolean(activityId)) ), ] window.dispatchEvent( new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, { detail: { activityIds, } satisfies HighlightInstructorMilestoneActivityEvent, }) ) return () => { window.dispatchEvent( new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, { detail: { activityIds: [], } satisfies HighlightInstructorMilestoneActivityEvent, }) ) } }, [invalidSelectedThread, matchingMilestones]) if (invalidSelectedThread) { return <NonIdealState title={t('emails.selectThread')} /> } const replyDisabled = selectedEmailThread !== undefined && !selectedEmailThread.participants.some( participant => participant.definitionAddress ) return ( Loading Loading
frontend/src/email/TeamEmails/ThreadHeaderCard/index.tsx +24 −6 Original line number Diff line number Diff line Loading @@ -3,7 +3,7 @@ import { css, cx } from '@emotion/css' import type { Milestone } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' import { breakWord } from '@inject/shared' import type { FC, ReactNode } from 'react' import { useMemo, type FC, type ReactNode } from 'react' import { HIGHLIGHT_MILESTONE_EVENT, type HighlightInstructorMilestoneActivityEvent, Loading Loading @@ -69,6 +69,23 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({ }) => { const { t } = useTranslationFrontend() const isSmallScreen = useMediaQuery('(max-width: 60rem)') const milestoneActivities = useMemo( () => milestones.reduce<NonNullable<Milestone['activity']>[]>( (acc, milestone) => { if ( milestone.activity && !acc.some(activity => activity.id === milestone.activity?.id) ) { acc.push(milestone.activity) } return acc }, [] ), [milestones] ) const replyText = ( <span className={replyButtonLabel}>{t('emails.reply')}</span> ) Loading Loading @@ -132,11 +149,11 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({ )} </ButtonGroup> </div> {milestones.length > 0 && ( {milestoneActivities.length > 0 && ( <div className={tagsRow}> {milestones.map(milestone => ( {milestoneActivities.map(activity => ( <Tag key={`${milestone.activity?.id}:${milestone.activity?.name}`} key={activity.id} minimal interactive intent='primary' Loading @@ -144,13 +161,14 @@ export const ThreadHeaderCard: FC<ThreadHeaderCardProps> = ({ window.dispatchEvent( new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, { detail: { activityId: milestone.activity?.id ?? '0', activityIds: milestoneActivities.map(item => item.id), scrollToActivityId: activity.id, } satisfies HighlightInstructorMilestoneActivityEvent, }) ) } > {milestone.activity?.name} {activity.name} </Tag> ))} </div> Loading
frontend/src/instructor/InstructorMilestones/events.ts +2 −1 Original line number Diff line number Diff line export const HIGHLIGHT_MILESTONE_EVENT = 'highlightInstructorMilestoneActivity' export interface HighlightInstructorMilestoneActivityEvent { activityId: string activityIds: string[] scrollToActivityId?: string | null } declare global { Loading
frontend/src/instructor/InstructorMilestones/index.tsx +25 −40 Original line number Diff line number Diff line Loading @@ -61,9 +61,16 @@ interface InstructorMilestonesProps { const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => { const [isOpen, setIsOpen] = useState(true) const [isUncategorizedOpen, setIsUncategorizedOpen] = useState(false) const [highlightedActivityId, setHighlightedActivityId] = useState< string | null >(null) const [highlightedActivityIds, setHighlightedActivityIds] = useState< string[] >([]) const [scrollRequest, setScrollRequest] = useState<{ activityId: string | null requestId: number }>({ activityId: null, requestId: 0, }) const [{ data: learningObjectivesData }] = useTypedQuery({ query: TeamLearningObjectivesQuery, variables: { teamIds: teamIds }, Loading Loading @@ -128,8 +135,19 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => { const handleHighlight = ( event: WindowEventMap[typeof HIGHLIGHT_MILESTONE_EVENT] ) => { const activityIds = [...new Set(event.detail.activityIds.filter(Boolean))] const scrollToActivityId = event.detail.scrollToActivityId ?? null if (scrollToActivityId) { setIsOpen(true) setHighlightedActivityId(event.detail.activityId) } setHighlightedActivityIds(activityIds) if (scrollToActivityId) { setScrollRequest(current => ({ activityId: scrollToActivityId, requestId: current.requestId + 1, })) } } window.addEventListener(HIGHLIGHT_MILESTONE_EVENT, handleHighlight) Loading @@ -138,37 +156,6 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => { window.removeEventListener(HIGHLIGHT_MILESTONE_EVENT, handleHighlight) }, []) useEffect(() => { if (!highlightedActivityId || !isOpen) { return } requestAnimationFrame(() => { document .querySelector<HTMLElement>( `[data-activity-id="${highlightedActivityId}"]` ) ?.scrollIntoView({ block: 'nearest', behavior: 'smooth', }) }) }, [highlightedActivityId, isOpen]) useEffect(() => { if (!highlightedActivityId) { return } const timeout = window.setTimeout(() => { setHighlightedActivityId(current => current === highlightedActivityId ? null : current ) }, 10000) return () => window.clearTimeout(timeout) }, [highlightedActivityId]) return ( <div className={cx(container, { Loading Loading @@ -209,10 +196,8 @@ const InstructorMilestones: FC<InstructorMilestonesProps> = ({ teamIds }) => { teamId={teamIds.length === 1 ? teamIds[0] : undefined} teamIds={teamIds} states={data.milestoneStates} highlighted={objective.activities.some( activity => activity.activity.id === highlightedActivityId )} highlightedActivityId={highlightedActivityId} highlightedActivityIds={highlightedActivityIds} scrollRequest={scrollRequest} /> ))} {uncategorizedMilestones.length > 0 && ( Loading
frontend/src/instructor/LearningObjectives/LearningObjective.tsx +113 −54 Original line number Diff line number Diff line import { Section, SectionCard } from '@blueprintjs/core' import { Colors, Section, SectionCard } from '@blueprintjs/core' import { css, cx } from '@emotion/css' import type { MilestoneState, TeamLearningObjective } from '@inject/graphql' import { useTranslationFrontend } from '@inject/locale' Loading @@ -10,6 +10,7 @@ import { reached, subtitle, title } from './classes' import LearningActivity from './LearningActivity' const objectiveClass = css` position: relative; flex-shrink: 0; margin-bottom: 1px; Loading @@ -30,13 +31,30 @@ const activities = css` margin: 1rem; ` const highlightedCount = css` position: absolute; top: 0.35rem; right: 0.6rem; z-index: 1; background-color: ${Colors.ORANGE3} !important; color: ${Colors.WHITE} !important; font-size: 0.7rem !important; line-height: 1 !important; min-height: 1.125rem !important; padding: 0 0.45rem !important; pointer-events: none; ` interface LearningObjectiveProps { objective: TeamLearningObjective teamId?: string teamIds?: string[] states?: MilestoneState[] highlighted?: boolean highlightedActivityId?: string | null highlightedActivityIds?: string[] scrollRequest?: { activityId: string | null requestId: number } disabled?: boolean } Loading @@ -45,27 +63,65 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({ teamId, teamIds, states, highlighted = false, highlightedActivityId, highlightedActivityIds = [], scrollRequest, disabled, }) => { const [expanded, setExpanded] = useState(false) const { t } = useTranslationFrontend() const didAutoExpandForHighlight = useRef(false) const handledScrollRequestId = useRef<number | null>(null) const activitiesWithMilestones = objective.activities.filter( iTeamLearningActivityCheck ) const highlightedActivitiesCount = activitiesWithMilestones.filter(activity => highlightedActivityIds.includes(activity.activity.id) ).length const containsScrollTarget = scrollRequest?.activityId ? activitiesWithMilestones.some( activity => activity.activity.id === scrollRequest.activityId ) : false useEffect(() => { if (highlighted && !didAutoExpandForHighlight.current) { didAutoExpandForHighlight.current = true setExpanded(true) if (!containsScrollTarget || !scrollRequest?.activityId) { return } if (handledScrollRequestId.current === scrollRequest.requestId) { return } if (!highlighted) { didAutoExpandForHighlight.current = false handledScrollRequestId.current = scrollRequest.requestId if (!expanded) { setExpanded(true) } }, [highlighted]) requestAnimationFrame(() => { requestAnimationFrame(() => { document .querySelector<HTMLElement>( `[data-activity-id="${scrollRequest.activityId}"]` ) ?.scrollIntoView({ block: 'nearest', behavior: 'smooth', }) }) }) }, [containsScrollTarget, expanded, scrollRequest]) return ( <div className={cx({ [objectiveClass]: true, [reached]: objective.reached })} > {highlightedActivitiesCount > 0 && ( <StyledTag content={`${highlightedActivitiesCount}`} className={highlightedCount} /> )} <Section title={<div className={title}>{objective.objective.name}</div>} subtitle={ Loading @@ -92,21 +148,21 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({ onToggle: () => setExpanded(!expanded), }} compact className={cx({ [objectiveClass]: true, [reached]: objective.reached })} rightElement={ objective.reached ? ( <StyledTag content={t('milestones.reached')} isAchieved /> ) : ( <StyledTag content={t('milestones.notReached')} isAchieved={false} /> <StyledTag content={t('milestones.notReached')} isAchieved={false} /> ) } > <SectionCard padded className={activities}> {/* currently, this component is only rendered in instructor view */} {/* TODO: change this when implementing trainee view summary */} {objective.activities .filter(iTeamLearningActivityCheck) .map(activity => ( {activitiesWithMilestones.map(activity => ( <LearningActivity key={activity.id} activity={activity} Loading @@ -114,10 +170,13 @@ export const LearningObjective: FC<LearningObjectiveProps> = ({ teamIds={teamIds} states={states} disabled={disabled} highlighted={highlightedActivityId === activity.activity.id} highlighted={highlightedActivityIds.includes( activity.activity.id )} /> ))} </SectionCard> </Section> </div> ) }
frontend/src/routes/_protected/instructor/$exerciseId/$teamId/email/$tab/$threadId.tsx +65 −16 Original line number Diff line number Diff line Loading @@ -9,7 +9,7 @@ import { import { useTranslationFrontend } from '@inject/locale' import { EmailSelection } from '@inject/shared' import { createFileRoute } from '@tanstack/react-router' import { useMemo } from 'react' import { useEffect, useMemo } from 'react' import { InstructorLandingPageRoute } from '../../..' import { OPEN_REPLY_EVENT_TYPE } from '../../../../../../../email/EmailFormOverlay/events' import { EmailCard } from '../../../../../../../email/TeamEmails/EmailCard' Loading @@ -19,6 +19,10 @@ import type { EmailThreadChecked, ExtendedEmail, } from '../../../../../../../email/typing' import { HIGHLIGHT_MILESTONE_EVENT, type HighlightInstructorMilestoneActivityEvent, } from '../../../../../../../instructor/InstructorMilestones/events' import { InstructorFilePageRoute } from '../../../file.$fileId' const emailThread = css` Loading Loading @@ -86,30 +90,75 @@ const RouteComponent = () => { ], [selectedEmailThread] ) if ( const invalidSelectedThread = !selectedEmailThread || selectedEmailThread.archived !== (tab === EmailSelection.ARCHIVED) ) { return <NonIdealState title={t('emails.selectThread')} /> const { matchingMilestones } = useMemo(() => { if (invalidSelectedThread) { return { matchingMilestones: [], } } const replyDisabled = selectedEmailThread !== undefined && !selectedEmailThread.participants.some( participant => participant.definitionAddress const participantAddressSet = new Set( selectedEmailThread.participants.map( participant => participant.definitionAddress?.address ) ) const participantAddresses = selectedEmailThread.participants .map(participant => participant.definitionAddress?.address) .filter((address): address is string => Boolean(address)) const matchingMilestones = (milestones?.milestones ?? []).filter( milestone => milestone.activity && !!milestone.activity && milestone.activity.addresses.some(address => participantAddresses.includes(address.address) participantAddressSet.has(address.address) ) ) return { matchingMilestones, } }, [invalidSelectedThread, milestones?.milestones, selectedEmailThread]) useEffect(() => { if (invalidSelectedThread) { return } const activityIds = [ ...new Set( matchingMilestones .map(milestone => milestone.activity?.id) .filter((activityId): activityId is string => Boolean(activityId)) ), ] window.dispatchEvent( new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, { detail: { activityIds, } satisfies HighlightInstructorMilestoneActivityEvent, }) ) return () => { window.dispatchEvent( new CustomEvent(HIGHLIGHT_MILESTONE_EVENT, { detail: { activityIds: [], } satisfies HighlightInstructorMilestoneActivityEvent, }) ) } }, [invalidSelectedThread, matchingMilestones]) if (invalidSelectedThread) { return <NonIdealState title={t('emails.selectThread')} /> } const replyDisabled = selectedEmailThread !== undefined && !selectedEmailThread.participants.some( participant => participant.definitionAddress ) return ( Loading