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 } // NotificationsOptions tunes a single [Client.Notifications] call. Match // the pattern used by [BrowseOptions] / [SearchOptions] / [ListOptions]: a // plain struct of zero-default fields, kept distinct from the per-request // [Option] values that swap creds. type NotificationsOptions struct { // ResolveAvatars 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 is // set, the SDK resolves authors by fetching each one's profile page. // // Each distinct author costs one extra HTTP request, deduplicated // within the call and serialized through the client's rate limiter. ResolveAvatars bool // AvatarLimit caps how many distinct authors are resolved when // ResolveAvatars is true. Authors are visited journals-first; any // author past the limit keeps AvatarURL == "". Zero or negative // means unlimited. Has no effect when ResolveAvatars is false. AvatarLimit int } // 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. Set [NotificationsOptions.ResolveAvatars] to additionally // backfill author avatars that FA omits from the page. // // reqOpts are per-request overrides (typically [WithCookies] etc. for the // multi-tenant case where many users share one client and one rate limiter). func (c *Client) Notifications(ctx context.Context, opts NotificationsOptions, reqOpts ...Option) (*Notifications, error) { 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 }, reqOpts...) if err != nil { return nil, err } if opts.ResolveAvatars { c.resolveNotificationAvatars(ctx, out, opts.AvatarLimit, reqOpts) } 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, reqOpts []Option) { 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, reqOpts) 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, reqOpts []Option) 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 }, reqOpts...) return avatar }