From ea4edd46753a8bef3dd574493d1d64003bfa6123 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Thu, 19 Mar 2026 18:04:24 +0200 Subject: [PATCH 1/4] added notification qortalRequests --- package-lock.json | 8 +- package.json | 2 +- src/components/Feed.tsx | 2 + src/components/Sidebar.tsx | 131 +++++++++++- src/components/SocialApp.tsx | 14 ++ src/hooks/useFollowingStorage.ts | 141 ++++++------ .../useInitializeNotificationPermission.ts | 78 +++++++ .../useMentionNotificationRegistration.ts | 201 ++++++++++++++++++ src/state/global/notifications.ts | 13 ++ src/styles/Layout.tsx | 10 +- 10 files changed, 510 insertions(+), 90 deletions(-) create mode 100644 src/hooks/useInitializeNotificationPermission.ts create mode 100644 src/hooks/useMentionNotificationRegistration.ts diff --git a/package-lock.json b/package-lock.json index 719066a..ac123ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "jotai": "^2.12.4", "localforage": "^1.10.0", "mediainfo.js": "^0.3.6", - "qapp-core": "^1.0.76", + "qapp-core": "^1.0.77", "quill-image-resize-module-react": "^3.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -5177,9 +5177,9 @@ } }, "node_modules/qapp-core": { - "version": "1.0.76", - "resolved": "https://registry.npmjs.org/qapp-core/-/qapp-core-1.0.76.tgz", - "integrity": "sha512-Jt7f+ma3jM7NorrS6DRrbO9eP24UdEybqL9K4reXfEm02g1DDfIQotbYKxHiLEYojdsqgAOFt3MX0K/S8ZyMQg==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/qapp-core/-/qapp-core-1.0.77.tgz", + "integrity": "sha512-VE1DUX/mjvmQIOCCMUMFgg5PtMUPVHmXsgDsYQC2JtcWUlFAYi85k3od6KrHSwgAcI9KJV3m9F+XoBdOByT0bg==", "license": "MIT", "dependencies": { "@tanstack/react-virtual": "^3.13.2", diff --git a/package.json b/package.json index 3029de4..f37406a 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "jotai": "^2.12.4", "localforage": "^1.10.0", "mediainfo.js": "^0.3.6", - "qapp-core": "^1.0.76", + "qapp-core": "^1.0.77", "quill-image-resize-module-react": "^3.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index cb69e9e..9898544 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -743,6 +743,7 @@ export function Feed({ searchNewData={undefined} onNewData={onNewData} ref={helperListMethodsRef} + isLoading={isLoadingChangeFeedType} /> ) : ( @@ -778,6 +779,7 @@ export function Feed({ } onNewData={onNewData} ref={helperListMethodsRef} + isLoading={isLoadingChangeFeedType} /> )} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 18988e6..4efff82 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -5,13 +5,17 @@ import PersonIcon from '@mui/icons-material/Person'; import NotificationsIcon from '@mui/icons-material/Notifications'; import BlockIcon from '@mui/icons-material/Block'; import GroupsIcon from '@mui/icons-material/Groups'; +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; import { Badge } from '@mui/material'; import { NameSwitcher } from './NameSwitcher'; import { WhatsHappening } from './WhatsHappening'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { notificationsAtom, hasUnreadNotificationsAtom, + hubNotificationSupportedAtom, + notificationPermissionAtom, + declinedNotificationPermissionAtom, } from '../state/global/notifications'; import { isPublicNodeAtom } from '../state/global/system'; import { useGlobal } from 'qapp-core'; @@ -163,6 +167,88 @@ const TweetButtonIcon = styled('span')(({ theme }) => ({ }, })); +const EnableNotificationsCard = styled('button')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1.5), + padding: theme.spacing(1.5, 2), + borderRadius: '16px', + border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(29, 155, 240, 0.35)' : 'rgba(29, 155, 240, 0.3)'}`, + background: + theme.palette.mode === 'dark' + ? 'linear-gradient(135deg, rgba(29,155,240,0.08) 0%, rgba(29,155,240,0.03) 100%)' + : 'linear-gradient(135deg, rgba(29,155,240,0.07) 0%, rgba(29,155,240,0.02) 100%)', + cursor: 'pointer', + textAlign: 'left', + width: '100%', + transition: 'all 0.25s ease', + '&:hover': { + borderColor: theme.palette.primary.main, + background: + theme.palette.mode === 'dark' + ? 'linear-gradient(135deg, rgba(29,155,240,0.18) 0%, rgba(29,155,240,0.08) 100%)' + : 'linear-gradient(135deg, rgba(29,155,240,0.14) 0%, rgba(29,155,240,0.06) 100%)', + transform: 'translateY(-1px)', + boxShadow: + theme.palette.mode === 'dark' + ? '0 4px 16px rgba(29,155,240,0.2)' + : '0 4px 16px rgba(29,155,240,0.15)', + }, + '&:active': { + transform: 'scale(0.98)', + }, + [theme.breakpoints.down('md')]: { + width: '52px', + height: '52px', + justifyContent: 'center', + padding: theme.spacing(1.5), + borderRadius: '50%', + }, +})); + +const EnableNotificationsIconWrap = styled('span')(() => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + '@keyframes bellRing': { + '0%, 100%': { transform: 'rotate(0deg)' }, + '15%': { transform: 'rotate(15deg)' }, + '30%': { transform: 'rotate(-12deg)' }, + '45%': { transform: 'rotate(10deg)' }, + '60%': { transform: 'rotate(-8deg)' }, + '75%': { transform: 'rotate(5deg)' }, + }, + '& svg': { + color: '#1d9bf0', + fontSize: '22px', + animation: 'bellRing 3s ease-in-out infinite', + }, +})); + +const EnableNotificationsText = styled('span')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: '1px', + [theme.breakpoints.down('md')]: { + display: 'none', + }, +})); + +const EnableNotificationsTitle = styled('span')(({ theme }) => ({ + fontSize: '14px', + fontWeight: 600, + color: theme.palette.primary.main, + lineHeight: 1.3, +})); + +const EnableNotificationsSubtitle = styled('span')(({ theme }) => ({ + fontSize: '11px', + fontWeight: 400, + color: theme.palette.text.secondary, + lineHeight: 1.3, +})); + interface SidebarProps { onNavigate?: (page: string) => void; onTweet?: () => void; @@ -172,6 +258,8 @@ interface SidebarProps { activePage?: string; } +declare const qortalRequest: (params: any) => Promise; + export function Sidebar({ onNavigate = () => {}, onTweet = () => {}, @@ -181,6 +269,31 @@ export function Sidebar({ const notifications = useAtomValue(notificationsAtom); const [hasUnread, setHasUnread] = useAtom(hasUnreadNotificationsAtom); const isPublicNode = useAtomValue(isPublicNodeAtom); + const hubSupported = useAtomValue(hubNotificationSupportedAtom); + const notificationPermission = useAtomValue(notificationPermissionAtom); + const setNotificationPermission = useSetAtom(notificationPermissionAtom); + const [, setDeclinedMap] = useAtom(declinedNotificationPermissionAtom); + + const address = auth?.address; + const showEnableNotifications = + hubSupported && !notificationPermission && !!address; + + const handleEnableHubNotifications = async () => { + if (!address) return; + try { + const result = await qortalRequest({ action: 'NOTIFICATION_PERMISSION' }); + if (result) { + setNotificationPermission(true); + setDeclinedMap((prev) => { + const next = { ...prev }; + delete next[address]; + return next; + }); + } + } catch (error) { + console.error('Notification permission declined or errored:', error); + } + }; // Check for unread notifications useEffect(() => { @@ -315,6 +428,22 @@ export function Sidebar({ + + {showEnableNotifications && ( + + + + + + + Enable Hub notifications + + + Get notified about mentions & replies + + + + )} + diff --git a/src/components/SocialApp.tsx b/src/components/SocialApp.tsx index 69c6034..f4e2ed4 100644 --- a/src/components/SocialApp.tsx +++ b/src/components/SocialApp.tsx @@ -61,6 +61,8 @@ import { handleUnfollowUser, } from '../utils/followingHelpers'; +import { updateFollowingPostsNotification } from '../hooks/useMentionNotificationRegistration'; + // Declare qortalRequest as a global function (provided by Qortal runtime) /** @@ -976,6 +978,12 @@ export function SocialApp({ userName = 'User', userAvatar }: SocialAppProps) { showSuccess(`You are now following @${targetUserName}`); // Refetch the follows list to update the UI await refetchFollows(); + updateFollowingPostsNotification( + auth.name, + identifierOperations, + targetUserName, + 'follow' + ); } catch (error) { console.error('Error following user:', error); showError( @@ -1015,6 +1023,12 @@ export function SocialApp({ userName = 'User', userAvatar }: SocialAppProps) { showSuccess(`You unfollowed @${targetUserName}`); // Refetch the follows list to update the UI await refetchFollows(); + updateFollowingPostsNotification( + auth.name, + identifierOperations, + targetUserName, + 'unfollow' + ); } catch (error) { console.error('Error unfollowing user:', error); showError( diff --git a/src/hooks/useFollowingStorage.ts b/src/hooks/useFollowingStorage.ts index 4728b20..a303a16 100644 --- a/src/hooks/useFollowingStorage.ts +++ b/src/hooks/useFollowingStorage.ts @@ -4,17 +4,14 @@ import { loadFollowingList, saveFollowingList, addFollowedUser, - getFollowedUserByIdentifier, - FollowedUser, } from '../utils/followingStorageDB'; // Configuration -const BATCH_SIZE = 5; // Number of names to fetch at a time const FETCH_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes const VALIDATION_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes /** - * Hook to progressively sync followed user names in the background using IndexedDB. + * Hook to sync followed user names in the background using IndexedDB. * * This hook runs background sync operations and DOES NOT return any reactive state. * It will not cause re-renders in the component that uses it. @@ -22,8 +19,7 @@ const VALIDATION_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes * Use `useFollowingListDB` hook to read the synced names. * * Features: - * - Fetches the real followed user names from resource data (not just metadata) - * - Fetches 5 names at a time every 3 minutes + * - Fetches all followed user names in a single batched POST request * - Validates identifiers still exist every 30 minutes * - Persists data in IndexedDB keyed by authenticated user's name * - Automatically initializes on mount @@ -72,93 +68,76 @@ export function useFollowingStorage(): void { ); /** - * Fetches the actual data for a resource to get the followed name + * Fetches all pending identifiers in a single batched POST request, + * decodes each base64 data payload, and saves resolved names to IndexedDB. */ - const fetchResourceData = useCallback( - async ( - identifier: string, - publisherName: string - ): Promise<{ followedName: string } | null> => { - try { - const response = await fetch( - `/arbitrary/DOCUMENT/${publisherName}/${identifier}` + const fetchBatch = useCallback(async (userName: string) => { + if (!isMountedRef.current || isFetchingRef.current) return; + + const pendingArray = Array.from(pendingIdentifiersRef.current); + if (pendingArray.length === 0) return; + + isFetchingRef.current = true; + + try { + const response = await fetch('/arbitrary/resources/onchain/data', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + pendingArray.map((identifier) => ({ + service: 'DOCUMENT', + name: userName, + identifier, + })) + ), + }); + + if (!response.ok) { + console.warn( + `Batch fetch failed: ${response.status} ${response.statusText}` ); - - if (!response.ok) { - console.warn( - `Failed to fetch resource data: ${response.status} ${response.statusText}` - ); - return null; - } - - const data = await response.json(); - - if (!data.followedName) { - console.warn('Resource data missing followedName:', data); - return null; - } - - return data; - } catch (error) { - console.error('Error fetching resource data:', error); - return null; + return; } - }, - [] - ); - /** - * Fetches a batch of names from pending identifiers - */ - const fetchBatch = useCallback( - async (userName: string) => { - if (!isMountedRef.current || isFetchingRef.current) return; + const results: Array<{ + service: string; + name: string; + identifier: string; + data: string | null; + error: string | null; + }> = await response.json(); - // Get the next batch of identifiers to process - const pendingArray = Array.from(pendingIdentifiersRef.current); - if (pendingArray.length === 0) return; + if (!isMountedRef.current) return; - isFetchingRef.current = true; + const now = Date.now(); - try { - const batch = pendingArray.slice(0, BATCH_SIZE); + for (const result of results) { + if (!result.data || result.error) continue; - for (const identifier of batch) { - if (!isMountedRef.current) break; + try { + const json = JSON.parse(atob(result.data)); + if (!json.followedName) continue; - // Check if we already have this identifier - const existing = await getFollowedUserByIdentifier( - userName, - identifier + await addFollowedUser(userName, { + name: json.followedName, + identifier: result.identifier, + lastValidated: now, + }); + } catch { + console.warn( + 'Failed to decode follow data for identifier:', + result.identifier ); - - if (existing) { - pendingIdentifiersRef.current.delete(identifier); - continue; - } - - // Fetch the actual data - const data = await fetchResourceData(identifier, userName); - - if (data?.followedName && isMountedRef.current) { - await addFollowedUser(userName, { - name: data.followedName, - identifier, - lastValidated: Date.now(), - }); - } - - // Remove from pending - pendingIdentifiersRef.current.delete(identifier); } - } catch (error) { - console.error('Error fetching batch:', error); - } finally { - isFetchingRef.current = false; + + pendingIdentifiersRef.current.delete(result.identifier); } - }, - [fetchResourceData] - ); + } catch (error) { + console.error('Error fetching batch:', error); + } finally { + isFetchingRef.current = false; + } + }, []); /** * Validates that stored identifiers still exist in the current resources diff --git a/src/hooks/useInitializeNotificationPermission.ts b/src/hooks/useInitializeNotificationPermission.ts new file mode 100644 index 0000000..8a05d68 --- /dev/null +++ b/src/hooks/useInitializeNotificationPermission.ts @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { useAtom, useSetAtom } from 'jotai'; +import { useGlobal } from 'qapp-core'; +import { + notificationPermissionAtom, + declinedNotificationPermissionAtom, + hubNotificationSupportedAtom, +} from '../state/global/notifications'; + +declare const qortalRequest: (params: any) => Promise; + +/** + * Checks SHOW_ACTIONS once on mount to set hubNotificationSupportedAtom. + * Then, when the user is authenticated, auto-prompts for NOTIFICATION_PERMISSION + * unless they have already declined (persisted by address in localStorage). + * On accept, clears the address from declinedMap in case it was set previously. + */ +export const useInitializeNotificationPermission = () => { + const { auth } = useGlobal(); + const setNotificationPermission = useSetAtom(notificationPermissionAtom); + const setHubNotificationSupported = useSetAtom(hubNotificationSupportedAtom); + const [declinedMap, setDeclinedMap] = useAtom(declinedNotificationPermissionAtom); + + // Check once on mount whether the client supports NOTIFICATION_PERMISSION + useEffect(() => { + const checkSupport = async () => { + try { + const actions: string[] = await qortalRequest({ action: 'SHOW_ACTIONS' }); + if (Array.isArray(actions) && actions.includes('NOTIFICATION_PERMISSION')) { + setHubNotificationSupported(true); + } + } catch { + // Client doesn't support SHOW_ACTIONS — treat as unsupported + } + }; + checkSupport(); + }, [setHubNotificationSupported]); + + useEffect(() => { + const address = auth?.address; + if (!address) return; + + // Already declined — skip the auto-prompt, let the sidebar button handle it + if (declinedMap[address]) return; + + const executeNotificationPermission = async () => { + let supported = false; + try { + const actions: string[] = await qortalRequest({ action: 'SHOW_ACTIONS' }); + supported = Array.isArray(actions) && actions.includes('NOTIFICATION_PERMISSION'); + setHubNotificationSupported(supported); + } catch { + // Not supported + } + + if (!supported) return; + + try { + const result = await qortalRequest({ action: 'NOTIFICATION_PERMISSION' }); + if (result) { + setNotificationPermission(true); + setDeclinedMap((prev) => { + const next = { ...prev }; + delete next[address]; + return next; + }); + } + } catch (error) { + // Declined — persist so we never auto-prompt again + setDeclinedMap((prev) => ({ ...prev, [address]: true })); + console.error('Notification permission declined or errored:', error); + } + }; + + executeNotificationPermission(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auth?.address]); +}; diff --git a/src/hooks/useMentionNotificationRegistration.ts b/src/hooks/useMentionNotificationRegistration.ts new file mode 100644 index 0000000..72117f7 --- /dev/null +++ b/src/hooks/useMentionNotificationRegistration.ts @@ -0,0 +1,201 @@ +import { useEffect, useRef } from 'react'; +import { useAtomValue } from 'jotai'; +import { useGlobal, EnumCollisionStrength } from 'qapp-core'; +import { notificationPermissionAtom } from '../state/global/notifications'; +import { ENTITY_POST, ENTITY_ROOT, ENTITY_REPLY } from '../constants/qdn'; +import { loadFollowingList } from '../utils/followingStorageDB'; + +declare const qortalRequest: (params: any) => Promise; + +const APP_NAME = 'Quitter'; +const SERVICE_DOCUMENT = 'DOCUMENT'; + +const FOLLOWING_POSTS_NOTIFICATION_ID = (userName: string) => + `quitter-following-posts-${userName}`; + +/** + * Updates the "following posts" push notification to match the current follow list. + * Call after follow/unfollow. Pass targetUserName and action to include or exclude + * that user (no duplicates when adding). If the user has no followed users, removes the notification. + */ +export async function updateFollowingPostsNotification( + userName: string, + identifierOperations: { + buildSearchPrefix: (a: string, b: string) => Promise; + }, + targetUserName?: string, + action?: 'follow' | 'unfollow' +): Promise { + try { + const prefix = await identifierOperations.buildSearchPrefix( + ENTITY_POST, + ENTITY_ROOT + ); + if (!prefix) return; + + let followedNames = (await loadFollowingList(userName)).map((u) => u.name); + + if (targetUserName !== undefined && action === 'follow') { + if (!followedNames.includes(targetUserName)) { + followedNames = [...followedNames, targetUserName]; + } + } else if (targetUserName !== undefined && action === 'unfollow') { + followedNames = followedNames.filter((n) => n !== targetUserName); + } + + if (followedNames.length > 0) { + await qortalRequest({ + action: 'NOTIFICATION_ADD', + notifications: [ + { + notificationId: FOLLOWING_POSTS_NOTIFICATION_ID(userName), + link: `qortal://APP/${APP_NAME}/post/{name}/{identifier}`, + image: '/arbitrary/THUMBNAIL/Quitter/qortal_avatar?async=true', + message: { en: '{name} posted' }, + filters: { + service: SERVICE_DOCUMENT, + identifier: prefix, + excludeBlocked: true, + mode: 'ALL' as const, + names: followedNames, + }, + }, + ], + }); + } else { + await qortalRequest({ + action: 'NOTIFICATION_REMOVE', + notificationIds: [FOLLOWING_POSTS_NOTIFICATION_ID(userName)], + }); + } + } catch (err) { + console.error('Failed to update following-posts notification:', err); + } +} + +/** + * Global hook: when notification permission is granted, registers NOTIFICATION_ADD + * so the user receives push notifications for: + * - mentions in posts + * - main posts from users they follow + * - replies to their posts + * Run once in Layout. + */ +export function useMentionNotificationRegistration() { + const { auth, identifierOperations } = useGlobal(); + const notificationPermission = useAtomValue(notificationPermissionAtom); + const hasRequestedRef = useRef(false); + + useEffect(() => { + const userName = auth?.name; + if (!notificationPermission || !userName || !identifierOperations) { + return; + } + if (hasRequestedRef.current) return; + + let cancelled = false; + + (async () => { + try { + const hashedName = await identifierOperations.hashString( + userName, + EnumCollisionStrength.HIGH + ); + if (cancelled || !hashedName) return; + if (hasRequestedRef.current) return; + + const prefix = await identifierOperations.buildSearchPrefix( + ENTITY_POST, + ENTITY_ROOT + ); + if (cancelled) return; + if (hasRequestedRef.current) return; + + const replyPrefix = await identifierOperations.buildSearchPrefix( + ENTITY_REPLY, + '' + ); + if (cancelled) return; + if (hasRequestedRef.current) return; + + const followedUsers = await loadFollowingList(userName); + const followedNames = + followedUsers.length > 0 + ? followedUsers.map((u) => u.name) + : undefined; + + const mentionDescription = `~@${hashedName}~`; + const replyDescription = `~rply${hashedName}~`; + + const notifications: Array<{ + notificationId: string; + link: string; + image: string; + message: { en: string }; + filters: Record; + }> = [ + { + notificationId: `quitter-mention-${userName}`, + link: `qortal://APP/${APP_NAME}/post/{name}/{identifier}`, + image: '/arbitrary/THUMBNAIL/Quitter/qortal_avatar?async=true', + message: { en: '{name} mentioned you in a post' }, + filters: { + service: SERVICE_DOCUMENT, + identifier: prefix, + description: mentionDescription, + excludeBlocked: true, + mode: 'ALL' as const, + }, + }, + { + notificationId: `quitter-reply-${userName}`, + link: `qortal://APP/${APP_NAME}/post/{name}/{identifier}`, + image: '/arbitrary/THUMBNAIL/Quitter/qortal_avatar?async=true', + message: { en: '{name} replied to your post' }, + filters: { + service: SERVICE_DOCUMENT, + identifier: replyPrefix, + description: replyDescription, + excludeBlocked: true, + mode: 'ALL' as const, + }, + }, + ]; + + if (followedNames && followedNames.length > 0) { + notifications.push({ + notificationId: FOLLOWING_POSTS_NOTIFICATION_ID(userName), + link: `qortal://APP/${APP_NAME}/post/{name}/{identifier}`, + image: '/arbitrary/THUMBNAIL/Quitter/qortal_avatar?async=true', + message: { en: '{name} posted' }, + filters: { + service: SERVICE_DOCUMENT, + identifier: prefix, + excludeBlocked: true, + mode: 'ALL' as const, + names: followedNames, + }, + }); + } + await qortalRequest({ + action: 'NOTIFICATION_REMOVE', + }); + hasRequestedRef.current = true; + await qortalRequest({ + action: 'NOTIFICATION_ADD', + notifications, + }); + } catch (err) { + console.error('Failed to register notifications:', err); + hasRequestedRef.current = false; + if (!cancelled) { + console.error('Failed to register notifications:', err); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [notificationPermission, auth?.name, identifierOperations]); +} diff --git a/src/state/global/notifications.ts b/src/state/global/notifications.ts index 033569c..870df90 100644 --- a/src/state/global/notifications.ts +++ b/src/state/global/notifications.ts @@ -1,4 +1,5 @@ import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; export interface Notification { id: string; @@ -40,3 +41,15 @@ export const shownNotificationIdsAtom = atom([]); // Atom to track if there are unread notifications (for badge indicator) // Updated by Sidebar useEffect and cleared when notifications are viewed export const hasUnreadNotificationsAtom = atom(false); + +// Atom to track if the user has granted notification permission (via Qortal NOTIFICATION_PERMISSION) +export const notificationPermissionAtom = atom(false); + +// Atom to track if the connected Qortal client supports the NOTIFICATION_PERMISSION action +export const hubNotificationSupportedAtom = atom(false); + +// Persisted map of address -> true for users who have declined the notification permission prompt. +// Keyed by address so each account's choice is tracked independently. +export const declinedNotificationPermissionAtom = atomWithStorage< + Record +>('quitter-declined-notification-permission', {}); diff --git a/src/styles/Layout.tsx b/src/styles/Layout.tsx index d81c8bc..6864dbb 100644 --- a/src/styles/Layout.tsx +++ b/src/styles/Layout.tsx @@ -16,6 +16,8 @@ import { useGlobal } from 'qapp-core'; import { useFollowingStorage } from '../hooks/useFollowingStorage'; import { useNotificationStorage } from '../hooks/useNotificationStorage'; import { useInitializePublicNode } from '../hooks/useInitializePublicNode'; +import { useInitializeNotificationPermission } from '../hooks/useInitializeNotificationPermission'; +import { useMentionNotificationRegistration } from '../hooks/useMentionNotificationRegistration'; const LoadingContainer = styled(Box)(({ theme }) => ({ display: 'flex', @@ -56,9 +58,11 @@ const Layout = () => { // Initialize public node status useInitializePublicNode(); - const [hasProfile] = useAtom(hasProfileAtom); - const [isLoadingProfile] = useAtom(isLoadingProfileAtom); - const [profileName] = useAtom(profileNameAtom); + // Request notification permission when user is authenticated + useInitializeNotificationPermission(); + + // Register for push notifications when someone mentions the user + useMentionNotificationRegistration(); // Show loading indicator while authentication is in progress if (auth?.isLoadingUser) { From 7f4b49aa8496dd51e11cffc1de195f8eeb6961e2 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sun, 24 May 2026 22:23:59 +0300 Subject: [PATCH 2/4] disable subs for quitter itself, added langguages for notification and fix fetch following --- src/AppWrapper.tsx | 2 +- src/components/Feed.tsx | 31 ++++++---- src/components/MobileNavigation.tsx | 24 ++++---- src/components/NewPostInput.tsx | 19 +++++-- src/components/PostPage.tsx | 9 ++- src/components/Sidebar.tsx | 19 ++++--- src/components/SocialApp.tsx | 12 +++- src/components/UserFeed.tsx | 23 +++++--- src/constants/qdn.ts | 4 +- src/hooks/useFollowingStorage.ts | 39 ++++++++----- src/hooks/useGroupOwnerNames.ts | 9 +++ src/hooks/useInitializeOwnedGroups.ts | 8 +++ .../useMentionNotificationRegistration.ts | 57 +++++++++++++++++-- src/hooks/useOwnedGroups.ts | 8 +++ src/hooks/useReplyCount.ts | 11 +++- 15 files changed, 205 insertions(+), 70 deletions(-) diff --git a/src/AppWrapper.tsx b/src/AppWrapper.tsx index c1874e8..bc8315f 100644 --- a/src/AppWrapper.tsx +++ b/src/AppWrapper.tsx @@ -17,7 +17,7 @@ export const AppWrapper = () => { return ( (null); - // Access group owner primary names on the fly without dependency const groupOwnerNames = useAtomValue(groupOwnerPrimaryNamesAtom); const [primaryNamesGroup, setPrimaryNamesGroup] = useState( @@ -340,11 +340,10 @@ export function Feed({ if (isLoadingGroupOwnerNames) return; const buildPrefix = async () => { if (!identifierOperations) return; - if (memberGroups === null) return; + if (!DISABLE_SUBSCRIPTIONS && memberGroups === null) return; try { const followedNames = await fetchFollowedNames(); - setFollowingNames(followedNames); const prefix = await identifierOperations.buildSearchPrefix( ENTITY_POST, @@ -362,17 +361,24 @@ export function Feed({ ENTITY_REPOST, '' ); + setRepostSearchPrefix(repostPrefix); + if (DISABLE_SUBSCRIPTIONS) { + setGroupSearchPrefix(null); + setPrimaryNamesGroup([]); + setGroupSearchPrefixes([]); + return; + } + const groupPrefix = await identifierOperations.buildSearchPrefix( null, '', GROUP_PRIVATE ); - setRepostSearchPrefix(repostPrefix); setGroupSearchPrefix(groupPrefix); setPrimaryNamesGroup(groupOwnerNames); - const groupIds = Array.from(memberGroups.keys()); + const groupIds = Array.from(memberGroups?.keys() || []); if (groupIds.length > 0) { const groupSearchPrefixesResponses = await Promise.all( groupIds.map(async (groupId) => { @@ -391,7 +397,13 @@ export function Feed({ }; buildPrefix(); - }, [identifierOperations, isLoadingGroupOwnerNames, memberGroups]); + }, [ + fetchFollowedNames, + groupOwnerNames, + identifierOperations, + isLoadingGroupOwnerNames, + memberGroups, + ]); const loaderItem = useCallback(() => { return ; @@ -474,8 +486,7 @@ export function Feed({ !followingNames || !replySearchPrefix || !repostSearchPrefix || - !primaryNamesGroup || - !groupSearchPrefix + (!DISABLE_SUBSCRIPTIONS && (!primaryNamesGroup || !groupSearchPrefix)) ) return undefined; @@ -571,7 +582,7 @@ export function Feed({ !intervalSearch || !searchPrefix || !replySearchPrefix || - !groupSearchPrefix || + (!DISABLE_SUBSCRIPTIONS && !groupSearchPrefix) || !repostSearchPrefix ) { return ( @@ -698,7 +709,7 @@ export function Feed({ disabled={!isAuthenticated} > - Following & Subs + {DISABLE_SUBSCRIPTIONS ? 'Following' : 'Following & Subs'} diff --git a/src/components/MobileNavigation.tsx b/src/components/MobileNavigation.tsx index 2d0cc87..a3d4ba2 100644 --- a/src/components/MobileNavigation.tsx +++ b/src/components/MobileNavigation.tsx @@ -13,6 +13,7 @@ import { useState } from 'react'; import { useAtomValue } from 'jotai'; import { hasUnreadNotificationsAtom } from '../state/global/notifications'; import { isPublicNodeAtom } from '../state/global/system'; +import { DISABLE_SUBSCRIPTIONS } from '../constants/qdn'; import { useGlobal } from 'qapp-core'; import { NameSwitcher } from './NameSwitcher'; @@ -174,7 +175,7 @@ export function MobileNavigation({ page === 'profile' || page === 'notifications' || page === 'blocked' || - page === 'groups' + (!DISABLE_SUBSCRIPTIONS && page === 'groups') ) { if (!auth?.name) return; } @@ -195,7 +196,7 @@ export function MobileNavigation({ page === 'profile' || page === 'notifications' || page === 'blocked' || - page === 'groups' + (!DISABLE_SUBSCRIPTIONS && page === 'groups') ) { if (!auth?.name) return; } @@ -312,17 +313,18 @@ export function MobileNavigation({ )} - handleMenuItemClick('groups')} - disabled={!auth?.name} - > - - Subscriptions - + {!DISABLE_SUBSCRIPTIONS && ( + handleMenuItemClick('groups')} + disabled={!auth?.name} + > + + Subscriptions + + )} ); } - diff --git a/src/components/NewPostInput.tsx b/src/components/NewPostInput.tsx index cd5a080..a55ff71 100644 --- a/src/components/NewPostInput.tsx +++ b/src/components/NewPostInput.tsx @@ -32,6 +32,7 @@ import { showError } from 'qapp-core'; import { useAtomValue } from 'jotai'; import { attachedGroupsAtom, ownedGroupsAtom } from '../state/global/profile'; import { AttachedGroup } from '../utils/profileQdn'; +import { DISABLE_SUBSCRIPTIONS } from '../constants/qdn'; // Declare qortalRequest as a global function (provided by Qortal runtime) @@ -444,11 +445,17 @@ export function NewPostInput({ const [showEmojiPicker, setShowEmojiPicker] = useState(false); // Set initial visibility based on replyingToGroupId (for encrypted replies) or editingPostGroupId (for editing) const [visibility, setVisibility] = useState( - replyingToGroupId?.toString() || editingPostGroupId?.toString() || 'public' + DISABLE_SUBSCRIPTIONS + ? 'public' + : replyingToGroupId?.toString() || + editingPostGroupId?.toString() || + 'public' ); // Enrich attached groups with groupName from ownedGroups if missing const enrichedAttachedGroups = useMemo(() => { + if (DISABLE_SUBSCRIPTIONS) return []; + const groups = attachedGroups .filter((item) => !!ownedGroups.find((g) => g.groupId === item)) .map((attachedGroup) => { @@ -526,7 +533,9 @@ export function NewPostInput({ setText(initialText); setMedia(initialMedia); // Set visibility based on editingPostGroupId - if (editingPostGroupId) { + if (DISABLE_SUBSCRIPTIONS) { + setVisibility('public'); + } else if (editingPostGroupId) { setVisibility(editingPostGroupId.toString()); } else { setVisibility('public'); @@ -668,7 +677,9 @@ export function NewPostInput({ try { // Get groupId from visibility if it's not 'public' const selectedGroupId = - visibility !== 'public' ? parseInt(visibility) : undefined; + !DISABLE_SUBSCRIPTIONS && visibility !== 'public' + ? parseInt(visibility) + : undefined; await onPost({ text, @@ -1444,7 +1455,7 @@ export function NewPostInput({ {/* Only show visibility selector when creating a new post (not editing or replying) */} - {!isEditing && !isReplying && ( + {!DISABLE_SUBSCRIPTIONS && !isEditing && !isReplying && (