A locally focused bluesky appview

attempt to handle notification seen state better

Changed files
+86 -11
models
xrpc
notification
+1
main.go
··· 131 131 db.AutoMigrate(StarterPack{}) 132 132 db.AutoMigrate(backend.SyncInfo{}) 133 133 db.AutoMigrate(Notification{}) 134 + db.AutoMigrate(NotificationSeen{}) 134 135 db.AutoMigrate(SequenceTracker{}) 135 136 db.Exec("CREATE INDEX IF NOT EXISTS reposts_subject_idx ON reposts (subject)") 136 137 db.Exec("CREATE INDEX IF NOT EXISTS posts_reply_to_idx ON posts (reply_to)")
+5
models/models.go
··· 47 47 Key string `gorm:"uniqueIndex"` 48 48 IntVal int64 49 49 } 50 + 51 + type NotificationSeen struct { 52 + Repo uint `gorm:"uniqueindex"` 53 + SeenAt time.Time 54 + }
+80 -11
xrpc/notification/listNotifications.go
··· 13 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 14 "github.com/labstack/echo/v4" 15 15 "github.com/whyrusleeping/konbini/hydration" 16 + models "github.com/whyrusleeping/konbini/models" 16 17 "github.com/whyrusleeping/konbini/views" 17 - "github.com/whyrusleeping/market/models" 18 18 "gorm.io/gorm" 19 + "gorm.io/gorm/clause" 19 20 ) 20 21 21 22 // HandleListNotifications implements app.bsky.notification.listNotifications 22 23 func HandleListNotifications(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 23 24 viewer := getUserDID(c) 24 25 if viewer == "" { 25 - return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 26 + return c.JSON(http.StatusUnauthorized, map[string]any{ 26 27 "error": "AuthenticationRequired", 27 28 "message": "authentication required", 28 29 }) ··· 77 78 } 78 79 query += ` ORDER BY n.created_at DESC LIMIT ?` 79 80 80 - var queryArgs []interface{} 81 + var queryArgs []any 81 82 queryArgs = append(queryArgs, viewer) 82 83 if cursor > 0 { 83 84 queryArgs = append(queryArgs, cursor) ··· 85 86 queryArgs = append(queryArgs, limit) 86 87 87 88 if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 88 - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 89 + return c.JSON(http.StatusInternalServerError, map[string]any{ 89 90 "error": "InternalError", 90 91 "message": "failed to query notifications", 91 92 }) ··· 142 143 func HandleGetUnreadCount(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 143 144 viewer := getUserDID(c) 144 145 if viewer == "" { 145 - return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 146 + return c.JSON(http.StatusUnauthorized, map[string]any{ 146 147 "error": "AuthenticationRequired", 147 148 "message": "authentication required", 148 149 }) 149 150 } 150 151 151 - // For now, return 0 - we'd need to track read state in the database 152 - return c.JSON(http.StatusOK, map[string]interface{}{ 153 - "count": 0, 152 + var repo models.Repo 153 + if err := db.Find(&repo, "did = ?", viewer).Error; err != nil { 154 + return err 155 + } 156 + 157 + var lastSeen time.Time 158 + if err := db.Raw("SELECT seen_at FROM notification_seens WHERE repo = ?", repo.ID).Scan(&lastSeen).Error; err != nil { 159 + return err 160 + } 161 + 162 + var count int 163 + query := `SELECT count(*) FROM notifications WHERE created_at > ? AND for = ?` 164 + if err := db.Raw(query, lastSeen, repo.ID).Scan(&count).Error; err != nil { 165 + return c.JSON(http.StatusInternalServerError, map[string]any{ 166 + "error": "InternalError", 167 + "message": "failed to count unread notifications", 168 + }) 169 + } 170 + 171 + return c.JSON(http.StatusOK, map[string]any{ 172 + "count": count, 154 173 }) 155 174 } 156 175 ··· 158 177 func HandleUpdateSeen(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 159 178 viewer := getUserDID(c) 160 179 if viewer == "" { 161 - return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 180 + return c.JSON(http.StatusUnauthorized, map[string]any{ 162 181 "error": "AuthenticationRequired", 163 182 "message": "authentication required", 164 183 }) 165 184 } 166 185 167 - // For now, just return success - we'd need to track seen timestamps in the database 168 - return c.JSON(http.StatusOK, map[string]interface{}{}) 186 + var body bsky.NotificationUpdateSeen_Input 187 + if err := c.Bind(&body); err != nil { 188 + return c.JSON(http.StatusBadRequest, map[string]any{ 189 + "error": "InvalidRequest", 190 + "message": "invalid request body", 191 + }) 192 + } 193 + 194 + // Parse the seenAt timestamp 195 + seenAt, err := time.Parse(time.RFC3339, body.SeenAt) 196 + if err != nil { 197 + return c.JSON(http.StatusBadRequest, map[string]any{ 198 + "error": "InvalidRequest", 199 + "message": "invalid seenAt timestamp", 200 + }) 201 + } 202 + 203 + // Get the viewer's repo ID 204 + var repoID uint 205 + if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&repoID).Error; err != nil { 206 + return c.JSON(http.StatusInternalServerError, map[string]any{ 207 + "error": "InternalError", 208 + "message": "failed to find viewer repo", 209 + }) 210 + } 211 + 212 + if repoID == 0 { 213 + return c.JSON(http.StatusInternalServerError, map[string]any{ 214 + "error": "InternalError", 215 + "message": "viewer repo not found", 216 + }) 217 + } 218 + 219 + // Upsert the NotificationSeen record 220 + notifSeen := models.NotificationSeen{ 221 + Repo: repoID, 222 + SeenAt: seenAt, 223 + } 224 + 225 + err = db.Clauses(clause.OnConflict{ 226 + Columns: []clause.Column{{Name: "repo"}}, 227 + DoUpdates: clause.AssignmentColumns([]string{"seen_at"}), 228 + }).Create(&notifSeen).Error 229 + 230 + if err != nil { 231 + return c.JSON(http.StatusInternalServerError, map[string]any{ 232 + "error": "InternalError", 233 + "message": "failed to update seen timestamp", 234 + }) 235 + } 236 + 237 + return c.JSON(http.StatusOK, map[string]any{}) 169 238 } 170 239 171 240 func getUserDID(c echo.Context) string {