feat: add device token management and safe area utilities; enhance notification settings and chore components

This commit is contained in:
Mo Tarbin
2025-09-20 22:20:02 -04:00
parent 3bf9126595
commit 79dfb11fa4
13 changed files with 568 additions and 146 deletions

View File

@@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
GetAllCircleMembers,
GetAllUsers,
GetDeviceTokens,
GetUserProfile,
} from '../utils/Fetcher'
import { isTokenValid } from '../utils/TokenManager'
@@ -52,3 +53,28 @@ export const useUserProfile = () => {
refetch: () => queryClient.invalidateQueries(['userProfile']),
}
}
export const useDeviceTokens = () => {
const queryClient = useQueryClient()
const { data, error, isLoading } = useQuery({
queryKey: ['deviceTokens'],
queryFn: async () => {
if (!isTokenValid()) {
return null
}
const resp = await GetDeviceTokens(true) // Only get active devices
const result = await resp.json()
return result.res || []
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
})
return {
data,
error,
isLoading,
refetch: () => queryClient.invalidateQueries(['deviceTokens']),
}
}

View File

@@ -72,7 +72,6 @@ export const TASK_COLOR = {
PENDING_REVIEW: '#8B6CE1',
// For the calendar
OVERDUE: '#F03A47',
TODAY: '#ffc107',
TOMORROW: '#4ec1a2',
NEXT_7_DAYS: '#00bcd4',

126
src/utils/FeatureToggle.js Normal file
View File

@@ -0,0 +1,126 @@
export const FEATURES = {
OFFLINE_MODE: 'experimental_feature_offline_mode',
}
/**
* Get the current state of a feature flag from localStorage
* @param {string} featureKey - The feature key from FEATURES constant
* @param {boolean} defaultValue - Default value if feature is not set (default: false)
* @returns {boolean} - Whether the feature is enabled
*/
export const isFeatureEnabled = (featureKey, defaultValue = false) => {
try {
const value = localStorage.getItem(featureKey)
if (value === 'true') return true
if (value === 'false') return false
if (value === null || value === undefined) return defaultValue
return Boolean(value)
} catch (error) {
console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error)
return defaultValue
}
}
/**
* Set the state of a feature flag in localStorage
* @param {string} featureKey - The feature key from FEATURES constant
* @param {boolean} enabled - Whether to enable the feature
*/
export const setFeatureEnabled = (featureKey, enabled) => {
try {
localStorage.setItem(featureKey, enabled.toString())
} catch (error) {
console.error(
`FeatureToggle: Error setting feature "${featureKey}":`,
error,
)
}
}
export const toggleFeature = featureKey => {
const currentState = isFeatureEnabled(featureKey)
const newState = !currentState
setFeatureEnabled(featureKey, newState)
return newState
}
export const getAllFeatureStates = () => {
const states = {}
Object.entries(FEATURES).forEach(([name, key]) => {
states[name] = isFeatureEnabled(key)
})
return states
}
export const clearAllFeatures = () => {
try {
Object.values(FEATURES).forEach(featureKey => {
localStorage.removeItem(featureKey)
})
} catch (error) {
console.error('FeatureToggle: Error clearing features:', error)
}
}
/**
* Check if the current instance is the official donetick.com service
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
*/
export const isOfficialDonetickInstance = async () => {
try {
// Import here to avoid circular dependencies
const { Preferences } = await import('@capacitor/preferences')
const { API_URL } = await import('../Config')
// Get custom server URL from preferences
const { value: customServerUrl } = await Preferences.get({
key: 'customServerUrl',
})
// Use custom URL if set, otherwise fall back to API_URL
const serverUrl = customServerUrl || API_URL
// Check if the server URL contains donetick.com
return serverUrl.toLowerCase().includes('donetick.com')
} catch (error) {
console.warn('FeatureToggle: Error checking server instance:', error)
// Default to false for safety (self-hosted assumption)
return false
}
}
/**
* Synchronous version that checks based on current API manager state
* Note: This requires apiManager to be initialized first
* @returns {boolean} - Whether this is the official donetick.com instance
*/
export const isOfficialDonetickInstanceSync = () => {
try {
// Import here to avoid circular dependencies
const { apiManager } = require('../utils/TokenManager')
const currentApiUrl = apiManager.getApiURL()
// Check if the API URL contains donetick.com
return currentApiUrl.toLowerCase().includes('donetick.com')
} catch (error) {
console.warn('FeatureToggle: Error checking server instance (sync):', error)
// Default to false for safety (self-hosted assumption)
return true
}
}
// Export default object for easier imports
export default {
FEATURES,
isFeatureEnabled,
setFeatureEnabled,
toggleFeature,
getAllFeatureStates,
clearAllFeatures,
isOfficialDonetickInstance,
isOfficialDonetickInstanceSync,
}

View File

@@ -0,0 +1,89 @@
import { Capacitor } from '@capacitor/core'
/**
* Utility functions for handling safe area insets consistently across the app
*/
/**
* Get the appropriate bottom value that accounts for safe area insets
* @param {number|string} baseBottom - The base bottom value (default: 0)
* @param {number|string} extraPadding - Additional padding to add (default: 0)
* @returns {string} - CSS calc() expression for bottom positioning
*/
export const getSafeBottom = (baseBottom = 0, extraPadding = 0) => {
const base = typeof baseBottom === 'number' ? `${baseBottom}px` : baseBottom
const extra =
typeof extraPadding === 'number' ? `${extraPadding}px` : extraPadding
if (Capacitor.getPlatform() === 'android') {
if (extraPadding) {
return `calc(var(--safe-area-inset-bottom, 0px) + ${base} + ${extra})`
}
return `calc(var(--safe-area-inset-bottom, 0px) + ${base})`
}
// For iOS and web, safe area is already handled by the system
if (extraPadding) {
return `calc(${base} + ${extra})`
}
return base
}
/**
* Get safe area padding for bottom elements
* @param {number|string} extraPadding - Additional padding to add
* @returns {string} - CSS calc() expression for padding
*/
export const getSafeBottomPadding = (extraPadding = 0) => {
const extra =
typeof extraPadding === 'number' ? `${extraPadding * 8}px` : extraPadding
if (Capacitor.getPlatform() === 'android') {
if (extraPadding) {
return `calc(var(--safe-area-inset-bottom, 0px) + ${extra})`
}
return `var(--safe-area-inset-bottom, 0px)`
}
return extra || '0px'
}
/**
* Get safe area styles object for common bottom-positioned elements
* @param {object} options - Configuration options
* @param {number|string} options.bottom - Bottom position value
* @param {number|string} options.padding - Additional padding
* @param {'fixed'|'absolute'|'sticky'} options.position - Position type
* @returns {object} - Style object
*/
export const getSafeBottomStyles = ({
bottom = 0,
padding = 0,
position = 'fixed',
} = {}) => {
return {
position,
bottom: getSafeBottom(bottom, padding),
}
}
/**
* Hook-like function to get safe area values for use in components
* @returns {object} - Object with safe area utility functions
*/
export const useSafeArea = () => {
return {
getSafeBottom,
getSafeBottomPadding,
getSafeBottomStyles,
isAndroid: Capacitor.getPlatform() === 'android',
isNative: Capacitor.isNativePlatform(),
}
}
export default {
getSafeBottom,
getSafeBottomPadding,
getSafeBottomStyles,
useSafeArea,
}

View File

@@ -40,6 +40,7 @@ import {
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import Priorities from '../../utils/Priorities.jsx'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
@@ -1210,6 +1211,7 @@ const ChoreEdit = () => {
left: 0,
right: 0,
p: 2, // padding
paddingBottom: getSafeBottomPadding(2), // safe area padding for iOS
display: 'flex',
justifyContent: 'flex-end',
gap: 2,

View File

@@ -36,6 +36,7 @@ import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import {
ApproveChore,
DeleteChore,
@@ -79,6 +80,7 @@ const ChoreCard = ({
const [confirmModelConfig, setConfirmModelConfig] = React.useState({})
const [isNFCModalOpen, setIsNFCModalOpen] = React.useState(false)
const [isNudgeModalOpen, setIsNudgeModalOpen] = React.useState(false)
const [isOfficialInstance, setIsOfficialInstance] = React.useState(false)
const navigate = useNavigate()
const [isPendingCompletion, setIsPendingCompletion] = React.useState(false)
@@ -107,6 +109,14 @@ const ChoreCard = ({
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0)
}
checkTouchDevice()
// Check if this is the official donetick.com instance
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
} catch (error) {
console.warn('Error checking instance type:', error)
setIsOfficialInstance(false)
}
}, [])
const handleDelete = () => {
@@ -826,23 +836,25 @@ const ChoreCard = ({
<Edit sx={{ fontSize: 20 }} />
</IconButton>
<IconButton
variant='soft'
color='warning'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
setIsNudgeModalOpen(true)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Notifications sx={{ fontSize: 20 }} />
</IconButton>
{isOfficialInstance && (
<IconButton
variant='soft'
color='warning'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
setIsNudgeModalOpen(true)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Notifications sx={{ fontSize: 20 }} />
</IconButton>
)}
<IconButton
variant='soft'

View File

@@ -35,6 +35,7 @@ import {
getPriorityColor,
getTextColorFromBackgroundColor,
} from '../../utils/Colors.jsx'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import {
ApproveChore,
DeleteChore,
@@ -78,6 +79,7 @@ const CompactChoreCard = ({
const [confirmModelConfig, setConfirmModelConfig] = React.useState({})
const [isNFCModalOpen, setIsNFCModalOpen] = React.useState(false)
const [isNudgeModalOpen, setIsNudgeModalOpen] = React.useState(false)
const [isOfficialInstance, setIsOfficialInstance] = React.useState(false)
const navigate = useNavigate()
const [isPendingCompletion, setIsPendingCompletion] = React.useState(false)
@@ -107,6 +109,14 @@ const CompactChoreCard = ({
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0)
}
checkTouchDevice()
// Check if this is the official donetick.com instance
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
} catch (error) {
console.warn('Error checking instance type:', error)
setIsOfficialInstance(false)
}
}, [])
// Swipe gesture handlers
@@ -441,7 +451,10 @@ const CompactChoreCard = ({
const handleNudge = async ({ choreId, message, notifyAllAssignees }) => {
try {
const response = await NudgeChore(choreId, { message, notifyAllAssignees })
const response = await NudgeChore(choreId, {
message,
notifyAllAssignees,
})
if (response.ok) {
const data = await response.json()
showNotification({
@@ -823,23 +836,25 @@ const CompactChoreCard = ({
<Edit sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='soft'
color='warning'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
setIsNudgeModalOpen(true)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Notifications sx={{ fontSize: 16 }} />
</IconButton>
{isOfficialInstance && (
<IconButton
variant='soft'
color='warning'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
setIsNudgeModalOpen(true)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Notifications sx={{ fontSize: 16 }} />
</IconButton>
)}
<IconButton
variant='soft'

View File

@@ -58,6 +58,7 @@ import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
@@ -2023,9 +2024,8 @@ const MyChores = () => {
// variant='outlined'
sx={{
position: 'fixed',
bottom: 0,
bottom: getSafeBottom(10, 10),
left: 10,
p: 2, // padding
display: 'flex',
justifyContent: 'flex-end',
gap: 2,

View File

@@ -21,6 +21,7 @@ import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../utils/Colors'
import { DeleteLabel } from '../../utils/Fetcher'
import { getSafeBottom, getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useLabels } from './LabelQueries'
@@ -221,7 +222,7 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
bottom: getSafeBottom(),
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
@@ -605,10 +606,8 @@ const LabelView = () => {
<Box
sx={{
position: 'fixed',
bottom: 0,
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
left: 10,
p: 2,
display: 'flex',
justifyContent: 'flex-end',
gap: 2,

View File

@@ -1,4 +1,5 @@
import {
Alert,
Box,
Button,
FormControl,
@@ -10,17 +11,23 @@ import {
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
function NudgeModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [message, setMessage] = useState('')
const [notifyAllAssignees, setNotifyAllAssignees] = useState(false)
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
const handleAction = useCallback(
isConfirmed => {
if (isConfirmed) {
config.onConfirm({ choreId: config.choreId, message, notifyAllAssignees })
config.onConfirm({
choreId: config.choreId,
message,
notifyAllAssignees,
})
} else {
config.onClose()
}
@@ -33,6 +40,14 @@ function NudgeModal({ config }) {
if (config?.isOpen) {
setMessage('')
setNotifyAllAssignees(false)
// Check if this is the official donetick.com instance
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
} catch (error) {
console.warn('Error checking instance type:', error)
setIsOfficialInstance(false)
}
}
}, [config?.isOpen])
@@ -101,6 +116,20 @@ function NudgeModal({ config }) {
customize the message and choose who gets notified.
</Typography>
{!isOfficialInstance && (
<Alert color='warning' sx={{ mb: 2 }}>
<Typography level='body-sm'>
<strong>Heads up!</strong>This feature avaiable on Donetick Cloud!
Since you're using a self-hosted instance, nudges will requires you
to setup Google cloud account and Firebase Cloud Messaging (FCM).
and build the Android or the iOS app by yourself.
<br />
Will update if we come up with a solution to make this easier for to
configure. for selfhosters
</Typography>
</Alert>
)}
<FormControl mb={2}>
<FormLabel>Custom Message (optional)</FormLabel>
<Textarea
@@ -130,6 +159,7 @@ function NudgeModal({ config }) {
<Button
size='lg'
onClick={() => handleAction(true)}
disabled={!isOfficialInstance}
fullWidth
color='primary'
endDecorator={

View File

@@ -1,6 +1,7 @@
import { Capacitor } from '@capacitor/core'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { Android, Apple } from '@mui/icons-material'
import {
Box,
Button,
@@ -19,17 +20,19 @@ import { useEffect, useState } from 'react'
import { PushNotifications } from '@capacitor/push-notifications'
import { registerPushNotifications } from '../../CapacitorListener'
import { useUserProfile } from '../../queries/UserQueries'
import { useDeviceTokens, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import {
UnregisterDeviceToken,
UpdateNotificationTarget,
UpdateUserDetails,
} 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' })
@@ -68,6 +71,7 @@ const NotificationSetting = () => {
const [preDueNotification, setPreDueNotification] = useState(false)
const [naggingNotification, setNaggingNotification] = useState(false)
const [pushNotification, setPushNotification] = useState(false)
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
useEffect(() => {
getNotificationPreferences().then(resp => {
@@ -83,6 +87,14 @@ const NotificationSetting = () => {
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)
}
}, [])
const [notificationTarget, setNotificationTarget] = useState(
@@ -186,6 +198,34 @@ const NotificationSetting = () => {
</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>
{[
@@ -242,90 +282,151 @@ const NotificationSetting = () => {
))}
</Card>
)}
<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()
{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)
}
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)
}
} else {
setPushNotification(false)
}
}}
color={pushNotification ? 'success' : 'neutral'}
variant={pushNotification ? 'solid' : 'outlined'}
endDecorator={pushNotification ? 'On' : 'Off'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
}}
color={pushNotification ? 'success' : 'neutral'}
variant={pushNotification ? 'solid' : 'outlined'}
endDecorator={pushNotification ? 'On' : 'Off'}
slotProps={{
endDecorator: {
sx: {
minWidth: 24,
},
},
},
}}
/>
</FormControl>
}}
/>
</FormControl>
)}
{isOfficialInstance && (
<>
<Typography level='h4' sx={{ mt: 2 }}>
Registered Devices
</Typography>
<Divider />
<Typography level='body-md' sx={{ mb: 2 }}>
Devices registered to receive push notifications for your account
</Typography>
{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>
)}
</>
)}
<Button
variant='soft'
color='primary'
sx={{
width: '210px',
mb: 1,
}}
onClick={() => {
// schedule a local notification in 5 seconds
LocalNotifications.schedule({
notifications: [
{
title: 'Task Reminder',
body: 'You have a task due soon',
id: 1,
schedule: { at: new Date(Date.now() + 3000) },
sound: null,
attachments: null,
actionTypeId: '',
extra: null,
},
],
})
}}
>
Test Notification{' '}
</Button>
<Typography level='h3'>Custom Notification</Typography>
<Divider />
<Typography level='body-md'>
@@ -338,19 +439,22 @@ const NotificationSetting = () => {
onClick={event => {
event.preventDefault()
if (chatID !== 0) {
// Turning off custom notification - call API to disable
setChatID(0)
} else {
setChatID('')
UpdateUserDetails({
chatID: Number(0),
setNotificationTarget('0')
UpdateNotificationTarget({
target: '',
type: 0,
}).then(resp => {
resp.json().then(data => {
if (resp.status === 200) {
refetchUserProfile()
})
}
})
} else {
// Turning on custom notification - just set state, user will use Save button
setChatID('')
setNotificationTarget('1') // Default to Telegram
}
setNotificationTarget('0')
handleSave()
}}
color={chatID !== 0 ? 'success' : 'neutral'}
variant={chatID !== 0 ? 'solid' : 'outlined'}

View File

@@ -36,6 +36,8 @@ import {
UpdateTimeSession,
} from '../../utils/Fetcher'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getSafeBottom } from '../../utils/SafeAreaUtils'
import LoadingComponent from '../components/Loading'
const TimerDetails = () => {
const { choreId } = useParams()
@@ -516,6 +518,10 @@ const TimerDetails = () => {
resetAllSwipes()
}, [editingSessions])
if (loading || isCircleMembersLoading) {
return <LoadingComponent />
}
return (
<Container maxWidth='lg' sx={{ py: 2 }}>
{/* Header */}
@@ -1126,7 +1132,7 @@ const TimerDetails = () => {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
bottom: getSafeBottom(),
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
@@ -1560,7 +1566,7 @@ const TimerDetails = () => {
disabled={loading}
sx={{
position: 'fixed',
bottom: 16,
bottom: getSafeBottom(16, 16),
left: 16,
width: 56,
height: 56,

View File

@@ -21,9 +21,10 @@ import {
Weekend,
} from '@mui/icons-material'
import { Divider, IconButton, Menu, MenuItem, Tooltip } from '@mui/joy'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNotification } from '../../service/NotificationProvider'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import {
ArchiveChore,
DeleteChore,
@@ -50,10 +51,21 @@ const ChoreActionMenu = ({
variant = 'soft',
}) => {
const [anchorEl, setAnchorEl] = React.useState(null)
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
const menuRef = React.useRef(null)
const navigate = useNavigate()
const { showError } = useNotification()
// Check if this is the official donetick.com instance
useEffect(() => {
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
} catch (error) {
console.warn('Error checking instance type:', error)
setIsOfficialInstance(false)
}
}, [])
useEffect(() => {
const handleMenuOutsideClick = event => {
if (
@@ -317,16 +329,18 @@ const ChoreActionMenu = ({
<RecordVoiceOver />
Delegate to someone else
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onNudge?.()
handleMenuClose()
}}
>
<Notifications />
Send nudge
</MenuItem>
{isOfficialInstance && (
<MenuItem
onClick={e => {
e.stopPropagation()
onNudge?.()
handleMenuClose()
}}
>
<Notifications />
Send nudge
</MenuItem>
)}
<Divider />
<MenuItem
onClick={e => {