Compare commits

...
This repository has been archived on 2024-07-08. You can view files and clone it, but cannot push or open issues or pull requests.

2 Commits

Author SHA1 Message Date
Asaf Gartner 4225d0a3a4 Timeline/follow fetching done 2024-05-22 19:07:41 +03:00
Asaf Gartner e51dd9c9ec Follow infrastructure 2024-05-16 23:19:13 +03:00
19 changed files with 620 additions and 140 deletions

View File

@ -207,7 +207,7 @@ func MonitorCalendars(ctx context.Context) jobs.Job {
if err != nil {
logging.Error().Err(err).Msg("Panicked in MonitorCalendars")
}
monitorTimer.Reset(time.Minute)
monitorTimer.Reset(60 * time.Minute)
case <-ctx.Done():
return
}

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

@ -62,11 +62,6 @@ func FetchSnippets(
return nil, oops.New(err, "failed to get snippet IDs for tag")
}
// special early-out: no snippets found for these tags at all
if len(snippetIDs) == 0 {
return nil, nil
}
tagSnippetIDs = snippetIDs
}
@ -87,11 +82,6 @@ func FetchSnippets(
return nil, oops.New(err, "failed to get snippet IDs for tag")
}
// special early-out: no snippets found for these projects at all
if len(snippetIDs) == 0 {
return nil, nil
}
projectSnippetIDs = snippetIDs
}
@ -109,17 +99,19 @@ func FetchSnippets(
TRUE
`,
)
if len(q.IDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
}
if len(tagSnippetIDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, tagSnippetIDs)
}
if len(projectSnippetIDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, projectSnippetIDs)
}
if len(q.OwnerIDs) > 0 {
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
allIDs := make([]int, 0, len(q.IDs)+len(tagSnippetIDs)+len(projectSnippetIDs))
allIDs = append(allIDs, q.IDs...)
allIDs = append(allIDs, tagSnippetIDs...)
allIDs = append(allIDs, projectSnippetIDs...)
if len(allIDs) > 0 && len(q.OwnerIDs) > 0 {
qb.Add(`AND (snippet.id = ANY ($?) OR snippet.owner_id = ANY ($?))`, allIDs, q.OwnerIDs)
} else {
if len(allIDs) > 0 {
qb.Add(`AND snippet.id = ANY ($?)`, allIDs)
}
if len(q.OwnerIDs) > 0 {
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
}
}
if len(q.DiscordMessageIDs) > 0 {
qb.Add(`AND snippet.discord_message_id = ANY ($?)`, q.DiscordMessageIDs)

View File

@ -375,11 +375,15 @@ func FetchPosts(
models.VisibleProjectLifecycles,
models.HMNProjectID,
)
if len(q.ProjectIDs) > 0 {
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
}
if len(q.UserIDs) > 0 {
qb.Add(`AND post.author_id = ANY ($?)`, q.UserIDs)
if len(q.ProjectIDs) > 0 && len(q.UserIDs) > 0 {
qb.Add(`AND (project.id = ANY($?) OR post.author_id = ANY($?))`, q.ProjectIDs, q.UserIDs)
} else {
if len(q.ProjectIDs) > 0 {
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
}
if len(q.UserIDs) > 0 {
qb.Add(`AND post.author_id = ANY ($?)`, q.UserIDs)
}
}
if len(q.ThreadIDs) > 0 {
qb.Add(`AND post.thread_id = ANY ($?)`, q.ThreadIDs)

View File

@ -8,6 +8,7 @@ import (
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf"
)
const InvalidUserTwitchID = "INVALID_USER"
@ -21,27 +22,58 @@ type TwitchStreamer struct {
var twitchRegex = regexp.MustCompile(`twitch\.tv/(?P<login>[^/]+)$`)
func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStreamer, error) {
dbStreamers, err := db.Query[models.Link](ctx, dbConn,
type TwitchStreamersQuery struct {
UserIDs []int
ProjectIDs []int
}
func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx, q TwitchStreamersQuery) ([]TwitchStreamer, error) {
perf := perf.ExtractPerf(ctx)
perf.StartBlock("SQL", "Fetch twitch streamers")
defer perf.EndBlock()
var qb db.QueryBuilder
qb.Add(
`
SELECT $columns{link}
FROM
link
LEFT JOIN hmn_user AS link_owner ON link_owner.id = link.user_id
WHERE
url ~* 'twitch\.tv/([^/]+)$' AND
((link.user_id IS NOT NULL AND link_owner.status = $1) OR (link.project_id IS NOT NULL AND
TRUE
`,
)
if len(q.UserIDs) > 0 && len(q.ProjectIDs) > 0 {
qb.Add(
`AND (link.user_id = ANY ($?) OR link.project_id = ANY ($?))`,
q.UserIDs,
q.ProjectIDs,
)
} else {
if len(q.UserIDs) > 0 {
qb.Add(`AND link.user_id = ANY ($?)`, q.UserIDs)
}
if len(q.ProjectIDs) > 0 {
qb.Add(`AND link.project_id = ANY ($?)`, q.ProjectIDs)
}
}
qb.Add(
`
AND url ~* 'twitch\.tv/([^/]+)$'
AND ((link.user_id IS NOT NULL AND link_owner.status = $?) OR (link.project_id IS NOT NULL AND
(SELECT COUNT(*)
FROM
user_project AS hup
JOIN hmn_user AS project_owner ON project_owner.id = hup.user_id
WHERE
hup.project_id = link.project_id AND
project_owner.status != $1
project_owner.status != $?
) = 0))
`,
models.UserStatusApproved,
models.UserStatusApproved,
)
dbStreamers, err := db.Query[models.Link](ctx, dbConn, qb.String(), qb.Args()...)
if err != nil {
return nil, oops.New(err, "failed to fetch twitch links")
}

View File

@ -963,6 +963,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,57 @@
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,
CONSTRAINT user_id_or_project_id CHECK ((following_user_id IS NOT NULL AND following_project_id IS NULL) OR (following_user_id IS NULL AND following_project_id IS NOT NULL))
);
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

@ -5,7 +5,6 @@ import (
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v5/pgxpool"
)
type Subforum struct {
@ -43,7 +42,7 @@ func (node *SubforumTreeNode) GetLineage() []*Subforum {
return result
}
func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
func GetFullSubforumTree(ctx context.Context, conn db.ConnOrTx) SubforumTree {
subforums, err := db.Query[Subforum](ctx, conn,
`
SELECT $columns

View File

@ -174,7 +174,8 @@ const PerfContextKey = "HMNPerf"
func ExtractPerf(ctx context.Context) *RequestPerf {
iperf := ctx.Value(PerfContextKey)
if iperf == nil {
return nil
// NOTE(asaf): Returning a dummy perf so we don't crash if it's missing
return MakeNewRequestPerf("PERF MISSING", "PERF MISSING", "PERF MISSING")
}
return iperf.(*RequestPerf)
}

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

@ -24,6 +24,34 @@
{{ end }}
</div>
<div class="mt3 mt0-ns mt3-l ml3-ns ml0-l overflow-hidden">
{{ 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 }}
<div class="mb3">
{{ range $i, $owner := .Owners }}
<div class="flex mb3 items-center">

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">

View File

@ -254,7 +254,7 @@ func syncWithTwitch(ctx context.Context, dbConn *pgxpool.Pool, updateAll bool, u
var stats twitchSyncStats
p.StartBlock("SQL", "Fetch list of streamers")
streamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn)
streamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn, hmndata.TwitchStreamersQuery{})
if err != nil {
log.Error().Err(err).Msg("Error while monitoring twitch")
return
@ -546,7 +546,7 @@ func updateStreamStatus(ctx context.Context, dbConn db.ConnOrTx, twitchID string
// NOTE(asaf): Verifying that the streamer we're processing hasn't been removed from our db in the meantime.
foundStreamer := false
allStreamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn)
allStreamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn, hmndata.TwitchStreamersQuery{})
if err != nil {
log.Error().Err(err).Msg("failed to fetch hmn streamers")
return
@ -599,7 +599,7 @@ func processEventSubNotification(ctx context.Context, dbConn db.ConnOrTx, notifi
// NOTE(asaf): Verifying that the streamer we're processing hasn't been removed from our db in the meantime.
foundStreamer := false
allStreamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn)
allStreamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn, hmndata.TwitchStreamersQuery{})
if err != nil {
log.Error().Err(err).Msg("failed to fetch hmn streamers")
return

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

@ -0,0 +1,110 @@
package website
import (
"net/http"
"strconv"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
)
func FollowingTest(c *RequestContext) ResponseData {
timelineItems, err := FetchFollowTimelineForUser(c, c.Conn, c.CurrentUser, c.Theme)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
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

@ -342,6 +342,9 @@ type ProjectHomepageData struct {
Licenses []templates.Link
RecentActivity []templates.TimelineItem
SnippetEdit templates.SnippetEdit
FollowUrl string
Following bool
}
func ProjectHomepage(c *RequestContext) ResponseData {
@ -393,19 +396,6 @@ func ProjectHomepage(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c, c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetching project timeline")
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
ProjectIDs: []int{c.CurrentProject.ID},
Limit: maxRecentActivity,
SortDescending: true,
})
c.Perf.EndBlock()
var templateData ProjectHomepageData
templateData.BaseData = getBaseData(c, c.CurrentProject.Name, nil)
@ -474,43 +464,17 @@ func ProjectHomepage(c *RequestContext) ResponseData {
templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(link))
}
for _, post := range posts {
templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem(
c.UrlContext,
lineageBuilder,
&post.Post,
&post.Thread,
post.Author,
c.Theme,
))
}
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
templateData.RecentActivity, err = FetchTimeline(c, c.Conn, c.CurrentUser, c.Theme, TimelineQuery{
ProjectIDs: []int{c.CurrentProject.ID},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets"))
}
for _, s := range snippets {
item := SnippetToTimelineItem(
&s.Snippet,
s.Asset,
s.DiscordMessage,
s.Projects,
s.Owner,
c.Theme,
(c.CurrentUser != nil && (s.Owner.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
)
item.SmallInfo = true
templateData.RecentActivity = append(templateData.RecentActivity, item)
return c.ErrorResponse(http.StatusInternalServerError, err)
}
c.Perf.StartBlock("PROFILE", "Sort timeline")
sort.Slice(templateData.RecentActivity, func(i, j int) bool {
return templateData.RecentActivity[j].Date.Before(templateData.RecentActivity[i].Date)
})
c.Perf.EndBlock()
templateData.RecentActivity = templateData.RecentActivity[:utils.IntMin(len(templateData.RecentActivity)-1, maxRecentActivity)]
followUrl := ""
following := false
if c.CurrentUser != nil {
userProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: []int{c.CurrentUser.ID},
@ -528,7 +492,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

@ -118,6 +118,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

@ -1,20 +1,229 @@
package website
import (
"context"
"fmt"
"html/template"
"regexp"
"sort"
"strconv"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl"
"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/perf"
"git.handmade.network/hmn/hmn/src/templates"
)
func FetchFollowTimelineForUser(ctx context.Context, conn db.ConnOrTx, user *models.User, theme string) ([]templates.TimelineItem, error) {
perf := perf.ExtractPerf(ctx)
type Follower struct {
UserID int `db:"user_id"`
FollowingUserID *int `db:"following_user_id"`
FollowingProjectID *int `db:"following_project_id"`
}
perf.StartBlock("FOLLOW", "Assemble follow data")
following, err := db.Query[Follower](ctx, conn, `
SELECT $columns
FROM follower
WHERE user_id = $1
`, user.ID)
if err != nil {
return nil, 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)
}
}
timelineItems, err := FetchTimeline(ctx, conn, user, theme, TimelineQuery{
UserIDs: userIDs,
ProjectIDs: projectIDs,
})
perf.EndBlock()
return timelineItems, err
}
type TimelineQuery struct {
UserIDs []int
ProjectIDs []int
}
func FetchTimeline(ctx context.Context, conn db.ConnOrTx, currentUser *models.User, theme string, q TimelineQuery) ([]templates.TimelineItem, error) {
perf := perf.ExtractPerf(ctx)
var users []*models.User
var projects []hmndata.ProjectAndStuff
var snippets []hmndata.SnippetAndStuff
var posts []hmndata.PostAndStuff
var streamers []hmndata.TwitchStreamer
var streams []*models.TwitchStreamHistory
perf.StartBlock("TIMELINE", "Fetch timeline data")
if len(q.UserIDs) > 0 || len(q.ProjectIDs) > 0 {
users, err := hmndata.FetchUsers(ctx, conn, currentUser, hmndata.UsersQuery{
UserIDs: q.UserIDs,
})
if err != nil {
return nil, oops.New(err, "failed to fetch users")
}
// NOTE(asaf): Clear out invalid users in case we banned someone after they got followed
q.UserIDs = q.UserIDs[0:0]
for _, u := range users {
q.UserIDs = append(q.UserIDs, u.ID)
}
projects, err = hmndata.FetchProjects(ctx, conn, currentUser, hmndata.ProjectsQuery{
ProjectIDs: q.ProjectIDs,
})
if err != nil {
return nil, 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.
q.ProjectIDs = q.ProjectIDs[0:0]
for _, p := range projects {
q.ProjectIDs = append(q.ProjectIDs, p.Project.ID)
}
snippets, err = hmndata.FetchSnippets(ctx, conn, currentUser, hmndata.SnippetQuery{
OwnerIDs: q.UserIDs,
ProjectIDs: q.ProjectIDs,
})
if err != nil {
return nil, oops.New(err, "failed to fetch user snippets")
}
posts, err = hmndata.FetchPosts(ctx, conn, currentUser, hmndata.PostsQuery{
UserIDs: q.UserIDs,
ProjectIDs: q.ProjectIDs,
SortDescending: true,
})
if err != nil {
return nil, oops.New(err, "failed to fetch user posts")
}
streamers, err = hmndata.FetchTwitchStreamers(ctx, conn, hmndata.TwitchStreamersQuery{
UserIDs: q.UserIDs,
ProjectIDs: q.ProjectIDs,
})
if err != nil {
return nil, oops.New(err, "failed to fetch streamers")
}
twitchLogins := make([]string, 0, len(streamers))
for _, s := range streamers {
twitchLogins = append(twitchLogins, s.TwitchLogin)
}
streams, err = db.Query[models.TwitchStreamHistory](ctx, conn,
`
SELECT $columns FROM twitch_stream_history WHERE twitch_login = ANY ($1)
`,
twitchLogins,
)
if err != nil {
return nil, oops.New(err, "failed to fetch stream histories")
}
}
perf.EndBlock()
perf.StartBlock("TIMELINE", "Construct timeline items")
timelineItems := make([]templates.TimelineItem, 0, len(snippets)+len(posts))
if len(posts) > 0 {
perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(ctx, conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
perf.EndBlock()
for _, post := range posts {
timelineItems = append(timelineItems, PostToTimelineItem(
hmndata.UrlContextForProject(&post.Project),
lineageBuilder,
&post.Post,
&post.Thread,
post.Author,
theme,
))
}
}
for _, s := range snippets {
item := SnippetToTimelineItem(
&s.Snippet,
s.Asset,
s.DiscordMessage,
s.Projects,
s.Owner,
theme,
false,
)
item.SmallInfo = true
timelineItems = append(timelineItems, item)
}
for _, s := range streams {
ownerAvatarUrl := ""
ownerName := ""
ownerUrl := ""
for _, streamer := range streamers {
if streamer.TwitchLogin == s.TwitchLogin {
if streamer.UserID != nil {
for _, u := range users {
if u.ID == *streamer.UserID {
ownerAvatarUrl = templates.UserAvatarUrl(u, theme)
ownerName = u.BestName()
ownerUrl = hmnurl.BuildUserProfile(u.Username)
break
}
}
} else if streamer.ProjectID != nil {
for _, p := range projects {
if p.Project.ID == *streamer.ProjectID {
ownerAvatarUrl = templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme)
ownerName = p.Project.Name
ownerUrl = hmndata.UrlContextForProject(&p.Project).BuildHomepage()
}
break
}
}
break
}
}
if ownerAvatarUrl == "" {
ownerAvatarUrl = templates.UserAvatarDefaultUrl(theme)
}
item := TwitchStreamToTimelineItem(s, ownerAvatarUrl, ownerName, ownerUrl)
timelineItems = append(timelineItems, item)
}
perf.StartBlock("TIMELINE", "Sort timeline")
sort.Slice(timelineItems, func(i, j int) bool {
return timelineItems[j].Date.Before(timelineItems[i].Date)
})
perf.EndBlock()
perf.EndBlock()
return timelineItems, nil
}
type TimelineTypeTitles struct {
TypeTitleFirst string
TypeTitleNotFirst string
@ -62,6 +271,42 @@ func PostToTimelineItem(
return item
}
func TwitchStreamToTimelineItem(
streamHistory *models.TwitchStreamHistory,
ownerAvatarUrl string,
ownerName string,
ownerUrl string,
) templates.TimelineItem {
url := fmt.Sprintf("https://twitch.tv/%s", streamHistory.TwitchLogin)
title := fmt.Sprintf("%s is live on Twitch: %s", streamHistory.TwitchLogin, streamHistory.Title)
desc := ""
if streamHistory.StreamEnded {
if streamHistory.VODUrl != "" {
url = streamHistory.VODUrl
}
title = fmt.Sprintf("%s was live on Twitch", streamHistory.TwitchLogin)
streamDuration := streamHistory.EndedAt.Sub(streamHistory.StartedAt).Truncate(time.Second).String()
desc = fmt.Sprintf("%s<br/><br/>Streamed for %s", streamHistory.Title, streamDuration)
}
item := templates.TimelineItem{
ID: streamHistory.StreamID,
Date: streamHistory.StartedAt,
FilterTitle: "Live streams",
Url: url,
Title: title,
Description: template.HTML(desc),
OwnerAvatarUrl: ownerAvatarUrl,
OwnerName: ownerName,
OwnerUrl: ownerUrl,
SmallInfo: true,
}
return item
}
func SnippetToTimelineItem(
snippet *models.Snippet,
asset *models.Asset,

View File

@ -93,7 +93,7 @@ func TwitchDebugPage(c *RequestContext) ResponseData {
Users []dataUser `json:"users"`
Logs []dataLog `json:"logs"`
}
streamers, err := hmndata.FetchTwitchStreamers(c, c.Conn)
streamers, err := hmndata.FetchTwitchStreamers(c, c.Conn, hmndata.TwitchStreamersQuery{})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch twitch streamers"))
}

View File

@ -3,7 +3,6 @@ package website
import (
"errors"
"net/http"
"sort"
"strconv"
"strings"
@ -36,6 +35,9 @@ type UserProfileTemplateData struct {
CanAddProject bool
NewProjectUrl string
FollowUrl string
Following bool
AdminSetOptionsUrl string
AdminNukeUrl string
@ -113,65 +115,20 @@ 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},
timelineItems, err := FetchTimeline(c, c.Conn, c.CurrentUser, c.Theme, TimelineQuery{
UserIDs: []int{profileUser.ID},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
return c.ErrorResponse(http.StatusInternalServerError, err)
}
c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c, c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
c.Perf.StartBlock("PROFILE", "Construct timeline items")
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippets))
for _, post := range posts {
timelineItems = append(timelineItems, PostToTimelineItem(
hmndata.UrlContextForProject(&post.Project),
lineageBuilder,
&post.Post,
&post.Thread,
profileUser,
c.Theme,
))
}
for _, s := range snippets {
item := SnippetToTimelineItem(
&s.Snippet,
s.Asset,
s.DiscordMessage,
s.Projects,
profileUser,
c.Theme,
(c.CurrentUser != nil && (profileUser.ID == c.CurrentUser.ID || c.CurrentUser.IsStaff)),
)
item.SmallInfo = true
timelineItems = append(timelineItems, item)
}
c.Perf.StartBlock("PROFILE", "Sort timeline")
sort.Slice(timelineItems, func(i, j int) bool {
return timelineItems[j].Date.Before(timelineItems[i].Date)
})
c.Perf.EndBlock()
c.Perf.EndBlock()
templateUser := templates.UserToTemplate(profileUser, c.Theme)
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 +136,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,12 +158,15 @@ func UserProfile(c *RequestContext) ResponseData {
ProfileUserLinks: profileUserLinks,
ProfileUserProjects: templateProjects,
TimelineItems: timelineItems,
OwnProfile: (c.CurrentUser != nil && c.CurrentUser.ID == profileUser.ID),
OwnProfile: ownProfile,
ShowcaseUrl: hmnurl.BuildShowcase(),
CanAddProject: numPersonalProjects < maxPersonalProjects,
NewProjectUrl: hmnurl.BuildProjectNew(),
FollowUrl: followUrl,
Following: following,
AdminSetOptionsUrl: hmnurl.BuildAdminSetUserOptions(),
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),