Skip to content

Commit 6d2f333

Browse files
Merge upstream develop
2 parents 73f19f3 + 45a47ff commit 6d2f333

9 files changed

Lines changed: 93 additions & 27 deletions

File tree

.gitlab/ci/release.yml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ release-android-apk:
3838
stage: release
3939
image:
4040
name: gcr.io/go-containerregistry/crane:debug
41-
entrypoint: ['']
41+
entrypoint: [""]
4242
script:
4343
- crane auth login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD
4444
- crane tag $CI_REGISTRY_IMAGE/$IMAGE:$CI_COMMIT_SHA $CI_COMMIT_TAG
@@ -93,7 +93,7 @@ release-publish-rs-core:
9393
stage: publish
9494
image: $RUST_IMAGE
9595
variables:
96-
GIT_DEPTH: '1'
96+
GIT_DEPTH: "1"
9797
script:
9898
- VERSION="${CI_COMMIT_TAG#v}"
9999
# Bump the crate versions and give the path dependency a version so the
@@ -164,11 +164,12 @@ release:
164164
artifacts: false
165165
release:
166166
tag_name: $CI_COMMIT_TAG
167-
name: '$CI_COMMIT_TAG'
168-
ref: '$CI_COMMIT_SHA'
167+
name: "$CI_COMMIT_TAG"
168+
ref: "$CI_COMMIT_SHA"
169169
description: release_notes.md
170170
assets:
171171
links:
172-
- name: 'Android APK ($CI_COMMIT_TAG)'
173-
url: '$APK_PACKAGE_URL'
172+
- name: "Android APK ($CI_COMMIT_TAG)"
173+
url: "$APK_PACKAGE_URL"
174174
link_type: package
175+
filepath: "/polycentric-android.apk"

apps/polycentric/src/common/lib/polycentric-hooks/helpers.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ function decodeRepostBundle(bundle: v2.EventBundle): {
196196
* directly; reposts resolve their target post from `event_hints` (the
197197
* server ships the reposted post alongside) and surface it tagged with
198198
* `repostedBy`. A repost whose target isn't in the hints is dropped.
199+
*
200+
* Items are de-duplicated by their list key (`repostId ?? id`). The explore
201+
* feed fans out across multiple servers and paginates with a single forward
202+
* token, so the same post routinely arrives more than once. Without this,
203+
* duplicate keys reach FlashList and recycled cells flash / the scroll
204+
* position jumps while paginating. The first occurrence wins so already-
205+
* rendered rows keep their position.
199206
*/
200207
export function decodeFeedItems(response: v2.GetFeedResponse): PostData[] {
201208
const hintPosts = new Map<string, PostData>();
@@ -206,17 +213,25 @@ export function decodeFeedItems(response: v2.GetFeedResponse): PostData[] {
206213
}
207214

208215
const items: PostData[] = [];
216+
const seen = new Set<string>();
217+
const pushUnique = (item: PostData) => {
218+
const key = item.repostId ?? item.id;
219+
if (seen.has(key)) return;
220+
seen.add(key);
221+
items.push(item);
222+
};
223+
209224
for (const bundle of response.eventBundles) {
210225
const post = decodePostBundle(bundle);
211226
if (post) {
212-
items.push(post);
227+
pushUnique(post);
213228
continue;
214229
}
215230
const repost = decodeRepostBundle(bundle);
216231
if (repost) {
217232
const target = hintPosts.get(repost.targetId);
218233
if (target) {
219-
items.push({
234+
pushUnique({
220235
...target,
221236
repostedBy: repost.repostedBy,
222237
repostId: repost.repostId,

apps/polycentric/src/features/feed/hooks/useExploreFeed.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { useMemo } from 'react';
21
import { Query, QueryStatus, UpdateMode } from '@polycentric/react-native';
32
import {
4-
decodeFeedQueryResult,
53
extractFeedToken,
64
shouldExtend,
75
usePolycentricContext,
86
} from '@/src/common/lib/polycentric-hooks';
97
import { type FeedHookResult } from './types';
108
import { RefreshStrategy, useQuery } from '@/src/common/query/hooks/useQuery';
119
import { feedQueryKeys } from './feedCache';
10+
import { useStableFeedItems } from './useStableFeedItems';
1211

1312
export function useExploreFeed(options?: {
1413
perServerLimit?: number;
@@ -33,10 +32,7 @@ export function useExploreFeed(options?: {
3332
enabled,
3433
);
3534

36-
const [items, hasNext] = useMemo(
37-
() => decodeFeedQueryResult(query.data),
38-
[query.data],
39-
);
35+
const [items, hasNext] = useStableFeedItems(query.data);
4036

4137
return {
4238
items,

apps/polycentric/src/features/feed/hooks/useFollowingFeed.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { useMemo } from 'react';
21
import { Query, QueryStatus, UpdateMode } from '@polycentric/react-native';
32
import {
4-
decodeFeedQueryResult,
53
extractFeedToken,
64
shouldExtend,
75
usePolycentricContext,
86
} from '@/src/common/lib/polycentric-hooks';
97
import { type FeedHookResult } from './types';
108
import { RefreshStrategy, useQuery } from '@/src/common/query/hooks/useQuery';
119
import { feedQueryKeys } from './feedCache';
10+
import { useStableFeedItems } from './useStableFeedItems';
1211

1312
export function useFollowingFeed(options?: {
1413
limit?: number;
@@ -33,10 +32,7 @@ export function useFollowingFeed(options?: {
3332
enabled,
3433
);
3534

36-
const [items, hasNext] = useMemo(
37-
() => decodeFeedQueryResult(query.data),
38-
[query.data],
39-
);
35+
const [items, hasNext] = useStableFeedItems(query.data);
4036

4137
return {
4238
items,

apps/polycentric/src/features/feed/hooks/useIdentityFeed.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { useMemo } from 'react';
21
import { Query, QueryStatus, UpdateMode } from '@polycentric/react-native';
32
import { type FeedHookResult } from './types';
43
import { RefreshStrategy, useQuery } from '@/src/common/query/hooks/useQuery';
54
import { feedQueryKeys } from './feedCache';
65
import {
7-
decodeFeedQueryResult,
86
extractFeedToken,
97
shouldExtend,
108
} from '@/src/common/lib/polycentric-hooks';
9+
import { useStableFeedItems } from './useStableFeedItems';
1110

1211
export function useIdentityFeed(
1312
identityId: string | null | undefined,
@@ -27,10 +26,7 @@ export function useIdentityFeed(
2726
enabled,
2827
);
2928

30-
const [items, hasNext] = useMemo(
31-
() => decodeFeedQueryResult(query.data),
32-
[query.data],
33-
);
29+
const [items, hasNext] = useStableFeedItems(query.data);
3430

3531
return {
3632
items,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useMemo, useRef } from 'react';
2+
import {
3+
decodeFeedQueryResult,
4+
type PostData,
5+
} from '@/src/common/lib/polycentric-hooks';
6+
7+
// Mirror the list's keyExtractor (`FeedList` keys rows by `repostId ?? id`).
8+
const keyOf = (item: PostData) => item.repostId ?? item.id;
9+
10+
/**
11+
* Decode a feed query buffer into items while preserving the object
12+
* reference for posts we've already decoded.
13+
*
14+
* Every `extend()`/`refresh()` produces a brand-new merged buffer, so a
15+
* naive `decodeFeedQueryResult(data)` rebuilds *every* `PostData` object.
16+
* That changes the `post` prop identity of every already-rendered row, so
17+
* all the memoized `Post` cells re-render at once and FlashList re-lays-out
18+
* the whole list — the previous items visibly flash on load-more.
19+
*
20+
* Polycentric posts are immutable content-addressed events, so the decoded
21+
* value for a given key never changes. We can therefore reuse the prior
22+
* reference for any key we've already seen: unchanged rows keep their
23+
* identity (and skip re-rendering), while only genuinely new rows mount.
24+
*/
25+
export function useStableFeedItems(
26+
data: ArrayBuffer | undefined,
27+
): [PostData[], boolean] {
28+
const cache = useRef<Map<string, PostData>>(new Map());
29+
30+
return useMemo(() => {
31+
const [decoded, hasNext] = decodeFeedQueryResult(data);
32+
const next = new Map<string, PostData>();
33+
const items = decoded.map((item) => {
34+
const key = keyOf(item);
35+
const stable = cache.current.get(key) ?? item;
36+
next.set(key, stable);
37+
return stable;
38+
});
39+
cache.current = next;
40+
return [items, hasNext];
41+
}, [data]);
42+
}

docs/content/intro.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ fetch it from another server that holds a copy.
1414
The [web client and app](https://polycentric.io) and the
1515
[source code](https://gitlab.futo.org/polycentric/polycentric) are public.
1616

17+
## Download
18+
19+
- **Web app**[polycentric.io](https://polycentric.io)
20+
- **Android**[latest APK](https://gitlab.futo.org/polycentric/polycentric/-/releases/permalink/latest/downloads/polycentric-android.apk)
21+
- **iOS**[TestFlight beta](https://testflight.apple.com/join/bZ8py7Ny)
22+
1723
## Model
1824

1925
- **Distributed.** No single server owns the network. A user picks which servers

docs/docusaurus.config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ const config: Config = {
7373
footer: {
7474
style: 'dark',
7575
links: [
76+
{
77+
title: 'Download',
78+
items: [
79+
{ label: 'Web app', href: 'https://polycentric.io' },
80+
{
81+
label: 'Android APK',
82+
href: 'https://gitlab.futo.org/polycentric/polycentric/-/releases/permalink/latest/downloads/polycentric-android.apk',
83+
},
84+
{
85+
label: 'iOS (TestFlight)',
86+
href: 'https://testflight.apple.com/join/bZ8py7Ny',
87+
},
88+
],
89+
},
7690
{
7791
title: 'Docs',
7892
items: [

docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"packageManager": "pnpm@10.34.4",
77
"scripts": {
88
"docusaurus": "docusaurus",
9-
"start": "docusaurus start",
9+
"dev": "docusaurus start",
1010
"build": "docusaurus build",
1111
"swizzle": "docusaurus swizzle",
1212
"deploy": "docusaurus deploy",

0 commit comments

Comments
 (0)