Compare commits

...

7 Commits

Author SHA1 Message Date
Dan Abramov 6472f95590 Add salt 2024-12-18 20:30:58 +00:00
Dan Abramov 1461addb72 Bump 2024-12-18 20:29:01 +00:00
Dan Abramov 9af57ee441 Typo comment 2024-12-18 20:28:13 +00:00
Dan Abramov 6d9d0b281a Oopsie 2024-12-18 20:28:13 +00:00
Dan Abramov 9ecdc487fb Count on surfaces without provider too 2024-12-18 20:28:13 +00:00
Dan Abramov b8d298a95d Track seen in post thread too 2024-12-18 20:28:12 +00:00
Dan Abramov 47c9a373c7 [Discover] Track seen items on the client side 2024-12-18 20:27:47 +00:00
5 changed files with 145 additions and 19 deletions
+1
View File
@@ -114,6 +114,7 @@
"base64-js": "^1.5.1",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"bloomfilter": "^0.0.19",
"date-fns": "^2.30.0",
"deprecated-react-native-prop-types": "^5.0.0",
"email-validator": "^2.0.4",
+31
View File
@@ -5,6 +5,8 @@ import {
jsonStringToLex,
} from '@atproto/api'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {isLikelyGloballySeenPost} from '#/state/feed-feedback'
import {
getAppLanguageAsContentLanguage,
getContentLanguages,
@@ -72,6 +74,35 @@ export class CustomFeedAPI implements FeedAPI {
)
: await loggedOutFetch({...this.params, cursor, limit})
if (res.success) {
if (this.params.feed === DISCOVER_FEED_URI) {
// Try not to show a post in Discover if you've seen it elsewhere in the app.
// A proper way to do this might be to let a feed declare it wants client-side filtering.
// I'm hacking this in here because it needs to be consistent for re-renders so
// the decision on what got included or not for this page needs to happen once.
const candidateFeed = res.data.feed.filter(post => {
if (isLikelyGloballySeenPost(post.post.uri)) {
return false
}
if (post.reply) {
if (
AppBskyFeedDefs.isPostView(post.reply.parent) &&
isLikelyGloballySeenPost(post.reply.parent.uri)
) {
return false
}
if (
AppBskyFeedDefs.isPostView(post.reply.root) &&
isLikelyGloballySeenPost(post.reply.root.uri)
) {
return false
}
}
return true
})
if (candidateFeed.length >= res.data.feed.length / 2) {
res.data.feed = candidateFeed
}
}
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
+48 -14
View File
@@ -1,6 +1,8 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
// @ts-ignore
import {BloomFilter} from 'bloomfilter'
import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
@@ -18,7 +20,15 @@ type StateContext = {
const stateContext = React.createContext<StateContext>({
enabled: false,
onItemSeen: (_item: any) => {},
onItemSeen: (feedItem: any) => {
const slice = getFeedPostSlice(feedItem)
if (slice === null) {
return
}
for (const postItem of slice.items) {
markGloballySeenPost(postItem.uri)
}
},
sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
})
@@ -93,24 +103,25 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const onItemSeen = React.useCallback(
(feedItem: any) => {
if (!enabled) {
return
}
const slice = getFeedPostSlice(feedItem)
if (slice === null) {
return
}
for (const postItem of slice.items) {
if (!history.current.has(postItem)) {
history.current.add(postItem)
queue.current.add(
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext: slice.feedContext,
}),
)
sendToFeed()
markGloballySeenPost(postItem.uri)
if (enabled) {
if (!history.current.has(postItem)) {
history.current.add(postItem)
queue.current.add(
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext: slice.feedContext,
}),
)
sendToFeed()
}
}
}
},
@@ -254,3 +265,26 @@ function flushToStatsig(stats: AggregatedStats | null) {
stats.seenCount = 0
}
}
// https://hur.st/bloomfilter/?n=50k&p=0.001&m=&k=
const p = 0.001 // 0.1% probability of collisions...
const n = 50_000 // ...while we stay below 50k expected items
const m = Math.ceil((n * Math.log(p)) / Math.log(1 / Math.pow(2, Math.log(2))))
const k = Math.round((m / n) * Math.log(2))
let globalBloomFilter = new BloomFilter(m, k)
const salt = Math.random().toString(36).slice(2)
export function markGloballySeenPost(uri: string) {
if (globalBloomFilter.size() >= n) {
// If we ever get here, just restart to avoid saturation.
globalBloomFilter = new BloomFilter(m, k)
}
const key = uri + salt
globalBloomFilter.add(key)
}
export function isLikelyGloballySeenPost(uri: string) {
const key = uri + salt
return globalBloomFilter.test(key)
}
+18 -1
View File
@@ -1,4 +1,4 @@
import React, {memo, useRef, useState} from 'react'
import React, {memo, useEffect, useRef, useState} from 'react'
import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
@@ -18,6 +18,7 @@ import {ScrollProvider} from '#/lib/ScrollContext'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {markGloballySeenPost} from '#/state/feed-feedback'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
fillThreadModerationCache,
@@ -391,6 +392,22 @@ export function PostThread({uri}: {uri: string | undefined}) {
[refetch],
)
useEffect(() => {
if (skeleton) {
// We're not tracking linger time here so conservatively mark
// just the highlighted post and the parents. Presumably,
// if you're already midthread, you know what was above.
const {parents, highlightedPost} = skeleton
markGloballySeenPost(highlightedPost.uri)
for (let i = 0; i < parents.length; i++) {
const parent = parents[i]
if (isThreadPost(parent)) {
markGloballySeenPost(parent.uri)
}
}
}
}, [skeleton])
const {openComposer} = useComposerControls()
const onPressReply = React.useCallback(() => {
if (thread?.type !== 'post') {
+47 -4
View File
@@ -3261,7 +3261,7 @@
"@babel/parser" "^7.25.9"
"@babel/types" "^7.25.9"
"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
@@ -3322,6 +3322,19 @@
debug "^4.3.1"
globals "^11.1.0"
"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
dependencies:
"@babel/code-frame" "^7.25.9"
"@babel/generator" "^7.25.9"
"@babel/parser" "^7.25.9"
"@babel/template" "^7.25.9"
"@babel/types" "^7.25.9"
debug "^4.3.1"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.5", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03"
@@ -8200,6 +8213,11 @@ bl@^4.0.3, bl@^4.1.0:
inherits "^2.0.4"
readable-stream "^3.4.0"
bloomfilter@^0.0.19:
version "0.0.19"
resolved "https://registry.yarnpkg.com/bloomfilter/-/bloomfilter-0.0.19.tgz#3d0e62ce41fc8fe402533902cd27e09e6a738d43"
integrity sha512-WPhd8qNG79Gnz3i3Uk/wx/Dm6ghYV5sUsTmWGbiQNV7WvnsC8OTGZSe0WNXfPPsKDdxV2g6Q7JiO6af1BOGmXA==
bn.js@^4.0.0, bn.js@^4.11.8, bn.js@^4.11.9:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
@@ -17457,7 +17475,16 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17557,7 +17584,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17571,6 +17598,13 @@ strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -18845,7 +18879,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -18863,6 +18897,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"