Skip to content

Commit 0f878b9

Browse files
Merge upstream develop
2 parents 94d3c05 + f5c52dc commit 0f878b9

16 files changed

Lines changed: 564 additions & 169 deletions

File tree

apps/polycentric/app/(tabs)/_layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ export default function TabsLayout() {
3535
<NativeTabs.Trigger.Icon sf="bell" md="notifications" />
3636
</NativeTabs.Trigger>
3737

38-
<NativeTabs.Trigger name="verify">
39-
<NativeTabs.Trigger.Label>Verify</NativeTabs.Trigger.Label>
38+
<NativeTabs.Trigger name="verifications">
39+
<NativeTabs.Trigger.Label>Verifications</NativeTabs.Trigger.Label>
4040
<NativeTabs.Trigger.Icon sf="checkmark.seal" md="verified" />
4141
</NativeTabs.Trigger>
4242

apps/polycentric/app/_layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Toaster } from '@/src/common/components/toast';
12
import { LinkPreviewsProvider } from '@/src/common/link-previews';
23
import { PolycentricProvider } from '@/src/common/lib/polycentric-hooks';
34
import { Atoms, ThemeProvider, useTheme } from '@/src/common/theme';
@@ -96,6 +97,7 @@ export default function RootLayout() {
9697
<TrueSheetProvider>
9798
<RootStack />
9899
<PortalHost />
100+
<Toaster />
99101
</TrueSheetProvider>
100102
</PolycentricProvider>
101103
</LinkPreviewsProvider>

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,19 @@ export type ScrollViewProps = RNScrollViewProps & {
1919
| undefined;
2020
};
2121

22-
export function ScrollView({
23-
HeaderComponent,
24-
contentContainerStyle,
25-
onScroll: _ignoredOnScroll,
26-
children,
27-
...rest
28-
}: ScrollViewProps) {
22+
export const ScrollView = React.forwardRef<
23+
Animated.ScrollView,
24+
ScrollViewProps
25+
>(function ScrollView(
26+
{
27+
HeaderComponent,
28+
contentContainerStyle,
29+
onScroll: _ignoredOnScroll,
30+
children,
31+
...rest
32+
},
33+
ref,
34+
) {
2935
const { onScroll, headerHeight, headerAnimatedStyle, onHeaderLayout } =
3036
useHidingHeader();
3137

@@ -40,6 +46,7 @@ export function ScrollView({
4046
) : null}
4147

4248
<Animated.ScrollView
49+
ref={ref}
4350
{...rest}
4451
onScroll={onScroll}
4552
scrollEventThrottle={16}
@@ -52,4 +59,4 @@ export function ScrollView({
5259
</Animated.ScrollView>
5360
</View>
5461
);
55-
}
62+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Text } from '@/src/common/components';
2+
import Icon, { IconName } from '@/src/common/components/Icon';
3+
import {
4+
Atoms,
5+
BorderRadius,
6+
PaletteColorToken,
7+
useTheme,
8+
} from '@/src/common/theme';
9+
import * as ToastPrimitive from '@rn-primitives/toast';
10+
import { useCallback, useEffect } from 'react';
11+
import { Pressable, View } from 'react-native';
12+
import Animated, {
13+
LinearTransition,
14+
useAnimatedStyle,
15+
useSharedValue,
16+
withTiming,
17+
} from 'react-native-reanimated';
18+
import { runOnJS } from 'react-native-worklets';
19+
import { ToastData, ToastVariant, useToastStore } from './useToastStore';
20+
21+
const ENTER_MS = 220;
22+
const EXIT_MS = 160;
23+
24+
const VARIANTS: Record<
25+
ToastVariant,
26+
{ icon: IconName; color: PaletteColorToken }
27+
> = {
28+
info: { icon: 'notification', color: 'primary_500' },
29+
success: { icon: 'checkmarkCircle', color: 'positive_500' },
30+
error: { icon: 'ban', color: 'negative_500' },
31+
warning: { icon: 'flag', color: 'warning_500' },
32+
};
33+
34+
const shadow = {
35+
shadowColor: '#000',
36+
shadowOpacity: 0.12,
37+
shadowRadius: 12,
38+
shadowOffset: { width: 0, height: 4 },
39+
elevation: 4,
40+
};
41+
42+
export function Toast({ toast }: { toast: ToastData }) {
43+
const { theme } = useTheme();
44+
const dismiss = useToastStore((s) => s.dismiss);
45+
const progress = useSharedValue(0);
46+
const variant = VARIANTS[toast.variant];
47+
48+
const close = useCallback(() => {
49+
progress.value = withTiming(0, { duration: EXIT_MS }, (finished) => {
50+
if (finished) runOnJS(dismiss)(toast.id);
51+
});
52+
}, [progress, dismiss, toast.id]);
53+
54+
useEffect(() => {
55+
progress.value = withTiming(1, { duration: ENTER_MS });
56+
if (toast.duration === null) return;
57+
const timer = setTimeout(close, toast.duration);
58+
return () => clearTimeout(timer);
59+
}, [progress, close, toast.duration]);
60+
61+
const animatedStyle = useAnimatedStyle(() => ({
62+
opacity: progress.value,
63+
// Drop down from above on enter, lift back up on exit.
64+
transform: [{ translateY: (progress.value - 1) * 16 }],
65+
}));
66+
67+
return (
68+
<Animated.View layout={LinearTransition} style={animatedStyle}>
69+
<ToastPrimitive.Root
70+
open
71+
onOpenChange={(next) => {
72+
if (!next) close();
73+
}}
74+
style={[
75+
Atoms.flex_row,
76+
Atoms.items_center,
77+
Atoms.gap_sm,
78+
Atoms.p_lg,
79+
{
80+
borderRadius: BorderRadius.full,
81+
backgroundColor: theme.palette.neutral_0,
82+
borderWidth: 1,
83+
borderColor: theme.palette.neutral_50,
84+
...shadow,
85+
},
86+
]}
87+
>
88+
<Icon name={variant.icon} color={variant.color} size={20} />
89+
<View style={Atoms.flex_1}>
90+
<ToastPrimitive.Title asChild>
91+
<Text variant="body" fontWeight="semibold" style={theme.atoms.text}>
92+
{toast.title}
93+
</Text>
94+
</ToastPrimitive.Title>
95+
{toast.description ? (
96+
<ToastPrimitive.Description asChild>
97+
<Text variant="small" style={theme.atoms.text_neutral_medium}>
98+
{toast.description}
99+
</Text>
100+
</ToastPrimitive.Description>
101+
) : null}
102+
</View>
103+
<ToastPrimitive.Close asChild>
104+
<Pressable hitSlop={8}>
105+
<Icon name="close" color="neutral_500" size={18} />
106+
</Pressable>
107+
</ToastPrimitive.Close>
108+
</ToastPrimitive.Root>
109+
</Animated.View>
110+
);
111+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Atoms, Spacing } from '@/src/common/theme';
2+
import { Portal } from '@rn-primitives/portal';
3+
import { View } from 'react-native';
4+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
5+
import { Toast } from './Toast';
6+
import { useToastStore } from './useToastStore';
7+
8+
export function Toaster() {
9+
const toasts = useToastStore((s) => s.toasts);
10+
const insets = useSafeAreaInsets();
11+
12+
if (toasts.length === 0) return null;
13+
14+
return (
15+
<Portal name="toaster">
16+
<View
17+
pointerEvents="box-none"
18+
style={[
19+
Atoms.absolute,
20+
Atoms.inset_0,
21+
Atoms.justify_start,
22+
Atoms.px_lg,
23+
Atoms.gap_sm,
24+
// Safe-area inset is dynamic, so it stays out of the Atoms list.
25+
{ paddingTop: insets.top + Spacing.sm },
26+
]}
27+
>
28+
{toasts.map((toast) => (
29+
<Toast key={toast.id} toast={toast} />
30+
))}
31+
</View>
32+
</Portal>
33+
);
34+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { Toaster } from './Toaster';
2+
export { toast, useToast } from './useToast';
3+
export { useToastStore } from './useToastStore';
4+
export type { ToastData, ToastOptions, ToastVariant } from './useToastStore';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ToastOptions, ToastVariant, useToastStore } from './useToastStore';
2+
3+
function trigger(variant: ToastVariant) {
4+
return (title: string, options?: ToastOptions) =>
5+
useToastStore.getState().show(title, { ...options, variant });
6+
}
7+
8+
// Imperative API usable outside of components (event handlers, async flows).
9+
export const toast = {
10+
show: (title: string, options?: ToastOptions) =>
11+
useToastStore.getState().show(title, options),
12+
info: trigger('info'),
13+
success: trigger('success'),
14+
error: trigger('error'),
15+
warning: trigger('warning'),
16+
dismiss: (id: string) => useToastStore.getState().dismiss(id),
17+
dismissAll: () => useToastStore.getState().dismissAll(),
18+
};
19+
20+
// Hook form for components; the triggers are stable and read live store state.
21+
export function useToast() {
22+
return toast;
23+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { create } from 'zustand';
2+
3+
export type ToastVariant = 'info' | 'success' | 'error' | 'warning';
4+
5+
export interface ToastData {
6+
id: string;
7+
title: string;
8+
description?: string;
9+
variant: ToastVariant;
10+
// Auto-dismiss delay in ms; null keeps it up until dismissed.
11+
duration: number | null;
12+
}
13+
14+
export interface ToastOptions {
15+
description?: string;
16+
variant?: ToastVariant;
17+
duration?: number | null;
18+
}
19+
20+
const MAX_VISIBLE = 4;
21+
const DEFAULT_DURATION = 4000;
22+
23+
interface ToastStore {
24+
toasts: ToastData[];
25+
show: (title: string, options?: ToastOptions) => string;
26+
dismiss: (id: string) => void;
27+
dismissAll: () => void;
28+
}
29+
30+
let nextId = 0;
31+
32+
export const useToastStore = create<ToastStore>((set) => ({
33+
toasts: [],
34+
show: (title, options = {}) => {
35+
const id = `toast-${(nextId += 1)}`;
36+
const toast: ToastData = {
37+
id,
38+
title,
39+
description: options.description,
40+
variant: options.variant ?? 'info',
41+
duration:
42+
options.duration === undefined ? DEFAULT_DURATION : options.duration,
43+
};
44+
// Drop the oldest once the stack is full.
45+
set((s) => ({ toasts: [...s.toasts, toast].slice(-MAX_VISIBLE) }));
46+
return id;
47+
},
48+
dismiss: (id) =>
49+
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
50+
dismissAll: () => set({ toasts: [] }),
51+
}));

apps/polycentric/src/features/composer/hooks/useComposer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toast } from '@/src/common/components/toast';
12
import { useLinkPreviews } from '@/src/common/link-previews';
23
import { processAndUploadImage } from '@/src/common/lib/images/processAndUploadImage';
34
import {
@@ -346,6 +347,7 @@ export function useComposer({
346347
await client.commitEvent(signedEvent, content);
347348

348349
setSubmitting(false);
350+
toast.success(isReply ? 'Reply posted' : 'Post published');
349351
onClose();
350352
resetAll();
351353

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Text } from '@/src/common/components';
2+
import Icon from '@/src/common/components/Icon';
3+
import { useToast } from '@/src/common/components/toast';
4+
import { Atoms, useTheme } from '@/src/common/theme';
5+
import * as Clipboard from 'expo-clipboard';
6+
import { useEffect, useRef, useState } from 'react';
7+
import { Pressable } from 'react-native';
8+
9+
export function CopyLinkComponent({ link }: { link: string }) {
10+
const { theme } = useTheme();
11+
const toast = useToast();
12+
const [copied, setCopied] = useState(false);
13+
const timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
14+
15+
useEffect(() => () => clearTimeout(timeout.current), []);
16+
17+
const onCopy = () => {
18+
void Clipboard.setStringAsync(link);
19+
setCopied(true);
20+
toast.success('Copied to clipboard');
21+
clearTimeout(timeout.current);
22+
timeout.current = setTimeout(() => setCopied(false), 2000);
23+
};
24+
25+
return (
26+
<Pressable
27+
onPress={onCopy}
28+
style={({ hovered }) => [
29+
Atoms.flex_row,
30+
Atoms.items_center,
31+
Atoms.gap_sm,
32+
Atoms.p_md,
33+
Atoms.rounded_md,
34+
{
35+
backgroundColor: hovered
36+
? theme.palette.neutral_200
37+
: theme.palette.neutral_100,
38+
},
39+
]}
40+
>
41+
<Text
42+
variant="body"
43+
style={[Atoms.flex_1, theme.atoms.text, { fontFamily: 'monospace' }]}
44+
>
45+
{link}
46+
</Text>
47+
<Icon
48+
name={copied ? 'checkmark' : 'copy'}
49+
size={18}
50+
color={copied ? 'positive_500' : 'neutral_500'}
51+
/>
52+
</Pressable>
53+
);
54+
}

0 commit comments

Comments
 (0)