Files
go-fa-api/notifications.go
2026-05-26 20:21:55 +02:00

194 lines
6.6 KiB
Go

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
}