Skip to content
81 linesCodeBlameRaw
1import { router } from "expo-router";
2import { X } from "lucide-react-native";
3import { useEffect, useRef } from "react";
4import { Animated, Pressable, StyleSheet, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7import { Avatar, Text } from "@/components/ui";
8import { useFeed } from "@/lib/feed";
9import { routeFor } from "@/lib/links";
10import { useSignedIn } from "@/lib/session";
11import { radius, space, useColors } from "@/theme";
12
13/**
14 * A notification while the app is open, as the site's toast: who, what,
15 * and a tap to go there. Not shown for the conversation on screen.
16 */
17export function Banner() {
18 const { banner, dismiss } = useFeed();
19 const { server } = useSignedIn();
20 const colors = useColors();
21 const insets = useSafeAreaInsets();
22 const offset = useRef(new Animated.Value(-160)).current;
23
24 useEffect(() => {
25 Animated.spring(offset, { toValue: banner ? 0 : -160, useNativeDriver: true, speed: 18, bounciness: 6 }).start();
26 }, [banner, offset]);
27
28 if (!banner) return null;
29 const go = () => {
30 dismiss();
31 if (banner.channel_id && banner.thread_root) router.push({ pathname: "/chat/thread", params: { channel: banner.channel_id, root: banner.thread_root } });
32 else if (banner.channel_id) router.push({ pathname: "/chat/[channel]", params: { channel: banner.channel_id } });
33 else {
34 const route = routeFor(banner.href);
35 router.push((route ?? `/open?path=${encodeURIComponent(banner.href)}`) as never);
36 }
37 };
38
39 return (
40 <Animated.View pointerEvents="box-none" style={[styles.wrap, { top: insets.top + space.sm, transform: [{ translateY: offset }] }]}>
41 <Pressable onPress={go} style={[styles.banner, { backgroundColor: colors.raised, borderColor: colors.lineStrong }]}>
42 <Avatar
43 name={banner.actor.name}
44 uri={banner.actor.avatar ? `${server.site}/avatars/${banner.actor.avatar}` : null}
45 agent={banner.actor.kind === "agent"}
46 size={36}
47 />
48 <View style={{ flex: 1 }}>
49 <Text style={{ fontWeight: "700" }} numberOfLines={1}>
50 {banner.title}
51 </Text>
52 {banner.body ? (
53 <Text size="small" tone="muted" numberOfLines={2}>
54 {banner.body}
55 </Text>
56 ) : null}
57 </View>
58 <Pressable accessibilityLabel="Dismiss" hitSlop={10} onPress={dismiss}>
59 <X size={18} color={colors.faint} />
60 </Pressable>
61 </Pressable>
62 </Animated.View>
63 );
64}
65
66const styles = StyleSheet.create({
67 wrap: { position: "absolute", left: space.md, right: space.md },
68 banner: {
69 flexDirection: "row",
70 alignItems: "center",
71 gap: space.md,
72 padding: space.md,
73 borderRadius: radius.lg,
74 borderWidth: StyleSheet.hairlineWidth,
75 shadowColor: "#000",
76 shadowOpacity: 0.3,
77 shadowRadius: 12,
78 shadowOffset: { width: 0, height: 4 },
79 elevation: 8,
80 },
81});