-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix last read activity marker in thread
- Loading branch information
Showing
3 changed files
with
117 additions
and
123 deletions.
There are no files selected for viewing
229 changes: 113 additions & 116 deletions
229
packages/webapp/src/features/thread/components/ThreadActivities.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,146 +1,143 @@ | ||
import { MeetingContext } from '@/meeting/contexts/MeetingContext' | ||
import useCurrentMember from '@/member/hooks/useCurrentMember' | ||
import { | ||
Alert, | ||
AlertDescription, | ||
AlertTitle, | ||
Box, | ||
StackProps, | ||
StyleProps, | ||
VStack, | ||
forwardRef, | ||
} from '@chakra-ui/react' | ||
import { ThreadMemberStatusFragment, Thread_Activity_Type_Enum } from '@gql' | ||
import { Thread_Activity_Type_Enum } from '@gql' | ||
import { ThreadActivityMeetingNoteFragment } from '@rolebase/shared/model/thread_activity' | ||
import { isSameDay } from 'date-fns' | ||
import React, { | ||
forwardRef, | ||
useContext, | ||
useEffect, | ||
useMemo, | ||
useState, | ||
} from 'react' | ||
import React, { useContext, useEffect, useMemo, useState } from 'react' | ||
import { useTranslation } from 'react-i18next' | ||
import { ThreadIcon } from 'src/icons' | ||
import { ThreadContext } from '../contexts/ThreadContext' | ||
import ThreadActivity from './ThreadActivity' | ||
import ThreadDaySeparator from './ThreadDaySeparator' | ||
|
||
interface Props extends StackProps { | ||
memberStatus?: ThreadMemberStatusFragment | ||
} | ||
|
||
export const activityMeetingNoteTmpId = 'tmp' | ||
|
||
const ThreadActivities = forwardRef<HTMLDivElement, Props>( | ||
({ memberStatus, ...stackProps }, ref) => { | ||
const { t } = useTranslation() | ||
const { thread, activities } = useContext(ThreadContext)! | ||
const meetingState = useContext(MeetingContext) | ||
const currentMember = useCurrentMember() | ||
export default forwardRef(function ThreadActivities( | ||
styleProps: StyleProps, | ||
ref | ||
) { | ||
const { t } = useTranslation() | ||
const { thread, activities, memberStatus } = useContext(ThreadContext)! | ||
const meetingState = useContext(MeetingContext) | ||
|
||
// Temporary meeting note | ||
const tmpMeetingNoteActivity = useMemo(() => { | ||
if ( | ||
!thread || | ||
!activities || | ||
!meetingState?.meeting || | ||
activities.some( | ||
(a) => | ||
a.type === Thread_Activity_Type_Enum.MeetingNote && | ||
a.refMeeting?.id === meetingState.meeting?.id | ||
) | ||
) { | ||
return undefined | ||
} | ||
// Temporary meeting note | ||
const tmpMeetingNoteActivity = useMemo(() => { | ||
if ( | ||
!thread || | ||
!activities || | ||
!meetingState?.meeting || | ||
activities.some( | ||
(a) => | ||
a.type === Thread_Activity_Type_Enum.MeetingNote && | ||
a.refMeeting?.id === meetingState.meeting?.id | ||
) | ||
) { | ||
return undefined | ||
} | ||
|
||
// Add temporary meeting note to activities | ||
return { | ||
id: activityMeetingNoteTmpId, | ||
threadId: thread.id, | ||
userId: '', | ||
createdAt: new Date().toISOString(), | ||
type: Thread_Activity_Type_Enum.MeetingNote, | ||
refMeeting: meetingState.meeting, | ||
reactions: [], | ||
data: { | ||
notes: '', | ||
}, | ||
} as ThreadActivityMeetingNoteFragment | ||
}, [thread, activities, meetingState?.meeting?.id]) | ||
// Add temporary meeting note to activities | ||
return { | ||
id: activityMeetingNoteTmpId, | ||
threadId: thread.id, | ||
userId: '', | ||
createdAt: new Date().toISOString(), | ||
type: Thread_Activity_Type_Enum.MeetingNote, | ||
refMeeting: meetingState.meeting, | ||
reactions: [], | ||
data: { | ||
notes: '', | ||
}, | ||
} as ThreadActivityMeetingNoteFragment | ||
}, [thread?.id, activities, meetingState?.meeting?.id]) | ||
|
||
// Previous status to show a mark | ||
const [lastReadActivityId, setLastReadActivityId] = useState< | ||
string | undefined | ||
>() | ||
// Show read mark on last read activity: | ||
// - at first load | ||
// - when it changes for an activity that is not the last one | ||
const [lastReadActivityId, setLastReadActivityId] = useState< | ||
string | undefined | ||
>() | ||
|
||
// Update read mark | ||
useEffect(() => { | ||
if (!thread || !activities || !currentMember) { | ||
return | ||
} | ||
const lastActivityId = activities[activities.length - 1]?.id || null | ||
const lastReadActivityId = memberStatus?.lastReadActivityId | ||
// Update read mark at first load | ||
useEffect(() => { | ||
if (!thread || !activities || thread.id !== activities[0].threadId) { | ||
return | ||
} | ||
const lastActivityId = activities[activities.length - 1]?.id | ||
const readActivityId = memberStatus?.lastReadActivityId | ||
|
||
if (lastReadActivityId && lastReadActivityId !== lastActivityId) { | ||
setLastReadActivityId(lastReadActivityId) | ||
} | ||
}, [ | ||
memberStatus, | ||
// Member status may be provided after other deps, | ||
// so we need to check the existence of their values | ||
!thread, | ||
!activities, | ||
!currentMember, | ||
]) | ||
if (readActivityId && readActivityId !== lastActivityId) { | ||
// Set mark to last read activity when opening thread | ||
setLastReadActivityId(readActivityId) | ||
} else { | ||
// Reset | ||
setLastReadActivityId(undefined) | ||
} | ||
}, [thread?.id, activities?.[0].threadId]) | ||
|
||
return ( | ||
<VStack spacing={0} mb={2} align="stretch" ref={ref} {...stackProps}> | ||
{activities && | ||
activities.map((activity, i) => ( | ||
<React.Fragment key={`activity_${activity.id}`}> | ||
{(i === 0 || | ||
!isSameDay( | ||
new Date(activity.createdAt), | ||
new Date(activities[i - 1].createdAt) | ||
)) && <ThreadDaySeparator date={activity.createdAt} />} | ||
// Update read mark when last read activity changes | ||
useEffect(() => { | ||
if (!memberStatus || !activities) return | ||
const readActivityId = memberStatus.lastReadActivityId | ||
if (!readActivityId) return | ||
const activityExists = activities.some((a) => a.id === readActivityId) | ||
const lastActivityId = activities[activities.length - 1]?.id | ||
if (activityExists && readActivityId !== lastActivityId) { | ||
setLastReadActivityId(readActivityId) | ||
} | ||
}, [memberStatus?.lastReadActivityId]) | ||
|
||
<ThreadActivity activity={activity} /> | ||
return ( | ||
<VStack spacing={0} mb={2} align="stretch" ref={ref} {...styleProps}> | ||
{activities && | ||
activities.map((activity, i) => ( | ||
<React.Fragment key={`activity_${activity.id}`}> | ||
{(i === 0 || | ||
!isSameDay( | ||
new Date(activity.createdAt), | ||
new Date(activities[i - 1].createdAt) | ||
)) && <ThreadDaySeparator date={activity.createdAt} />} | ||
|
||
{lastReadActivityId === activity.id && ( | ||
<Box h="3px" w="100%" bg="red.200" _dark={{ bg: 'red.800' }} /> | ||
)} | ||
</React.Fragment> | ||
))} | ||
<ThreadActivity activity={activity} /> | ||
|
||
{activities?.length === 0 && | ||
!tmpMeetingNoteActivity && | ||
!thread?.archived && ( | ||
<Alert | ||
status="success" | ||
variant="subtle" | ||
flexDirection="column" | ||
alignItems="center" | ||
justifyContent="center" | ||
textAlign="center" | ||
height="200px" | ||
> | ||
<ThreadIcon size={40} /> | ||
<AlertTitle mt={4} mb={1} fontSize="lg"> | ||
{t('ThreadActivities.emptyTitle')} | ||
</AlertTitle> | ||
<AlertDescription maxWidth="sm"> | ||
{t('ThreadActivities.emptyDescription')} | ||
</AlertDescription> | ||
</Alert> | ||
)} | ||
{lastReadActivityId === activity.id && ( | ||
<Box h="3px" w="100%" bg="red.200" _dark={{ bg: 'red.800' }} /> | ||
)} | ||
</React.Fragment> | ||
))} | ||
|
||
{tmpMeetingNoteActivity && ( | ||
<ThreadActivity activity={tmpMeetingNoteActivity} /> | ||
{activities?.length === 0 && | ||
!tmpMeetingNoteActivity && | ||
!thread?.archived && ( | ||
<Alert | ||
status="success" | ||
variant="subtle" | ||
flexDirection="column" | ||
alignItems="center" | ||
justifyContent="center" | ||
textAlign="center" | ||
height="200px" | ||
> | ||
<ThreadIcon size={40} /> | ||
<AlertTitle mt={4} mb={1} fontSize="lg"> | ||
{t('ThreadActivities.emptyTitle')} | ||
</AlertTitle> | ||
<AlertDescription maxWidth="sm"> | ||
{t('ThreadActivities.emptyDescription')} | ||
</AlertDescription> | ||
</Alert> | ||
)} | ||
</VStack> | ||
) | ||
} | ||
) | ||
|
||
ThreadActivities.displayName = 'ThreadActivities' | ||
|
||
export default ThreadActivities | ||
{tmpMeetingNoteActivity && ( | ||
<ThreadActivity activity={tmpMeetingNoteActivity} /> | ||
)} | ||
</VStack> | ||
) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters