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, lineageBuilder *models.SubforumLineageBuilder) ([]templates.TimelineItem, error) { perf := perf.ExtractPerf(ctx) perf.StartBlock("FOLLOW", "Assemble follow data") following, err := db.Query[models.Follow](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 := []templates.TimelineItem{} if len(userIDs)+len(projectIDs) > 0 { timelineItems, err = FetchTimeline(ctx, conn, user, lineageBuilder, hmndata.TimelineQuery{ OwnerIDs: userIDs, ProjectIDs: projectIDs, }) } perf.EndBlock() return timelineItems, err } func FetchTimeline(ctx context.Context, conn db.ConnOrTx, currentUser *models.User, lineageBuilder *models.SubforumLineageBuilder, q hmndata.TimelineQuery) ([]templates.TimelineItem, error) { results, err := hmndata.FetchTimeline(ctx, conn, currentUser, q) if err != nil { logging.Error().Err(err).Msg("Fail") } if err != nil { return nil, err } timelineItems := make([]templates.TimelineItem, 0, len(results)) for _, r := range results { timelineItems = append(timelineItems, TimelineItemToTemplate(r, lineageBuilder, false)) } return timelineItems, nil } func FetchFollows(ctx context.Context, conn db.ConnOrTx, currentUser *models.User, userID int) ([]templates.Follow, error) { perf := perf.ExtractPerf(ctx) perf.StartBlock("SQL", "Fetch follows") following, err := db.Query[models.Follow](ctx, conn, ` SELECT $columns FROM follower WHERE user_id = $1 `, userID) if err != nil { return nil, oops.New(err, "failed to fetch follows") } perf.EndBlock() var userIDs, projectIDs []int for _, follow := range following { if follow.FollowingUserID != nil { userIDs = append(userIDs, *follow.FollowingUserID) } if follow.FollowingProjectID != nil { projectIDs = append(projectIDs, *follow.FollowingProjectID) } } var users []*models.User var projectsAndStuff []hmndata.ProjectAndStuff if len(userIDs) > 0 { users, err = hmndata.FetchUsers(ctx, conn, currentUser, hmndata.UsersQuery{ UserIDs: userIDs, }) if err != nil { return nil, oops.New(err, "failed to fetch users for follows") } } if len(projectIDs) > 0 { projectsAndStuff, err = hmndata.FetchProjects(ctx, conn, currentUser, hmndata.ProjectsQuery{ ProjectIDs: projectIDs, }) if err != nil { return nil, oops.New(err, "failed to fetch projects for follows") } } var result []templates.Follow for _, follow := range following { if follow.FollowingUserID != nil { for _, user := range users { if user.ID == *follow.FollowingUserID { u := templates.UserToTemplate(user) result = append(result, templates.Follow{ User: &u, }) break } } } if follow.FollowingProjectID != nil { for _, p := range projectsAndStuff { if p.Project.ID == *follow.FollowingProjectID { proj := templates.ProjectAndStuffToTemplate(&p) result = append(result, templates.Follow{ Project: &proj, }) break } } } } return result, nil } type TimelineTypeTitles struct { TypeTitleFirst string TypeTitleNotFirst string FilterTitle string } var TimelineTypeTitleMap = map[models.ThreadType]TimelineTypeTitles{ models.ThreadTypeProjectBlogPost: {"New blog post", "Blog comment", "Blogs"}, models.ThreadTypeForumPost: {"New forum thread", "Forum reply", "Forums"}, } func PostToTimelineItem( urlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, post *models.Post, thread *models.Thread, owner *models.User, ) templates.TimelineItem { ownerTmpl := templates.UserToTemplate(owner) item := templates.TimelineItem{ Date: post.PostDate, Title: thread.Title, Breadcrumbs: GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread), Url: UrlForGenericPost(urlContext, thread, post, lineageBuilder), OwnerAvatarUrl: ownerTmpl.AvatarUrl, OwnerName: ownerTmpl.Name, OwnerUrl: ownerTmpl.ProfileUrl, ForumLayout: true, } if typeTitles, ok := TimelineTypeTitleMap[post.ThreadType]; ok { if thread.FirstID == post.ID { item.TypeTitle = typeTitles.TypeTitleFirst } else { item.TypeTitle = typeTitles.TypeTitleNotFirst } item.FilterTitle = typeTitles.FilterTitle } else { logging.Warn(). Int("postID", post.ID). Int("threadType", int(post.ThreadType)). Msg("unknown thread type for post") } 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, ForumLayout: true, } return item } func SnippetToTimelineItem( snippet *models.Snippet, asset *models.Asset, discordMessage *models.DiscordMessage, projects []*hmndata.ProjectAndStuff, owner *models.User, editable bool, ) templates.TimelineItem { item := templates.TimelineItem{ ID: strconv.Itoa(snippet.ID), Date: snippet.When, FilterTitle: "Snippets", Url: hmnurl.BuildSnippet(snippet.ID), OwnerAvatarUrl: templates.UserAvatarUrl(owner), OwnerName: owner.BestName(), OwnerUrl: hmnurl.BuildUserProfile(owner.Username), Description: template.HTML(snippet.DescriptionHtml), RawDescription: snippet.Description, CanShowcase: true, Editable: editable, } if asset != nil { if strings.HasPrefix(asset.MimeType, "image/") { item.Media = append(item.Media, imageMediaItem(asset)) } else if strings.HasPrefix(asset.MimeType, "video/") { item.Media = append(item.Media, videoMediaItem(asset)) } else if strings.HasPrefix(asset.MimeType, "audio/") { item.Media = append(item.Media, audioMediaItem(asset)) } else { item.Media = append(item.Media, unknownMediaItem(asset)) } } if snippet.Url != nil { url := *snippet.Url if videoId := getYoutubeVideoID(url); videoId != "" { item.Media = append(item.Media, youtubeMediaItem(videoId)) item.CanShowcase = false } } if len(item.Media) == 0 || (len(item.Media) > 0 && (item.Media[0].Width == 0 || item.Media[0].Height == 0)) { item.CanShowcase = false } if discordMessage != nil { item.DiscordMessageUrl = discordMessage.Url } sort.Slice(projects, func(i, j int) bool { return projects[i].Project.Name < projects[j].Project.Name }) for _, proj := range projects { item.Projects = append(item.Projects, templates.ProjectAndStuffToTemplate(proj)) } return item } var youtubeRegexes = [...]*regexp.Regexp{ regexp.MustCompile(`(?i)youtube\.com/watch\?.*v=(?P[^/&]+)`), regexp.MustCompile(`(?i)youtu\.be/(?P[^/]+)`), } func getYoutubeVideoID(url string) string { for _, regex := range youtubeRegexes { match := regex.FindStringSubmatch(url) if match != nil { return match[regex.SubexpIndex("videoid")] } } return "" } func imageMediaItem(asset *models.Asset) templates.TimelineItemMedia { assetUrl := hmnurl.BuildS3Asset(asset.S3Key) return templates.TimelineItemMedia{ Type: templates.TimelineItemMediaTypeImage, AssetUrl: assetUrl, ThumbnailUrl: assetUrl, // TODO: Use smaller thumbnails? MimeType: asset.MimeType, Width: asset.Width, Height: asset.Height, } } func videoMediaItem(asset *models.Asset) templates.TimelineItemMedia { assetUrl := hmnurl.BuildS3Asset(asset.S3Key) var thumbnailUrl string if asset.ThumbnailS3Key != "" { thumbnailUrl = hmnurl.BuildS3Asset(asset.ThumbnailS3Key) } return templates.TimelineItemMedia{ Type: templates.TimelineItemMediaTypeVideo, AssetUrl: assetUrl, ThumbnailUrl: thumbnailUrl, MimeType: asset.MimeType, Width: asset.Width, Height: asset.Height, } } func audioMediaItem(asset *models.Asset) templates.TimelineItemMedia { assetUrl := hmnurl.BuildS3Asset(asset.S3Key) return templates.TimelineItemMedia{ Type: templates.TimelineItemMediaTypeAudio, AssetUrl: assetUrl, MimeType: asset.MimeType, Width: asset.Width, Height: asset.Height, } } func youtubeMediaItem(videoId string) templates.TimelineItemMedia { return templates.TimelineItemMedia{ Type: templates.TimelineItemMediaTypeEmbed, EmbedHTML: template.HTML(fmt.Sprintf( ``, template.HTMLEscapeString(videoId), )), ExtraOpenGraphItems: []templates.OpenGraphItem{ {Property: "og:video", Value: fmt.Sprintf("https://youtube.com/watch?v=%s", videoId)}, {Name: "twitter:card", Value: "player"}, }, } } func unknownMediaItem(asset *models.Asset) templates.TimelineItemMedia { assetUrl := hmnurl.BuildS3Asset(asset.S3Key) return templates.TimelineItemMedia{ Type: templates.TimelineItemMediaTypeUnknown, AssetUrl: assetUrl, MimeType: asset.MimeType, Filename: asset.Filename, FileSize: asset.Size, } } func TimelineItemToTemplate(item *hmndata.TimelineItemAndStuff, lineageBuilder *models.SubforumLineageBuilder, editable bool) templates.TimelineItem { filterTitle := "" typeTitle := "" url := "" var breadcrumbs []templates.Breadcrumb switch item.Item.Type { case models.TimelineItemTypeSnippet: filterTitle = "Snippets" typeTitle = "Snippet" url = hmnurl.BuildSnippet(item.Item.ID) case models.TimelineItemTypePost: urlContext := hmndata.UrlContextForProject(&item.Projects[0].Project) if item.Item.ThreadType == models.ThreadTypeProjectBlogPost { filterTitle = "Blogs" if item.Item.FirstPost { typeTitle = "New blog post" } else { typeTitle = "Blog comment" } url = urlContext.BuildBlogThreadWithPostHash(item.Item.ThreadID, item.Item.Title, item.Item.ID) breadcrumbs = []templates.Breadcrumb{ { Name: urlContext.ProjectName, Url: urlContext.BuildHomepage(), }, { Name: "Blog", Url: urlContext.BuildBlog(1), }, } } else if item.Item.ThreadType == models.ThreadTypeForumPost { filterTitle = "Forums" if item.Item.FirstPost { typeTitle = "New forum thread" } else { typeTitle = "Forum reply" } url = urlContext.BuildForumPost(lineageBuilder.GetSubforumLineageSlugs(item.Item.SubforumID), item.Item.ThreadID, item.Item.ID) breadcrumbs = SubforumBreadcrumbs(urlContext, lineageBuilder, item.Item.SubforumID) } } ownerTmpl := templates.UserToTemplate(item.Owner) ti := templates.TimelineItem{ ID: strconv.Itoa(item.Item.ID), Date: item.Item.Date, Title: item.Item.Title, TypeTitle: typeTitle, FilterTitle: filterTitle, Breadcrumbs: breadcrumbs, Url: url, DiscordMessageUrl: "", OwnerAvatarUrl: ownerTmpl.AvatarUrl, OwnerName: ownerTmpl.Name, OwnerUrl: ownerTmpl.ProfileUrl, Projects: nil, Description: template.HTML(item.Item.ParsedDescription), RawDescription: item.Item.RawDescription, Media: nil, ForumLayout: item.Item.Type == models.TimelineItemTypePost, AllowTitleWrap: false, TruncateDescription: false, CanShowcase: item.Item.Type == models.TimelineItemTypeSnippet, Editable: item.Item.Type == models.TimelineItemTypeSnippet && editable, } if item.Asset != nil { if strings.HasPrefix(item.Asset.MimeType, "image/") { ti.Media = append(ti.Media, imageMediaItem(item.Asset)) } else if strings.HasPrefix(item.Asset.MimeType, "video/") { ti.Media = append(ti.Media, videoMediaItem(item.Asset)) } else if strings.HasPrefix(item.Asset.MimeType, "audio/") { ti.Media = append(ti.Media, audioMediaItem(item.Asset)) } else { ti.Media = append(ti.Media, unknownMediaItem(item.Asset)) } } if item.Item.ExternalUrl != nil { if videoId := getYoutubeVideoID(*item.Item.ExternalUrl); videoId != "" { ti.Media = append(ti.Media, youtubeMediaItem(videoId)) ti.CanShowcase = false } } if len(ti.Media) == 0 || (len(ti.Media) > 0 && (ti.Media[0].Width == 0 || ti.Media[0].Height == 0)) { ti.CanShowcase = false } if item.DiscordMessage != nil { ti.DiscordMessageUrl = item.DiscordMessage.Url } sort.Slice(item.Projects, func(i, j int) bool { return item.Projects[i].Project.Name < item.Projects[j].Project.Name }) for _, proj := range item.Projects { ti.Projects = append(ti.Projects, templates.ProjectAndStuffToTemplate(proj)) } return ti }