Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,395 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
MakoSource = "mako"
|
||||
makoBusName = "org.freedesktop.Notifications"
|
||||
makoObjectPath = "/fr/emersion/Mako"
|
||||
makoInterface = "fr.emersion.Mako"
|
||||
)
|
||||
|
||||
type MakoConnection interface {
|
||||
Signal(chan<- *dbus.Signal)
|
||||
RemoveSignal(subscription chan<- *dbus.Signal)
|
||||
Object(dest string, path dbus.ObjectPath) dbus.BusObject
|
||||
AddMatchSignal(match ...dbus.MatchOption) error
|
||||
RemoveMatchSignal(match ...dbus.MatchOption) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type MakoAnnouncer interface {
|
||||
Announce(entry Entry)
|
||||
}
|
||||
|
||||
type MakoMonitor struct {
|
||||
connection MakoConnection
|
||||
presenter *Presenter
|
||||
announcer MakoAnnouncer
|
||||
|
||||
mutex sync.Mutex
|
||||
generation int
|
||||
available bool
|
||||
known map[int]struct{}
|
||||
running bool
|
||||
stop chan struct{}
|
||||
stopped chan struct{}
|
||||
subscription <-chan *dbus.Signal
|
||||
}
|
||||
|
||||
type MakoController interface {
|
||||
IsCurrent(entry Entry) bool
|
||||
Dismiss(notificationID int) bool
|
||||
InvokeAction(notificationID int, actionKey string) bool
|
||||
}
|
||||
|
||||
func NewMakoMonitor(connection MakoConnection, presenter *Presenter, announcer MakoAnnouncer) *MakoMonitor {
|
||||
return &MakoMonitor{
|
||||
connection: connection,
|
||||
presenter: presenter,
|
||||
announcer: announcer,
|
||||
known: make(map[int]struct{}),
|
||||
stop: make(chan struct{}),
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) Start(ctx context.Context) error {
|
||||
monitor.mutex.Lock()
|
||||
if monitor.running {
|
||||
monitor.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
monitor.running = true
|
||||
monitor.stop = make(chan struct{})
|
||||
monitor.stopped = make(chan struct{})
|
||||
monitor.mutex.Unlock()
|
||||
|
||||
go monitor.watch(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) Stop(ctx context.Context) error {
|
||||
monitor.mutex.Lock()
|
||||
if !monitor.running {
|
||||
monitor.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
monitor.running = false
|
||||
close(monitor.stop)
|
||||
monitor.mutex.Unlock()
|
||||
|
||||
select {
|
||||
case <-monitor.stopped:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
monitor.mutex.Lock()
|
||||
defer monitor.mutex.Unlock()
|
||||
monitor.unsubscribe()
|
||||
monitor.presenter.MarkSourceUnavailable(MakoSource)
|
||||
clear(monitor.known)
|
||||
monitor.available = false
|
||||
return monitor.connection.Close()
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) Refresh(announceNew bool) bool {
|
||||
monitor.mutex.Lock()
|
||||
available := monitor.available
|
||||
monitor.mutex.Unlock()
|
||||
if !available {
|
||||
return false
|
||||
}
|
||||
|
||||
notifications, err := monitor.list()
|
||||
if err != nil {
|
||||
monitor.markUnavailable()
|
||||
return false
|
||||
}
|
||||
monitor.sync(notifications, announceNew)
|
||||
return true
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) activate(announceNew bool) bool {
|
||||
notifications, err := monitor.list()
|
||||
if err != nil {
|
||||
monitor.markUnavailable()
|
||||
return false
|
||||
}
|
||||
monitor.mutex.Lock()
|
||||
monitor.generation++
|
||||
monitor.available = true
|
||||
clear(monitor.known)
|
||||
monitor.mutex.Unlock()
|
||||
monitor.sync(notifications, announceNew)
|
||||
return true
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) Dismiss(notificationID int) bool {
|
||||
options := map[string]dbus.Variant{
|
||||
"id": dbus.MakeVariant(uint32(notificationID)),
|
||||
"group": dbus.MakeVariant(false),
|
||||
"history": dbus.MakeVariant(true),
|
||||
"all": dbus.MakeVariant(false),
|
||||
}
|
||||
if err := monitor.object().Call("fr.emersion.Mako.DismissNotifications", 0, options).Err; err != nil {
|
||||
return false
|
||||
}
|
||||
monitor.Refresh(false)
|
||||
return true
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) InvokeAction(notificationID int, actionKey string) bool {
|
||||
if err := monitor.object().Call("fr.emersion.Mako.InvokeAction", 0, uint32(notificationID), actionKey).Err; err != nil {
|
||||
return false
|
||||
}
|
||||
monitor.Refresh(false)
|
||||
return true
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) IsCurrent(entry Entry) bool {
|
||||
monitor.mutex.Lock()
|
||||
defer monitor.mutex.Unlock()
|
||||
return monitor.available && entry.Source == MakoSource &&
|
||||
entry.SourceGeneration == monitor.generation && entry.Live
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) Generation() int {
|
||||
monitor.mutex.Lock()
|
||||
defer monitor.mutex.Unlock()
|
||||
return monitor.generation
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) watch(ctx context.Context) {
|
||||
defer close(monitor.stopped)
|
||||
signals := make(chan *dbus.Signal, 16)
|
||||
monitor.subscription = signals
|
||||
monitor.connection.Signal(signals)
|
||||
defer monitor.connection.RemoveSignal(signals)
|
||||
|
||||
if err := monitor.connection.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(makoObjectPath),
|
||||
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
|
||||
); err != nil {
|
||||
monitor.markUnavailable()
|
||||
return
|
||||
}
|
||||
if err := monitor.connection.AddMatchSignal(
|
||||
dbus.WithMatchSender("org.freedesktop.DBus"),
|
||||
dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
|
||||
dbus.WithMatchInterface("org.freedesktop.DBus"),
|
||||
dbus.WithMatchMember("NameOwnerChanged"),
|
||||
); err != nil {
|
||||
monitor.markUnavailable()
|
||||
return
|
||||
}
|
||||
monitor.activate(false)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-monitor.stop:
|
||||
return
|
||||
case signal := <-signals:
|
||||
monitor.handleSignal(signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) handleSignal(signal *dbus.Signal) {
|
||||
if signal == nil {
|
||||
return
|
||||
}
|
||||
switch signal.Name {
|
||||
case "org.freedesktop.DBus.Properties.PropertiesChanged":
|
||||
if signal.Path == makoObjectPath {
|
||||
monitor.Refresh(true)
|
||||
}
|
||||
case "org.freedesktop.DBus.NameOwnerChanged":
|
||||
if signal.Path != "/org/freedesktop/DBus" || len(signal.Body) < 3 {
|
||||
return
|
||||
}
|
||||
name, _ := signal.Body[0].(string)
|
||||
newOwner, _ := signal.Body[2].(string)
|
||||
if name != makoBusName {
|
||||
return
|
||||
}
|
||||
if newOwner == "" {
|
||||
monitor.markUnavailable()
|
||||
} else {
|
||||
monitor.activate(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) sync(notifications []map[string]dbus.Variant, announceNew bool) {
|
||||
parsed := make([]LiveMetadata, 0, len(notifications))
|
||||
ids := make([]int, 0, len(notifications))
|
||||
live := make(map[int]LiveMetadata)
|
||||
for _, notification := range notifications {
|
||||
metadata, id := parseMako(notification)
|
||||
if id == nil {
|
||||
continue
|
||||
}
|
||||
parsed = append(parsed, metadata)
|
||||
ids = append(ids, *id)
|
||||
live[*id] = metadata
|
||||
}
|
||||
monitor.presenter.SyncLive(MakoSource, monitor.Generation(), live)
|
||||
if announceNew {
|
||||
for index, id := range ids {
|
||||
if monitor.knownID(id) {
|
||||
continue
|
||||
}
|
||||
notificationID := id
|
||||
monitor.announcer.Announce(Entry{
|
||||
Message: parsed[index].Message,
|
||||
Source: MakoSource,
|
||||
SourceGeneration: monitor.Generation(),
|
||||
NotificationID: ¬ificationID,
|
||||
Live: true,
|
||||
Actions: parsed[index].Actions,
|
||||
AppName: parsed[index].AppName,
|
||||
Summary: parsed[index].Summary,
|
||||
Body: parsed[index].Body,
|
||||
Urgency: parsed[index].Urgency,
|
||||
DesktopEntry: parsed[index].DesktopEntry,
|
||||
})
|
||||
}
|
||||
}
|
||||
monitor.replaceKnown(live)
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) list() ([]map[string]dbus.Variant, error) {
|
||||
var reply []map[string]dbus.Variant
|
||||
object := monitor.object()
|
||||
if err := object.Call(makoInterface+".ListNotifications", 0).Store(&reply); err != nil {
|
||||
return nil, fmt.Errorf("list mako notifications: %w", err)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) object() dbus.BusObject {
|
||||
return monitor.connection.Object(makoBusName, makoObjectPath)
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) knownID(id int) bool {
|
||||
monitor.mutex.Lock()
|
||||
defer monitor.mutex.Unlock()
|
||||
_, known := monitor.known[id]
|
||||
return known
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) replaceKnown(live map[int]LiveMetadata) {
|
||||
monitor.mutex.Lock()
|
||||
defer monitor.mutex.Unlock()
|
||||
monitor.known = make(map[int]struct{}, len(live))
|
||||
for id := range live {
|
||||
monitor.known[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) markUnavailable() {
|
||||
monitor.mutex.Lock()
|
||||
defer monitor.mutex.Unlock()
|
||||
monitor.available = false
|
||||
clear(monitor.known)
|
||||
monitor.presenter.MarkSourceUnavailable(MakoSource)
|
||||
}
|
||||
|
||||
func (monitor *MakoMonitor) unsubscribe() {
|
||||
if monitor.subscription != nil {
|
||||
_ = monitor.connection.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(makoObjectPath),
|
||||
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
|
||||
)
|
||||
_ = monitor.connection.RemoveMatchSignal(
|
||||
dbus.WithMatchSender("org.freedesktop.DBus"),
|
||||
dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
|
||||
dbus.WithMatchInterface("org.freedesktop.DBus"),
|
||||
dbus.WithMatchMember("NameOwnerChanged"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
makoTagPattern = regexp.MustCompile(`<[^>]+>`)
|
||||
makoWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
func parseMako(notification map[string]dbus.Variant) (LiveMetadata, *int) {
|
||||
id, idValid := notification["id"].Value().(uint32)
|
||||
if !idValid {
|
||||
return LiveMetadata{}, nil
|
||||
}
|
||||
metadata := LiveMetadata{
|
||||
AppName: normalizeMakoText(notification["app-name"]),
|
||||
Summary: normalizeMakoText(notification["summary"]),
|
||||
Body: normalizeMakoText(notification["body"]),
|
||||
DesktopEntry: normalizeMakoText(notification["desktop-entry"]),
|
||||
}
|
||||
if value, valid := notification["urgency"].Value().(int32); valid {
|
||||
metadata.Urgency = int(value)
|
||||
} else {
|
||||
metadata.Urgency = -1
|
||||
}
|
||||
if actions, valid := notification["actions"].Value().(map[string]dbus.Variant); valid {
|
||||
metadata.Actions = make(map[string]string, len(actions))
|
||||
for key, action := range actions {
|
||||
value := normalizeMakoText(action)
|
||||
if value == "" {
|
||||
value = key
|
||||
}
|
||||
metadata.Actions[key] = value
|
||||
}
|
||||
}
|
||||
notificationID := int(id)
|
||||
metadata.Message = buildMakoMessage(metadata)
|
||||
return metadata, ¬ificationID
|
||||
}
|
||||
|
||||
func normalizeMakoText(variant dbus.Variant) string {
|
||||
text, valid := variant.Value().(string)
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
text = html.UnescapeString(text)
|
||||
text = makoTagPattern.ReplaceAllString(text, " ")
|
||||
text = makoWhitespacePattern.ReplaceAllString(text, " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func buildMakoMessage(metadata LiveMetadata) string {
|
||||
parts := []string{MessageNotification}
|
||||
if metadata.Summary != "" {
|
||||
parts = append(parts, metadata.Summary)
|
||||
}
|
||||
if metadata.Body != "" && metadata.Body != metadata.Summary {
|
||||
parts = append(parts, metadata.Body)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
fallback := metadata.AppName
|
||||
if fallback == "" {
|
||||
fallback = metadata.DesktopEntry
|
||||
}
|
||||
if fallback != "" {
|
||||
parts = append(parts, fallback)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
func TestMakoSyncAnnouncesOnlyNewNotifications(t *testing.T) {
|
||||
presenter := NewPresenter()
|
||||
announcer := &fakeAnnouncer{}
|
||||
monitor := NewMakoMonitor(nil, presenter, announcer)
|
||||
monitor.available = true
|
||||
|
||||
notifications := []map[string]dbus.Variant{makoNotification(7)}
|
||||
monitor.sync(notifications, true)
|
||||
if len(announcer.entries) != 1 {
|
||||
t.Fatalf("announced %d notifications, want 1", len(announcer.entries))
|
||||
}
|
||||
if announcer.entries[0].Message != "Notification Mail You have one message" {
|
||||
t.Fatalf("message = %q", announcer.entries[0].Message)
|
||||
}
|
||||
|
||||
monitor.sync(notifications, true)
|
||||
if len(announcer.entries) != 1 {
|
||||
t.Fatalf("announced %d notifications after refresh, want 1", len(announcer.entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakoParseNormalizesMetadata(t *testing.T) {
|
||||
metadata, id := parseMako(makoNotification(9))
|
||||
if id == nil || *id != 9 {
|
||||
t.Fatalf("id = %v, want 9", id)
|
||||
}
|
||||
if metadata.AppName != "Application" || metadata.Summary != "Mail" ||
|
||||
metadata.Body != "You have one message" || metadata.DesktopEntry != "application" ||
|
||||
metadata.Urgency != 2 || metadata.Actions["default"] != "View" {
|
||||
t.Fatalf("metadata = %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakoStopMarksLiveHistoryUnavailable(t *testing.T) {
|
||||
presenter := NewPresenter()
|
||||
notificationID := 12
|
||||
presenter.Save("Notification Mail", SaveOptions{
|
||||
Source: MakoSource, NotificationID: ¬ificationID, Live: true,
|
||||
})
|
||||
monitor := NewMakoMonitor(&fakeMakoConnection{}, presenter, &fakeAnnouncer{})
|
||||
monitor.available = true
|
||||
monitor.running = true
|
||||
monitor.stop = make(chan struct{})
|
||||
monitor.stopped = make(chan struct{})
|
||||
close(monitor.stopped)
|
||||
if err := monitor.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("stop mako: %v", err)
|
||||
}
|
||||
entries := presenter.Entries()
|
||||
if len(entries) != 1 || entries[0].Live {
|
||||
t.Fatalf("entries = %+v, want one unavailable entry", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakoOwnerLossMarksLiveHistoryUnavailable(t *testing.T) {
|
||||
presenter := NewPresenter()
|
||||
notificationID := 12
|
||||
presenter.Save("Notification Mail", SaveOptions{
|
||||
Source: MakoSource, NotificationID: ¬ificationID, Live: true,
|
||||
})
|
||||
monitor := NewMakoMonitor(&fakeMakoConnection{}, presenter, &fakeAnnouncer{})
|
||||
monitor.available = true
|
||||
monitor.handleSignal(&dbus.Signal{
|
||||
Name: "org.freedesktop.DBus.NameOwnerChanged",
|
||||
Path: "/org/freedesktop/DBus",
|
||||
Body: []interface{}{makoBusName, ":1.10", ""},
|
||||
})
|
||||
|
||||
entries := presenter.Entries()
|
||||
if monitor.available || len(entries) != 1 || entries[0].Live {
|
||||
t.Fatalf("available = %v, entries = %+v", monitor.available, entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakoUnsupportedDaemonMarksHistoryUnavailable(t *testing.T) {
|
||||
presenter := NewPresenter()
|
||||
notificationID := 12
|
||||
presenter.Save("Notification Mail", SaveOptions{
|
||||
Source: MakoSource, NotificationID: ¬ificationID, Live: true,
|
||||
})
|
||||
monitor := NewMakoMonitor(&fakeMakoConnection{
|
||||
object: &fakeMakoObject{err: errors.New("object does not exist")},
|
||||
}, presenter, &fakeAnnouncer{})
|
||||
monitor.available = true
|
||||
|
||||
if monitor.Refresh(false) {
|
||||
t.Fatal("unsupported notification daemon reported as available")
|
||||
}
|
||||
entries := presenter.Entries()
|
||||
if len(entries) != 1 || entries[0].Live {
|
||||
t.Fatalf("entries = %+v, want one unavailable entry", entries)
|
||||
}
|
||||
if len(presenter.ActionsForEntry(entries[0])) != 0 {
|
||||
t.Fatal("unsupported notification daemon retained live actions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenterRoutesLiveMakoActions(t *testing.T) {
|
||||
presenter := NewPresenter()
|
||||
controller := &fakeMakoController{}
|
||||
presenter.SetMakoController(controller)
|
||||
notificationID := 6
|
||||
entry := presenter.Save("Notification Mail New message", SaveOptions{
|
||||
Source: MakoSource, SourceGeneration: 1,
|
||||
NotificationID: ¬ificationID, Live: true,
|
||||
Actions: map[string]string{"default": "View"},
|
||||
})
|
||||
|
||||
if !presenter.CanControlEntry(entry) {
|
||||
t.Fatal("live mako entry was not controllable")
|
||||
}
|
||||
if actions := presenter.ActionsForEntry(entry); actions["default"] != "View" {
|
||||
t.Fatalf("actions = %q, want View", actions["default"])
|
||||
}
|
||||
|
||||
messages, dismissed := presenter.DismissEntry(entry)
|
||||
if !dismissed || messages[0] != MessageDismissed {
|
||||
t.Fatalf("dismiss = %v %q", dismissed, messages)
|
||||
}
|
||||
if len(presenter.Entries()) != 0 || controller.dismissed[0] != 6 {
|
||||
t.Fatalf("entries = %+v dismissed = %v", presenter.Entries(), controller.dismissed)
|
||||
}
|
||||
|
||||
actionMessages, invoked := presenter.InvokeActionEntry(entry, "default")
|
||||
if !invoked || actionMessages[0] != MessageActionInvoked || controller.invoked[0] != (makoAction{6, "default"}) {
|
||||
t.Fatalf("invoke = %v %q", invoked, actionMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func makoNotification(id uint32) map[string]dbus.Variant {
|
||||
return map[string]dbus.Variant{
|
||||
"id": dbus.MakeVariant(id),
|
||||
"app-name": dbus.MakeVariant("Application"),
|
||||
"summary": dbus.MakeVariant("<b>Mail</b>"),
|
||||
"body": dbus.MakeVariant("You have one message"),
|
||||
"desktop-entry": dbus.MakeVariant("application"),
|
||||
"urgency": dbus.MakeVariant(int32(2)),
|
||||
"actions": dbus.MakeVariant(map[string]dbus.Variant{"default": dbus.MakeVariant("View")}),
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAnnouncer struct{ entries []Entry }
|
||||
|
||||
func (announcer *fakeAnnouncer) Announce(entry Entry) {
|
||||
announcer.entries = append(announcer.entries, entry)
|
||||
}
|
||||
|
||||
type fakeMakoConnection struct {
|
||||
object *fakeMakoObject
|
||||
}
|
||||
|
||||
func (connection *fakeMakoConnection) Signal(chan<- *dbus.Signal) {}
|
||||
|
||||
func (connection *fakeMakoConnection) RemoveSignal(chan<- *dbus.Signal) {}
|
||||
func (connection *fakeMakoConnection) Object(string, dbus.ObjectPath) dbus.BusObject {
|
||||
return connection.object
|
||||
}
|
||||
func (connection *fakeMakoConnection) AddMatchSignal(...dbus.MatchOption) error { return nil }
|
||||
func (connection *fakeMakoConnection) RemoveMatchSignal(...dbus.MatchOption) error { return nil }
|
||||
func (connection *fakeMakoConnection) Close() error { return nil }
|
||||
|
||||
type fakeMakoObject struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (object *fakeMakoObject) Call(string, dbus.Flags, ...interface{}) *dbus.Call {
|
||||
return &dbus.Call{Err: object.err}
|
||||
}
|
||||
func (object *fakeMakoObject) CallWithContext(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call {
|
||||
return &dbus.Call{Err: object.err}
|
||||
}
|
||||
func (object *fakeMakoObject) Go(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call {
|
||||
return &dbus.Call{Err: object.err}
|
||||
}
|
||||
func (object *fakeMakoObject) GoWithContext(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call {
|
||||
return &dbus.Call{Err: object.err}
|
||||
}
|
||||
func (object *fakeMakoObject) AddMatchSignal(string, string, ...dbus.MatchOption) *dbus.Call {
|
||||
return &dbus.Call{Err: object.err}
|
||||
}
|
||||
func (object *fakeMakoObject) RemoveMatchSignal(string, string, ...dbus.MatchOption) *dbus.Call {
|
||||
return &dbus.Call{Err: object.err}
|
||||
}
|
||||
func (object *fakeMakoObject) GetProperty(string) (dbus.Variant, error) {
|
||||
return dbus.Variant{}, object.err
|
||||
}
|
||||
func (object *fakeMakoObject) StoreProperty(string, interface{}) error {
|
||||
return object.err
|
||||
}
|
||||
func (object *fakeMakoObject) SetProperty(string, interface{}) error {
|
||||
return object.err
|
||||
}
|
||||
func (object *fakeMakoObject) Destination() string { return makoBusName }
|
||||
func (object *fakeMakoObject) Path() dbus.ObjectPath { return makoObjectPath }
|
||||
|
||||
type makoAction struct {
|
||||
id int
|
||||
key string
|
||||
}
|
||||
|
||||
type fakeMakoController struct {
|
||||
dismissed []int
|
||||
invoked []makoAction
|
||||
}
|
||||
|
||||
func (controller *fakeMakoController) IsCurrent(Entry) bool { return true }
|
||||
func (controller *fakeMakoController) Dismiss(id int) bool {
|
||||
controller.dismissed = append(controller.dismissed, id)
|
||||
return true
|
||||
}
|
||||
func (controller *fakeMakoController) InvokeAction(id int, key string) bool {
|
||||
controller.invoked = append(controller.invoked, makoAction{id, key})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var noMessages []string
|
||||
|
||||
const historySize = 55
|
||||
|
||||
const (
|
||||
MessageNotification = "Notification"
|
||||
MessageListBottom = "Bottom"
|
||||
MessageListTop = "Top"
|
||||
MessageNoMessages = "No notification messages"
|
||||
MessageUnavailable = "Notification is no longer available"
|
||||
MessageNoActions = "No notification actions available"
|
||||
MessageDismissed = "Notification dismissed"
|
||||
MessageActionInvoked = "Notification action invoked"
|
||||
SourceGeneric = "generic"
|
||||
ListTitle = "Notifications"
|
||||
ListColumnMessages = "Notifications"
|
||||
ListColumnReceived = "Received"
|
||||
ListDismissButton = "Dismiss Notification"
|
||||
ListActionsButton = "Notification Actions"
|
||||
ListActionsTitle = "Notification Actions"
|
||||
ListInvokeActionButton = "Invoke Action"
|
||||
ListClearButton = "Clear"
|
||||
ListCopyButton = "Copy"
|
||||
ListCloseButton = "Close"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
Message string
|
||||
Timestamp time.Time
|
||||
Source string
|
||||
SourceGeneration int
|
||||
NotificationID *int
|
||||
Live bool
|
||||
Actions map[string]string
|
||||
AppName string
|
||||
Summary string
|
||||
Body string
|
||||
Urgency int
|
||||
DesktopEntry string
|
||||
}
|
||||
|
||||
type SaveOptions struct {
|
||||
Source string
|
||||
SourceGeneration int
|
||||
NotificationID *int
|
||||
Live bool
|
||||
Actions map[string]string
|
||||
AppName string
|
||||
Summary string
|
||||
Body string
|
||||
Urgency int
|
||||
DesktopEntry string
|
||||
}
|
||||
|
||||
type LiveMetadata struct {
|
||||
Message string
|
||||
Actions map[string]string
|
||||
AppName string
|
||||
Summary string
|
||||
Body string
|
||||
Urgency int
|
||||
DesktopEntry string
|
||||
}
|
||||
|
||||
type Presenter struct {
|
||||
mutex sync.Mutex
|
||||
now func() time.Time
|
||||
mako MakoController
|
||||
|
||||
entries []Entry
|
||||
currentIndex int
|
||||
}
|
||||
|
||||
func NewPresenter() *Presenter {
|
||||
return &Presenter{now: time.Now, currentIndex: -1}
|
||||
}
|
||||
|
||||
func NewPresenterAt(now func() time.Time) *Presenter {
|
||||
return &Presenter{now: now, currentIndex: -1}
|
||||
}
|
||||
|
||||
func (presenter *Presenter) SetMakoController(controller MakoController) {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
presenter.mako = controller
|
||||
}
|
||||
|
||||
func (presenter *Presenter) Save(message string, options ...SaveOptions) Entry {
|
||||
option := SaveOptions{}
|
||||
if len(options) != 0 {
|
||||
option = options[0]
|
||||
}
|
||||
if option.Source == "" {
|
||||
option.Source = SourceGeneric
|
||||
}
|
||||
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
if excess := len(presenter.entries) - historySize + 1; excess > 0 {
|
||||
presenter.entries = presenter.entries[excess:]
|
||||
}
|
||||
entry := Entry{
|
||||
Message: message,
|
||||
Timestamp: presenter.now(),
|
||||
Source: option.Source,
|
||||
SourceGeneration: option.SourceGeneration,
|
||||
NotificationID: option.NotificationID,
|
||||
Live: option.Live,
|
||||
Actions: cloneActions(option.Actions),
|
||||
AppName: option.AppName,
|
||||
Summary: option.Summary,
|
||||
Body: option.Body,
|
||||
Urgency: option.Urgency,
|
||||
DesktopEntry: option.DesktopEntry,
|
||||
}
|
||||
presenter.entries = append(presenter.entries, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
func (presenter *Presenter) Clear() {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
presenter.entries = nil
|
||||
presenter.currentIndex = -1
|
||||
}
|
||||
|
||||
func (presenter *Presenter) Entries() []Entry {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
return append([]Entry(nil), presenter.entries...)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) EntriesForList() []Entry {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
return presenter.entriesForList()
|
||||
}
|
||||
|
||||
func (presenter *Presenter) ListTitle() string {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
return presenter.listTitle()
|
||||
}
|
||||
|
||||
func (presenter *Presenter) EntryText(entry Entry) string {
|
||||
presenter.mutex.Lock()
|
||||
now := presenter.now()
|
||||
presenter.mutex.Unlock()
|
||||
return entryText(entry, now)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) ReceivedTime(timestamp time.Time) string {
|
||||
presenter.mutex.Lock()
|
||||
now := presenter.now()
|
||||
presenter.mutex.Unlock()
|
||||
return relativeTime(timestamp, now)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) RefreshLive() bool {
|
||||
presenter.mutex.Lock()
|
||||
controller := presenter.mako
|
||||
presenter.mutex.Unlock()
|
||||
refresher, ok := controller.(interface{ Refresh(bool) bool })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return refresher.Refresh(false)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) Remove(entry Entry) bool {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
for index, candidate := range presenter.entries {
|
||||
if entriesEqual(candidate, entry) {
|
||||
presenter.entries = append(presenter.entries[:index], presenter.entries[index+1:]...)
|
||||
presenter.removeIndex(index)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (presenter *Presenter) SyncLive(source string, generation int, live map[int]LiveMetadata) {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
for index := range presenter.entries {
|
||||
entry := &presenter.entries[index]
|
||||
if entry.Source != source || entry.SourceGeneration != generation {
|
||||
continue
|
||||
}
|
||||
if entry.NotificationID == nil {
|
||||
continue
|
||||
}
|
||||
if metadata, exists := live[*entry.NotificationID]; exists {
|
||||
entry.Live = true
|
||||
if metadata.Message != "" {
|
||||
entry.Message = metadata.Message
|
||||
}
|
||||
entry.Actions = cloneActions(metadata.Actions)
|
||||
entry.AppName = metadata.AppName
|
||||
entry.Summary = metadata.Summary
|
||||
entry.Body = metadata.Body
|
||||
entry.Urgency = metadata.Urgency
|
||||
entry.DesktopEntry = metadata.DesktopEntry
|
||||
} else if entry.Live {
|
||||
entry.Live = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (presenter *Presenter) MarkSourceUnavailable(source string) {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
for index := range presenter.entries {
|
||||
if presenter.entries[index].Source == source && presenter.entries[index].Live {
|
||||
presenter.entries[index].Live = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (presenter *Presenter) CanControlEntry(entry Entry) bool {
|
||||
presenter.mutex.Lock()
|
||||
controller := presenter.mako
|
||||
presenter.mutex.Unlock()
|
||||
return entry.Live && entry.NotificationID != nil && controller != nil && controller.IsCurrent(entry)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) ActionsForEntry(entry Entry) map[string]string {
|
||||
if !presenter.CanControlEntry(entry) {
|
||||
return map[string]string{}
|
||||
}
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
return cloneActions(entry.Actions)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) DismissEntry(entry Entry) ([]string, bool) {
|
||||
if !presenter.CanControlEntry(entry) {
|
||||
return []string{MessageUnavailable}, false
|
||||
}
|
||||
presenter.mutex.Lock()
|
||||
controller := presenter.mako
|
||||
presenter.mutex.Unlock()
|
||||
if controller == nil || !controller.Dismiss(*entry.NotificationID) {
|
||||
return []string{MessageUnavailable}, false
|
||||
}
|
||||
entry.Live = false
|
||||
presenter.Remove(entry)
|
||||
return []string{MessageDismissed}, true
|
||||
}
|
||||
|
||||
func (presenter *Presenter) InvokeActionEntry(entry Entry, actionKey string) ([]string, bool) {
|
||||
if !presenter.CanControlEntry(entry) {
|
||||
return []string{MessageUnavailable}, false
|
||||
}
|
||||
presenter.mutex.Lock()
|
||||
controller := presenter.mako
|
||||
presenter.mutex.Unlock()
|
||||
if controller == nil || !controller.InvokeAction(*entry.NotificationID, actionKey) {
|
||||
return []string{MessageUnavailable}, false
|
||||
}
|
||||
return []string{MessageActionInvoked}, true
|
||||
}
|
||||
|
||||
func (presenter *Presenter) PresentLast() []string {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
if len(presenter.entries) == 0 {
|
||||
return []string{MessageNoMessages}
|
||||
}
|
||||
presenter.currentIndex = -1
|
||||
return []string{entryText(presenter.entries[len(presenter.entries)-1], presenter.now())}
|
||||
}
|
||||
|
||||
func (presenter *Presenter) PresentPrevious() []string {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
if len(presenter.entries) == 0 {
|
||||
return []string{MessageNoMessages}
|
||||
}
|
||||
messages := noMessages
|
||||
next := presenter.currentIndex - 1
|
||||
if next < -len(presenter.entries) {
|
||||
messages = append(messages, MessageListTop)
|
||||
} else {
|
||||
presenter.currentIndex = next
|
||||
}
|
||||
current := len(presenter.entries) + presenter.currentIndex
|
||||
return append(messages, entryText(presenter.entries[current], presenter.now()))
|
||||
}
|
||||
|
||||
func (presenter *Presenter) PresentNext() []string {
|
||||
presenter.mutex.Lock()
|
||||
defer presenter.mutex.Unlock()
|
||||
if len(presenter.entries) == 0 {
|
||||
return []string{MessageNoMessages}
|
||||
}
|
||||
messages := noMessages
|
||||
if presenter.currentIndex == -1 {
|
||||
messages = append(messages, MessageListBottom)
|
||||
} else {
|
||||
next := presenter.currentIndex + 1
|
||||
if next > -1 {
|
||||
messages = append(messages, MessageListBottom)
|
||||
} else {
|
||||
presenter.currentIndex = next
|
||||
}
|
||||
}
|
||||
current := len(presenter.entries) + presenter.currentIndex
|
||||
return append(messages, entryText(presenter.entries[current], presenter.now()))
|
||||
}
|
||||
|
||||
func (presenter *Presenter) removeIndex(index int) {
|
||||
if len(presenter.entries) == 0 {
|
||||
presenter.currentIndex = -1
|
||||
return
|
||||
}
|
||||
if presenter.currentIndex == -1 {
|
||||
return
|
||||
}
|
||||
// Navigation uses negative offsets from the newest entry; removal receives
|
||||
// a forward slice index. Preserve the selected entry when it survives, or
|
||||
// select its successor when removed, without retaining an out-of-range offset.
|
||||
current := len(presenter.entries) + 1 + presenter.currentIndex
|
||||
if index < current {
|
||||
current--
|
||||
}
|
||||
current = min(current, len(presenter.entries)-1)
|
||||
presenter.currentIndex = current - len(presenter.entries)
|
||||
}
|
||||
|
||||
func (presenter *Presenter) entriesForList() []Entry {
|
||||
entries := append([]Entry(nil), presenter.entries...)
|
||||
for left, right := 0, len(entries)-1; left < right; left, right = left+1, right-1 {
|
||||
entries[left], entries[right] = entries[right], entries[left]
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (presenter *Presenter) listTitle() string {
|
||||
return plural(len(presenter.entries), "%d notification", "%d notifications")
|
||||
}
|
||||
|
||||
func entryText(entry Entry, now time.Time) string {
|
||||
return fmt.Sprintf("%s %s", entry.Message, relativeTime(entry.Timestamp, now))
|
||||
}
|
||||
|
||||
func relativeTime(timestamp, now time.Time) string {
|
||||
elapsed := now.Sub(timestamp)
|
||||
switch {
|
||||
case elapsed < time.Minute:
|
||||
count := int(elapsed.Seconds())
|
||||
return plural(count, "%d second ago", "%d seconds ago")
|
||||
case elapsed < time.Hour:
|
||||
count := roundLikePython(elapsed.Minutes())
|
||||
return plural(count, "%d minute ago", "%d minutes ago")
|
||||
case elapsed < 24*time.Hour:
|
||||
count := roundLikePython(elapsed.Hours())
|
||||
return plural(count, "%d hour ago", "%d hours ago")
|
||||
default:
|
||||
count := roundLikePython(elapsed.Hours() / 24)
|
||||
return plural(count, "%d day ago", "%d days ago")
|
||||
}
|
||||
}
|
||||
|
||||
func plural(count int, singular, pluralFormat string) string {
|
||||
if count == 1 {
|
||||
return fmt.Sprintf(singular, count)
|
||||
}
|
||||
return fmt.Sprintf(pluralFormat, count)
|
||||
}
|
||||
|
||||
func roundLikePython(value float64) int {
|
||||
lower := math.Floor(value)
|
||||
fraction := value - lower
|
||||
switch {
|
||||
case fraction > 0.5:
|
||||
return int(lower) + 1
|
||||
case fraction == 0.5 && int(lower)%2 != 0:
|
||||
return int(lower) + 1
|
||||
default:
|
||||
return int(lower)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneActions(actions map[string]string) map[string]string {
|
||||
if len(actions) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
clone := make(map[string]string, len(actions))
|
||||
for key, value := range actions {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func entriesEqual(left, right Entry) bool {
|
||||
return left.Message == right.Message &&
|
||||
left.Timestamp.Equal(right.Timestamp) &&
|
||||
left.Source == right.Source &&
|
||||
left.SourceGeneration == right.SourceGeneration &&
|
||||
notificationIDsEqual(left.NotificationID, right.NotificationID) &&
|
||||
actionsEqual(left.Actions, right.Actions) &&
|
||||
left.AppName == right.AppName &&
|
||||
left.Summary == right.Summary &&
|
||||
left.Body == right.Body &&
|
||||
left.Urgency == right.Urgency &&
|
||||
left.DesktopEntry == right.DesktopEntry
|
||||
}
|
||||
|
||||
func notificationIDsEqual(left, right *int) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func actionsEqual(left, right map[string]string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for key, value := range left {
|
||||
if right[key] != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPresenterSavesHistoryAndEnforcesMaximumSize(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
presenter := NewPresenterAt(func() time.Time { return now })
|
||||
for number := 0; number < historySize+2; number++ {
|
||||
presenter.Save(time.Unix(int64(number), 0).Format("2006"))
|
||||
now = now.Add(time.Second)
|
||||
}
|
||||
entries := presenter.Entries()
|
||||
if len(entries) != historySize {
|
||||
t.Fatalf("history length = %d, want %d", len(entries), historySize)
|
||||
}
|
||||
if entries[0].Source != SourceGeneric {
|
||||
t.Fatalf("first source = %q, want %q", entries[0].Source, SourceGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenterNavigationMatchesReferenceBoundaries(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
presenter := NewPresenterAt(func() time.Time { return now })
|
||||
presenter.Save("first")
|
||||
presenter.Save("second")
|
||||
|
||||
assertMessages := func(t *testing.T, got []string, want ...string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("message count = %d, want %d: %q", len(got), len(want), got)
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("message %d = %q, want %q", index, got[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Run("last", func(t *testing.T) {
|
||||
assertMessages(t, presenter.PresentLast(), "second 0 seconds ago")
|
||||
})
|
||||
t.Run("previous then top", func(t *testing.T) {
|
||||
assertMessages(t, presenter.PresentPrevious(), "first 0 seconds ago")
|
||||
assertMessages(t, presenter.PresentPrevious(), MessageListTop, "first 0 seconds ago")
|
||||
})
|
||||
t.Run("next then bottom", func(t *testing.T) {
|
||||
assertMessages(t, presenter.PresentNext(), "second 0 seconds ago")
|
||||
assertMessages(t, presenter.PresentNext(), MessageListBottom, "second 0 seconds ago")
|
||||
assertMessages(t, presenter.PresentNext(), MessageListBottom, "second 0 seconds ago")
|
||||
})
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
empty := NewPresenterAt(func() time.Time { return now })
|
||||
assertMessages(t, empty.PresentLast(), MessageNoMessages)
|
||||
assertMessages(t, empty.PresentPrevious(), MessageNoMessages)
|
||||
assertMessages(t, empty.PresentNext(), MessageNoMessages)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPresenterStartsAtLatestNotification(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
presenter := NewPresenterAt(func() time.Time { return now })
|
||||
presenter.Save("first")
|
||||
presenter.Save("second")
|
||||
|
||||
assertMessages(t, presenter.PresentPrevious(), "first 0 seconds ago")
|
||||
}
|
||||
|
||||
func TestPresenterRemovalKeepsHistoryNavigationValid(t *testing.T) {
|
||||
for _, removeAt := range []int{0, 1, 2} {
|
||||
t.Run(time.Duration(removeAt).String(), func(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
presenter := NewPresenterAt(func() time.Time { return now })
|
||||
entries := []Entry{presenter.Save("first"), presenter.Save("second"), presenter.Save("third")}
|
||||
presenter.PresentPrevious()
|
||||
presenter.PresentPrevious()
|
||||
if !presenter.Remove(entries[removeAt]) {
|
||||
t.Fatal("notification was not removed")
|
||||
}
|
||||
want := "first 0 seconds ago"
|
||||
if removeAt == 0 {
|
||||
want = "second 0 seconds ago"
|
||||
}
|
||||
assertMessages(t, presenter.PresentPrevious(), MessageListTop, want)
|
||||
presenter.PresentNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertMessages(t *testing.T, got []string, want ...string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("message count = %d, want %d: %q", len(got), len(want), got)
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("message %d = %q, want %q", index, got[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeTimeUsesReferenceRounding(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
for _, test := range []struct {
|
||||
elapsed time.Duration
|
||||
want string
|
||||
}{
|
||||
{90 * time.Second, "2 minutes ago"},
|
||||
{150 * time.Second, "2 minutes ago"},
|
||||
{89 * time.Second, "1 minute ago"},
|
||||
{90 * time.Minute, "2 hours ago"},
|
||||
{90 * time.Hour, "4 days ago"},
|
||||
} {
|
||||
if got := relativeTime(now.Add(-test.elapsed), now); got != test.want {
|
||||
t.Fatalf("relativeTime(%s) = %q, want %q", test.elapsed, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user