Skip to content

Commit f1b142b

Browse files
committed
More UI and UX tweaks
1 parent 819f3ae commit f1b142b

17 files changed

Lines changed: 251 additions & 138 deletions

File tree

apps/polycentric/app/_layout.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ export default function RootLayout() {
6969
if (!ready) {
7070
return;
7171
}
72-
console.log('app is ready?');
7372
void SplashScreen.hideAsync().catch(() => {});
7473
}, [ready]);
7574

apps/polycentric/src/common/components/List/List.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);
2626
const WEB_INITIAL_VISIBLE = 12;
2727
const WEB_PAGE_SIZE = 12;
2828

29+
// Keep the sticky header fully visible until the user has scrolled past
30+
// this distance; only then does scroll-driven hiding kick in.
31+
const HEADER_HIDE_THRESHOLD = 50;
32+
2933
export type { FlashListProps, ListRenderItem, ListRenderItemInfo };
3034

3135
export type ListProps<T> = FlashListProps<T>;
@@ -54,9 +58,20 @@ function NativeList<T>({
5458
const currentY = event.contentOffset.y;
5559
const h = headerHeightShared.value;
5660

57-
const delta = currentY - lastScrollY.value;
58-
const next = headerTranslate.value - delta;
59-
headerTranslate.value = Math.min(0, Math.max(-h, next));
61+
if (currentY <= HEADER_HIDE_THRESHOLD) {
62+
headerTranslate.value = 0;
63+
} else {
64+
// Compute delta relative to the threshold so movement past it
65+
// starts the hide from translate 0 instead of snapping.
66+
const lastEffective = Math.max(
67+
0,
68+
lastScrollY.value - HEADER_HIDE_THRESHOLD,
69+
);
70+
const currentEffective = currentY - HEADER_HIDE_THRESHOLD;
71+
const delta = currentEffective - lastEffective;
72+
const next = headerTranslate.value - delta;
73+
headerTranslate.value = Math.min(0, Math.max(-h, next));
74+
}
6075
lastScrollY.value = currentY;
6176
});
6277

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useIsFocused } from 'expo-router';
2+
import { useEffect, type ReactNode } from 'react';
3+
import Animated, {
4+
useAnimatedStyle,
5+
useSharedValue,
6+
withTiming,
7+
} from 'react-native-reanimated';
8+
9+
// Native tab controllers swap screens instantly; this wraps tab content
10+
// so it fades in whenever the screen becomes focused.
11+
export function TabFade({ children }: { children: ReactNode }) {
12+
const isFocused = useIsFocused();
13+
const opacity = useSharedValue(0);
14+
15+
useEffect(() => {
16+
if (isFocused) {
17+
opacity.value = withTiming(1, { duration: 200 });
18+
} else {
19+
opacity.value = 0;
20+
}
21+
}, [isFocused, opacity]);
22+
23+
const animatedStyle = useAnimatedStyle(() => ({
24+
opacity: opacity.value,
25+
}));
26+
27+
return (
28+
<Animated.View style={[{ flex: 1 }, animatedStyle]}>
29+
{children}
30+
</Animated.View>
31+
);
32+
}

apps/polycentric/src/common/components/layout/Topbar.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import BLUE_LOGO from '../../assets/images/polycentric-logo-blue-256.png';
77
import WHITE_LOGO from '../../assets/images/polycentric-logo-white-256.png';
88
import { ProfileAvatar, Text } from '../primitives';
99
import { useCurrentIdentity } from '../../lib/polycentric-hooks';
10-
import { memo, useState, type ReactNode } from 'react';
10+
import { memo, type ReactNode } from 'react';
1111

1212
const SIDE_WIDTH = 32;
1313

@@ -27,9 +27,7 @@ function Topbar({ title, center, right }: TopbarProps) {
2727
const { theme } = useTheme();
2828

2929
const segments = useSegments();
30-
const [isTabRoot] = useState(
31-
() => segments[0] === '(tabs)' && segments.length === 2,
32-
);
30+
const isTabRoot = segments[0] === '(tabs)' && segments.length === 2;
3331
const canGoBack = !isTabRoot;
3432

3533
const identityKey = currentIdentity?.identityKey ?? null;

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ export function bytesToHex(bytes: Uint8Array): string {
143143
.join('');
144144
}
145145

146+
/** Hex of the event key for a bundle — matches `PostData.id` encoding.
147+
* Returns `null` if the bundle isn't a well-formed signed event. */
148+
export function bundleEventId(bundle: v2.EventBundle): string | null {
149+
if (!bundle.signedEvent) return null;
150+
try {
151+
const event = v2.Event.fromBinary(bundle.signedEvent.eventBytes);
152+
if (!event.key) return null;
153+
return bytesToHex(v2.EventKey.toBinary(event.key));
154+
} catch {
155+
return null;
156+
}
157+
}
158+
146159
export function hexToBytes(hex: string): Uint8Array {
147160
const bytes = new Uint8Array(hex.length / 2);
148161
for (let i = 0; i < hex.length; i += 2) {

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ export {
2424
type ProfileEditState,
2525
} from '../../../features/profile/hooks/useProfileEdit';
2626

27-
// Local post injection (composer → live feeds)
28-
export { useLocalPosts as useLocalPostInjection } from '../../../features/post/hooks/useLocalPosts';
29-
3027
// Helpers
3128
export {
3229
decodePostBundle as decodeV2PostBundle,
@@ -35,6 +32,7 @@ export {
3532
pickImageVariant,
3633
timeAgo,
3734
bytesToHex,
35+
bundleEventId,
3836
hexToBytes,
3937
truncateName,
4038
publicKeyToString,

apps/polycentric/src/common/query/hooks/useQuery.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ export type QueryRef = {
1717

1818
type QueryArgs = {
1919
client: PolycentricClient;
20-
queryKey: string[];
20+
queryKey: QueryKey;
2121
query: Query;
2222
opts: QueryOpts | undefined;
2323
};
2424

25+
type QueryKey = string[];
26+
2527
type SubscriptionRef = {
2628
refCount: number;
2729
dispose: () => void;
@@ -137,14 +139,6 @@ export const useQueryStore = create<QueryStoreState>((set, get) => {
137139
const sub = get().subscriptions.get(key);
138140
const target = args ?? sub?.args;
139141
if (target) target.client.core.invalidateQuery(target.queryKey);
140-
if (sub && target) {
141-
sub.dispose();
142-
sub.args = target;
143-
// Keep stale `data` visible — `fetch` flips status to
144-
// Loading and the new fan-out's `next` emissions will
145-
// overwrite as fresh results arrive.
146-
sub.dispose = fetch(key, target);
147-
}
148142
},
149143
};
150144
});
@@ -170,12 +164,43 @@ export type UseQueryResult = QueryRef & {
170164
*/
171165
export function invalidateQuery(
172166
client: PolycentricClient,
173-
queryKey: string[],
167+
queryKey: QueryKey,
174168
): void {
175169
client.core.invalidateQuery(queryKey);
176170
useQueryStore.getState().refresh(queryKey.join('\0'));
177171
}
178172

173+
/**
174+
* Returns the current cache for a query key
175+
*/
176+
export function getQueryCache(queryKey: QueryKey): QueryRef | undefined {
177+
return useQueryStore.getState().queries.get(queryKey.join('\0'));
178+
}
179+
180+
/**
181+
* Updates the local cache state for a query key
182+
*/
183+
export function setQueryCache(
184+
queryKey: QueryKey,
185+
value: Partial<QueryRef>,
186+
): void {
187+
const cacheKey = queryKey.join('\0');
188+
useQueryStore.setState((state) => {
189+
const prev = state.queries.get(cacheKey) ?? EMPTY_ENTRY;
190+
const merged = { ...prev, ...value };
191+
if (
192+
merged.data === prev.data &&
193+
merged.status === prev.status &&
194+
merged.error === prev.error
195+
) {
196+
return {};
197+
}
198+
const next = new Map(state.queries);
199+
next.set(cacheKey, merged);
200+
return { queries: next };
201+
});
202+
}
203+
179204
/**
180205
* Subscribe to a rust-core query and share its state across every
181206
* consumer using the same `queryKey`. The first consumer kicks off
@@ -185,7 +210,7 @@ export function invalidateQuery(
185210
* subscription entirely (the hook still returns cached state if any).
186211
*/
187212
export function useQuery(
188-
queryKey: string[],
213+
queryKey: QueryKey,
189214
query: Query,
190215
opts: QueryOpts = { fetchMode: FetchMode.Default },
191216
enabled = true,

apps/polycentric/src/features/composer/ComposeSheetInner.tsx

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ import { useComposerStore } from './hooks/useComposerStore';
3131
import { Routes } from '@/src/common/constants';
3232
import { router } from 'expo-router';
3333
import { invalidateQuery } from '@/src/common/query/hooks/useQuery';
34+
import { injectReplyIntoThreadCache } from '@/src/features/post/hooks/useThread';
35+
import {
36+
feedQueryKeys,
37+
injectPostIntoFeedCache,
38+
} from '@/src/features/feed/hooks/feedCache';
3439

3540
const MAX_ATTACHMENTS = 4;
3641
const THUMBNAIL_SIZE = 72;
@@ -179,28 +184,39 @@ export function ComposeSheetInner({
179184
const event = await client.buildEvent(content);
180185

181186
const signedEvent = await client.signEvent(event);
182-
// `commitEvent` persists the event locally and, when content is
183-
// passed, seeds the core's content store + emits contentCreated
184-
// with both signedEvent and content so feeds can decode directly.
187+
188+
const newBundle = v2.EventBundle.create({
189+
signedEvent,
190+
serializedContent: { contentBytes: v2.Content.toBinary(content) },
191+
});
192+
const identity = currentIdentityKey ?? '';
193+
194+
// Optimistically add the new event to the below query
195+
if (isReply && replyTo) {
196+
injectReplyIntoThreadCache(replyTo.id, newBundle);
197+
}
198+
injectPostIntoFeedCache(feedQueryKeys.following(), newBundle);
199+
injectPostIntoFeedCache(feedQueryKeys.identity(identity), newBundle);
200+
injectPostIntoFeedCache(feedQueryKeys.explore(identity), newBundle);
201+
202+
// `commitEvent` persists the event locally
185203
await client.commitEvent(signedEvent, content);
186204

187-
resetComposer();
205+
setSubmitting(false);
188206
await dismissSheet(DismissReason.PostSubmitted);
207+
resetComposer();
208+
189209
void client
190210
.sync()
191211
.then(() => {
192-
// Force the feed observables to re-fetch from servers so the
193-
// newly-synced post appears alongside whatever else changed.
194-
const identity = currentIdentityKey ?? '';
195-
invalidateQuery(client, ['following_feed']);
196-
invalidateQuery(client, ['identity_feed', identity]);
197-
invalidateQuery(client, ['explore_feed', identity]);
212+
// Invalidate all the caches now the post has been successfully submitted
213+
invalidateQuery(client, feedQueryKeys.following());
214+
invalidateQuery(client, feedQueryKeys.identity(identity));
215+
invalidateQuery(client, feedQueryKeys.explore(identity));
198216
})
199217
.catch((err) => {
200218
console.warn('compose sync failed:', err);
201219
});
202-
// TODO
203-
// await onPostCreatedRef.current(signedEvent);
204220
} catch (err) {
205221
console.error(err);
206222
const message = err instanceof Error ? err.message : String(err);
@@ -232,7 +248,7 @@ export function ComposeSheetInner({
232248
<SheetHeaderBlock
233249
title={title}
234250
onClose={handleClose}
235-
closeDisabled={submitting}
251+
//closeDisabled={submitting}
236252
trailing={
237253
isWeb ? (
238254
<View style={{ minWidth: 80 }} />
@@ -251,7 +267,7 @@ export function ComposeSheetInner({
251267
/>
252268
) : (
253269
<Button
254-
title="Post"
270+
title={'Post'}
255271
onPress={handlePost}
256272
variant="primary"
257273
disabled={!canPost}
@@ -341,7 +357,7 @@ export function ComposeSheetInner({
341357
autoFocus
342358
value={text}
343359
onChangeText={setText}
344-
disabled={submitting}
360+
// disabled={submitting}
345361
maxLength={2000}
346362
style={[
347363
Atoms.px_0,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { v2 } from '@polycentric/react-native';
2+
import { bundleEventId } from '@/src/common/lib/polycentric-hooks';
3+
import {
4+
getQueryCache,
5+
setQueryCache,
6+
} from '@/src/common/query/hooks/useQuery';
7+
8+
export const feedQueryKeys = {
9+
following: (): string[] => ['following_feed'],
10+
identity: (identity: string): string[] => ['identity_feed', identity],
11+
explore: (identity: string): string[] => ['explore_feed', identity],
12+
};
13+
14+
/**
15+
* Optimistically prepend a post into a feed
16+
*/
17+
export function injectPostIntoFeedCache(
18+
queryKey: string[],
19+
newBundle: v2.EventBundle,
20+
): void {
21+
const cached = getQueryCache(queryKey);
22+
if (!cached?.data) return;
23+
24+
const newId = bundleEventId(newBundle);
25+
if (!newId) return;
26+
27+
let response: v2.GetFeedResponse;
28+
try {
29+
response = v2.GetFeedResponse.fromBinary(new Uint8Array(cached.data));
30+
} catch {
31+
return;
32+
}
33+
34+
for (const b of response.eventBundles) {
35+
if (bundleEventId(b) === newId) return;
36+
}
37+
38+
const updated = v2.GetFeedResponse.create({
39+
eventBundles: [newBundle, ...response.eventBundles],
40+
});
41+
const bytes = v2.GetFeedResponse.toBinary(updated);
42+
setQueryCache(queryKey, { data: bytes.slice().buffer });
43+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@/src/common/lib/polycentric-hooks';
88
import { type FeedHookResult, NOOP } from './types';
99
import { useQuery } from '@/src/common/query/hooks/useQuery';
10+
import { feedQueryKeys } from './feedCache';
1011

1112
export function useExploreFeed(options?: {
1213
perServerLimit?: number;
@@ -17,7 +18,7 @@ export function useExploreFeed(options?: {
1718
const identity = client.activeIdentityKey ?? '';
1819

1920
const query = useQuery(
20-
['explore_feed', identity],
21+
feedQueryKeys.explore(identity),
2122
new Query.GetExploreFeed({
2223
identity: identity === '' ? undefined : identity,
2324
}),

0 commit comments

Comments
 (0)