PathrockNetwork Gno Explorer
HomeBlocksTransactionsTokensRealmsPackagesValidatorsAnalytics

PathrockNetwork Gno Explorer — an independent explorer for Gno.land Mainnet (gnoland-1), operated by PathrockNetwork. Not an official Gno.land service.

gnowebarchive RPC

gno.land/p/moul/x/social/threads/v0

Package
Open in gnoweb ↗

Overview

Kind
Pure package
Name
v0
Namespace
moul / x / social / threads
Files
4 (README)(gnomod.toml)
Exported functions
n/a — not supported for pure packages by the node (vm/qfuncs)
Module
gno.land/p/moul/x/social/threads/v0
gno
0.9

Files (4)

  • README.mdmarkdown
  • gnomod.tomltoml
  • threads.gnogno
  • threads_test.gnogno
threads.gnogno
1// Package threads is the engine behind an embeddable discussion block: the2// text half of what a reaction bar does, keyed on the PAGE it is shown under3// rather than on the realm that stores it.4//5// # Why not a forum6//7// A forum is a destination, and a destination has to earn its traffic before8// anybody writes the first post. A block does not: it is dropped into pages9// that already have readers, and the discussion attaches to the object it is10// about. One realm holds every thread, every host realm ships the same two11// lines, and no host realm stores anything.12//13// That is the shape the web settled on for comments in 2010, and the same one14// [gno.land/p/moul/reactions] uses for the tally. This package is deliberately15// its neighbour: reactions are a closed palette and need no moderation, text16// needs some, and the two are separate so a realm can take the cheap one alone.17//18// # The model19//20//	Board    every thread, across every page21//	Thread   a root post: page, author, body, height, pin, replies22//	Reply    an author, a body and a height, and nothing else23//24// A thread remembers the set of addresses that have replied to it, which is25// what lets a realm implement "earned by being replied to" without counting26// one person twice. [Board.Reply] reports whether the replier was new, so the27// mint rule has exactly one signal and exactly one call site.28//29// # Ordering, and the pin30//31// [Board.List] returns pinned threads first, then the rest, both newest first,32// which is a partition and not a sort: no comparator, no tie to break, and the33// same input always produces the same page. A pin is an absolute height, so it34// expires on its own and nothing has to be swept.35//36// The realm decides what a pin costs. This package only enforces that a pin37// cannot be moved backwards, so buying one does not shorten somebody else's.38//39// # Page keys and bodies40//41// A page key is a package path, the full one, chain domain included.42// [ValidPage] bounds it to a path-shaped lowercase ASCII string of at most43// [MaxPageLen] bytes, so a stored key can never be a markdown payload. A body44// is bounded by [MaxBodyLen] and rejected when it carries a control character,45// but it is otherwise free text and therefore attacker-controlled markdown:46// everything here escapes it with ui.Inline or ui.Cell, and so must any realm47// that renders one itself.48package threads4950import (51	"errors"52	"strconv"53	"strings"5455	"gno.land/p/moul/kit/store/v0"56	"gno.land/p/moul/kit/ui/v0"57	"gno.land/p/moul/md/v0"58)5960const (61	// MaxPageLen is the longest page key accepted, matching62	// gno.land/p/moul/reactions so the two blocks agree on what a page is.63	MaxPageLen = 1206465	// MaxBodyLen is the longest post or reply accepted, in bytes. Long66	// enough for a real comment, short enough that one call cannot lock an67	// unbounded storage deposit somebody else is paying for.68	MaxBodyLen = 10006970	// ExcerptLen is how much of a body a listing shows.71	ExcerptLen = 6072)7374// The errors a caller can get back. A p/ returns them; the realm decides to75// abort.76var (77	ErrBadPage   = errors.New("threads: not a page key")78	ErrBadBody   = errors.New("threads: body is empty, too long, or has control characters")79	ErrNoThread  = errors.New("threads: no such thread")80	ErrPinIsPast = errors.New("threads: a pin cannot be moved backwards")81)8283// Reply is one answer under a thread.84type Reply struct {85	Author address86	Body   string87	At     int64 // block height88}8990// Thread is a root post and everything under it.91type Thread struct {92	Page    string93	Author  address94	Body    string95	At      int64 // block height96	Replies []Reply9798	// PinnedUntil is the height the thread stops being pinned at. Zero is99	// never pinned, and a past height is an expired pin: nothing has to100	// sweep it.101	PinnedUntil int64102103	// repliers is the set of addresses that have replied, so a thread can104	// be scored on people rather than on messages.105	repliers map[string]bool106}107108// Pinned reports whether the thread is pinned at height now.109func (t *Thread) Pinned(now int64) bool { return t != nil && t.PinnedUntil > now }110111// Repliers is how many distinct addresses have replied.112func (t *Thread) Repliers() int {113	if t == nil {114		return 0115	}116	return len(t.repliers)117}118119// HasReplied reports whether who has already replied to this thread.120func (t *Thread) HasReplied(who address) bool {121	if t == nil {122		return false123	}124	return t.repliers[who.String()]125}126127// Board holds every thread, indexed by the page it was posted under.128type Board struct {129	threads *store.Store130	pages   map[string][]store.ID // page key -> ids, oldest first131}132133// NewBoard returns an empty board.134func NewBoard() *Board {135	return &Board{threads: store.Named("thread"), pages: map[string][]store.ID{}}136}137138// Post opens a thread on page and returns its id.139func (b *Board) Post(page string, author address, body string, at int64) (store.ID, error) {140	if !ValidPage(page) {141		return 0, ErrBadPage142	}143	if !ValidBody(body) {144		return 0, ErrBadBody145	}146	id := b.threads.Add(&Thread{147		Page:     page,148		Author:   author,149		Body:     body,150		At:       at,151		repliers: map[string]bool{},152	})153	b.pages[page] = append(b.pages[page], id)154	return id, nil155}156157// Reply appends to a thread and reports whether this author had never replied158// to it before.159//160// That boolean is the mint signal: a realm that pays an author for attention161// wants distinct people, not distinct messages, and a thread's own author162// replying to themselves is never new.163func (b *Board) Reply(id store.ID, author address, body string, at int64) (isNewReplier bool, err error) {164	t, ok := b.Get(id)165	if !ok {166		return false, ErrNoThread167	}168	if !ValidBody(body) {169		return false, ErrBadBody170	}171	t.Replies = append(t.Replies, Reply{Author: author, Body: body, At: at})172173	key := author.String()174	if author == t.Author || t.repliers[key] {175		return false, nil176	}177	t.repliers[key] = true178	return true, nil179}180181// Pin keeps a thread at the top of its page until height until.182//183// It refuses to move a pin backwards, so a cheap pin cannot cut short an184// expensive one, and extends from whichever is later: the current pin or now.185func (b *Board) Pin(id store.ID, until int64) error {186	t, ok := b.Get(id)187	if !ok {188		return ErrNoThread189	}190	if until <= t.PinnedUntil {191		return ErrPinIsPast192	}193	t.PinnedUntil = until194	return nil195}196197// Get returns a thread by id.198func (b *Board) Get(id store.ID) (*Thread, bool) {199	v, ok := b.threads.Get(id)200	if !ok {201		return nil, false202	}203	return v.(*Thread), true204}205206// Len is how many threads exist, across every page.207func (b *Board) Len() int { return b.threads.Len() }208209// Pages is how many pages have ever been posted on.210func (b *Board) Pages() int { return len(b.pages) }211212// PageLen is how many threads a page holds.213func (b *Board) PageLen(page string) int { return len(b.pages[page]) }214215// Listing is one row of [Board.List]: the thread and the id a link needs.216type Listing struct {217	ID     store.ID218	Thread *Thread219}220221// List returns up to limit threads on page: pinned first, then the rest, both222// newest first.223//224// It is a partition and not a sort. There is no comparator and no tie to225// break, so two identical calls always produce the same page, which is what a226// Render needs. limit <= 0 returns everything.227func (b *Board) List(page string, now int64, limit int) []Listing {228	ids := b.pages[page]229	var pinned, rest []Listing230	for i := len(ids) - 1; i >= 0; i-- {231		t, ok := b.Get(ids[i])232		if !ok {233			continue234		}235		item := Listing{ID: ids[i], Thread: t}236		if t.Pinned(now) {237			pinned = append(pinned, item)238		} else {239			rest = append(rest, item)240		}241	}242	out := append(pinned, rest...)243	if limit > 0 && len(out) > limit {244		out = out[:limit]245	}246	return out247}248249// Recent returns up to limit threads from every page, newest first. It is what250// the hosting realm's own homepage shows.251func (b *Board) Recent(limit int) []Listing {252	var out []Listing253	for _, e := range b.threads.PageReverse(1, limit) {254		out = append(out, Listing{ID: e.ID, Thread: e.Value.(*Thread)})255	}256	return out257}258259// ValidPage reports whether page is a usable page key: path-shaped, lowercase260// ASCII, no leading, trailing or doubled slash, at most [MaxPageLen] bytes.261//262// Same rule as gno.land/p/moul/reactions, deliberately: a realm embedding both263// blocks passes one key to both.264func ValidPage(page string) bool {265	if page == "" || len(page) > MaxPageLen {266		return false267	}268	if strings.HasPrefix(page, "/") || strings.HasSuffix(page, "/") ||269		strings.Contains(page, "//") || !strings.Contains(page, "/") {270		return false271	}272	for i := 0; i < len(page); i++ {273		c := page[i]274		switch {275		case c >= 'a' && c <= 'z', c >= '0' && c <= '9':276		case c == '/' || c == '.' || c == '-' || c == '_' || c == ':':277		default:278			return false279		}280	}281	return true282}283284// ValidBody reports whether body can be stored: non-empty after trimming,285// within [MaxBodyLen], and free of control characters.286//287// Control characters are refused rather than stripped because a body is shown288// back to its author: silently rewriting what somebody wrote is worse than289// telling them it was refused. Everything else is allowed and escaped at290// render time, since a validator and an escaper protect against different291// mistakes.292func ValidBody(body string) bool {293	if len(body) > MaxBodyLen || strings.TrimSpace(body) == "" {294		return false295	}296	for i := 0; i < len(body); i++ {297		c := body[i]298		if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f {299			return false300		}301	}302	return true303}304305// Block renders the embeddable widget: the page's threads, then the button306// that opens a new one.307//308// realmPath is the realm that owns Post and Reply, given as the full package309// path from its gnomod.toml module line. It is a parameter and not a constant310// because this package is the engine and not the deployment.311func Block(realmPath, page string, items []Listing, total int, now int64) string {312	out := ""313	if len(items) == 0 {314		out += ui.Empty("No discussion yet.")315	} else {316		t := ui.NewTable("", "#", "thread", "by", "replies")317		for _, it := range items {318			mark := ""319			if it.Thread.Pinned(now) {320				mark = "📌"321			}322			t.Row(323				mark,324				// The link title is the id and never the body. md.Link325				// escapes markdown but NOT a pipe (sanitize.InlineText326				// leaves it; only ui.Cell rewrites it), so a body used as327				// a link title inside a table opens a column.328				md.Link("#"+it.ID.String(), ThreadURL(realmPath, it.ID)),329				// Cut first, escape second: the other order can strand a330				// trailing backslash that escapes the chrome after it.331				ui.Cell(ui.ShortN(it.Thread.Body, ExcerptLen, 0)),332				ui.Addr(it.Thread.Author),333				strconv.Itoa(len(it.Thread.Replies)),334			)335		}336		out += t.String()337	}338339	out += "\n" + ui.ActionIn(realmPath, "💬 Post", "Post", "page", page, "body", "")340	if total > len(items) {341		out += " · " + md.Link(342			strconv.Itoa(total)+" threads",343			PageURL(realmPath, page),344		)345	}346	return out + "\n"347}348349// ThreadURL is the gnoweb path of one thread on the hosting realm.350func ThreadURL(realmPath string, id store.ID) string {351	return RealmURL(realmPath) + ":thread/" + id.String()352}353354// PageURL is the gnoweb path of a page's full thread list.355func PageURL(realmPath, page string) string {356	return RealmURL(realmPath) + ":page/" + page357}358359// RealmURL is the gnoweb path of a realm given as a package path.360//361// The chain domain is the first element of a package path and a gnoweb path is362// the rest of it, so this is a prefix strip and not a hostname this package363// has to know.364func RealmURL(realmPath string) string {365	if i := strings.Index(realmPath, "/"); i >= 0 {366		return realmPath[i:]367	}368	return "/" + realmPath369}370

Functions

not supported for pure packages by the node (vm/qfuncs)

Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.