Poll on subscribe; test message
This commit is contained in:
parent
c57fac283e
commit
415ab57749
5 changed files with 89 additions and 9 deletions
24
web/src/app/Api.js
Normal file
24
web/src/app/Api.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import {topicUrlJsonPoll, fetchLinesIterator, topicUrl} from "./utils";
|
||||||
|
|
||||||
|
class Api {
|
||||||
|
static async poll(baseUrl, topic) {
|
||||||
|
const url = topicUrlJsonPoll(baseUrl, topic);
|
||||||
|
const messages = [];
|
||||||
|
console.log(`[Api] Polling ${url}`);
|
||||||
|
for await (let line of fetchLinesIterator(url)) {
|
||||||
|
messages.push(JSON.parse(line));
|
||||||
|
}
|
||||||
|
return messages.sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
|
||||||
|
}
|
||||||
|
|
||||||
|
static async publish(baseUrl, topic, message) {
|
||||||
|
const url = topicUrl(baseUrl, topic);
|
||||||
|
console.log(`[Api] Publishing message to ${url}`);
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Api;
|
|
@ -2,5 +2,39 @@ export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
||||||
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
|
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
|
||||||
.replaceAll("https://", "wss://")
|
.replaceAll("https://", "wss://")
|
||||||
.replaceAll("http://", "ws://");
|
.replaceAll("http://", "ws://");
|
||||||
|
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
|
||||||
|
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
|
||||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||||
export const shortTopicUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
export const shortTopicUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
||||||
|
|
||||||
|
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||||
|
export async function* fetchLinesIterator(fileURL) {
|
||||||
|
const utf8Decoder = new TextDecoder('utf-8');
|
||||||
|
const response = await fetch(fileURL);
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
let { value: chunk, done: readerDone } = await reader.read();
|
||||||
|
chunk = chunk ? utf8Decoder.decode(chunk) : '';
|
||||||
|
|
||||||
|
const re = /\n|\r|\r\n/gm;
|
||||||
|
let startIndex = 0;
|
||||||
|
let result;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
let result = re.exec(chunk);
|
||||||
|
if (!result) {
|
||||||
|
if (readerDone) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let remainder = chunk.substr(startIndex);
|
||||||
|
({ value: chunk, done: readerDone } = await reader.read());
|
||||||
|
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
|
||||||
|
startIndex = re.lastIndex = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield chunk.substring(startIndex, result.index);
|
||||||
|
startIndex = re.lastIndex;
|
||||||
|
}
|
||||||
|
if (startIndex < chunk.length) {
|
||||||
|
yield chunk.substr(startIndex); // last line didn't end in a newline char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import NotificationList from "./NotificationList";
|
||||||
import DetailSettingsIcon from "./DetailSettingsIcon";
|
import DetailSettingsIcon from "./DetailSettingsIcon";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import LocalStorage from "../app/Storage";
|
import LocalStorage from "../app/Storage";
|
||||||
|
import Api from "../app/Api";
|
||||||
|
|
||||||
const drawerWidth = 240;
|
const drawerWidth = 240;
|
||||||
|
|
||||||
|
@ -107,13 +108,19 @@ const App = () => {
|
||||||
const [selectedSubscription, setSelectedSubscription] = useState(null);
|
const [selectedSubscription, setSelectedSubscription] = useState(null);
|
||||||
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
|
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
|
||||||
const subscriptionChanged = (subscription) => {
|
const subscriptionChanged = (subscription) => {
|
||||||
setSubscriptions(prev => ({...prev, [subscription.id]: subscription})); // Fake-replace
|
setSubscriptions(prev => ({...prev, [subscription.id]: subscription}));
|
||||||
};
|
};
|
||||||
const handleSubscribeSubmit = (subscription) => {
|
const handleSubscribeSubmit = (subscription) => {
|
||||||
const connection = new WsConnection(subscription, subscriptionChanged);
|
const connection = new WsConnection(subscription, subscriptionChanged);
|
||||||
setSubscribeDialogOpen(false);
|
setSubscribeDialogOpen(false);
|
||||||
setSubscriptions(prev => ({...prev, [subscription.id]: subscription}));
|
setSubscriptions(prev => ({...prev, [subscription.id]: subscription}));
|
||||||
setConnections(prev => ({...prev, [subscription.id]: connection}));
|
setConnections(prev => ({...prev, [subscription.id]: connection}));
|
||||||
|
setSelectedSubscription(subscription);
|
||||||
|
Api.poll(subscription.baseUrl, subscription.topic)
|
||||||
|
.then(messages => {
|
||||||
|
messages.forEach(m => subscription.addNotification(m));
|
||||||
|
setSubscriptions(prev => ({...prev, [subscription.id]: subscription}));
|
||||||
|
});
|
||||||
connection.start();
|
connection.start();
|
||||||
};
|
};
|
||||||
const handleSubscribeCancel = () => {
|
const handleSubscribeCancel = () => {
|
||||||
|
@ -124,8 +131,11 @@ const App = () => {
|
||||||
setSubscriptions(prev => {
|
setSubscriptions(prev => {
|
||||||
const newSubscriptions = {...prev};
|
const newSubscriptions = {...prev};
|
||||||
delete newSubscriptions[subscription.id];
|
delete newSubscriptions[subscription.id];
|
||||||
if (newSubscriptions.length > 0) {
|
const newSubscriptionValues = Object.values(newSubscriptions);
|
||||||
setSelectedSubscription(newSubscriptions[0]);
|
if (newSubscriptionValues.length > 0) {
|
||||||
|
setSelectedSubscription(newSubscriptionValues[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedSubscription(null);
|
||||||
}
|
}
|
||||||
return newSubscriptions;
|
return newSubscriptions;
|
||||||
});
|
});
|
||||||
|
@ -184,12 +194,12 @@ const App = () => {
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ flexGrow: 1 }}
|
sx={{ flexGrow: 1 }}
|
||||||
>
|
>
|
||||||
{(selectedSubscription != null) ? selectedSubscription.shortUrl() : "ntfy.sh"}
|
{(selectedSubscription !== null) ? selectedSubscription.shortUrl() : "ntfy"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<DetailSettingsIcon
|
{selectedSubscription !== null && <DetailSettingsIcon
|
||||||
subscription={selectedSubscription}
|
subscription={selectedSubscription}
|
||||||
onUnsubscribe={handleUnsubscribe}
|
onUnsubscribe={handleUnsubscribe}
|
||||||
/>
|
/>}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<Drawer variant="permanent" open={drawerOpen}>
|
<Drawer variant="permanent" open={drawerOpen}>
|
||||||
|
|
|
@ -8,6 +8,7 @@ import MenuItem from '@mui/material/MenuItem';
|
||||||
import MenuList from '@mui/material/MenuList';
|
import MenuList from '@mui/material/MenuList';
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
|
import Api from "../app/Api";
|
||||||
|
|
||||||
// Originally from https://mui.com/components/menus/#MenuListComposition.js
|
// Originally from https://mui.com/components/menus/#MenuListComposition.js
|
||||||
const DetailSettingsIcon = (props) => {
|
const DetailSettingsIcon = (props) => {
|
||||||
|
@ -23,9 +24,20 @@ const DetailSettingsIcon = (props) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnsubscribe = (event) => {
|
||||||
|
handleClose(event);
|
||||||
props.onUnsubscribe(props.subscription);
|
props.onUnsubscribe(props.subscription);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendTestMessage = () => {
|
||||||
|
const baseUrl = props.subscription.baseUrl;
|
||||||
|
const topic = props.subscription.topic;
|
||||||
|
Api.publish(baseUrl, topic, `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`); // FIXME result ignored
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
function handleListKeyDown(event) {
|
function handleListKeyDown(event) {
|
||||||
if (event.key === 'Tab') {
|
if (event.key === 'Tab') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
@ -84,8 +96,8 @@ const DetailSettingsIcon = (props) => {
|
||||||
aria-labelledby="composition-button"
|
aria-labelledby="composition-button"
|
||||||
onKeyDown={handleListKeyDown}
|
onKeyDown={handleListKeyDown}
|
||||||
>
|
>
|
||||||
<MenuItem onClick={handleClose}>Send test notification</MenuItem>
|
<MenuItem onClick={handleSendTestMessage}>Send test notification</MenuItem>
|
||||||
<MenuItem onClick={handleClose}>Unsubscribe</MenuItem>
|
<MenuItem onClick={handleUnsubscribe}>Unsubscribe</MenuItem>
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</ClickAwayListener>
|
</ClickAwayListener>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
|
@ -26,7 +26,7 @@ const NotificationItem = (props) => {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography sx={{ fontSize: 14 }} color="text.secondary">{date}</Typography>
|
<Typography sx={{ fontSize: 14 }} color="text.secondary">{date}</Typography>
|
||||||
{notification.title && <Typography variant="h5" component="div">{notification.title}</Typography>}
|
{notification.title && <Typography variant="h5" component="div">{notification.title}</Typography>}
|
||||||
<Typography variant="body1" gutterBottom>{notification.message}</Typography>
|
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>{notification.message}</Typography>
|
||||||
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
|
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
Loading…
Reference in a new issue