1
0
Fork 0
forked from mirrors/ntfy

Detail page in web UI

This commit is contained in:
Philipp Heckel 2021-11-08 09:24:34 -05:00
parent c01c94c64c
commit 43c9a92748
6 changed files with 329 additions and 36 deletions

View file

@ -1,3 +1,4 @@
{{- /*gotype: heckel.io/ntfy/server.indexPage*/ -}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -27,12 +28,16 @@
<meta property="og:description" content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." /> <meta property="og:description" content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
<meta property="og:image" content="/static/img/ntfy.png" /> <meta property="og:image" content="/static/img/ntfy.png" />
<meta property="og:url" content="https://ntfy.sh" /> <meta property="og:url" content="https://ntfy.sh" />
{{if .Topic}}
<!-- Never index topic page -->
<meta name="robots" content="noindex, nofollow" />
{{end}}
</head> </head>
<body> <body>
<div id="main"> <div id="main"{{if .Topic}} style="display: none"{{end}}>
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/>ntfy.sh - simple HTTP-based pub-sub</h1> <h1><img src="static/img/ntfy.png" alt="ntfy"/><br/>ntfy.sh | simple HTTP-based pub-sub</h1>
<p> <p>
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification service. <b>Ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification service.
It allows you to send notifications <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy">to your phone</a> or desktop via scripts from any computer, It allows you to send notifications <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy">to your phone</a> or desktop via scripts from any computer,
entirely <b>without signup or cost</b>. It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own. entirely <b>without signup or cost</b>. It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
</p> </p>
@ -79,7 +84,7 @@
<form id="subscribeForm"> <form id="subscribeForm">
<p> <p>
<b>Topic:</b><br/> <b>Topic:</b><br/>
<input type="text" id="topicField" autocomplete="off" placeholder="Topic name, e.g. phil_alerts" pattern="[-_A-Za-z]{1,64}" /> <input type="text" id="topicField" autocomplete="off" placeholder="Topic name, e.g. phil_alerts" maxlength="64" pattern="[-_A-Za-z0-9]{1,64}" />
<button id="subscribeButton">Subscribe</button> <button id="subscribeButton">Subscribe</button>
</p> </p>
<p id="topicsHeader"><b>Subscribed topics:</b></p> <p id="topicsHeader"><b>Subscribed topics:</b></p>
@ -209,6 +214,31 @@
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center> <center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
</div> </div>
<div id="detail"{{if not .Topic}} style="display: none"{{end}}>
<div id="detailMain">
<button id="detailCloseButton"><img src="static/img/close_black_24dp.svg"/></button>
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/><span id="detailTitle"></span></h1>
<p class="smallMarginBottom">
<b>Ntfy</b> is a simple HTTP-based pub-sub notification service. This is a Ntfy topic.
To send notifications to it, simply PUT or POST to the topic URL. Here's an example using <tt>curl</tt>:
</p>
<code>
curl -d "Backup failed" <span id="detailTopicUrl"></span>
</code>
<p id="detailNotificationsDisallowed">
If you'd like to receive desktop notifications when new messages arrive on this topic, you have
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
Click the link to do so.
</p>
<p class="smallMarginBottom">
<b>Recent notifications</b> (cached for {{.CacheDuration}}):
</p>
<p id="detailNoNotifications">
<i>You haven't received any notifications for this topic yet.</i>
</p>
<div id="detailEventsList"></div>
</div>
</div>
<script src="static/js/app.js"></script> <script src="static/js/app.js"></script>
</body> </body>
</html> </html>

View file

@ -11,6 +11,8 @@ import (
"fmt" "fmt"
"google.golang.org/api/option" "google.golang.org/api/option"
"heckel.io/ntfy/config" "heckel.io/ntfy/config"
"heckel.io/ntfy/util"
"html/template"
"io" "io"
"log" "log"
"net" "net"
@ -46,20 +48,26 @@ func (e errHTTP) Error() string {
return fmt.Sprintf("http: %s", e.Status) return fmt.Sprintf("http: %s", e.Status)
} }
type indexPage struct {
Topic string
CacheDuration string
}
const ( const (
messageLimit = 512 messageLimit = 512
) )
var ( var (
topicRegex = regexp.MustCompile(`^/[^/]+$`) topicRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`) jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/json$`)
sseRegex = regexp.MustCompile(`^/[^/]+/sse$`) sseRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/sse$`)
rawRegex = regexp.MustCompile(`^/[^/]+/raw$`) rawRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/raw$`)
staticRegex = regexp.MustCompile(`^/static/.+`) staticRegex = regexp.MustCompile(`^/static/.+`)
//go:embed "index.html" //go:embed "index.gohtml"
indexSource string indexSource string
indexTemplate = template.Must(template.New("index").Parse(indexSource))
//go:embed static //go:embed static
webStaticFs embed.FS webStaticFs embed.FS
@ -159,7 +167,7 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
if r.Method == http.MethodGet && r.URL.Path == "/" { if r.Method == http.MethodGet && (r.URL.Path == "/" || topicRegex.MatchString(r.URL.Path)) {
return s.handleHome(w, r) return s.handleHome(w, r)
} else if r.Method == http.MethodHead && r.URL.Path == "/" { } else if r.Method == http.MethodHead && r.URL.Path == "/" {
return s.handleEmpty(w, r) return s.handleEmpty(w, r)
@ -180,8 +188,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
} }
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
_, err := io.WriteString(w, indexSource) return indexTemplate.Execute(w, &indexPage{
return err Topic: r.URL.Path[1:],
CacheDuration: util.DurationToHuman(s.config.CacheDuration),
})
} }
func (s *Server) handleEmpty(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleEmpty(w http.ResponseWriter, r *http.Request) error {

View file

@ -6,6 +6,12 @@ html, body {
font-size: 1.1em; font-size: 1.1em;
} }
html {
/* prevent scrollbar from repositioning website:
* https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
overflow-y: scroll;
}
a, a:visited { a, a:visited {
color: #3a9784; color: #3a9784;
} }
@ -107,7 +113,7 @@ button:hover {
ul { ul {
padding-left: 1em; padding-left: 1em;
list-style-type: none; list-style-type: circle;
padding-bottom: 0; padding-bottom: 0;
margin: 0; margin: 0;
} }
@ -146,7 +152,6 @@ li {
#subscribeBox ul { #subscribeBox ul {
margin: 0; margin: 0;
padding: 0;
} }
#subscribeBox li { #subscribeBox li {
@ -160,6 +165,10 @@ li {
vertical-align: bottom; vertical-align: bottom;
} }
#subscribeBox li a {
padding: 0 5px 0 0;
}
#subscribeBox button { #subscribeBox button {
font-size: 0.8em; font-size: 0.8em;
background: #3a9784; background: #3a9784;
@ -202,7 +211,6 @@ li {
#subscribeBox ul { #subscribeBox ul {
margin: 0; margin: 0;
padding: 0;
} }
#subscribeBox input { #subscribeBox input {
@ -228,6 +236,10 @@ li {
vertical-align: bottom; vertical-align: bottom;
} }
#subscribeBox li a {
padding: 0 5px 0 0;
}
#subscribeBox button { #subscribeBox button {
font-size: 0.7em; font-size: 0.7em;
background: #3a9784; background: #3a9784;
@ -240,7 +252,62 @@ li {
#subscribeBox button:hover { #subscribeBox button:hover {
background: #317f6f; background: #317f6f;
} }
} }
/** Detail view */
#detail {
display: none;
position: absolute;
z-index: 1;
left: 8px;
right: 8px;
top: 0;
bottom: 0;
background: white;
}
#detail .detailDate {
color: #888;
font-size: 0.9em;
}
#detail .detailMessage {
margin-bottom: 20px;
font-size: 1.1em;
}
#detail #detailMain {
max-width: 900px;
margin: 0 auto 50px auto;
position: relative; /* required for close button's "position: absolute" */
}
#detail #detailCloseButton {
background: #eee;
border-radius: 5px;
border: none;
padding: 5px;
position: absolute;
right: 0;
top: 10px;
display: block;
}
#detail #detailCloseButton:hover {
padding: 5px;
background: #ccc;
}
#detail #detailCloseButton img {
display: block; /* get rid of the weird bottom border */
}
#detail #detailNotificationsDisallowed {
display: none;
color: darkred;
}
#detail #events {
max-width: 900px;
margin: 0 auto 50px auto;
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

View file

@ -10,36 +10,50 @@
/* All the things */ /* All the things */
let topics = {}; let topics = {};
let currentTopic = "";
let currentTopicUnsubscribeOnClose = false;
/* Main view */
const main = document.getElementById("main");
const topicsHeader = document.getElementById("topicsHeader"); const topicsHeader = document.getElementById("topicsHeader");
const topicsList = document.getElementById("topicsList"); const topicsList = document.getElementById("topicsList");
const topicField = document.getElementById("topicField"); const topicField = document.getElementById("topicField");
const notifySound = document.getElementById("notifySound"); const notifySound = document.getElementById("notifySound");
const subscribeButton = document.getElementById("subscribeButton"); const subscribeButton = document.getElementById("subscribeButton");
const errorField = document.getElementById("error"); const errorField = document.getElementById("error");
const originalTitle = document.title;
/* Detail view */
const detailView = document.getElementById("detail");
const detailTitle = document.getElementById("detailTitle");
const detailEventsList = document.getElementById("detailEventsList");
const detailTopicUrl = document.getElementById("detailTopicUrl");
const detailNoNotifications = document.getElementById("detailNoNotifications");
const detailCloseButton = document.getElementById("detailCloseButton");
const detailNotificationsDisallowed = document.getElementById("detailNotificationsDisallowed");
const subscribe = (topic) => { const subscribe = (topic) => {
if (Notification.permission !== "granted") { if (Notification.permission !== "granted") {
Notification.requestPermission().then((permission) => { Notification.requestPermission().then((permission) => {
if (permission === "granted") { if (permission === "granted") {
subscribeInternal(topic, 0); subscribeInternal(topic, true, 0);
} else { } else {
showNotificationDeniedError(); showNotificationDeniedError();
} }
}); });
} else { } else {
subscribeInternal(topic, 0); subscribeInternal(topic, true,0);
} }
}; };
const subscribeInternal = (topic, delaySec) => { const subscribeInternal = (topic, persist, delaySec) => {
setTimeout(() => { setTimeout(() => {
// Render list entry // Render list entry
let topicEntry = document.getElementById(`topic-${topic}`); let topicEntry = document.getElementById(`topic-${topic}`);
if (!topicEntry) { if (!topicEntry) {
topicEntry = document.createElement('li'); topicEntry = document.createElement('li');
topicEntry.id = `topic-${topic}`; topicEntry.id = `topic-${topic}`;
topicEntry.innerHTML = `${topic} <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`; topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
topicsList.appendChild(topicEntry); topicsList.appendChild(topicEntry);
} }
topicsHeader.style.display = ''; topicsHeader.style.display = '';
@ -47,30 +61,47 @@ const subscribeInternal = (topic, delaySec) => {
// Open event source // Open event source
let eventSource = new EventSource(`${topic}/sse`); let eventSource = new EventSource(`${topic}/sse`);
eventSource.onopen = () => { eventSource.onopen = () => {
topicEntry.innerHTML = `${topic} <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`; topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
delaySec = 0; // Reset on successful connection delaySec = 0; // Reset on successful connection
}; };
eventSource.onerror = (e) => { eventSource.onerror = (e) => {
topicEntry.innerHTML = `${topic} <i>(Reconnecting)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}'); return false;">Unsubscribe</button>`; topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <i>(Reconnecting)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}'); return false;">Unsubscribe</button>`;
eventSource.close(); eventSource.close();
const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15; const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15;
subscribeInternal(topic, newDelaySec); subscribeInternal(topic, persist, newDelaySec);
}; };
eventSource.onmessage = (e) => { eventSource.onmessage = (e) => {
const event = JSON.parse(e.data); const event = JSON.parse(e.data);
notifySound.play(); topics[topic]['messages'].push(event);
new Notification(`${location.host}/${topic}`, { topics[topic]['messages'].sort((a, b) => { return a.time < b.time; }) // Newest first
body: event.message, if (currentTopic === topic) {
icon: '/static/img/favicon.png' rerenderDetailView();
}); }
if (Notification.permission === "granted") {
notifySound.play();
new Notification(`${location.host}/${topic}`, {
body: event.message,
icon: '/static/img/favicon.png'
});
}
}; };
topics[topic] = eventSource; topics[topic] = {
localStorage.setItem('topics', JSON.stringify(Object.keys(topics))); 'eventSource': eventSource,
'messages': [],
'persist': persist
};
fetchCachedMessages(topic).then(() => {
if (currentTopic === topic) {
rerenderDetailView();
}
})
let persistedTopicKeys = Object.keys(topics).filter(t => topics[t].persist);
localStorage.setItem('topics', JSON.stringify(persistedTopicKeys));
}, delaySec * 1000); }, delaySec * 1000);
}; };
const unsubscribe = (topic) => { const unsubscribe = (topic) => {
topics[topic].close(); topics[topic]['eventSource'].close();
delete topics[topic]; delete topics[topic];
localStorage.setItem('topics', JSON.stringify(Object.keys(topics))); localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
document.getElementById(`topic-${topic}`).remove(); document.getElementById(`topic-${topic}`).remove();
@ -83,7 +114,79 @@ const test = (topic) => {
fetch(`/${topic}`, { fetch(`/${topic}`, {
method: 'PUT', method: 'PUT',
body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.` body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`
});
};
const fetchCachedMessages = async (topic) => {
const topicJsonUrl = `/${topic}/json?poll=1&since=12h`; // Poll!
for await (let line of makeTextFileLineIterator(topicJsonUrl)) {
const message = JSON.parse(line);
topics[topic]['messages'].push(message);
}
topics[topic]['messages'].sort((a, b) => { return a.time < b.time; }) // Newest first
};
const showDetail = (topic) => {
currentTopic = topic;
history.replaceState(topic, `ntfy.sh/${topic}`, `/${topic}`);
window.scrollTo(0, 0);
rerenderDetailView();
return false;
};
const rerenderDetailView = () => {
detailTitle.innerHTML = `ntfy.sh/${currentTopic}`; // document.location.replaceAll(..)
detailTopicUrl.innerHTML = `ntfy.sh/${currentTopic}`;
while (detailEventsList.firstChild) {
detailEventsList.removeChild(detailEventsList.firstChild);
}
topics[currentTopic]['messages'].forEach(m => {
let dateDiv = document.createElement('div');
let messageDiv = document.createElement('div');
let eventDiv = document.createElement('div');
dateDiv.classList.add('detailDate');
dateDiv.innerHTML = new Date(m.time * 1000).toLocaleString();
messageDiv.classList.add('detailMessage');
messageDiv.innerText = m.message;
eventDiv.appendChild(dateDiv);
eventDiv.appendChild(messageDiv);
detailEventsList.appendChild(eventDiv);
}) })
if (topics[currentTopic]['messages'].length === 0) {
detailNoNotifications.style.display = '';
} else {
detailNoNotifications.style.display = 'none';
}
if (Notification.permission === "granted") {
detailNotificationsDisallowed.style.display = 'none';
} else {
detailNotificationsDisallowed.style.display = 'block';
}
detailView.style.display = 'block';
main.style.display = 'none';
};
const hideDetailView = () => {
if (currentTopicUnsubscribeOnClose) {
unsubscribe(currentTopic);
currentTopicUnsubscribeOnClose = false;
}
currentTopic = "";
history.replaceState('', originalTitle, '/');
detailView.style.display = 'none';
main.style.display = '';
return false;
};
const requestPermission = () => {
if (Notification.permission !== "granted") {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
detailNotificationsDisallowed.style.display = 'none';
}
});
}
return false;
}; };
const showError = (msg) => { const showError = (msg) => {
@ -100,7 +203,39 @@ const showNotificationDeniedError = () => {
showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications."); showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
}; };
subscribeButton.onclick = function () { // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
async function* makeTextFileLineIterator(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
}
}
subscribeButton.onclick = () => {
if (!topicField.value) { if (!topicField.value) {
return false; return false;
} }
@ -109,6 +244,10 @@ subscribeButton.onclick = function () {
return false; return false;
}; };
detailCloseButton.onclick = () => {
hideDetailView();
};
// Disable Web UI if notifications of EventSource are not available // Disable Web UI if notifications of EventSource are not available
if (!window["Notification"] || !window["EventSource"]) { if (!window["Notification"] || !window["EventSource"]) {
showBrowserIncompatibleError(); showBrowserIncompatibleError();
@ -119,14 +258,27 @@ if (!window["Notification"] || !window["EventSource"]) {
// Reset UI // Reset UI
topicField.value = ""; topicField.value = "";
// (Temporarily) subscribe topic if we navigated to /sometopic URL
const match = location.pathname.match(/^\/([-_a-zA-Z0-9]{1,64})$/) // Regex must match Go & Android app!
if (match) {
currentTopic = match[1];
subscribeInternal(currentTopic, false,0);
}
// Restore topics // Restore topics
const storedTopics = localStorage.getItem('topics'); const storedTopics = localStorage.getItem('topics');
if (storedTopics && Notification.permission === "granted") { if (storedTopics) {
const storedTopicsArray = JSON.parse(storedTopics) const storedTopicsArray = JSON.parse(storedTopics);
storedTopicsArray.forEach((topic) => { subscribeInternal(topic, 0); }); storedTopicsArray.forEach((topic) => { subscribeInternal(topic, true, 0); });
if (storedTopicsArray.length === 0) { if (storedTopicsArray.length === 0) {
topicsHeader.style.display = 'none'; topicsHeader.style.display = 'none';
} }
if (currentTopic) {
currentTopicUnsubscribeOnClose = !storedTopicsArray.includes(currentTopic);
}
} else { } else {
topicsHeader.style.display = 'none'; topicsHeader.style.display = 'none';
if (currentTopic) {
currentTopicUnsubscribeOnClose = true;
}
} }

View file

@ -1,6 +1,7 @@
package util package util
import ( import (
"fmt"
"math/rand" "math/rand"
"os" "os"
"time" "time"
@ -27,3 +28,35 @@ func RandomString(length int) string {
} }
return string(b) return string(b)
} }
// DurationToHuman converts a duration to a human readable format
func DurationToHuman(d time.Duration) (str string) {
if d == 0 {
return "0"
}
d = d.Round(time.Second)
days := d / time.Hour / 24
if days > 0 {
str += fmt.Sprintf("%dd", days)
}
d -= days * time.Hour * 24
hours := d / time.Hour
if hours > 0 {
str += fmt.Sprintf("%dh", hours)
}
d -= hours * time.Hour
minutes := d / time.Minute
if minutes > 0 {
str += fmt.Sprintf("%dm", minutes)
}
d -= minutes * time.Minute
seconds := d / time.Second
if seconds > 0 {
str += fmt.Sprintf("%ds", seconds)
}
return
}