Bluesky app fork with some witchin' additions 💫

Standardize metadata for client events in feeds (#9653)

authored by

Alex Benzer and committed by
GitHub
2d8de1dd c540dae4

+194 -38
+14 -2
src/components/PostControls/BookmarkButton.tsx
··· 8 import {useCleanError} from '#/lib/hooks/useCleanError' 9 import {logger} from '#/logger' 10 import {type Shadow} from '#/state/cache/post-shadow' 11 import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 12 import {useRequireAuth} from '#/state/session' 13 import {useTheme} from '#/alf' ··· 32 const {mutateAsync: bookmark} = useBookmarkMutation() 33 const cleanError = useCleanError() 34 const requireAuth = useRequireAuth() 35 36 const {viewer} = post 37 const isBookmarked = !!viewer?.bookmarked ··· 50 post, 51 }) 52 53 - logger.metric('post:bookmark', {logContext}) 54 55 toast.show( 56 <toast.Outer> ··· 85 uri: post.uri, 86 }) 87 88 - logger.metric('post:unbookmark', {logContext}) 89 90 toast.show( 91 <toast.Outer>
··· 8 import {useCleanError} from '#/lib/hooks/useCleanError' 9 import {logger} from '#/logger' 10 import {type Shadow} from '#/state/cache/post-shadow' 11 + import {useFeedFeedbackContext} from '#/state/feed-feedback' 12 import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 13 import {useRequireAuth} from '#/state/session' 14 import {useTheme} from '#/alf' ··· 33 const {mutateAsync: bookmark} = useBookmarkMutation() 34 const cleanError = useCleanError() 35 const requireAuth = useRequireAuth() 36 + const {feedDescriptor} = useFeedFeedbackContext() 37 38 const {viewer} = post 39 const isBookmarked = !!viewer?.bookmarked ··· 52 post, 53 }) 54 55 + logger.metric('post:bookmark', { 56 + uri: post.uri, 57 + authorDid: post.author.did, 58 + logContext, 59 + feedDescriptor, 60 + }) 61 62 toast.show( 63 <toast.Outer> ··· 92 uri: post.uri, 93 }) 94 95 + logger.metric('post:unbookmark', { 96 + uri: post.uri, 97 + authorDid: post.author.did, 98 + logContext, 99 + feedDescriptor, 100 + }) 101 102 toast.show( 103 <toast.Outer>
+26
src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 98 richText, 99 threadgateRecord, 100 onShowLess, 101 }: { 102 testID: string 103 post: Shadow<AppBskyFeedDefs.PostView> ··· 111 timestamp: string 112 threadgateRecord?: AppBskyFeedThreadgate.Record 113 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 114 }): React.ReactNode => { 115 const {hasSession, currentAccount} = useSession() 116 const {_} = useLingui() ··· 210 try { 211 if (isThreadMuted) { 212 unmuteThread() 213 Toast.show(_(msg`You will now receive notifications for this thread`)) 214 } else { 215 muteThread() 216 Toast.show( 217 _(msg`You will no longer receive notifications for this thread`), 218 ) ··· 272 feedContext: postFeedContext, 273 reqId: postReqId, 274 }) 275 Toast.show( 276 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 277 ) ··· 283 item: postUri, 284 feedContext: postFeedContext, 285 reqId: postReqId, 286 }) 287 if (onShowLess) { 288 onShowLess({
··· 98 richText, 99 threadgateRecord, 100 onShowLess, 101 + logContext, 102 }: { 103 testID: string 104 post: Shadow<AppBskyFeedDefs.PostView> ··· 112 timestamp: string 113 threadgateRecord?: AppBskyFeedThreadgate.Record 114 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 115 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 116 }): React.ReactNode => { 117 const {hasSession, currentAccount} = useSession() 118 const {_} = useLingui() ··· 212 try { 213 if (isThreadMuted) { 214 unmuteThread() 215 + logger.metric('post:unmute', { 216 + uri: postUri, 217 + authorDid: postAuthor.did, 218 + logContext, 219 + feedDescriptor: feedFeedback.feedDescriptor, 220 + }) 221 Toast.show(_(msg`You will now receive notifications for this thread`)) 222 } else { 223 muteThread() 224 + logger.metric('post:mute', { 225 + uri: postUri, 226 + authorDid: postAuthor.did, 227 + logContext, 228 + feedDescriptor: feedFeedback.feedDescriptor, 229 + }) 230 Toast.show( 231 _(msg`You will no longer receive notifications for this thread`), 232 ) ··· 286 feedContext: postFeedContext, 287 reqId: postReqId, 288 }) 289 + logger.metric('post:showMore', { 290 + uri: postUri, 291 + authorDid: postAuthor.did, 292 + logContext, 293 + feedDescriptor: feedFeedback.feedDescriptor, 294 + }) 295 Toast.show( 296 _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 297 ) ··· 303 item: postUri, 304 feedContext: postFeedContext, 305 reqId: postReqId, 306 + }) 307 + logger.metric('post:showLess', { 308 + uri: postUri, 309 + authorDid: postAuthor.did, 310 + logContext, 311 + feedDescriptor: feedFeedback.feedDescriptor, 312 }) 313 if (onShowLess) { 314 onShowLess({
+3
src/components/PostControls/PostMenu/index.tsx
··· 29 threadgateRecord, 30 onShowLess, 31 hitSlop, 32 }: { 33 testID: string 34 post: Shadow<AppBskyFeedDefs.PostView> ··· 41 threadgateRecord?: AppBskyFeedThreadgate.Record 42 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 43 hitSlop?: Insets 44 }): React.ReactNode => { 45 const {_} = useLingui() 46 ··· 87 timestamp={timestamp} 88 threadgateRecord={threadgateRecord} 89 onShowLess={onShowLess} 90 /> 91 )} 92 </Menu.Root>
··· 29 threadgateRecord, 30 onShowLess, 31 hitSlop, 32 + logContext, 33 }: { 34 testID: string 35 post: Shadow<AppBskyFeedDefs.PostView> ··· 42 threadgateRecord?: AppBskyFeedThreadgate.Record 43 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 44 hitSlop?: Insets 45 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 46 }): React.ReactNode => { 47 const {_} = useLingui() 48 ··· 89 timestamp={timestamp} 90 threadgateRecord={threadgateRecord} 91 onShowLess={onShowLess} 92 + logContext={logContext} 93 /> 94 )} 95 </Menu.Root>
+21 -3
src/components/PostControls/ShareMenu/index.tsx
··· 16 import {toShareUrl} from '#/lib/strings/url-helpers' 17 import {logger} from '#/logger' 18 import {type Shadow} from '#/state/cache/post-shadow' 19 import {EventStopper} from '#/view/com/util/EventStopper' 20 import {native} from '#/alf' 21 import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' ··· 35 threadgateRecord, 36 onShare, 37 hitSlop, 38 }: { 39 testID: string 40 post: Shadow<AppBskyFeedDefs.PostView> ··· 45 threadgateRecord?: AppBskyFeedThreadgate.Record 46 onShare: () => void 47 hitSlop?: Insets 48 }): React.ReactNode => { 49 const {_} = useLingui() 50 const gate = useGate() 51 52 const ShareIcon = gate('alt_share_icon') 53 ? ArrowShareRightIcon ··· 65 setTimeout(menuControl.open) 66 67 logger.metric( 68 - 'share:open', 69 - {context: big ? 'thread' : 'feed'}, 70 {statsig: true}, 71 ) 72 }, 73 }), 74 - [menuControl, setHasBeenOpen, big], 75 ) 76 77 const onNativeLongPress = () => {
··· 16 import {toShareUrl} from '#/lib/strings/url-helpers' 17 import {logger} from '#/logger' 18 import {type Shadow} from '#/state/cache/post-shadow' 19 + import {useFeedFeedbackContext} from '#/state/feed-feedback' 20 import {EventStopper} from '#/view/com/util/EventStopper' 21 import {native} from '#/alf' 22 import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' ··· 36 threadgateRecord, 37 onShare, 38 hitSlop, 39 + logContext, 40 }: { 41 testID: string 42 post: Shadow<AppBskyFeedDefs.PostView> ··· 47 threadgateRecord?: AppBskyFeedThreadgate.Record 48 onShare: () => void 49 hitSlop?: Insets 50 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 51 }): React.ReactNode => { 52 const {_} = useLingui() 53 const gate = useGate() 54 + const {feedDescriptor} = useFeedFeedbackContext() 55 56 const ShareIcon = gate('alt_share_icon') 57 ? ArrowShareRightIcon ··· 69 setTimeout(menuControl.open) 70 71 logger.metric( 72 + 'post:share', 73 + { 74 + uri: post.uri, 75 + authorDid: post.author.did, 76 + logContext, 77 + feedDescriptor, 78 + postContext: big ? 'thread' : 'feed', 79 + }, 80 {statsig: true}, 81 ) 82 }, 83 }), 84 + [ 85 + menuControl, 86 + setHasBeenOpen, 87 + big, 88 + logContext, 89 + feedDescriptor, 90 + post.uri, 91 + post.author.did, 92 + ], 93 ) 94 95 const onNativeLongPress = () => {
+19 -1
src/components/PostControls/index.tsx
··· 13 import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 14 import {useHaptics} from '#/lib/haptics' 15 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 import {type Shadow} from '#/state/cache/types' 17 import {useFeedFeedbackContext} from '#/state/feed-feedback' 18 import { ··· 174 feedContext, 175 reqId, 176 }) 177 openComposer({ 178 quote: post, 179 onPost: onPostReply, ··· 217 testID="replyBtn" 218 onPress={ 219 !replyDisabled 220 - ? () => requireAuth(() => onPressReply()) 221 : undefined 222 } 223 label={_( ··· 315 left: secondaryControlSpacingStyles.gap / 2, 316 right: secondaryControlSpacingStyles.gap / 2, 317 }} 318 /> 319 <PostMenuButton 320 testID="postDropdownBtn" ··· 330 hitSlop={{ 331 left: secondaryControlSpacingStyles.gap / 2, 332 }} 333 /> 334 </View> 335 </View>
··· 13 import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 14 import {useHaptics} from '#/lib/haptics' 15 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 + import {logger} from '#/logger' 17 import {type Shadow} from '#/state/cache/types' 18 import {useFeedFeedbackContext} from '#/state/feed-feedback' 19 import { ··· 175 feedContext, 176 reqId, 177 }) 178 + logger.metric('post:clickQuotePost', { 179 + uri: post.uri, 180 + authorDid: post.author.did, 181 + logContext, 182 + feedDescriptor, 183 + }) 184 openComposer({ 185 quote: post, 186 onPost: onPostReply, ··· 224 testID="replyBtn" 225 onPress={ 226 !replyDisabled 227 + ? () => 228 + requireAuth(() => { 229 + logger.metric('post:clickReply', { 230 + uri: post.uri, 231 + authorDid: post.author.did, 232 + logContext, 233 + feedDescriptor, 234 + }) 235 + onPressReply() 236 + }) 237 : undefined 238 } 239 label={_( ··· 331 left: secondaryControlSpacingStyles.gap / 2, 332 right: secondaryControlSpacingStyles.gap / 2, 333 }} 334 + logContext={logContext} 335 /> 336 <PostMenuButton 337 testID="postDropdownBtn" ··· 347 hitSlop={{ 348 left: secondaryControlSpacingStyles.gap / 2, 349 }} 350 + logContext={logContext} 351 /> 352 </View> 353 </View>
+77 -9
src/logger/metrics.ts
··· 176 'feed:suggestion:press': { 177 feedUrl: string 178 } 179 - 'feed:showMore': { 180 - feed: string 181 - feedContext: string 182 } 183 - 'feed:showLess': { 184 - feed: string 185 - feedContext: string 186 } 187 'feed:clickthrough': { 188 feed: string ··· 257 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 258 feedDescriptor?: string 259 } 260 - 'post:mute': {} 261 - 'post:unmute': {} 262 'post:pin': {} 263 'post:unpin': {} 264 'post:bookmark': { 265 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 266 } 267 'post:unbookmark': { 268 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 269 } 270 'post:view': { 271 uri: string ··· 565 'live:view:profile': {subject: string} 566 'live:view:post': {subject: string; feed?: string} 567 568 - 'share:open': {context: 'feed' | 'thread'} 569 'share:press:copyLink': {} 570 'share:press:nativeShare': {} 571 'share:press:openDmSearch': {}
··· 176 'feed:suggestion:press': { 177 feedUrl: string 178 } 179 + 'post:showMore': { 180 + uri: string 181 + authorDid: string 182 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 183 + feedDescriptor?: string 184 + position?: number 185 } 186 + 'post:showLess': { 187 + uri: string 188 + authorDid: string 189 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 190 + feedDescriptor?: string 191 + position?: number 192 } 193 'feed:clickthrough': { 194 feed: string ··· 263 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 264 feedDescriptor?: string 265 } 266 + 'post:mute': { 267 + uri: string 268 + authorDid: string 269 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 270 + feedDescriptor?: string 271 + position?: number 272 + } 273 + 'post:unmute': { 274 + uri: string 275 + authorDid: string 276 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 277 + feedDescriptor?: string 278 + position?: number 279 + } 280 'post:pin': {} 281 'post:unpin': {} 282 'post:bookmark': { 283 + uri: string 284 + authorDid: string 285 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 286 + feedDescriptor?: string 287 + position?: number 288 } 289 'post:unbookmark': { 290 + uri: string 291 + authorDid: string 292 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 293 + feedDescriptor?: string 294 + position?: number 295 + } 296 + 'post:clickReply': { 297 + uri: string 298 + authorDid: string 299 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 300 + feedDescriptor?: string 301 + position?: number 302 + } 303 + 'post:clickQuotePost': { 304 + uri: string 305 + authorDid: string 306 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 307 + feedDescriptor?: string 308 + position?: number 309 + } 310 + 'post:clickthroughAuthor': { 311 + uri: string 312 + authorDid: string 313 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 314 + feedDescriptor?: string 315 + position?: number 316 + } 317 + 'post:clickthroughItem': { 318 + uri: string 319 + authorDid: string 320 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 321 + feedDescriptor?: string 322 + position?: number 323 + } 324 + 'post:clickthroughEmbed': { 325 + uri: string 326 + authorDid: string 327 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 328 + feedDescriptor?: string 329 + position?: number 330 } 331 'post:view': { 332 uri: string ··· 626 'live:view:profile': {subject: string} 627 'live:view:post': {subject: string; feed?: string} 628 629 + 'post:share': { 630 + uri: string 631 + authorDid: string 632 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 633 + feedDescriptor?: string 634 + postContext: 'feed' | 'thread' 635 + position?: number 636 + } 637 'share:press:copyLink': {} 638 'share:press:nativeShare': {} 639 'share:press:openDmSearch': {}
+12
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 281 ]) 282 283 const onOpenAuthor = () => { 284 if (postSource) { 285 feedFeedback.sendInteraction({ 286 item: post.uri, ··· 292 } 293 294 const onOpenEmbed = () => { 295 if (postSource) { 296 feedFeedback.sendInteraction({ 297 item: post.uri,
··· 281 ]) 282 283 const onOpenAuthor = () => { 284 + logger.metric('post:clickthroughAuthor', { 285 + uri: post.uri, 286 + authorDid: post.author.did, 287 + logContext: 'PostThreadItem', 288 + feedDescriptor: feedFeedback.feedDescriptor, 289 + }) 290 if (postSource) { 291 feedFeedback.sendInteraction({ 292 item: post.uri, ··· 298 } 299 300 const onOpenEmbed = () => { 301 + logger.metric('post:clickthroughEmbed', { 302 + uri: post.uri, 303 + authorDid: post.author.did, 304 + logContext: 'PostThreadItem', 305 + feedDescriptor: feedFeedback.feedDescriptor, 306 + }) 307 if (postSource) { 308 feedFeedback.sendInteraction({ 309 item: post.uri,
+1 -20
src/state/feed-feedback.tsx
··· 137 sendOrAggregateInteractionsForStats( 138 aggregatedStats.current, 139 interactionsToSend, 140 - feed?.feedDescriptor ?? 'unknown', 141 ) 142 throttledFlushAggregatedStats() 143 logger.debug('flushed') ··· 274 function sendOrAggregateInteractionsForStats( 275 stats: AggregatedStats, 276 interactions: AppBskyFeedDefs.Interaction[], 277 - feed: string, 278 ) { 279 for (let interaction of interactions) { 280 switch (interaction.event) { 281 - // Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them. 282 - // This lets us send the feed context together with them. 283 - case 'app.bsky.feed.defs#requestLess': { 284 - logger.metric('feed:showLess', { 285 - feed, 286 - feedContext: interaction.feedContext ?? '', 287 - }) 288 - break 289 - } 290 - case 'app.bsky.feed.defs#requestMore': { 291 - logger.metric('feed:showMore', { 292 - feed, 293 - feedContext: interaction.feedContext ?? '', 294 - }) 295 - break 296 - } 297 - 298 - // The rest of the events are aggregated and sent later in batches. 299 case 'app.bsky.feed.defs#clickthroughAuthor': 300 case 'app.bsky.feed.defs#clickthroughEmbed': 301 case 'app.bsky.feed.defs#clickthroughItem':
··· 137 sendOrAggregateInteractionsForStats( 138 aggregatedStats.current, 139 interactionsToSend, 140 ) 141 throttledFlushAggregatedStats() 142 logger.debug('flushed') ··· 273 function sendOrAggregateInteractionsForStats( 274 stats: AggregatedStats, 275 interactions: AppBskyFeedDefs.Interaction[], 276 ) { 277 for (let interaction of interactions) { 278 switch (interaction.event) { 279 + // The events are aggregated and sent later in batches. 280 case 'app.bsky.feed.defs#clickthroughAuthor': 281 case 'app.bsky.feed.defs#clickthroughEmbed': 282 case 'app.bsky.feed.defs#clickthroughItem':
-2
src/state/queries/post.ts
··· 373 {uri: string} // the root post's uri 374 >({ 375 mutationFn: ({uri}) => { 376 - logger.metric('post:mute', {}) 377 return agent.api.app.bsky.graph.muteThread({root: uri}) 378 }, 379 }) ··· 383 const agent = useAgent() 384 return useMutation<{}, Error, {uri: string}>({ 385 mutationFn: ({uri}) => { 386 - logger.metric('post:unmute', {}) 387 return agent.api.app.bsky.graph.unmuteThread({root: uri}) 388 }, 389 })
··· 373 {uri: string} // the root post's uri 374 >({ 375 mutationFn: ({uri}) => { 376 return agent.api.app.bsky.graph.muteThread({root: uri}) 377 }, 378 }) ··· 382 const agent = useAgent() 383 return useMutation<{}, Error, {uri: string}>({ 384 mutationFn: ({uri}) => { 385 return agent.api.app.bsky.graph.unmuteThread({root: uri}) 386 }, 387 })
+21 -1
src/view/com/posts/PostFeedItem.tsx
··· 21 import {type NavigationProp} from '#/lib/routes/types' 22 import {useGate} from '#/lib/statsig/statsig' 23 import {countLines} from '#/lib/strings/helpers' 24 import { 25 POST_TOMBSTONE, 26 type Shadow, ··· 173 const urip = new AtUri(post.uri) 174 return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] 175 }, [post.uri, post.author]) 176 - const {sendInteraction, feedSourceInfo} = useFeedFeedbackContext() 177 178 const onPressReply = () => { 179 sendInteraction({ ··· 209 feedContext, 210 reqId, 211 }) 212 } 213 214 const onOpenReposter = () => { ··· 227 feedContext, 228 reqId, 229 }) 230 } 231 232 const onBeforePress = () => { ··· 235 event: 'app.bsky.feed.defs#clickthroughItem', 236 feedContext, 237 reqId, 238 }) 239 unstableCacheProfileView(queryClient, post.author) 240 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), {
··· 21 import {type NavigationProp} from '#/lib/routes/types' 22 import {useGate} from '#/lib/statsig/statsig' 23 import {countLines} from '#/lib/strings/helpers' 24 + import {logger} from '#/logger' 25 import { 26 POST_TOMBSTONE, 27 type Shadow, ··· 174 const urip = new AtUri(post.uri) 175 return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] 176 }, [post.uri, post.author]) 177 + const {sendInteraction, feedSourceInfo, feedDescriptor} = 178 + useFeedFeedbackContext() 179 180 const onPressReply = () => { 181 sendInteraction({ ··· 211 feedContext, 212 reqId, 213 }) 214 + logger.metric('post:clickthroughAuthor', { 215 + uri: post.uri, 216 + authorDid: post.author.did, 217 + logContext: 'FeedItem', 218 + feedDescriptor, 219 + }) 220 } 221 222 const onOpenReposter = () => { ··· 235 feedContext, 236 reqId, 237 }) 238 + logger.metric('post:clickthroughEmbed', { 239 + uri: post.uri, 240 + authorDid: post.author.did, 241 + logContext: 'FeedItem', 242 + feedDescriptor, 243 + }) 244 } 245 246 const onBeforePress = () => { ··· 249 event: 'app.bsky.feed.defs#clickthroughItem', 250 feedContext, 251 reqId, 252 + }) 253 + logger.metric('post:clickthroughItem', { 254 + uri: post.uri, 255 + authorDid: post.author.did, 256 + logContext: 'FeedItem', 257 + feedDescriptor, 258 }) 259 unstableCacheProfileView(queryClient, post.author) 260 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), {