diff --git a/examples/gno.land/r/demo/boards2/board.gno b/examples/gno.land/r/demo/boards2/board.gno new file mode 100644 index 00000000000..79b27da84b2 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/board.gno @@ -0,0 +1,139 @@ +package boards + +import ( + "std" + "strconv" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/moul/txlink" +) + +//---------------------------------------- +// Board + +type BoardID uint64 + +func (bid BoardID) String() string { + return strconv.Itoa(int(bid)) +} + +type Board struct { + id BoardID // only set for public boards. + url string + name string + creator std.Address + threads avl.Tree // Post.id -> *Post + postsCtr uint64 // increments Post.id + createdAt time.Time + deleted avl.Tree // TODO reserved for fast-delete. +} + +func newBoard(id BoardID, url string, name string, creator std.Address) *Board { + if !reName.MatchString(name) { + panic("invalid name: " + name) + } + exists := gBoardsByName.Has(name) + if exists { + panic("board already exists") + } + return &Board{ + id: id, + url: url, + name: name, + creator: creator, + threads: avl.Tree{}, + createdAt: time.Now(), + deleted: avl.Tree{}, + } +} + +/* TODO support this once we figure out how to ensure URL correctness. +// A private board is not tracked by gBoards*, +// but must be persisted by the caller's realm. +// Private boards have 0 id and does not ping +// back the remote board on reposts. +func NewPrivateBoard(url string, name string, creator std.Address) *Board { + return newBoard(0, url, name, creator) +} +*/ + +func (board *Board) IsPrivate() bool { + return board.id == 0 +} + +func (board *Board) GetThread(pid PostID) *Post { + pidkey := postIDKey(pid) + postI, exists := board.threads.Get(pidkey) + if !exists { + return nil + } + return postI.(*Post) +} + +func (board *Board) AddThread(creator std.Address, title string, body string) *Post { + pid := board.incGetPostID() + pidkey := postIDKey(pid) + thread := newPost(board, pid, creator, title, body, pid, 0, 0) + board.threads.Set(pidkey, thread) + return thread +} + +// NOTE: this can be potentially very expensive for threads with many replies. +// TODO: implement optional fast-delete where thread is simply moved. +func (board *Board) DeleteThread(pid PostID) { + pidkey := postIDKey(pid) + _, removed := board.threads.Remove(pidkey) + if !removed { + panic("thread does not exist with id " + pid.String()) + } +} + +func (board *Board) HasPermission(addr std.Address, perm Permission) bool { + if board.creator == addr { + switch perm { + case EditPermission: + return true + case DeletePermission: + return true + default: + return false + } + } + return false +} + +// Renders the board for display suitable as plaintext in +// console. This is suitable for demonstration or tests, +// but not for prod. +func (board *Board) RenderBoard() string { + str := "" + str += "\\[[post](" + board.GetPostFormURL() + ")]\n\n" + if board.threads.Size() > 0 { + board.threads.Iterate("", "", func(key string, value interface{}) bool { + if str != "" { + str += "----------------------------------------\n" + } + str += value.(*Post).RenderSummary() + "\n" + return false + }) + } + return str +} + +func (board *Board) incGetPostID() PostID { + board.postsCtr++ + return PostID(board.postsCtr) +} + +func (board *Board) GetURLFromThreadAndReplyID(threadID, replyID PostID) string { + if replyID == 0 { + return board.url + "/" + threadID.String() + } else { + return board.url + "/" + threadID.String() + "/" + replyID.String() + } +} + +func (board *Board) GetPostFormURL() string { + return txlink.URL("CreateThread", "bid", board.id.String()) +} diff --git a/examples/gno.land/r/demo/boards2/boards.gno b/examples/gno.land/r/demo/boards2/boards.gno new file mode 100644 index 00000000000..5de0555a2f9 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/boards.gno @@ -0,0 +1,22 @@ +package boards + +import ( + "regexp" + + "gno.land/p/demo/avl" +) + +//---------------------------------------- +// Realm (package) state + +var ( + gBoards avl.Tree // id -> *Board + gBoardsCtr int // increments Board.id + gBoardsByName avl.Tree // name -> *Board + gDefaultAnonFee = 100000000 // minimum fee required if anonymous +) + +//---------------------------------------- +// Constants + +var reName = regexp.MustCompile(`^[a-z]+[_a-z0-9]{2,29}$`) diff --git a/examples/gno.land/r/demo/boards2/gno.mod b/examples/gno.land/r/demo/boards2/gno.mod new file mode 100644 index 00000000000..1738959bf31 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/gno.mod @@ -0,0 +1,7 @@ +module gno.land/r/demo/boards2 + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/moul/txlink v0.0.0-latest + gno.land/r/demo/users v0.0.0-latest +) diff --git a/examples/gno.land/r/demo/boards2/misc.gno b/examples/gno.land/r/demo/boards2/misc.gno new file mode 100644 index 00000000000..bc561ca7d22 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/misc.gno @@ -0,0 +1,95 @@ +package boards + +import ( + "std" + "strconv" + "strings" + + "gno.land/r/demo/users" +) + +//---------------------------------------- +// private utility methods +// XXX ensure these cannot be called from public. + +func getBoard(bid BoardID) *Board { + bidkey := boardIDKey(bid) + board_, exists := gBoards.Get(bidkey) + if !exists { + return nil + } + board := board_.(*Board) + return board +} + +func incGetBoardID() BoardID { + gBoardsCtr++ + return BoardID(gBoardsCtr) +} + +func padLeft(str string, length int) string { + if len(str) >= length { + return str + } else { + return strings.Repeat(" ", length-len(str)) + str + } +} + +func padZero(u64 uint64, length int) string { + str := strconv.Itoa(int(u64)) + if len(str) >= length { + return str + } else { + return strings.Repeat("0", length-len(str)) + str + } +} + +func boardIDKey(bid BoardID) string { + return padZero(uint64(bid), 10) +} + +func postIDKey(pid PostID) string { + return padZero(uint64(pid), 10) +} + +func indentBody(indent string, body string) string { + lines := strings.Split(body, "\n") + res := "" + for i, line := range lines { + if i > 0 { + res += "\n" + } + res += indent + line + } + return res +} + +// NOTE: length must be greater than 3. +func summaryOf(str string, length int) string { + lines := strings.SplitN(str, "\n", 2) + line := lines[0] + if len(line) > length { + line = line[:(length-3)] + "..." + } else if len(lines) > 1 { + // len(line) <= 80 + line = line + "..." + } + return line +} + +func displayAddressMD(addr std.Address) string { + user := users.GetUserByAddress(addr) + if user == nil { + return "[" + addr.String() + "](/r/demo/users:" + addr.String() + ")" + } else { + return "[@" + user.Name + "](/r/demo/users:" + user.Name + ")" + } +} + +func usernameOf(addr std.Address) string { + user := users.GetUserByAddress(addr) + if user == nil { + return "" + } + return user.Name +} diff --git a/examples/gno.land/r/demo/boards2/post.gno b/examples/gno.land/r/demo/boards2/post.gno new file mode 100644 index 00000000000..95d4b2977ba --- /dev/null +++ b/examples/gno.land/r/demo/boards2/post.gno @@ -0,0 +1,263 @@ +package boards + +import ( + "std" + "strconv" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/moul/txlink" +) + +//---------------------------------------- +// Post + +// NOTE: a PostID is relative to the board. +type PostID uint64 + +func (pid PostID) String() string { + return strconv.Itoa(int(pid)) +} + +// A Post is a "thread" or a "reply" depending on context. +// A thread is a Post of a Board that holds other replies. +type Post struct { + board *Board + id PostID + creator std.Address + title string // optional + body string + replies avl.Tree // Post.id -> *Post + repliesAll avl.Tree // Post.id -> *Post (all replies, for top-level posts) + reposts avl.Tree // Board.id -> Post.id + threadID PostID // original Post.id + parentID PostID // parent Post.id (if reply or repost) + repostBoard BoardID // original Board.id (if repost) + createdAt time.Time + updatedAt time.Time +} + +func newPost(board *Board, id PostID, creator std.Address, title, body string, threadID, parentID PostID, repostBoard BoardID) *Post { + return &Post{ + board: board, + id: id, + creator: creator, + title: title, + body: body, + replies: avl.Tree{}, + repliesAll: avl.Tree{}, + reposts: avl.Tree{}, + threadID: threadID, + parentID: parentID, + repostBoard: repostBoard, + createdAt: time.Now(), + } +} + +func (post *Post) IsThread() bool { + return post.parentID == 0 +} + +func (post *Post) GetPostID() PostID { + return post.id +} + +func (post *Post) AddReply(creator std.Address, body string) *Post { + board := post.board + pid := board.incGetPostID() + pidkey := postIDKey(pid) + reply := newPost(board, pid, creator, "", body, post.threadID, post.id, 0) + post.replies.Set(pidkey, reply) + if post.threadID == post.id { + post.repliesAll.Set(pidkey, reply) + } else { + thread := board.GetThread(post.threadID) + thread.repliesAll.Set(pidkey, reply) + } + return reply +} + +func (post *Post) Update(title string, body string) { + post.title = title + post.body = body + post.updatedAt = time.Now() +} + +func (thread *Post) GetReply(pid PostID) *Post { + pidkey := postIDKey(pid) + replyI, ok := thread.repliesAll.Get(pidkey) + if !ok { + return nil + } else { + return replyI.(*Post) + } +} + +func (post *Post) AddRepostTo(creator std.Address, title, body string, dst *Board) *Post { + if !post.IsThread() { + panic("cannot repost non-thread post") + } + pid := dst.incGetPostID() + pidkey := postIDKey(pid) + repost := newPost(dst, pid, creator, title, body, pid, post.id, post.board.id) + dst.threads.Set(pidkey, repost) + if !dst.IsPrivate() { + bidkey := boardIDKey(dst.id) + post.reposts.Set(bidkey, pid) + } + return repost +} + +func (thread *Post) DeletePost(pid PostID) { + if thread.id == pid { + panic("should not happen") + } + pidkey := postIDKey(pid) + postI, removed := thread.repliesAll.Remove(pidkey) + if !removed { + panic("post not found in thread") + } + post := postI.(*Post) + if post.parentID != thread.id { + parent := thread.GetReply(post.parentID) + parent.replies.Remove(pidkey) + } else { + thread.replies.Remove(pidkey) + } +} + +func (post *Post) HasPermission(addr std.Address, perm Permission) bool { + if post.creator == addr { + switch perm { + case EditPermission: + return true + case DeletePermission: + return true + default: + return false + } + } + // post notes inherit permissions of the board. + return post.board.HasPermission(addr, perm) +} + +func (post *Post) GetSummary() string { + return summaryOf(post.body, 80) +} + +func (post *Post) GetURL() string { + if post.IsThread() { + return post.board.GetURLFromThreadAndReplyID( + post.id, 0) + } else { + return post.board.GetURLFromThreadAndReplyID( + post.threadID, post.id) + } +} + +func (post *Post) GetReplyFormURL() string { + return txlink.URL("CreateReply", + "bid", post.board.id.String(), + "threadid", post.threadID.String(), + "postid", post.id.String(), + ) +} + +func (post *Post) GetRepostFormURL() string { + return txlink.URL("CreateRepost", + "bid", post.board.id.String(), + "postid", post.id.String(), + ) +} + +func (post *Post) GetDeleteFormURL() string { + return txlink.URL("DeletePost", + "bid", post.board.id.String(), + "threadid", post.threadID.String(), + "postid", post.id.String(), + ) +} + +func (post *Post) RenderSummary() string { + if post.repostBoard != 0 { + dstBoard := getBoard(post.repostBoard) + if dstBoard == nil { + panic("repostBoard does not exist") + } + thread := dstBoard.GetThread(PostID(post.parentID)) + if thread == nil { + return "reposted post does not exist" + } + return "Repost: " + post.GetSummary() + "\n" + thread.RenderSummary() + } + str := "" + if post.title != "" { + str += "## [" + summaryOf(post.title, 80) + "](" + post.GetURL() + ")\n" + str += "\n" + } + str += post.GetSummary() + "\n" + str += "\\- " + displayAddressMD(post.creator) + "," + str += " [" + post.createdAt.Format("2006-01-02 3:04pm MST") + "](" + post.GetURL() + ")" + str += " \\[[x](" + post.GetDeleteFormURL() + ")]" + str += " (" + strconv.Itoa(post.replies.Size()) + " replies)" + str += " (" + strconv.Itoa(post.reposts.Size()) + " reposts)" + "\n" + return str +} + +func (post *Post) RenderPost(indent string, levels int) string { + if post == nil { + return "nil post" + } + str := "" + if post.title != "" { + str += indent + "# " + post.title + "\n" + str += indent + "\n" + } + str += indentBody(indent, post.body) + "\n" // TODO: indent body lines. + str += indent + "\\- " + displayAddressMD(post.creator) + ", " + str += "[" + post.createdAt.Format("2006-01-02 3:04pm (MST)") + "](" + post.GetURL() + ")" + str += " \\[[reply](" + post.GetReplyFormURL() + ")]" + if post.IsThread() { + str += " \\[[repost](" + post.GetRepostFormURL() + ")]" + } + str += " \\[[x](" + post.GetDeleteFormURL() + ")]\n" + if levels > 0 { + if post.replies.Size() > 0 { + post.replies.Iterate("", "", func(key string, value interface{}) bool { + str += indent + "\n" + str += value.(*Post).RenderPost(indent+"> ", levels-1) + return false + }) + } + } else { + if post.replies.Size() > 0 { + str += indent + "\n" + str += indent + "_[see all " + strconv.Itoa(post.replies.Size()) + " replies](" + post.GetURL() + ")_\n" + } + } + return str +} + +// render reply and link to context thread +func (post *Post) RenderInner() string { + if post.IsThread() { + panic("unexpected thread") + } + threadID := post.threadID + // replyID := post.id + parentID := post.parentID + str := "" + str += "_[see thread](" + post.board.GetURLFromThreadAndReplyID( + threadID, 0) + ")_\n\n" + thread := post.board.GetThread(post.threadID) + var parent *Post + if thread.id == parentID { + parent = thread + } else { + parent = thread.GetReply(parentID) + } + str += parent.RenderPost("", 0) + str += "\n" + str += post.RenderPost("> ", 5) + return str +} diff --git a/examples/gno.land/r/demo/boards2/public.gno b/examples/gno.land/r/demo/boards2/public.gno new file mode 100644 index 00000000000..1d26126fcb2 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/public.gno @@ -0,0 +1,185 @@ +package boards + +import ( + "std" + "strconv" +) + +//---------------------------------------- +// Public facing functions + +func GetBoardIDFromName(name string) (BoardID, bool) { + boardI, exists := gBoardsByName.Get(name) + if !exists { + return 0, false + } + return boardI.(*Board).id, true +} + +func CreateBoard(name string) BoardID { + if !(std.IsOriginCall() || std.PrevRealm().IsUser()) { + panic("invalid non-user call") + } + bid := incGetBoardID() + caller := std.GetOrigCaller() + if usernameOf(caller) == "" { + panic("unauthorized") + } + url := "/r/demo/boards:" + name + board := newBoard(bid, url, name, caller) + bidkey := boardIDKey(bid) + gBoards.Set(bidkey, board) + gBoardsByName.Set(name, board) + return board.id +} + +func checkAnonFee() bool { + sent := std.GetOrigSend() + anonFeeCoin := std.NewCoin("ugnot", int64(gDefaultAnonFee)) + if len(sent) == 1 && sent[0].IsGTE(anonFeeCoin) { + return true + } + return false +} + +func CreateThread(bid BoardID, title string, body string) PostID { + if !(std.IsOriginCall() || std.PrevRealm().IsUser()) { + panic("invalid non-user call") + } + caller := std.GetOrigCaller() + if usernameOf(caller) == "" { + if !checkAnonFee() { + panic("please register, otherwise minimum fee " + strconv.Itoa(gDefaultAnonFee) + " is required if anonymous") + } + } + board := getBoard(bid) + if board == nil { + panic("board not exist") + } + thread := board.AddThread(caller, title, body) + return thread.id +} + +func CreateReply(bid BoardID, threadid, postid PostID, body string) PostID { + if !(std.IsOriginCall() || std.PrevRealm().IsUser()) { + panic("invalid non-user call") + } + caller := std.GetOrigCaller() + if usernameOf(caller) == "" { + if !checkAnonFee() { + panic("please register, otherwise minimum fee " + strconv.Itoa(gDefaultAnonFee) + " is required if anonymous") + } + } + board := getBoard(bid) + if board == nil { + panic("board not exist") + } + thread := board.GetThread(threadid) + if thread == nil { + panic("thread not exist") + } + if postid == threadid { + reply := thread.AddReply(caller, body) + return reply.id + } else { + post := thread.GetReply(postid) + reply := post.AddReply(caller, body) + return reply.id + } +} + +// If dstBoard is private, does not ping back. +// If board specified by bid is private, panics. +func CreateRepost(bid BoardID, postid PostID, title string, body string, dstBoardID BoardID) PostID { + if !(std.IsOriginCall() || std.PrevRealm().IsUser()) { + panic("invalid non-user call") + } + caller := std.GetOrigCaller() + if usernameOf(caller) == "" { + // TODO: allow with gDefaultAnonFee payment. + if !checkAnonFee() { + panic("please register, otherwise minimum fee " + strconv.Itoa(gDefaultAnonFee) + " is required if anonymous") + } + } + board := getBoard(bid) + if board == nil { + panic("src board not exist") + } + if board.IsPrivate() { + panic("cannot repost from a private board") + } + dst := getBoard(dstBoardID) + if dst == nil { + panic("dst board not exist") + } + thread := board.GetThread(postid) + if thread == nil { + panic("thread not exist") + } + repost := thread.AddRepostTo(caller, title, body, dst) + return repost.id +} + +func DeletePost(bid BoardID, threadid, postid PostID, reason string) { + if !(std.IsOriginCall() || std.PrevRealm().IsUser()) { + panic("invalid non-user call") + } + caller := std.GetOrigCaller() + board := getBoard(bid) + if board == nil { + panic("board not exist") + } + thread := board.GetThread(threadid) + if thread == nil { + panic("thread not exist") + } + if postid == threadid { + // delete thread + if !thread.HasPermission(caller, DeletePermission) { + panic("unauthorized") + } + board.DeleteThread(threadid) + } else { + // delete thread's post + post := thread.GetReply(postid) + if post == nil { + panic("post not exist") + } + if !post.HasPermission(caller, DeletePermission) { + panic("unauthorized") + } + thread.DeletePost(postid) + } +} + +func EditPost(bid BoardID, threadid, postid PostID, title, body string) { + if !(std.IsOriginCall() || std.PrevRealm().IsUser()) { + panic("invalid non-user call") + } + caller := std.GetOrigCaller() + board := getBoard(bid) + if board == nil { + panic("board not exist") + } + thread := board.GetThread(threadid) + if thread == nil { + panic("thread not exist") + } + if postid == threadid { + // edit thread + if !thread.HasPermission(caller, EditPermission) { + panic("unauthorized") + } + thread.Update(title, body) + } else { + // edit thread's post + post := thread.GetReply(postid) + if post == nil { + panic("post not exist") + } + if !post.HasPermission(caller, EditPermission) { + panic("unauthorized") + } + post.Update(title, body) + } +} diff --git a/examples/gno.land/r/demo/boards2/render.gno b/examples/gno.land/r/demo/boards2/render.gno new file mode 100644 index 00000000000..3709ad02e5d --- /dev/null +++ b/examples/gno.land/r/demo/boards2/render.gno @@ -0,0 +1,83 @@ +package boards + +import ( + "strconv" + "strings" +) + +//---------------------------------------- +// Render functions + +func RenderBoard(bid BoardID) string { + board := getBoard(bid) + if board == nil { + return "missing board" + } + return board.RenderBoard() +} + +func Render(path string) string { + if path == "" { + str := "These are all the boards of this realm:\n\n" + gBoards.Iterate("", "", func(key string, value interface{}) bool { + board := value.(*Board) + str += " * [" + board.url + "](" + board.url + ")\n" + return false + }) + return str + } + parts := strings.Split(path, "/") + if len(parts) == 1 { + // /r/demo/boards:BOARD_NAME + name := parts[0] + boardI, exists := gBoardsByName.Get(name) + if !exists { + return "board does not exist: " + name + } + return boardI.(*Board).RenderBoard() + } else if len(parts) == 2 { + // /r/demo/boards:BOARD_NAME/THREAD_ID + name := parts[0] + boardI, exists := gBoardsByName.Get(name) + if !exists { + return "board does not exist: " + name + } + pid, err := strconv.Atoi(parts[1]) + if err != nil { + return "invalid thread id: " + parts[1] + } + board := boardI.(*Board) + thread := board.GetThread(PostID(pid)) + if thread == nil { + return "thread does not exist with id: " + parts[1] + } + return thread.RenderPost("", 5) + } else if len(parts) == 3 { + // /r/demo/boards:BOARD_NAME/THREAD_ID/REPLY_ID + name := parts[0] + boardI, exists := gBoardsByName.Get(name) + if !exists { + return "board does not exist: " + name + } + pid, err := strconv.Atoi(parts[1]) + if err != nil { + return "invalid thread id: " + parts[1] + } + board := boardI.(*Board) + thread := board.GetThread(PostID(pid)) + if thread == nil { + return "thread does not exist with id: " + parts[1] + } + rid, err := strconv.Atoi(parts[2]) + if err != nil { + return "invalid reply id: " + parts[2] + } + reply := thread.GetReply(PostID(rid)) + if reply == nil { + return "reply does not exist with id: " + parts[2] + } + return reply.RenderInner() + } else { + return "unrecognized path " + path + } +} diff --git a/examples/gno.land/r/demo/boards2/role.gno b/examples/gno.land/r/demo/boards2/role.gno new file mode 100644 index 00000000000..64073d64f34 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/role.gno @@ -0,0 +1,8 @@ +package boards + +type Permission string + +const ( + DeletePermission Permission = "role:delete" + EditPermission Permission = "role:edit" +)