offline-first, p2p synced, atproto enabled, feed reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip

+253 -135
+19
src/app/components/logo.tsx
··· 1 + import { ComponentProps, VoidProps } from "solid-js"; 2 + 3 + export function Logo(props: ComponentProps<'svg'>) { 4 + return ( 5 + <svg viewBox="0 0 640 640" xmlns="http://www.w3.org/2000/svg" {...props}> 6 + <title>feedline.at</title> 7 + <g id="logomark"> 8 + <g transform="rotate(-180, 400, 300)"> 9 + <path id="outer-beam" stroke-width="40" stroke="currentColor" fill="none" d="m242.71695,140.94978c246.88738,5.659 306.05873,87.00719 304.01834,326.10013" /> 10 + <path id="middle-beam" stroke-width="40" stroke="currentColor" fill="none" d="m243.60313,228.8047c176.30383,4.11259 218.55847,63.23105 217.10141,236.98793" /> 11 + <path id="inner-beam" stroke-width="50" stroke="currentColor" fill="none" d="m243.60313,323.17414c109.42636,2.438 135.65251,37.48428 134.74816,140.48986" /> 12 + </g> 13 + 14 + <ellipse fill="currentColor" stroke="currentColor" stroke-width="65" cx="156.71432" cy="415.54991" rx="30" ry="30" id="circle"/> 15 + </g> 16 + <rect rx="20" stroke-width="20" id="border" height="480" width="568" y="80" x="46" stroke="currentColor" fill="none"/> 17 + </svg> 18 + ) 19 + }
+2 -2
src/app/components/navigation-page.tsx
··· 1 - import {Accessor, JSX, mergeProps, ParentProps} from 'solid-js' 1 + import {Accessor, JSX, ParentProps, mergeProps} from 'solid-js' 2 2 3 3 import styles from './navigation-page.module.css' 4 4 5 5 export type NavigationPageProps = ParentProps & JSX.HTMLAttributes<HTMLElement> 6 6 7 7 export function NavigationPage(_props: NavigationPageProps) { 8 - const props = mergeProps(_props, { class: styles['nav-page'] + ' ' + _props.class }) 8 + const props = mergeProps(_props, {class: styles['nav-page'] + ' ' + _props.class}) 9 9 return <article {...props} /> 10 10 }
+3 -5
src/app/components/virtual-list.tsx
··· 1 - import { Accessor, createMemo } from "solid-js" 1 + import {Accessor, createMemo} from 'solid-js' 2 2 3 3 export type VirtualListProps<T> = { 4 4 items: T[] ··· 14 14 15 15 // calculate how much of the scroll belongs to the list (after header has consumed it's part) 16 16 17 - const offsetY = createMemo(() => props.offsetY && props.offsetY() || 0) 17 + const offsetY = createMemo(() => (props.offsetY && props.offsetY()) || 0) 18 18 const listScrollY = createMemo(() => Math.max(0, props.scrollY() - offsetY())) 19 19 20 20 // based on the scroll and heights, where should we slice? 21 21 22 - const firstIndex = createMemo(() => 23 - Math.max(0, Math.floor(listScrollY() / props.rowHeight) - overscan) 24 - ) 22 + const firstIndex = createMemo(() => Math.max(0, Math.floor(listScrollY() / props.rowHeight) - overscan)) 25 23 26 24 const lastIndex = createMemo(() => { 27 25 const count = Math.ceil(props.viewportHeight() / props.rowHeight)
-2
src/app/index.html
··· 8 8 <title>feedline.at</title> 9 9 </head> 10 10 <body> 11 - <script type="module"> 12 - </script> 13 11 <script type="module" src="index.tsx"></script> 14 12 </body> 15 13 </html>
+10 -4
src/app/index.tsx
··· 1 1 import {Route, Router} from '@solidjs/router' 2 - import {Match, render, Show, Switch} from 'solid-js/web' 2 + import {Match, Show, Switch} from 'solid-js' 3 + import {render} from 'solid-js/web' 3 4 4 5 import {FeedlineContextProvider} from './context/feedline' 5 6 import {RealmIdentityProvider} from './context/realm-identity' 6 7 import Layout from './layout' 8 + import SideNav from './layout/sidenav' 7 9 import Dashboard from './pages/dashboard' 8 10 import EntryPage from './pages/entry' 9 11 import FeedPage from './pages/feed' 10 - import SideNav from './layout/sidenav' 11 - import { useBreakpoints } from './primitives/breakpoints' 12 + import {useBreakpoints} from './primitives/breakpoints' 12 13 13 14 export function App() { 14 15 const host = window.location.host ··· 18 19 return ( 19 20 <RealmIdentityProvider> 20 21 <FeedlineContextProvider apiroot={apiroot}> 21 - <Router root={Layout}> 22 + <Router root={Layout} preload={true}> 22 23 <Route path="/" component={Homepage} /> 23 24 <Route path="/dashboard" component={Dashboard} /> 24 25 <Route path="/feeds/:feedurl" component={FeedPage} /> ··· 31 32 32 33 function Homepage() { 33 34 const bp = useBreakpoints() 35 + 36 + return <Dashboard /> 37 + 38 + // on mobile, the sidenav _is_ the homepage 39 + // on desktop, dashboard is the homepage, because sidenav is, well, sidenav 34 40 35 41 return ( 36 42 <Switch fallback={<SideNav />}>
+9 -3
src/app/layout/index.module.css
··· 80 80 opacity: 0; /* initial, otherwise we flash */ 81 81 } 82 82 83 - &.slide-exiting {} 83 + &.slide-exiting { 84 + padding: 0; 85 + } 84 86 85 - &.slide-back {} 86 - &.slide-forward {} 87 + &.slide-back { 88 + background: #030; 89 + } 90 + &.slide-forward { 91 + background: #090; 92 + } 87 93 } 88 94 } 89 95
+3 -2
src/app/layout/index.tsx
··· 1 1 import {Breadcrumbs} from '#app/components/breadcrumbs' 2 2 import {useBreakpoints} from '#app/primitives/breakpoints' 3 - import {ParentProps, Show} from 'solid-js' 3 + import {ParentProps, Show, Suspense} from 'solid-js' 4 4 5 5 import {Drawer} from './drawer' 6 6 import {DrawerContextProvider} from './drawer/context' 7 7 import styles from './index.module.css' 8 8 import {MainTransition} from './main/transition' 9 9 import SideNav from './sidenav' 10 + import { Logo } from '#app/components/logo.jsx' 10 11 11 12 export default function Layout(props: ParentProps) { 12 13 const bp = useBreakpoints() ··· 15 16 <div class={styles['layout']}> 16 17 <DrawerContextProvider> 17 18 <header> 18 - <h2>feedline.at</h2> 19 + <Logo style={{height: 'var(--layout-header-height)', padding: '8px'}} /> 19 20 <Breadcrumbs /> 20 21 </header> 21 22 <Show when={!bp.isPhone()}>
+69 -40
src/app/layout/main/transition.tsx
··· 2 2 import {resolveFirst} from '@solid-primitives/refs' 3 3 import {createSwitchTransition} from '@solid-primitives/transition-group' 4 4 import {useBeforeLeave} from '@solidjs/router' 5 - import {AnimationOptions, animate} from 'motion' 5 + import {AnimationOptions, AnimationPlaybackControlsWithThen, animate} from 'motion' 6 6 import {ParentProps, createSignal} from 'solid-js' 7 7 8 + import {SpinTimeout, sleep, spinUntil} from '#lib/async/sleep.js' 9 + 8 10 export type NavTransitionDir = 'back' | 'forward' 11 + export type NavTransitionState = 'entering' | 'exiting' 9 12 10 13 const enterTransition = (dir?: NavTransitionDir) => ({ 11 14 opacity: [0, 1], ··· 19 22 dir === 'forward' ? ['translateX(0)', 'translateX(-100%)'] : ['translateX(0%)', 'translateX(100%)'], 20 23 }) 21 24 25 + const slideStyles = (state: NavTransitionState, dir?: NavTransitionDir) => { 26 + const res = [styles['slide-active'], styles[`slide-${state}`]] 27 + if (dir) res.push(styles[`slide-${dir}`]) 28 + 29 + return res 30 + } 31 + 22 32 const transitionOpts: AnimationOptions = { 23 33 duration: 0.15, 24 34 ease: 'easeOut', ··· 26 36 27 37 /// 28 38 29 - async function sleepUntil(pred: () => boolean) { 30 - return new Promise<void>((resolve) => { 31 - const check = () => { 32 - if (pred()) { 33 - resolve() 34 - return 35 - } 36 - requestAnimationFrame(check) 37 - } 38 - 39 - check() 40 - }) 41 - } 42 - 43 39 /* 44 40 basically Transition, but with a sleep for parent element, instead of skipping animation in that case 45 41 solves transitions skipping on pending route transitions ··· 47 43 48 44 export function MainTransition(props: ParentProps) { 49 45 const [direction, setDirection] = createSignal<NavTransitionDir>() 46 + 47 + const activeAnimation = new WeakMap<Element, AnimationPlaybackControlsWithThen>() 50 48 51 49 useBeforeLeave((e) => { 52 50 const to = e.to ··· 74 72 onEnter: (el: Element, done) => { 75 73 const dir = direction() 76 74 77 - el.classList.add(styles[`slide-${dir}`]) 78 - el.classList.add(styles['slide-active']) 79 - el.classList.add(styles['slide-entering']) 75 + // if this element is already animating, finish that one 76 + const extant = activeAnimation.get(el) 77 + if (extant) extant.complete() 78 + 79 + const go = async () => { 80 + try { 81 + // wait to animate until we've been attached to a parent 82 + await spinUntil(() => el.parentElement != null, 500) 83 + 84 + try { 85 + el.classList.add(...slideStyles('entering', dir)) 86 + 87 + const slide = animate(el, enterTransition(dir), transitionOpts) 88 + activeAnimation.set(el, slide) 89 + 90 + // race the finished with a timeout, 91 + // since it might never finish if we're unmounted while animating 92 + await Promise.race([sleep((transitionOpts.duration || 0.3) * 1001), slide.finished]) 93 + } finally { 94 + activeAnimation.delete(el) 95 + el.classList.remove(...slideStyles('entering', dir)) 96 + } 97 + } catch (exc: unknown) { 98 + // timeout = no parent = transient suspense or something, ignore 99 + if (exc instanceof SpinTimeout) return 100 + 101 + throw exc 102 + } 103 + } 80 104 81 - sleepUntil(() => el.parentElement != null) 82 - .then(() => animate(el, enterTransition(dir), transitionOpts).finished) 83 - .then(() => { 84 - el.classList.remove(styles[`slide-${dir}`]) 85 - el.classList.remove(styles['slide-active']) 86 - el.classList.remove(styles['slide-entering']) 105 + go() 106 + .catch((exc: unknown) => { 107 + console.error('unexpected error in entrance animation', exc) 87 108 }) 88 - .then(() => { 109 + .finally(() => { 89 110 done() 90 - }) 91 - .catch((exc: unknown) => { 92 - console.error('unexpected error in enter animation', exc) 93 111 }) 94 112 }, 95 113 96 114 onExit: (el, done) => { 97 115 const dir = direction() 98 116 99 - el.classList.add(styles[`slide-${dir}`]) 100 - el.classList.add(styles['slide-active']) 101 - el.classList.add(styles['slide-exiting']) 117 + // if this element is already animating, finish that one 118 + const extant = activeAnimation.get(el) 119 + if (extant) extant.complete() 102 120 103 - animate(el, exitTransition(dir), transitionOpts) 104 - .finished.then(() => { 105 - el.classList.remove(styles[`slide-${dir}`]) 106 - el.classList.remove(styles['slide-active']) 107 - el.classList.remove(styles['slide-exiting']) 121 + // if the element was never even mounted, there's nothing to animate 122 + // similar to onEnter, but we're not going to wait for it to get added 123 + if (!el.isConnected) { 124 + done() 125 + return 126 + } 127 + 128 + el.classList.add(...slideStyles('exiting', dir)) 129 + 130 + const slide = animate(el, exitTransition(dir), transitionOpts) 131 + activeAnimation.set(el, slide) 132 + 133 + // race the finished with a timeout, 134 + // since it might never finish if we're unmounted while animating 135 + Promise.race([sleep((transitionOpts.duration || 0.3) * 1001), slide.finished]) 136 + .catch((exc: unknown) => { 137 + console.error('unexpected error in exit animation', exc) 108 138 }) 109 - .then(() => { 139 + .finally(() => { 140 + activeAnimation.delete(el) 141 + el.classList.remove(...slideStyles('exiting', dir)) 110 142 done() 111 - }) 112 - .catch((exc: unknown) => { 113 - console.error('unexpected error in exit animation', exc) 114 143 }) 115 144 }, 116 145 },
+1
src/app/pages/feed/.#index.tsx
··· 1 + jonathan@buzz.492312:1764524568
+23 -18
src/app/pages/feed/index.module.css
··· 1 1 .entry-list-scroller { 2 + position: absolute; 3 + inset: 0; 4 + 2 5 overflow-y: auto; 3 6 7 + .entry-list-header { 8 + position: sticky; 9 + top: 0; 10 + z-index: 1; 11 + 12 + background: #779; 13 + max-height: 200px; 14 + min-height: 80px; 15 + /* height driven by js animation */ 16 + } 17 + 4 18 .entry-list-container { 5 - background-color: purple; 19 + position: fixed; 6 20 } 7 21 8 - .entry-list-header { 9 - top: 0; 10 - position: sticky; 11 - overflow: hidden; 12 - max-height: 200px; 13 - min-height: 80px; 14 - /* height driven by js animation */ 22 + .entry-list { 23 + position: relative; 24 + 25 + .entry-list-empty-item { 26 + color: #333; 15 27 } 16 28 17 - .entry-list { 18 - position: absolute; 19 - 20 - .entry-list-empty-item { 21 - color: #333; 22 - } 23 - 24 - .entry-list-item { 25 - background-color: pink; 26 - } 29 + .entry-list-item { 30 + background-color: pink; 27 31 } 32 + } 28 33 }
+70 -40
src/app/pages/feed/index.tsx
··· 1 1 import {NavigationPage} from '#app/components/navigation-page.jsx' 2 + import {createVirtualList} from '#app/components/virtual-list' 2 3 import {useFeedlineDatabase} from '#app/context/feedline.jsx' 3 4 import {makeStoreQuery} from '#app/primitives/database' 4 5 import {A, RouteSectionProps} from '@solidjs/router' 5 - import {Accessor, For, Show, Suspense, createMemo, createResource, createSignal, onCleanup, onMount} from 'solid-js' 6 - import {createVirtualList} from '#app/components/virtual-list' 6 + import { 7 + Accessor, 8 + For, 9 + JSX, 10 + Show, 11 + Suspense, 12 + createMemo, 13 + createResource, 14 + createSignal, 15 + onCleanup, 16 + onMount, 17 + } from 'solid-js' 7 18 8 19 import {Entry} from '#feedline/schema/entry' 9 20 import {Feed} from '#feedline/schema/feed' ··· 84 95 ) 85 96 } 86 97 98 + function FeedEntryRow(props: {entry: Entry; feedurl: string}) { 99 + const feedurl = createMemo(() => encodeURIComponent(props.feedurl)) 100 + const guid = createMemo(() => encodeURIComponent(props.entry.guid)) 101 + const date = createMemo( 102 + () => props.entry.publishedAt && new Date(props.entry.publishedAt).toLocaleDateString(), 103 + ) 104 + 105 + return ( 106 + <A href={`/feeds/${feedurl()}/${guid()}`}> 107 + <h3>{props.entry.title || props.entry.guid}</h3> 108 + 109 + {date && <time>{date()}</time>} 110 + </A> 111 + ) 112 + } 113 + 87 114 function FeedPageDetail(props: {feed: Feed}) { 88 115 const database = useFeedlineDatabase() 89 - 90 116 const entries = makeStoreQuery(async () => { 91 117 const u = props.feed.url 92 118 return u ? await database.entries.where('feedurl').equals(u).reverse().sortBy('publishedAt') : [] 93 119 }) 94 120 95 - let scrollerRef!: HTMLElement 121 + let scrollerRef!: HTMLDivElement 96 122 const [scrollY, setScrollY] = createSignal(0) 97 123 const [viewportHeight, setViewportHeight] = createSignal(0) 98 124 ··· 100 126 const setter = () => setViewportHeight(scrollerRef.offsetHeight) 101 127 setter() 102 128 103 - window.addEventListener('resize', setter, { passive: true }) 129 + window.addEventListener('resize', setter, {passive: true}) 104 130 onCleanup(() => { 105 131 window.removeEventListener('resize', setter) 106 132 }) ··· 110 136 const HEADER_MIN = 80 111 137 const COLLAPSE_DIST = HEADER_MAX - HEADER_MIN 112 138 113 - const headerHeight = createMemo(() => 114 - Math.max(HEADER_MIN, HEADER_MAX - Math.min(scrollY(), COLLAPSE_DIST)) 115 - ) 116 - 117 - const scrollProgress = createMemo(() => 118 - Math.min(1, scrollY() / COLLAPSE_DIST) 119 - ) 139 + const headerOffset = () => -Math.min(scrollY(), COLLAPSE_DIST) 140 + const headerHeight = createMemo(() => Math.max(HEADER_MIN, HEADER_MAX - Math.min(scrollY(), COLLAPSE_DIST))) 120 141 121 142 const virtual = createVirtualList({ 122 143 items: entries, 123 144 scrollY, 124 145 offsetY: headerHeight, 125 - rowHeight: 36, // TODO: measure the entry row 146 + rowHeight: 48, // TODO: measure the entry row 126 147 viewportHeight, 127 148 overscan: 5, 128 149 }) 129 150 151 + const headerHeightPx = () => `${headerHeight()}px` 152 + const headerStyles = createMemo(() => ({ 153 + height: headerHeightPx(), 154 + })) 155 + 156 + const containerHeightPx = () => `${virtual.listHeight()}px` 157 + const containerStyles = createMemo(() => ({ 158 + top: headerHeightPx(), 159 + height: containerHeightPx(), 160 + })) 161 + 162 + const offsetYPx = () => `${virtual.offsetY() - headerOffset()}px` 163 + const virtualHeightPx = () => `${viewportHeight()}px` 164 + const virtualStyles = createMemo(() => ({ 165 + top: offsetYPx(), 166 + height: virtualHeightPx(), 167 + })) 168 + 130 169 return ( 131 - <NavigationPage ref={scrollerRef} class={styles['entry-list-scroller']} onscroll={e => setScrollY(e.currentTarget.scrollTop)}> 132 - <header class={styles['entry-list-header']} style={{height: `${headerHeight()}px`}}> 133 - <p>{scrollProgress()}</p> 170 + <NavigationPage 171 + ref={scrollerRef} 172 + class={styles['entry-list-scroller']} 173 + on:scroll={{ 174 + handleEvent: (e) => setScrollY(e.currentTarget.scrollTop), 175 + passive: true 176 + }}> 177 + <header class={styles['entry-list-header']} style={headerStyles()}> 134 178 <FeedPageHeader feed={props.feed} /> 135 179 </header> 136 - <div class={styles['entry-list-container']} style={{top: `${HEADER_MAX + virtual.offsetY()}px`, height: `${virtual.listHeight()}px`}} > 137 - <ol class={styles['entry-list']} style={{}} start={virtual.firstIndex()}> 180 + <main class={styles['entry-list-container']} style={containerStyles()}> 181 + <ol start={virtual.firstIndex()} class={styles['entry-list']} style={virtualStyles()}> 138 182 <For 139 183 each={virtual.items()} 140 184 fallback={<li class={styles['entry-list-empty-item']}>No entries yet...</li>} 141 - >{(entry) => ( 142 - <li class={styles['entry-list-item']} style={{height: '32px'}}> 143 - <FeedEntryRow feedurl={props.feed.url} entry={entry} /> 144 - </li> 145 - )}</For> 185 + > 186 + {(entry) => ( 187 + <li class={styles['entry-list-item']} style={{height: '48px'}}> 188 + <FeedEntryRow feedurl={props.feed.url} entry={entry} /> 189 + </li> 190 + )} 191 + </For> 146 192 </ol> 147 - </div> 193 + </main> 148 194 </NavigationPage> 149 195 ) 150 196 } 151 - 152 - function FeedEntryRow(props: {entry: Entry; feedurl: string}) { 153 - const feedurl = createMemo(() => encodeURIComponent(props.feedurl)) 154 - const guid = createMemo(() => encodeURIComponent(props.entry.guid)) 155 - const date = createMemo( 156 - () => props.entry.publishedAt && new Date(props.entry.publishedAt).toLocaleDateString(), 157 - ) 158 - 159 - return ( 160 - <A href={`/feeds/${feedurl()}/${guid()}`}> 161 - <h3>{props.entry.title || props.entry.guid}</h3> 162 - 163 - {date && <time>{date()}</time>} 164 - </A> 165 - ) 166 - }
+1 -1
src/app/primitives/breakpoints.ts
··· 8 8 const content = window 9 9 .getComputedStyle(document.body, ':before') 10 10 .getPropertyValue('content') 11 - .replace(/\"|\'/g, '') 11 + .replace(/"|'/g, '') 12 12 13 13 // will fail loudly if we change labels 14 14 return breakpointSchema.parse(content)
+1
src/app/primitives/dataset.ts
··· 10 10 const set = (value: T | null) => { 11 11 console.log('setting dataset:', value) 12 12 if (value === null) { 13 + // eslint-disable-next-lint @typescript-eslint/no-dynamic-delete 13 14 delete target.dataset[key] 14 15 } else { 15 16 target.dataset[key] = value
-1
src/feedline/client/feed-fetcher.ts
··· 3 3 import {Atom, Rss} from 'feedsmith/types' 4 4 5 5 import {controllerWithSignals} from '#lib/async/aborts' 6 - import {sleep} from '#lib/async/sleep' 7 6 import {ProtocolError, normalizeError, normalizeProtocolError} from '#lib/errors' 8 7 import {TypedEventTarget} from '#lib/events' 9 8
+1 -1
src/feedline/main.ts
··· 18 18 args: process.argv.slice(2), 19 19 options: { 20 20 port: {type: 'string', default: '4001'}, 21 - host: {type: 'string', default: '127.0.0.1'}, 21 + host: {type: 'string', default: '0.0.0.0'}, 22 22 root: {type: 'string', default: path.join(__dirname, '../../dist')}, 23 23 }, 24 24 })
+32
src/lib/async/sleep.ts
··· 30 30 } 31 31 } 32 32 33 + /** 34 + * RAF as a promise 35 + */ 33 36 export async function waitFrame(): Promise<void> { 34 37 const {resolve, promise} = Promise.withResolvers<void>() 35 38 ··· 37 40 resolve() 38 41 }) 39 42 return promise 43 + } 44 + 45 + export class SpinTimeout extends Error {} 46 + 47 + /** 48 + * RAF loop until a predicate succeeds, or the timeout hits 49 + */ 50 + export async function spinUntil(pred: () => boolean, max_ms?: number) { 51 + return new Promise<void>((resolve, reject) => { 52 + const start = performance.now() 53 + const check = () => { 54 + if (pred()) { 55 + resolve() 56 + return 57 + } 58 + 59 + if (max_ms !== undefined) { 60 + const elapsed = performance.now() - start 61 + if (elapsed > max_ms) { 62 + reject(new SpinTimeout(`ran out of time, elapsed: ${elapsed}ms`)) 63 + return 64 + } 65 + } 66 + 67 + requestAnimationFrame(check) 68 + } 69 + 70 + check() 71 + }) 40 72 } 41 73 42 74 /**
+9 -16
src/spec/helpers-socket-pair.ts
··· 239 239 send(data: DataChannelSendable): void { 240 240 if (!this.#connected || this.#destroyed) return 241 241 242 - // Convert to string or ArrayBuffer 243 - let message: string | ArrayBuffer 244 - if (typeof data === 'string') { 245 - message = data 246 - } else if (data instanceof ArrayBuffer) { 247 - message = data 248 - } else if (data instanceof Blob) { 249 - // For simplicity, we don't support Blob in tests 250 - throw new Error('MockDataChannel does not support Blob') 251 - } else { 252 - // ArrayBufferView 253 - message = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) 254 - } 255 - 256 242 // Deliver to peer asynchronously 257 243 if (this.#peer) { 258 244 queueMicrotask(() => { 259 - this.#peer?._receive(message) 245 + this.#peer?._receive(data) 260 246 }) 261 247 } 262 248 } ··· 301 287 this.#messageQueue.push(data) 302 288 } 303 289 290 + let message: string 291 + if (data instanceof ArrayBuffer) { 292 + message = new TextDecoder().decode(data) 293 + } else { 294 + message = data 295 + } 296 + 304 297 // Also emit the event for RealmPeer 305 - this.dispatchCustomEvent('data', data) 298 + this.dispatchCustomEvent('data', message) 306 299 } 307 300 308 301 /**