Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

appview/notifications: add ability to mark read/unread for a single notif

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
committer
Lewis
date (May 29, 2026, 2:50 PM +0300) commit 36bf8cca parent d87c6fb7 change-id qorslxpm
+102 -12
+43
appview/db/notifications.go
··· 113 113 return notifications, nil 114 114 } 115 115 116 + func GetNotificationWithEntity(e Execer, notificationID int64, userDID string) (*models.NotificationWithEntity, error) { 117 + results, err := GetNotificationsWithEntities(e, pagination.Page{Limit: 1, Offset: 0}, 118 + orm.FilterEq("n.id", notificationID), 119 + orm.FilterEq("n.recipient_did", userDID), 120 + ) 121 + if err != nil { 122 + return nil, err 123 + } 124 + if len(results) == 0 { 125 + return nil, fmt.Errorf("notification not found") 126 + } 127 + return results[0], nil 128 + } 129 + 116 130 // GetNotificationsWithEntities retrieves notifications with their related entities 117 131 func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) { 118 132 var conditions []string ··· 303 317 result, err := e.Exec(query, args...) 304 318 if err != nil { 305 319 return fmt.Errorf("failed to mark notification as read: %w", err) 320 + } 321 + 322 + rowsAffected, err := result.RowsAffected() 323 + if err != nil { 324 + return fmt.Errorf("failed to get rows affected: %w", err) 325 + } 326 + 327 + if rowsAffected == 0 { 328 + return fmt.Errorf("notification not found or access denied") 329 + } 330 + 331 + return nil 332 + } 333 + 334 + func MarkNotificationUnread(e Execer, notificationID int64, userDID string) error { 335 + idFilter := orm.FilterEq("id", notificationID) 336 + recipientFilter := orm.FilterEq("recipient_did", userDID) 337 + 338 + query := fmt.Sprintf(` 339 + UPDATE notifications 340 + SET read = 0 341 + WHERE %s AND %s 342 + `, idFilter.Condition(), recipientFilter.Condition()) 343 + 344 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 345 + 346 + result, err := e.Exec(query, args...) 347 + if err != nil { 348 + return fmt.Errorf("failed to mark notification as unread: %w", err) 306 349 } 307 350 308 351 rowsAffected, err := result.RowsAffected()
+30 -2
appview/notifications/notifications.go
··· 41 41 r.With(middleware.Paginate).Get("/", n.notificationsPage) 42 42 r.Get("/preview", n.previewHandler) 43 43 r.Post("/{id}/read", n.markRead) 44 + r.Post("/{id}/unread", n.markUnread) 44 45 r.Post("/read-all", n.markAllRead) 45 46 r.Delete("/{id}", n.deleteNotification) 46 47 }) ··· 224 225 } 225 226 226 227 func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 228 + n.toggleRead(w, r, true) 229 + } 230 + 231 + func (n *Notifications) markUnread(w http.ResponseWriter, r *http.Request) { 232 + n.toggleRead(w, r, false) 233 + } 234 + 235 + func (n *Notifications) toggleRead(w http.ResponseWriter, r *http.Request, read bool) { 236 + l := n.logger.With("handler", "toggleRead") 227 237 userDid := n.oauth.GetDid(r) 228 238 229 239 idStr := chi.URLParam(r, "id") ··· 233 243 return 234 244 } 235 245 236 - err = db.MarkNotificationRead(n.db, notificationID, userDid) 246 + if read { 247 + err = db.MarkNotificationRead(n.db, notificationID, userDid) 248 + } else { 249 + err = db.MarkNotificationUnread(n.db, notificationID, userDid) 250 + } 237 251 if err != nil { 238 - http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 252 + http.Error(w, "Failed to update notification", http.StatusInternalServerError) 253 + return 254 + } 255 + 256 + // if called via HTMX (has HX-Request header), return the updated item fragment 257 + if r.Header.Get("HX-Request") == "true" { 258 + notif, err := db.GetNotificationWithEntity(n.db, notificationID, userDid) 259 + if err != nil { 260 + l.Error("failed to fetch notification after toggle", "err", err) 261 + http.Error(w, "Failed to fetch notification", http.StatusInternalServerError) 262 + return 263 + } 264 + if err := n.pages.NotificationItem(w, notif); err != nil { 265 + l.Error("failed to render notification item", "err", err) 266 + } 239 267 return 240 268 } 241 269
+2 -6
appview/pages/pages.go
··· 487 487 return p.execute("notifications/list", w, params) 488 488 } 489 489 490 - type NotificationItemParams struct { 491 - Notification *models.Notification 492 - } 493 - 494 - func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 495 - return p.executePlain("notifications/fragments/item", w, params) 490 + func (p *Pages) NotificationItem(w io.Writer, notif *models.NotificationWithEntity) error { 491 + return p.executePlain("notifications/fragments/item", w, notif) 496 492 } 497 493 498 494 type NotificationCountParams struct {
+27 -4
appview/pages/templates/notifications/fragments/item.html
··· 1 1 {{define "notifications/fragments/item"}} 2 2 <a href="{{ template "notificationUrl" . }}" 3 - onclick="navigator.sendBeacon('/notifications/{{ .ID }}/read')" 4 - class="block no-underline hover:no-underline"> 3 + onclick="if(!event.target.closest('button'))navigator.sendBeacon('/notifications/{{ .ID }}/read')" 4 + class="block no-underline hover:no-underline group"> 5 5 <div 6 6 class=" 7 7 w-full mx-auto dark:text-white px-2 md:px-6 py-4 ··· 19 19 {{- end -}} 20 20 {{- template "notificationHeader" . -}} 21 21 </div> 22 - {{/* row 1, col 3: time */}} 23 - <span class="row-start-1 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ template "repo/fragments/shortTime" .Created }}</span> 22 + {{/* row 1, col 3: timestamp normally, button on hover */}} 23 + <div class="row-start-1 relative flex items-center justify-end"> 24 + <span class="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap md:group-hover:invisible">{{ template "repo/fragments/shortTime" .Created }}</span> 25 + {{ if .Read }} 26 + <button 27 + hx-post="/notifications/{{ .ID }}/unread" 28 + hx-target="closest a" 29 + hx-swap="outerHTML" 30 + class="hidden md:group-hover:flex absolute inset-0 items-center justify-end p-0 text-gray-400 dark:text-gray-500 hover:text-gray-600" 31 + title="Mark as unread" 32 + onclick="event.preventDefault()"> 33 + {{ i "mail" "size-4" }} 34 + </button> 35 + {{ else }} 36 + <button 37 + hx-post="/notifications/{{ .ID }}/read" 38 + hx-target="closest a" 39 + hx-swap="outerHTML" 40 + class="hidden md:group-hover:flex absolute inset-0 items-center justify-end p-0 text-gray-400 dark:text-gray-500 hover:text-gray-600" 41 + title="Mark as read" 42 + onclick="event.preventDefault()"> 43 + {{ i "mail-open" "size-4" }} 44 + </button> 45 + {{ end }} 46 + </div> 24 47 {{/* row 2, col 1: #N */}} 25 48 {{ template "notificationNumber" . }} 26 49 {{/* row 2, col 2: title */}}