mirror of
https://github.com/fiatjaf/nak.git
synced 2026-06-04 09:41:24 +02:00
'group forum' with the ui like 'nak git issue'.
This commit is contained in:
315
git.go
315
git.go
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,7 +14,6 @@ import (
|
|||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/nip19"
|
"fiatjaf.com/nostr/nip19"
|
||||||
"fiatjaf.com/nostr/nip22"
|
|
||||||
"fiatjaf.com/nostr/nip34"
|
"fiatjaf.com/nostr/nip34"
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
@@ -891,7 +889,7 @@ aside from those, there is also:
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return showGitDiscussionWithComments(ctx, repo, evt, statusLabelForEvent(evt.ID, statuses, false))
|
return showThreadWithComments(ctx, repo.Relays, evt, statusLabelForEvent(evt.ID, statuses, false), nil)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
@@ -1205,7 +1203,7 @@ aside from those, there is also:
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return showGitDiscussionWithComments(ctx, repo, evt, statusLabelForEvent(evt.ID, statuses, true))
|
return showThreadWithComments(ctx, repo.Relays, evt, statusLabelForEvent(evt.ID, statuses, true), nil)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
@@ -1218,6 +1216,11 @@ aside from those, there is also:
|
|||||||
return fmt.Errorf("failed to gather keyer: %w", err)
|
return fmt.Errorf("failed to gather keyer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
repo, err := readGitRepositoryFromConfig()
|
repo, err := readGitRepositoryFromConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1225,7 +1228,8 @@ aside from those, there is also:
|
|||||||
|
|
||||||
content, err := editWithDefaultEditor(
|
content, err := editWithDefaultEditor(
|
||||||
"nak-git-issue/NOTES_EDITMSG",
|
"nak-git-issue/NOTES_EDITMSG",
|
||||||
strings.TrimSpace(`
|
strings.TrimSpace(fmt.Sprintf(`# creating as '%s' ('%s')
|
||||||
|
# creating issue on repository '%s'
|
||||||
# the first line will be used as the issue subject
|
# the first line will be used as the issue subject
|
||||||
everything is broken
|
everything is broken
|
||||||
|
|
||||||
@@ -1233,7 +1237,7 @@ everything is broken
|
|||||||
please fix
|
please fix
|
||||||
|
|
||||||
# lines starting with '#' are ignored
|
# lines starting with '#' are ignored
|
||||||
`),
|
`, selfName, selfNpub, repo.ID)),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1569,47 +1573,6 @@ func fetchIssueStatus(
|
|||||||
return latest, nil
|
return latest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchGitDiscussionComments(ctx context.Context, repo nip34.Repository, discussionID nostr.ID) ([]nostr.RelayEvent, error) {
|
|
||||||
comments := make([]nostr.RelayEvent, 0, 15)
|
|
||||||
for ie := range sys.Pool.FetchMany(ctx, repo.Relays, nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{1111},
|
|
||||||
Tags: nostr.TagMap{
|
|
||||||
"E": []string{discussionID.Hex()},
|
|
||||||
},
|
|
||||||
Limit: 500,
|
|
||||||
}, nostr.SubscriptionOptions{Label: "nak-git"}) {
|
|
||||||
comments = append(comments, ie)
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.SortFunc(comments, nostr.CompareRelayEvent)
|
|
||||||
|
|
||||||
return comments, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func showGitDiscussionWithComments(
|
|
||||||
ctx context.Context,
|
|
||||||
repo nip34.Repository,
|
|
||||||
evt nostr.RelayEvent,
|
|
||||||
status string,
|
|
||||||
) error {
|
|
||||||
comments, err := fetchGitDiscussionComments(ctx, repo, evt.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
printGitDiscussionMetadata(ctx, os.Stdout, evt, status, true)
|
|
||||||
stdout("")
|
|
||||||
stdout(evt.Content)
|
|
||||||
|
|
||||||
if len(comments) > 0 {
|
|
||||||
stdout("")
|
|
||||||
stdout(color.CyanString("comments:"))
|
|
||||||
printGitDiscussionCommentsThreaded(ctx, os.Stdout, comments, evt.ID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitDiscussionReply(
|
func gitDiscussionReply(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
c *cli.Command,
|
c *cli.Command,
|
||||||
@@ -1627,6 +1590,11 @@ func gitDiscussionReply(
|
|||||||
return fmt.Errorf("failed to gather keyer: %w", err)
|
return fmt.Errorf("failed to gather keyer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
repo, err := readGitRepositoryFromConfig()
|
repo, err := readGitRepositoryFromConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1642,21 +1610,31 @@ func gitDiscussionReply(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
comments, err := fetchGitDiscussionComments(ctx, repo, discussionEvt.ID)
|
comments, err := fetchThreadComments(ctx, repo.Relays, discussionEvt.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subject := subjectPreview(discussionEvt, 72)
|
||||||
|
if subject == "" {
|
||||||
|
subject = "<untitled>"
|
||||||
|
}
|
||||||
|
pm := sys.FetchProfileMetadata(ctx, discussionEvt.PubKey)
|
||||||
|
headerLines := []string{
|
||||||
|
fmt.Sprintf("commenting as '%s' ('%s')", selfName, selfNpub),
|
||||||
|
fmt.Sprintf("commenting on %s '%s' '%s' by '%s' ('%s') on repository '%s'", discussionName, discussionEvt.ID.Hex()[:6], subject, pm.ShortName(), pm.NpubShort(), repo.ID),
|
||||||
|
}
|
||||||
|
|
||||||
edited, err := editWithDefaultEditor(
|
edited, err := editWithDefaultEditor(
|
||||||
fmt.Sprintf("nak-git-%s-reply/NOTES_EDITMSG", discussionName),
|
fmt.Sprintf("nak-git-%s-reply/NOTES_EDITMSG", discussionName),
|
||||||
gitDiscussionReplyEditorTemplate(ctx, discussionEvt, comments),
|
threadReplyEditorTemplate(ctx, headerLines, discussionEvt, comments),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
content, parentEvt, err := parseGitDiscussionReplyContent(discussionEvt, comments, edited)
|
content, parentEvt, err := parseThreadReplyContent(discussionEvt, comments, edited)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1789,122 +1767,6 @@ func ensureGitRepositoryMaintainer(ctx context.Context, kr nostr.Keyer, repo nip
|
|||||||
return pubkey, nil
|
return pubkey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func printGitDiscussionCommentsThreaded(
|
|
||||||
ctx context.Context,
|
|
||||||
w io.Writer,
|
|
||||||
comments []nostr.RelayEvent,
|
|
||||||
discussionID nostr.ID,
|
|
||||||
withColor bool,
|
|
||||||
) {
|
|
||||||
byID := make(map[nostr.ID]struct{}, len(comments)+1)
|
|
||||||
byID[discussionID] = struct{}{}
|
|
||||||
for _, c := range comments {
|
|
||||||
byID[c.ID] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// preload metadata from everybody
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
|
|
||||||
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
|
|
||||||
for _, c := range comments {
|
|
||||||
wg.Go(func() {
|
|
||||||
sys.FetchProfileMetadata(ctx, c.PubKey)
|
|
||||||
})
|
|
||||||
|
|
||||||
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := byID[parent.ID]; ok {
|
|
||||||
children[parent.ID] = append(children[parent.ID], c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for parent := range children {
|
|
||||||
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
var render func(parent nostr.ID, depth int)
|
|
||||||
render = func(parent nostr.ID, depth int) {
|
|
||||||
for _, c := range children[parent] {
|
|
||||||
indent := strings.Repeat(" ", depth)
|
|
||||||
author := authorPreview(ctx, c.PubKey)
|
|
||||||
created := c.CreatedAt.Time().Format(time.DateTime)
|
|
||||||
|
|
||||||
if withColor {
|
|
||||||
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range strings.Split(c.Content, "\n") {
|
|
||||||
fmt.Fprintln(w, indent+" "+line)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w, indent+"")
|
|
||||||
|
|
||||||
render(c.ID, depth+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(discussionID, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
|
|
||||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
|
||||||
if prefix == "" {
|
|
||||||
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
|
|
||||||
}
|
|
||||||
|
|
||||||
matchCount := 0
|
|
||||||
matched := nostr.RelayEvent{}
|
|
||||||
for _, evt := range events {
|
|
||||||
if strings.HasPrefix(evt.ID.Hex(), prefix) {
|
|
||||||
matched = evt
|
|
||||||
matchCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if matchCount == 0 {
|
|
||||||
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
|
|
||||||
}
|
|
||||||
if matchCount > 1 {
|
|
||||||
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
return matched, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func printGitDiscussionMetadata(
|
|
||||||
ctx context.Context,
|
|
||||||
w io.Writer,
|
|
||||||
evt nostr.RelayEvent,
|
|
||||||
status string,
|
|
||||||
withColors bool,
|
|
||||||
) {
|
|
||||||
label := func(s string) string { return s }
|
|
||||||
value := func(s string) string { return s }
|
|
||||||
statusValue := func(s string) string { return s }
|
|
||||||
if withColors {
|
|
||||||
label = func(s string) string { return color.CyanString(s) }
|
|
||||||
value = func(s string) string { return color.HiWhiteString(s) }
|
|
||||||
statusValue = colorizeGitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
|
|
||||||
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
|
|
||||||
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
|
|
||||||
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
|
|
||||||
if status != "" {
|
|
||||||
fmt.Fprintln(w, label("status:"), statusValue(status))
|
|
||||||
}
|
|
||||||
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
|
|
||||||
fmt.Fprintln(w, label("subject:"), value(subject[1]))
|
|
||||||
fmt.Fprintln(w, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`)
|
var patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`)
|
||||||
|
|
||||||
func patchSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
|
func patchSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
|
||||||
@@ -1984,90 +1846,6 @@ func parseIssueCreateContent(content string) (subject string, body string, err e
|
|||||||
return subject, body, nil
|
return subject, body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGitDiscussionReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
|
|
||||||
currentParent := discussion
|
|
||||||
selectedParent := nostr.ZeroID
|
|
||||||
inComments := false
|
|
||||||
|
|
||||||
replyb := strings.Builder{}
|
|
||||||
for _, line := range strings.Split(edited, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
|
|
||||||
inComments = false
|
|
||||||
currentParent = discussion
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if replyb.Len() == 0 && line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(line, "#>") {
|
|
||||||
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
|
|
||||||
if quoted == "comments:" {
|
|
||||||
inComments = true
|
|
||||||
currentParent = discussion
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep track of which comment the reply body shows up below of
|
|
||||||
// so we can assign it as a reply to that specifically
|
|
||||||
fields := strings.Fields(quoted)
|
|
||||||
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
|
|
||||||
currId := fields[0][1 : len(fields[0])-1]
|
|
||||||
for _, comment := range comments {
|
|
||||||
if strings.HasPrefix(comment.ID.Hex(), currId) {
|
|
||||||
currentParent = comment
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we reach here this is a line for the reply input from the user
|
|
||||||
replyb.WriteString(line)
|
|
||||||
replyb.WriteByte('\n')
|
|
||||||
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
|
|
||||||
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedParent = currentParent.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
content := strings.TrimSpace(replyb.String())
|
|
||||||
if content == "" {
|
|
||||||
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
|
|
||||||
}
|
|
||||||
|
|
||||||
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
|
|
||||||
return content, discussion, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, comment := range comments {
|
|
||||||
if comment.ID == selectedParent {
|
|
||||||
return content, comment, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
panic("selected reply parent not found (this never happens)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
|
|
||||||
meta := sys.FetchProfileMetadata(ctx, pubkey)
|
|
||||||
if meta.Name != "" {
|
|
||||||
return meta.ShortName() + " (" + meta.NpubShort() + ")"
|
|
||||||
}
|
|
||||||
return meta.NpubShort()
|
|
||||||
}
|
|
||||||
|
|
||||||
func statusLabelForEvent(id nostr.ID, statuses map[nostr.ID]nostr.Event, isIssue bool) string {
|
func statusLabelForEvent(id nostr.ID, statuses map[nostr.ID]nostr.Event, isIssue bool) string {
|
||||||
statusEvt, ok := statuses[id]
|
statusEvt, ok := statuses[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -2123,43 +1901,6 @@ func shortCommitID(commit string, n int) string {
|
|||||||
return commit[:n]
|
return commit[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitDiscussionReplyEditorTemplate(ctx context.Context, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
|
|
||||||
lines := []string{
|
|
||||||
"# write your reply here.",
|
|
||||||
"# lines starting with '#' are ignored.",
|
|
||||||
"",
|
|
||||||
}
|
|
||||||
|
|
||||||
appender := &lineAppender{lines, "#> "}
|
|
||||||
|
|
||||||
printGitDiscussionMetadata(ctx, appender, discussion, "", false)
|
|
||||||
|
|
||||||
for _, line := range strings.Split(discussion.Content, "\n") {
|
|
||||||
appender.lines = append(appender.lines, "#> "+line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(comments) > 0 {
|
|
||||||
appender.lines = append(appender.lines, "#> ", "#> comments:")
|
|
||||||
printGitDiscussionCommentsThreaded(ctx, appender, comments, discussion.ID, false)
|
|
||||||
appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(appender.lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
type lineAppender struct {
|
|
||||||
lines []string
|
|
||||||
prefix string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *lineAppender) Write(b []byte) (int, error) {
|
|
||||||
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
|
|
||||||
line = strings.TrimRight(line, " ")
|
|
||||||
l.lines = append(l.lines, l.prefix+line)
|
|
||||||
}
|
|
||||||
return len(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func colorizeGitStatus(status string) string {
|
func colorizeGitStatus(status string) string {
|
||||||
switch status {
|
switch status {
|
||||||
case "open":
|
case "open":
|
||||||
|
|||||||
306
group.go
306
group.go
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -379,32 +380,235 @@ var group = &cli.Command{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "forum",
|
Name: "forum",
|
||||||
Usage: "read group forum posts",
|
Usage: "forum topic operations",
|
||||||
Description: "access group forum functionality.",
|
Description: "when called directly, lists forum topics; with an id prefix, displays that topic with threaded comments.",
|
||||||
ArgsUsage: "<relay>'<identifier>",
|
ArgsUsage: "<relay>'<identifier> [id-prefix]",
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
relay, identifier, err := parseGroupIdentifier(c)
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for evt := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
topics, err := fetchGroupForumTopics(ctx, relay, identifier)
|
||||||
Kinds: []nostr.Kind{11},
|
if err != nil {
|
||||||
Tags: nostr.TagMap{"h": []string{identifier}},
|
return err
|
||||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
|
||||||
title := evt.Tags.Find("title")
|
|
||||||
if title != nil {
|
|
||||||
stdout(colors.bold(title[1]))
|
|
||||||
} else {
|
|
||||||
stdout(colors.bold("<untitled>"))
|
|
||||||
}
|
|
||||||
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
|
||||||
stdout("by " + evt.PubKey.Hex() + " (" + color.HiBlueString(meta.ShortName()) + ") at " + evt.CreatedAt.Time().Format(time.DateTime))
|
|
||||||
stdout(evt.Content)
|
|
||||||
}
|
}
|
||||||
// TODO: see what to do about this
|
|
||||||
|
|
||||||
return nil
|
prefix := strings.TrimSpace(c.Args().Get(1))
|
||||||
|
if prefix == "" {
|
||||||
|
if len(topics) == 0 {
|
||||||
|
log("no forum topics found\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
for _, evt := range topics {
|
||||||
|
wg.Go(func() {
|
||||||
|
sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for _, evt := range topics {
|
||||||
|
id := evt.ID.Hex()
|
||||||
|
date := evt.CreatedAt.Time().Format(time.DateOnly)
|
||||||
|
author := authorPreview(ctx, evt.PubKey)
|
||||||
|
subject := forumSubjectPreview(evt, 72)
|
||||||
|
if subject == "" {
|
||||||
|
subject = "<untitled>"
|
||||||
|
}
|
||||||
|
stdout(color.CyanString(id[:6]), color.HiBlackString(date), color.HiBlueString(author), color.HiWhiteString(subject))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
evt, err := findEventByPrefix(topics, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return showThreadWithComments(ctx, []string{relay}, evt, "", nostr.TagMap{"h": []string{identifier}})
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Usage: "edit and send a forum topic event (kind 11)",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupMeta, err := fetchGroupMetadata(ctx, relay, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groupName := groupMeta.Name
|
||||||
|
if groupName == "" {
|
||||||
|
groupName = identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to gather keyer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := editWithDefaultEditor(
|
||||||
|
"nak-group-forum/NOTES_EDITMSG",
|
||||||
|
strings.TrimSpace(fmt.Sprintf(`# creating as '%s' ('%s')
|
||||||
|
# creating forum topic in group '%s' ('%s''%s')
|
||||||
|
# the first line will be used as the topic title
|
||||||
|
topic title here
|
||||||
|
|
||||||
|
# the remaining lines will be the body
|
||||||
|
write your forum post
|
||||||
|
|
||||||
|
# lines starting with '#' are ignored
|
||||||
|
`, selfName, selfNpub, groupName, relay, identifier)),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
title, body, err := parseForumCreateContent(content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := nostr.Event{
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Kind: 11,
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
nostr.Tag{"h", identifier},
|
||||||
|
nostr.Tag{"title", title},
|
||||||
|
},
|
||||||
|
Content: body,
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign forum topic event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sys.Pool.EnsureRelay(relay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Publish(ctx, evt)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "comment",
|
||||||
|
Usage: "comment on a forum topic with a NIP-22 comment event",
|
||||||
|
ArgsUsage: "<relay>'<identifier> <id-prefix>",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
relay, identifier, err := parseGroupIdentifier(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := strings.TrimSpace(c.Args().Get(1))
|
||||||
|
if prefix == "" {
|
||||||
|
return fmt.Errorf("missing forum topic id prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to gather keyer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
topics, err := fetchGroupForumTopics(ctx, relay, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
topic, err := findEventByPrefix(topics, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupMeta, err := fetchGroupMetadata(ctx, relay, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groupName := groupMeta.Name
|
||||||
|
if groupName == "" {
|
||||||
|
groupName = identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := forumSubjectPreview(topic, 72)
|
||||||
|
if subject == "" {
|
||||||
|
subject = "<untitled>"
|
||||||
|
}
|
||||||
|
pm := sys.FetchProfileMetadata(ctx, topic.PubKey)
|
||||||
|
headerLines := []string{
|
||||||
|
fmt.Sprintf("commenting as '%s' ('%s')", selfName, selfNpub),
|
||||||
|
fmt.Sprintf("commenting on forum topic '%s' '%s' by '%s' ('%s') in group '%s' ('%s''%s')", topic.ID.Hex()[:6], subject, pm.ShortName(), pm.NpubShort(), groupName, relay, identifier),
|
||||||
|
}
|
||||||
|
|
||||||
|
comments, err := fetchThreadComments(ctx, []string{relay}, topic.ID, nostr.TagMap{"h": []string{identifier}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
edited, err := editWithDefaultEditor(
|
||||||
|
"nak-group-forum-reply/NOTES_EDITMSG",
|
||||||
|
threadReplyEditorTemplate(ctx, headerLines, topic, comments),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content, parentEvt, err := parseThreadReplyContent(topic, comments, edited)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rootRelay := relay
|
||||||
|
if topic.Relay.URL != "" {
|
||||||
|
rootRelay = topic.Relay.URL
|
||||||
|
}
|
||||||
|
parentRelay := rootRelay
|
||||||
|
if parentEvt.Relay.URL != "" {
|
||||||
|
parentRelay = parentEvt.Relay.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := nostr.Event{
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Kind: 1111,
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
nostr.Tag{"E", topic.ID.Hex(), rootRelay},
|
||||||
|
nostr.Tag{"e", parentEvt.ID.Hex(), parentRelay},
|
||||||
|
nostr.Tag{"P", topic.PubKey.Hex()},
|
||||||
|
nostr.Tag{"p", parentEvt.PubKey.Hex()},
|
||||||
|
nostr.Tag{"h", identifier},
|
||||||
|
},
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign forum comment event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := sys.Pool.EnsureRelay(relay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Publish(ctx, evt)
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -747,6 +951,72 @@ func fetchGroupMetadata(ctx context.Context, relay string, identifier string) (n
|
|||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchGroupForumTopics(ctx context.Context, relay string, identifier string) ([]nostr.RelayEvent, error) {
|
||||||
|
topics := make([]nostr.RelayEvent, 0, 30)
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{11},
|
||||||
|
Tags: nostr.TagMap{"h": []string{identifier}},
|
||||||
|
Limit: 500,
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||||
|
topics = append(topics, ie)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(topics, nostr.CompareRelayEvent)
|
||||||
|
return topics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func forumSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
|
||||||
|
if tag := evt.Tags.Find("title"); len(tag) >= 2 {
|
||||||
|
subject := strings.TrimSpace(tag[1])
|
||||||
|
if subject != "" {
|
||||||
|
return clampWithEllipsis(subject, maxChars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag := evt.Tags.Find("subject"); len(tag) >= 2 {
|
||||||
|
subject := strings.TrimSpace(tag[1])
|
||||||
|
if subject != "" {
|
||||||
|
return clampWithEllipsis(subject, maxChars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(evt.Content, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
return clampWithEllipsis(line, maxChars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseForumCreateContent(content string) (title string, body string, err error) {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var bodyb strings.Builder
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
title = line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyb.WriteString(line)
|
||||||
|
bodyb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
return "", "", fmt.Errorf("topic title cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
body = strings.TrimSpace(bodyb.String())
|
||||||
|
return title, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
func checkRelayLivekitMetadataSupport(ctx context.Context, relay string) error {
|
func checkRelayLivekitMetadataSupport(ctx context.Context, relay string) error {
|
||||||
url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit"
|
url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit"
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
|||||||
321
thread_helpers.go
Normal file
321
thread_helpers.go
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip22"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fetchThreadComments(ctx context.Context, relays []string, discussionID nostr.ID, extraTags nostr.TagMap) ([]nostr.RelayEvent, error) {
|
||||||
|
filterTags := nostr.TagMap{
|
||||||
|
"E": []string{discussionID.Hex()},
|
||||||
|
}
|
||||||
|
for key, values := range extraTags {
|
||||||
|
filterTags[key] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
comments := make([]nostr.RelayEvent, 0, 15)
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{1111},
|
||||||
|
Tags: filterTags,
|
||||||
|
Limit: 500,
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-thread"}) {
|
||||||
|
comments = append(comments, ie)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(comments, nostr.CompareRelayEvent)
|
||||||
|
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func showThreadWithComments(
|
||||||
|
ctx context.Context,
|
||||||
|
relays []string,
|
||||||
|
evt nostr.RelayEvent,
|
||||||
|
status string,
|
||||||
|
extraTags nostr.TagMap,
|
||||||
|
) error {
|
||||||
|
comments, err := fetchThreadComments(ctx, relays, evt.ID, extraTags)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
printThreadMetadata(ctx, os.Stdout, evt, status, true)
|
||||||
|
stdout("")
|
||||||
|
stdout(evt.Content)
|
||||||
|
|
||||||
|
if len(comments) > 0 {
|
||||||
|
stdout("")
|
||||||
|
stdout(color.CyanString("comments:"))
|
||||||
|
printThreadedComments(ctx, os.Stdout, comments, evt.ID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printThreadedComments(
|
||||||
|
ctx context.Context,
|
||||||
|
w io.Writer,
|
||||||
|
comments []nostr.RelayEvent,
|
||||||
|
discussionID nostr.ID,
|
||||||
|
withColor bool,
|
||||||
|
) {
|
||||||
|
byID := make(map[nostr.ID]struct{}, len(comments)+1)
|
||||||
|
byID[discussionID] = struct{}{}
|
||||||
|
for _, c := range comments {
|
||||||
|
byID[c.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preload metadata from everybody
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
|
||||||
|
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
|
||||||
|
for _, c := range comments {
|
||||||
|
wg.Go(func() {
|
||||||
|
sys.FetchProfileMetadata(ctx, c.PubKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := byID[parent.ID]; ok {
|
||||||
|
children[parent.ID] = append(children[parent.ID], c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for parent := range children {
|
||||||
|
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
var render func(parent nostr.ID, depth int)
|
||||||
|
render = func(parent nostr.ID, depth int) {
|
||||||
|
for _, c := range children[parent] {
|
||||||
|
indent := strings.Repeat(" ", depth)
|
||||||
|
author := authorPreview(ctx, c.PubKey)
|
||||||
|
created := c.CreatedAt.Time().Format(time.DateTime)
|
||||||
|
|
||||||
|
if withColor {
|
||||||
|
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(c.Content, "\n") {
|
||||||
|
fmt.Fprintln(w, indent+" "+line)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, indent+"")
|
||||||
|
|
||||||
|
render(c.ID, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(discussionID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
|
||||||
|
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
||||||
|
if prefix == "" {
|
||||||
|
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
matchCount := 0
|
||||||
|
matched := nostr.RelayEvent{}
|
||||||
|
for _, evt := range events {
|
||||||
|
if strings.HasPrefix(evt.ID.Hex(), prefix) {
|
||||||
|
matched = evt
|
||||||
|
matchCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchCount == 0 {
|
||||||
|
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
|
||||||
|
}
|
||||||
|
if matchCount > 1 {
|
||||||
|
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printThreadMetadata(
|
||||||
|
ctx context.Context,
|
||||||
|
w io.Writer,
|
||||||
|
evt nostr.RelayEvent,
|
||||||
|
status string,
|
||||||
|
withColors bool,
|
||||||
|
) {
|
||||||
|
label := func(s string) string { return s }
|
||||||
|
value := func(s string) string { return s }
|
||||||
|
statusValue := func(s string) string { return s }
|
||||||
|
if withColors {
|
||||||
|
label = func(s string) string { return color.CyanString(s) }
|
||||||
|
value = func(s string) string { return color.HiWhiteString(s) }
|
||||||
|
statusValue = colorizeGitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
|
||||||
|
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
|
||||||
|
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
|
||||||
|
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
|
||||||
|
if status != "" {
|
||||||
|
fmt.Fprintln(w, label("status:"), statusValue(status))
|
||||||
|
}
|
||||||
|
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
|
||||||
|
fmt.Fprintln(w, label("subject:"), value(subject[1]))
|
||||||
|
fmt.Fprintln(w, "")
|
||||||
|
} else if title := evt.Tags.Find("title"); title != nil && len(title) >= 2 {
|
||||||
|
fmt.Fprintln(w, label("title:"), value(title[1]))
|
||||||
|
fmt.Fprintln(w, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseThreadReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
|
||||||
|
currentParent := discussion
|
||||||
|
selectedParent := nostr.ZeroID
|
||||||
|
inComments := false
|
||||||
|
|
||||||
|
replyb := strings.Builder{}
|
||||||
|
for _, line := range strings.Split(edited, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
|
||||||
|
inComments = false
|
||||||
|
currentParent = discussion
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if replyb.Len() == 0 && line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "#>") {
|
||||||
|
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
|
||||||
|
if quoted == "comments:" {
|
||||||
|
inComments = true
|
||||||
|
currentParent = discussion
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep track of which comment the reply body shows up below of
|
||||||
|
// so we can assign it as a reply to that specifically
|
||||||
|
fields := strings.Fields(quoted)
|
||||||
|
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
|
||||||
|
currId := fields[0][1 : len(fields[0])-1]
|
||||||
|
for _, comment := range comments {
|
||||||
|
if strings.HasPrefix(comment.ID.Hex(), currId) {
|
||||||
|
currentParent = comment
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we reach here this is a line for the reply input from the user
|
||||||
|
replyb.WriteString(line)
|
||||||
|
replyb.WriteByte('\n')
|
||||||
|
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
|
||||||
|
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedParent = currentParent.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(replyb.String())
|
||||||
|
if content == "" {
|
||||||
|
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
|
||||||
|
return content, discussion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, comment := range comments {
|
||||||
|
if comment.ID == selectedParent {
|
||||||
|
return content, comment, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("selected reply parent not found (this never happens)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadReplyEditorTemplate(ctx context.Context, headerLines []string, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
|
||||||
|
lines := make([]string, 0, len(headerLines)+3)
|
||||||
|
for _, line := range headerLines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, "# "+strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
"# write your reply here.",
|
||||||
|
"# lines starting with '#' are ignored.",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
appender := &lineAppender{lines, "#> "}
|
||||||
|
|
||||||
|
printThreadMetadata(ctx, appender, discussion, "", false)
|
||||||
|
|
||||||
|
for _, line := range strings.Split(discussion.Content, "\n") {
|
||||||
|
appender.lines = append(appender.lines, "#> "+line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(comments) > 0 {
|
||||||
|
appender.lines = append(appender.lines, "#> ", "#> comments:")
|
||||||
|
printThreadedComments(ctx, appender, comments, discussion.ID, false)
|
||||||
|
appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(appender.lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyerIdentity(ctx context.Context, kr nostr.Keyer) (nostr.PubKey, string, string, error) {
|
||||||
|
pk, err := kr.GetPublicKey(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nostr.ZeroPK, "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, pk)
|
||||||
|
return pk, meta.ShortName(), meta.NpubShort(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
|
||||||
|
meta := sys.FetchProfileMetadata(ctx, pubkey)
|
||||||
|
if meta.Name != "" {
|
||||||
|
return meta.ShortName() + " (" + meta.NpubShort() + ")"
|
||||||
|
}
|
||||||
|
return meta.NpubShort()
|
||||||
|
}
|
||||||
|
|
||||||
|
type lineAppender struct {
|
||||||
|
lines []string
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lineAppender) Write(b []byte) (int, error) {
|
||||||
|
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
|
||||||
|
line = strings.TrimRight(line, " ")
|
||||||
|
l.lines = append(l.lines, l.prefix+line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user