diff --git a/src/hmndata/user_helper.go b/src/hmndata/user_helper.go index 4e8d6cb..ccf098d 100644 --- a/src/hmndata/user_helper.go +++ b/src/hmndata/user_helper.go @@ -12,8 +12,9 @@ import ( type UsersQuery struct { // Ignored when using FetchUser - UserIDs []int // if empty, all users - Usernames []string // if empty, all users + UserIDs []int // if empty, all users + Usernames []string // if empty, all users + DiscordUserIDs []string // if empty, no Discord filtering // Flags to modify behavior AnyStatus bool // Bypasses shadowban system @@ -44,8 +45,9 @@ func FetchUsers( } type userRow struct { - User models.User `db:"hmn_user"` - AvatarAsset *models.Asset `db:"avatar"` + User models.User `db:"hmn_user"` + AvatarAsset *models.Asset `db:"avatar"` + DiscordUser *models.DiscordUser `db:"discord_user"` } var qb db.QueryBuilder @@ -54,6 +56,7 @@ func FetchUsers( FROM hmn_user LEFT JOIN asset AS avatar ON avatar.id = hmn_user.avatar_asset_id + LEFT JOIN discord_user ON discord_user.hmn_user_id = hmn_user.id WHERE TRUE `) @@ -63,6 +66,9 @@ func FetchUsers( if len(q.Usernames) > 0 { qb.Add(`AND LOWER(hmn_user.username) = ANY($?)`, q.Usernames) } + if len(q.DiscordUserIDs) > 0 { + qb.Add(`AND discord_user.userid = ANY($?)`, q.DiscordUserIDs) + } if !q.AnyStatus { if currentUser == nil { qb.Add(`AND hmn_user.status = $?`, models.UserStatusApproved) @@ -89,6 +95,7 @@ func FetchUsers( for i, row := range userRows { user := row.User user.AvatarAsset = row.AvatarAsset + user.DiscordUser = row.DiscordUser result[i] = &user } diff --git a/src/models/user.go b/src/models/user.go index 874fbe6..758f346 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -50,6 +50,7 @@ type User struct { // Non-db fields, to be filled in by fetch helpers AvatarAsset *Asset + DiscordUser *DiscordUser } func (u *User) BestName() string { diff --git a/src/templates/src/fishbowl.html b/src/templates/src/fishbowl.html index 8c5149a..572b1ee 100644 --- a/src/templates/src/fishbowl.html +++ b/src/templates/src/fishbowl.html @@ -65,6 +65,10 @@ .fishbowl-banner a { color: {{ eq .Theme "dark" | ternary "#9ad0ff" "#1f4f99" }}; } + + .fishbowl .chatlog__author a { + color: inherit; + } {{ end }} diff --git a/src/website/fishbowl.go b/src/website/fishbowl.go index 52c61b9..7348ec9 100644 --- a/src/website/fishbowl.go +++ b/src/website/fishbowl.go @@ -1,15 +1,20 @@ package website import ( + "fmt" "html/template" "io" "io/fs" "net/http" + "regexp" "sort" "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/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/utils" @@ -157,6 +162,11 @@ func Fishbowl(c *RequestContext) ResponseData { } contentsFile := utils.Must1(fishbowlFS.Open(info.ContentsPath)) + contents := string(utils.Must1(io.ReadAll(contentsFile))) + contents, err := linkifyDiscordContent(c, c.Conn, contents) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to linkify fishbowl content")) + } tmpl := FishbowlData{ BaseData: getBaseData(c, info.Title, []templates.Breadcrumb{ @@ -165,7 +175,7 @@ func Fishbowl(c *RequestContext) ResponseData { }), Slug: slug, Info: info, - Contents: template.HTML(utils.Must1(io.ReadAll(contentsFile))), + Contents: template.HTML(contents), } tmpl.BaseData.OpenGraphItems = append(tmpl.BaseData.OpenGraphItems, templates.OpenGraphItem{ Property: "og:description", @@ -173,7 +183,7 @@ func Fishbowl(c *RequestContext) ResponseData { }) var res ResponseData - err := res.WriteTemplate("fishbowl.html", tmpl, c.Perf) + err = res.WriteTemplate("fishbowl.html", tmpl, c.Perf) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render fishbowl index page")) } @@ -186,3 +196,45 @@ func FishbowlFiles(c *RequestContext) ResponseData { AddCORSHeaders(c, &res) return res } + +var reFishbowlDiscordUserId = regexp.MustCompile(`data-user-id="(\d+)"`) +var reFishbowlDiscordAuthorHeader = regexp.MustCompile(`(?s:(
.*?)()(.*?)(.*?)())`) + +func linkifyDiscordContent(c *RequestContext, dbConn db.ConnOrTx, content string) (string, error) { + discordUserIdSet := make(map[string]struct{}) + userIdMatches := reFishbowlDiscordUserId.FindAllStringSubmatch(content, -1) + for _, m := range userIdMatches { + discordUserIdSet[m[1]] = struct{}{} + } + discordUserIds := make([]string, 0, len(discordUserIdSet)) + for id := range discordUserIdSet { + discordUserIds = append(discordUserIds, id) + } + + hmnUsers, err := hmndata.FetchUsers(c.Context(), dbConn, c.CurrentUser, hmndata.UsersQuery{ + DiscordUserIDs: discordUserIds, + }) + if err != nil { + return "", err + } + + return reFishbowlDiscordAuthorHeader.ReplaceAllStringFunc(content, func(s string) string { + m := reFishbowlDiscordAuthorHeader.FindStringSubmatch(s) + discordUserID := m[4] + + var matchingUser *models.User + for _, u := range hmnUsers { + if u.DiscordUser.UserID == discordUserID { + matchingUser = u + break + } + } + + if matchingUser == nil { + return s + } else { + link := fmt.Sprintf(``, hmnurl.BuildUserProfile(matchingUser.Username)) + return m[1] + link + m[2] + "" + m[3] + link + m[5] + "" + m[6] + } + }), nil +}