Follow infrastructure

This commit is contained in:
Asaf Gartner 2024-05-16 23:19:13 +03:00 committed by Ben Visness
parent 3ff6ba6563
commit 6a28660407
10 changed files with 459 additions and 7 deletions

View File

@ -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)

View File

@ -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
*/

View File

@ -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
}

View File

@ -0,0 +1,9 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="flex flex-column">
{{ range .TimelineItems }}
{{ template "timeline_item.html" . }}
{{ end }}
</div>
{{ end }}

View File

@ -43,6 +43,34 @@
</a>
</div>
{{ end }}
{{ if .FollowUrl }}
<a id="follow_link" class="db" href="javascript:;">{{ if .Following }}Unfollow{{ else }}Follow{{ end }}</a>
<script>
const followLink = document.getElementById("follow_link");
let following = {{ .Following }};
followLink.addEventListener("click", async function() {
followLink.disabled = true;
let formData = new FormData();
formData.set("csrf_token", "{{ .Session.CSRFToken }}");
formData.set("project_id", "{{ .Project.ID }}");
if (following) {
formData.set("unfollow", "true");
}
let result = await fetch("{{ .FollowUrl }}", {
method: "POST",
body: formData,
redirect: "error",
});
if (result.ok) {
following = !following;
followLink.textContent = (following ? "Unfollow" : "Follow");
}
followLink.disabled = false;
});
</script>
{{ end }}
{{ if or .Header.Project.CanEdit (gt (len .RecentActivity) 0) }}
<hr class="w-100 mv4">
<div class="w-100 flex g3">

View File

@ -43,6 +43,34 @@
{{ with or .ProfileUser.Bio .ProfileUser.Blurb }}
<div class="mb3">{{ . }}</div>
{{ end }}
{{ if .FollowUrl }}
<a id="follow_link" class="db" href="javascript:;">{{ if .Following }}Unfollow{{ else }}Follow{{ end }}</a>
<script>
const followLink = document.getElementById("follow_link");
let following = {{ .Following }};
followLink.addEventListener("click", async function() {
followLink.disabled = true;
let formData = new FormData();
formData.set("csrf_token", "{{ .Session.CSRFToken }}");
formData.set("user_id", "{{ .ProfileUser.ID }}");
if (following) {
formData.set("unfollow", "true");
}
let result = await fetch("{{ .FollowUrl }}", {
method: "POST",
body: formData,
redirect: "error",
});
if (result.ok) {
following = !following;
followLink.textContent = (following ? "Unfollow" : "Follow");
}
followLink.disabled = false;
});
</script>
{{ end }}
<div class="w-100 w-auto-ns w-100-l">
{{ if .ProfileUser.Email }}
<div class="pair flex">

254
src/website/following.go Normal file
View File

@ -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
}

View File

@ -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)

View File

@ -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)))

View File

@ -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(),