diff --git a/git.go b/git.go index 7a86a82..9602f35 100644 --- a/git.go +++ b/git.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -15,7 +14,6 @@ import ( "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip19" - "fiatjaf.com/nostr/nip22" "fiatjaf.com/nostr/nip34" "github.com/AlecAivazis/survey/v2" "github.com/fatih/color" @@ -891,7 +889,7 @@ aside from those, there is also: 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{ @@ -1205,7 +1203,7 @@ aside from those, there is also: 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{ @@ -1218,6 +1216,11 @@ aside from those, there is also: 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() if err != nil { return err @@ -1225,7 +1228,8 @@ aside from those, there is also: content, err := editWithDefaultEditor( "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 everything is broken @@ -1233,7 +1237,7 @@ everything is broken please fix # lines starting with '#' are ignored -`), +`, selfName, selfNpub, repo.ID)), true, ) if err != nil { @@ -1569,47 +1573,6 @@ func fetchIssueStatus( 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( ctx context.Context, c *cli.Command, @@ -1627,6 +1590,11 @@ func gitDiscussionReply( 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() if err != nil { return err @@ -1642,21 +1610,31 @@ func gitDiscussionReply( return err } - comments, err := fetchGitDiscussionComments(ctx, repo, discussionEvt.ID) + comments, err := fetchThreadComments(ctx, repo.Relays, discussionEvt.ID, nil) if err != nil { return err } + subject := subjectPreview(discussionEvt, 72) + if subject == "" { + subject = "" + } + 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( fmt.Sprintf("nak-git-%s-reply/NOTES_EDITMSG", discussionName), - gitDiscussionReplyEditorTemplate(ctx, discussionEvt, comments), + threadReplyEditorTemplate(ctx, headerLines, discussionEvt, comments), true, ) if err != nil { return err } - content, parentEvt, err := parseGitDiscussionReplyContent(discussionEvt, comments, edited) + content, parentEvt, err := parseThreadReplyContent(discussionEvt, comments, edited) if err != nil { return err } @@ -1789,122 +1767,6 @@ func ensureGitRepositoryMaintainer(ctx context.Context, kr nostr.Keyer, repo nip 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*`) 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 } -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 { statusEvt, ok := statuses[id] if !ok { @@ -2123,43 +1901,6 @@ func shortCommitID(commit string, n int) string { 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 { switch status { case "open": diff --git a/group.go b/group.go index bcbc175..1aa278e 100644 --- a/group.go +++ b/group.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "slices" "strconv" "strings" "sync" @@ -379,32 +380,235 @@ var group = &cli.Command{ }, { Name: "forum", - Usage: "read group forum posts", - Description: "access group forum functionality.", - ArgsUsage: "'", + Usage: "forum topic operations", + Description: "when called directly, lists forum topics; with an id prefix, displays that topic with threaded comments.", + ArgsUsage: "' [id-prefix]", Action: func(ctx context.Context, c *cli.Command) error { relay, identifier, err := parseGroupIdentifier(c) if err != nil { return err } - for evt := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{ - Kinds: []nostr.Kind{11}, - Tags: nostr.TagMap{"h": []string{identifier}}, - }, nostr.SubscriptionOptions{Label: "nak-nip29"}) { - title := evt.Tags.Find("title") - if title != nil { - stdout(colors.bold(title[1])) - } else { - stdout(colors.bold("")) - } - 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) + topics, err := fetchGroupForumTopics(ctx, relay, identifier) + if err != nil { + return err } - // 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 = "" + } + 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: "' ", + 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 = "" + } + 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 } +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 { url := "http" + nostr.NormalizeURL(relay)[2:] + "/.well-known/nip29/livekit" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/thread_helpers.go b/thread_helpers.go new file mode 100644 index 0000000..8d1485a --- /dev/null +++ b/thread_helpers.go @@ -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 +}