Files
donetick/src/views/Settings/NotificationSetting.jsx
Mo Tarbin f27bbd318f Refactor chore management to use React Query hooks for timer and chore actions
- Replaced direct API calls with React Query hooks in ChoreView, ChoreCard, CompactChoreCard, and TimerDetails components for better state management and error handling.
- Updated ArchivedTasks to utilize useUnArchiveChore hook for restoring archived chores.
- Enhanced NotificationSetting to include device registration logic and improved user feedback for push notifications.
- Refactored TimerEditModal and TimerDetails to streamline timer session updates and deletions using hooks.
- Improved ChoreActionMenu to handle archiving and unarchiving chores with hooks.
- Adjusted various components to use getSafeBottomStyles for consistent bottom padding.
- Cleaned up unused imports and optimized loading states across components.
2025-09-21 12:46:03 -04:00

754 lines
26 KiB
JavaScript

import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { Android, Apple } from '@mui/icons-material'
import {
Box,
Button,
Card,
Divider,
FormControl,
FormHelperText,
FormLabel,
Input,
Option,
Select,
Switch,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { PushNotifications } from '@capacitor/push-notifications'
import { registerPushNotifications } from '../../CapacitorListener'
import { useDeviceTokens, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import {
UnregisterDeviceToken,
UpdateNotificationTarget,
} from '../../utils/Fetcher'
import SettingsLayout from './SettingsLayout'
const NotificationSetting = () => {
const { showWarning } = useNotification()
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const { data: deviceTokens, refetch: refetchDevices } = useDeviceTokens()
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' })
return JSON.parse(ret.value)
}
const setNotificationPreferences = async value => {
if (value.granted === false) {
await Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify({ granted: false }),
})
return
}
const currentSettings = await getNotificationPreferences()
await Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify({ ...currentSettings, ...value }),
})
}
const getPushNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'pushNotificationPreferences' })
return JSON.parse(ret.value)
}
const setPushNotificationPreferences = async value => {
await Preferences.set({
key: 'pushNotificationPreferences',
value: JSON.stringify(value),
})
}
const [deviceNotification, setDeviceNotification] = useState(false)
const [dueNotification, setDueNotification] = useState(true)
const [preDueNotification, setPreDueNotification] = useState(false)
const [naggingNotification, setNaggingNotification] = useState(false)
const [pushNotification, setPushNotification] = useState(false)
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
const [currentDevice, setCurrentDevice] = useState(null)
const [isCurrentDeviceRegistered, setIsCurrentDeviceRegistered] =
useState(true)
useEffect(() => {
getNotificationPreferences().then(resp => {
if (resp) {
setDeviceNotification(Boolean(resp.granted))
setDueNotification(Boolean(resp.dueNotification ?? true))
setPreDueNotification(Boolean(resp.preDueNotification))
setNaggingNotification(Boolean(resp.naggingNotification))
}
})
getPushNotificationPreferences().then(resp => {
if (resp) {
setPushNotification(Boolean(resp.granted))
}
})
// Check if this is the official donetick.com instance
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
} catch (error) {
console.warn('Error checking instance type:', error)
setIsOfficialInstance(false)
}
// Get current device info if on native platform
if (Capacitor.isNativePlatform()) {
const getCurrentDeviceInfo = async () => {
try {
const deviceInfo = await Device.getInfo()
const deviceId = await Device.getId()
const platform =
Capacitor.getPlatform() === 'android' ? 'android' : 'ios'
setCurrentDevice({
id: deviceId.identifier,
platform,
model: deviceInfo.model,
appVersion: deviceInfo.appVersion,
})
} catch (error) {
console.error('Error getting device info:', error)
}
}
getCurrentDeviceInfo()
}
}, [])
const [notificationTarget, setNotificationTarget] = useState(
userProfile?.notification_target
? String(userProfile.notification_target.type)
: '0',
)
const [chatID, setChatID] = useState(
userProfile?.notification_target?.target_id ?? 0,
)
const [error, setError] = useState('')
// Check if current device is registered whenever deviceTokens or currentDevice changes
useEffect(() => {
if (currentDevice && deviceTokens && isOfficialInstance) {
const isRegistered = deviceTokens.some(
device => device.deviceId === currentDevice.id,
)
setIsCurrentDeviceRegistered(isRegistered)
}
}, [currentDevice, deviceTokens, isOfficialInstance])
// Listen for device registration events from CapacitorListener
useEffect(() => {
const handleDeviceRegistered = () => {
refetchDevices()
showWarning({
title: 'Success',
message: 'Device registered successfully for push notifications.',
})
}
const handleDeviceRegistrationFailed = event => {
const { status, error } = event.detail || {}
if (status === 409) {
showWarning({
title: 'Device Limit Reached',
message:
'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.',
})
} else {
showWarning({
title: 'Registration Failed',
message:
error ||
'Failed to register device automatically. Please try again.',
})
}
}
// Listen for the custom events that CapacitorListener might emit
window.addEventListener('deviceTokenRegistered', handleDeviceRegistered)
window.addEventListener(
'deviceTokenRegistrationFailed',
handleDeviceRegistrationFailed,
)
return () => {
window.removeEventListener(
'deviceTokenRegistered',
handleDeviceRegistered,
)
window.removeEventListener(
'deviceTokenRegistrationFailed',
handleDeviceRegistrationFailed,
)
}
}, [refetchDevices, showWarning])
const SaveValidation = () => {
switch (notificationTarget) {
case '1':
if (chatID === '') {
setError('Chat ID is required')
return false
} else if (isNaN(chatID) || chatID === '0') {
setError('Invalid Chat ID')
return false
}
break
case '2':
if (chatID === '') {
setError('User key is required')
return false
}
break
default:
break
}
setError('')
return true
}
const handleSave = () => {
if (!SaveValidation()) return
UpdateNotificationTarget({
target: chatID,
type: Number(notificationTarget),
}).then(resp => {
if (resp.status != 200) {
alert(`Error while updating notification target: ${resp.statusText}`)
return
}
refetchUserProfile()
alert('Notification target updated')
})
}
const handleRegisterCurrentDevice = async () => {
if (!currentDevice) return
// Check device limit before attempting registration
const currentDeviceCount = deviceTokens ? deviceTokens.length : 0
if (currentDeviceCount >= 5) {
showWarning({
title: 'Device Limit Reached',
message:
'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.',
})
return
}
try {
// First request push notification permission
const permStatus = await PushNotifications.requestPermissions()
if (permStatus.receive !== 'granted') {
showWarning({
title: 'Permission Required',
message:
'Push notification permission is required to register this device.',
})
return
}
// Ensure push notification listeners are set up before registration
await registerPushNotifications()
// Store registration preferences immediately since permission was granted
await setPushNotificationPreferences({ granted: true })
setPushNotification(true)
showWarning({
title: 'Registration Initiated',
message:
'Push notification registration has been initiated. The device will be registered automatically.',
})
} catch (error) {
console.error('Error registering device:', error)
showWarning({
title: 'Error',
message: 'Failed to register device. Please try again.',
})
}
}
return (
<SettingsLayout title='Notification Settings'>
<div className='grid gap-4 py-4' id='notifications'>
<Typography level='h3'>Device Notification</Typography>
<Divider />
<Typography level='body-md'>Manage your Device Notificaiton</Typography>
<FormControl orientation='horizontal'>
<Switch
disabled={!Capacitor.isNativePlatform()}
checked={deviceNotification}
onClick={event => {
event.preventDefault()
if (deviceNotification === false) {
LocalNotifications.requestPermissions().then(resp => {
if (resp.display === 'granted') {
setDeviceNotification(true)
setNotificationPreferences({ granted: true })
} else if (resp.display === 'denied') {
showWarning({
title: 'Notification Permission Denied',
message:
'You have denied notification permissions. You can enable them later in your device settings.',
})
setDeviceNotification(false)
setNotificationPreferences({ granted: false })
}
})
} else {
setDeviceNotification(false)
}
}}
color={deviceNotification ? 'success' : 'neutral'}
variant={deviceNotification ? 'solid' : 'outlined'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
},
},
}}
sx={{ mr: 2 }}
/>
<div>
<FormLabel>Device Notification</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
{Capacitor.isNativePlatform()
? 'Receive notification on your device when a task is due'
: 'This feature is only available on mobile devices'}{' '}
</FormHelperText>
</div>
</FormControl>
<Button
variant='soft'
color='primary'
disabled={!deviceNotification}
sx={{
width: '210px',
mb: 1,
}}
onClick={() => {
// schedule a local notification in 5 seconds
LocalNotifications.schedule({
notifications: [
{
title: 'Test Notification',
body: 'You have a task due soon',
id: 1,
schedule: { at: new Date(Date.now() + 2000) },
sound: null,
attachments: null,
actionTypeId: '',
extra: null,
},
],
})
}}
>
Test Notification{' '}
</Button>
{deviceNotification && (
<Card>
{[
{
title: 'Due Date Notification',
checked: dueNotification,
set: setDueNotification,
label: 'Notification when the task is due',
property: 'dueNotification',
disabled: false,
},
{
title: 'Pre-Due Date Notification',
checked: preDueNotification,
set: setPreDueNotification,
label: 'Notification a few hours before the task is due',
property: 'preDueNotification',
disabled: false,
},
{
title: 'Overdue Notification',
checked: naggingNotification,
set: setNaggingNotification,
label: 'Notification when the task is overdue',
property: 'naggingNotification',
disabled: false,
},
].map(item => (
<FormControl
key={item.property}
orientation='horizontal'
sx={{ width: 385, justifyContent: 'space-between' }}
>
<div>
<FormLabel>{item.title}</FormLabel>
<FormHelperText sx={{ mt: 0 }}>{item.label} </FormHelperText>
</div>
<Switch
checked={item.checked}
disabled={item.disabled}
onClick={() => {
setNotificationPreferences({
[item.property]: !item.checked,
})
item.set(!item.checked)
}}
color={item.checked ? 'success' : ''}
variant='solid'
endDecorator={item.checked ? 'On' : 'Off'}
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
/>
</FormControl>
))}
</Card>
)}
{isOfficialInstance && (
<FormControl
orientation='horizontal'
sx={{ width: 400, justifyContent: 'space-between' }}
>
<div>
<FormLabel>Push Notifications</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
{Capacitor.isNativePlatform()
? 'Receive Nudges, Announcements, and Chore Assignments via Push Notifications'
: 'This feature is only available on mobile devices'}{' '}
</FormHelperText>
</div>
<Switch
disabled={!Capacitor.isNativePlatform()}
checked={pushNotification}
onClick={async event => {
event.preventDefault()
if (pushNotification === false) {
try {
const resp = await PushNotifications.requestPermissions()
console.log('user PushNotifications permission', resp)
if (resp.receive === 'granted') {
setPushNotification(true)
setPushNotificationPreferences({ granted: true })
// Register push notifications after permission is granted
await registerPushNotifications()
}
if (resp.receive !== 'granted') {
showWarning({
title: 'Push Notification Permission Denied',
message:
'Push notifications have been disabled. You can enable them in your device settings if needed.',
})
setPushNotification(false)
setPushNotificationPreferences({ granted: false })
console.log('User denied permission', resp)
}
} catch (error) {
console.error('Error setting up push notifications:', error)
}
} else {
setPushNotification(false)
}
}}
color={pushNotification ? 'success' : 'neutral'}
variant={pushNotification ? 'solid' : 'outlined'}
endDecorator={pushNotification ? 'On' : 'Off'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
},
},
}}
/>
</FormControl>
)}
{isOfficialInstance && (
<>
<Typography level='h4' sx={{ mt: 2 }}>
Registered Devices ({deviceTokens ? deviceTokens.length : 0}/5)
</Typography>
<Divider />
<Typography level='body-md' sx={{ mb: 2 }}>
Devices registered to receive push notifications for your account
</Typography>
{/* Show register current device option if not registered */}
{Capacitor.isNativePlatform() &&
currentDevice &&
!isCurrentDeviceRegistered && (
<Card
variant='outlined'
sx={{ p: 2, mb: 2, bgcolor: 'background.level1' }}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{currentDevice.platform === 'ios' ? (
<Apple sx={{ fontSize: 24, color: '#007AFF' }} />
) : (
<Android sx={{ fontSize: 24, color: '#3DDC84' }} />
)}
<Box>
<Typography level='body-md' sx={{ fontWeight: 'bold' }}>
Current Device:{' '}
{currentDevice.platform === 'ios' ? 'iOS' : 'Android'}{' '}
{currentDevice.model}
</Typography>
<Typography level='body-sm' color='neutral'>
This device is not registered for push notifications
</Typography>
</Box>
</Box>
<Button
variant='solid'
color='primary'
size='sm'
disabled={deviceTokens && deviceTokens.length >= 5}
onClick={handleRegisterCurrentDevice}
>
{deviceTokens && deviceTokens.length >= 5
? 'Limit Reached'
: 'Register Device'}
</Button>
</Box>
</Card>
)}
{deviceTokens && deviceTokens.length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{deviceTokens.map(device => (
<Card key={device.id} variant='outlined' sx={{ p: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
>
{device.platform === 'ios' ? (
<Apple sx={{ fontSize: 24, color: '#007AFF' }} />
) : (
<Android sx={{ fontSize: 24, color: '#3DDC84' }} />
)}
<Box>
<Typography
level='body-md'
sx={{ fontWeight: 'bold' }}
>
{device.platform === 'ios' ? 'iOS' : 'Android'}{' '}
{device.deviceModel || 'Unknown Device'}
</Typography>
{device.createdAt && (
<Typography level='body-sm' color='neutral'>
Created At:{' '}
{new Date(device.createdAt).toLocaleDateString()}
</Typography>
)}
</Box>
</Box>
<Button
variant='outlined'
color='danger'
size='sm'
onClick={async () => {
try {
const resp = await UnregisterDeviceToken(
device.deviceId,
null,
)
if (resp.ok) {
refetchDevices()
} else {
showWarning({
title: 'Error',
message: 'Failed to unregister device',
})
}
} catch (error) {
showWarning({
title: 'Error',
message: 'Failed to unregister device',
})
}
}}
>
Remove
</Button>
</Box>
</Card>
))}
</Box>
) : (
<Typography level='body-md' color='neutral'>
No devices registered for push notifications
</Typography>
)}
</>
)}
<Typography level='h3'>Custom Notification</Typography>
<Divider />
<Typography level='body-md'>
Notificaiton through other platform like Telegram or Pushover
</Typography>
<FormControl orientation='horizontal'>
<Switch
checked={Boolean(chatID !== 0)}
onClick={event => {
event.preventDefault()
if (chatID !== 0) {
// Turning off custom notification - call API to disable
setChatID(0)
setNotificationTarget('0')
UpdateNotificationTarget({
target: '',
type: 0,
}).then(resp => {
if (resp.status === 200) {
refetchUserProfile()
}
})
} else {
// Turning on custom notification - just set state, user will use Save button
setChatID('')
setNotificationTarget('1') // Default to Telegram
}
}}
color={chatID !== 0 ? 'success' : 'neutral'}
variant={chatID !== 0 ? 'solid' : 'outlined'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
},
},
}}
sx={{ mr: 2 }}
/>
<div>
<FormLabel>Custom Notification</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
Receive notification on other platform
</FormHelperText>
</div>
</FormControl>
{chatID !== 0 && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Select
value={notificationTarget}
sx={{ maxWidth: '200px' }}
onChange={(e, selected) => setNotificationTarget(selected)}
>
<Option value='0'>None</Option>
<Option value='1'>Telegram</Option>
<Option value='2'>Pushover</Option>
<Option value='3'>Webhooks</Option>
</Select>
{notificationTarget === '1' && (
<>
<Typography level='body-xs'>
You need to initiate a message to the bot in order for the
Telegram notification to work{' '}
<a
style={{
textDecoration: 'underline',
color: '#0891b2',
}}
href='https://t.me/DonetickBot'
>
Click here
</a>{' '}
to start a chat
</Typography>
<Typography level='body-sm'>Chat ID</Typography>
<Input
value={chatID}
onChange={e => setChatID(e.target.value)}
placeholder='User ID / Chat ID'
sx={{
width: '200px',
}}
/>
<Typography mt={0} level='body-xs'>
If you don't know your Chat ID, start chat with userinfobot
and it will send you your Chat ID.{' '}
<a
style={{
textDecoration: 'underline',
color: '#0891b2',
}}
href='https://t.me/userinfobot'
>
Click here
</a>{' '}
to start chat with userinfobot{' '}
</Typography>
</>
)}
{notificationTarget === '2' && (
<>
<Typography level='body-sm'>User key</Typography>
<Input
value={chatID}
onChange={e => setChatID(e.target.value)}
placeholder='User ID'
sx={{
width: '200px',
}}
/>
</>
)}
{error && (
<Typography color='warning' level='body-sm'>
{error}
</Typography>
)}
<Button
sx={{
width: '110px',
mb: 1,
}}
onClick={handleSave}
>
Save
</Button>
</Box>
)}
</div>
</SettingsLayout>
)
}
export default NotificationSetting