feat: add device token management and safe area utilities; enhance notification settings and chore components
This commit is contained in:
@@ -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']),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
126
src/utils/FeatureToggle.js
Normal 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,
|
||||
}
|
||||
89
src/utils/SafeAreaUtils.js
Normal file
89
src/utils/SafeAreaUtils.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user