diff --git a/src/calendar/calendar.go b/src/calendar/calendar.go index 2ae232a3..bf355458 100644 --- a/src/calendar/calendar.go +++ b/src/calendar/calendar.go @@ -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 } diff --git a/src/hmndata/snippet_helper.go b/src/hmndata/snippet_helper.go index e942bec1..61b09c87 100644 --- a/src/hmndata/snippet_helper.go +++ b/src/hmndata/snippet_helper.go @@ -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) diff --git a/src/hmndata/threads_and_posts_helper.go b/src/hmndata/threads_and_posts_helper.go index 806f0571..58d91c8e 100644 --- a/src/hmndata/threads_and_posts_helper.go +++ b/src/hmndata/threads_and_posts_helper.go @@ -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) diff --git a/src/hmndata/twitch.go b/src/hmndata/twitch.go index d8223728..aa6f56b8 100644 --- a/src/hmndata/twitch.go +++ b/src/hmndata/twitch.go @@ -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[^/]+)$`) -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") } diff --git a/src/migration/migrations/2024-05-16T194134Z_AddFollower.go b/src/migration/migrations/2024-05-16T194134Z_AddFollower.go index a4569795..3c226494 100644 --- a/src/migration/migrations/2024-05-16T194134Z_AddFollower.go +++ b/src/migration/migrations/2024-05-16T194134Z_AddFollower.go @@ -32,7 +32,8 @@ func (m AddFollower) Up(ctx context.Context, tx pgx.Tx) error { 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 + 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); diff --git a/src/models/subforum.go b/src/models/subforum.go index d4d29fc2..b9d04a75 100644 --- a/src/models/subforum.go +++ b/src/models/subforum.go @@ -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 diff --git a/src/perf/perf.go b/src/perf/perf.go index c4c5b000..3097096d 100644 --- a/src/perf/perf.go +++ b/src/perf/perf.go @@ -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) } diff --git a/src/twitch/twitch.go b/src/twitch/twitch.go index 938ada15..d296567e 100644 --- a/src/twitch/twitch.go +++ b/src/twitch/twitch.go @@ -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 diff --git a/src/website/following.go b/src/website/following.go index 4283030d..7f7d5ca5 100644 --- a/src/website/following.go +++ b/src/website/following.go @@ -2,163 +2,19 @@ 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) - + timelineItems, err := FetchFollowTimelineForUser(c, c.Conn, c.CurrentUser, c.Theme) if err != nil { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch follow data")) + return c.ErrorResponse(http.StatusInternalServerError, err) } - 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 diff --git a/src/website/projects.go b/src/website/projects.go index f7bd56c3..05669d58 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -382,19 +382,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() - type ProjectHomepageData struct { templates.BaseData Project templates.Project @@ -480,42 +467,14 @@ func ProjectHomepage(c *RequestContext) ResponseData { } } - 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 diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go index aa0b7d5e..687b6274 100644 --- a/src/website/timeline_helper.go +++ b/src/website/timeline_helper.go @@ -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

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, diff --git a/src/website/twitch.go b/src/website/twitch.go index 0be75551..b762ffc7 100644 --- a/src/website/twitch.go +++ b/src/website/twitch.go @@ -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")) } diff --git a/src/website/user.go b/src/website/user.go index bb037396..c23ac708 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -3,7 +3,6 @@ package website import ( "errors" "net/http" - "sort" "strconv" "strings" @@ -116,59 +115,13 @@ func UserProfile(c *RequestContext) ResponseData { } c.Perf.EndBlock() - posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{ - UserIDs: []int{profileUser.ID}, - SortDescending: true, - }) - - 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)