Files

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
}