Skip to content
120 linesCodeBlameRaw
1import type { ChatSidebar, FeedEvent, FeedNotification, Presence } from "@g1t/contracts";
2import { useQueryClient } from "@tanstack/react-query";
3import * as Haptics from "expo-haptics";
4import * as Notifications from "expo-notifications";
5import { usePathname } from "expo-router";
6import { type ReactNode, createContext, useContext, useEffect, useRef, useState } from "react";
7import { AppState } from "react-native";
8
9import { chatKeys } from "./chat";
10import { openSocket } from "./live";
11import { useSignedIn } from "./session";
12import { viewing } from "./viewing";
13
14/**
15 * The person's feed, while the app is in front: the same socket every tab
16 * of the site keeps open (`/-/live?workspace=<slug>`, services/notify).
17 * It brings unread counts as they move, notifications as they happen (a
18 * banner unless the conversation is open), and who is online. The app
19 * says it is focused, so g1t pushes nothing to the phone while it is in
20 * use; in the background the socket closes and pushes take over.
21 */
22
23type FeedState = {
24 /** Unread in Chat, leaving out muted conversations; mentions apart. */
25 unread: number;
26 mentions: number;
27 /** A notification to show as a banner, until it is dismissed. */
28 banner: FeedNotification | null;
29 dismiss: () => void;
30 presence: Map<string, Presence>;
31};
32
33const FeedContext = createContext<FeedState>({ unread: 0, mentions: 0, banner: null, dismiss: () => {}, presence: new Map() });
34
35export function FeedProvider({ children }: { children: ReactNode }) {
36 const { client, workspace } = useSignedIn();
37 const queries = useQueryClient();
38 const slug = workspace?.slug ?? "";
39 const path = usePathname();
40 const [active, setActive] = useState(AppState.currentState === "active");
41 const [counts, setCounts] = useState({ unread: 0, mentions: 0 });
42 const [banner, setBanner] = useState<FeedNotification | null>(null);
43 const [presence, setPresence] = useState<Map<string, Presence>>(new Map());
44 const sendRef = useRef<((frame: unknown) => void) | null>(null);
45 const pathRef = useRef(path);
46 pathRef.current = path;
47
48 useEffect(() => {
49 const subscription = AppState.addEventListener("change", (state) => setActive(state === "active"));
50 return () => subscription.remove();
51 }, []);
52
53 useEffect(() => {
54 if (!active || !slug) return;
55 const socket = openSocket(client, "/-/live", { workspace: slug }, {
56 onOpen: (send) => {
57 sendRef.current = send;
58 send({ type: "state", focused: true, path: pathRef.current });
59 },
60 onReconnect: () => void queries.invalidateQueries({ queryKey: chatKeys.all(slug) }),
61 onMessage: (data) => {
62 const event = data as FeedEvent;
63 if (event.type === "counts" && event.workspace === slug) {
64 setCounts({ unread: event.chat_unread, mentions: event.chat_mentions });
65 void Notifications.setBadgeCountAsync(event.chat_unread + event.chat_mentions).catch(() => undefined);
66 const per = new Map(event.per_channel.map((row) => [row.channel_id, row]));
67 queries.setQueryData<ChatSidebar>(chatKeys.sidebar(slug), (sidebar) => {
68 if (!sidebar) return sidebar;
69 const known = new Set(sidebar.entries.map((entry) => entry.channel.id));
70 // A conversation the app has not seen yet: read the sidebar again.
71 if ([...per.keys()].some((id) => !known.has(id))) void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
72 return {
73 ...sidebar,
74 entries: sidebar.entries.map((entry) => {
75 const row = per.get(entry.channel.id);
76 if (row) return { ...entry, unread: row.unread, mentions: row.mentions };
77 return event.complete ? { ...entry, unread: 0, mentions: 0 } : entry;
78 }),
79 };
80 });
81 } else if (event.type === "notification" && event.toast) {
82 const note = event.notification;
83 if (note.workspace !== slug || (note.channel_id && note.channel_id === viewing.channelId)) return;
84 setBanner(note);
85 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined);
86 } else if (event.type === "presence" && event.workspace === slug) {
87 setPresence((before) => {
88 const next = event.full ? new Map<string, Presence>() : new Map(before);
89 for (const person of event.people) next.set(person.user_id, person.presence);
90 return next;
91 });
92 }
93 },
94 });
95 return () => {
96 sendRef.current = null;
97 socket.close();
98 };
99 }, [active, client, queries, slug]);
100
101 // Where the app is, as a tab says where it is.
102 useEffect(() => {
103 sendRef.current?.({ type: "state", focused: true, path });
104 }, [path]);
105
106 // A banner goes by itself.
107 useEffect(() => {
108 if (!banner) return;
109 const timer = setTimeout(() => setBanner(null), 5000);
110 return () => clearTimeout(timer);
111 }, [banner]);
112
113 return (
114 <FeedContext.Provider value={{ ...counts, banner, dismiss: () => setBanner(null), presence }}>{children}</FeedContext.Provider>
115 );
116}
117
118export function useFeed(): FeedState {
119 return useContext(FeedContext);
120}