✨ Universal reading feed
♻️ Refactor post listing
			
			
This commit is contained in:
		| @@ -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()) | ||||
|   | ||||
| @@ -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") | ||||
|   | ||||
| @@ -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 | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										84
									
								
								pkg/internal/services/feed.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								pkg/internal/services/feed.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										79
									
								
								pkg/internal/services/posts_getter.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								pkg/internal/services/posts_getter.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
		Reference in New Issue
	
	Block a user