Files
reflector/www/app/[domain]/transcripts/topicList.tsx
2024-07-18 12:08:32 +02:00

260 lines
7.8 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { formatTime } from "../../lib/time";
import ScrollToBottom from "./scrollToBottom";
import { Topic } from "./webSocketTypes";
import { generateHighContrastColor } from "../../lib/utils";
import useParticipants from "./useParticipants";
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Flex,
Text,
} from "@chakra-ui/react";
import { featureEnabled } from "../domainContext";
type TopicListProps = {
topics: Topic[];
useActiveTopic: [
Topic | null,
React.Dispatch<React.SetStateAction<Topic | null>>,
];
autoscroll: boolean;
transcriptId: string;
status: string;
currentTranscriptText: any;
};
export function TopicList({
topics,
useActiveTopic,
autoscroll,
transcriptId,
status,
currentTranscriptText,
}: TopicListProps) {
const [activeTopic, setActiveTopic] = useActiveTopic;
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
const participants = useParticipants(transcriptId);
const scrollToTopic = () => {
const topicDiv = document.getElementById(
`accordion-button-topic-${activeTopic?.id}`,
);
setTimeout(() => {
topicDiv?.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest",
});
}, 200);
};
useEffect(() => {
if (activeTopic) scrollToTopic();
}, [activeTopic]);
// scroll top is not rounded, heights are, so exact match won't work.
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
const toggleScroll = (element) => {
const bottom =
Math.abs(
element.scrollHeight - element.clientHeight - element.scrollTop,
) < 2 || element.scrollHeight == element.clientHeight;
if (!bottom && autoscrollEnabled) {
setAutoscrollEnabled(false);
} else if (bottom && !autoscrollEnabled) {
setAutoscrollEnabled(true);
}
};
const handleScroll = (e) => {
toggleScroll(e.target);
};
useEffect(() => {
if (autoscroll) {
const topicsDiv = document.getElementById("scroll-div");
topicsDiv && toggleScroll(topicsDiv);
}
}, [activeTopic, autoscroll]);
useEffect(() => {
if (autoscroll && autoscrollEnabled) scrollToBottom();
}, [topics.length, currentTranscriptText]);
const scrollToBottom = () => {
const topicsDiv = document.getElementById("scroll-div");
if (topicsDiv) topicsDiv.scrollTop = topicsDiv.scrollHeight;
};
const getSpeakerName = (speakerNumber: number) => {
if (!participants.response) return;
return (
participants.response.find(
(participant) => participant.speaker == speakerNumber,
)?.name || `Speaker ${speakerNumber}`
);
};
const requireLogin = featureEnabled("requireLogin");
useEffect(() => {
setActiveTopic(topics[topics.length - 1]);
}, [topics]);
return (
<Flex
position={"relative"}
w={"100%"}
h={"95%"}
flexDirection={"column"}
justify={"center"}
align={"center"}
flexShrink={0}
>
{autoscroll && (
<ScrollToBottom
visible={!autoscrollEnabled}
handleScrollBottom={scrollToBottom}
/>
)}
<Box
id="scroll-div"
overflowY={"auto"}
h={"100%"}
onScroll={handleScroll}
width="full"
padding={2}
>
{topics.length > 0 && (
<Accordion
index={topics.findIndex((topic) => topic.id == activeTopic?.id)}
variant="custom"
allowToggle
>
{topics.map((topic, index) => (
<AccordionItem
key={index}
background={{
base: "light",
hover: "gray.100",
focus: "gray.100",
}}
id={`topic-${topic.id}`}
>
<Flex dir="row" letterSpacing={".2"}>
<AccordionButton
onClick={() => {
setActiveTopic(
activeTopic?.id == topic.id ? null : topic,
);
}}
>
<AccordionIcon />
<Box as="span" textAlign="left" ml="1">
{topic.title}{" "}
<Text
as="span"
color="gray.500"
fontSize="sm"
fontWeight="bold"
>
&nbsp;[{formatTime(topic.timestamp)}]&nbsp;-&nbsp;[
{formatTime(topic.timestamp + (topic.duration || 0))}]
</Text>
</Box>
</AccordionButton>
</Flex>
<AccordionPanel>
{topic.segments ? (
<>
{topic.segments.map((segment, index: number) => (
<Text
key={index}
className="text-left text-slate-500 text-sm md:text-base"
pb={2}
lineHeight={"1.3"}
>
<Text
as="span"
color={"gray.500"}
fontFamily={"monospace"}
fontSize={"sm"}
>
[{formatTime(segment.start)}]
</Text>
<Text
as="span"
fontWeight={"bold"}
fontSize={"sm"}
color={generateHighContrastColor(
`Speaker ${segment.speaker}`,
[96, 165, 250],
)}
>
{" "}
{getSpeakerName(segment.speaker)}:
</Text>{" "}
<span>{segment.text}</span>
</Text>
))}
</>
) : (
<>{topic.transcript}</>
)}
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
)}
{status == "recording" && (
<Box textAlign={"center"}>
<Text>{currentTranscriptText}</Text>
</Box>
)}
{(status == "recording" || status == "idle") &&
currentTranscriptText.length == 0 &&
topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Text>
Full discussion transcript will appear here after you start
recording.
</Text>
<Text>
It may take up to 5 minutes of conversation to first appear.
</Text>
</Box>
)}
{status == "processing" && (
<Box textAlign={"center"} textColor="gray">
<Text>We are processing the recording, please wait.</Text>
{!requireLogin && (
<span>
Please do not navigate away from the page during this time.
</span>
)}
</Box>
)}
{status == "ended" && topics.length == 0 && (
<Box textAlign={"center"} textColor="gray">
<Text>Recording has ended without topics being found.</Text>
</Box>
)}
{status == "error" && (
<Box textAlign={"center"} textColor="gray">
<Text>There was an error processing your recording</Text>
</Box>
)}
</Box>
</Flex>
);
}