appview: generate atom feed from profile timeline for users #444

merged
opened by ptr.pet targeting master from ptr.pet/core: feeds
Changed files
+117 -1
appview
+1 -1
appview/middleware/middleware.go
···
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
if err != nil {
// invalid did or handle
-
log.Println("failed to resolve did/handle:", err)
+
log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err)
mw.pages.Error404(w)
return
}
+112
appview/state/profile.go
···
package state
import (
+
"context"
"fmt"
"log"
"net/http"
···
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/go-chi/chi/v5"
+
"github.com/gorilla/feeds"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
···
})
}
+
func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed {
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
s.pages.Error404(w)
+
return nil
+
}
+
+
feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String())
+
if err != nil {
+
s.pages.Error500(w)
+
return nil
+
}
+
+
return feed
+
}
+
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
+
feed := s.feedFromRequest(w, r)
+
if feed == nil {
+
return
+
}
+
+
atom, err := feed.ToAtom()
+
if err != nil {
+
s.pages.Error500(w)
+
return
+
}
+
+
w.Header().Set("content-type", "application/atom+xml")
+
w.Write([]byte(atom))
+
}
+
+
func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) {
+
timeline, err := db.MakeProfileTimeline(s.db, did)
+
if err != nil {
+
return nil, err
+
}
+
+
author := &feeds.Author{
+
Name: fmt.Sprintf("@%s", handle),
+
}
+
feed := &feeds.Feed{
+
Title: fmt.Sprintf("timeline feed for %s", author.Name),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"},
+
Items: make([]*feeds.Item, 0),
+
Updated: time.UnixMilli(0),
+
Author: author,
+
}
+
for _, byMonth := range timeline.ByMonth {
+
for _, pull := range byMonth.PullEvents.Items {
+
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
+
if err != nil {
+
return nil, err
+
}
+
feed.Items = append(feed.Items, &feeds.Item{
+
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
+
Created: pull.Created,
+
Author: author,
+
})
+
for _, submission := range pull.Submissions {
+
feed.Items = append(feed.Items, &feeds.Item{
+
Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
+
Created: submission.Created,
+
Author: author,
+
})
+
}
+
}
+
for _, issue := range byMonth.IssueEvents.Items {
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
+
if err != nil {
+
return nil, err
+
}
+
feed.Items = append(feed.Items, &feeds.Item{
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
+
Created: issue.Created,
+
Author: author,
+
})
+
}
+
for _, repo := range byMonth.RepoEvents {
+
var title string
+
if repo.Source != nil {
+
id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
+
if err != nil {
+
return nil, err
+
}
+
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name)
+
} else {
+
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
+
}
+
feed.Items = append(feed.Items, &feeds.Item{
+
Title: title,
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"},
+
Created: repo.Repo.Created,
+
Author: author,
+
})
+
}
+
}
+
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
+
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
+
})
+
if len(feed.Items) > 0 {
+
feed.Updated = feed.Items[0].Created
+
}
+
+
return feed, nil
+
}
+
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
+1
appview/state/router.go
···
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
r.Get("/", s.Profile)
+
r.Get("/feed.atom", s.AtomFeedPage)
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
r.Use(mw.GoImport())
+1
go.mod
···
github.com/golang/mock v1.6.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
+
github.com/gorilla/feeds v1.2.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+2
go.sum
···
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
+
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=