Skip to content

Commit

Permalink
add thread view
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkr committed Oct 17, 2023
1 parent 0063bd5 commit 84a388d
Show file tree
Hide file tree
Showing 6 changed files with 422 additions and 5 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ or, to build a static executable:
* /hashtags shows a list of popular hashtags.
* /stats shows statistics and server health metrics.

* /view shows a complete post with extra details like links in the post, a list mentioned users, a list of hashtags, a link to the author's outbox, a list of replies and a link to the parent post (if any).
* /view shows a complete post with extra details like links in the post, a list mentioned users, a list of hashtags, a link to the author's outbox, a list of replies and a link to the parent post (if found).
* /thread displays a tree of replies in a thread.
* /outbox shows list of posts by a user.

Users are authenticated using TLS client certificates; see [Gemini protocol specification](https://gemini.circumlunar.space/docs/specification.html) for more details. The following pages require authentication:
Expand Down Expand Up @@ -93,6 +94,7 @@ Some clients generate a certificate for / (all pages of this capsule) when /foo
* /users/hashtags
* /users/stats
* /users/view
* /users/thread

This way, users who prefer not to provide a client certificate when browsing to /x can reply to public posts by using /users/x instead.

Expand Down
3 changes: 3 additions & 0 deletions front/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ func NewHandler() Handler {
h[regexp.MustCompile(`^/view/[0-9a-f]{64}$`)] = withUserMenu(view)
h[regexp.MustCompile(`^/users/view/[0-9a-f]{64}$`)] = withUserMenu(view)

h[regexp.MustCompile(`^/thread/[0-9a-f]{64}$`)] = withUserMenu(thread)
h[regexp.MustCompile(`^/users/thread/[0-9a-f]{64}$`)] = withUserMenu(thread)

h[regexp.MustCompile(`^/users/whisper$`)] = whisper
h[regexp.MustCompile(`^/users/say$`)] = say
h[regexp.MustCompile(`^/users/dm/[0-9a-f]{64}`)] = dm
Expand Down
150 changes: 150 additions & 0 deletions front/thread.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
Copyright 2023 Dima Krasner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package front

import (
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"github.com/dimkr/tootik/ap"
"github.com/dimkr/tootik/front/text"
"path/filepath"
"strings"
)

type threadNode struct {
Depth int
PostID, Inserted, AuthorUserName string
}

func thread(w text.Writer, r *request) {
hash := filepath.Base(r.URL.Path)

offset, err := getOffset(r.URL)
if err != nil {
r.Log.Info("Failed to parse query", "error", err)
w.Status(40, "Invalid query")
return
}

r.Log.Info("Viewing thread", "hash", hash)

var threadHead sql.NullString
if err := r.QueryRow(`with recursive thread(id, parent) as (select notes.id, notes.object->>'inReplyTo' as parent from notes where hash = ? union select notes.id, notes.object->>'inReplyTo' as parent from thread t join notes on notes.id = t.parent) select thread.id from thread where thread.parent is null limit 1`, hash).Scan(&threadHead); err != nil && !errors.Is(err, sql.ErrNoRows) {
r.Log.Warn("Failed to fetch thread head", "error", err)
w.Error()
return
}

var rootAuthorID, rootAuthorType, rootAuthorUsername string
var rootAuthorName sql.NullString
if err := r.QueryRow(`select persons.id, persons.actor->>'type', persons.actor->>'preferredUsername', persons.actor->>'name' from notes join persons on persons.id = notes.author where notes.hash = ?`, hash).Scan(&rootAuthorID, &rootAuthorType, &rootAuthorUsername, &rootAuthorName); err != nil && !errors.Is(err, sql.ErrNoRows) {
r.Log.Warn("Failed to fetch thread root author", "error", err)
w.Error()
return
}

rows, err := r.Query(`select thread.depth, thread.id, strftime('%Y-%m-%d', datetime(thread.inserted, 'unixepoch')), persons.actor->>'preferredUsername' from (with recursive thread(id, author, inserted, parent, depth, path) as (select notes.id, notes.author, notes.inserted, object->>'inReplyTo' as parent, 0 as depth, notes.id as path from notes where hash = $1 union select notes.id, notes.author, notes.inserted, notes.object->>'inReplyTo', t.depth + 1, t.path || ';' || notes.id from thread t join notes on notes.object->>'inReplyTo' = t.id where t.depth <= 5) select thread.depth, thread.id, thread.author, thread.inserted, thread.path from thread order by thread.path limit $2 offset $3) thread join persons on persons.id = thread.author order by thread.path`, hash, postsPerPage, offset)
if err != nil {
r.Log.Info("Failed to fetch thread", "hash", hash, "error", err)
w.Status(40, "Post not found")
return
}
defer rows.Close()

count := 0
nodes := make([]threadNode, postsPerPage)

for rows.Next() {
if err := rows.Scan(
&nodes[count].Depth,
&nodes[count].PostID,
&nodes[count].Inserted,
&nodes[count].AuthorUserName,
); err != nil {
r.Log.Info("Failed to scan post", "hash", hash, "error", err)
continue
}

count++
}
rows.Close()

if count == 0 {
r.Log.Info("Failed to fetch any nodes in thread", "hash", hash)
w.Error()
return
}

w.OK()

var displayName string
if rootAuthorName.Valid {
displayName = getDisplayName(rootAuthorID, rootAuthorUsername, rootAuthorName.String, ap.ActorType(rootAuthorType), r.Log)
} else {
displayName = getDisplayName(rootAuthorID, rootAuthorUsername, "", ap.ActorType(rootAuthorType), r.Log)
}
w.Titlef("🧵 Replies to %s", displayName)

for _, node := range nodes[:count] {
var b strings.Builder
b.WriteString(node.Inserted)
b.WriteByte(' ')
if node.Depth > 0 {
for i := 0; i < node.Depth; i++ {
b.WriteRune('·')
}
b.WriteByte(' ')
}
b.WriteString(node.AuthorUserName)

if r.User == nil {
w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(node.PostID))), b.String())
} else {
w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(node.PostID))), b.String())
}
}

if (threadHead.Valid && count > 0 && threadHead.String != nodes[0].PostID) || offset >= postsPerPage || count == postsPerPage {
w.Separator()
}

if threadHead.Valid && count > 0 && threadHead.String != nodes[0].PostID && r.User == nil {
w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(threadHead.String))), "View first post in thread")
} else if threadHead.Valid && count > 0 && threadHead.String != nodes[0].PostID {
w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(threadHead.String))), "View first post in thread")
}

if offset > postsPerPage && r.User == nil {
w.Link("/thread/"+hash, "First page")
} else if offset > postsPerPage {
w.Link("/users/thread/"+hash, "First page")
}

if offset >= postsPerPage && r.User == nil {
w.Linkf(fmt.Sprintf("/thread/%s?%d", hash, offset-postsPerPage), "Previous page (%d-%d)", offset-postsPerPage, offset)
} else if offset >= postsPerPage {
w.Linkf(fmt.Sprintf("/users/thread/%s?%d", hash, offset-postsPerPage), "Previous page (%d-%d)", offset-postsPerPage, offset)
}

if count == postsPerPage && r.User == nil {
w.Linkf(fmt.Sprintf("/thread/%s?%d", hash, offset+postsPerPage), "Next page (%d-%d)", offset+postsPerPage, offset+2*postsPerPage)
} else if count == postsPerPage {
w.Linkf(fmt.Sprintf("/users/thread/%s?%d", hash, offset+postsPerPage), "Next page (%d-%d)", offset+postsPerPage, offset+2*postsPerPage)
}
}
30 changes: 26 additions & 4 deletions front/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,20 +162,42 @@ func view(w text.Writer, r *request) {
r.PrintNotes(w, replies, true, false)

var originalPostExists int
var threadHead sql.NullString
if note.InReplyTo != "" {
if err := r.QueryRow(`select exists (select 1 from notes where id = ?)`, note.InReplyTo).Scan(&originalPostExists); err != nil {
r.Log.Warn("Failed to check if original post exists", "error", err)
r.Log.Warn("Failed to check if parent post exists", "error", err)
}

if err := r.QueryRow(`with recursive thread(id, parent, depth) as (select notes.id, notes.object->>'inReplyTo' as parent, 1 as depth from notes where id = ? union select notes.id, notes.object->>'inReplyTo' as parent, t.depth + 1 from thread t join notes on notes.id = t.parent) select id from thread order by depth desc limit 1`, note.InReplyTo).Scan(&threadHead); err != nil {
r.Log.Warn("Failed to fetch first post in thread", "error", err)
}
}

var threadDepth int
if err := r.QueryRow(`with recursive thread(id, depth) as (select notes.id, 0 as depth from notes where id = ? union select notes.id, t.depth + 1 from thread t join notes on notes.object->>'inReplyTo' = t.id where t.depth <= 2) select max(thread.depth) from thread`, note.ID).Scan(&threadDepth); err != nil {
r.Log.Warn("Failed to query thread depth", "error", err)
}

if originalPostExists == 1 || offset >= repliesPerPage || count == repliesPerPage {
if originalPostExists == 1 || threadHead.Valid || threadDepth > 1 || offset >= repliesPerPage || count == repliesPerPage {
w.Separator()
}

if originalPostExists == 1 && r.User == nil {
w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(note.InReplyTo))), "View original post")
w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(note.InReplyTo))), "View parent post")
} else if originalPostExists == 1 {
w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(note.InReplyTo))), "View original post")
w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(note.InReplyTo))), "View parent post")
}

if threadHead.Valid && threadHead.String != note.ID && r.User == nil {
w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(threadHead.String))), "View first post in thread")
} else if threadHead.Valid && threadHead.String != note.ID {
w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(threadHead.String))), "View first post in thread")
}

if threadDepth > 1 && r.User == nil {
w.Link("/thread/"+hash, "View thread")
} else if threadDepth > 1 {
w.Link("/users/thread/"+hash, "View thread")
}

if offset > repliesPerPage && r.User == nil {
Expand Down
18 changes: 18 additions & 0 deletions migrations/008_thread.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package migrations

import (
"context"
"database/sql"
)

func thread(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `CREATE INDEX notesidinreplytoauthorinserted ON notes(id, object->>'inReplyTo', author, inserted)`); err != nil {
return err
}

if _, err := tx.ExecContext(ctx, `DROP INDEX notesinreplyto`); err != nil {
return err
}

return nil
}
Loading

0 comments on commit 84a388d

Please sign in to comment.