Skip to content

Commit deb2642

Browse files
mertbagtMephistic
andauthored
Timestamp Urls (#2034)
* basic framework to add timestamps to url * cleanup * copy to clipboard url button * handle page load * remove unneeded code * set aside common function * fix(transcripts): Make the auto-scroll work when first loading a Hearing page at a specific timestamp, some de-duping * cr2 * show share button only on active elemet * material ui share icon * cleanup --------- Co-authored-by: Mephistic <deathbyfiresermon@gmail.com>
1 parent 5594e73 commit deb2642

File tree

7 files changed

+411
-28
lines changed

7 files changed

+411
-28
lines changed

components/buttons.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,48 @@ export const CopyButton = ({
341341
)
342342
}
343343

344+
export const ShareLinkButton = ({
345+
text,
346+
tooltipDurationMs = 1000,
347+
children,
348+
format = "text/html",
349+
...props
350+
}: ButtonProps & {
351+
text: string
352+
tooltipDurationMs?: number
353+
format?: string
354+
}) => {
355+
const { t } = useTranslation("common")
356+
const [show, setShow] = useState(false)
357+
const target = useRef(null)
358+
const closeTimeout = useRef<any>()
359+
return (
360+
<>
361+
<CopyToClipboard
362+
text={text}
363+
options={{ format: format }}
364+
onCopy={(_, success) => {
365+
if (success) {
366+
clearTimeout(closeTimeout.current)
367+
setShow(true)
368+
closeTimeout.current = setTimeout(
369+
() => setShow(false),
370+
tooltipDurationMs
371+
)
372+
}
373+
}}
374+
>
375+
<Button ref={target} style={{ color: "#737373" }} variant="" {...props}>
376+
{children}
377+
</Button>
378+
</CopyToClipboard>
379+
<Overlay target={target} show={show} placement="top">
380+
{props => <Tooltip {...props}>{t("copiedToClipboard")}</Tooltip>}
381+
</Overlay>
382+
</>
383+
)
384+
}
385+
344386
export const GearIcon = (
345387
<div className={`py-0`}>
346388
<svg

components/hearing/HearingDetails.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { doc, getDoc } from "firebase/firestore"
1+
import { useRouter } from "next/router"
22
import { Trans, useTranslation } from "next-i18next"
3-
import { useCallback, useEffect, useRef, useState } from "react"
3+
import { useEffect, useRef, useState } from "react"
44
import styled from "styled-components"
55
import { Col, Container, Image, Row } from "../bootstrap"
6-
import { firestore } from "../firebase"
76
import * as links from "../links"
87
import { committeeURL, External } from "../links"
98
import {
@@ -12,7 +11,12 @@ import {
1211
FeatureCalloutButton
1312
} from "../shared/CommonComponents"
1413
import { HearingSidebar } from "./HearingSidebar"
15-
import { HearingData, Paragraph, fetchTranscriptionData } from "./hearing"
14+
import {
15+
HearingData,
16+
Paragraph,
17+
convertToString,
18+
fetchTranscriptionData
19+
} from "./hearing"
1620
import { Transcriptions } from "./Transcriptions"
1721

1822
const LegalContainer = styled(Container)`
@@ -51,9 +55,11 @@ export const HearingDetails = ({
5155
hearingData: HearingData
5256
}) => {
5357
const { t } = useTranslation(["common", "hearing"])
54-
const [transcriptData, setTranscriptData] = useState<Paragraph[] | null>(null)
58+
const router = useRouter()
5559

60+
const [transcriptData, setTranscriptData] = useState<Paragraph[] | null>(null)
5661
const [videoLoaded, setVideoLoaded] = useState(false)
62+
5763
const handleVideoLoad = () => {
5864
setVideoLoaded(true)
5965
}
@@ -63,6 +69,15 @@ export const HearingDetails = ({
6369
videoRef.current ? (videoRef.current.currentTime = value) : null
6470
}
6571

72+
useEffect(() => {
73+
const startTime = router.query.t
74+
const resultString: string = convertToString(startTime)
75+
76+
if (startTime && videoRef.current) {
77+
setCurTimeVideo(parseInt(resultString, 10))
78+
}
79+
}, [router.query.t, videoRef.current])
80+
6681
useEffect(() => {
6782
;(async function () {
6883
if (!videoTranscriptionId || transcriptData !== null) return
@@ -169,6 +184,7 @@ export const HearingDetails = ({
169184

170185
{transcriptData ? (
171186
<Transcriptions
187+
hearingId={hearingId}
172188
transcriptData={transcriptData}
173189
setCurTimeVideo={setCurTimeVideo}
174190
videoLoaded={videoLoaded}

components/hearing/Transcriptions.tsx

Lines changed: 107 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons"
22
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
3+
import ShareIcon from "@mui/icons-material/Share"
4+
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined"
5+
import { useRouter } from "next/router"
36
import { useTranslation } from "next-i18next"
47
import React, { forwardRef, useEffect, useRef, useState } from "react"
58
import styled from "styled-components"
69
import { Col, Container, Row } from "../bootstrap"
7-
import { Paragraph, formatMilliseconds } from "./hearing"
10+
import {
11+
Paragraph,
12+
convertToString,
13+
formatMilliseconds,
14+
formatTotalSeconds
15+
} from "./hearing"
16+
import { ShareLinkButton } from "components/buttons"
817

918
const ClearButton = styled(FontAwesomeIcon)`
1019
position: absolute;
@@ -116,11 +125,13 @@ const TranscriptRow = styled(Row)`
116125
`
117126

118127
export const Transcriptions = ({
128+
hearingId,
119129
transcriptData,
120130
setCurTimeVideo,
121131
videoLoaded,
122132
videoRef
123133
}: {
134+
hearingId: string
124135
transcriptData: Paragraph[]
125136
setCurTimeVideo: any
126137
videoLoaded: boolean
@@ -132,11 +143,40 @@ export const Transcriptions = ({
132143
const transcriptRefs = useRef(new Map())
133144
const [searchTerm, setSearchTerm] = useState("")
134145
const [filteredData, setFilteredData] = useState<Paragraph[]>([])
146+
const [initialScrollTarget, setInitialScrollTarget] = useState<number | null>(
147+
null
148+
)
149+
const hasScrolledToInitial = useRef(false)
135150

136151
const handleClearInput = () => {
137152
setSearchTerm("")
138153
}
139154

155+
// Shared function to scroll to a transcript index
156+
const scrollToTranscript = (index: number) => {
157+
const container = containerRef.current
158+
const elem = transcriptRefs.current.get(index)
159+
160+
if (elem && container) {
161+
const elemTop = elem.offsetTop - container.offsetTop
162+
const elemBottom = elemTop + elem.offsetHeight
163+
const viewTop = container.scrollTop
164+
const viewBottom = viewTop + container.clientHeight
165+
166+
if (elemTop < viewTop) {
167+
container.scrollTo({
168+
top: elemTop,
169+
behavior: "smooth"
170+
})
171+
} else if (elemBottom > viewBottom) {
172+
container.scrollTo({
173+
top: elemBottom - container.clientHeight,
174+
behavior: "smooth"
175+
})
176+
}
177+
}
178+
}
179+
140180
useEffect(() => {
141181
setFilteredData(
142182
transcriptData.filter(el =>
@@ -145,32 +185,51 @@ export const Transcriptions = ({
145185
)
146186
}, [transcriptData, searchTerm])
147187

188+
const router = useRouter()
189+
const startTime = router.query.t
190+
const resultString: string = convertToString(startTime)
191+
192+
let currentIndex = transcriptData.findIndex(
193+
element => parseInt(resultString, 10) <= element.end / 1000
194+
)
195+
196+
// Set the initial scroll target when we have a startTime and transcripts
197+
useEffect(() => {
198+
if (
199+
startTime &&
200+
transcriptData.length > 0 &&
201+
currentIndex !== -1 &&
202+
!hasScrolledToInitial.current
203+
) {
204+
setInitialScrollTarget(currentIndex)
205+
}
206+
}, [startTime, transcriptData, currentIndex])
207+
208+
// Scroll to the initial target when the ref becomes available
209+
useEffect(() => {
210+
if (initialScrollTarget !== null && !searchTerm) {
211+
const elem = transcriptRefs.current.get(initialScrollTarget)
212+
213+
if (elem) {
214+
setHighlightedId(initialScrollTarget)
215+
scrollToTranscript(initialScrollTarget)
216+
hasScrolledToInitial.current = true
217+
setInitialScrollTarget(null)
218+
}
219+
}
220+
}, [initialScrollTarget, transcriptRefs.current.size, searchTerm])
221+
148222
useEffect(() => {
149223
const handleTimeUpdate = () => {
150-
const currentIndex = transcriptData.findIndex(
151-
element => videoRef.current.currentTime <= element.end / 1000
152-
)
224+
videoLoaded
225+
? (currentIndex = transcriptData.findIndex(
226+
element => videoRef.current.currentTime <= element.end / 1000
227+
))
228+
: null
153229
if (containerRef.current && currentIndex !== highlightedId) {
154230
setHighlightedId(currentIndex)
155231
if (currentIndex !== -1 && !searchTerm) {
156-
const container = containerRef.current
157-
const elem = transcriptRefs.current.get(currentIndex)
158-
const elemTop = elem.offsetTop - container.offsetTop
159-
const elemBottom = elemTop + elem.offsetHeight
160-
const viewTop = container.scrollTop
161-
const viewBottom = viewTop + container.clientHeight
162-
163-
if (elemTop < viewTop) {
164-
container.scrollTo({
165-
top: elemTop,
166-
behavior: "smooth"
167-
})
168-
} else if (elemBottom > viewBottom) {
169-
container.scrollTo({
170-
top: elemBottom - container.clientHeight,
171-
behavior: "smooth"
172-
})
173-
}
232+
scrollToTranscript(currentIndex)
174233
}
175234
}
176235
}
@@ -217,6 +276,7 @@ export const Transcriptions = ({
217276
<TranscriptItem
218277
key={index}
219278
element={element}
279+
hearingId={hearingId}
220280
highlightedId={highlightedId}
221281
index={index}
222282
ref={elem => {
@@ -249,12 +309,14 @@ export const Transcriptions = ({
249309
const TranscriptItem = forwardRef(function TranscriptItem(
250310
{
251311
element,
312+
hearingId,
252313
highlightedId,
253314
index,
254315
setCurTimeVideo,
255316
searchTerm
256317
}: {
257318
element: Paragraph
319+
hearingId: string
258320
highlightedId: number
259321
index: number
260322
setCurTimeVideo: any
@@ -275,6 +337,7 @@ const TranscriptItem = forwardRef(function TranscriptItem(
275337
const isHighlighted = (index: number): boolean => {
276338
return index === highlightedId
277339
}
340+
278341
const highlightText = (text: string, term: string) => {
279342
if (!term) return text
280343
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
@@ -290,6 +353,8 @@ const TranscriptItem = forwardRef(function TranscriptItem(
290353
)
291354
}
292355

356+
const [isHovered, setIsHovered] = useState(false)
357+
293358
return (
294359
<TranscriptRow
295360
className={
@@ -316,6 +381,26 @@ const TranscriptItem = forwardRef(function TranscriptItem(
316381
</Row>
317382
</TimestampCol>
318383
<Col className={`pt-1`}>{highlightText(element.text, searchTerm)}</Col>
384+
<Col xs="1" className={`my-1 px-0`}>
385+
{isHighlighted(index) ? (
386+
<>
387+
<ShareLinkButton
388+
key="copy"
389+
text={`http://localhost:3000/hearing/${hearingId}?t=${formatTotalSeconds(
390+
element.start
391+
)}`}
392+
className={`copy my-1 px-1 py-0`}
393+
format="text/plain"
394+
onMouseEnter={() => setIsHovered(true)}
395+
onMouseLeave={() => setIsHovered(false)}
396+
>
397+
{isHovered ? <ShareIcon /> : <ShareOutlinedIcon />}
398+
</ShareLinkButton>
399+
</>
400+
) : (
401+
<></>
402+
)}
403+
</Col>
319404
</TranscriptRow>
320405
)
321406
})

components/hearing/hearing.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ export type Paragraph = {
2929
text: string
3030
}
3131

32+
export const convertToString = (
33+
value: string | string[] | undefined
34+
): string => {
35+
if (Array.isArray(value)) {
36+
return value.join(", ")
37+
}
38+
return value ?? ""
39+
}
40+
3241
export async function fetchHearingData(
3342
hearingId: string
3443
): Promise<HearingData | null> {
@@ -98,6 +107,13 @@ export function formatMilliseconds(ms: number): string {
98107
}
99108
}
100109

110+
export function formatTotalSeconds(ms: number): string {
111+
const totalSeconds = Math.floor(ms / 1000)
112+
const formattedSeconds = String(totalSeconds)
113+
114+
return `${formattedSeconds}`
115+
}
116+
101117
export function formatVTTTimestamp(ms: number): string {
102118
const totalSeconds = Math.floor(ms / 1000)
103119
const milliseconds = ms % 1000

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,14 @@
7272
]
7373
},
7474
"dependencies": {
75+
"@emotion/react": "^11.14.0",
76+
"@emotion/styled": "^11.14.1",
7577
"@emotion/weak-memoize": "^0.3.1",
7678
"@fortawesome/fontawesome-svg-core": "^6.5.1",
7779
"@fortawesome/free-solid-svg-icons": "^6.5.1",
7880
"@fortawesome/react-fontawesome": "^0.2.0",
81+
"@mui/icons-material": "^7.3.7",
82+
"@mui/material": "^7.3.7",
7983
"@popperjs/core": "^2.11.8",
8084
"@react-aria/ssr": "^3.2.0",
8185
"@react-aria/utils": "^3.13.1",

pages/hearing/[hearingId].tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { GetServerSideProps } from "next"
2-
import { useRouter } from "next/router"
32
import { serverSideTranslations } from "next-i18next/serverSideTranslations"
43
import { z } from "zod"
54
import { flags } from "components/featureFlags"

0 commit comments

Comments
 (0)