diff --git a/src/db/db.go b/src/db/db.go index 15b075f5..6628db83 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -62,8 +62,8 @@ func NewConnWithConfig(cfg config.PostgresConfig) *pgx.Conn { pgcfg, err := pgx.ParseConfig(cfg.DSN()) pgcfg.Tracer = &tracelog.TraceLog{ - zerologadapter.NewLogger(log.Logger), - cfg.LogLevel, + Logger: zerologadapter.NewLogger(log.Logger), + LogLevel: cfg.LogLevel, } conn, err := pgx.ConnectConfig(context.Background(), pgcfg) @@ -88,8 +88,8 @@ func NewConnPoolWithConfig(cfg config.PostgresConfig) *pgxpool.Pool { pgcfg.MinConns = cfg.MinConn pgcfg.MaxConns = cfg.MaxConn pgcfg.ConnConfig.Tracer = &tracelog.TraceLog{ - zerologadapter.NewLogger(log.Logger), - cfg.LogLevel, + Logger: zerologadapter.NewLogger(log.Logger), + LogLevel: cfg.LogLevel, } conn, err := pgxpool.NewWithConfig(context.Background(), pgcfg) diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 158dd813..ed380f9f 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -937,6 +937,28 @@ func BuildTwitchEventSubCallback() string { var RegexTwitchDebugPage = regexp.MustCompile("^/twitch_debug$") +/* +* Following + */ + +var RegexFollowingTest = regexp.MustCompile("^/following$") + +func BuildFollowingTest() string { + return Url("/following", nil) +} + +var RegexFollowUser = regexp.MustCompile("^/follow/user$") + +func BuildFollowUser() string { + return Url("/follow/user", nil) +} + +var RegexFollowProject = regexp.MustCompile("^/follow/project$") + +func BuildFollowProject() string { + return Url("/follow/project", nil) +} + /* * User assets */ diff --git a/src/migration/migrations/2024-05-16T194134Z_AddFollower.go b/src/migration/migrations/2024-05-16T194134Z_AddFollower.go new file mode 100644 index 00000000..a4569795 --- /dev/null +++ b/src/migration/migrations/2024-05-16T194134Z_AddFollower.go @@ -0,0 +1,56 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddFollower{}) +} + +type AddFollower struct{} + +func (m AddFollower) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2024, 5, 16, 19, 41, 34, 0, time.UTC)) +} + +func (m AddFollower) Name() string { + return "AddFollower" +} + +func (m AddFollower) Description() string { + return "Add follower table" +} + +func (m AddFollower) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + CREATE TABLE follower ( + user_id int NOT NULL, + following_user_id int REFERENCES hmn_user (id) ON DELETE CASCADE, + following_project_id int REFERENCES project (id) ON DELETE CASCADE + ); + + CREATE INDEX follower_user_id ON follower(user_id); + CREATE UNIQUE INDEX follower_following_user ON follower (user_id, following_user_id); + CREATE UNIQUE INDEX follower_following_project ON follower (user_id, following_project_id); + `, + ) + return err +} + +func (m AddFollower) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + DROP INDEX follower_following_user; + DROP INDEX follower_following_project; + DROP INDEX follower_user_id; + DROP TABLE follower; + `, + ) + return err +} diff --git a/src/templates/src/following_test.html b/src/templates/src/following_test.html new file mode 100644 index 00000000..4235e0bd --- /dev/null +++ b/src/templates/src/following_test.html @@ -0,0 +1,9 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+ {{ range .TimelineItems }} + {{ template "timeline_item.html" . }} + {{ end }} +
+{{ end }} diff --git a/src/templates/src/project_homepage.html b/src/templates/src/project_homepage.html index 5d199892..7be1f9e1 100644 --- a/src/templates/src/project_homepage.html +++ b/src/templates/src/project_homepage.html @@ -43,6 +43,34 @@ {{ end }} + {{ if .FollowUrl }} + {{ if .Following }}Unfollow{{ else }}Follow{{ end }} + + {{ end }} {{ if or .Header.Project.CanEdit (gt (len .RecentActivity) 0) }}
diff --git a/src/templates/src/user_profile.html b/src/templates/src/user_profile.html index 26838673..8534a4a8 100644 --- a/src/templates/src/user_profile.html +++ b/src/templates/src/user_profile.html @@ -43,6 +43,34 @@ {{ with or .ProfileUser.Bio .ProfileUser.Blurb }}
{{ . }}
{{ end }} + {{ if .FollowUrl }} + {{ if .Following }}Unfollow{{ else }}Follow{{ end }} + + {{ end }}
{{ if .ProfileUser.Email }}
diff --git a/src/website/following.go b/src/website/following.go new file mode 100644 index 00000000..4283030d --- /dev/null +++ b/src/website/following.go @@ -0,0 +1,254 @@ +package website + +import ( + "net/http" + "sort" + "strconv" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/hmndata" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/templates" +) + +func FollowingTest(c *RequestContext) ResponseData { + type Follower struct { + UserID int `db:"user_id"` + FollowingUserID *int `db:"following_user_id"` + FollowingProjectID *int `db:"following_project_id"` + } + + following, err := db.Query[Follower](c, c.Conn, ` + SELECT $columns + FROM follower + WHERE user_id = $1 + `, c.CurrentUser.ID) + + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch follow data")) + } + + projectIDs := make([]int, 0, len(following)) + userIDs := make([]int, 0, len(following)) + for _, f := range following { + if f.FollowingProjectID != nil { + projectIDs = append(projectIDs, *f.FollowingProjectID) + } + if f.FollowingUserID != nil { + userIDs = append(userIDs, *f.FollowingUserID) + } + } + + var userSnippets []hmndata.SnippetAndStuff + var projectSnippets []hmndata.SnippetAndStuff + var userPosts []hmndata.PostAndStuff + var projectPosts []hmndata.PostAndStuff + + if len(following) > 0 { + projects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{ + ProjectIDs: projectIDs, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects")) + } + + // NOTE(asaf): The original projectIDs might container hidden/abandoned projects, + // so we recreate it after the projects get filtered by FetchProjects. + projectIDs = projectIDs[0:0] + for _, p := range projects { + projectIDs = append(projectIDs, p.Project.ID) + } + + userSnippets, err = hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{ + OwnerIDs: userIDs, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user snippets")) + } + projectSnippets, err = hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{ + ProjectIDs: projectIDs, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets")) + } + + userPosts, err = hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{ + UserIDs: userIDs, + SortDescending: true, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user posts")) + } + projectPosts, err = hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{ + ProjectIDs: projectIDs, + SortDescending: true, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project posts")) + } + + } + + c.Perf.StartBlock("FOLLOWING", "Construct timeline items") + timelineItems := make([]templates.TimelineItem, 0, len(userSnippets)+len(projectSnippets)+len(userPosts)+len(projectPosts)) + + if len(userPosts) > 0 || len(projectPosts) > 0 { + c.Perf.StartBlock("SQL", "Fetch subforum tree") + subforumTree := models.GetFullSubforumTree(c, c.Conn) + lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree) + c.Perf.EndBlock() + + for _, post := range userPosts { + timelineItems = append(timelineItems, PostToTimelineItem( + hmndata.UrlContextForProject(&post.Project), + lineageBuilder, + &post.Post, + &post.Thread, + post.Author, + c.Theme, + )) + } + for _, post := range projectPosts { + timelineItems = append(timelineItems, PostToTimelineItem( + hmndata.UrlContextForProject(&post.Project), + lineageBuilder, + &post.Post, + &post.Thread, + post.Author, + c.Theme, + )) + } + } + + for _, s := range userSnippets { + item := SnippetToTimelineItem( + &s.Snippet, + s.Asset, + s.DiscordMessage, + s.Projects, + s.Owner, + c.Theme, + false, + ) + item.SmallInfo = true + timelineItems = append(timelineItems, item) + } + for _, s := range projectSnippets { + item := SnippetToTimelineItem( + &s.Snippet, + s.Asset, + s.DiscordMessage, + s.Projects, + s.Owner, + c.Theme, + false, + ) + item.SmallInfo = true + timelineItems = append(timelineItems, item) + } + + // TODO(asaf): Show when they're live on twitch + + c.Perf.StartBlock("FOLLOWING", "Sort timeline") + sort.Slice(timelineItems, func(i, j int) bool { + return timelineItems[j].Date.Before(timelineItems[i].Date) + }) + c.Perf.EndBlock() + + c.Perf.EndBlock() + + type FollowingTestData struct { + templates.BaseData + TimelineItems []templates.TimelineItem + } + + var res ResponseData + res.MustWriteTemplate("following_test.html", FollowingTestData{ + BaseData: getBaseDataAutocrumb(c, "Following test"), + TimelineItems: timelineItems, + }, c.Perf) + return res +} + +func FollowUser(c *RequestContext) ResponseData { + err := c.Req.ParseForm() + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form data")) + } + + userIDStr := c.Req.Form.Get("user_id") + unfollowStr := c.Req.Form.Get("unfollow") + + userID, err := strconv.Atoi(userIDStr) + if err != nil { + return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to parse user_id field")) + } + unfollow := unfollowStr != "" + + if unfollow { + _, err = c.Conn.Exec(c, ` + DELETE FROM follower + WHERE user_id = $1 AND following_user_id = $2 + `, c.CurrentUser.ID, userID) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to unfollow user")) + } + } else { + _, err = c.Conn.Exec(c, ` + INSERT INTO follower (user_id, following_user_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, c.CurrentUser.ID, userID) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to follow user")) + } + } + + var res ResponseData + addCORSHeaders(c, &res) + res.WriteHeader(http.StatusNoContent) + return res +} + +func FollowProject(c *RequestContext) ResponseData { + err := c.Req.ParseForm() + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form data")) + } + + projectIDStr := c.Req.Form.Get("project_id") + unfollowStr := c.Req.Form.Get("unfollow") + + projectID, err := strconv.Atoi(projectIDStr) + if err != nil { + return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to parse project_id field")) + } + unfollow := unfollowStr != "" + + if unfollow { + _, err = c.Conn.Exec(c, ` + DELETE FROM follower + WHERE user_id = $1 AND following_project_id = $2 + `, c.CurrentUser.ID, projectID) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to unfollow project")) + } + } else { + logging.Debug().Int("userID", c.CurrentUser.ID).Int("projectID", projectID).Msg("thing") + _, err = c.Conn.Exec(c, ` + INSERT INTO follower (user_id, following_project_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, c.CurrentUser.ID, projectID) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to follow project")) + } + } + + var res ResponseData + addCORSHeaders(c, &res) + res.WriteHeader(http.StatusNoContent) + return res +} diff --git a/src/website/projects.go b/src/website/projects.go index 845033e7..f7bd56c3 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -319,6 +319,20 @@ func jamLink(jamSlug string) string { } } +type ProjectHomepageData struct { + templates.BaseData + Project templates.Project + Owners []templates.User + Screenshots []string + ProjectLinks []templates.Link + Licenses []templates.Link + RecentActivity []templates.TimelineItem + SnippetEdit templates.SnippetEdit + + FollowUrl string + Following bool +} + func ProjectHomepage(c *RequestContext) ResponseData { maxRecentActivity := 15 @@ -389,6 +403,9 @@ func ProjectHomepage(c *RequestContext) ResponseData { NamedLinks, UnnamedLinks []templates.Link RecentActivity []templates.TimelineItem SnippetEdit templates.SnippetEdit + + FollowUrl string + Following bool } var templateData ProjectHomepageData @@ -500,6 +517,8 @@ func ProjectHomepage(c *RequestContext) ResponseData { }) c.Perf.EndBlock() + followUrl := "" + following := false if c.CurrentUser != nil { userProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{ OwnerIDs: []int{c.CurrentUser.ID}, @@ -521,7 +540,19 @@ func ProjectHomepage(c *RequestContext) ResponseData { SubmitUrl: hmnurl.BuildSnippetSubmit(), AssetMaxSize: AssetMaxSize(c.CurrentUser), } + + followUrl = hmnurl.BuildFollowProject() + following, err = db.QueryOneScalar[bool](c, c.Conn, ` + SELECT COUNT(*) > 0 + FROM follower + WHERE user_id = $1 AND following_project_id = $2 + `, c.CurrentUser.ID, c.CurrentProject.ID) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch following status")) + } } + templateData.FollowUrl = followUrl + templateData.Following = following var res ResponseData err = res.WriteTemplate("project_homepage.html", templateData, c.Perf) diff --git a/src/website/routes.go b/src/website/routes.go index 609088c7..57b6f470 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -165,6 +165,10 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler { hmnOnly.GET(hmnurl.RegexSnippet, Snippet) hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex) + hmnOnly.GET(hmnurl.RegexFollowingTest, needsAuth(FollowingTest)) + hmnOnly.POST(hmnurl.RegexFollowUser, needsAuth(csrfMiddleware(FollowUser))) + hmnOnly.POST(hmnurl.RegexFollowProject, needsAuth(csrfMiddleware(FollowProject))) + hmnOnly.GET(hmnurl.RegexProjectNew, needsAuth(ProjectNew)) hmnOnly.POST(hmnurl.RegexProjectNew, needsAuth(csrfMiddleware(ProjectNewSubmit))) diff --git a/src/website/user.go b/src/website/user.go index fc511b31..bb037396 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -36,6 +36,9 @@ type UserProfileTemplateData struct { CanAddProject bool NewProjectUrl string + FollowUrl string + Following bool + AdminSetOptionsUrl string AdminNukeUrl string @@ -113,12 +116,10 @@ func UserProfile(c *RequestContext) ResponseData { } c.Perf.EndBlock() - c.Perf.StartBlock("SQL", "Fetch posts") posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{ UserIDs: []int{profileUser.ID}, SortDescending: true, }) - c.Perf.EndBlock() snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{ OwnerIDs: []int{profileUser.ID}, @@ -172,6 +173,9 @@ func UserProfile(c *RequestContext) ResponseData { baseData := getBaseDataAutocrumb(c, templateUser.Name) + ownProfile := (c.CurrentUser != nil && c.CurrentUser.ID == profileUser.ID) + followUrl := "" + following := false snippetEdit := templates.SnippetEdit{} if c.CurrentUser != nil { snippetEdit = templates.SnippetEdit{ @@ -179,6 +183,19 @@ func UserProfile(c *RequestContext) ResponseData { SubmitUrl: hmnurl.BuildSnippetSubmit(), AssetMaxSize: AssetMaxSize(c.CurrentUser), } + + if !ownProfile { + followUrl = hmnurl.BuildFollowUser() + following, err = db.QueryOneScalar[bool](c, c.Conn, ` + SELECT COUNT(*) > 0 + FROM follower + WHERE user_id = $1 AND following_user_id = $2 + `, c.CurrentUser.ID, profileUser.ID) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch following status")) + } + } + } var res ResponseData @@ -188,11 +205,14 @@ func UserProfile(c *RequestContext) ResponseData { ProfileUserLinks: profileUserLinks, ProfileUserProjects: templateProjects, TimelineItems: timelineItems, - OwnProfile: (c.CurrentUser != nil && c.CurrentUser.ID == profileUser.ID), + OwnProfile: ownProfile, CanAddProject: numPersonalProjects < maxPersonalProjects, NewProjectUrl: hmnurl.BuildProjectNew(), + FollowUrl: followUrl, + Following: following, + AdminSetOptionsUrl: hmnurl.BuildAdminSetUserOptions(), AdminNukeUrl: hmnurl.BuildAdminNukeUser(),