Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type BottomSheet from "@gorhom/bottom-sheet"
import type { Session, Project } from "../../src/lib/sdk"
import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat"
import { groupByDirectory } from "../../src/lib/session-grouping"
import { UpdateBanner } from "../../src/components/UpdateBanner"
import { nameOf } from "../../src/lib/path-utils"
import { SETUP_GUIDE_URL } from "../../src/lib/links"

Expand Down Expand Up @@ -548,6 +549,8 @@ export default function SessionsScreen() {
</View>
)}

<UpdateBanner isDark={isDark} />

<FlatList
data={rows}
keyExtractor={(row) => (row.type === "header" ? `dir:${row.directory}` : row.session.id)}
Expand Down
40 changes: 38 additions & 2 deletions app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import {
View,
Text,
Expand All @@ -23,6 +23,7 @@ import {
import type { Category } from "../../src/lib/notifications"
import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry"
import { PRIVACY_POLICY_URL } from "../../src/lib/links"
import { CURRENT_VERSION, checkForUpdate, type AvailableUpdate } from "../../src/lib/update-check"
import type { LocalePreference } from "../../src/lib/i18n/locale-resolve"

function SettingRow({
Expand Down Expand Up @@ -79,6 +80,23 @@ export default function SettingsScreen() {
const [osGranted, setOsGranted] = useState<boolean | null>(null)
const [telemetryUpdating, setTelemetryUpdating] = useState(false)

// Settings is where a user goes to ask "what am I running?". Answer it, and if
// a newer build exists say so here too — the banner on the sessions list is
// dismissible, this row is not (AGE-110). Uses the same 24h-throttled check,
// so opening Settings repeatedly costs no extra requests.
const [updateAvailable, setUpdateAvailable] = useState<AvailableUpdate | null>(null)
useEffect(() => {
let cancelled = false
checkForUpdate({ ignoreDismissed: true })
.then((result) => {
if (!cancelled) setUpdateAvailable(result)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [])

// Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false.
// We initialise local state from in-memory value; updates call setTelemetryConsent().
const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false)
Expand Down Expand Up @@ -247,7 +265,25 @@ export default function SettingsScreen() {
onPress={handleLanguagePress}
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
<SettingRow icon="information-circle" label={t("settings.about.version")} description="1.0.0" isDark={isDark} />
<SettingRow
icon="information-circle"
label={t("settings.about.version")}
// Was hard-coded "1.0.0" — wrong for every build ever shipped, and the
// one place a user could have checked what they are running while 64%
// of the base sat on a four-week-old build (AGE-110).
description={
updateAvailable
? `${CURRENT_VERSION} → ${updateAvailable.version}`
: `${CURRENT_VERSION} · ${t("update.upToDate")}`
}
isDark={isDark}
onPress={updateAvailable ? () => Linking.openURL(updateAvailable.url) : undefined}
right={
updateAvailable ? (
<Ionicons name="arrow-up-circle" size={20} color={isDark ? "#7dd3fc" : "#0369a1"} />
) : undefined
}
/>
<SettingRow
icon="logo-github"
label={t("settings.about.github.label")}
Expand Down
107 changes: 107 additions & 0 deletions src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* "A newer version exists" — the only way a sideloaded install ever learns that.
*
* Deliberately not a modal: a modal on launch is the fastest way to get an app
* uninstalled, and this must be safe to ship to the whole install base. It is a
* single dismissible strip above the session list, with a "Not now" that sticks
* for that version (see update-check-policy.ts).
*
* Renders nothing at all when there is no update, when the check failed, on
* iOS, or once dismissed — so the common case costs one hook and no pixels.
*/

import { useCallback, useEffect, useState } from "react"
import { View, Text, TouchableOpacity, StyleSheet, Linking } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
import { checkForUpdate, dismissUpdate, CURRENT_VERSION, type AvailableUpdate } from "../lib/update-check"

export function UpdateBanner({ isDark }: { isDark: boolean }) {
const { t } = useTranslation()
const [update, setUpdate] = useState<AvailableUpdate | null>(null)

useEffect(() => {
let cancelled = false
checkForUpdate()
.then((result) => {
if (!cancelled) setUpdate(result)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [])

const onDismiss = useCallback(() => {
if (update) void dismissUpdate(update.version)
setUpdate(null)
}, [update])

const onOpen = useCallback(() => {
if (!update) return
// Opening the release page is also an implicit "I've seen this version":
// do not nag about it again either.
void dismissUpdate(update.version)
void Linking.openURL(update.url)
}, [update])

if (!update) return null

return (
<View style={[styles.banner, isDark && styles.bannerDark]} testID="update-banner">
<Ionicons name="arrow-up-circle" size={20} color={isDark ? "#7dd3fc" : "#0369a1"} />
<View style={styles.text}>
<Text style={[styles.title, isDark && styles.titleDark]}>{t("update.available")}</Text>
<Text style={[styles.body, isDark && styles.bodyDark]} numberOfLines={1}>
{t("update.body", { version: update.version, current: CURRENT_VERSION })}
</Text>
</View>
<TouchableOpacity onPress={onOpen} testID="update-banner-open" hitSlop={8}>
<Text style={[styles.action, isDark && styles.actionDark]}>{t("update.action")}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onDismiss} testID="update-banner-dismiss" hitSlop={8}>
<Ionicons name="close" size={18} color={isDark ? "#94a3b8" : "#64748b"} />
</TouchableOpacity>
</View>
)
}

const styles = StyleSheet.create({
banner: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "#e0f2fe",
},
bannerDark: {
backgroundColor: "#0c2b3d",
},
text: {
flex: 1,
},
title: {
fontSize: 14,
fontWeight: "600",
color: "#0c4a6e",
},
titleDark: {
color: "#e0f2fe",
},
body: {
fontSize: 12,
color: "#0369a1",
},
bodyDark: {
color: "#94a3b8",
},
action: {
fontSize: 14,
fontWeight: "600",
color: "#0369a1",
},
actionDark: {
color: "#7dd3fc",
},
})
7 changes: 7 additions & 0 deletions src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -444,5 +444,12 @@
"connectButton": "Connect your own server",
"setupGuideLink": "How to set up a server",
"hostedCtaLink": "No server? Join the OpenCode Connect waitlist — hosted, no setup"
},
"update": {
"available": "Update available",
"body": "Version {{version}} is out. You're on {{current}}.",
"action": "Get it",
"dismiss": "Not now",
"upToDate": "Up to date"
}
}
7 changes: 7 additions & 0 deletions src/lib/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -444,5 +444,12 @@
"connectButton": "连接您自己的服务器",
"setupGuideLink": "如何设置服务器",
"hostedCtaLink": "没有服务器?加入 OpenCode Connect 候补名单 — 托管,无需搭建"
},
"update": {
"available": "有可用更新",
"body": "版本 {{version}} 已发布,你当前使用的是 {{current}}。",
"action": "去获取",
"dismiss": "暂不",
"upToDate": "已是最新"
}
}
Loading
Loading