Skip to content

Commit

A conversation keeps its newest message in view when the keyboard opens, on the site and in the phone app, and the phone app's pushes are ready for a development build.

On the site, a conversation and a thread stayed where they were, measured from the top, when they got shorter, so opening a phone's keyboard slid the newest messages out of sight under the composer (and a card or an image laying out after it arrived did the same). Both now hold to the bottom whenever the list or what is in it changes size, while the reader is at the bottom (components/chat/stick.ts); someone scrolled up into the history is left where they are. The thread panel had no hold at all. In the phone app, the keyboard pushes the composer up on Android too: apps draw edge to edge there, so the window no longer resizes for it, and the box and the newest messages ended under the keyboard. The list no longer pins its position as messages arrive, which could leave a new one out of view; tapping into the box or sending goes to the newest message; the bar steps aside inside a conversation or a thread, as on the site, and the box drops the home indicator's room while the keyboard is up. Pushes: the app tells g1t it is in the background before its feed socket closes, since a phone can freeze the socket instead, and g1t pushes nothing for up to 70 seconds while a socket says it is in front. More has Send a test notification, to see a build gets them, and the app registers again when the system gives it a new push token. expo-dev-client is in, for the development profile's build; npm run build:android:dev makes it, npx expo start serves it, and start:go is Expo Go.

Claude-Session:
https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
Claude and Claude Opus 5.5committed Parent755fb0cBrowse files
11 files+162−230/11 viewed
+11−7
107107 ```sh
108108 npm install # at the repository's root: one workspace
109109 cd apps/mobile
110−npx expo start # then scan the QR code with Expo Go
110+npx expo start --go # then scan the QR code with Expo Go
111111 ```
112112
113113 Install [Expo Go](https://expo.dev/go) on the phone, or press `a` for an
138138 lacks later (push notifications):
139139
140140 ```sh
141−npx expo run:android # builds g1t (dev) and starts Metro
141+npm run build:android:dev # EAS builds g1t (dev) in the cloud: install it from the link
142+npx expo start # then open g1t (dev) on the phone; it finds this computer
142143 ```
143144
144−`expo run:android` makes the native project (`android/`, ignored by git)
145−and installs `g1t (dev)`, which signs in through `g1t://oauth/callback`.
146−After the first build, `npx expo start` and the installed app are enough
147−until a native module changes. `eas build --profile development` makes
148−the same build in the cloud.
145+The development build is `g1t (dev)` (`sh.g1t.app.dev`), with
146+`expo-dev-client`: it loads the app's code from `npx expo start` on your
147+computer, signs in through `g1t://oauth/callback`, and gets notifications
148+once Firebase is set up ([below](#push-notifications-on-android)). Build
149+it again only when a native module or `app.config.ts` changes; code
150+changes reload live. With Android Studio, `APP_VARIANT=development npx expo
151+run:android` builds the same app on your computer instead (it makes
152+`android/`, ignored by git).
149153
150154 Checks, which CI runs from the root (`npm run typecheck`, `npm test`):
151155
+3−0
66 "main": "expo-router/entry",
77 "scripts": {
88 "start": "expo start",
9+ "start:go": "expo start --go",
910 "android": "expo run:android",
1011 "ios": "expo run:ios",
1112 "prebuild": "expo prebuild --clean",
1314 "test": "node --test \"src/lib/*.test.ts\"",
1415 "doctor": "expo-doctor",
1516 "icons": "node scripts/icons.mjs",
17+ "build:android:dev": "eas build --platform android --profile development",
1618 "build:android": "eas build --platform android --profile production",
1719 "build:android:apk": "eas build --platform android --profile preview",
1820 "build:ios": "eas build --platform ios --profile production"
2527 "expo-clipboard": "~57.0.2",
2628 "expo-constants": "~57.0.21",
2729 "expo-crypto": "~57.0.3",
30+ "expo-dev-client": "~57.0.19",
2831 "expo-device": "~57.0.2",
2932 "expo-font": "~57.0.4",
3033 "expo-haptics": "~57.0.3",
+13−2
11 import * as Notifications from "expo-notifications";
2−import { Tabs, router, useGlobalSearchParams } from "expo-router";
2+import { Tabs, router, useGlobalSearchParams, useSegments } from "expo-router";
33 import { Bell, Bot, Code2, House, Menu, MessagesSquare } from "lucide-react-native";
44 import type { LucideIcon } from "lucide-react-native";
55 import { type ComponentType, useEffect, useRef } from "react";
3939 if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked);
4040 }, [linked, workspace?.slug, selectWorkspace]);
4141 const code = !workspace || hasCodeAccess(workspace);
42+ // A conversation or a thread takes the whole screen, as on the site: the bar steps aside.
43+ const segments = useSegments() as string[];
44+ const inConversation = segments[1] === "chat" && (segments[2] === "[channel]" || segments[2] === "thread");
4245 const badge = mentions > 0 ? mentions : unread > 0 ? unread : undefined;
4346
4447 return (
5053 // app you are in marked as the rail marks it (apps/web components/mobile.tsx).
5154 tabBarActiveTintColor: colors.fg,
5255 tabBarInactiveTintColor: colors.faint,
53− tabBarStyle: { backgroundColor: colors.rail, borderTopColor: colors.line, borderTopWidth: StyleSheet.hairlineWidth, elevation: 0 },
56+ tabBarStyle: inConversation
57+ ? { display: "none" }
58+ : { backgroundColor: colors.rail, borderTopColor: colors.line, borderTopWidth: StyleSheet.hairlineWidth, elevation: 0 },
59+ tabBarHideOnKeyboard: true,
5460 tabBarLabelStyle: { fontSize: 11, fontWeight: "500" },
5561 tabBarIconStyle: { width: 44, height: 28 },
5662 }}
97103
98104 useEffect(() => {
99105 void registerForPush(client, true).catch((error: unknown) => console.warn("g1t: registering for notifications failed", error));
106+ // The system can give the app a new push token: register that one too.
107+ const changed = Notifications.addPushTokenListener(() => {
108+ void registerForPush(client, false).catch(() => undefined);
109+ });
110+ return () => changed.remove();
100111 }, [client]);
101112
102113 useEffect(() => {
+23−3
1−import { useQuery, useQueryClient } from "@tanstack/react-query";
1+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22 import Constants from "expo-constants";
33 import { Stack, router } from "expo-router";
44 import * as WebBrowser from "expo-web-browser";
5−import { Bell, Blocks, Check, ExternalLink, LogOut } from "lucide-react-native";
5+import { Bell, BellRing, Blocks, Check, ExternalLink, LogOut } from "lucide-react-native";
66 import { Linking, View } from "react-native";
77
88 import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui";
9−import { pushState, registerForPush } from "@/lib/push";
9+import { pushState, registerForPush, sendTestPush } from "@/lib/push";
1010 import { isHosted, siteUrl } from "@/lib/server";
1111 import { useSignedIn } from "@/lib/session";
1212 import { space, useColors } from "@/theme";
1919 const name = me.display_username ?? me.username;
2020 const oauth = client.credentials.kind === "oauth";
2121 const push = useQuery({ queryKey: ["push-state"], queryFn: pushState });
22+ const test = useMutation({ mutationFn: () => sendTestPush(client) });
2223
2324 const pushRow = {
2425 unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." },
5657 : undefined
5758 }
5859 />
60+ {push.data === "granted" ? (
61+ <Row
62+ leading={<BellRing size={22} color={colors.accent} />}
63+ title="Send a test notification"
64+ subtitle={
65+ test.isPending
66+ ? "Sending…"
67+ : test.error
68+ ? test.error.message
69+ : test.data !== undefined
70+ ? test.data > 0
71+ ? `Sent to ${test.data} ${test.data === 1 ? "place" : "places"}: this phone, and any browser with notifications on.`
72+ : "Nothing was sent: g1t has no phone or browser for you. Sign out and in again to register this phone."
73+ : "To check this phone gets them. It arrives in a few seconds, even with the app open; if it never does, see the setup guide's troubleshooting."
74+ }
75+ chevron={false}
76+ onPress={test.isPending ? undefined : () => test.mutate()}
77+ />
78+ ) : null}
5979 </View>
6080
6181 <View>
+28−4
11 import * as Haptics from "expo-haptics";
22 import { ArrowUp } from "lucide-react-native";
3−import { useState } from "react";
4−import { Pressable, StyleSheet, TextInput, View } from "react-native";
3+import { useEffect, useState } from "react";
4+import { Keyboard, Pressable, StyleSheet, TextInput, View } from "react-native";
55 import { useSafeAreaInsets } from "react-native-safe-area-context";
66
77 import { radius, space, useColors } from "@/theme";
1313 * The box at the bottom of a conversation. Enter makes a new line, as on
1414 * a phone; the arrow sends. Typing tells the others (at most every 3 s).
1515 */
16−export function Composer({ draftKey, placeholder, onSend, onTyping }: { draftKey: string; placeholder: string; onSend: (body: string) => void; onTyping: () => void }) {
16+export function Composer({
17+ draftKey,
18+ placeholder,
19+ onSend,
20+ onTyping,
21+ onFocus,
22+}: {
23+ draftKey: string;
24+ placeholder: string;
25+ onSend: (body: string) => void;
26+ onTyping: () => void;
27+ /** The box was tapped into: the conversation goes to its newest message. */
28+ onFocus?: () => void;
29+}) {
1730 const colors = useColors();
1831 const insets = useSafeAreaInsets();
1932 const [text, setText] = useState(() => drafts.get(draftKey) ?? "");
2033 const ready = text.trim().length > 0;
34+ // With the keyboard up the box sits on it; the home indicator's room is only for when it is down.
35+ const [keyboard, setKeyboard] = useState(false);
36+ useEffect(() => {
37+ const shown = Keyboard.addListener("keyboardDidShow", () => setKeyboard(true));
38+ const hidden = Keyboard.addListener("keyboardDidHide", () => setKeyboard(false));
39+ return () => {
40+ shown.remove();
41+ hidden.remove();
42+ };
43+ }, []);
2144
2245 const send = () => {
2346 const body = text.trim();
2952 };
3053
3154 return (
32− <View style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, space.sm) }]}>
55+ <View style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: keyboard ? space.sm : Math.max(insets.bottom, space.sm) }]}>
3356 <TextInput
3457 value={text}
3558 onChangeText={(next) => {
3962 }}
4063 placeholder={placeholder}
4164 placeholderTextColor={colors.faint}
65+ onFocus={onFocus}
4266 multiline
4367 style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
4468 />
+17−4
22 import { useQueryClient } from "@tanstack/react-query";
33 import * as Clipboard from "expo-clipboard";
44 import { router, useFocusEffect } from "expo-router";
5+import { useHeaderHeight } from "expo-router/react-navigation";
56 import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native";
67 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7−import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, View } from "react-native";
8+import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Pressable, StyleSheet, View } from "react-native";
89
910 import { Composer } from "@/components/chat/composer";
1011 import { Message, runsOn } from "@/components/chat/message";
3839 const [scrolledUp, setScrolledUp] = useState(false);
3940 const list = useRef<FlatList<Row>>(null);
4041 const focused = useRef(false);
42+ const headerHeight = useHeaderHeight();
4143
4244 // While on screen, this conversation's banners and pushes are not shown.
4345 useFocusEffect(
103105 const typingLine = typingText(live.typing.map((member) => member.display_name));
104106
105107 return (
106− <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={Platform.OS === "ios" ? 96 : 0}>
108+ // The keyboard pushes the composer up and the list shrinks from the top, on
109+ // Android too: apps draw edge to edge there, so the window no longer resizes
110+ // for it. The list is drawn from the bottom, so the newest stays in view.
111+ <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
107112 <View style={{ flex: 1 }}>
108113 {messages.isPending ? (
109114 <Loading />
152157 }
153158 keyboardDismissMode="interactive"
154159 keyboardShouldPersistTaps="handled"
155− maintainVisibleContentPosition={{ minIndexForVisible: 1 }}
156160 />
157161 )}
158162 {scrolledUp ? (
175179 </Text>
176180 ) : null}
177181 </View>
178− <Composer draftKey={`${channelId}:${thread ?? ""}`} placeholder={thread ? "Reply in thread" : `Message ${title}`} onSend={(body) => send(body)} onTyping={live.sayTyping} />
182+ <Composer
183+ draftKey={`${channelId}:${thread ?? ""}`}
184+ placeholder={thread ? "Reply in thread" : `Message ${title}`}
185+ onSend={(body) => {
186+ send(body);
187+ list.current?.scrollToOffset({ offset: 0, animated: true });
188+ }}
189+ onTyping={live.sayTyping}
190+ onFocus={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
191+ />
179192
180193 <Sheet visible={!!picked} onClose={() => setPicked(null)}>
181194 <View style={styles.quick}>
+7−1
4646 pathRef.current = path;
4747
4848 useEffect(() => {
49− const subscription = AppState.addEventListener("change", (state) => setActive(state === "active"));
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+ });
5056 return () => subscription.remove();
5157 }, []);
5258
+10−0
101101 await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => undefined);
102102 await Notifications.setBadgeCountAsync(0).catch(() => undefined);
103103 }
104+
105+/**
106+ * Sends a test notification to this person's phones and browsers
107+ * (`POST /-/notify` `test`), whatever is in front of them: the way to see
108+ * that a build gets pushes. Answers how many it reached.
109+ */
110+export async function sendTestPush(client: Client): Promise<number> {
111+ const answer = await client.siteRaw<{ ok: boolean; pushed: number }>("/-/notify", { method: "POST", body: JSON.stringify({ intent: "test" }) });
112+ return answer.pushed ?? 0;
113+}
+18−2
6868 import { type Mentionable, type ShownMessage, agentsAmong, channelPath, dmPlaceholder, isMixed, mentionNames, mergeMessages, mixedNotice, shownHandle, shownName, timeline } from "../../lib/chat";
6969 import { codeAccessPath } from "../../lib/workspace-nav";
7070 import { conversationCache } from "./conversation-cache";
71+import { atBottom, useKeepAtBottom } from "./stick";
7172 // Reactions and the workspace's own emoji (components/emoji).
7273 import { EmojiProvider, useCustomEmoji } from "../emoji/context";
7374 import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar";
243244 useEffect(() => {
244245 if (stick.current) toBottom(true);
245246 }, [messages.length, typing.size, toBottom]);
247+ // And while the list or what is in it changes size: the keyboard opening on a phone, a card laying out.
248+ useKeepAtBottom(scroller, stick);
246249
247250 // Read up to the newest message whenever it is in view.
248251 const lastRead = useRef<string | null>(null);
648651 ref={scroller}
649652 onScroll={(event) => {
650653 const element = event.currentTarget;
651− stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120;
654+ stick.current = atBottom(element);
652655 }}
653656 className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
654657 >
15791582 draftKey: string;
15801583 }) {
15811584 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
1585+ // The newest reply stays in view as replies arrive and as the keyboard opens, unless the reader scrolled up.
1586+ const scroller = useRef<HTMLDivElement>(null);
1587+ const stick = useRef(true);
1588+ useKeepAtBottom(scroller, stick);
15821589 return (
15831590 <>
1584− <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]">
1591+ <div
1592+ ref={scroller}
1593+ onScroll={(event) => {
1594+ stick.current = atBottom(event.currentTarget);
1595+ }}
1596+ className="min-h-0 grow overflow-y-auto overscroll-contain px-1 py-3 [scrollbar-width:thin]"
1597+ >
1598+ {/* One box for what scrolls, so its growing is seen (./stick.ts). */}
1599+ <div>
15851600 {root ? (
15861601 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
15871602 ) : (
16061621 />
16071622 ) : null,
16081623 )}
1624+ </div>
16091625 </div>
16101626 {joined && (
16111627 <div className="shrink-0 p-3">
+32−0
1+import { type RefObject, useEffect, useRef } from "react";
2+
3+/**
4+ * Keeps a conversation's newest message in view while the reader is at
5+ * the bottom: when the list itself changes size (the keyboard opening on
6+ * a phone shrinks it, a window resized) and when what is in it does (a
7+ * card, an image or a code block laying out after it arrived). Without
8+ * this, a list that gets shorter keeps its distance from the top, and the
9+ * newest messages slide out of sight under the composer.
10+ *
11+ * `stick` says whether the reader is at the bottom (within 120px), as the
12+ * list's `onScroll` keeps it (`atBottom`); a reader scrolled up into the
13+ * history is left where they are.
14+ */
15+export function useKeepAtBottom(scroller: RefObject<HTMLElement | null>, stick: RefObject<boolean>) {
16+ useEffect(() => {
17+ const element = scroller.current;
18+ if (!element || typeof ResizeObserver === "undefined") return;
19+ const keep = () => {
20+ if (stick.current) element.scrollTop = element.scrollHeight;
21+ };
22+ const observer = new ResizeObserver(keep);
23+ observer.observe(element);
24+ for (const child of Array.from(element.children)) observer.observe(child);
25+ return () => observer.disconnect();
26+ }, [scroller, stick]);
27+}
28+
29+/** Whether a list is scrolled to (within 120px of) its bottom. */
30+export function atBottom(element: HTMLElement): boolean {
31+ return element.scrollHeight - element.scrollTop - element.clientHeight < 120;
32+}
+0−0

Binary or large file; its contents are not shown.