443 lines
12 KiB
Go
443 lines
12 KiB
Go
// 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
|
|
}
|