Timeline/follow fetching done
This commit is contained in:
parent
6a28660407
commit
d9073db5d0
|
@ -207,7 +207,7 @@ func MonitorCalendars(ctx context.Context) jobs.Job {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error().Err(err).Msg("Panicked in MonitorCalendars")
|
logging.Error().Err(err).Msg("Panicked in MonitorCalendars")
|
||||||
}
|
}
|
||||||
monitorTimer.Reset(time.Minute)
|
monitorTimer.Reset(60 * time.Minute)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,11 +62,6 @@ func FetchSnippets(
|
||||||
return nil, oops.New(err, "failed to get snippet IDs for tag")
|
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
|
tagSnippetIDs = snippetIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,11 +82,6 @@ func FetchSnippets(
|
||||||
return nil, oops.New(err, "failed to get snippet IDs for tag")
|
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
|
projectSnippetIDs = snippetIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,17 +99,19 @@ func FetchSnippets(
|
||||||
TRUE
|
TRUE
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
if len(q.IDs) > 0 {
|
allIDs := make([]int, 0, len(q.IDs)+len(tagSnippetIDs)+len(projectSnippetIDs))
|
||||||
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
|
allIDs = append(allIDs, q.IDs...)
|
||||||
}
|
allIDs = append(allIDs, tagSnippetIDs...)
|
||||||
if len(tagSnippetIDs) > 0 {
|
allIDs = append(allIDs, projectSnippetIDs...)
|
||||||
qb.Add(`AND snippet.id = ANY ($?)`, tagSnippetIDs)
|
if len(allIDs) > 0 && len(q.OwnerIDs) > 0 {
|
||||||
}
|
qb.Add(`AND (snippet.id = ANY ($?) OR snippet.owner_id = ANY ($?))`, allIDs, q.OwnerIDs)
|
||||||
if len(projectSnippetIDs) > 0 {
|
} else {
|
||||||
qb.Add(`AND snippet.id = ANY ($?)`, projectSnippetIDs)
|
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.OwnerIDs) > 0 {
|
||||||
|
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(q.DiscordMessageIDs) > 0 {
|
if len(q.DiscordMessageIDs) > 0 {
|
||||||
qb.Add(`AND snippet.discord_message_id = ANY ($?)`, q.DiscordMessageIDs)
|
qb.Add(`AND snippet.discord_message_id = ANY ($?)`, q.DiscordMessageIDs)
|
||||||
|
|
|
@ -375,11 +375,15 @@ func FetchPosts(
|
||||||
models.VisibleProjectLifecycles,
|
models.VisibleProjectLifecycles,
|
||||||
models.HMNProjectID,
|
models.HMNProjectID,
|
||||||
)
|
)
|
||||||
if len(q.ProjectIDs) > 0 {
|
if len(q.ProjectIDs) > 0 && len(q.UserIDs) > 0 {
|
||||||
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
qb.Add(`AND (project.id = ANY($?) OR post.author_id = ANY($?))`, q.ProjectIDs, q.UserIDs)
|
||||||
}
|
} else {
|
||||||
if len(q.UserIDs) > 0 {
|
if len(q.ProjectIDs) > 0 {
|
||||||
qb.Add(`AND post.author_id = ANY ($?)`, q.UserIDs)
|
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 {
|
if len(q.ThreadIDs) > 0 {
|
||||||
qb.Add(`AND post.thread_id = ANY ($?)`, q.ThreadIDs)
|
qb.Add(`AND post.thread_id = ANY ($?)`, q.ThreadIDs)
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"git.handmade.network/hmn/hmn/src/perf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const InvalidUserTwitchID = "INVALID_USER"
|
const InvalidUserTwitchID = "INVALID_USER"
|
||||||
|
@ -21,27 +22,58 @@ type TwitchStreamer struct {
|
||||||
|
|
||||||
var twitchRegex = regexp.MustCompile(`twitch\.tv/(?P<login>[^/]+)$`)
|
var twitchRegex = regexp.MustCompile(`twitch\.tv/(?P<login>[^/]+)$`)
|
||||||
|
|
||||||
func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStreamer, error) {
|
type TwitchStreamersQuery struct {
|
||||||
dbStreamers, err := db.Query[models.Link](ctx, dbConn,
|
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}
|
SELECT $columns{link}
|
||||||
FROM
|
FROM
|
||||||
link
|
link
|
||||||
LEFT JOIN hmn_user AS link_owner ON link_owner.id = link.user_id
|
LEFT JOIN hmn_user AS link_owner ON link_owner.id = link.user_id
|
||||||
WHERE
|
WHERE
|
||||||
url ~* 'twitch\.tv/([^/]+)$' AND
|
TRUE
|
||||||
((link.user_id IS NOT NULL AND link_owner.status = $1) OR (link.project_id IS NOT NULL AND
|
`,
|
||||||
|
)
|
||||||
|
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(*)
|
(SELECT COUNT(*)
|
||||||
FROM
|
FROM
|
||||||
user_project AS hup
|
user_project AS hup
|
||||||
JOIN hmn_user AS project_owner ON project_owner.id = hup.user_id
|
JOIN hmn_user AS project_owner ON project_owner.id = hup.user_id
|
||||||
WHERE
|
WHERE
|
||||||
hup.project_id = link.project_id AND
|
hup.project_id = link.project_id AND
|
||||||
project_owner.status != $1
|
project_owner.status != $?
|
||||||
) = 0))
|
) = 0))
|
||||||
`,
|
`,
|
||||||
models.UserStatusApproved,
|
models.UserStatusApproved,
|
||||||
|
models.UserStatusApproved,
|
||||||
)
|
)
|
||||||
|
dbStreamers, err := db.Query[models.Link](ctx, dbConn, qb.String(), qb.Args()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "failed to fetch twitch links")
|
return nil, oops.New(err, "failed to fetch twitch links")
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,8 @@ func (m AddFollower) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
CREATE TABLE follower (
|
CREATE TABLE follower (
|
||||||
user_id int NOT NULL,
|
user_id int NOT NULL,
|
||||||
following_user_id int REFERENCES hmn_user (id) ON DELETE CASCADE,
|
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);
|
CREATE INDEX follower_user_id ON follower(user_id);
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Subforum struct {
|
type Subforum struct {
|
||||||
|
@ -43,7 +42,7 @@ func (node *SubforumTreeNode) GetLineage() []*Subforum {
|
||||||
return result
|
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,
|
subforums, err := db.Query[Subforum](ctx, conn,
|
||||||
`
|
`
|
||||||
SELECT $columns
|
SELECT $columns
|
||||||
|
|
|
@ -174,7 +174,8 @@ const PerfContextKey = "HMNPerf"
|
||||||
func ExtractPerf(ctx context.Context) *RequestPerf {
|
func ExtractPerf(ctx context.Context) *RequestPerf {
|
||||||
iperf := ctx.Value(PerfContextKey)
|
iperf := ctx.Value(PerfContextKey)
|
||||||
if iperf == nil {
|
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)
|
return iperf.(*RequestPerf)
|
||||||
}
|
}
|
||||||
|
|
|
@ -254,7 +254,7 @@ func syncWithTwitch(ctx context.Context, dbConn *pgxpool.Pool, updateAll bool, u
|
||||||
var stats twitchSyncStats
|
var stats twitchSyncStats
|
||||||
|
|
||||||
p.StartBlock("SQL", "Fetch list of streamers")
|
p.StartBlock("SQL", "Fetch list of streamers")
|
||||||
streamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn)
|
streamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn, hmndata.TwitchStreamersQuery{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Error while monitoring twitch")
|
log.Error().Err(err).Msg("Error while monitoring twitch")
|
||||||
return
|
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.
|
// NOTE(asaf): Verifying that the streamer we're processing hasn't been removed from our db in the meantime.
|
||||||
foundStreamer := false
|
foundStreamer := false
|
||||||
allStreamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn)
|
allStreamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn, hmndata.TwitchStreamersQuery{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("failed to fetch hmn streamers")
|
log.Error().Err(err).Msg("failed to fetch hmn streamers")
|
||||||
return
|
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.
|
// NOTE(asaf): Verifying that the streamer we're processing hasn't been removed from our db in the meantime.
|
||||||
foundStreamer := false
|
foundStreamer := false
|
||||||
allStreamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn)
|
allStreamers, err := hmndata.FetchTwitchStreamers(ctx, dbConn, hmndata.TwitchStreamersQuery{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("failed to fetch hmn streamers")
|
log.Error().Err(err).Msg("failed to fetch hmn streamers")
|
||||||
return
|
return
|
||||||
|
|
|
@ -2,163 +2,19 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"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/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
func FollowingTest(c *RequestContext) ResponseData {
|
func FollowingTest(c *RequestContext) ResponseData {
|
||||||
type Follower struct {
|
timelineItems, err := FetchFollowTimelineForUser(c, c.Conn, c.CurrentUser, c.Theme)
|
||||||
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 {
|
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 {
|
type FollowingTestData struct {
|
||||||
templates.BaseData
|
templates.BaseData
|
||||||
TimelineItems []templates.TimelineItem
|
TimelineItems []templates.TimelineItem
|
||||||
|
|
|
@ -382,19 +382,6 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
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 {
|
type ProjectHomepageData struct {
|
||||||
templates.BaseData
|
templates.BaseData
|
||||||
Project templates.Project
|
Project templates.Project
|
||||||
|
@ -480,42 +467,14 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, post := range posts {
|
templateData.RecentActivity, err = FetchTimeline(c, c.Conn, c.CurrentUser, c.Theme, TimelineQuery{
|
||||||
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{
|
|
||||||
ProjectIDs: []int{c.CurrentProject.ID},
|
ProjectIDs: []int{c.CurrentProject.ID},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Perf.StartBlock("PROFILE", "Sort timeline")
|
templateData.RecentActivity = templateData.RecentActivity[:utils.IntMin(len(templateData.RecentActivity)-1, maxRecentActivity)]
|
||||||
sort.Slice(templateData.RecentActivity, func(i, j int) bool {
|
|
||||||
return templateData.RecentActivity[j].Date.Before(templateData.RecentActivity[i].Date)
|
|
||||||
})
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
|
|
||||||
followUrl := ""
|
followUrl := ""
|
||||||
following := false
|
following := false
|
||||||
|
|
|
@ -1,20 +1,229 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/logging"
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"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"
|
"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 {
|
type TimelineTypeTitles struct {
|
||||||
TypeTitleFirst string
|
TypeTitleFirst string
|
||||||
TypeTitleNotFirst string
|
TypeTitleNotFirst string
|
||||||
|
@ -62,6 +271,42 @@ func PostToTimelineItem(
|
||||||
return item
|
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(
|
func SnippetToTimelineItem(
|
||||||
snippet *models.Snippet,
|
snippet *models.Snippet,
|
||||||
asset *models.Asset,
|
asset *models.Asset,
|
||||||
|
|
|
@ -93,7 +93,7 @@ func TwitchDebugPage(c *RequestContext) ResponseData {
|
||||||
Users []dataUser `json:"users"`
|
Users []dataUser `json:"users"`
|
||||||
Logs []dataLog `json:"logs"`
|
Logs []dataLog `json:"logs"`
|
||||||
}
|
}
|
||||||
streamers, err := hmndata.FetchTwitchStreamers(c, c.Conn)
|
streamers, err := hmndata.FetchTwitchStreamers(c, c.Conn, hmndata.TwitchStreamersQuery{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch twitch streamers"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch twitch streamers"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ package website
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
@ -116,59 +115,13 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
timelineItems, err := FetchTimeline(c, c.Conn, c.CurrentUser, c.Theme, TimelineQuery{
|
||||||
UserIDs: []int{profileUser.ID},
|
UserIDs: []int{profileUser.ID},
|
||||||
SortDescending: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
|
||||||
OwnerIDs: []int{profileUser.ID},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
templateUser := templates.UserToTemplate(profileUser, c.Theme)
|
||||||
|
|
||||||
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
baseData := getBaseDataAutocrumb(c, templateUser.Name)
|
||||||
|
|
Loading…
Reference in New Issue