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 }}
{{ . }}
{{ 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(),