diff --git a/web/.env.example b/web/.env.example
index 2604758..23023d1 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -1,5 +1,6 @@
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001/api/v1
+NEXT_PUBLIC_LATEST_APP_VERSION_CODE=17
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
NEXT_PUBLIC_TAWKTO_EMBED_URL=
diff --git a/web/app/(app)/dashboard/(components)/device-list.tsx b/web/app/(app)/dashboard/(components)/device-list.tsx
index 24f1c76..6ed6b88 100644
--- a/web/app/(app)/dashboard/(components)/device-list.tsx
+++ b/web/app/(app)/dashboard/(components)/device-list.tsx
@@ -3,14 +3,20 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
-import { ScrollArea } from '@/components/ui/scroll-area'
import { Smartphone, Battery, Signal, Copy } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
+import { Routes } from '@/config/routes'
import { useQuery } from '@tanstack/react-query'
import { Skeleton } from '@/components/ui/skeleton'
import { formatDeviceName } from '@/lib/utils'
+import {
+ DeviceVersionCandidate,
+ getDeviceVersionCode,
+ isDeviceOutdated,
+ latestAppVersionCode,
+} from './update-app-helpers'
export default function DeviceList() {
const { toast } = useToast()
@@ -86,14 +92,24 @@ export default function DeviceList() {
{formatDeviceName(device)}
-
- {device.enabled ? 'Enabled' : 'Disabled'}
-
+
+ {isDeviceOutdated(device as DeviceVersionCandidate) && (
+
+ Update available
+
+ )}
+
+ {device.enabled ? 'Enabled' : 'Disabled'}
+
+
@@ -116,6 +132,11 @@ export default function DeviceList() {
-
+
+ App version:{' '}
+ {getDeviceVersionCode(device as DeviceVersionCandidate) ??
+ 'unknown'}
+
Registered at:{' '}
{new Date(device.createdAt).toLocaleString('en-US', {
@@ -124,6 +145,31 @@ export default function DeviceList() {
})}
+ {isDeviceOutdated(device as DeviceVersionCandidate) && (
+
+
+ This device is behind the latest supported version{' '}
+
+ {latestAppVersionCode}
+
+ .
+
+
+
+ Update app
+
+
+
+ )}
diff --git a/web/app/(app)/dashboard/(components)/update-app-helpers.ts b/web/app/(app)/dashboard/(components)/update-app-helpers.ts
new file mode 100644
index 0000000..16bcddd
--- /dev/null
+++ b/web/app/(app)/dashboard/(components)/update-app-helpers.ts
@@ -0,0 +1,169 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+
+export type DeviceVersionCandidate = {
+ _id: string
+ brand: string
+ model: string
+ name?: string | null
+ appVersionCode?: number | null
+ appVersionInfo?: {
+ versionCode?: number | null
+ } | null
+}
+
+export const DEFAULT_LATEST_APP_VERSION_CODE = 17
+export const UPDATE_APP_REMIND_LATER_MS = 6 * 60 * 60 * 1000
+export const UPDATE_APP_DONT_ASK_AGAIN_MS = 30 * 24 * 60 * 60 * 1000
+
+const UPDATE_APP_SNOOZE_KEY = 'update_app_prompt_snooze_until'
+const UPDATE_APP_SNOOZE_EVENT = 'update-app-prompt-snooze-changed'
+
+const envLatestVersionCode = Number.parseInt(
+ process.env.NEXT_PUBLIC_LATEST_APP_VERSION_CODE?.trim() ?? '',
+ 10
+)
+
+export const latestAppVersionCode =
+ Number.isFinite(envLatestVersionCode) && envLatestVersionCode > 0
+ ? envLatestVersionCode
+ : DEFAULT_LATEST_APP_VERSION_CODE
+
+export function getDeviceVersionCode(device: DeviceVersionCandidate) {
+ const heartbeatVersionCode = device.appVersionInfo?.versionCode
+
+ if (typeof heartbeatVersionCode === 'number') {
+ return heartbeatVersionCode
+ }
+
+ return typeof device.appVersionCode === 'number' ? device.appVersionCode : null
+}
+
+export function isDeviceOutdated(
+ device: DeviceVersionCandidate,
+ latestVersionCode = latestAppVersionCode
+) {
+ const deviceVersionCode = getDeviceVersionCode(device)
+
+ if (deviceVersionCode === null) {
+ return false
+ }
+
+ return deviceVersionCode < latestVersionCode
+}
+
+export function getOutdatedDevices(
+ devices: DeviceVersionCandidate[] | undefined,
+ latestVersionCode = latestAppVersionCode
+) {
+ if (!devices?.length) {
+ return []
+ }
+
+ return devices.filter((device) => isDeviceOutdated(device, latestVersionCode))
+}
+
+export function summarizeOutdatedDeviceNames(
+ devices: DeviceVersionCandidate[],
+ formatDeviceName: (device: DeviceVersionCandidate) => string,
+ visibleCount = 3
+) {
+ const visibleDevices = devices.slice(0, visibleCount).map(formatDeviceName)
+ const remainingCount = Math.max(devices.length - visibleDevices.length, 0)
+
+ if (visibleDevices.length === 0) {
+ return ''
+ }
+
+ if (visibleDevices.length === 1) {
+ return visibleDevices[0]
+ }
+
+ if (visibleDevices.length === 2) {
+ return remainingCount > 0
+ ? `${visibleDevices[0]}, ${visibleDevices[1]} and ${remainingCount} more device${
+ remainingCount > 1 ? 's' : ''
+ }`
+ : `${visibleDevices[0]} and ${visibleDevices[1]}`
+ }
+
+ const namedPrefix = `${visibleDevices[0]}, ${visibleDevices[1]} and ${visibleDevices[2]}`
+
+ return remainingCount > 0
+ ? `${namedPrefix} and ${remainingCount} more device${
+ remainingCount > 1 ? 's' : ''
+ }`
+ : namedPrefix
+}
+
+function readUpdatePromptSnoozeUntil() {
+ if (typeof window === 'undefined') {
+ return 0
+ }
+
+ const value = window.localStorage.getItem(UPDATE_APP_SNOOZE_KEY)
+ const parsedValue = value ? Number.parseInt(value, 10) : 0
+
+ return Number.isFinite(parsedValue) ? parsedValue : 0
+}
+
+function emitUpdatePromptSnoozeChanged() {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ window.dispatchEvent(new Event(UPDATE_APP_SNOOZE_EVENT))
+}
+
+export function setUpdatePromptSnooze(durationMs: number) {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ window.localStorage.setItem(
+ UPDATE_APP_SNOOZE_KEY,
+ (Date.now() + durationMs).toString()
+ )
+ emitUpdatePromptSnoozeChanged()
+}
+
+export function useUpdatePromptSnooze() {
+ const [snoozeUntil, setSnoozeUntil] = useState(0)
+
+ const refreshSnoozeUntil = useCallback(() => {
+ setSnoozeUntil(readUpdatePromptSnoozeUntil())
+ }, [])
+
+ useEffect(() => {
+ refreshSnoozeUntil()
+
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ refreshSnoozeUntil()
+ }
+ }
+
+ window.addEventListener('storage', refreshSnoozeUntil)
+ window.addEventListener(
+ UPDATE_APP_SNOOZE_EVENT,
+ refreshSnoozeUntil as EventListener
+ )
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
+ return () => {
+ window.removeEventListener('storage', refreshSnoozeUntil)
+ window.removeEventListener(
+ UPDATE_APP_SNOOZE_EVENT,
+ refreshSnoozeUntil as EventListener
+ )
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ }
+ }, [refreshSnoozeUntil])
+
+ return {
+ isSnoozed: snoozeUntil > Date.now(),
+ snoozeUntil,
+ refreshSnoozeUntil,
+ }
+}
diff --git a/web/app/(app)/dashboard/(components)/update-app-modal.tsx b/web/app/(app)/dashboard/(components)/update-app-modal.tsx
new file mode 100644
index 0000000..96955b9
--- /dev/null
+++ b/web/app/(app)/dashboard/(components)/update-app-modal.tsx
@@ -0,0 +1,160 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import Link from 'next/link'
+import { useQuery } from '@tanstack/react-query'
+import { Download, Sparkles, Smartphone } from 'lucide-react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { ApiEndpoints } from '@/config/api'
+import { Routes } from '@/config/routes'
+import httpBrowserClient from '@/lib/httpBrowserClient'
+import { formatDeviceName } from '@/lib/utils'
+import {
+ DeviceVersionCandidate,
+ UPDATE_APP_DONT_ASK_AGAIN_MS,
+ UPDATE_APP_REMIND_LATER_MS,
+ getOutdatedDevices,
+ latestAppVersionCode,
+ setUpdatePromptSnooze,
+ summarizeOutdatedDeviceNames,
+ useUpdatePromptSnooze,
+} from './update-app-helpers'
+
+export default function UpdateAppModal() {
+ const [isOpen, setIsOpen] = useState(false)
+ const ignoreNextOpenChangeRef = useRef(false)
+ const { isSnoozed } = useUpdatePromptSnooze()
+
+ const { data: devicesResponse, isLoading, error } = useQuery({
+ queryKey: ['devices'],
+ queryFn: () =>
+ httpBrowserClient
+ .get(ApiEndpoints.gateway.listDevices())
+ .then((res) => res.data),
+ })
+
+ const outdatedDevices = useMemo(
+ () =>
+ getOutdatedDevices(
+ (devicesResponse?.data ?? []) as DeviceVersionCandidate[]
+ ),
+ [devicesResponse?.data]
+ )
+
+ const primaryOutdatedDevice = outdatedDevices[0]
+
+ useEffect(() => {
+ if (isLoading || error || isSnoozed || outdatedDevices.length === 0) {
+ setIsOpen(false)
+ return
+ }
+
+ if (isOpen) {
+ return
+ }
+
+ const timer = window.setTimeout(() => {
+ setIsOpen(true)
+ }, 900)
+
+ return () => window.clearTimeout(timer)
+ }, [error, isLoading, isOpen, isSnoozed, outdatedDevices.length])
+
+ const closeWithSnooze = (durationMs: number) => {
+ setUpdatePromptSnooze(durationMs)
+ ignoreNextOpenChangeRef.current = true
+ setIsOpen(false)
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open && isOpen) {
+ if (ignoreNextOpenChangeRef.current) {
+ ignoreNextOpenChangeRef.current = false
+ } else {
+ setUpdatePromptSnooze(UPDATE_APP_REMIND_LATER_MS)
+ }
+ }
+
+ setIsOpen(open)
+ }
+
+ if (!primaryOutdatedDevice || error) {
+ return null
+ }
+
+ const deviceSummary = summarizeOutdatedDeviceNames(
+ outdatedDevices,
+ formatDeviceName
+ )
+
+ return (
+
+
+
+
+
+
+
+ You are using an older version of the textbee mobile app
+
+
+ {deviceSummary} {' '}
+ is ready for an update. Install version {latestAppVersionCode} to get
+ improved reliability, bug fixes, and more.
+
+
+
+
+
+
+
+
Update highlights
+
Improved reliability, bug fixes, and more.
+
+
+
+
+
+
Recommended action
+
Download the latest Android app build and update your device.
+
+
+
+
+
+
+ Update now
+
+
+ closeWithSnooze(UPDATE_APP_REMIND_LATER_MS)}
+ className='text-muted-foreground'
+ >
+ Remind later
+
+ closeWithSnooze(UPDATE_APP_DONT_ASK_AGAIN_MS)}
+ className='text-muted-foreground hover:text-destructive'
+ >
+ Don't ask again
+
+
+
+
+
+ )
+}
diff --git a/web/app/(app)/dashboard/(components)/update-app-notification-bar.tsx b/web/app/(app)/dashboard/(components)/update-app-notification-bar.tsx
new file mode 100644
index 0000000..2c0ef14
--- /dev/null
+++ b/web/app/(app)/dashboard/(components)/update-app-notification-bar.tsx
@@ -0,0 +1,64 @@
+'use client'
+
+import { useMemo } from 'react'
+import Link from 'next/link'
+import { useQuery } from '@tanstack/react-query'
+import { ArrowUpRight, BellRing } from 'lucide-react'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { ApiEndpoints } from '@/config/api'
+import { Routes } from '@/config/routes'
+import httpBrowserClient from '@/lib/httpBrowserClient'
+import { formatDeviceName } from '@/lib/utils'
+import {
+ DeviceVersionCandidate,
+ getOutdatedDevices,
+ latestAppVersionCode,
+ summarizeOutdatedDeviceNames,
+} from './update-app-helpers'
+
+export default function UpdateAppNotificationBar() {
+ const { data: devicesResponse, isLoading, error } = useQuery({
+ queryKey: ['devices'],
+ queryFn: () =>
+ httpBrowserClient
+ .get(ApiEndpoints.gateway.listDevices())
+ .then((res) => res.data),
+ })
+
+ const outdatedDevices = useMemo(
+ () =>
+ getOutdatedDevices(
+ (devicesResponse?.data ?? []) as DeviceVersionCandidate[]
+ ),
+ [devicesResponse?.data]
+ )
+
+ const primaryOutdatedDevice = outdatedDevices[0]
+
+ if (isLoading || error || !primaryOutdatedDevice) {
+ return null
+ }
+
+ const summary = summarizeOutdatedDeviceNames(outdatedDevices, formatDeviceName)
+ const verb = outdatedDevices.length > 1 ? 'are' : 'is'
+
+ return (
+
+
+
+
+ {summary} {verb} running an older app
+ version. Update to version {latestAppVersionCode} for improved
+ reliability, bug fixes, and more.
+
+
+
+ Update app
+
+
+
+
+
+ )
+}
diff --git a/web/app/(app)/dashboard/layout.tsx b/web/app/(app)/dashboard/layout.tsx
index b71e4ad..e0e6cf7 100644
--- a/web/app/(app)/dashboard/layout.tsx
+++ b/web/app/(app)/dashboard/layout.tsx
@@ -5,8 +5,9 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import AccountDeletionAlert from './(components)/account-deletion-alert'
import UpgradeToProAlert from './(components)/upgrade-to-pro-alert'
+import UpdateAppModal from './(components)/update-app-modal'
+import UpdateAppNotificationBar from './(components)/update-app-notification-bar'
import VerifyEmailAlert from './(components)/verify-email-alert'
-import BlackFridayModal from './(components)/black-friday-modal'
import { SurveyModal } from '@/components/shared/survey-modal'
export default function DashboardLayout({
@@ -51,6 +52,7 @@ export default function DashboardLayout({
{/* Main content with left padding to account for fixed sidebar */}
+
@@ -93,6 +95,7 @@ export default function DashboardLayout({
+
)
}