mirror of
https://github.com/plebbit/seedit.git
synced 2026-04-27 02:33:11 -04:00
feat: add push notifications to web browser, android app, desktop app
This commit is contained in:
@@ -1,28 +1,18 @@
|
||||
// Import log.cjs
|
||||
require('./log.cjs');
|
||||
|
||||
// Import Electron components using CommonJS require
|
||||
const { app, BrowserWindow, Menu, MenuItem, Tray, shell, dialog, nativeTheme, ipcMain } = require('electron');
|
||||
const { app, BrowserWindow, Menu, MenuItem, Tray, shell, dialog, nativeTheme, ipcMain, Notification } = require('electron');
|
||||
|
||||
// Import Node.js built-ins using CommonJS
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { URL, fileURLToPath } = require('url');
|
||||
const util = require('util');
|
||||
// Don't require EnvPaths directly, it will be dynamically imported later
|
||||
// const EnvPaths = require('env-paths');
|
||||
const { URL } = require('url');
|
||||
const FormData = require('form-data');
|
||||
const fetch = require('node-fetch');
|
||||
const contextMenu = require('electron-context-menu');
|
||||
|
||||
// Import local modules using CommonJS
|
||||
require('./start-ipfs.cjs');
|
||||
require('./start-plebbit-rpc.cjs');
|
||||
|
||||
// Load package.json using CommonJS
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
// Set ELECTRON_IS_DEV for other modules to use
|
||||
process.env.ELECTRON_IS_DEV = app.isPackaged ? '0' : '1';
|
||||
|
||||
// Since we're in CommonJS, we can't use import.meta.url
|
||||
@@ -62,6 +52,30 @@ startIpfs.onError = (error) => {
|
||||
if (process.platform === 'linux') fakeUserAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36';
|
||||
const realUserAgent = `seedit/${packageJson.version}`;
|
||||
|
||||
// Handle IPC call to show notification
|
||||
ipcMain.on('show-notification', (event, notificationData) => {
|
||||
if (Notification.isSupported()) {
|
||||
const notification = new Notification({
|
||||
title: notificationData.title,
|
||||
body: notificationData.body,
|
||||
});
|
||||
|
||||
// Optional: Handle click event for notification
|
||||
if (notificationData.url) {
|
||||
notification.on('click', () => {
|
||||
// Make sure the mainWindow is available and not destroyed
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
shell.openExternal(notificationData.url).catch(err => console.error('Failed to open URL:', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notification.show();
|
||||
} else {
|
||||
console.warn('Electron Notifications not supported on this system.');
|
||||
}
|
||||
});
|
||||
|
||||
// add right click menu
|
||||
contextMenu({
|
||||
// prepend custom buttons to top
|
||||
@@ -104,9 +118,9 @@ startIpfs.onError = (error) => {
|
||||
webSecurity: true, // must be true or iframe embeds like youtube can do remote code execution
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
devTools: true, // TODO: change to isDev when no bugs left
|
||||
preload: path.join(dirname, 'preload.mjs'),
|
||||
sandbox: false, // Required for ESM preload scripts
|
||||
devTools: !app.isPackaged, // Use app.isPackaged to determine if devTools should be enabled
|
||||
preload: path.join(dirname, 'preload.cjs'),
|
||||
// sandbox: false, // sandbox:false is generally discouraged for security unless strictly necessary. Re-evaluate if needed.
|
||||
},
|
||||
});
|
||||
|
||||
@@ -456,3 +470,7 @@ ipcMain.handle('plugin:file-uploader:pickMedia', async (event) => {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-platform', () => {
|
||||
return process.platform; // 'darwin', 'win32', 'linux', etc.
|
||||
});
|
||||
|
||||
10
electron/preload.cjs
Normal file
10
electron/preload.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
console.log('Preload script loaded.');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronApi', {
|
||||
isElectron: true,
|
||||
getNotificationStatus: () => ipcRenderer.invoke('get-notification-permission-status'),
|
||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||
testNotification: () => ipcRenderer.invoke('test-notification-permission')
|
||||
});
|
||||
@@ -35,6 +35,31 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
if (validChannels.includes(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
throw new Error(`Unauthorized IPC channel: ${channel}`);
|
||||
throw new Error(`Unauthorized IPC invoke channel: ${channel}`);
|
||||
},
|
||||
send: (channel, ...args) => {
|
||||
const validChannels = [
|
||||
'show-notification'
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
} else {
|
||||
throw new Error(`Unauthorized IPC send channel: ${channel}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('seeditIpc', {
|
||||
send: (channel, ...args) => {
|
||||
const validChannels = [
|
||||
'show-notification'
|
||||
// Add other seedit-specific send channels here if needed
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
} else {
|
||||
throw new Error(`Unauthorized Seedit IPC send channel: ${channel}`);
|
||||
}
|
||||
}
|
||||
// Add invoke/on methods here if needed for seedit-specific IPC
|
||||
});
|
||||
|
||||
@@ -3,91 +3,98 @@ const { spawn } = require('child_process');
|
||||
const fs = require('fs-extra');
|
||||
const ps = require('node:process');
|
||||
const tcpPortUsed = require('tcp-port-used');
|
||||
|
||||
// Import local modules using CommonJS
|
||||
const proxyServer = require('./proxy-server.cjs');
|
||||
|
||||
// Instead of using electron-is-dev, use app.isPackaged
|
||||
// (we already made this change in main.js)
|
||||
// Use app.isPackaged defined in main.js via process.env
|
||||
const isDev = process.env.ELECTRON_IS_DEV === '1';
|
||||
|
||||
// Use __dirname directly instead of fileURLToPath(import.meta.url)
|
||||
// CommonJS equivalent of __dirname
|
||||
const dirname = __dirname;
|
||||
|
||||
// Make the main logic async to handle dynamic import
|
||||
let proxyStarted = false;
|
||||
|
||||
// Start a debugging proxy for the IPFS API only in development mode.
|
||||
// This allows connecting dev tools (like the IPFS Web UI) to the standard
|
||||
// port 50019 while the actual daemon runs on 50029.
|
||||
function startDevProxyOnce() {
|
||||
if (isDev && !proxyStarted) {
|
||||
const proxyPort = 50019;
|
||||
const targetPort = 50029;
|
||||
try {
|
||||
console.log(`Attempting to start development proxy server on port ${proxyPort}...`);
|
||||
proxyServer.start({ proxyPort, targetPort });
|
||||
proxyStarted = true;
|
||||
console.log(`Development proxy server started successfully on port ${proxyPort}, forwarding to ${targetPort}.`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to start development proxy server on port ${proxyPort}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handles IPFS setup: finding binary, initializing repo, configuring ports, and starting daemon.
|
||||
async function initializeIpfs() {
|
||||
// Dynamically import env-paths
|
||||
const EnvPaths = (await import('env-paths')).default;
|
||||
const envPaths = EnvPaths('plebbit', { suffix: false });
|
||||
|
||||
const ipfsFileName = process.platform == 'win32' ? 'ipfs.exe' : 'ipfs';
|
||||
let ipfsPath = path.join(process.resourcesPath, 'bin', ipfsFileName);
|
||||
let ipfsDataPath = path.join(envPaths.data, 'ipfs');
|
||||
let ipfsPath = path.join(process.resourcesPath, 'bin', ipfsFileName); // Packaged app path
|
||||
let ipfsDataPath = path.join(envPaths.data, 'ipfs'); // Standard data path
|
||||
|
||||
// test launching the ipfs binary in dev mode
|
||||
// they must be downloaded first using `yarn electron:build`
|
||||
// Override paths for development mode
|
||||
if (isDev) {
|
||||
let binFolderName = 'win';
|
||||
if (process.platform === 'linux') {
|
||||
binFolderName = 'linux';
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
binFolderName = 'mac';
|
||||
}
|
||||
if (process.platform === 'linux') binFolderName = 'linux';
|
||||
if (process.platform === 'darwin') binFolderName = 'mac';
|
||||
ipfsPath = path.join(dirname, '..', 'bin', binFolderName, ipfsFileName);
|
||||
ipfsDataPath = path.join(dirname, '..', '.plebbit', 'ipfs');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(ipfsPath)) {
|
||||
throw Error(`ipfs binary '${ipfsPath}' doesn't exist`);
|
||||
throw Error(`ipfs binary '${ipfsPath}' doesn't exist. Run 'yarn electron:build' or 'yarn electron:before:download-ipfs' first?`);
|
||||
}
|
||||
|
||||
console.log({ ipfsPath, ipfsDataPath });
|
||||
|
||||
fs.ensureDirSync(ipfsDataPath);
|
||||
const env = { IPFS_PATH: ipfsDataPath };
|
||||
// init ipfs client on first launch
|
||||
try {
|
||||
await spawnAsync(ipfsPath, ['init'], { env, hideWindows: true });
|
||||
} catch (e) {}
|
||||
|
||||
// make sure repo is migrated
|
||||
try {
|
||||
await spawnAsync(ipfsPath, ['repo', 'migrate'], { env, hideWindows: true });
|
||||
} catch (e) {}
|
||||
// Attempt to initialize the IPFS repo; ignore errors if already initialized.
|
||||
try { await spawnAsync(ipfsPath, ['init'], { env, hideWindows: true }); } catch (e) {}
|
||||
|
||||
// dont use 8080 port because it's too common
|
||||
await spawnAsync(ipfsPath, ['config', '--json', 'Addresses.Gateway', '"/ip4/127.0.0.1/tcp/6473"'], {
|
||||
env,
|
||||
hideWindows: true,
|
||||
});
|
||||
// Ensure the repo is migrated to the latest version.
|
||||
try { await spawnAsync(ipfsPath, ['repo', 'migrate'], { env, hideWindows: true }); } catch (e) {}
|
||||
|
||||
// use different port with proxy for debugging during env
|
||||
// Configure IPFS Gateway port (avoiding common port 8080).
|
||||
await spawnAsync(ipfsPath, ['config', '--json', 'Addresses.Gateway', '"/ip4/127.0.0.1/tcp/6473"'], { env, hideWindows: true });
|
||||
|
||||
// Configure IPFS API port. Use a different port in dev (50029) vs prod (50019).
|
||||
let apiAddress = '/ip4/127.0.0.1/tcp/50019';
|
||||
if (isDev) {
|
||||
apiAddress = apiAddress.replace('50019', '50029');
|
||||
proxyServer.start({ proxyPort: 50019, targetPort: 50029 });
|
||||
}
|
||||
await spawnAsync(ipfsPath, ['config', 'Addresses.API', apiAddress], { env, hideWindows: true });
|
||||
|
||||
await startIpfsDaemon(ipfsPath, env);
|
||||
|
||||
// Attempt to start the dev proxy *after* trying to start the daemon (increases chance target port is ready).
|
||||
startDevProxyOnce();
|
||||
}
|
||||
|
||||
// use this custom function instead of spawnSync for better logging
|
||||
// also spawnSync might have been causing crash on start on windows
|
||||
// Wrapper around child_process.spawn for better logging and Promise interface.
|
||||
const spawnAsync = (...args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const spawedProcess = spawn(...args);
|
||||
spawedProcess.on('exit', (exitCode, signal) => {
|
||||
const spawnedProcess = spawn(...args);
|
||||
spawnedProcess.on('exit', (exitCode, signal) => {
|
||||
if (exitCode === 0) resolve();
|
||||
else reject(Error(`spawnAsync process '${spawedProcess.pid}' exited with code '${exitCode}' signal '${signal}'`));
|
||||
else reject(Error(`spawnAsync process '${spawnedProcess.pid}' exited with code '${exitCode}' signal '${signal}'`));
|
||||
});
|
||||
spawedProcess.stderr.on('data', (data) => console.error(data.toString()));
|
||||
spawedProcess.stdin.on('data', (data) => console.log(data.toString()));
|
||||
spawedProcess.stdout.on('data', (data) => console.log(data.toString()));
|
||||
spawedProcess.on('error', (data) => console.error(data.toString()));
|
||||
spawnedProcess.stderr.on('data', (data) => console.error(data.toString()));
|
||||
spawnedProcess.stdin.on('data', (data) => console.log(data.toString()));
|
||||
spawnedProcess.stdout.on('data', (data) => console.log(data.toString()));
|
||||
spawnedProcess.on('error', (data) => console.error(data.toString()));
|
||||
});
|
||||
|
||||
// Starts the IPFS daemon process and resolves when it's ready.
|
||||
const startIpfsDaemon = (ipfsPath, env) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const ipfsProcess = spawn(ipfsPath, ['daemon', '--migrate', '--enable-pubsub-experiment', '--enable-namesys-pubsub'], { env, hideWindows: true });
|
||||
@@ -110,54 +117,55 @@ const startIpfsDaemon = (ipfsPath, env) =>
|
||||
console.error(`ipfs process with pid ${ipfsProcess.pid} exited`);
|
||||
reject(Error(lastError));
|
||||
});
|
||||
|
||||
// Ensure IPFS daemon is killed when the Electron app exits.
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
console.log(`Attempting to kill IPFS daemon (pid: ${ipfsProcess.pid}) on Electron exit...`);
|
||||
ps.kill(ipfsProcess.pid);
|
||||
console.log(`Successfully sent kill signal to IPFS daemon (pid: ${ipfsProcess.pid}).`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
// sometimes ipfs doesnt exit unless we kill pid +1
|
||||
ps.kill(ipfsProcess.pid + 1);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// Ignore error if process doesn't exist (ESRCH).
|
||||
if (e.code !== 'ESRCH') {
|
||||
console.warn(`Warn: Failed to kill IPFS daemon (pid: ${ipfsProcess.pid}) on exit:`, e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Export object for error handling
|
||||
// Export object for other modules to attach an onError handler.
|
||||
const DefaultExport = {};
|
||||
|
||||
// Auto-restart logic now calls the async initializeIpfs
|
||||
// Checks periodically if IPFS daemon is running (by checking its API port)
|
||||
// and starts it via initializeIpfs if not.
|
||||
const startIpfsAutoRestart = async () => {
|
||||
let pendingStart = false;
|
||||
const start = async () => {
|
||||
if (pendingStart) {
|
||||
return;
|
||||
}
|
||||
if (pendingStart) return;
|
||||
pendingStart = true;
|
||||
try {
|
||||
const apiPort = isDev ? 50029 : 50019;
|
||||
const apiPort = isDev ? 50029 : 50019; // Check the *actual* daemon port
|
||||
const started = await tcpPortUsed.check(apiPort, '127.0.0.1');
|
||||
if (!started) {
|
||||
await initializeIpfs(); // Call the async initialization function
|
||||
console.log(`IPFS API port ${apiPort} not detected. Initializing IPFS...`);
|
||||
await initializeIpfs();
|
||||
} else {
|
||||
console.log(`IPFS API port ${apiPort} already in use. Assuming IPFS is running.`);
|
||||
// Ensure dev proxy is started if IPFS was already running.
|
||||
startDevProxyOnce();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('failed starting ipfs', e);
|
||||
try {
|
||||
// try to run exported onError callback, can be undefined
|
||||
DefaultExport.onError(e)?.catch?.((console.log));
|
||||
} catch (e) {}
|
||||
DefaultExport.onError?.(e);
|
||||
}
|
||||
pendingStart = false;
|
||||
};
|
||||
|
||||
// retry starting ipfs every 1 second,
|
||||
// in case it was started by another client that shut down and shut down ipfs with it
|
||||
start();
|
||||
setInterval(() => {
|
||||
start();
|
||||
}, 1000);
|
||||
// Try starting dev proxy immediately in case IPFS is already running.
|
||||
startDevProxyOnce();
|
||||
|
||||
start(); // Initial check
|
||||
setInterval(start, 5000); // Periodic check
|
||||
};
|
||||
startIpfsAutoRestart();
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -8,8 +8,9 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@capacitor/app": "6.0.1",
|
||||
"@capacitor/local-notifications": "7.0.1",
|
||||
"@floating-ui/react": "0.26.1",
|
||||
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#6d35eb3b4dc84f8fdcbb46d95e3b1d78f1750b5f",
|
||||
"@plebbit/plebbit-react-hooks": "https://github.com/plebbit/plebbit-react-hooks.git#19f82d4a5763c53fadf40c87bc1d830c111788f2",
|
||||
"@testing-library/jest-dom": "5.14.1",
|
||||
"@testing-library/react": "13.0.0",
|
||||
"@testing-library/user-event": "13.2.1",
|
||||
@@ -55,14 +56,14 @@
|
||||
"test": "vitest",
|
||||
"preview": "vite preview",
|
||||
"analyze-bundle": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=true vite build && source-map-explorer 'build/assets/*.js'",
|
||||
"electron": "yarn electron:before && electron .",
|
||||
"electron": "cross-env ELECTRON_IS_DEV=1 yarn electron:before && cross-env ELECTRON_IS_DEV=1 electron .",
|
||||
"electron:no-delete-data": "yarn electron:before:download-ipfs && electron .",
|
||||
"electron:start": "concurrently \"cross-env BROWSER=none yarn start\" \"wait-on http://localhost:3000 && yarn electron\"",
|
||||
"electron:start:no-delete-data": "concurrently \"cross-env BROWSER=none yarn start\" \"wait-on http://localhost:3000 && yarn electron:no-delete-data\"",
|
||||
"electron:build:linux": "yarn build && electron-builder build --publish never -l",
|
||||
"electron:build:windows": "yarn build && electron-builder build --publish never -w",
|
||||
"electron:build:mac": "yarn build && electron-builder build --publish never -m",
|
||||
"electron:before": "yarn electron:before:download-ipfs && yarn electron:before:delete-data",
|
||||
"electron:build:linux": "yarn build && electron-rebuild && electron-builder build --publish never -l",
|
||||
"electron:build:windows": "yarn build && yarn electron-rebuild && electron-builder build --publish never -w",
|
||||
"electron:build:mac": "yarn build && yarn electron-rebuild && electron-builder build --publish never -m",
|
||||
"electron:before": "yarn electron-rebuild && yarn electron:before:download-ipfs && yarn electron:before:delete-data",
|
||||
"electron:before:download-ipfs": "node electron/download-ipfs",
|
||||
"electron:before:delete-data": "rimraf .plebbit",
|
||||
"android:build:icons": "cordova-res android --skip-config --copy --resources /tmp/plebbit-react-android-icons --icon-source ./android/icons/icon.png --splash-source ./android/icons/splash.png --icon-foreground-source ./android/icons/icon-foreground.png --icon-background-source '#ffffee'",
|
||||
@@ -88,6 +89,7 @@
|
||||
"@capacitor/android": "5.0.0",
|
||||
"@capacitor/cli": "5.0.0",
|
||||
"@capacitor/core": "5.0.0",
|
||||
"@electron/rebuild": "4.0.0",
|
||||
"@types/memoizee": "0.4.9",
|
||||
"@types/node-fetch": "2",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.0",
|
||||
@@ -103,7 +105,7 @@
|
||||
"crypto-browserify": "3.12.1",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"decompress": "4.2.1",
|
||||
"electron": "31.4.0",
|
||||
"electron": "36.0.0",
|
||||
"electron-builder": "24.13.2",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
|
||||
BIN
public/assets/info-alert.png
Normal file
BIN
public/assets/info-alert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 656 B |
BIN
public/assets/info-success.png
Normal file
BIN
public/assets/info-success.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 736 B |
BIN
public/assets/info.png
Normal file
BIN
public/assets/info.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { initializeNotificationSystem } from './lib/push';
|
||||
import useTheme from './hooks/use-theme';
|
||||
import AboutView from './views/about';
|
||||
import All from './views/all';
|
||||
@@ -22,11 +23,15 @@ import Header from './components/header';
|
||||
import StickyHeader from './components/sticky-header';
|
||||
import TopBar from './components/topbar';
|
||||
import styles from './app.module.css';
|
||||
import { NotificationHandler } from './components/notification-handler/NotificationHandler';
|
||||
|
||||
initializeNotificationSystem();
|
||||
|
||||
const App = () => {
|
||||
const globalLayout = (
|
||||
<>
|
||||
<ChallengeModal />
|
||||
<NotificationHandler />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import styles from './author-sidebar.module.css';
|
||||
import { getFormattedTimeDuration } from '../../lib/utils/time-utils';
|
||||
import { isAuthorView, isProfileView } from '../../lib/utils/view-utils';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAccount, useAccountComment } from '@plebbit/plebbit-react-hooks';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import { sortTypes } from '../../constants/sort-types';
|
||||
import { sortLabels } from '../../constants/sort-labels';
|
||||
|
||||
54
src/components/notification-handler/NotificationHandler.tsx
Normal file
54
src/components/notification-handler/NotificationHandler.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNotifications } from '@plebbit/plebbit-react-hooks';
|
||||
import useContentOptionsStore from '../../stores/use-content-options-store';
|
||||
import { showLocalNotification } from '../../lib/push';
|
||||
|
||||
/**
|
||||
* Listens for new notifications from useNotifications and triggers platform-specific
|
||||
* local notification display if enabled in settings.
|
||||
* Does not render any UI itself.
|
||||
*/
|
||||
export const NotificationHandler = () => {
|
||||
const { enableLocalNotifications } = useContentOptionsStore();
|
||||
const { notifications } = useNotifications();
|
||||
const location = useLocation();
|
||||
const previousNotificationsRef = useRef(notifications);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableLocalNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousCids = new Set(previousNotificationsRef.current?.map((n) => n.cid) || []);
|
||||
const newNotifications = notifications?.filter((n) => !previousCids.has(n.cid)) || [];
|
||||
|
||||
newNotifications.forEach((notification) => {
|
||||
// Don't notify if the user is already viewing the inbox
|
||||
if (location.pathname.startsWith('/inbox')) {
|
||||
console.log('[NotificationHandler] Skipping notification, user is in inbox.', notification.cid);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Enhance title/body/URL based on notification type (reply vs mention etc.)
|
||||
const payload = {
|
||||
title: 'New Notification', // Generic title for now
|
||||
body: notification.text || 'You have a new notification.', // Fallback body
|
||||
url: `/p/${notification.subplebbitAddress}/c/${notification.cid}`, // Link to comment
|
||||
};
|
||||
|
||||
console.log('[NotificationHandler] Triggering notification:', payload);
|
||||
showLocalNotification(payload);
|
||||
});
|
||||
|
||||
previousNotificationsRef.current = notifications;
|
||||
|
||||
// Dependencies ensure this runs when relevant state changes,
|
||||
// including location to re-evaluate the inbox check.
|
||||
}, [notifications, enableLocalNotifications, location.pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Optional: Default export if preferred
|
||||
// export default NotificationHandler;
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Author, useBlock } from '@plebbit/plebbit-react-hooks';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import { autoUpdate, flip, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
|
||||
import { isProfileHiddenView } from '../../../../lib/utils/view-utils';
|
||||
import styles from './hide-menu.module.css';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Comment, useAuthorAddress, useBlock, useEditedComment, useSubscribe } from '@plebbit/plebbit-react-hooks';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { getHasThumbnail } from '../../lib/utils/media-utils';
|
||||
|
||||
@@ -23,7 +23,7 @@ import useDownvote from '../../hooks/use-downvote';
|
||||
import useStateString from '../../hooks/use-state-string';
|
||||
import useUpvote from '../../hooks/use-upvote';
|
||||
import { isInboxView, isPostContextView, isPostPageView } from '../../lib/utils/view-utils';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import Markdown from '../markdown';
|
||||
import { getHostname } from '../../lib/utils/url-utils';
|
||||
import useAvatarVisibilityStore from '../../stores/use-avatar-visibility-store';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import { Comment, useAccount, useBlock, Role, Subplebbit, useSubplebbitStats, useAccountComment } from '@plebbit/plebbit-react-hooks';
|
||||
import styles from './sidebar.module.css';
|
||||
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAccount, useAccountSubplebbits } from '@plebbit/plebbit-react-hooks';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import { isAllView, isDomainView, isHomeView, isModView, isSubplebbitView } from '../../lib/utils/view-utils';
|
||||
import useContentOptionsStore from '../../stores/use-content-options-store';
|
||||
import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
|
||||
|
||||
11
src/globals.d.ts
vendored
11
src/globals.d.ts
vendored
@@ -5,4 +5,15 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronApi?: {
|
||||
isElectron: boolean;
|
||||
getNotificationStatus: () => Promise<'granted' | 'denied' | 'not-determined'>;
|
||||
getPlatform: () => Promise<NodeJS.Platform>;
|
||||
testNotification: () => Promise<{ success: boolean; reason?: string }>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -8,6 +8,9 @@ import './index.css';
|
||||
import './themes.css';
|
||||
import './preload-assets.css';
|
||||
import { App as CapacitorApp } from '@capacitor/app';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
registerSW({ immediate: true });
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
root.render(
|
||||
|
||||
7
src/lib/push/common.ts
Normal file
7
src/lib/push/common.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface LocalNotification {
|
||||
id: number; // Required for Capacitor, can be derived from timestamp or counter
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string; // Optional icon URL (mostly for web/electron)
|
||||
url?: string; // Optional URL to open on click
|
||||
}
|
||||
44
src/lib/push/electron.ts
Normal file
44
src/lib/push/electron.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { LocalNotification } from './common';
|
||||
|
||||
// Define the dedicated Seedit IPC API exposed via preload
|
||||
declare global {
|
||||
interface Window {
|
||||
seeditIpc?: {
|
||||
send: (channel: string, ...args: any[]) => void;
|
||||
// Add invoke/on if needed for seedit-specific IPC
|
||||
};
|
||||
// Keep existing electron definition if other parts of the app use it
|
||||
electron?: {
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests permission to display notifications in Electron.
|
||||
* On Electron, permission is implicitly granted, but we check if the API exists.
|
||||
*/
|
||||
export async function requestElectronNotificationPermission(): Promise<boolean> {
|
||||
// Check if the preload script successfully exposed the Seedit IPC API
|
||||
const supported = typeof window.seeditIpc?.send === 'function';
|
||||
if (!supported) {
|
||||
console.warn('Seedit Electron IPC for notifications not available.');
|
||||
}
|
||||
// We don't need to ask the user for permission like in the browser
|
||||
return supported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a local notification via Electron IPC using the dedicated Seedit API.
|
||||
*/
|
||||
export async function showElectronLocalNotification(notification: Omit<LocalNotification, 'id'>): Promise<void> {
|
||||
if (window.seeditIpc?.send) {
|
||||
try {
|
||||
window.seeditIpc.send('show-notification', notification);
|
||||
} catch (error) {
|
||||
console.error('Error sending notification via Seedit IPC:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('Seedit Electron IPC send function not available.');
|
||||
}
|
||||
}
|
||||
74
src/lib/push/index.ts
Normal file
74
src/lib/push/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import type { LocalNotification } from './common';
|
||||
import { requestWebNotificationPermission, showWebLocalNotification } from './web';
|
||||
import { requestNativeNotificationPermission, showNativeLocalNotification, initializeNativeNotificationListeners } from './native';
|
||||
import { requestElectronNotificationPermission, showElectronLocalNotification } from './electron';
|
||||
|
||||
// --- Platform Detection ---
|
||||
|
||||
const isElectron = typeof window.seeditIpc?.send === 'function';
|
||||
const isNativePlatform = Capacitor.isNativePlatform();
|
||||
const isWebPlatform = !isNativePlatform && !isElectron;
|
||||
|
||||
let notificationIdCounter = Date.now(); // Simple counter for unique IDs
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
/**
|
||||
* Initializes platform-specific notification listeners (currently only needed for Capacitor).
|
||||
* Should be called once when the app starts.
|
||||
*/
|
||||
export function initializeNotificationSystem(): void {
|
||||
if (isNativePlatform) {
|
||||
initializeNativeNotificationListeners();
|
||||
}
|
||||
console.log('Notification system initialized for platform:', isNativePlatform ? 'Native' : isElectron ? 'Electron' : 'Web');
|
||||
}
|
||||
|
||||
// --- Permissions ---
|
||||
|
||||
/**
|
||||
* Requests permission to show notifications based on the current platform.
|
||||
* Must be called from a user interaction context (e.g., button click).
|
||||
*/
|
||||
export async function requestNotificationPermission(): Promise<boolean> {
|
||||
if (isNativePlatform) {
|
||||
return requestNativeNotificationPermission();
|
||||
}
|
||||
if (isElectron) {
|
||||
// Permission is implicit, just check if IPC is working
|
||||
return requestElectronNotificationPermission();
|
||||
}
|
||||
if (isWebPlatform) {
|
||||
return requestWebNotificationPermission();
|
||||
}
|
||||
console.warn('Notification permission request: Unknown platform.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Showing Notifications ---
|
||||
|
||||
/**
|
||||
* Shows a local notification using the appropriate platform API.
|
||||
*/
|
||||
export async function showLocalNotification(notificationData: Omit<LocalNotification, 'id'>): Promise<void> {
|
||||
// Generate a unique ID required by Capacitor
|
||||
const id = notificationIdCounter++;
|
||||
const platformNotification = { ...notificationData, id };
|
||||
|
||||
console.log('Attempting to show notification:', platformNotification);
|
||||
|
||||
if (isNativePlatform) {
|
||||
return showNativeLocalNotification(platformNotification);
|
||||
}
|
||||
if (isElectron) {
|
||||
// Electron doesn't need the ID, but doesn't hurt to pass it
|
||||
return showElectronLocalNotification(platformNotification);
|
||||
}
|
||||
if (isWebPlatform) {
|
||||
// Service Worker doesn't need the ID, but doesn't hurt to pass it
|
||||
return showWebLocalNotification(platformNotification);
|
||||
}
|
||||
|
||||
console.warn('Show notification: Unknown platform.');
|
||||
}
|
||||
73
src/lib/push/native.ts
Normal file
73
src/lib/push/native.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import type { LocalNotification } from './common';
|
||||
import type { PermissionState } from '@capacitor/core';
|
||||
|
||||
/**
|
||||
* Requests permission to display local notifications on native platforms.
|
||||
*/
|
||||
export async function requestNativeNotificationPermission(): Promise<boolean> {
|
||||
try {
|
||||
const result: { display: PermissionState } = await LocalNotifications.requestPermissions();
|
||||
return result.display === 'granted';
|
||||
} catch (error) {
|
||||
console.error('Error requesting native notification permission:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a local notification on native platforms.
|
||||
*/
|
||||
export async function showNativeLocalNotification(notification: LocalNotification): Promise<void> {
|
||||
try {
|
||||
// Check permission first (optional, but good practice)
|
||||
const permissionStatus: { display: PermissionState } = await LocalNotifications.checkPermissions();
|
||||
if (permissionStatus.display !== 'granted') {
|
||||
console.warn('Native notification permission not granted.');
|
||||
// Optionally re-request permission here if appropriate
|
||||
// const granted = await requestNativeNotificationPermission();
|
||||
// if (!granted) return;
|
||||
return;
|
||||
}
|
||||
|
||||
await LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{
|
||||
id: notification.id,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
// Note: Native icons are handled differently (usually app icon)
|
||||
// schedule: { at: new Date(Date.now() + 1000) }, // Schedule immediately
|
||||
extra: { url: notification.url }, // Store URL in extra data
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Add listener for when a notification action is performed (e.g., tapped)
|
||||
// Do this only once, perhaps in an initialization function
|
||||
// await LocalNotifications.addListener('localNotificationActionPerformed', (action) => {
|
||||
// const url = action.notification.extra?.url;
|
||||
// if (url) {
|
||||
// // Handle navigation, e.g., window.location.href = url;
|
||||
// console.log('Notification clicked, navigate to:', url);
|
||||
// }
|
||||
// });
|
||||
} catch (error) {
|
||||
console.error('Error scheduling native local notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// It's crucial to add listeners for notification actions (like clicks) only ONCE during app initialization.
|
||||
// Consider creating an init function that sets up these listeners.
|
||||
export async function initializeNativeNotificationListeners() {
|
||||
await LocalNotifications.addListener('localNotificationActionPerformed', (action) => {
|
||||
const url = action.notification.extra?.url;
|
||||
if (url && typeof url === 'string') {
|
||||
// Basic navigation. Implement more robust routing if needed.
|
||||
window.location.href = url;
|
||||
console.log('Native notification action performed, navigating to:', url);
|
||||
} else {
|
||||
console.log('Native notification action performed, no URL found.');
|
||||
}
|
||||
});
|
||||
}
|
||||
46
src/lib/push/web.ts
Normal file
46
src/lib/push/web.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { LocalNotification } from './common';
|
||||
|
||||
/**
|
||||
* Requests permission to display notifications.
|
||||
* Must be called from a user interaction context (e.g., button click).
|
||||
*/
|
||||
export async function requestWebNotificationPermission(): Promise<boolean> {
|
||||
if (!('Notification' in window) || !navigator.serviceWorker) {
|
||||
console.warn('Web Notifications or Service Worker not supported.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission === 'granted';
|
||||
} catch (error) {
|
||||
console.error('Error requesting notification permission:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a local notification via the Service Worker.
|
||||
*/
|
||||
export async function showWebLocalNotification(notification: Omit<LocalNotification, 'id'>): Promise<void> {
|
||||
if (!navigator.serviceWorker) {
|
||||
console.warn('Service Worker not available to show notification.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!registration.active) {
|
||||
console.warn('No active Service Worker registration found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send message to the service worker
|
||||
registration.active.postMessage({
|
||||
type: 'SHOW_NOTIFICATION',
|
||||
payload: notification,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending notification message to Service Worker:', error);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ interface ContentOptionsStore extends ContentOptionsState {
|
||||
setHasAcceptedWarning: (value: boolean) => void;
|
||||
hideDefaultCommunities: boolean;
|
||||
setHideDefaultCommunities: (hide: boolean) => void;
|
||||
enableLocalNotifications: boolean;
|
||||
setEnableLocalNotifications: (enable: boolean) => void;
|
||||
}
|
||||
|
||||
const useContentOptionsStore = create<ContentOptionsStore>()(
|
||||
@@ -31,6 +33,7 @@ const useContentOptionsStore = create<ContentOptionsStore>()(
|
||||
hideVulgarCommunities: true,
|
||||
hasAcceptedWarning: false,
|
||||
hideDefaultCommunities: false,
|
||||
enableLocalNotifications: false,
|
||||
setBlurNsfwThumbnails: (blur) => set({ blurNsfwThumbnails: blur }),
|
||||
setHideAdultCommunities: (hide) => set({ hideAdultCommunities: hide }),
|
||||
setHideGoreCommunities: (hide) => set({ hideGoreCommunities: hide }),
|
||||
@@ -38,6 +41,7 @@ const useContentOptionsStore = create<ContentOptionsStore>()(
|
||||
setHideVulgarCommunities: (hide) => set({ hideVulgarCommunities: hide }),
|
||||
setHasAcceptedWarning: (value) => set({ hasAcceptedWarning: value }),
|
||||
setHideDefaultCommunities: (hide) => set({ hideDefaultCommunities: hide }),
|
||||
setEnableLocalNotifications: (enable) => set({ enableLocalNotifications: enable }),
|
||||
}),
|
||||
{
|
||||
name: 'content-options',
|
||||
|
||||
59
src/sw.ts
Normal file
59
src/sw.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/// <reference lib="webworker" />
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// Precache all assets specified in the manifest
|
||||
cleanupOutdatedCaches();
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// Standard SW lifecycle methods
|
||||
self.skipWaiting();
|
||||
clientsClaim();
|
||||
|
||||
interface NotificationData {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
|
||||
const data: NotificationData = event.data.payload;
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: data.icon || '/android-chrome-192x192.png',
|
||||
data: { url: data.url },
|
||||
tag: data.body + '_' + Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
|
||||
// Check if a window/tab matching the URL is already open
|
||||
for (const client of clientList) {
|
||||
// Use endsWith because client.url might have query params
|
||||
if (client.url.endsWith(urlToOpen) && 'focus' in client) {
|
||||
return client.focus(); // Focus the existing window/tab
|
||||
}
|
||||
}
|
||||
|
||||
// If no matching window/tab is found, open a new one
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(urlToOpen);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
1
src/views/settings/notifications-settings/index.ts
Normal file
1
src/views/settings/notifications-settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './notifications-settings';
|
||||
@@ -0,0 +1,50 @@
|
||||
.notificationsSettings input[type="checkbox"] {
|
||||
margin-right: .5em;
|
||||
}
|
||||
|
||||
.permissionStatus {
|
||||
font-size: 10px;
|
||||
color: var(--text-info);
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.permissionStatusSuccess::before {
|
||||
background-image: url('/assets/info-success.png');
|
||||
background-size: 16px 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
content: '';
|
||||
margin-right: 5px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.permissionStatusRequesting::before {
|
||||
background-image: url('/assets/info.png');
|
||||
background-size: 16px 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
content: '';
|
||||
margin-right: 5px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.permissionStatusDenied::before {
|
||||
background-image: url('/assets/info-alert.png');
|
||||
background-size: 16px 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
content: '';
|
||||
margin-right: 5px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.permissionStatusTestButton {
|
||||
font-size: 10px;
|
||||
color: var(--link-primary);
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import useContentOptionsStore from '../../../stores/use-content-options-store';
|
||||
import { requestNotificationPermission } from '../../../lib/push';
|
||||
import styles from './notifications-settings.module.css';
|
||||
|
||||
const NotificationsSettings = () => {
|
||||
const { enableLocalNotifications, setEnableLocalNotifications } = useContentOptionsStore();
|
||||
const [permissionStatus, setPermissionStatus] = useState<string | null>(null);
|
||||
const [platform, setPlatform] = useState<NodeJS.Platform | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDeniedMessage, setShowDeniedMessage] = useState(false);
|
||||
|
||||
// Function to check permission via API, memoized with useCallback
|
||||
const checkPermissionStatus = useCallback(async () => {
|
||||
// if (!window.electronApi?.getNotificationStatus) return; // Commented out
|
||||
console.warn('[NotificationsSettings] checkPermissionStatus called, but electronApi.getNotificationStatus is disabled.');
|
||||
|
||||
console.log('[Electron Native] Checking OS notification permission status...');
|
||||
try {
|
||||
// This call now correctly handles 'not-supported' internally in main.cjs
|
||||
// const nativeStatus = await window.electronApi.getNotificationStatus(); // Commented out
|
||||
const nativeStatus = 'unknown' as any; // Mock status, cast to any to bypass linter
|
||||
console.log('[Electron Native] OS permission status from native API:', nativeStatus);
|
||||
|
||||
setPermissionStatus(nativeStatus); // Directly set the status received
|
||||
|
||||
if (nativeStatus === 'granted') {
|
||||
// On macOS, even if the API returns 'granted', we should do a real test
|
||||
// to confirm notifications are actually working, unless it's already tested ok
|
||||
// if (platform === 'darwin' && !testResult?.success) { // Logic using testNotificationPermission commented out
|
||||
// testNotificationPermission(); // Test to ensure it *really* works
|
||||
// } else
|
||||
if (!enableLocalNotifications) {
|
||||
// Update store only if needed
|
||||
setEnableLocalNotifications(true);
|
||||
}
|
||||
} else if (nativeStatus === 'denied') {
|
||||
if (enableLocalNotifications) {
|
||||
setEnableLocalNotifications(false);
|
||||
}
|
||||
setShowDeniedMessage(true);
|
||||
setTimeout(() => setShowDeniedMessage(false), 5000);
|
||||
} else if (nativeStatus === 'not-determined') {
|
||||
// If undetermined, try a direct test which might trigger the prompt
|
||||
// testNotificationPermission(); // Logic using testNotificationPermission commented out
|
||||
console.warn('[NotificationsSettings] Permission status is not-determined, cannot test.');
|
||||
} else if (nativeStatus === 'not-supported') {
|
||||
// If not supported, ensure checkbox is off
|
||||
if (enableLocalNotifications) {
|
||||
setEnableLocalNotifications(false);
|
||||
}
|
||||
} else if (nativeStatus === 'unknown') {
|
||||
// Handle the mocked 'unknown' state
|
||||
console.warn('[NotificationsSettings] Permission status is unknown (Electron API disabled).');
|
||||
// Optionally disable the checkbox or show a specific message
|
||||
if (enableLocalNotifications) {
|
||||
// Maybe keep it enabled but show a warning?
|
||||
// Or disable it:
|
||||
// setEnableLocalNotifications(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Electron Native] Error checking notification permissions:', err);
|
||||
// On error, fall back to the test notification approach as a last resort
|
||||
// testNotificationPermission(); // Logic using testNotificationPermission commented out
|
||||
setPermissionStatus('unknown'); // Set status to unknown on error too
|
||||
}
|
||||
}, [enableLocalNotifications, setEnableLocalNotifications /*, testNotificationPermission */]); // Dependencies for checkPermissionStatus, commented out testNotificationPermission
|
||||
|
||||
// Run the direct test on mount
|
||||
useEffect(() => {
|
||||
if (window.electronApi) {
|
||||
// Get platform first
|
||||
if (window.electronApi.getPlatform) {
|
||||
window.electronApi.getPlatform().then(setPlatform).catch(console.error);
|
||||
}
|
||||
|
||||
// Then check notification permission status
|
||||
checkPermissionStatus();
|
||||
}
|
||||
}, [checkPermissionStatus]); // Now depends on the memoized function
|
||||
|
||||
const handleCheckboxChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const isEnabled = event.target.checked;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (isEnabled) {
|
||||
// If in Electron, do a direct test
|
||||
if (window.electronApi) {
|
||||
// await testNotificationPermission(); // Commented out
|
||||
console.warn('[NotificationsSettings] testNotificationPermission call skipped in handleCheckboxChange.');
|
||||
setPermissionStatus('unknown'); // Set to unknown as we can't test
|
||||
} else {
|
||||
// Use the web browser API for non-Electron
|
||||
setPermissionStatus('requesting...');
|
||||
const granted = await requestNotificationPermission();
|
||||
if (granted) {
|
||||
setEnableLocalNotifications(true);
|
||||
setPermissionStatus('granted');
|
||||
} else {
|
||||
setEnableLocalNotifications(false);
|
||||
setPermissionStatus('denied');
|
||||
setShowDeniedMessage(true);
|
||||
setTimeout(() => setShowDeniedMessage(false), 5000);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setEnableLocalNotifications(false);
|
||||
// Don't change permissionStatus when disabling
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to manually test a notification
|
||||
const showTestNotification = () => {
|
||||
// If we're on Electron, we should verify permission status first
|
||||
if (window.electronApi) {
|
||||
// testNotificationPermission(); // Commented out
|
||||
console.warn('[NotificationsSettings] testNotificationPermission call skipped in showTestNotification.');
|
||||
alert('Cannot test Electron notifications currently.');
|
||||
} else {
|
||||
import('../../../lib/push').then(({ showLocalNotification }) => {
|
||||
showLocalNotification({
|
||||
title: 'Look at this fancy notification!',
|
||||
body: 'We did it Seedit!',
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.notificationsSettings}>
|
||||
<div>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='enableLocalNotifications'
|
||||
checked={enableLocalNotifications}
|
||||
onChange={handleCheckboxChange}
|
||||
disabled={isLoading || permissionStatus === 'requesting...' || permissionStatus === 'not-supported'}
|
||||
/>
|
||||
<label htmlFor='enableLocalNotifications'>new replies received</label>
|
||||
|
||||
{/* Not supported message */}
|
||||
{permissionStatus === 'not-supported' && (
|
||||
<span className={styles.permissionStatus} data-status='not-supported'>
|
||||
<span className={styles.permissionStatusDenied}>Notifications are not supported on this system.</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Permission status messages */}
|
||||
{showDeniedMessage && permissionStatus === 'denied' && (
|
||||
<span className={styles.permissionStatus} data-status={permissionStatus}>
|
||||
<span className={styles.permissionStatusDenied}>
|
||||
{window.electronApi?.isElectron && platform === 'darwin'
|
||||
? 'Permission denied. Please go to System Settings > Notifications > Seedit and enable notifications.'
|
||||
: window.electronApi?.isElectron
|
||||
? `Permission denied. Please check your system's ${platform && `(${platform}) `} notification settings for this application.`
|
||||
: `Permission denied. Please allow notifications for this site in your browser settings.`}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{permissionStatus === 'requesting...' && (
|
||||
<span className={styles.permissionStatus} data-status={permissionStatus}>
|
||||
<span className={styles.permissionStatusRequesting}>Click "Allow" to enable notifications</span>
|
||||
</span>
|
||||
)}
|
||||
{permissionStatus === 'granted' && (
|
||||
<span className={styles.permissionStatus} data-status={permissionStatus}>
|
||||
<span className={styles.permissionStatusSuccess}>
|
||||
Success! You're done.
|
||||
<span className={styles.permissionStatusTestButton} onClick={showTestNotification}>
|
||||
(Test)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsSettings;
|
||||
@@ -12,12 +12,12 @@ import AvatarSettings from './avatar-settings';
|
||||
import PlebbitOptions from './plebbit-options';
|
||||
import ContentOptions from './content-options';
|
||||
import WalletSettings from './wallet-settings';
|
||||
import NotificationsSettings from './notifications-settings';
|
||||
import styles from './settings.module.css';
|
||||
import packageJson from '../../../package.json';
|
||||
import _ from 'lodash';
|
||||
|
||||
const commitRef = process.env.REACT_APP_COMMIT_REF;
|
||||
const isElectron = window.isElectron === true;
|
||||
const isAndroid = Capacitor.getPlatform() === 'android';
|
||||
|
||||
const CheckForUpdates = () => {
|
||||
@@ -33,7 +33,7 @@ const CheckForUpdates = () => {
|
||||
|
||||
if (packageJson.version !== packageData.version) {
|
||||
const newVersionText = t('new_stable_version', { newVersion: packageData.version, oldVersion: packageJson.version });
|
||||
const updateActionText = isElectron
|
||||
const updateActionText = window.electronApi?.isElectron
|
||||
? t('download_latest_desktop', { link: 'https://github.com/plebbit/seedit/releases/latest', interpolation: { escapeValue: false } })
|
||||
: isAndroid
|
||||
? t('download_latest_android')
|
||||
@@ -136,9 +136,6 @@ const DisplayNameSetting = () => {
|
||||
try {
|
||||
await setAccount({ ...account, author: { ...account?.author, displayName } });
|
||||
setSavedDisplayName(true);
|
||||
setTimeout(() => {
|
||||
setSavedDisplayName(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message);
|
||||
@@ -173,7 +170,7 @@ const GeneralSettings = () => {
|
||||
<span className={styles.categorySettings}>
|
||||
<div className={styles.version}>
|
||||
<Version />
|
||||
{isElectron && (
|
||||
{window.electronApi?.isElectron && (
|
||||
<a className={styles.fullNodeStats} href='http://localhost:50019/webui/' target='_blank' rel='noreferrer'>
|
||||
{t('node_stats')}
|
||||
</a>
|
||||
@@ -218,6 +215,12 @@ const GeneralSettings = () => {
|
||||
<WalletSettings />
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.category}>
|
||||
<span className={styles.categoryTitle}>{t('notifications')}</span>
|
||||
<span className={styles.categorySettings}>
|
||||
<NotificationsSettings />
|
||||
</span>
|
||||
</div>
|
||||
<div className={`${styles.category} ${location.hash === '#exportAccount' ? styles.highlightedSetting : ''}`} id='exportBackup'>
|
||||
<span className={styles.categoryTitle}>{t('account')}</span>
|
||||
<span className={styles.categorySettings}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useDropzone } from 'react-dropzone';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAccount, usePublishComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
|
||||
import Plebbit from '@plebbit/plebbit-js';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import FileUploader from '../../plugins/file-uploader';
|
||||
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
|
||||
|
||||
24
src/vite-env.d.ts
vendored
Normal file
24
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
declare module '*.svg' {
|
||||
import * as React from 'react';
|
||||
|
||||
export const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement> & { title?: string }>;
|
||||
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// Type declaration for the virtual PWA module
|
||||
declare module 'virtual:pwa-register' {
|
||||
export type RegisterSWOptions = {
|
||||
immediate?: boolean;
|
||||
onNeedRefresh?: () => void;
|
||||
onOfflineReady?: () => void;
|
||||
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void;
|
||||
onRegisterError?: (error: any) => void;
|
||||
};
|
||||
|
||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>;
|
||||
}
|
||||
@@ -36,6 +36,13 @@ export default defineConfig({
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'Seedit',
|
||||
|
||||
Reference in New Issue
Block a user