Skip to content
126 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) => {
50 // Say so before the socket closes: a phone can freeze it rather than close
51 // it, and g1t pushes nothing while a socket says it is in front (for up to
52 // 70 s after it last spoke).
53 if (state !== "active") sendRef.current?.({ type: "state", focused: false, path: pathRef.current });
54 setActive(state === "active");
55 });
56 return () => subscription.remove();
57 }, []);
58
59 useEffect(() => {
60 if (!active || !slug) return;
61 const socket = openSocket(client, "/-/live", { workspace: slug }, {
62 onOpen: (send) => {
63 sendRef.current = send;
64 send({ type: "state", focused: true, path: pathRef.current });
65 },
66 onReconnect: () => void queries.invalidateQueries({ queryKey: chatKeys.all(slug) }),
67 onMessage: (data) => {
68 const event = data as FeedEvent;
69 if (event.type === "counts" && event.workspace === slug) {
70 setCounts({ unread: event.chat_unread, mentions: event.chat_mentions });
71 void Notifications.setBadgeCountAsync(event.chat_unread + event.chat_mentions).catch(() => undefined);
72 const per = new Map(event.per_channel.map((row) => [row.channel_id, row]));
73 queries.setQueryData<ChatSidebar>(chatKeys.sidebar(slug), (sidebar) => {
74 if (!sidebar) return sidebar;
75 const known = new Set(sidebar.entries.map((entry) => entry.channel.id));
76 // A conversation the app has not seen yet: read the sidebar again.
77 if ([...per.keys()].some((id) => !known.has(id))) void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
78 return {
79 ...sidebar,
80 entries: sidebar.entries.map((entry) => {
81 const row = per.get(entry.channel.id);
82 if (row) return { ...entry, unread: row.unread, mentions: row.mentions };
83 return event.complete ? { ...entry, unread: 0, mentions: 0 } : entry;
84 }),
85 };
86 });
87 } else if (event.type === "notification" && event.toast) {
88 const note = event.notification;
89 if (note.workspace !== slug || (note.channel_id && note.channel_id === viewing.channelId)) return;
90 setBanner(note);
91 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined);
92 } else if (event.type === "presence" && event.workspace === slug) {
93 setPresence((before) => {
94 const next = event.full ? new Map<string, Presence>() : new Map(before);
95 for (const person of event.people) next.set(person.user_id, person.presence);
96 return next;
97 });
98 }
99 },
100 });
101 return () => {
102 sendRef.current = null;
103 socket.close();
104 };
105 }, [active, client, queries, slug]);
106
107 // Where the app is, as a tab says where it is.
108 useEffect(() => {
109 sendRef.current?.({ type: "state", focused: true, path });
110 }, [path]);
111
112 // A banner goes by itself.
113 useEffect(() => {
114 if (!banner) return;
115 const timer = setTimeout(() => setBanner(null), 5000);
116 return () => clearTimeout(timer);
117 }, [banner]);
118
119 return (
120 <FeedContext.Provider value={{ ...counts, banner, dismiss: () => setBanner(null), presence }}>{children}</FeedContext.Provider>
121 );
122}
123
124export function useFeed(): FeedState {
125 return useContext(FeedContext);
126}