appview: introduce email notifications for @ mentions on issue/pr comments #393

closed
opened by boltless.me targeting master from boltless.me/core: feat/mentions

Stacked on top of #392

Yes, I know we have stacked PRs, but I want to explicitly separate two sets of commits and review both on different places

This is MVC implementation of email notification.

Still lot of parts are missing, but this is a PR with most basic features.

Changed files
+283 -40
appview
+7 -1
appview/pages/markup/markdown.go
···
Sanitizer Sanitizer
}
-
func (rctx *RenderContext) RenderMarkdown(source string) string {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
···
extension.NewFootnote(
extension.WithFootnoteIDPrefix([]byte("footnote")),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
if rctx != nil {
var transformers []util.PrioritizedValue
···
Sanitizer Sanitizer
}
+
func NewMarkdown() goldmark.Markdown {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
···
extension.NewFootnote(
extension.WithFootnoteIDPrefix([]byte("footnote")),
),
+
AtExt,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
+
return md
+
}
+
+
func (rctx *RenderContext) RenderMarkdown(source string) string {
+
md := NewMarkdown()
if rctx != nil {
var transformers []util.PrioritizedValue
+134
appview/pages/markup/markdown_at_extension.go
···
···
+
// heavily inspired by: https://github.com/kaleocheng/goldmark-extensions
+
+
package markup
+
+
import (
+
"regexp"
+
+
"github.com/yuin/goldmark"
+
"github.com/yuin/goldmark/ast"
+
"github.com/yuin/goldmark/parser"
+
"github.com/yuin/goldmark/renderer"
+
"github.com/yuin/goldmark/renderer/html"
+
"github.com/yuin/goldmark/text"
+
"github.com/yuin/goldmark/util"
+
)
+
+
// An AtNode struct represents an AtNode
+
type AtNode struct {
+
handle string
+
ast.BaseInline
+
}
+
+
var _ ast.Node = &AtNode{}
+
+
// Dump implements Node.Dump.
+
func (n *AtNode) Dump(source []byte, level int) {
+
ast.DumpHelper(n, source, level, nil, nil)
+
}
+
+
// KindAt is a NodeKind of the At node.
+
var KindAt = ast.NewNodeKind("At")
+
+
// Kind implements Node.Kind.
+
func (n *AtNode) Kind() ast.NodeKind {
+
return KindAt
+
}
+
+
var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`)
+
+
type atParser struct{}
+
+
// NewAtParser return a new InlineParser that parses
+
// at expressions.
+
func NewAtParser() parser.InlineParser {
+
return &atParser{}
+
}
+
+
func (s *atParser) Trigger() []byte {
+
return []byte{'@'}
+
}
+
+
func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
+
line, segment := block.PeekLine()
+
m := atRegexp.FindSubmatchIndex(line)
+
if m == nil {
+
return nil
+
}
+
block.Advance(m[1])
+
node := &AtNode{}
+
node.AppendChild(node, ast.NewTextSegment(text.NewSegment(segment.Start, segment.Start+m[1])))
+
node.handle = string(node.Text(block.Source())[1:])
+
return node
+
}
+
+
// atHtmlRenderer is a renderer.NodeRenderer implementation that
+
// renders At nodes.
+
type atHtmlRenderer struct {
+
html.Config
+
}
+
+
// NewAtHTMLRenderer returns a new AtHTMLRenderer.
+
func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
+
r := &atHtmlRenderer{
+
Config: html.NewConfig(),
+
}
+
for _, opt := range opts {
+
opt.SetHTMLOption(&r.Config)
+
}
+
return r
+
}
+
+
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
+
func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+
reg.Register(KindAt, r.renderAt)
+
}
+
+
func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+
if entering {
+
w.WriteString(`<a href="/@`)
+
w.WriteString(n.(*AtNode).handle)
+
w.WriteString(`" class="text-red-500">`)
+
} else {
+
w.WriteString("</a>")
+
}
+
return ast.WalkContinue, nil
+
}
+
+
type atExt struct{}
+
+
// At is an extension that allow you to use at expression like '@user.bsky.social' .
+
var AtExt = &atExt{}
+
+
func (e *atExt) Extend(m goldmark.Markdown) {
+
m.Parser().AddOptions(parser.WithInlineParsers(
+
util.Prioritized(NewAtParser(), 500),
+
))
+
m.Renderer().AddOptions(renderer.WithNodeRenderers(
+
util.Prioritized(NewAtHTMLRenderer(), 500),
+
))
+
}
+
+
// FindUserMentions returns Set of user handles from given markup soruce.
+
// It doesn't guarntee unique DIDs
+
func FindUserMentions(source string) []string {
+
var (
+
mentions []string
+
mentionsSet = make(map[string]struct{})
+
md = NewMarkdown()
+
sourceBytes = []byte(source)
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
+
)
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+
if entering && n.Kind() == KindAt {
+
handle := n.(*AtNode).handle
+
mentionsSet[handle] = struct{}{}
+
return ast.WalkSkipChildren, nil
+
}
+
return ast.WalkContinue, nil
+
})
+
for handle := range mentionsSet {
+
mentions = append(mentions, handle)
+
}
+
return mentions
+
}
+18 -16
appview/issues/issues.go
···
return
}
-
commentId := mathrand.IntN(1000000)
-
rkey := tid.TID()
-
-
err := db.NewIssueComment(rp.db, &db.Comment{
OwnerDid: user.Did,
RepoAt: f.RepoAt(),
Issue: issueIdInt,
-
CommentId: commentId,
Body: body,
-
Rkey: rkey,
-
})
if err != nil {
log.Println("failed to create comment", err)
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
···
}
createdAt := time.Now().Format(time.RFC3339)
-
commentIdInt64 := int64(commentId)
-
ownerDid := user.Did
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
if err != nil {
-
log.Println("failed to get issue at", err)
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
-
atUri := f.RepoAt().String()
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client", err)
···
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
-
Rkey: rkey,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssueComment{
Repo: &atUri,
-
Issue: issueAt,
CommentId: &commentIdInt64,
-
Owner: &ownerDid,
Body: body,
CreatedAt: createdAt,
},
···
return
}
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
return
}
}
···
return
}
+
comment := &db.Comment{
OwnerDid: user.Did,
RepoAt: f.RepoAt(),
Issue: issueIdInt,
+
CommentId: mathrand.IntN(1000000),
Body: body,
+
Rkey: tid.TID(),
+
}
+
+
err := db.NewIssueComment(rp.db, comment)
if err != nil {
log.Println("failed to create comment", err)
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
···
}
createdAt := time.Now().Format(time.RFC3339)
+
commentIdInt64 := int64(comment.CommentId)
+
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
if err != nil {
+
log.Println("failed to get issue", err)
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
+
atUri := comment.RepoAt.String()
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client", err)
···
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
+
Rkey: comment.Rkey,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssueComment{
Repo: &atUri,
+
Issue: issue.AtUri().String(),
CommentId: &commentIdInt64,
+
Owner: &comment.OwnerDid,
Body: body,
CreatedAt: createdAt,
},
···
return
}
+
mentions := markup.FindUserMentions(comment.Body)
+
+
rp.notifier.NewIssueComment(r.Context(), &f.Repo, issue, comment, mentions)
+
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), comment.Issue, comment.CommentId))
return
}
}
+3 -8
appview/email/email.go
···
package email
import (
-
"fmt"
"net"
"regexp"
"strings"
···
type Email struct {
From string
-
To string
Subject string
Text string
Html string
APIKey string
}
-
func SendEmail(email Email) error {
client := resend.NewClient(email.APIKey)
_, err := client.Emails.Send(&resend.SendEmailRequest{
From: email.From,
-
To: []string{email.To},
Subject: email.Subject,
Text: email.Text,
Html: email.Html,
})
-
if err != nil {
-
return fmt.Errorf("error sending email: %w", err)
-
}
-
return nil
}
func IsValidEmail(email string) bool {
···
package email
import (
"net"
"regexp"
"strings"
···
type Email struct {
From string
Subject string
Text string
Html string
APIKey string
}
+
func SendEmail(email Email, recipients ...string) error {
client := resend.NewClient(email.APIKey)
_, err := client.Emails.Send(&resend.SendEmailRequest{
From: email.From,
+
To: recipients,
Subject: email.Subject,
Text: email.Text,
Html: email.Html,
})
+
return err
}
func IsValidEmail(email string) bool {
+1 -2
appview/signup/signup.go
···
em := email.Email{
APIKey: s.config.Resend.ApiKey,
From: s.config.Resend.SentFrom,
-
To: emailId,
Subject: "Verify your Tangled account",
Text: `Copy and paste this code below to verify your account on Tangled.
` + code,
···
<p><code>` + code + `</code></p>`,
}
-
err = email.SendEmail(em)
if err != nil {
s.l.Error("failed to send email", "error", err)
s.pages.Notice(w, noticeId, "Failed to send email.")
···
em := email.Email{
APIKey: s.config.Resend.ApiKey,
From: s.config.Resend.SentFrom,
Subject: "Verify your Tangled account",
Text: `Copy and paste this code below to verify your account on Tangled.
` + code,
···
<p><code>` + code + `</code></p>`,
}
+
err = email.SendEmail(em, emailId)
if err != nil {
s.l.Error("failed to send email", "error", err)
s.pages.Notice(w, noticeId, "Failed to send email.")
+2
appview/state/state.go
···
"tangled.sh/tangled.sh/core/appview/cache/session"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
···
spindlestream.Start(ctx)
var notifiers []notify.Notifier
if !config.Core.Dev {
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
}
···
"tangled.sh/tangled.sh/core/appview/cache/session"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/email"
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
···
spindlestream.Start(ctx)
var notifiers []notify.Notifier
+
notifiers = append(notifiers, email.NewEmailNotifier(d, res, config))
if !config.Core.Dev {
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
}
+7
appview/db/db.go
···
return err
})
return &DB{db}, nil
}
···
return err
})
+
runMigration(conn, "add-email-notif-preference-to-profile", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table profile add column email_notif_preference integer not null default 0 check (email_notif_preference in (0, 1, 2)); -- disable, metion, enable
+
`)
+
return err
+
})
+
return &DB{db}, nil
}
+22
appview/db/email.go
···
_, err := e.Exec(query, code, did, email)
return err
}
···
_, err := e.Exec(query, code, did, email)
return err
}
+
+
func GetUserEmailPreference(e Execer, did string) (EmailPreference, error) {
+
var preference EmailPreference
+
err := e.QueryRow(`
+
select email_notif_preference
+
from profile
+
where did = ?
+
`, did).Scan(&preference)
+
if err != nil {
+
return preference, err
+
}
+
return preference, nil
+
}
+
+
func UpdateSettingsEmailPreference(e Execer, did string, preference EmailPreference) error {
+
_, err := e.Exec(`
+
update profile
+
set email_notif_preference = ?
+
where did = ?
+
`, preference, did)
+
return err
+
}
+32 -6
appview/db/profile.go
···
Links [5]string
Stats [2]VanityStat
PinnedRepos [6]syntax.ATURI
}
func (p Profile) IsLinksEmpty() bool {
···
did,
description,
include_bluesky,
-
location
)
-
values (?, ?, ?, ?)`,
profile.Did,
profile.Description,
includeBskyValue,
profile.Location,
)
if err != nil {
···
did,
description,
include_bluesky,
-
location
from
profile
%s`,
···
var profile Profile
var includeBluesky int
-
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
if err != nil {
return nil, err
}
···
includeBluesky := 0
err := e.QueryRow(
-
`select description, include_bluesky, location from profile where did = ?`,
did,
-
).Scan(&profile.Description, &includeBluesky, &profile.Location)
if err == sql.ErrNoRows {
profile := Profile{}
profile.Did = did
···
Links [5]string
Stats [2]VanityStat
PinnedRepos [6]syntax.ATURI
+
+
// settings
+
EmailNotifPreference EmailPreference
+
}
+
+
type EmailPreference int
+
+
const (
+
EmailNotifDisabled EmailPreference = iota
+
EmailNotifMention
+
EmailNotifEnabled
+
)
+
+
func (p EmailPreference) IsDisabled() bool {
+
return p == EmailNotifDisabled
+
}
+
+
func (p EmailPreference) IsMention() bool {
+
return p == EmailNotifMention
+
}
+
+
func (p EmailPreference) IsEnabled() bool {
+
return p == EmailNotifEnabled
}
func (p Profile) IsLinksEmpty() bool {
···
did,
description,
include_bluesky,
+
location,
+
email_notif_preference
)
+
values (?, ?, ?, ?, ?)`,
profile.Did,
profile.Description,
includeBskyValue,
profile.Location,
+
profile.EmailNotifPreference,
)
if err != nil {
···
did,
description,
include_bluesky,
+
location,
+
email_notif_preference
from
profile
%s`,
···
var profile Profile
var includeBluesky int
+
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference)
if err != nil {
return nil, err
}
···
includeBluesky := 0
err := e.QueryRow(
+
`select description, include_bluesky, location, email_notif_preference from profile where did = ?`,
did,
+
).Scan(&profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference)
if err == sql.ErrNoRows {
profile := Profile{}
profile.Did = did
+4 -3
appview/pages/pages.go
···
}
type SettingsParams struct {
-
LoggedInUser *oauth.User
-
PubKeys []db.PublicKey
-
Emails []db.Email
}
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
···
}
type SettingsParams struct {
+
LoggedInUser *oauth.User
+
PubKeys []db.PublicKey
+
Emails []db.Email
+
EmailNotifPreference db.EmailPreference
}
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
+20
appview/pages/templates/settings.html
···
{{ define "emails" }}
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
<div id="email-list" class="flex flex-col gap-6 mb-8">
{{ range $index, $email := .Emails }}
···
{{ define "emails" }}
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
+
<form
+
hx-post="/settings/email/preference"
+
hx-swap="none"
+
hx-indicator="#email-preference-spinner"
+
>
+
<select
+
name="preference"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option value="enable" {{ if .EmailNotifPreference.IsEnabled }}selected{{ end }}>Enable Email Notifications</option>
+
<option value="mention" {{ if .EmailNotifPreference.IsMention }}selected{{ end }}>Only on Mentions</option>
+
<option value="disable" {{ if .EmailNotifPreference.IsDisabled }}selected{{ end }}>Disable Email Notifications</option>
+
</select>
+
<button type="submit" class="btn text-base">
+
<span>Save Preference</span>
+
<span id="email-preference-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</form>
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
<div id="email-list" class="flex flex-col gap-6 mb-8">
{{ range $index, $email := .Emails }}
+30 -3
appview/email/notifier.go
···
}, nil
}
func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string {
recipients := []string{}
for _, handle := range handles {
···
}
}
-
// func (n *EmailNotifier) NewPullComment(ctx context.Context, comment *db.PullComment, []string) {
-
// n.usersMentioned(ctx, mentions)
-
// }
···
}, nil
}
+
func (n *EmailNotifier) buildPullEmail(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment) (Email, error) {
+
commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid)
+
if err != nil || commentOwner.Handle.IsInvalidHandle() {
+
return Email{}, fmt.Errorf("resolve comment owner did: %w", err)
+
}
+
repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo)
+
if err != nil {
+
return Email{}, nil
+
}
+
baseUrl := n.Config.Core.AppviewHost
+
url := fmt.Sprintf("%s/%s/pulls/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.PullId, comment.ID)
+
return Email{
+
APIKey: n.Config.Resend.ApiKey,
+
From: n.Config.Resend.SentFrom,
+
Subject: fmt.Sprintf("[%s] %s (pr#%d)", repoOwnerSlashName, pull.Title, pull.PullId),
+
Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url),
+
}, nil
+
}
+
func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string {
recipients := []string{}
for _, handle := range handles {
···
}
}
+
func (n *EmailNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) {
+
email, err := n.buildPullEmail(ctx, repo, pull, comment)
+
if err != nil {
+
log.Println("failed to create pull-email:", err)
+
}
+
recipients := n.gatherRecipientEmails(ctx, mentions)
+
log.Println("sending email to:", recipients)
+
if err = SendEmail(email); err != nil {
+
log.Println("error sending email:", err)
+
}
+
}
+3 -1
appview/pulls/pulls.go
···
return
}
-
s.notifier.NewPullComment(r.Context(), comment)
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
return
···
return
}
+
mentions := markup.FindUserMentions(comment.Body)
+
+
s.notifier.NewPullComment(r.Context(), &f.Repo, pull, comment, mentions)
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
return