Emojis in notifications; server caching
parent
052ab7d411
commit
8616be12a2
|
@ -213,6 +213,6 @@ Third party libraries and resources:
|
||||||
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
||||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
||||||
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
||||||
* [emoji-java](https://github.com/vdurmont/emoji-java) (MIT) is used for emoji support (the emoji.json file only)
|
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)
|
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)
|
||||||
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
||||||
|
|
|
@ -85,7 +85,7 @@
|
||||||
curl \<br/>
|
curl \<br/>
|
||||||
-H "Title: Unauthorized access detected" \<br/>
|
-H "Title: Unauthorized access detected" \<br/>
|
||||||
-H "Priority: urgent" \<br/>
|
-H "Priority: urgent" \<br/>
|
||||||
-H "Tags: warn,skull" \<br/>
|
-H "Tags: warning,skull" \<br/>
|
||||||
-d "Remote access to $(hostname) detected. Act right away." \<br/>
|
-d "Remote access to $(hostname) detected. Act right away." \<br/>
|
||||||
<span class="ntfyUrl">ntfy.sh</span>/mytopic
|
<span class="ntfyUrl">ntfy.sh</span>/mytopic
|
||||||
</code>
|
</code>
|
||||||
|
@ -228,16 +228,22 @@
|
||||||
curl -H "Title: Dogs are better than cats" -d "Oh my ..." <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
|
curl -H "Title: Dogs are better than cats" -d "Oh my ..." <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
|
||||||
</code>
|
</code>
|
||||||
|
|
||||||
<h3 id="tags" class="anchor">Tagging messages (<tt>X-Tags</tt>, <tt>Tags</tt>, or <tt>ta</tt>)</h3>
|
<h3 id="tags" class="anchor">Tags & emojis 🥳 🎉 (<tt>X-Tags</tt>, <tt>Tags</tt>, or <tt>ta</tt>)</h3>
|
||||||
|
<p>
|
||||||
|
You can tag messages with emojis (or other relevant strings). If a tag matches a <a href="https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json">known emoji short code</a>,
|
||||||
|
it will be converted to an emoji. If it doesn't match, it will be listed below the notification. This is useful
|
||||||
|
for things like warnings and such (⚠️, ️🚨, or 🚩), but also to simply tag messages otherwise (e.g. which script the
|
||||||
|
message came from, ...).
|
||||||
|
</p>
|
||||||
<p class="smallMarginBottom">
|
<p class="smallMarginBottom">
|
||||||
You can tag notifications with emojis (or other relevant strings). In the phone app, the tags will be converted
|
You can set tags with the <tt>X-Tags</tt> header (or any of its aliases: <tt>Tags</tt>, or <tt>ta</tt>).
|
||||||
to emojis and prepended to the message or title in the notification. You can set tags with the <tt>X-Tags</tt> header
|
Use <a href="https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json">this reference</a>
|
||||||
(or any of its aliases: <tt>Tags</tt>, or <tt>ta</tt>). Use <a href="https://github.com/vdurmont/emoji-java/blob/master/EMOJIS.md">this reference</a>
|
to figure out what tags can be converted to emojis. In the example below, the tag "warning" matches the emoji ⚠️,
|
||||||
to figure out what tags you can use to send emojis.
|
the tag "ssh-login" doesn't match and will be displayed below the message.
|
||||||
</p>
|
</p>
|
||||||
<code>
|
<code>
|
||||||
curl -H "Tags: warn,skull" -d "Unauthorized SSH access" <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
|
$ curl -H "Tags: warning,ssh-login" -d "Unauthorized SSH access" <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
|
||||||
curl -H tags:thumbsup -d "Backup successful" <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
|
{"id":"ZEIwjfHlSS",...,"tags":["warning","ssh-login"],"message":"Unauthorized SSH access"}
|
||||||
</code>
|
</code>
|
||||||
|
|
||||||
<h2 id="examples" class="anchor">Examples</h2>
|
<h2 id="examples" class="anchor">Examples</h2>
|
||||||
|
@ -257,7 +263,7 @@
|
||||||
rsync -a root@laptop /backups/laptop \<br/>
|
rsync -a root@laptop /backups/laptop \<br/>
|
||||||
&& zfs snapshot ... \<br/>
|
&& zfs snapshot ... \<br/>
|
||||||
&& curl -d "Laptop backup succeeded" <span class="ntfyUrl">ntfy.sh</span>/backups \<br/>
|
&& curl -d "Laptop backup succeeded" <span class="ntfyUrl">ntfy.sh</span>/backups \<br/>
|
||||||
|| curl -H tags:warn -H prio:high -d "Laptop backup failed" <span class="ntfyUrl">ntfy.sh</span>/backups
|
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" <span class="ntfyUrl">ntfy.sh</span>/backups
|
||||||
</code>
|
</code>
|
||||||
|
|
||||||
<h3 id="example-web" class="anchor">Example: Server-sent messages in your web app</h3>
|
<h3 id="example-web" class="anchor">Example: Server-sent messages in your web app</h3>
|
||||||
|
@ -284,7 +290,7 @@
|
||||||
<code>
|
<code>
|
||||||
#!/bin/bash<br/>
|
#!/bin/bash<br/>
|
||||||
if [ "${PAM_TYPE}" = "open_session" ]; then<br/>
|
if [ "${PAM_TYPE}" = "open_session" ]; then<br/>
|
||||||
curl -H tags:warn -d "SSH login: ${PAM_USER} from ${PAM_RHOST}" <span class="ntfyUrl">ntfy.sh</span>/alerts<br/>
|
curl -H tags:warning -d "SSH login: ${PAM_USER} from ${PAM_RHOST}" <span class="ntfyUrl">ntfy.sh</span>/alerts<br/>
|
||||||
fi
|
fi
|
||||||
</code>
|
</code>
|
||||||
|
|
||||||
|
@ -405,6 +411,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="lightbox" class="lightbox"></div>
|
<div id="lightbox" class="lightbox"></div>
|
||||||
|
<script src="static/js/emoji.js"></script>
|
||||||
<script src="static/js/app.js"></script>
|
<script src="static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -93,6 +93,7 @@ var (
|
||||||
|
|
||||||
//go:embed static
|
//go:embed static
|
||||||
webStaticFs embed.FS
|
webStaticFs embed.FS
|
||||||
|
webStaticFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webStaticFs}
|
||||||
|
|
||||||
errHTTPBadRequest = &errHTTP{http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}
|
errHTTPBadRequest = &errHTTP{http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}
|
||||||
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
|
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
|
||||||
|
@ -231,7 +232,7 @@ func (s *Server) handleExample(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
|
||||||
http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r)
|
http.FileServer(http.FS(webStaticFsCached)).ServeHTTP(w, r)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -86,9 +86,10 @@ const subscribeInternal = (topic, persist, delaySec) => {
|
||||||
}
|
}
|
||||||
if (Notification.permission === "granted") {
|
if (Notification.permission === "granted") {
|
||||||
notifySound.play();
|
notifySound.play();
|
||||||
const title = (event.title) ? event.title : `${location.host}/${topic}`;
|
const title = formatTitle(event);
|
||||||
|
const message = formatMessage(event);
|
||||||
const notification = new Notification(title, {
|
const notification = new Notification(title, {
|
||||||
body: event.message,
|
body: message,
|
||||||
icon: '/static/img/favicon.png'
|
icon: '/static/img/favicon.png'
|
||||||
});
|
});
|
||||||
notification.onclick = (e) => {
|
notification.onclick = (e) => {
|
||||||
|
@ -158,56 +159,28 @@ const rerenderDetailView = () => {
|
||||||
const messageDiv = document.createElement('div');
|
const messageDiv = document.createElement('div');
|
||||||
const tagsDiv = document.createElement('div');
|
const tagsDiv = document.createElement('div');
|
||||||
|
|
||||||
// Figure out mapped emojis (and unmapped tags)
|
|
||||||
let mappedEmojiTags = '';
|
|
||||||
let unmappedTags = '';
|
|
||||||
if (m.tags) {
|
|
||||||
mappedEmojiTags = m.tags
|
|
||||||
.filter(tag => tag in emojis)
|
|
||||||
.map(tag => emojis[tag])
|
|
||||||
.join("");
|
|
||||||
unmappedTags = m.tags
|
|
||||||
.filter(tag => !(tag in emojis))
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figure out title and message
|
|
||||||
let title = '';
|
|
||||||
let message = m.message;
|
|
||||||
if (m.title) {
|
|
||||||
if (mappedEmojiTags) {
|
|
||||||
title = `${mappedEmojiTags} ${m.title}`;
|
|
||||||
} else {
|
|
||||||
title = m.title;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mappedEmojiTags) {
|
|
||||||
message = `${mappedEmojiTags} ${m.message}`;
|
|
||||||
} else {
|
|
||||||
message = m.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entryDiv.classList.add('detailEntry');
|
entryDiv.classList.add('detailEntry');
|
||||||
dateDiv.classList.add('detailDate');
|
dateDiv.classList.add('detailDate');
|
||||||
|
titleDiv.classList.add('detailTitle');
|
||||||
|
messageDiv.classList.add('detailMessage');
|
||||||
|
tagsDiv.classList.add('detailTags');
|
||||||
|
|
||||||
const dateStr = new Date(m.time * 1000).toLocaleString();
|
const dateStr = new Date(m.time * 1000).toLocaleString();
|
||||||
if (m.priority && [1,2,4,5].includes(m.priority)) {
|
if (m.priority && [1,2,4,5].includes(m.priority)) {
|
||||||
dateDiv.innerHTML = `${dateStr} <img src="static/img/priority-${m.priority}.svg"/>`;
|
dateDiv.innerHTML = `${dateStr} <img src="static/img/priority-${m.priority}.svg"/>`;
|
||||||
} else {
|
} else {
|
||||||
dateDiv.innerHTML = `${dateStr}`;
|
dateDiv.innerHTML = `${dateStr}`;
|
||||||
}
|
}
|
||||||
messageDiv.classList.add('detailMessage');
|
messageDiv.innerText = formatMessage(m);
|
||||||
messageDiv.innerText = message;
|
|
||||||
entryDiv.appendChild(dateDiv);
|
entryDiv.appendChild(dateDiv);
|
||||||
if (m.title) {
|
if (m.title) {
|
||||||
titleDiv.classList.add('detailTitle');
|
titleDiv.innerText = formatTitleA(m);
|
||||||
titleDiv.innerText = title;
|
|
||||||
entryDiv.appendChild(titleDiv);
|
entryDiv.appendChild(titleDiv);
|
||||||
}
|
}
|
||||||
entryDiv.appendChild(messageDiv);
|
entryDiv.appendChild(messageDiv);
|
||||||
if (unmappedTags) {
|
const otherTags = unmatchedTags(m.tags);
|
||||||
tagsDiv.classList.add('detailTags');
|
if (otherTags.length > 0) {
|
||||||
tagsDiv.innerText = `Tags: ${unmappedTags}`;
|
tagsDiv.innerText = `Tags: ${otherTags.join(", ")}`;
|
||||||
entryDiv.appendChild(tagsDiv);
|
entryDiv.appendChild(tagsDiv);
|
||||||
}
|
}
|
||||||
detailEventsList.appendChild(entryDiv);
|
detailEventsList.appendChild(entryDiv);
|
||||||
|
@ -311,10 +284,46 @@ const nextScreenshotKeyboardListener = (e) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toEmoji = (tag) => {
|
const formatTitle = (m) => {
|
||||||
emojis
|
if (m.title) {
|
||||||
|
return formatTitleA(m);
|
||||||
|
} else {
|
||||||
|
return `${location.host}/${m.topic}`;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTitleA = (m) => {
|
||||||
|
const emojiList = toEmojis(m.tags);
|
||||||
|
if (emojiList) {
|
||||||
|
return `${emojiList.join(" ")} ${m.title}`;
|
||||||
|
} else {
|
||||||
|
return m.title;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMessage = (m) => {
|
||||||
|
if (m.title) {
|
||||||
|
return m.message;
|
||||||
|
} else {
|
||||||
|
const emojiList = toEmojis(m.tags);
|
||||||
|
if (emojiList) {
|
||||||
|
return `${emojiList.join(" ")} ${m.message}`;
|
||||||
|
} else {
|
||||||
|
return m.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toEmojis = (tags) => {
|
||||||
|
if (!tags) return [];
|
||||||
|
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmatchedTags = (tags) => {
|
||||||
|
if (!tags) return [];
|
||||||
|
else return tags.filter(tag => !(tag in emojis));
|
||||||
|
}
|
||||||
|
|
||||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||||
async function* makeTextFileLineIterator(fileURL) {
|
async function* makeTextFileLineIterator(fileURL) {
|
||||||
const utf8Decoder = new TextDecoder('utf-8');
|
const utf8Decoder = new TextDecoder('utf-8');
|
||||||
|
@ -417,14 +426,10 @@ document.querySelectorAll('.ntfyProtocol').forEach((el) => {
|
||||||
el.innerHTML = window.location.protocol + "//";
|
el.innerHTML = window.location.protocol + "//";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch emojis
|
// Format emojis (see emoji.js)
|
||||||
const emojis = {};
|
const emojis = {};
|
||||||
fetch('static/js/emoji.json')
|
rawEmojis.forEach(emoji => {
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
data.forEach(emoji => {
|
|
||||||
emoji.aliases.forEach(alias => {
|
emoji.aliases.forEach(alias => {
|
||||||
emojis[alias] = emoji.emoji;
|
emojis[alias] = emoji.emoji;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
[
|
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||||
|
// Manually prepended "const rawEmojis = " to make it play nice with JS/HTML.
|
||||||
|
|
||||||
|
const rawEmojis = [
|
||||||
{
|
{
|
||||||
"emoji": "😀"
|
"emoji": "😀"
|
||||||
, "description": "grinning face"
|
, "description": "grinning face"
|
|
@ -0,0 +1,79 @@
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CachingEmbedFS struct {
|
||||||
|
ModTime time.Time
|
||||||
|
FS embed.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CachingEmbedFS) Open(name string) (fs.File, error) {
|
||||||
|
f, err := e.FS.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cachingEmbedFile{f, e.ModTime}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachingEmbedFile struct {
|
||||||
|
file fs.File
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f cachingEmbedFile) Stat() (fs.FileInfo, error) {
|
||||||
|
s, err := f.file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &etagEmbedFileInfo{s, f.modTime}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f cachingEmbedFile) Read(bytes []byte) (int, error) {
|
||||||
|
return f.file.Read(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *cachingEmbedFile) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
if seeker, ok := f.file.(io.Seeker); ok {
|
||||||
|
return seeker.Seek(offset, whence)
|
||||||
|
}
|
||||||
|
return 0, errors.New("io.Seeker not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f cachingEmbedFile) Close() error {
|
||||||
|
return f.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type etagEmbedFileInfo struct {
|
||||||
|
file fs.FileInfo
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e etagEmbedFileInfo) Name() string {
|
||||||
|
return e.file.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e etagEmbedFileInfo) Size() int64 {
|
||||||
|
return e.file.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e etagEmbedFileInfo) Mode() fs.FileMode {
|
||||||
|
return e.file.Mode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e etagEmbedFileInfo) ModTime() time.Time {
|
||||||
|
return e.modTime // We override this!
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e etagEmbedFileInfo) IsDir() bool {
|
||||||
|
return e.file.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e etagEmbedFileInfo) Sys() interface{} {
|
||||||
|
return e.file.Sys()
|
||||||
|
}
|
Loading…
Reference in New Issue