import range from 'lodash/range' import sortBy from 'lodash/sortBy' import truncate from 'lodash/truncate' import ReconnectingWebSocket from 'reconnecting-websocket' import * as Y from 'yjs' import { patch as patchJSON } from 'jsondiffpatch' import { h, Fragment, render } from 'preact' import { useEffect, useLayoutEffect, useState, useCallback, useRef, } from 'preact/hooks' import { State } from 'xstate' import styled, { createGlobalStyle } from 'styled-components' import { useHotkeys } from 'react-hotkeys-hook' import Color from 'color' import { DateTime } from 'luxon' import '../index.css' import { idxInBox } from '../geometry' import { roleCan } from '../roles' import SoundIcon from '../static/volume-up-solid.svg' import NoVideoIcon from '../static/video-slash-solid.svg' import ReloadIcon from '../static/sync-alt-solid.svg' import RotateIcon from '../static/redo-alt-solid.svg' import SwapIcon from '../static/exchange-alt-solid.svg' import LifeRingIcon from '../static/life-ring-regular.svg' import WindowIcon from '../static/window-maximize-regular.svg' import { idColor } from './colors' const hotkeyTriggers = [ '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', ] const GlobalStyle = createGlobalStyle` html { height: 100%; } html, body { display: flex; flex: 1; } ` const normalStreamKinds = new Set(['video', 'audio', 'web']) function filterStreams(streams) { const liveStreams = [] const otherStreams = [] for (const stream of streams) { const { kind, status } = stream if (kind && !normalStreamKinds.has(kind)) { continue } if ((kind && kind !== 'video') || status === 'Live') { liveStreams.push(stream) } else { otherStreams.push(stream) } } return [liveStreams, otherStreams] } function useYDoc(keys) { const [doc, setDoc] = useState(new Y.Doc()) const [docValue, setDocValue] = useState() useEffect(() => { function updateDocValue() { const valueCopy = Object.fromEntries( keys.map((k) => [k, doc.getMap(k).toJSON()]), ) setDocValue(valueCopy) } updateDocValue() doc.on('update', updateDocValue) return () => { doc.off('update', updateDocValue) } }, [doc]) return [docValue, doc, setDoc] } function useStreamwallConnection(wsEndpoint) { const wsRef = useRef() const [isConnected, setIsConnected] = useState(false) const [sharedState, stateDoc, setStateDoc] = useYDoc(['views']) const [config, setConfig] = useState({}) const [streams, setStreams] = useState([]) const [customStreams, setCustomStreams] = useState([]) const [views, setViews] = useState([]) const [stateIdxMap, setStateIdxMap] = useState(new Map()) const [delayState, setDelayState] = useState() const [authState, setAuthState] = useState() useEffect(() => { let lastStateData const ws = new ReconnectingWebSocket(wsEndpoint, [], { maxReconnectionDelay: 5000, minReconnectionDelay: 1000 + Math.random() * 500, reconnectionDelayGrowFactor: 1.1, }) ws.binaryType = 'arraybuffer' ws.addEventListener('open', () => setIsConnected(true)) ws.addEventListener('close', () => { setStateDoc(new Y.Doc()) setIsConnected(false) }) ws.addEventListener('message', (ev) => { if (ev.data instanceof ArrayBuffer) { return } const msg = JSON.parse(ev.data) if (msg.response) { const { responseMap } = wsRef.current const responseCb = responseMap.get(msg.id) if (responseCb) { responseMap.delete(msg.id) responseCb(msg) } } else if (msg.type === 'state' || msg.type === 'state-delta') { let state if (msg.type === 'state') { state = msg.state } else { state = patchJSON(lastStateData, msg.delta) } lastStateData = state const { config: newConfig, streams: newStreams, views, streamdelay, auth, } = state const newStateIdxMap = new Map() const newViews = [] for (const viewState of views) { const { pos } = viewState.context const state = State.from(viewState.state, viewState.context) const isListening = state.matches( 'displaying.running.audio.listening', ) const isBackgroundListening = state.matches( 'displaying.running.audio.background', ) const isBlurred = state.matches('displaying.running.video.blurred') const viewInfo = { state, isListening, isBackgroundListening, isBlurred, spaces: pos.spaces, } newViews.push(viewInfo) for (const space of pos.spaces) { if (!newStateIdxMap.has(space)) { newStateIdxMap.set(space, {}) } Object.assign(newStateIdxMap.get(space), viewInfo) } } setConfig(newConfig) setStateIdxMap(newStateIdxMap) setStreams(sortBy(newStreams, ['_id'])) setViews(newViews) setCustomStreams(newStreams.filter((s) => s._dataSource === 'custom')) setDelayState( streamdelay && { ...streamdelay, state: State.from(streamdelay.state), }, ) setAuthState(auth) } else { console.warn('unexpected ws message', msg) } }) wsRef.current = { ws, msgId: 0, responseMap: new Map() } }, []) const send = useCallback((msg, cb) => { const { ws, msgId, responseMap } = wsRef.current ws.send( JSON.stringify({ ...msg, id: msgId, }), ) if (cb) { responseMap.set(msgId, cb) } wsRef.current.msgId++ }, []) useEffect(() => { function sendUpdate(update, origin) { if (origin === 'server') { return } wsRef.current.ws.send(update) } function receiveUpdate(ev) { if (!(ev.data instanceof ArrayBuffer)) { return } Y.applyUpdate(stateDoc, new Uint8Array(ev.data), 'server') } stateDoc.on('update', sendUpdate) wsRef.current.ws.addEventListener('message', receiveUpdate) return () => { stateDoc.off('update', sendUpdate) wsRef.current.ws.removeEventListener('message', receiveUpdate) } }, [stateDoc]) return { isConnected, send, sharedState, stateDoc, config, streams, customStreams, views, stateIdxMap, delayState, authState, } } function App({ wsEndpoint, role }) { const { isConnected, send, sharedState, stateDoc, config, streams, customStreams, views, stateIdxMap, delayState, authState, } = useStreamwallConnection(wsEndpoint) const { gridCount, width: windowWidth, height: windowHeight } = config const [showDebug, setShowDebug] = useState(false) const handleChangeShowDebug = useCallback((ev) => { setShowDebug(ev.target.checked) }) const [swapStartIdx, setSwapStartIdx] = useState() const handleSwapView = useCallback( (idx) => { if (!stateIdxMap.has(idx)) { return } // Deselect the input so the contents aren't persisted by GridInput's `editingValue` document.activeElement.blur() setSwapStartIdx(idx) }, [stateIdxMap], ) const handleSwap = useCallback( (toIdx) => { if (swapStartIdx === undefined) { return } stateDoc.transact(() => { const viewsState = stateDoc.getMap('views') const startStreamId = viewsState .get(String(swapStartIdx)) .get('streamId') const toStreamId = viewsState.get(String(toIdx)).get('streamId') const startSpaces = stateIdxMap.get(swapStartIdx).spaces const toSpaces = stateIdxMap.get(toIdx).spaces for (const startSpaceIdx of startSpaces) { viewsState.get(String(startSpaceIdx)).set('streamId', toStreamId) } for (const toSpaceIdx of toSpaces) { viewsState.get(String(toSpaceIdx)).set('streamId', startStreamId) } }) setSwapStartIdx() }, [stateDoc, stateIdxMap, swapStartIdx], ) const [hoveringIdx, setHoveringIdx] = useState() const updateHoveringIdx = useCallback( (ev) => { const { width, height, left, top, } = ev.currentTarget.getBoundingClientRect() const x = Math.floor(ev.clientX - left) const y = Math.floor(ev.clientY - top) const spaceWidth = width / gridCount const spaceHeight = height / gridCount const idx = Math.floor(y / spaceHeight) * gridCount + Math.floor(x / spaceWidth) setHoveringIdx(idx) }, [setHoveringIdx, gridCount], ) const [dragStart, setDragStart] = useState() const handleDragStart = useCallback( (ev) => { ev.preventDefault() if (swapStartIdx !== undefined) { handleSwap(hoveringIdx) } else { setDragStart(hoveringIdx) // Select the text (if it is an input element) ev.target.select?.() } }, [handleSwap, swapStartIdx, hoveringIdx], ) useLayoutEffect(() => { function endDrag() { if (dragStart === undefined) { return } stateDoc.transact(() => { const viewsState = stateDoc.getMap('views') const streamId = viewsState.get(String(dragStart)).get('streamId') for (let idx = 0; idx < gridCount ** 2; idx++) { if (idxInBox(gridCount, dragStart, hoveringIdx, idx)) { viewsState.get(String(idx)).set('streamId', streamId) } } }) setDragStart() } window.addEventListener('mouseup', endDrag) return () => window.removeEventListener('mouseup', endDrag) }, [stateDoc, dragStart, hoveringIdx]) const [focusedInputIdx, setFocusedInputIdx] = useState() const handleFocusInput = useCallback(setFocusedInputIdx, []) const handleBlurInput = useCallback(() => setFocusedInputIdx(), []) const handleSetView = useCallback( (idx, streamId) => { const stream = streams.find((d) => d._id === streamId) stateDoc .getMap('views') .get(String(idx)) .set('streamId', stream ? streamId : '') }, [stateDoc, streams], ) const handleSetListening = useCallback((idx, listening) => { send({ type: 'set-listening-view', viewIdx: listening ? idx : null, }) }, []) const handleSetBackgroundListening = useCallback((viewIdx, listening) => { send({ type: 'set-view-background-listening', viewIdx, listening, }) }, []) const handleSetBlurred = useCallback((viewIdx, blurred) => { send({ type: 'set-view-blurred', viewIdx, blurred: blurred, }) }, []) const handleReloadView = useCallback((viewIdx) => { send({ type: 'reload-view', viewIdx, }) }, []) const handleRotateStream = useCallback( (streamId) => { const stream = streams.find((d) => d._id === streamId) if (!stream) { return } send({ type: 'rotate-stream', url: stream.link, rotation: ((stream.rotation || 0) + 90) % 360, }) }, [streams], ) const handleBrowse = useCallback( (streamId) => { const stream = streams.find((d) => d._id === streamId) if (!stream) { return } send({ type: 'browse', url: stream.link, }) }, [streams], ) const handleDevTools = useCallback((viewIdx) => { send({ type: 'dev-tools', viewIdx, }) }, []) const handleClickId = useCallback( (streamId) => { try { navigator.clipboard.writeText(streamId) } catch (err) { console.warn('Unable to copy stream id to clipboard:', err) } if (focusedInputIdx !== undefined) { handleSetView(focusedInputIdx, streamId) return } const availableIdx = range(gridCount * gridCount).find( (i) => !sharedState.views[i].streamId, ) if (availableIdx === undefined) { return } handleSetView(availableIdx, streamId) }, [gridCount, sharedState, focusedInputIdx], ) const handleChangeCustomStream = useCallback((url, customStream) => { send({ type: 'update-custom-stream', url, data: customStream, }) }) const handleDeleteCustomStream = useCallback((url) => { send({ type: 'delete-custom-stream', url, }) return }) const setStreamCensored = useCallback((isCensored) => { send({ type: 'set-stream-censored', isCensored, }) }, []) const setStreamRunning = useCallback((isStreamRunning) => { send({ type: 'set-stream-running', isStreamRunning, }) }, []) const [newInvite, setNewInvite] = useState() const handleCreateInvite = useCallback(({ name, role }) => { send( { type: 'create-invite', name, role, }, ({ name, secret }) => { setNewInvite({ name, secret }) }, ) }, []) const handleDeleteToken = useCallback((tokenId) => { send({ type: 'delete-token', tokenId, }) }, []) const preventLinkClick = useCallback((ev) => { ev.preventDefault() }) // Set up keyboard shortcuts. useHotkeys( hotkeyTriggers.map((k) => `alt+${k}`).join(','), (ev, { key }) => { ev.preventDefault() const idx = hotkeyTriggers.indexOf(key[key.length - 1]) const isListening = stateIdxMap.get(idx)?.isListening ?? false handleSetListening(idx, !isListening) }, // This enables hotkeys when input elements are focused, and affects all hotkeys, not just this one. { filter: () => true }, [stateIdxMap], ) useHotkeys( hotkeyTriggers.map((k) => `alt+shift+${k}`).join(','), (ev, { key }) => { ev.preventDefault() const idx = hotkeyTriggers.indexOf(key[key.length - 1]) const isBlurred = stateIdxMap.get(idx)?.isBlurred ?? false handleSetBlurred(idx, !isBlurred) }, [stateIdxMap], ) useHotkeys( `alt+c`, () => { setStreamCensored(true) }, [setStreamCensored], ) useHotkeys( `alt+shift+c`, () => { setStreamCensored(false) }, [setStreamCensored], ) useHotkeys( `alt+s`, () => { handleSwapView(focusedInputIdx) }, [handleSwapView, focusedInputIdx], ) const [liveStreams, otherStreams] = filterStreams(streams) function StreamList({ rows }) { return rows.map((row) => ( )) } return (

Streamwall ({location.host})

connection status: {isConnected ? 'connected' : 'connecting...'}
role: {role}
{delayState && ( )} {gridCount && ( {range(0, gridCount).map((y) => range(0, gridCount).map((x) => { const idx = gridCount * y + x const { state } = stateIdxMap.get(idx) || {} const { streamId } = sharedState.views?.[idx] ?? {} const isDragHighlighted = dragStart !== undefined && idxInBox(gridCount, dragStart, hoveringIdx, idx) return ( ) }), )} {views.map(({ state, isListening }) => { const { pos } = state.context const { streamId } = sharedState.views[pos.spaces[0]] ?? {} const data = streams.find((d) => d._id === streamId) return ( {streamId}
{data?.source}
) })}
{views.map( ({ state, isListening, isBackgroundListening, isBlurred }) => { const { pos } = state.context const { streamId } = sharedState.views[pos.spaces[0]] ?? {} return ( ) }, )}
)} {(roleCan(role, 'dev-tools') || roleCan(role, 'browse')) && ( )}
{isConnected ? (

Live

Offline / Unknown

) : (
loading...
)} {roleCan(role, 'update-custom-stream') && roleCan(role, 'delete-custom-stream') && ( <>

Custom Streams

{/* Include an empty object at the end to create an extra input for a new custom stream. We need it to be part of the array (rather than JSX below) for DOM diffing to match the key and retain focus. */} {customStreams.map(({ link, label, kind }, idx) => ( ))}
)} {roleCan(role, 'edit-tokens') && authState && ( <>

Access

Invites

{newInvite && ( Invite link created:{' '} "{newInvite.name}" )} {authState.invites.map(({ id, name, role }) => ( ))}

Sessions

{authState.sessions.map(({ id, name, role }) => ( ))}
)}
) } const Stack = styled.div` display: flex; flex-direction: column; flex: ${({ flex }) => flex}; ${({ scroll }) => scroll && `overflow-y: auto`}; ${({ minHeight }) => minHeight && `min-height: ${minHeight}px`}; ` function StreamDurationClock({ startTime }) { const [now, setNow] = useState(() => DateTime.now()) useEffect(() => { const interval = setInterval(() => { setNow(DateTime.now()) }, 500) return () => { clearInterval(interval) } }, [startTime]) return ( {now.diff(DateTime.fromMillis(startTime)).toFormat('hh:mm:ss')} ) } function StreamDelayBox({ role, delayState, setStreamCensored, setStreamRunning, }) { const handleToggleStreamCensored = useCallback(() => { setStreamCensored(!delayState.isCensored) }, [delayState.isCensored, setStreamCensored]) const handleToggleStreamRunning = useCallback(() => { if (!delayState.isStreamRunning || confirm('End stream?')) { setStreamRunning(!delayState.isStreamRunning) } }, [delayState.isStreamRunning, setStreamRunning]) let buttonText if (delayState.isConnected) { if (delayState.state.matches('censorship.censored.deactivating')) { buttonText = 'Deactivating...' } else if (delayState.isCensored) { buttonText = 'Uncensor stream' } else { buttonText = 'Censor stream' } } return (
Streamdelay {!delayState.isConnected && connecting...} {!delayState.isStreamRunning && stream stopped} {delayState.isConnected && ( <> {delayState.startTime !== null && ( )} delay: {delayState.delaySeconds}s {delayState.isStreamRunning && ( {buttonText} )} {roleCan(role, 'set-stream-running') && ( {delayState.isStreamRunning ? 'End stream' : 'Start stream'} )} )}
) } function StreamLine({ id, row: { label, source, title, link, notes, state, city }, disabled, onClickId, }) { // Use mousedown instead of click event so a potential destination grid input stays focused. const handleMouseDownId = useCallback(() => { onClickId(id) }, [onClickId, id]) let location if (state && city) { location = ` (${city} ${state}) ` } return ( {id}
{label ? ( label ) : ( <> {source} {location} {truncate(title || link, { length: 55 })} {' '} {notes} )}
) } // An input that maintains local edits and fires onChange after blur (like a non-React input does), or optionally on every edit if isEager is set. function LazyChangeInput({ value = '', onChange, onFocus, onBlur, onKeyDown, isEager = false, ...props }) { const [editingValue, setEditingValue] = useState() const handleFocus = useCallback( (ev) => { setEditingValue(ev.target.value) onFocus?.(ev) }, [onFocus], ) const handleBlur = useCallback( (ev) => { if (!isEager && editingValue !== undefined) { onChange(editingValue) } setEditingValue() onBlur?.(ev) }, [onBlur, editingValue], ) const handleKeyDown = useCallback((ev) => { if (ev.key === 'Enter') { handleBlur?.(ev) } }) const handleChange = useCallback( (ev) => { const { value } = ev.target setEditingValue(value) if (isEager) { onChange(value) } }, [onChange, isEager], ) return ( ) } function GridInput({ style, idx, onChangeSpace, spaceValue, isHighlighted, role, onMouseDown, onFocus, onBlur, }) { const handleFocus = useCallback(() => { onFocus(idx) }, [onFocus, idx]) const handleBlur = useCallback(() => { onBlur(idx) }, [onBlur, idx]) const handleChange = useCallback( (value) => { onChangeSpace(idx, value) }, [idx, onChangeSpace], ) return ( ) } function GridControls({ idx, streamId, style, isDisplaying, isListening, isBackgroundListening, isBlurred, isSwapping, showDebug, role, onSetListening, onSetBackgroundListening, onSetBlurred, onReloadView, onSwapView, onRotateView, onBrowse, onDevTools, onMouseDown, }) { // TODO: Refactor callbacks to use streamID instead of idx. // We should probably also switch the view-state-changing RPCs to use a view id instead of idx like they do currently. const handleListeningClick = useCallback( (ev) => ev.shiftKey || isBackgroundListening ? onSetBackgroundListening(idx, !isBackgroundListening) : onSetListening(idx, !isListening), [ idx, onSetListening, onSetBackgroundListening, isListening, isBackgroundListening, ], ) const handleBlurClick = useCallback(() => onSetBlurred(idx, !isBlurred), [ idx, onSetBlurred, isBlurred, ]) const handleReloadClick = useCallback(() => onReloadView(idx), [ idx, onReloadView, ]) const handleSwapClick = useCallback(() => onSwapView(idx), [idx, onSwapView]) const handleRotateClick = useCallback(() => onRotateView(streamId), [ streamId, onRotateView, ]) const handleBrowseClick = useCallback(() => onBrowse(streamId), [ streamId, onBrowse, ]) const handleDevToolsClick = useCallback(() => onDevTools(idx), [ idx, onDevTools, ]) return ( {isDisplaying && ( {showDebug ? ( <> {roleCan(role, 'browse') && ( )} {roleCan(role, 'dev-tools') && ( )} ) : ( <> {roleCan(role, 'reload-view') && ( )} {roleCan(role, 'mutate-state-doc') && ( )} {roleCan(role, 'rotate-stream') && ( )} )} )} {roleCan(role, 'set-view-blurred') && ( )} {roleCan(role, 'set-listening-view') && ( )} ) } function CustomStreamInput({ onChange, onDelete, ...props }) { const handleChangeLabel = useCallback( (value) => { onChange(props.link, { ...props, label: value }) }, [onChange, props], ) const handleDeleteClick = useCallback(() => { onDelete(props.link) }, [onDelete, props.link]) return (
{' '} {props.link} ({props.kind}){' '}
) } function CreateCustomStreamInput({ onCreate }) { const [link, setLink] = useState('') const [kind, setKind] = useState('video') const [label, setLabel] = useState('') const handleSubmit = useCallback( (ev) => { ev.preventDefault() onCreate(link, { link, kind, label }) setLink('') setKind('video') setLabel('') }, [onCreate, link, kind, label], ) return (
setLink(ev.target.value)} placeholder="https://..." /> setLabel(ev.target.value)} placeholder="Label (optional)" />
) } const StyledHeader = styled.header` display: flex; flex-direction: row; align-items: center; h1 { margin-top: 0; margin-bottom: 0; } * { margin-right: 2rem; } ` const StyledStreamDelayBox = styled.div` display: inline-flex; margin: 5px 0; padding: 10px; background: #fdd; & > * { margin-right: 1em; } ` const StyledDataContainer = styled.div` opacity: ${({ isConnected }) => (isConnected ? 1 : 0.5)}; ` const StyledButton = styled.button` display: flex; align-items: center; border: 2px solid gray; border-color: gray; background: #ccc; border-radius: 5px; ${({ isActive, activeColor = 'red' }) => isActive && ` border-color: ${activeColor}; background: ${Color(activeColor).desaturate(0.5).lighten(0.5)}; `}; &:focus { outline: none; box-shadow: 0 0 10px orange inset; } svg { width: 20px; height: 20px; } ` const StyledSmallButton = styled(StyledButton)` svg { width: 14px; height: 14px; } ` const StyledGridPreview = styled.div` position: absolute; top: 0; left: 0; width: 100%; height: 100%; ` const StyledGridPreviewBox = styled.div.attrs((props) => ({ borderWidth: 2, }))` display: flex; align-items: center; justify-content: center; position: absolute; background: ${({ color }) => color.lightness(50) || '#333'}; border: 0 solid ${({ isError }) => (isError ? 'red' : 'black')}; border-left-width: ${({ pos, borderWidth }) => pos.x === 0 ? 0 : borderWidth}px; border-right-width: ${({ pos, borderWidth, windowWidth }) => pos.x + pos.width === windowWidth ? 0 : borderWidth}px; border-top-width: ${({ pos, borderWidth }) => pos.y === 0 ? 0 : borderWidth}px; border-bottom-width: ${({ pos, borderWidth, windowHeight }) => pos.y + pos.height === windowHeight ? 0 : borderWidth}px; box-shadow: ${({ isListening }) => isListening ? `0 0 0 4px red inset` : 'none'}; box-sizing: border-box; overflow: hidden; user-select: none; ` const StyledGridInfo = styled.div` text-align: center; ` const StyledGridLabel = styled.div` font-size: 30px; ` const StyledGridInputs = styled.div` position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; transition: opacity 100ms ease-out; overflow: hidden; z-index: 100; ` const StyledGridInputContainer = styled.div` position: absolute; ` const StyledGridButtons = styled.div` display: flex; position: absolute; ${({ side }) => side === 'left' ? 'top: 0; left: 0' : 'bottom: 0; right: 0'}; ${StyledButton} { margin: 5px; ${({ side }) => (side === 'left' ? 'margin-right: 0' : 'margin-left: 0')}; } ` const StyledGridInput = styled(LazyChangeInput)` width: 100%; height: 100%; outline: 1px solid black; border: none; padding: 0; background: ${({ color, isHighlighted }) => isHighlighted ? color.lightness(90) : color.lightness(75)}; font-size: 20px; text-align: center; &:focus { outline: 1px solid black; box-shadow: 0 0 5px black inset; z-index: 100; } ` const StyledGridControlsContainer = styled.div` position: absolute; user-select: none; & > * { z-index: 200; } ` const StyledGridContainer = styled.div.attrs((props) => ({ scale: 0.75, }))` position: relative; width: ${({ windowWidth, scale }) => windowWidth * scale}px; height: ${({ windowHeight, scale }) => windowHeight * scale}px; border: 2px solid black; background: black; &:hover ${StyledGridInputs} { opacity: 0.35; } ` const StyledId = styled.div` flex-shrink: 0; margin-right: 5px; background: ${({ color }) => color.lightness(50) || '#333'}; color: white; padding: 3px; border-radius: 5px; width: 3em; text-align: center; cursor: ${({ disabled }) => (disabled ? 'normal' : 'pointer')}; ` const StyledStreamLine = styled.div` display: flex; align-items: center; margin: 0.5em 0; ` function CreateInviteInput({ onCreateInvite }) { const [inviteName, setInviteName] = useState('') const [inviteRole, setInviteRole] = useState('operator') const handleChangeName = useCallback( (ev) => { setInviteName(ev.target.value) }, [setInviteName], ) const handleChangeRole = useCallback( (ev) => { setInviteRole(ev.target.value) }, [setInviteRole], ) const handleSubmit = useCallback( (ev) => { ev.preventDefault() setInviteName('') setInviteRole('operator') onCreateInvite({ name: inviteName, role: inviteRole }) }, [onCreateInvite, inviteName, inviteRole], ) return (
) } const StyledNewInviteBox = styled.div` display: inline-block; padding: 10px; background: #dfd; ` function AuthTokenLine({ id, role, name, onDelete }) { const handleDeleteClick = useCallback(() => { onDelete(id) }, [id]) return (
{name}: {role}{' '}
) } function Facts() { return ( Black Lives Matter. Trans rights are human rights. Technology is not neutral. ) } const StyledFacts = styled.div` display: flex; margin: 4px 0; & > * { line-height: 26px; margin-right: 0.5em; padding: 0 6px; flex-shrink: 0; } ` const BLM = styled.div` background: black; color: white; ` const TRM = styled.div` background: linear-gradient( to bottom, #55cdfc 12%, #f7a8b8 12%, #f7a8b8 88%, #55cdfc 88% ); color: white; text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); ` const TIN = styled.div` background: gray; font-family: monospace; ` function main() { const script = document.getElementById('main-script') render( <> , document.body, ) } main()