forked from tangled.org/core
this repo has no description

allow editing and deleting issues

Changed files
+476 -46
appview
+9
appview/db/db.go
···
return nil
})
+
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
+
// add unconstrained column
+
_, err := tx.Exec(`
+
alter table comments add column deleted text; -- timestamp
+
alter table comments add column edited text; -- timestamp
+
`)
+
return err
+
})
+
return &DB{db}, nil
}
+71 -1
appview/db/issues.go
···
type Comment struct {
OwnerDid string
RepoAt syntax.ATURI
-
CommentAt string
+
CommentAt syntax.ATURI
Issue int
CommentId int
Body string
Created *time.Time
+
Deleted *time.Time
+
Edited *time.Time
}
func NewIssue(tx *sql.Tx, issue *Issue) error {
···
}
return comments, nil
+
}
+
+
func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
+
query := `
+
select
+
owner_did, body, comment_at, created, deleted, edited
+
from
+
comments where repo_at = ? and issue_id = ? and comment_id = ?
+
`
+
row := e.QueryRow(query, repoAt, issueId, commentId)
+
+
var comment Comment
+
var createdAt string
+
var deletedAt, editedAt sql.NullString
+
err := row.Scan(&comment.OwnerDid, &comment.Body, &comment.CommentAt, &createdAt, &deletedAt, &editedAt)
+
if err != nil {
+
return nil, err
+
}
+
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
return nil, err
+
}
+
comment.Created = &createdTime
+
+
if deletedAt.Valid {
+
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
+
if err != nil {
+
return nil, err
+
}
+
comment.Deleted = &deletedTime
+
}
+
+
if editedAt.Valid {
+
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
+
if err != nil {
+
return nil, err
+
}
+
comment.Edited = &editedTime
+
}
+
+
comment.RepoAt = repoAt
+
comment.Issue = issueId
+
comment.CommentId = commentId
+
+
return &comment, nil
+
}
+
+
func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
+
_, err := e.Exec(
+
`
+
update comments
+
set body = ?,
+
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+
where repo_at = ? and issue_id = ? and comment_id = ?
+
`, newBody, repoAt, issueId, commentId)
+
return err
+
}
+
+
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
+
_, err := e.Exec(
+
`
+
update comments
+
set body = "",
+
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+
where repo_at = ? and issue_id = ? and comment_id = ?
+
`, repoAt, issueId, commentId)
+
return err
}
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
+23
appview/pages/pages.go
···
return p.executeRepo("repo/issues/new", w, params)
}
+
type EditIssueCommentParams struct {
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Issue *db.Issue
+
Comment *db.Comment
+
}
+
+
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
+
return p.executePlain("fragments/editIssueComment", w, params)
+
}
+
+
type SingleIssueCommentParams struct {
+
LoggedInUser *auth.User
+
DidHandleMap map[string]string
+
RepoInfo RepoInfo
+
Issue *db.Issue
+
Comment *db.Comment
+
}
+
+
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
+
return p.executePlain("fragments/issueComment", w, params)
+
}
+
type RepoNewPullParams struct {
LoggedInUser *auth.User
RepoInfo RepoInfo
+23 -18
appview/pages/templates/fragments/diff.html
···
This is a binary file and will not be displayed.
</p>
{{ else }}
-
<pre class="overflow-auto">
-
{{- range .TextFragments -}}
-
<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none">{{ .Header }}</div>
-
{{- range .Lines -}}
-
{{- if eq .Op.String "+" -}}
-
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
-
{{- end -}}
-
-
{{- if eq .Op.String "-" -}}
-
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
-
{{- end -}}
-
-
{{- if eq .Op.String " " -}}
-
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 px"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
-
{{- end -}}
-
-
{{- end -}}
-
{{- end -}}
+
<pre class="overflow-x-auto">
+
{{- range .TextFragments -}}
+
<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none">{{- .Header -}}</div><div class="overflow-x-auto"><div class="min-w-full inline-block">
+
{{- range .Lines -}}
+
{{- if eq .Op.String "+" -}}
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full">
+
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
+
<div class="p-1 whitespace-pre">{{ .Line }}</div>
+
</div>
+
{{- end -}}
+
{{- if eq .Op.String "-" -}}
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full">
+
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
+
<div class="p-1 whitespace-pre">{{ .Line }}</div>
+
</div>
+
{{- end -}}
+
{{- if eq .Op.String " " -}}
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full">
+
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
+
<div class="p-1 whitespace-pre">{{ .Line }}</div>
+
</div>
+
{{- end -}}
+
{{- end -}}</div></div>{{- end -}}
</pre>
{{- end -}}
{{ end }}
+52
appview/pages/templates/fragments/editIssueComment.html
···
+
{{ define "fragments/editIssueComment" }}
+
{{ with .Comment }}
+
<div id="comment-container-{{.CommentId}}">
+
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
+
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
+
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
+
+
<!-- show user "hats" -->
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+
{{ if $isIssueAuthor }}
+
<span class="before:content-['·']"></span>
+
<span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
+
author
+
</span>
+
{{ end }}
+
+
<span class="before:content-['·']"></span>
+
<a
+
href="#{{ .CommentId }}"
+
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
+
id="{{ .CommentId }}">
+
{{ .Created | timeFmt }}
+
</a>
+
+
<button
+
class="btn px-2 py-1 flex items-center gap-2 text-sm"
+
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
+
hx-include="#edit-textarea-{{ .CommentId }}"
+
hx-target="#comment-container-{{ .CommentId }}"
+
hx-swap="outerHTML">
+
{{ i "check" "w-4 h-4" }}
+
</button>
+
<button
+
class="btn px-2 py-1 flex items-center gap-2 text-sm"
+
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
+
hx-target="#comment-container-{{ .CommentId }}"
+
hx-swap="outerHTML">
+
{{ i "x" "w-4 h-4" }}
+
</button>
+
<span id="comment-{{.CommentId}}-status"></span>
+
</div>
+
+
<div>
+
<textarea
+
id="edit-textarea-{{ .CommentId }}"
+
name="body"
+
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
+52
appview/pages/templates/fragments/issueComment.html
···
+
{{ define "fragments/issueComment" }}
+
{{ with .Comment }}
+
<div id="comment-container-{{.CommentId}}">
+
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
+
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
+
+
<!-- show user "hats" -->
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
+
{{ if $isIssueAuthor }}
+
<span class="before:content-['·']"></span>
+
<span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
+
author
+
</span>
+
{{ end }}
+
+
<span class="before:content-['·']"></span>
+
<a
+
href="#{{ .CommentId }}"
+
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
+
id="{{ .CommentId }}">
+
{{ .Created | timeFmt }}
+
</a>
+
+
{{ $isCommentOwner := eq $.LoggedInUser.Did .OwnerDid }}
+
{{ if and $isCommentOwner (not .Deleted) }}
+
<button
+
class="btn px-2 py-1 text-sm"
+
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
+
hx-swap="outerHTML"
+
hx-target="#comment-container-{{.CommentId}}"
+
>
+
{{ i "pencil" "w-4 h-4" }}
+
</button>
+
<button class="btn px-2 py-1 text-sm text-red-500" hx-delete="">
+
{{ i "trash-2" "w-4 h-4" }}
+
</button>
+
{{ end }}
+
+
{{ if .Deleted }}
+
<span class="before:content-['·']">deleted {{ .Deleted | timeFmt }}</span>
+
{{ end }}
+
+
</div>
+
{{ if not .Deleted }}
+
<div class="prose">
+
{{ .Body | markdown }}
+
</div>
+
{{ end }}
+
</div>
+
{{ end }}
+
{{ end }}
+3 -25
appview/pages/templates/repo/issues/issue.html
···
-
{{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot;{{ .RepoInfo.FullName }}{{ end }}
+
{{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
<header class="pb-4">
···
{{ range $index, $comment := .Comments }}
<div
id="comment-{{ .CommentId }}"
-
class="rounded bg-white px-6 py-4 relative dark:bg-gray-800"
-
>
+
class="rounded bg-white px-6 py-4 relative dark:bg-gray-800">
{{ if eq $index 0 }}
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div>
{{ else }}
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-700" ></div>
{{ end }}
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
-
<span class="text-sm">
-
<a
-
href="/{{ $owner }}"
-
class="no-underline hover:underline"
-
>{{ $owner }}</a
-
>
-
</span>
-
<span class="before:content-['·']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 text-sm hover:text-gray-500 hover:underline no-underline dark:text-gray-400 dark:hover:text-gray-300 dark:hover:bg-gray-800"
-
id="{{ .CommentId }}"
-
title="{{ .Created | longTimeFmt }}"
-
>
-
{{ .Created | timeFmt }}
-
</a>
-
</div>
-
<div class="prose dark:prose-invert">
-
{{ .Body | markdown }}
-
</div>
+
{{ template "fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
</div>
{{ end }}
</section>
+236 -1
appview/state/repo.go
···
"strings"
"time"
+
"github.com/bluesky-social/indigo/atproto/data"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
securejoin "github.com/cyphar/filepath-securejoin"
···
}
}
-
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
+
func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
user := s.auth.GetUser(r)
f, err := fullyResolvedRepo(r)
if err != nil {
···
s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
return
}
+
}
+
+
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
if err != nil {
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
commentId := chi.URLParam(r, "comment_id")
+
commentIdInt, err := strconv.Atoi(commentId)
+
if err != nil {
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
+
if err != nil {
+
log.Println("failed to get issue", err)
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
return
+
}
+
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
+
if err != nil {
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
return
+
}
+
+
identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
+
if err != nil {
+
log.Println("failed to resolve did")
+
return
+
}
+
+
didHandleMap := make(map[string]string)
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Issue: issue,
+
Comment: comment,
+
})
+
}
+
+
func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
if err != nil {
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
commentId := chi.URLParam(r, "comment_id")
+
commentIdInt, err := strconv.Atoi(commentId)
+
if err != nil {
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
+
if err != nil {
+
log.Println("failed to get issue", err)
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
return
+
}
+
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
+
if err != nil {
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
return
+
}
+
+
if comment.OwnerDid != user.Did {
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodGet:
+
s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
Issue: issue,
+
Comment: comment,
+
})
+
case http.MethodPost:
+
// extract form value
+
newBody := r.FormValue("body")
+
client, _ := s.auth.AuthorizedClient(r)
+
log.Println("comment at", comment.CommentAt)
+
rkey := comment.CommentAt.RecordKey()
+
+
// optimistic update
+
err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
+
if err != nil {
+
log.Println("failed to perferom update-description query", err)
+
s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
+
return
+
}
+
+
// update the record on pds
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey.String())
+
if err != nil {
+
// failed to get record
+
log.Println(err, rkey.String())
+
s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
+
return
+
}
+
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
+
record, _ := data.UnmarshalJSON(value)
+
+
repoAt := record["repo"].(string)
+
issueAt := record["issue"].(string)
+
createdAt := record["createdAt"].(string)
+
commentIdInt64 := int64(commentIdInt)
+
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoNSID,
+
Repo: user.Did,
+
Rkey: rkey.String(),
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoIssueComment{
+
Repo: &repoAt,
+
Issue: issueAt,
+
CommentId: &commentIdInt64,
+
Owner: &comment.OwnerDid,
+
Body: &newBody,
+
CreatedAt: &createdAt,
+
},
+
},
+
})
+
if err != nil {
+
log.Println(err)
+
}
+
+
// optimistic update for htmx
+
didHandleMap := map[string]string{
+
user.Did: user.Handle,
+
}
+
comment.Body = newBody
+
+
// return new comment body with htmx
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Issue: issue,
+
Comment: comment,
+
})
+
return
+
+
}
+
+
}
+
+
func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
if err != nil {
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
commentId := chi.URLParam(r, "comment_id")
+
commentIdInt, err := strconv.Atoi(commentId)
+
if err != nil {
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
+
if err != nil {
+
http.Error(w, "bad comment id", http.StatusBadRequest)
+
return
+
}
+
+
if comment.OwnerDid != user.Did {
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
+
return
+
}
+
+
if comment.Deleted != nil {
+
http.Error(w, "comment already deleted", http.StatusBadRequest)
+
return
+
}
+
+
// optimistic deletion
+
err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
+
if err != nil {
+
log.Println("failed to delete comment")
+
s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
+
return
+
}
+
+
// delete from pds
+
+
// htmx fragment of comment after deletion
+
return
}
func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
+7 -1
appview/state/router.go
···
r.Use(AuthMiddleware(s))
r.Get("/new", s.NewIssue)
r.Post("/new", s.NewIssue)
-
r.Post("/{issue}/comment", s.IssueComment)
+
r.Post("/{issue}/comment", s.NewIssueComment)
+
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
+
r.Get("/", s.IssueComment)
+
r.Delete("/", s.DeleteIssueComment)
+
r.Get("/edit", s.EditIssueComment)
+
r.Post("/edit", s.EditIssueComment)
+
})
r.Post("/{issue}/close", s.CloseIssue)
r.Post("/{issue}/reopen", s.ReopenIssue)
})