Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

fix: metrics, scroll state management, style-related console warning

+265 -130
+3 -2
.env.example
··· 28 28 # 29 29 # 30 30 31 - # Bluesky's metrics API 31 + # Optional direct metrics ingestion endpoint. 32 + # If unset, metrics fall back to Sentry/GlitchTip when EXPO_PUBLIC_SENTRY_DSN is set. 32 33 EXPO_PUBLIC_METRICS_API_HOST= 33 34 34 35 # Growthbook config 35 36 EXPO_PUBLIC_GROWTHBOOK_API_HOST= 36 37 EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY= 37 38 38 - # Sentry DSN for telemetry 39 + # Sentry-compatible DSN for telemetry, including GlitchTip. 39 40 EXPO_PUBLIC_SENTRY_DSN= 40 41 41 42 # Bitdrift API key. If undefined, Bitdrift will be disabled.
+73 -15
src/analytics/metrics/client.test.ts
··· 1 1 import {MetricsClient} from './client' 2 2 3 3 let appStateCallback: (state: string) => void 4 + const mockCaptureMessage = jest.fn() 5 + let mockMetricsApiHost: string | undefined = 'https://test.metrics.api' 6 + let mockSentryDsn: string | undefined 4 7 5 8 jest.mock('#/lib/appState', () => ({ 6 9 onAppStateChange: jest.fn(cb => { ··· 20 23 }, 21 24 })) 22 25 26 + jest.mock('@sentry/react-native', () => ({ 27 + captureMessage: mockCaptureMessage, 28 + })) 29 + 23 30 jest.mock('#/env', () => ({ 24 - METRICS_API_HOST: 'https://test.metrics.api', 31 + get METRICS_API_HOST() { 32 + return mockMetricsApiHost 33 + }, 34 + get SENTRY_DSN() { 35 + return mockSentryDsn 36 + }, 25 37 IS_WEB: false, 26 38 })) 27 39 ··· 30 42 view: {screen: string} 31 43 } 32 44 45 + type FetchRequestBody = { 46 + events: Array<{ 47 + event: string 48 + }> 49 + } 50 + 51 + function parseFetchBody(options?: RequestInit): FetchRequestBody { 52 + const {body} = options ?? {} 53 + const raw = 54 + typeof body === 'string' ? body : body == null ? '{}' : JSON.stringify(body) 55 + return JSON.parse(raw) as FetchRequestBody 56 + } 57 + 33 58 describe('MetricsClient', () => { 34 59 let fetchMock: jest.Mock 35 - let fetchRequests: {body: any}[] 60 + let fetchRequests: {body: FetchRequestBody}[] 36 61 37 62 beforeEach(() => { 38 63 jest.useFakeTimers({advanceTimers: true}) 64 + mockMetricsApiHost = 'https://test.metrics.api' 65 + mockSentryDsn = undefined 66 + mockCaptureMessage.mockReset() 39 67 fetchRequests = [] 40 - fetchMock = jest.fn().mockImplementation(async (_url, options) => { 41 - const body = JSON.parse(options.body) 68 + fetchMock = jest.fn().mockImplementation((_url, options?: RequestInit) => { 69 + const body = parseFetchBody(options) 42 70 fetchRequests.push({body}) 43 - return {ok: true, status: 200} 71 + return Promise.resolve({ok: true, status: 200}) 44 72 }) 45 73 global.fetch = fetchMock 46 74 }) ··· 90 118 it('retries failed events once on 500 response', async () => { 91 119 let requestCount = 0 92 120 93 - fetchMock.mockImplementation(async (_url, options) => { 121 + fetchMock.mockImplementation((_url, options?: RequestInit) => { 94 122 requestCount++ 95 - const body = JSON.parse(options.body) 123 + const body = parseFetchBody(options) 96 124 97 125 if (requestCount === 1) { 98 126 // First request fails with 500 - "Failed to fetch" triggers isNetworkError 99 - return { 127 + return Promise.resolve({ 100 128 ok: false, 101 129 status: 500, 102 - text: async () => 'Internal Server Error', 103 - } 130 + text: () => Promise.resolve('Internal Server Error'), 131 + }) 104 132 } 105 133 106 134 // Retry succeeds 107 135 fetchRequests.push({body}) 108 - return {ok: true, status: 200} 136 + return Promise.resolve({ok: true, status: 200}) 109 137 }) 110 138 111 139 const client = new MetricsClient<TestEvents>() ··· 130 158 it('does not retry more than once', async () => { 131 159 let requestCount = 0 132 160 133 - fetchMock.mockImplementation(async () => { 161 + fetchMock.mockImplementation(() => { 134 162 requestCount++ 135 163 // Always fail with network-like error 136 - return { 164 + return Promise.resolve({ 137 165 ok: false, 138 166 status: 500, 139 - text: async () => 'Internal Server Error', 140 - } 167 + text: () => Promise.resolve('Internal Server Error'), 168 + }) 141 169 }) 142 170 143 171 const client = new MetricsClient<TestEvents>() ··· 172 200 await jest.advanceTimersByTimeAsync(0) 173 201 174 202 expect(fetchRequests).toHaveLength(1) 203 + }) 204 + 205 + it('sends metrics through sentry when metrics api host is unset', async () => { 206 + mockMetricsApiHost = undefined 207 + mockSentryDsn = 'https://public@example.glitchtip.com/1' 208 + 209 + const client = new MetricsClient<TestEvents>() 210 + client.track('click', {button: 'submit'}) 211 + client.track('view', {screen: 'home'}) 212 + 213 + await jest.advanceTimersByTimeAsync(10_000) 214 + 215 + expect(fetchRequests).toHaveLength(0) 216 + expect(mockCaptureMessage).toHaveBeenCalledTimes(2) 217 + expect(mockCaptureMessage).toHaveBeenNthCalledWith( 218 + 1, 219 + 'metric:click', 220 + expect.objectContaining({ 221 + level: 'info', 222 + fingerprint: ['metric', 'click'], 223 + tags: expect.objectContaining({ 224 + metric_name: 'click', 225 + metric_source: 'app', 226 + }), 227 + extra: expect.objectContaining({ 228 + logger: 'metric', 229 + payload: {button: 'submit'}, 230 + }), 231 + }), 232 + ) 175 233 }) 176 234 })
+81 -42
src/analytics/metrics/client.ts
··· 1 1 import {onAppStateChange} from '#/lib/appState' 2 - // import {isNetworkError} from '#/lib/strings/errors' 2 + import {isNetworkError} from '#/lib/strings/errors' 3 3 import {Logger} from '#/logger' 4 - // import * as env from '#/env' 4 + import {Sentry} from '#/logger/sentry/lib' 5 + import * as env from '#/env' 5 6 6 7 type Event<M extends Record<string, any>> = { 7 8 source: 'app' ··· 11 12 metadata: Record<string, any> 12 13 } 13 14 14 - // const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/t' 15 15 const logger = Logger.create(Logger.Context.Metric, {}) 16 16 17 17 export class MetricsClient<M extends Record<string, any>> { ··· 63 63 flush() { 64 64 if (!this.queue.length) return 65 65 const events = this.queue.splice(0, this.queue.length) 66 - this.sendBatch(events) 66 + void this.sendBatch(events) 67 67 } 68 68 69 69 private async sendBatch(events: Event<M>[], isRetry: boolean = false) { ··· 71 71 isRetry, 72 72 }) 73 73 74 - // Witchsky: we don't need this :3 75 - // try { 76 - // const body = JSON.stringify({events}) 77 - // if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { 78 - // const success = navigator.sendBeacon( 79 - // TRACKING_ENDPOINT, 80 - // new Blob([body], {type: 'application/json'}), 81 - // ) 82 - // if (!success) { 83 - // // construct a "network error" for `isNetworkError` to work 84 - // throw new Error(`Failed to fetch: sendBeacon returned false`) 85 - // } 86 - // } else { 87 - // const res = await fetch(TRACKING_ENDPOINT, { 88 - // method: 'POST', 89 - // headers: { 90 - // 'Content-Type': 'application/json', 91 - // }, 92 - // body: JSON.stringify({events}), 93 - // keepalive: true, 94 - // }) 74 + const metricsApiHost = env.METRICS_API_HOST 75 + if (metricsApiHost) { 76 + await this.sendBatchToEndpoint(metricsApiHost, events, isRetry) 77 + return 78 + } 79 + 80 + if (env.SENTRY_DSN) { 81 + this.sendBatchToSentry(events) 82 + return 83 + } 84 + 85 + logger.debug(`No metrics transport configured`, { 86 + eventCount: events.length, 87 + }) 88 + } 89 + 90 + private async sendBatchToEndpoint( 91 + endpoint: string, 92 + events: Event<M>[], 93 + isRetry: boolean = false, 94 + ) { 95 + try { 96 + const body = JSON.stringify({events}) 97 + if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { 98 + const success = navigator.sendBeacon( 99 + endpoint, 100 + new Blob([body], {type: 'application/json'}), 101 + ) 102 + if (!success) { 103 + // construct a "network error" for `isNetworkError` to work 104 + throw new Error(`Failed to fetch: sendBeacon returned false`) 105 + } 106 + } else { 107 + const res = await fetch(endpoint, { 108 + method: 'POST', 109 + headers: { 110 + 'Content-Type': 'application/json', 111 + }, 112 + body: JSON.stringify({events}), 113 + keepalive: true, 114 + }) 115 + 116 + if (!res.ok) { 117 + const error = await res.text().catch(() => 'Unknown error') 118 + // construct a "network error" for `isNetworkError` to work 119 + throw new Error(`${res.status} Failed to fetch — ${error}`) 120 + } 121 + } 122 + } catch (e: unknown) { 123 + if (isNetworkError(e)) { 124 + if (isRetry) return // retry once 125 + this.failedQueue.push(...events) 126 + return 127 + } 128 + logger.error(`Failed to send metrics`, { 129 + safeMessage: String(e), 130 + }) 131 + } 132 + } 95 133 96 - // if (!res.ok) { 97 - // const error = await res.text().catch(() => 'Unknown error') 98 - // // construct a "network error" for `isNetworkError` to work 99 - // throw new Error(`${res.status} Failed to fetch — ${error}`) 100 - // } 101 - // } 102 - // } catch (e: any) { 103 - // if (isNetworkError(e)) { 104 - // if (isRetry) return // retry once 105 - // this.failedQueue.push(...events) 106 - // return 107 - // } 108 - // logger.error(`Failed to send metrics`, { 109 - // safeMessage: e.toString(), 110 - // }) 111 - // } 134 + private sendBatchToSentry(events: Event<M>[]) { 135 + for (const event of events) { 136 + Sentry.captureMessage(`metric:${String(event.event)}`, { 137 + level: 'info', 138 + fingerprint: ['metric', String(event.event)], 139 + tags: { 140 + metric_name: String(event.event), 141 + metric_source: event.source, 142 + }, 143 + extra: { 144 + logger: 'metric', 145 + eventTime: event.time, 146 + payload: event.payload, 147 + metadata: event.metadata, 148 + }, 149 + }) 150 + } 112 151 } 113 152 114 153 private retryFailedLogs() { 115 154 if (!this.failedQueue.length) return 116 155 const events = this.failedQueue.splice(0, this.failedQueue.length) 117 - this.sendBatch(events, true) 156 + void this.sendBatch(events, true) 118 157 } 119 158 }
+4 -3
src/env/common.ts
··· 87 87 /** 88 88 * Metrics API host 89 89 */ 90 - export const METRICS_API_HOST: string = 91 - process.env.EXPO_PUBLIC_METRICS_API_HOST || 'https://events.bsky.app' 90 + export const METRICS_API_HOST: string | undefined = 91 + process.env.EXPO_PUBLIC_METRICS_API_HOST 92 92 93 93 /** 94 94 * Growthbook API host 95 95 */ 96 96 export const GROWTHBOOK_API_HOST: string = 97 - process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST || `${METRICS_API_HOST}/gb` 97 + process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST || 98 + (METRICS_API_HOST ? `${METRICS_API_HOST}/gb` : '') 98 99 99 100 /** 100 101 * Growthbook client key
+102 -68
src/screens/PostThread/index.tsx
··· 191 191 const alsoLikedHeaderRef = useRef<View | null>(null) 192 192 const currentScrollOffsetRef = useRef(0) 193 193 const scrollStateRequestIdRef = useRef(0) 194 + const scrollStateAnimationFrameRef = useRef<number | null>(null) 195 + const contentSizeAnimationFrameRef = useRef<number | null>(null) 194 196 const [isAlsoLikedFocused, setIsAlsoLikedFocused] = useState(false) 195 197 196 198 useEffect(() => { ··· 232 234 * The result being: any intentional change in view by the user will result 233 235 * in the anchor being pinned as the first item. 234 236 */ 235 - const onContentSizeChangeWebOnly = web(() => { 236 - const list = listRef.current 237 - const anchorElement = anchorRef.current as any as Element 238 - const header = headerRef.current as any as Element 237 + const onContentSizeChangeWebOnly = web( 238 + useNonReactiveCallback(() => { 239 + if (contentSizeAnimationFrameRef.current !== null) { 240 + cancelAnimationFrame(contentSizeAnimationFrameRef.current) 241 + } 242 + 243 + contentSizeAnimationFrameRef.current = requestAnimationFrame(() => { 244 + contentSizeAnimationFrameRef.current = null 245 + const list = listRef.current 246 + const anchorElement = anchorRef.current as any as Element 247 + const header = headerRef.current as any as Element 239 248 240 - if (list && anchorElement && header && shouldHandleScroll.current) { 241 - const anchorOffsetTop = anchorElement.getBoundingClientRect().top 242 - const headerHeight = header.getBoundingClientRect().height 249 + if (list && anchorElement && header && shouldHandleScroll.current) { 250 + const anchorOffsetTop = anchorElement.getBoundingClientRect().top 251 + const headerHeight = header.getBoundingClientRect().height 243 252 244 - /* 245 - * `deferParents` is `true` on a cold load, and always reset to 246 - * `true` when params change via `prepareForParamsUpdate`. 247 - * 248 - * On a cold load or a push to a new post, on the first pass of this 249 - * logic, the anchor post is the first item in the list. Therefore 250 - * `anchorOffsetTop - headerHeight` will be 0. 251 - * 252 - * When a user changes thread params, on the first pass of this logic, 253 - * the anchor post may not move (if there are no parents above it), or it 254 - * may have gone off the screen above, because of the sudden lack of 255 - * parents due to `deferParents === true`. This negative value (minus 256 - * `headerHeight`) will result in a _negative_ `offset` value, which will 257 - * scroll the anchor post _down_ to the top of the screen. 258 - * 259 - * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user 260 - * changes params, the anchor post's offset will actually be equivalent 261 - * to the `headerHeight` because of how the DOM is stacked on web. 262 - * Therefore, `anchorOffsetTop - headerHeight` will once again be 0, 263 - * which means the first pass in this case will result in no scroll. 264 - * 265 - * Then, once parents are prepended, this will fire again. Now, the 266 - * `anchorOffsetTop` will be positive, which minus the header height, 267 - * will give us a _positive_ offset, which will scroll the anchor post 268 - * back _up_ to the top of the screen. 269 - */ 270 - const offset = anchorOffsetTop - headerHeight 271 - list.scrollToOffset({offset}) 253 + /* 254 + * `deferParents` is `true` on a cold load, and always reset to 255 + * `true` when params change via `prepareForParamsUpdate`. 256 + * 257 + * On a cold load or a push to a new post, on the first pass of this 258 + * logic, the anchor post is the first item in the list. Therefore 259 + * `anchorOffsetTop - headerHeight` will be 0. 260 + * 261 + * When a user changes thread params, on the first pass of this logic, 262 + * the anchor post may not move (if there are no parents above it), or it 263 + * may have gone off the screen above, because of the sudden lack of 264 + * parents due to `deferParents === true`. This negative value (minus 265 + * `headerHeight`) will result in a _negative_ `offset` value, which will 266 + * scroll the anchor post _down_ to the top of the screen. 267 + * 268 + * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user 269 + * changes params, the anchor post's offset will actually be equivalent 270 + * to the `headerHeight` because of how the DOM is stacked on web. 271 + * Therefore, `anchorOffsetTop - headerHeight` will once again be 0, 272 + * which means the first pass in this case will result in no scroll. 273 + * 274 + * Then, once parents are prepended, this will fire again. Now, the 275 + * `anchorOffsetTop` will be positive, which minus the header height, 276 + * will give us a _positive_ offset, which will scroll the anchor post 277 + * back _up_ to the top of the screen. 278 + */ 279 + const offset = anchorOffsetTop - headerHeight 280 + list.scrollToOffset({offset}) 272 281 273 - /* 274 - * After we manage to do a positive adjustment, we need to ensure this 275 - * doesn't run again until scroll handling is requested again via 276 - * `shouldHandleScroll.current === true` and a params change via 277 - * `prepareForParamsUpdate`. 278 - * 279 - * The `isRoot` here is needed because if we're looking at the anchor 280 - * post, this handler will not fire after `deferParents` is set to 281 - * `false`, since there are no parents to render above it. In this case, 282 - * we want to make sure `shouldHandleScroll` is set to `false` right away 283 - * so that subsequent size changes unrelated to a params change (like 284 - * pagination) do not affect scroll. 285 - */ 286 - if (offset > 0 || isRoot) shouldHandleScroll.current = false 287 - } 288 - }) 282 + /* 283 + * After we manage to do a positive adjustment, we need to ensure this 284 + * doesn't run again until scroll handling is requested again via 285 + * `shouldHandleScroll.current === true` and a params change via 286 + * `prepareForParamsUpdate`. 287 + * 288 + * The `isRoot` here is needed because if we're looking at the anchor 289 + * post, this handler will not fire after `deferParents` is set to 290 + * `false`, since there are no parents to render above it. In this case, 291 + * we want to make sure `shouldHandleScroll` is set to `false` right away 292 + * so that subsequent size changes unrelated to a params change (like 293 + * pagination) do not affect scroll. 294 + */ 295 + if (offset > 0 || isRoot) shouldHandleScroll.current = false 296 + } 297 + }) 298 + }), 299 + ) 289 300 290 301 /** 291 302 * Ditto the above, but for native. ··· 605 616 const toggleAlsoLikedCollapsed = useCallback(() => { 606 617 setAlsoLikedCollapsed(current => !current) 607 618 }, []) 608 - const handleScrollOffsetChange = useNonReactiveCallback((offsetY: number) => { 609 - currentScrollOffsetRef.current = offsetY 610 - if (offsetY <= 1) { 611 - scrollStateRequestIdRef.current += 1 612 - setIsAlsoLikedFocused(false) 613 - return 614 - } 615 - scrollStateRequestIdRef.current += 1 616 - updateAlsoLikedScrollState(offsetY, scrollStateRequestIdRef.current) 617 - }) 618 - const updateAlsoLikedScrollState = useNonReactiveCallback( 619 - (offsetY: number, requestId: number) => { 619 + const runAlsoLikedScrollStateUpdate = useNonReactiveCallback( 620 + (requestId: number) => { 621 + scrollStateAnimationFrameRef.current = null 622 + 620 623 if ( 621 624 !alsoLikedVisible || 622 625 alsoLikedCollapsed || ··· 651 654 }) 652 655 }, 653 656 ) 657 + const scheduleAlsoLikedScrollStateUpdate = useNonReactiveCallback(() => { 658 + if (scrollStateAnimationFrameRef.current !== null) { 659 + cancelAnimationFrame(scrollStateAnimationFrameRef.current) 660 + } 661 + 662 + const requestId = scrollStateRequestIdRef.current 663 + scrollStateAnimationFrameRef.current = requestAnimationFrame(() => { 664 + runAlsoLikedScrollStateUpdate(requestId) 665 + }) 666 + }) 667 + const handleScrollOffsetChange = useNonReactiveCallback((offsetY: number) => { 668 + currentScrollOffsetRef.current = offsetY 669 + if (offsetY <= 1) { 670 + scrollStateRequestIdRef.current += 1 671 + setIsAlsoLikedFocused(false) 672 + return 673 + } 674 + if (!alsoLikedVisible || alsoLikedCollapsed) { 675 + return 676 + } 677 + scrollStateRequestIdRef.current += 1 678 + scheduleAlsoLikedScrollStateUpdate() 679 + }) 680 + 681 + useEffect(() => { 682 + return () => { 683 + if (scrollStateAnimationFrameRef.current !== null) { 684 + cancelAnimationFrame(scrollStateAnimationFrameRef.current) 685 + } 686 + if (contentSizeAnimationFrameRef.current !== null) { 687 + cancelAnimationFrame(contentSizeAnimationFrameRef.current) 688 + } 689 + } 690 + }, []) 654 691 655 692 useEffect(() => { 656 693 scrollStateRequestIdRef.current += 1 657 - updateAlsoLikedScrollState( 658 - currentScrollOffsetRef.current, 659 - scrollStateRequestIdRef.current, 660 - ) 694 + scheduleAlsoLikedScrollStateUpdate() 661 695 }, [ 662 696 alsoLikedCollapsed, 663 697 alsoLikedPosts.length, 664 698 alsoLikedVisible, 665 - updateAlsoLikedScrollState, 699 + scheduleAlsoLikedScrollStateUpdate, 666 700 ]) 667 701 668 702 return (
+2
src/style.css
··· 374 374 .radix-popover-content { 375 375 animation-duration: 300ms; 376 376 animation-timing-function: cubic-bezier(0.17, 0.73, 0.14, 1); 377 + } 378 + .radix-popover-content[data-state='open'] { 377 379 will-change: transform, opacity; 378 380 } 379 381 .radix-popover-content[data-state='open'][data-side='top'] {