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
| 107 | 107 | ```sh | |
| 108 | 108 | npm install # at the repository's root: one workspace | |
| 109 | 109 | 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 | |
| 111 | 111 | ``` | |
| 112 | 112 | ||
| 113 | 113 | Install [Expo Go](https://expo.dev/go) on the phone, or press `a` for an | |
| ⋯ | |||
| 138 | 138 | lacks later (push notifications): | |
| 139 | 139 | ||
| 140 | 140 | ```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 | |
| 142 | 143 | ``` | |
| 143 | 144 | ||
| 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). | |
| 149 | 153 | ||
| 150 | 154 | Checks, which CI runs from the root (`npm run typecheck`, `npm test`): | |
| 151 | 155 | ||
| 6 | 6 | "main": "expo-router/entry", | |
| 7 | 7 | "scripts": { | |
| 8 | 8 | "start": "expo start", | |
| 9 | + | "start:go": "expo start --go", | |
| 9 | 10 | "android": "expo run:android", | |
| 10 | 11 | "ios": "expo run:ios", | |
| 11 | 12 | "prebuild": "expo prebuild --clean", | |
| ⋯ | |||
| 13 | 14 | "test": "node --test \"src/lib/*.test.ts\"", | |
| 14 | 15 | "doctor": "expo-doctor", | |
| 15 | 16 | "icons": "node scripts/icons.mjs", | |
| 17 | + | "build:android:dev": "eas build --platform android --profile development", | |
| 16 | 18 | "build:android": "eas build --platform android --profile production", | |
| 17 | 19 | "build:android:apk": "eas build --platform android --profile preview", | |
| 18 | 20 | "build:ios": "eas build --platform ios --profile production" | |
| ⋯ | |||
| 25 | 27 | "expo-clipboard": "~57.0.2", | |
| 26 | 28 | "expo-constants": "~57.0.21", | |
| 27 | 29 | "expo-crypto": "~57.0.3", | |
| 30 | + | "expo-dev-client": "~57.0.19", | |
| 28 | 31 | "expo-device": "~57.0.2", | |
| 29 | 32 | "expo-font": "~57.0.4", | |
| 30 | 33 | "expo-haptics": "~57.0.3", | |
| 1 | 1 | import * as Notifications from "expo-notifications"; | |
| 2 | − | import { Tabs, router, useGlobalSearchParams } from "expo-router"; | |
| 2 | + | import { Tabs, router, useGlobalSearchParams, useSegments } from "expo-router"; | |
| 3 | 3 | import { Bell, Bot, Code2, House, Menu, MessagesSquare } from "lucide-react-native"; | |
| 4 | 4 | import type { LucideIcon } from "lucide-react-native"; | |
| 5 | 5 | import { type ComponentType, useEffect, useRef } from "react"; | |
| ⋯ | |||
| 39 | 39 | if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked); | |
| 40 | 40 | }, [linked, workspace?.slug, selectWorkspace]); | |
| 41 | 41 | 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"); | |
| 42 | 45 | const badge = mentions > 0 ? mentions : unread > 0 ? unread : undefined; | |
| 43 | 46 | ||
| 44 | 47 | return ( | |
| ⋯ | |||
| 50 | 53 | // app you are in marked as the rail marks it (apps/web components/mobile.tsx). | |
| 51 | 54 | tabBarActiveTintColor: colors.fg, | |
| 52 | 55 | 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, | |
| 54 | 60 | tabBarLabelStyle: { fontSize: 11, fontWeight: "500" }, | |
| 55 | 61 | tabBarIconStyle: { width: 44, height: 28 }, | |
| 56 | 62 | }} | |
| ⋯ | |||
| 97 | 103 | ||
| 98 | 104 | useEffect(() => { | |
| 99 | 105 | 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(); | |
| 100 | 111 | }, [client]); | |
| 101 | 112 | ||
| 102 | 113 | useEffect(() => { | |
| 1 | − | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 1 | + | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | 2 | import Constants from "expo-constants"; | |
| 3 | 3 | import { Stack, router } from "expo-router"; | |
| 4 | 4 | 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"; | |
| 6 | 6 | import { Linking, View } from "react-native"; | |
| 7 | 7 | ||
| 8 | 8 | 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"; | |
| 10 | 10 | import { isHosted, siteUrl } from "@/lib/server"; | |
| 11 | 11 | import { useSignedIn } from "@/lib/session"; | |
| 12 | 12 | import { space, useColors } from "@/theme"; | |
| ⋯ | |||
| 19 | 19 | const name = me.display_username ?? me.username; | |
| 20 | 20 | const oauth = client.credentials.kind === "oauth"; | |
| 21 | 21 | const push = useQuery({ queryKey: ["push-state"], queryFn: pushState }); | |
| 22 | + | const test = useMutation({ mutationFn: () => sendTestPush(client) }); | |
| 22 | 23 | ||
| 23 | 24 | const pushRow = { | |
| 24 | 25 | unsupported: { label: "Not here", detail: "Notifications need a build of the app on a phone: not Expo Go on Android, nor a simulator." }, | |
| ⋯ | |||
| 56 | 57 | : undefined | |
| 57 | 58 | } | |
| 58 | 59 | /> | |
| 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} | |
| 59 | 79 | </View> | |
| 60 | 80 | ||
| 61 | 81 | <View> | |
| 1 | 1 | import * as Haptics from "expo-haptics"; | |
| 2 | 2 | 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"; | |
| 5 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 6 | 6 | ||
| 7 | 7 | import { radius, space, useColors } from "@/theme"; | |
| ⋯ | |||
| 13 | 13 | * The box at the bottom of a conversation. Enter makes a new line, as on | |
| 14 | 14 | * a phone; the arrow sends. Typing tells the others (at most every 3 s). | |
| 15 | 15 | */ | |
| 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 | + | }) { | |
| 17 | 30 | const colors = useColors(); | |
| 18 | 31 | const insets = useSafeAreaInsets(); | |
| 19 | 32 | const [text, setText] = useState(() => drafts.get(draftKey) ?? ""); | |
| 20 | 33 | 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 | + | }, []); | |
| 21 | 44 | ||
| 22 | 45 | const send = () => { | |
| 23 | 46 | const body = text.trim(); | |
| ⋯ | |||
| 29 | 52 | }; | |
| 30 | 53 | ||
| 31 | 54 | 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) }]}> | |
| 33 | 56 | <TextInput | |
| 34 | 57 | value={text} | |
| 35 | 58 | onChangeText={(next) => { | |
| ⋯ | |||
| 39 | 62 | }} | |
| 40 | 63 | placeholder={placeholder} | |
| 41 | 64 | placeholderTextColor={colors.faint} | |
| 65 | + | onFocus={onFocus} | |
| 42 | 66 | multiline | |
| 43 | 67 | style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]} | |
| 44 | 68 | /> | |
| 2 | 2 | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | 3 | import * as Clipboard from "expo-clipboard"; | |
| 4 | 4 | import { router, useFocusEffect } from "expo-router"; | |
| 5 | + | import { useHeaderHeight } from "expo-router/react-navigation"; | |
| 5 | 6 | import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native"; | |
| 6 | 7 | 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"; | |
| 8 | 9 | ||
| 9 | 10 | import { Composer } from "@/components/chat/composer"; | |
| 10 | 11 | import { Message, runsOn } from "@/components/chat/message"; | |
| ⋯ | |||
| 38 | 39 | const [scrolledUp, setScrolledUp] = useState(false); | |
| 39 | 40 | const list = useRef<FlatList<Row>>(null); | |
| 40 | 41 | const focused = useRef(false); | |
| 42 | + | const headerHeight = useHeaderHeight(); | |
| 41 | 43 | ||
| 42 | 44 | // While on screen, this conversation's banners and pushes are not shown. | |
| 43 | 45 | useFocusEffect( | |
| ⋯ | |||
| 103 | 105 | const typingLine = typingText(live.typing.map((member) => member.display_name)); | |
| 104 | 106 | ||
| 105 | 107 | 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}> | |
| 107 | 112 | <View style={{ flex: 1 }}> | |
| 108 | 113 | {messages.isPending ? ( | |
| 109 | 114 | <Loading /> | |
| ⋯ | |||
| 152 | 157 | } | |
| 153 | 158 | keyboardDismissMode="interactive" | |
| 154 | 159 | keyboardShouldPersistTaps="handled" | |
| 155 | − | maintainVisibleContentPosition={{ minIndexForVisible: 1 }} | |
| 156 | 160 | /> | |
| 157 | 161 | )} | |
| 158 | 162 | {scrolledUp ? ( | |
| ⋯ | |||
| 175 | 179 | </Text> | |
| 176 | 180 | ) : null} | |
| 177 | 181 | </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 | + | /> | |
| 179 | 192 | ||
| 180 | 193 | <Sheet visible={!!picked} onClose={() => setPicked(null)}> | |
| 181 | 194 | <View style={styles.quick}> | |
| 46 | 46 | pathRef.current = path; | |
| 47 | 47 | ||
| 48 | 48 | 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 | + | }); | |
| 50 | 56 | return () => subscription.remove(); | |
| 51 | 57 | }, []); | |
| 52 | 58 |
| 101 | 101 | await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => undefined); | |
| 102 | 102 | await Notifications.setBadgeCountAsync(0).catch(() => undefined); | |
| 103 | 103 | } | |
| 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 | + | } |
| 68 | 68 | import { type Mentionable, type ShownMessage, agentsAmong, channelPath, dmPlaceholder, isMixed, mentionNames, mergeMessages, mixedNotice, shownHandle, shownName, timeline } from "../../lib/chat"; | |
| 69 | 69 | import { codeAccessPath } from "../../lib/workspace-nav"; | |
| 70 | 70 | import { conversationCache } from "./conversation-cache"; | |
| 71 | + | import { atBottom, useKeepAtBottom } from "./stick"; | |
| 71 | 72 | // Reactions and the workspace's own emoji (components/emoji). | |
| 72 | 73 | import { EmojiProvider, useCustomEmoji } from "../emoji/context"; | |
| 73 | 74 | import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar"; | |
| ⋯ | |||
| 243 | 244 | useEffect(() => { | |
| 244 | 245 | if (stick.current) toBottom(true); | |
| 245 | 246 | }, [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); | |
| 246 | 249 | ||
| 247 | 250 | // Read up to the newest message whenever it is in view. | |
| 248 | 251 | const lastRead = useRef<string | null>(null); | |
| ⋯ | |||
| 648 | 651 | ref={scroller} | |
| 649 | 652 | onScroll={(event) => { | |
| 650 | 653 | const element = event.currentTarget; | |
| 651 | − | stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120; | |
| 654 | + | stick.current = atBottom(element); | |
| 652 | 655 | }} | |
| 653 | 656 | className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]" | |
| 654 | 657 | > | |
| ⋯ | |||
| 1579 | 1582 | draftKey: string; | |
| 1580 | 1583 | }) { | |
| 1581 | 1584 | 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); | |
| 1582 | 1589 | return ( | |
| 1583 | 1590 | <> | |
| 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> | |
| 1585 | 1600 | {root ? ( | |
| 1586 | 1601 | <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread /> | |
| 1587 | 1602 | ) : ( | |
| ⋯ | |||
| 1606 | 1621 | /> | |
| 1607 | 1622 | ) : null, | |
| 1608 | 1623 | )} | |
| 1624 | + | </div> | |
| 1609 | 1625 | </div> | |
| 1610 | 1626 | {joined && ( | |
| 1611 | 1627 | <div className="shrink-0 p-3"> | |
| 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 | + | } |
Binary or large file; its contents are not shown.