From d6fa3bb15d9d0faab0afe2ee3fdbc302db8bc5be Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 12 Mar 2025 22:02:33 +0800 Subject: [PATCH] :sparkles: Universal reading feed :recycle: Refactor post listing --- pkg/internal/http/api/activitypub_api.go | 2 +- pkg/internal/http/api/index.go | 1 + pkg/internal/http/api/posts_api.go | 72 +----------------- pkg/internal/http/api/recommendation_api.go | 26 ++++++- pkg/internal/services/fediverse.go | 2 + pkg/internal/services/feed.go | 84 +++++++++++++++++++++ pkg/internal/services/posts.go | 8 +- pkg/internal/services/posts_getter.go | 79 +++++++++++++++++++ 8 files changed, 203 insertions(+), 71 deletions(-) create mode 100644 pkg/internal/services/feed.go create mode 100644 pkg/internal/services/posts_getter.go diff --git a/pkg/internal/http/api/activitypub_api.go b/pkg/internal/http/api/activitypub_api.go index 7df6c3e..5972334 100644 --- a/pkg/internal/http/api/activitypub_api.go +++ b/pkg/internal/http/api/activitypub_api.go @@ -53,7 +53,7 @@ func apUserOutbox(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusNotFound, err.Error()) } - tx, err := UniversalPostFilter(c, database.C) + tx, err := services.UniversalPostFilter(c, database.C) tx.Where("publisher_id = ? AND reply_id IS NULL", publisher.ID) if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) diff --git a/pkg/internal/http/api/index.go b/pkg/internal/http/api/index.go index 4e30cb7..28ebeba 100644 --- a/pkg/internal/http/api/index.go +++ b/pkg/internal/http/api/index.go @@ -31,6 +31,7 @@ func MapControllers(app *fiber.App, baseURL string) { { recommendations.Get("/", listRecommendation) recommendations.Get("/shuffle", listRecommendationShuffle) + recommendations.Get("/feed", getRecommendationFeed) } stories := api.Group("/stories").Name("Story API") diff --git a/pkg/internal/http/api/posts_api.go b/pkg/internal/http/api/posts_api.go index 809946e..21c7cb7 100644 --- a/pkg/internal/http/api/posts_api.go +++ b/pkg/internal/http/api/posts_api.go @@ -4,13 +4,11 @@ import ( "fmt" "strconv" "strings" - "time" "git.solsynth.dev/hypernet/nexus/pkg/nex/cruda" "git.solsynth.dev/hypernet/nexus/pkg/nex/sec" "git.solsynth.dev/hypernet/passport/pkg/authkit" authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models" - "gorm.io/gorm" "git.solsynth.dev/hypernet/interactive/pkg/internal/database" "git.solsynth.dev/hypernet/interactive/pkg/internal/gap" @@ -21,68 +19,6 @@ import ( "github.com/samber/lo" ) -type UniversalPostFilterConfig struct { - ShowDraft bool - ShowReply bool - ShowCollapsed bool -} - -func UniversalPostFilter(c *fiber.Ctx, tx *gorm.DB, cfg ...UniversalPostFilterConfig) (*gorm.DB, error) { - var config UniversalPostFilterConfig - if len(cfg) > 0 { - config = cfg[0] - } else { - config = UniversalPostFilterConfig{} - } - - if user, authenticated := c.Locals("user").(authm.Account); authenticated { - tx = services.FilterPostWithUserContext(c, tx, &user) - if c.QueryBool("noDraft", true) && !config.ShowDraft { - tx = services.FilterPostDraft(tx) - tx = services.FilterPostWithPublishedAt(tx, time.Now()) - } else { - tx = services.FilterPostDraftWithAuthor(database.C, user.ID) - tx = services.FilterPostWithPublishedAt(tx, time.Now(), user.ID) - } - } else { - tx = services.FilterPostWithUserContext(c, tx, nil) - tx = services.FilterPostDraft(tx) - tx = services.FilterPostWithPublishedAt(tx, time.Now()) - } - - if c.QueryBool("noReply", true) && !config.ShowReply { - tx = services.FilterPostReply(tx) - } - if c.QueryBool("noCollapse", true) && !config.ShowCollapsed { - tx = tx.Where("is_collapsed = ? OR is_collapsed IS NULL", false) - } - - if len(c.Query("author")) > 0 { - var author models.Publisher - if err := database.C.Where("name = ?", c.Query("author")).First(&author).Error; err != nil { - return tx, fiber.NewError(fiber.StatusNotFound, err.Error()) - } - tx = tx.Where("publisher_id = ?", author.ID) - } - - if len(c.Query("categories")) > 0 { - tx = services.FilterPostWithCategory(tx, c.Query("categories")) - } - if len(c.Query("tags")) > 0 { - tx = services.FilterPostWithTag(tx, c.Query("tags")) - } - - if len(c.Query("type")) > 0 { - tx = services.FilterPostWithType(tx, c.Query("type")) - } - - if len(c.Query("realm")) > 0 { - tx = services.FilterPostWithRealm(tx, c.Query("realm")) - } - - return tx, nil -} - func getPost(c *fiber.Ctx) error { id := c.Params("postId") @@ -91,7 +27,7 @@ func getPost(c *fiber.Ctx) error { tx := database.C - if tx, err = UniversalPostFilter(c, tx, UniversalPostFilterConfig{ + if tx, err = services.UniversalPostFilter(c, tx, services.UniversalPostFilterConfig{ ShowReply: true, ShowDraft: true, }); err != nil { @@ -140,7 +76,7 @@ func searchPost(c *fiber.Ctx) error { tx = services.FilterPostWithFuzzySearch(tx, probe) var err error - if tx, err = UniversalPostFilter(c, tx, UniversalPostFilterConfig{ + if tx, err = services.UniversalPostFilter(c, tx, services.UniversalPostFilterConfig{ ShowReply: true, }); err != nil { return err @@ -183,7 +119,7 @@ func listPost(c *fiber.Ctx) error { tx := database.C var err error - if tx, err = UniversalPostFilter(c, tx); err != nil { + if tx, err = services.UniversalPostFilter(c, tx); err != nil { return err } @@ -224,7 +160,7 @@ func listPostMinimal(c *fiber.Ctx) error { tx := database.C var err error - if tx, err = UniversalPostFilter(c, tx); err != nil { + if tx, err = services.UniversalPostFilter(c, tx); err != nil { return err } diff --git a/pkg/internal/http/api/recommendation_api.go b/pkg/internal/http/api/recommendation_api.go index 286addd..3d9b723 100644 --- a/pkg/internal/http/api/recommendation_api.go +++ b/pkg/internal/http/api/recommendation_api.go @@ -1,6 +1,8 @@ package api import ( + "time" + "git.solsynth.dev/hypernet/interactive/pkg/internal/database" "git.solsynth.dev/hypernet/interactive/pkg/internal/models" "git.solsynth.dev/hypernet/interactive/pkg/internal/services" @@ -50,7 +52,7 @@ func listRecommendationShuffle(c *fiber.Ctx) error { tx := database.C var err error - if tx, err = UniversalPostFilter(c, tx); err != nil { + if tx, err = services.UniversalPostFilter(c, tx); err != nil { return err } @@ -83,3 +85,25 @@ func listRecommendationShuffle(c *fiber.Ctx) error { "data": items, }) } + +func getRecommendationFeed(c *fiber.Ctx) error { + limit := c.QueryInt("limit", 20) + cursor := c.QueryInt("cursor", 0) + + var cursorTime *time.Time + if cursor > 0 { + cursorTime = lo.ToPtr(time.Unix(int64(cursor), 0)) + } + + var userId *uint + if user, authenticated := c.Locals("user").(authm.Account); authenticated { + userId = &user.ID + } + + entries, err := services.GetFeed(c, limit, userId, cursorTime) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return c.JSON(entries) +} diff --git a/pkg/internal/services/fediverse.go b/pkg/internal/services/fediverse.go index f0e9f43..9b9defd 100644 --- a/pkg/internal/services/fediverse.go +++ b/pkg/internal/services/fediverse.go @@ -44,6 +44,8 @@ func FetchFediversePost(cfg FediverseFriendConfig) ([]models.FediversePost, erro }) return posts, nil default: + // TODO Other platform fetching is still under development + // DO NOT USE THEM return nil, fmt.Errorf("unsupported fediverse service: %s", cfg.Type) } } diff --git a/pkg/internal/services/feed.go b/pkg/internal/services/feed.go new file mode 100644 index 0000000..de73414 --- /dev/null +++ b/pkg/internal/services/feed.go @@ -0,0 +1,84 @@ +package services + +import ( + "fmt" + "time" + + "git.solsynth.dev/hypernet/interactive/pkg/internal/database" + "git.solsynth.dev/hypernet/interactive/pkg/internal/models" + "github.com/gofiber/fiber/v2" + "github.com/samber/lo" + "gorm.io/gorm" +) + +type FeedEntry struct { + Type string `json:"type"` + Data any `json:"data"` + CreatedAt time.Time `json:"created_at"` +} + +func GetFeed(c *fiber.Ctx, limit int, user *uint, cursor *time.Time) ([]FeedEntry, error) { + // We got two types of data for now + // Plan to let each of them take 50% of the output + + var feed []FeedEntry + + interTx, err := UniversalPostFilter(c, database.C, UniversalPostFilterConfig{ + TimeCursor: cursor, + }) + if err != nil { + return nil, fmt.Errorf("failed to prepare load interactive posts: %v", err) + } + interPosts, err := ListPostForFeed(interTx, limit/2, user) + if err != nil { + return nil, fmt.Errorf("failed to load interactive posts: %v", err) + } + feed = append(feed, interPosts...) + + fediTx := database.C + if cursor != nil { + fediTx = fediTx.Where("created_at < ?", *cursor) + } + fediPosts, err := ListFediversePostForFeed(fediTx, limit/2) + if err != nil { + return feed, fmt.Errorf("failed to load fediverse posts: %v", err) + } + feed = append(feed, fediPosts...) + + return feed, nil +} + +// We assume the database context already handled the filtering and pagination +// Only manage to pulling the content only + +func ListPostForFeed(tx *gorm.DB, limit int, user *uint) ([]FeedEntry, error) { + posts, err := ListPost(tx, limit, -1, "published_at DESC", user) + if err != nil { + return nil, err + } + entries := lo.Map(posts, func(post *models.Post, _ int) FeedEntry { + return FeedEntry{ + Type: "interactive.post", + Data: post, + CreatedAt: post.CreatedAt, + } + }) + return entries, nil +} + +func ListFediversePostForFeed(tx *gorm.DB, limit int) ([]FeedEntry, error) { + var posts []models.FediversePost + if err := tx. + Preload("User").Limit(limit). + Find(&posts).Error; err != nil { + return nil, err + } + entries := lo.Map(posts, func(post models.FediversePost, _ int) FeedEntry { + return FeedEntry{ + Type: "fediverse.post", + Data: post, + CreatedAt: post.CreatedAt, + } + }) + return entries, nil +} diff --git a/pkg/internal/services/posts.go b/pkg/internal/services/posts.go index 371ec67..b9a91b4 100644 --- a/pkg/internal/services/posts.go +++ b/pkg/internal/services/posts.go @@ -414,9 +414,15 @@ func ListPost(tx *gorm.DB, take int, offset int, order any, user *uint, noReact take = 100 } + if take >= 0 { + tx = tx.Limit(take) + } + if offset >= 0 { + tx = tx.Offset(offset) + } + var items []*models.Post if err := PreloadGeneral(tx). - Limit(take).Offset(offset). Order(order). Find(&items).Error; err != nil { return items, err diff --git a/pkg/internal/services/posts_getter.go b/pkg/internal/services/posts_getter.go new file mode 100644 index 0000000..58566f9 --- /dev/null +++ b/pkg/internal/services/posts_getter.go @@ -0,0 +1,79 @@ +package services + +import ( + "time" + + "git.solsynth.dev/hypernet/interactive/pkg/internal/database" + "git.solsynth.dev/hypernet/interactive/pkg/internal/models" + authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type UniversalPostFilterConfig struct { + ShowDraft bool + ShowReply bool + ShowCollapsed bool + TimeCursor *time.Time +} + +func UniversalPostFilter(c *fiber.Ctx, tx *gorm.DB, cfg ...UniversalPostFilterConfig) (*gorm.DB, error) { + var config UniversalPostFilterConfig + if len(cfg) > 0 { + config = cfg[0] + } else { + config = UniversalPostFilterConfig{} + } + + timeCursor := time.Now() + if config.TimeCursor != nil { + timeCursor = *config.TimeCursor + } + + if user, authenticated := c.Locals("user").(authm.Account); authenticated { + tx = FilterPostWithUserContext(c, tx, &user) + if c.QueryBool("noDraft", true) && !config.ShowDraft { + tx = FilterPostDraft(tx) + tx = FilterPostWithPublishedAt(tx, timeCursor) + } else { + tx = FilterPostDraftWithAuthor(database.C, user.ID) + tx = FilterPostWithPublishedAt(tx, timeCursor, user.ID) + } + } else { + tx = FilterPostWithUserContext(c, tx, nil) + tx = FilterPostDraft(tx) + tx = FilterPostWithPublishedAt(tx, timeCursor) + } + + if c.QueryBool("noReply", true) && !config.ShowReply { + tx = FilterPostReply(tx) + } + if c.QueryBool("noCollapse", true) && !config.ShowCollapsed { + tx = tx.Where("is_collapsed = ? OR is_collapsed IS NULL", false) + } + + if len(c.Query("author")) > 0 { + var author models.Publisher + if err := database.C.Where("name = ?", c.Query("author")).First(&author).Error; err != nil { + return tx, fiber.NewError(fiber.StatusNotFound, err.Error()) + } + tx = tx.Where("publisher_id = ?", author.ID) + } + + if len(c.Query("categories")) > 0 { + tx = FilterPostWithCategory(tx, c.Query("categories")) + } + if len(c.Query("tags")) > 0 { + tx = FilterPostWithTag(tx, c.Query("tags")) + } + + if len(c.Query("type")) > 0 { + tx = FilterPostWithType(tx, c.Query("type")) + } + + if len(c.Query("realm")) > 0 { + tx = FilterPostWithRealm(tx, c.Query("realm")) + } + + return tx, nil +}