// 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 }