Files
go-fa-api/notifications.go
2026-05-25 22:27:18 +02:00

208 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package fa
import (
"context"
"time"
"github.com/PuerkitoBio/goquery"
"git.anthrove.art/public/go-fa-api/internal/urls"
)
// Notifications is the parsed contents of /msg/others/ FA's catch-all
// notification page. Each category surfaces as a separate slice so callers
// don't have to type-switch over a heterogeneous list.
//
// Sections that aren't currently rendered (because you have none of that
// type pending) come back as nil/empty rather than as an error.
type Notifications struct {
Journals []JournalNotification
Watches []WatchNotification
SubmissionComments []CommentNotification
JournalComments []CommentNotification
Favorites []FavNotification
Shouts []ShoutNotification
}
// JournalNotification represents one entry in the "Journals" notification
// section a journal posted by someone you watch.
type JournalNotification struct {
JournalID JournalID
Title string
Author UserRef
Rating Rating
PostedAt time.Time
}
// WatchNotification is a user that started watching you.
type WatchNotification struct {
User UserRef
WatchedAt time.Time
}
// CommentNotification is a comment posted on one of your submissions or
// journals. OnSubmission is non-zero for submission comments;
// OnJournal is non-zero for journal comments. Only one is set per item;
// they're segregated into the two slices on [Notifications] but share this
// type for callers that want to merge.
type CommentNotification struct {
CommentID CommentID
OnSubmission SubmissionID
OnJournal JournalID
OnTitle string // title of the submission/journal that was commented on
Author UserRef
PostedAt time.Time
}
// FavNotification is someone favoriting one of your submissions.
type FavNotification struct {
SubmissionID SubmissionID
SubmissionTitle string
Favoriter UserRef
FavoritedAt time.Time
}
// ShoutNotification is a new shout left on your profile.
type ShoutNotification struct {
ShoutID int64 // FA's internal shout id (matches the anchor id="shout-N" on your profile)
Author UserRef
PostedAt time.Time
}
// NotificationsOption tunes a single [Client.Notifications] call.
type NotificationsOption func(*notificationsConfig)
type notificationsConfig struct {
resolveAvatars bool
avatarLimit int
}
// WithResolvedAvatars makes [Client.Notifications] fill in author avatar
// URLs that FurAffinity omits from the /msg/others/ markup.
//
// FA does not render an avatar image on every notification row journal
// notifications in particular are a purely textual list, so their author
// UserRef comes back with AvatarURL == "". When this option is set, the
// SDK resolves authors by fetching each one's profile page and reading the
// avatar from it.
//
// Each distinct author costs one extra HTTP request, deduplicated within
// the call and serialized through the client's rate limiter. Because that
// limiter is global, total wall time is roughly (distinct authors) ×
// (rate interval) on a busy account, dozens of seconds. limit caps how
// many distinct authors are resolved: pass a small value (e.g. 12) to
// bound a cold load. Authors are resolved in notification order with
// journals first, so a Home "recent journals" feed still gets real
// avatars; any author past the limit keeps AvatarURL == "" (callers
// typically render an initials fallback). A limit <= 0 means unlimited.
//
// Per-author failures are ignored: a user whose page can't be fetched
// simply keeps an empty AvatarURL.
func WithResolvedAvatars(limit int) NotificationsOption {
return func(c *notificationsConfig) {
c.resolveAvatars = true
c.avatarLimit = limit
}
}
// Notifications fetches /msg/others/ and returns the parsed notification
// page. Requires a logged-in client; anonymous calls surface as
// [ErrUnauthorized].
//
// All categories are returned in a single fetch there is no pagination
// on this page. Pass [WithResolvedAvatars] to additionally backfill author
// avatars that FA omits from the page (see that option's docs).
func (c *Client) Notifications(ctx context.Context, opts ...NotificationsOption) (*Notifications, error) {
var cfg notificationsConfig
for _, o := range opts {
o(&cfg)
}
var out *Notifications
err := c.fetch(ctx, urls.MsgOthers(), func(doc *goquery.Document) error {
n, err := parseNotifications(doc)
if err != nil {
return err
}
out = n
return nil
})
if err != nil {
return nil, err
}
if cfg.resolveAvatars {
c.resolveNotificationAvatars(ctx, out, cfg.avatarLimit)
}
return out, nil
}
// resolveNotificationAvatars backfills empty author AvatarURLs on a parsed
// [Notifications] by fetching each distinct author's profile page. Authors
// that already carry an avatar (FA does render one on, e.g., watch rows)
// are left untouched. Lookups are deduplicated by URL-safe name including
// negative results so each author costs at most one request.
//
// limit caps the number of distinct authors actually fetched; a limit <= 0
// is unlimited. Authors are visited journals-first, so when the budget is
// small the Home "recent journals" feed is the part that gets real avatars.
// An already-fetched author (cache hit) is still applied past the limit —
// that costs nothing only *new* fetches are gated.
//
// Failures are deliberately swallowed: a single unreachable profile must
// not fail the whole notifications call, and a stale ctx simply leaves the
// remaining avatars empty.
func (c *Client) resolveNotificationAvatars(ctx context.Context, n *Notifications, limit int) {
if n == nil {
return
}
cache := make(map[string]string)
fill := func(ref *UserRef) {
if ref.Name == "" || ref.AvatarURL != "" {
return
}
avatar, ok := cache[ref.Name]
if !ok {
if limit > 0 && len(cache) >= limit {
return // fetch budget exhausted leave AvatarURL empty
}
avatar = c.fetchUserAvatar(ctx, ref.Name)
cache[ref.Name] = avatar
}
ref.AvatarURL = avatar
}
for i := range n.Journals {
fill(&n.Journals[i].Author)
}
for i := range n.Watches {
fill(&n.Watches[i].User)
}
for i := range n.SubmissionComments {
fill(&n.SubmissionComments[i].Author)
}
for i := range n.JournalComments {
fill(&n.JournalComments[i].Author)
}
for i := range n.Favorites {
fill(&n.Favorites[i].Favoriter)
}
for i := range n.Shouts {
fill(&n.Shouts[i].Author)
}
}
// fetchUserAvatar fetches /user/{name}/ and returns just the profile
// owner's avatar URL. It returns "" on any failure callers treat a
// missing avatar as non-fatal.
func (c *Client) fetchUserAvatar(ctx context.Context, name string) string {
var avatar string
_ = c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error {
avatar = urls.AbsoluteCDN(firstNonEmpty(
trimAttr(doc.Find("userpage-nav-avatar img").First(), "src"),
trimAttr(doc.Find("div.userpage-nav-avatar img").First(), "src"),
))
return nil
})
return avatar
}