From 8616be12a291ff0db387b66ad3b0d53a46c3cc4c Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 29 Nov 2021 09:34:43 -0500 Subject: [PATCH] Emojis in notifications; server caching --- README.md | 2 +- server/index.gohtml | 27 +++--- server/server.go | 5 +- server/static/js/app.js | 105 +++++++++++----------- server/static/js/{emoji.json => emoji.js} | 5 +- util/embedfs.go | 79 ++++++++++++++++ 6 files changed, 159 insertions(+), 64 deletions(-) rename server/static/js/{emoji.json => emoji.js} (99%) create mode 100644 util/embedfs.go diff --git a/README.md b/README.md index 0710aedc..2aef15b3 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,6 @@ Third party libraries and resources: * [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 * [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) * [Statically linking go-sqlite3](https://www.arp242.net/static-go.html) diff --git a/server/index.gohtml b/server/index.gohtml index 3c248729..7c99451d 100644 --- a/server/index.gohtml +++ b/server/index.gohtml @@ -85,7 +85,7 @@ curl \
  -H "Title: Unauthorized access detected" \
  -H "Priority: urgent" \
-   -H "Tags: warn,skull" \
+   -H "Tags: warning,skull" \
  -d "Remote access to $(hostname) detected. Act right away." \
  ntfy.sh/mytopic @@ -228,16 +228,22 @@ curl -H "Title: Dogs are better than cats" -d "Oh my ..." ntfy.sh/mytopic
-

Tagging messages (X-Tags, Tags, or ta)

+

Tags & emojis 🥳 🎉 (X-Tags, Tags, or ta)

+

+ You can tag messages with emojis (or other relevant strings). If a tag matches a known emoji short code, + 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, ...). +

- You can tag notifications with emojis (or other relevant strings). In the phone app, the tags will be converted - to emojis and prepended to the message or title in the notification. You can set tags with the X-Tags header - (or any of its aliases: Tags, or ta). Use this reference - to figure out what tags you can use to send emojis. + You can set tags with the X-Tags header (or any of its aliases: Tags, or ta). + Use this reference + to figure out what tags can be converted to emojis. In the example below, the tag "warning" matches the emoji ⚠️, + the tag "ssh-login" doesn't match and will be displayed below the message.

- curl -H "Tags: warn,skull" -d "Unauthorized SSH access" ntfy.sh/mytopic
- curl -H tags:thumbsup -d "Backup successful" ntfy.sh/mytopic
+ $ curl -H "Tags: warning,ssh-login" -d "Unauthorized SSH access" ntfy.sh/mytopic
+ {"id":"ZEIwjfHlSS",...,"tags":["warning","ssh-login"],"message":"Unauthorized SSH access"}

Examples

@@ -257,7 +263,7 @@ rsync -a root@laptop /backups/laptop \
  && zfs snapshot ... \
  && curl -d "Laptop backup succeeded" ntfy.sh/backups \
-   || curl -H tags:warn -H prio:high -d "Laptop backup failed" ntfy.sh/backups +   || curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups

Example: Server-sent messages in your web app

@@ -284,7 +290,7 @@ #!/bin/bash
if [ "${PAM_TYPE}" = "open_session" ]; then
-   curl -H tags:warn -d "SSH login: ${PAM_USER} from ${PAM_RHOST}" ntfy.sh/alerts
+   curl -H tags:warning -d "SSH login: ${PAM_USER} from ${PAM_RHOST}" ntfy.sh/alerts
fi
@@ -405,6 +411,7 @@ + diff --git a/server/server.go b/server/server.go index 08ca68ce..ab46dbcc 100644 --- a/server/server.go +++ b/server/server.go @@ -92,7 +92,8 @@ var ( exampleSource string //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)} 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 { - http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r) + http.FileServer(http.FS(webStaticFsCached)).ServeHTTP(w, r) return nil } diff --git a/server/static/js/app.js b/server/static/js/app.js index 915302d0..b1cf4401 100644 --- a/server/static/js/app.js +++ b/server/static/js/app.js @@ -86,9 +86,10 @@ const subscribeInternal = (topic, persist, delaySec) => { } if (Notification.permission === "granted") { notifySound.play(); - const title = (event.title) ? event.title : `${location.host}/${topic}`; + const title = formatTitle(event); + const message = formatMessage(event); const notification = new Notification(title, { - body: event.message, + body: message, icon: '/static/img/favicon.png' }); notification.onclick = (e) => { @@ -158,56 +159,28 @@ const rerenderDetailView = () => { const messageDiv = 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'); dateDiv.classList.add('detailDate'); + titleDiv.classList.add('detailTitle'); + messageDiv.classList.add('detailMessage'); + tagsDiv.classList.add('detailTags'); + const dateStr = new Date(m.time * 1000).toLocaleString(); if (m.priority && [1,2,4,5].includes(m.priority)) { dateDiv.innerHTML = `${dateStr} `; } else { dateDiv.innerHTML = `${dateStr}`; } - messageDiv.classList.add('detailMessage'); - messageDiv.innerText = message; + messageDiv.innerText = formatMessage(m); entryDiv.appendChild(dateDiv); if (m.title) { - titleDiv.classList.add('detailTitle'); - titleDiv.innerText = title; + titleDiv.innerText = formatTitleA(m); entryDiv.appendChild(titleDiv); } entryDiv.appendChild(messageDiv); - if (unmappedTags) { - tagsDiv.classList.add('detailTags'); - tagsDiv.innerText = `Tags: ${unmappedTags}`; + const otherTags = unmatchedTags(m.tags); + if (otherTags.length > 0) { + tagsDiv.innerText = `Tags: ${otherTags.join(", ")}`; entryDiv.appendChild(tagsDiv); } detailEventsList.appendChild(entryDiv); @@ -311,10 +284,46 @@ const nextScreenshotKeyboardListener = (e) => { } }; -const toEmoji = (tag) => { - emojis +const formatTitle = (m) => { + 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 async function* makeTextFileLineIterator(fileURL) { const utf8Decoder = new TextDecoder('utf-8'); @@ -417,14 +426,10 @@ document.querySelectorAll('.ntfyProtocol').forEach((el) => { el.innerHTML = window.location.protocol + "//"; }); -// Fetch emojis +// Format emojis (see emoji.js) const emojis = {}; -fetch('static/js/emoji.json') - .then(response => response.json()) - .then(data => { - data.forEach(emoji => { - emoji.aliases.forEach(alias => { - emojis[alias] = emoji.emoji; - }); - }); +rawEmojis.forEach(emoji => { + emoji.aliases.forEach(alias => { + emojis[alias] = emoji.emoji; }); +}); diff --git a/server/static/js/emoji.json b/server/static/js/emoji.js similarity index 99% rename from server/static/js/emoji.json rename to server/static/js/emoji.js index aa7dbb0a..d763e29d 100644 --- a/server/static/js/emoji.json +++ b/server/static/js/emoji.js @@ -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": "😀" , "description": "grinning face" diff --git a/util/embedfs.go b/util/embedfs.go new file mode 100644 index 00000000..f06e3b1b --- /dev/null +++ b/util/embedfs.go @@ -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() +}