Skip to content

Commit fb69886

Browse files
Merge upstream develop
2 parents 5cba3bd + 66c50ea commit fb69886

29 files changed

Lines changed: 1153 additions & 288 deletions

File tree

apps/polycentric/app/_layout.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import {
1313
initialWindowMetrics,
1414
} from 'react-native-safe-area-context';
1515

16+
// Anchor the root stack on the tabs so that deep-linking directly
17+
// into a modal route (e.g. `/feed/compose`, `/settings/identity`)
18+
// mounts the tabs underneath — giving the modal something to sit on
19+
// and a sensible target for the close button's `router.back()`.
20+
export const unstable_settings = {
21+
initialRouteName: '(tabs)',
22+
};
23+
1624
function RootStack() {
1725
const { theme } = useTheme();
1826

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const AVATAR_SIZE_MAP: Record<AvatarSizePreset, number> = {
1717
sm: 32,
1818
md: 40,
1919
lg: 56,
20-
xl: 80,
20+
xl: 112,
2121
massive: 170,
2222
};
2323

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
forwardRef,
3+
useImperativeHandle,
4+
useLayoutEffect,
5+
useRef,
6+
} from 'react';
7+
import { Platform, TextInput as RNTextInput } from 'react-native';
8+
import { TextInput, type TextInputProps } from '../primitives/TextInput';
9+
10+
export interface TextAreaProps extends Omit<TextInputProps, 'multiline'> {
11+
/** Minimum height in pixels. Defaults to 40. */
12+
minHeight?: number;
13+
}
14+
15+
/**
16+
* Multiline text input that auto-grows and auto-shrinks with content.
17+
*/
18+
export const TextArea = forwardRef<RNTextInput, TextAreaProps>(
19+
({ minHeight = 20, style, value, scrollEnabled, ...props }, ref) => {
20+
const innerRef = useRef<RNTextInput>(null);
21+
useImperativeHandle(ref, () => innerRef.current as RNTextInput);
22+
23+
useLayoutEffect(() => {
24+
if (Platform.OS !== 'web') return;
25+
const node = innerRef.current as unknown as HTMLTextAreaElement | null;
26+
if (!node?.style) return;
27+
node.style.height = 'auto';
28+
node.style.height = `${Math.max(minHeight, node.scrollHeight)}px`;
29+
}, [value, minHeight]);
30+
31+
return (
32+
<TextInput
33+
ref={innerRef}
34+
multiline
35+
numberOfLines={1}
36+
scrollEnabled={scrollEnabled ?? false}
37+
value={value}
38+
style={[
39+
{ minHeight },
40+
Platform.OS === 'web'
41+
? // Suppress the corner resize grip and the vertical
42+
// scrollbar — we drive height via scrollHeight.
43+
({ resize: 'none', overflow: 'hidden' } as object)
44+
: null,
45+
style,
46+
]}
47+
{...props}
48+
/>
49+
);
50+
},
51+
);
52+
53+
TextArea.displayName = 'TextArea';

apps/polycentric/src/common/components/primitives/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from '../Avatar/Avatar';
22
export * from '../Avatar/ProfileAvatar';
3+
export * from '../TextArea/TextArea';
34
export * from './Button';
45
export * from './Chip';
56
export * from './HorizontalScrollGroup';

apps/polycentric/src/common/constants/routes.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import { router } from 'expo-router';
22

3-
export function openCompose(replyTo?: {
4-
identityId: string;
5-
sequence: string;
6-
}) {
3+
export type OpenComposeOptions = {
4+
replyTo?: { identityId: string; sequence: string };
5+
/** Immediately launch the image picker once the composer mounts. */
6+
attachImage?: boolean;
7+
};
8+
9+
export function openCompose(options: OpenComposeOptions = {}) {
10+
const { replyTo, attachImage } = options;
11+
const params = new URLSearchParams();
712
if (replyTo) {
8-
const path = `${encodeURIComponent(replyTo.identityId)}/${encodeURIComponent(replyTo.sequence)}`;
9-
router.push(`${Routes.tabs.feed.compose}?replyTo=${path}`);
10-
} else {
11-
router.push(Routes.tabs.feed.compose);
13+
params.set('replyTo', `${replyTo.identityId}/${replyTo.sequence}`);
1214
}
15+
if (attachImage) params.set('attach', '1');
16+
const qs = params.toString();
17+
router.push(
18+
qs ? `${Routes.tabs.feed.compose}?${qs}` : Routes.tabs.feed.compose,
19+
);
1320
}
1421

1522
export const Routes = {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { sha256 } from '@noble/hashes/sha2';
2+
import { v2, type PolycentricClient } from '@polycentric/react-native';
3+
4+
/** Default variant edge lengths. */
5+
export const DEFAULT_IMAGE_VARIANT_SIZES = [48, 128, 512];
6+
7+
export type ProcessAndUploadOptions = {
8+
/** Variant edge lengths to emit. Interpreted per `mode`. */
9+
sizes?: number[];
10+
/** `fill` crops to a square (default, for avatars). `fit` preserves aspect. */
11+
mode?: 'fill' | 'fit';
12+
/** Source image width. Required for `mode: 'fit'` so variants record correct dims. */
13+
sourceWidth?: number;
14+
/** Source image height. Required for `mode: 'fit'` so variants record correct dims. */
15+
sourceHeight?: number;
16+
};
17+
18+
/**
19+
* Fetch an image from `uri`, resize it into each size in `sizes` via
20+
* the core's JPEG encoder, upload each variant's bytes to the client's
21+
* servers, and return the assembled `ImageSet`.
22+
*/
23+
export async function processAndUploadImage(
24+
client: PolycentricClient,
25+
uri: string,
26+
options: ProcessAndUploadOptions = {},
27+
): Promise<v2.ImageSet> {
28+
const sizes = options.sizes ?? DEFAULT_IMAGE_VARIANT_SIZES;
29+
const mode = options.mode ?? 'fill';
30+
31+
const response = await fetch(uri);
32+
const raw = new Uint8Array(await response.arrayBuffer());
33+
34+
const variants = await Promise.all(
35+
sizes.map(async (size) => {
36+
const jpeg = client.processImageToJpeg(raw, size, size, mode);
37+
const { width, height } = computeOutputDims(
38+
size,
39+
size,
40+
mode,
41+
options.sourceWidth,
42+
options.sourceHeight,
43+
);
44+
const image = v2.Image.create({
45+
blob: {
46+
digest: {
47+
type: v2.ContentDigestType.SHA256,
48+
value: sha256(jpeg),
49+
},
50+
mimeType: 'image/jpeg',
51+
size: BigInt(jpeg.length),
52+
},
53+
width,
54+
height,
55+
});
56+
return { image, body: jpeg };
57+
}),
58+
);
59+
60+
await Promise.all(
61+
variants.map((v) =>
62+
v.image.blob
63+
? client.uploadBlob(v.image.blob, v.body)
64+
: Promise.resolve(),
65+
),
66+
);
67+
68+
return v2.ImageSet.create({ images: variants.map((v) => v.image) });
69+
}
70+
71+
/**
72+
* Match the `image` crate's `resize()`: scale both axes by
73+
* `min(targetW/srcW, targetH/srcH)` and round. For fill mode the
74+
* output is always exactly the requested bounds. For fit mode without
75+
* known source dims, we fall back to the bounds (the client can re-
76+
* derive aspect from the served image if needed).
77+
*/
78+
function computeOutputDims(
79+
targetW: number,
80+
targetH: number,
81+
mode: 'fill' | 'fit',
82+
srcW?: number,
83+
srcH?: number,
84+
): { width: number; height: number } {
85+
if (mode === 'fill' || !srcW || !srcH) {
86+
return { width: targetW, height: targetH };
87+
}
88+
const ratio = Math.min(targetW / srcW, targetH / srcH);
89+
return {
90+
width: Math.max(1, Math.round(srcW * ratio)),
91+
height: Math.max(1, Math.round(srcH * ratio)),
92+
};
93+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export type PostData = {
3737
content: string;
3838
createdAt: number;
3939

40+
/** Attached image sets, in author-provided order. */
41+
images: v2.ImageSet[];
42+
4043
/** Set when the underlying `v2.Post` carried a `reply`. */
4144
reply?: {
4245
root?: EventKeyRef;
@@ -105,6 +108,7 @@ export function decodeV2PostBundle(bundle: v2.EventBundle): PostData | null {
105108
sequence: key.sequence.toString(),
106109
content: post.text,
107110
createdAt: Number(event.createdAt ?? 0),
111+
images: post.images,
108112
reply,
109113
signedEvent: v2.SignedEvent.create({
110114
eventBytes: bundle.signedEvent.eventBytes,

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import { SheetMenu } from '@/src/common/lib/sheet';
2+
import { Routes } from '@/src/common/constants';
23
import { usePostById } from '@/src/features/post/hooks/usePostById';
34
import { types } from '@polycentric/react-native';
45
import { router, useLocalSearchParams } from 'expo-router';
56
import { useCallback } from 'react';
67
import { ComposeSheetInner } from './ComposeSheetInner';
78

89
export default function ComposeScreen() {
9-
const params = useLocalSearchParams<{ replyTo?: string }>();
10+
const params = useLocalSearchParams<{ replyTo?: string; attach?: string }>();
1011

1112
// replyTo is an `identityId/sequence` path — the same identifier pair
1213
// `Routes.tabs.post` uses.
1314
const [replyToIdentity, replyToSequence] = params.replyTo?.split('/') ?? [];
1415
const { post: replyTo } = usePostById(replyToIdentity, replyToSequence);
1516

16-
console.log(replyTo);
17+
const attachOnMount = params.attach === '1';
1718

1819
const handlePostCreated = useCallback(
1920
async (_signedEvent: types.SignedEvent) => {
@@ -23,13 +24,22 @@ export default function ComposeScreen() {
2324
[],
2425
);
2526

27+
const handleClose = useCallback(() => {
28+
if (router.canGoBack()) {
29+
router.back();
30+
} else {
31+
router.replace(Routes.tabs.feed.index);
32+
}
33+
}, []);
34+
2635
return (
27-
<SheetMenu onClose={() => router.back()} detents={[0.82]} scrollable>
36+
<SheetMenu onClose={handleClose} detents={[0.82]} scrollable>
2837
{(dismissSheet) => (
2938
<ComposeSheetInner
3039
dismissSheet={dismissSheet}
3140
onPostCreated={handlePostCreated}
3241
replyTo={replyTo}
42+
attachOnMount={attachOnMount}
3343
/>
3444
)}
3545
</SheetMenu>

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

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { Button, Text } from '@/src/common/components/primitives';
22
import { Atoms, useTheme, withHexOpacity } from '@/src/common/theme';
3-
import { ActivityIndicator, View } from 'react-native';
3+
import { Ionicons } from '@expo/vector-icons';
4+
import { ActivityIndicator, Pressable, View } from 'react-native';
5+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
46

57
export type ComposeSheetFooterBarProps = {
68
charCount: number;
79
submitting: boolean;
810
canPost: boolean;
911
onPost: () => void;
12+
/** Optional hook for the "attach image" button. Button is hidden when omitted. */
13+
onAttachImage?: () => void;
14+
attachDisabled?: boolean;
1015
variant: 'native' | 'web';
1116
};
1217

@@ -15,14 +20,39 @@ export function ComposeSheetFooterBar({
1520
submitting,
1621
canPost,
1722
onPost,
23+
onAttachImage,
24+
attachDisabled = false,
1825
variant,
1926
}: ComposeSheetFooterBarProps) {
2027
const { theme } = useTheme();
2128

22-
const countLabel = (
23-
<Text variant="small" color="neutral_500">
24-
{charCount}/2000
25-
</Text>
29+
const attachButton = onAttachImage ? (
30+
<Pressable
31+
onPress={onAttachImage}
32+
disabled={attachDisabled}
33+
hitSlop={10}
34+
accessibilityLabel="Attach image"
35+
style={[
36+
Atoms.p_xs,
37+
Atoms.rounded_md,
38+
{ opacity: attachDisabled ? 0.4 : 1 },
39+
]}
40+
>
41+
<Ionicons
42+
name="image-outline"
43+
size={22}
44+
color={theme.palette.neutral_700}
45+
/>
46+
</Pressable>
47+
) : null;
48+
49+
const leading = (
50+
<View style={[Atoms.flex_row, Atoms.items_center, Atoms.gap_sm]}>
51+
{attachButton}
52+
<Text variant="small" color="neutral_500">
53+
{charCount}/2000
54+
</Text>
55+
</View>
2656
);
2757

2858
const postSlot = (
@@ -68,10 +98,9 @@ export function ComposeSheetFooterBar({
6898
Atoms.px_lg,
6999
theme.atoms.bg,
70100
borderTop,
71-
{ paddingBottom: 24 },
72101
]}
73102
>
74-
{countLabel}
103+
{leading}
75104
{postSlot}
76105
</View>
77106
);
@@ -81,15 +110,15 @@ export function ComposeSheetFooterBar({
81110
<View
82111
style={[
83112
Atoms.flex_row,
84-
Atoms.justify_end,
113+
Atoms.justify_between,
114+
Atoms.items_center,
85115
Atoms.py_md,
86116
Atoms.px_lg,
87117
theme.atoms.bg,
88118
borderTop,
89-
{ paddingBottom: 24 },
90119
]}
91120
>
92-
{countLabel}
121+
{leading}
93122
</View>
94123
);
95124
}

0 commit comments

Comments
 (0)