Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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