Monorepo for Tangled tangled.org
5

Configure Feed

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

1package notifications 2 3import ( 4 "log/slog" 5 "net/http" 6 "strconv" 7 8 "github.com/go-chi/chi/v5" 9 "tangled.org/core/appview/db" 10 "tangled.org/core/appview/middleware" 11 "tangled.org/core/appview/models" 12 "tangled.org/core/appview/oauth" 13 "tangled.org/core/appview/pages" 14 "tangled.org/core/appview/pagination" 15 "tangled.org/core/orm" 16) 17 18type Notifications struct { 19 db *db.DB 20 oauth *oauth.OAuth 21 pages *pages.Pages 22 logger *slog.Logger 23} 24 25func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { 26 return &Notifications{ 27 db: database, 28 oauth: oauthHandler, 29 pages: pagesHandler, 30 logger: logger, 31 } 32} 33 34func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 35 r := chi.NewRouter() 36 37 r.Get("/count", n.getUnreadCount) 38 39 r.Group(func(r chi.Router) { 40 r.Use(middleware.AuthMiddleware(n.oauth)) 41 r.With(middleware.Paginate).Get("/", n.notificationsPage) 42 r.Get("/preview", n.previewHandler) 43 r.Post("/{id}/read", n.markRead) 44 r.Post("/{id}/unread", n.markUnread) 45 r.Post("/read-all", n.markAllRead) 46 r.Delete("/{id}", n.deleteNotification) 47 }) 48 49 return r 50} 51 52func notificationFilters(r *http.Request, userDid string) (filters []orm.Filter, readFilter, categoryFilter string) { 53 filters = []orm.Filter{orm.FilterEq("recipient_did", userDid)} 54 55 readFilter = r.URL.Query().Get("read") 56 if readFilter != "unread" { 57 readFilter = "inbox" 58 } 59 if readFilter == "unread" { 60 filters = append(filters, orm.FilterEq("read", 0)) 61 } 62 63 categoryFilter = r.URL.Query().Get("category") 64 switch categoryFilter { 65 case "social": 66 filters = append(filters, orm.FilterIn("type", models.SocialNotificationTypes)) 67 case "work": 68 filters = append(filters, orm.FilterIn("type", models.WorkNotificationTypes)) 69 default: 70 categoryFilter = "all" 71 } 72 73 return filters, readFilter, categoryFilter 74} 75 76func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 77 l := n.logger.With("handler", "notificationsPage") 78 user := n.oauth.GetMultiAccountUser(r) 79 80 page := pagination.FromContext(r.Context()) 81 filters, readFilter, categoryFilter := notificationFilters(r, user.Did) 82 83 // mobile: respects category filter 84 mobileTotal, err := db.CountNotifications(n.db, filters...) 85 if err != nil { 86 l.Error("failed to get total notifications", "err", err) 87 n.pages.Error500(w) 88 return 89 } 90 notifications, err := db.GetNotificationsWithEntities(n.db, page, filters...) 91 if err != nil { 92 l.Error("failed to get notifications", "err", err) 93 n.pages.Error500(w) 94 return 95 } 96 97 // desktop columns: category is fixed, only read filter applies 98 readFilters := []orm.Filter{orm.FilterEq("recipient_did", user.Did)} 99 if readFilter == "unread" { 100 readFilters = append(readFilters, orm.FilterEq("read", 0)) 101 } 102 workTotal, err := db.CountNotifications(n.db, 103 append(readFilters, orm.FilterIn("type", models.WorkNotificationTypes))..., 104 ) 105 if err != nil { 106 l.Error("failed to count work notifications", "err", err) 107 n.pages.Error500(w) 108 return 109 } 110 workNotifications, err := db.GetNotificationsWithEntities(n.db, page, 111 append(readFilters, orm.FilterIn("type", models.WorkNotificationTypes))..., 112 ) 113 if err != nil { 114 l.Error("failed to get work notifications", "err", err) 115 n.pages.Error500(w) 116 return 117 } 118 socialTotal, err := db.CountNotifications(n.db, 119 append(readFilters, orm.FilterIn("type", models.SocialNotificationTypes))..., 120 ) 121 if err != nil { 122 l.Error("failed to count social notifications", "err", err) 123 n.pages.Error500(w) 124 return 125 } 126 socialNotifications, err := db.GetNotificationsWithEntities(n.db, page, 127 append(readFilters, orm.FilterIn("type", models.SocialNotificationTypes))..., 128 ) 129 if err != nil { 130 l.Error("failed to get social notifications", "err", err) 131 n.pages.Error500(w) 132 return 133 } 134 135 // shared pagination total: max of all relevant counts 136 total := int(max(socialTotal, max(workTotal, mobileTotal))) 137 138 unreadBase := []orm.Filter{ 139 orm.FilterEq("recipient_did", user.Did), 140 orm.FilterEq("read", 0), 141 } 142 workUnreadCount, err := db.CountNotifications(n.db, 143 append(unreadBase, orm.FilterIn("type", models.WorkNotificationTypes))..., 144 ) 145 if err != nil { 146 l.Error("failed to count work unread", "err", err) 147 } 148 socialUnreadCount, err := db.CountNotifications(n.db, 149 append(unreadBase, orm.FilterIn("type", models.SocialNotificationTypes))..., 150 ) 151 if err != nil { 152 l.Error("failed to count social unread", "err", err) 153 } 154 155 err = n.pages.Notifications(w, pages.NotificationsParams{ 156 BaseParams: pages.BaseParamsFromContext(r.Context()), 157 MobileGroups: pages.GroupNotificationsByDate(notifications), 158 WorkGroups: pages.GroupNotificationsByDate(workNotifications), 159 SocialGroups: pages.GroupNotificationsByDate(socialNotifications), 160 WorkUnreadCount: workUnreadCount, 161 SocialUnreadCount: socialUnreadCount, 162 Page: page, 163 Total: total, 164 ReadFilter: readFilter, 165 CategoryFilter: categoryFilter, 166 }) 167 if err != nil { 168 l.Error("failed to render page", "err", err) 169 } 170} 171 172func (n *Notifications) previewHandler(w http.ResponseWriter, r *http.Request) { 173 l := n.logger.With("handler", "previewHandler") 174 user := n.oauth.GetMultiAccountUser(r) 175 176 filters, readFilter, categoryFilter := notificationFilters(r, user.Did) 177 178 notifications, err := db.GetNotificationsWithEntities( 179 n.db, 180 pagination.Page{Limit: 5, Offset: 0}, 181 filters..., 182 ) 183 if err != nil { 184 l.Error("failed to get notifications", "err", err) 185 n.pages.Error500(w) 186 return 187 } 188 189 err = n.pages.NotificationPreview(w, pages.NotificationPreviewParams{ 190 BaseParams: pages.BaseParamsFromContext(r.Context()), 191 Notifications: notifications, 192 ReadFilter: readFilter, 193 CategoryFilter: categoryFilter, 194 }) 195 if err != nil { 196 l.Error("failed to render notification preview", "err", err) 197 } 198} 199 200func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 201 user := n.oauth.GetMultiAccountUser(r) 202 if user == nil { 203 http.Error(w, "Forbidden", http.StatusUnauthorized) 204 return 205 } 206 207 count, err := db.CountNotifications( 208 n.db, 209 orm.FilterEq("recipient_did", user.Did), 210 orm.FilterEq("read", 0), 211 ) 212 if err != nil { 213 http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 214 return 215 } 216 217 params := pages.NotificationCountParams{ 218 Count: count, 219 } 220 err = n.pages.NotificationCount(w, params) 221 if err != nil { 222 http.Error(w, "Failed to render count", http.StatusInternalServerError) 223 return 224 } 225} 226 227func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 228 n.toggleRead(w, r, true) 229} 230 231func (n *Notifications) markUnread(w http.ResponseWriter, r *http.Request) { 232 n.toggleRead(w, r, false) 233} 234 235func (n *Notifications) toggleRead(w http.ResponseWriter, r *http.Request, read bool) { 236 l := n.logger.With("handler", "toggleRead") 237 userDid := n.oauth.GetDid(r) 238 239 idStr := chi.URLParam(r, "id") 240 notificationID, err := strconv.ParseInt(idStr, 10, 64) 241 if err != nil { 242 http.Error(w, "Invalid notification ID", http.StatusBadRequest) 243 return 244 } 245 246 if read { 247 err = db.MarkNotificationRead(n.db, notificationID, userDid) 248 } else { 249 err = db.MarkNotificationUnread(n.db, notificationID, userDid) 250 } 251 if err != nil { 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 } 267 return 268 } 269 270 w.WriteHeader(http.StatusNoContent) 271} 272 273func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 274 userDid := n.oauth.GetDid(r) 275 276 err := db.MarkAllNotificationsRead(n.db, userDid) 277 if err != nil { 278 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 279 return 280 } 281 282 w.WriteHeader(http.StatusOK) 283} 284 285func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 286 userDid := n.oauth.GetDid(r) 287 288 idStr := chi.URLParam(r, "id") 289 notificationID, err := strconv.ParseInt(idStr, 10, 64) 290 if err != nil { 291 http.Error(w, "Invalid notification ID", http.StatusBadRequest) 292 return 293 } 294 295 err = db.DeleteNotification(n.db, notificationID, userDid) 296 if err != nil { 297 http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 298 return 299 } 300 301 w.WriteHeader(http.StatusOK) 302}