Skip to content

Commit cb616c6

Browse files
authored
Merge pull request #203 from vernu/dev
show update app version cta in dashboard
2 parents ffa66be + 002ac9c commit cb616c6

File tree

6 files changed

+453
-10
lines changed

6 files changed

+453
-10
lines changed

web/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
NEXT_PUBLIC_SITE_URL=http://localhost:3000
22
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001/api/v1
3+
NEXT_PUBLIC_LATEST_APP_VERSION_CODE=17
34
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
45
NEXT_PUBLIC_TAWKTO_EMBED_URL=
56

web/app/(app)/dashboard/(components)/device-list.tsx

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
44
import { Badge } from '@/components/ui/badge'
55
import { Button } from '@/components/ui/button'
6-
import { ScrollArea } from '@/components/ui/scroll-area'
76
import { Smartphone, Battery, Signal, Copy } from 'lucide-react'
87
import { useToast } from '@/hooks/use-toast'
98
import httpBrowserClient from '@/lib/httpBrowserClient'
109
import { ApiEndpoints } from '@/config/api'
10+
import { Routes } from '@/config/routes'
1111
import { useQuery } from '@tanstack/react-query'
1212
import { Skeleton } from '@/components/ui/skeleton'
1313
import { formatDeviceName } from '@/lib/utils'
14+
import {
15+
DeviceVersionCandidate,
16+
getDeviceVersionCode,
17+
isDeviceOutdated,
18+
latestAppVersionCode,
19+
} from './update-app-helpers'
1420

1521
export default function DeviceList() {
1622
const { toast } = useToast()
@@ -86,14 +92,24 @@ export default function DeviceList() {
8692
<h3 className='font-semibold text-sm'>
8793
{formatDeviceName(device)}
8894
</h3>
89-
<Badge
90-
variant={
91-
device.status === 'online' ? 'default' : 'secondary'
92-
}
93-
className='text-xs'
94-
>
95-
{device.enabled ? 'Enabled' : 'Disabled'}
96-
</Badge>
95+
<div className='flex items-center gap-2'>
96+
{isDeviceOutdated(device as DeviceVersionCandidate) && (
97+
<Badge
98+
variant='outline'
99+
className='border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300'
100+
>
101+
Update available
102+
</Badge>
103+
)}
104+
<Badge
105+
variant={
106+
device.status === 'online' ? 'default' : 'secondary'
107+
}
108+
className='text-xs'
109+
>
110+
{device.enabled ? 'Enabled' : 'Disabled'}
111+
</Badge>
112+
</div>
97113
</div>
98114
<div className='flex items-center space-x-2 mt-1'>
99115
<code className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs'>
@@ -116,6 +132,11 @@ export default function DeviceList() {
116132
<div className='flex items-center'>
117133
<Signal className='h-3 w-3 mr-1' />-
118134
</div>
135+
<div>
136+
App version:{' '}
137+
{getDeviceVersionCode(device as DeviceVersionCandidate) ??
138+
'unknown'}
139+
</div>
119140
<div>
120141
Registered at:{' '}
121142
{new Date(device.createdAt).toLocaleString('en-US', {
@@ -124,6 +145,31 @@ export default function DeviceList() {
124145
})}
125146
</div>
126147
</div>
148+
{isDeviceOutdated(device as DeviceVersionCandidate) && (
149+
<div className='mt-3 flex items-center justify-between gap-2 rounded-lg border border-brand-100 bg-brand-50/60 px-3 py-2 dark:border-brand-900/50 dark:bg-brand-950/20'>
150+
<p className='text-xs text-muted-foreground'>
151+
This device is behind the latest supported version{' '}
152+
<span className='font-medium text-foreground'>
153+
{latestAppVersionCode}
154+
</span>
155+
.
156+
</p>
157+
<Button
158+
variant='outline'
159+
size='sm'
160+
asChild
161+
className='shrink-0'
162+
>
163+
<a
164+
href={Routes.downloadAndroidApp}
165+
target='_blank'
166+
rel='noreferrer'
167+
>
168+
Update app
169+
</a>
170+
</Button>
171+
</div>
172+
)}
127173
</div>
128174
</CardContent>
129175
</Card>
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useState } from 'react'
4+
5+
export type DeviceVersionCandidate = {
6+
_id: string
7+
brand: string
8+
model: string
9+
name?: string | null
10+
appVersionCode?: number | null
11+
appVersionInfo?: {
12+
versionCode?: number | null
13+
} | null
14+
}
15+
16+
export const DEFAULT_LATEST_APP_VERSION_CODE = 17
17+
export const UPDATE_APP_REMIND_LATER_MS = 6 * 60 * 60 * 1000
18+
export const UPDATE_APP_DONT_ASK_AGAIN_MS = 30 * 24 * 60 * 60 * 1000
19+
20+
const UPDATE_APP_SNOOZE_KEY = 'update_app_prompt_snooze_until'
21+
const UPDATE_APP_SNOOZE_EVENT = 'update-app-prompt-snooze-changed'
22+
23+
const envLatestVersionCode = Number.parseInt(
24+
process.env.NEXT_PUBLIC_LATEST_APP_VERSION_CODE?.trim() ?? '',
25+
10
26+
)
27+
28+
export const latestAppVersionCode =
29+
Number.isFinite(envLatestVersionCode) && envLatestVersionCode > 0
30+
? envLatestVersionCode
31+
: DEFAULT_LATEST_APP_VERSION_CODE
32+
33+
export function getDeviceVersionCode(device: DeviceVersionCandidate) {
34+
const heartbeatVersionCode = device.appVersionInfo?.versionCode
35+
36+
if (typeof heartbeatVersionCode === 'number') {
37+
return heartbeatVersionCode
38+
}
39+
40+
return typeof device.appVersionCode === 'number' ? device.appVersionCode : null
41+
}
42+
43+
export function isDeviceOutdated(
44+
device: DeviceVersionCandidate,
45+
latestVersionCode = latestAppVersionCode
46+
) {
47+
const deviceVersionCode = getDeviceVersionCode(device)
48+
49+
if (deviceVersionCode === null) {
50+
return false
51+
}
52+
53+
return deviceVersionCode < latestVersionCode
54+
}
55+
56+
export function getOutdatedDevices(
57+
devices: DeviceVersionCandidate[] | undefined,
58+
latestVersionCode = latestAppVersionCode
59+
) {
60+
if (!devices?.length) {
61+
return []
62+
}
63+
64+
return devices.filter((device) => isDeviceOutdated(device, latestVersionCode))
65+
}
66+
67+
export function summarizeOutdatedDeviceNames(
68+
devices: DeviceVersionCandidate[],
69+
formatDeviceName: (device: DeviceVersionCandidate) => string,
70+
visibleCount = 3
71+
) {
72+
const visibleDevices = devices.slice(0, visibleCount).map(formatDeviceName)
73+
const remainingCount = Math.max(devices.length - visibleDevices.length, 0)
74+
75+
if (visibleDevices.length === 0) {
76+
return ''
77+
}
78+
79+
if (visibleDevices.length === 1) {
80+
return visibleDevices[0]
81+
}
82+
83+
if (visibleDevices.length === 2) {
84+
return remainingCount > 0
85+
? `${visibleDevices[0]}, ${visibleDevices[1]} and ${remainingCount} more device${
86+
remainingCount > 1 ? 's' : ''
87+
}`
88+
: `${visibleDevices[0]} and ${visibleDevices[1]}`
89+
}
90+
91+
const namedPrefix = `${visibleDevices[0]}, ${visibleDevices[1]} and ${visibleDevices[2]}`
92+
93+
return remainingCount > 0
94+
? `${namedPrefix} and ${remainingCount} more device${
95+
remainingCount > 1 ? 's' : ''
96+
}`
97+
: namedPrefix
98+
}
99+
100+
function readUpdatePromptSnoozeUntil() {
101+
if (typeof window === 'undefined') {
102+
return 0
103+
}
104+
105+
const value = window.localStorage.getItem(UPDATE_APP_SNOOZE_KEY)
106+
const parsedValue = value ? Number.parseInt(value, 10) : 0
107+
108+
return Number.isFinite(parsedValue) ? parsedValue : 0
109+
}
110+
111+
function emitUpdatePromptSnoozeChanged() {
112+
if (typeof window === 'undefined') {
113+
return
114+
}
115+
116+
window.dispatchEvent(new Event(UPDATE_APP_SNOOZE_EVENT))
117+
}
118+
119+
export function setUpdatePromptSnooze(durationMs: number) {
120+
if (typeof window === 'undefined') {
121+
return
122+
}
123+
124+
window.localStorage.setItem(
125+
UPDATE_APP_SNOOZE_KEY,
126+
(Date.now() + durationMs).toString()
127+
)
128+
emitUpdatePromptSnoozeChanged()
129+
}
130+
131+
export function useUpdatePromptSnooze() {
132+
const [snoozeUntil, setSnoozeUntil] = useState(0)
133+
134+
const refreshSnoozeUntil = useCallback(() => {
135+
setSnoozeUntil(readUpdatePromptSnoozeUntil())
136+
}, [])
137+
138+
useEffect(() => {
139+
refreshSnoozeUntil()
140+
141+
const handleVisibilityChange = () => {
142+
if (document.visibilityState === 'visible') {
143+
refreshSnoozeUntil()
144+
}
145+
}
146+
147+
window.addEventListener('storage', refreshSnoozeUntil)
148+
window.addEventListener(
149+
UPDATE_APP_SNOOZE_EVENT,
150+
refreshSnoozeUntil as EventListener
151+
)
152+
document.addEventListener('visibilitychange', handleVisibilityChange)
153+
154+
return () => {
155+
window.removeEventListener('storage', refreshSnoozeUntil)
156+
window.removeEventListener(
157+
UPDATE_APP_SNOOZE_EVENT,
158+
refreshSnoozeUntil as EventListener
159+
)
160+
document.removeEventListener('visibilitychange', handleVisibilityChange)
161+
}
162+
}, [refreshSnoozeUntil])
163+
164+
return {
165+
isSnoozed: snoozeUntil > Date.now(),
166+
snoozeUntil,
167+
refreshSnoozeUntil,
168+
}
169+
}

0 commit comments

Comments
 (0)