diff --git a/examples/gno.land/r/demo/boards2/board.gno b/examples/gno.land/r/demo/boards2/board.gno index 1920cd41225..8b667cb291e 100644 --- a/examples/gno.land/r/demo/boards2/board.gno +++ b/examples/gno.land/r/demo/boards2/board.gno @@ -100,8 +100,13 @@ func (board *Board) Render() string { s := "\\[" + newLink("post", board.GetPostFormURL()) + "]\n\n" if board.threads.Size() > 0 { board.threads.Iterate("", "", func(_ string, v interface{}) bool { + post := v.(*Post) + if post.isHidden { + return false + } + s += "----------------------------------------\n" - s += v.(*Post).RenderSummary() + "\n" + s += post.RenderSummary() + "\n" return false }) } diff --git a/examples/gno.land/r/demo/boards2/flag.gno b/examples/gno.land/r/demo/boards2/flag.gno new file mode 100644 index 00000000000..e5744211ddf --- /dev/null +++ b/examples/gno.land/r/demo/boards2/flag.gno @@ -0,0 +1,47 @@ +package boards2 + +import ( + "std" + "strconv" +) + +const flagThreshold = 1 + +type Flag struct { + User std.Address + Reason string +} + +func NewFlag(creator std.Address, reason string) Flag { + return Flag{ + User: creator, + Reason: reason, + } +} + +type Flaggable interface { + // AddFlag adds a new flag to an item. + // + // Returns false if item was already flagged by user. + AddFlag(flag Flag) bool + + // FlagsCount returns number of times item was flagged. + FlagsCount() int +} + +// flagItem adds a flag to a flaggable item (post, thread, etc). +// +// Returns whether flag count threshold is reached and item can be hidden. +// +// Panics if flag count threshold was already reached. +func flagItem(item Flaggable, flag Flag) bool { + if item.FlagsCount() >= flagThreshold { + panic("item flag count threshold exceeded: " + strconv.Itoa(flagThreshold)) + } + + if !item.AddFlag(flag) { + panic("item has been already flagged by a current user") + } + + return item.FlagsCount() == flagThreshold +} diff --git a/examples/gno.land/r/demo/boards2/permission.gno b/examples/gno.land/r/demo/boards2/permission.gno index d287b4f8e8b..e465e346418 100644 --- a/examples/gno.land/r/demo/boards2/permission.gno +++ b/examples/gno.land/r/demo/boards2/permission.gno @@ -7,8 +7,10 @@ const ( PermissionThreadCreate = "thread:create" PermissionThreadEdit = "thread:edit" PermissionThreadDelete = "thread:delete" + PermissionThreadFlag = "thread:flag" PermissionThreadRepost = "thread:repost" PermissionReplyDelete = "reply:delete" + PermissionReplyFlag = "reply:flag" PermissionMemberInvite = "member:invite" PermissionMemberRemove = "member:remove" ) diff --git a/examples/gno.land/r/demo/boards2/post.gno b/examples/gno.land/r/demo/boards2/post.gno index 7364b985c84..5a79302af22 100644 --- a/examples/gno.land/r/demo/boards2/post.gno +++ b/examples/gno.land/r/demo/boards2/post.gno @@ -30,12 +30,14 @@ type Post struct { creator std.Address title string // optional body string + isHidden bool 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) - repostBoardID BoardID // original Board.id (if repost) + flags []Flag + threadID PostID // original Post.id + parentID PostID // parent Post.id (if reply or repost) + repostBoardID BoardID // original Board.id (if repost) createdAt time.Time updatedAt time.Time } @@ -97,6 +99,30 @@ func (post *Post) GetUpdatedAt() time.Time { return post.updatedAt } +func (post *Post) AddFlag(flag Flag) bool { + // TODO: sort flags for fast search in case of big thresholds + for _, v := range post.flags { + if v.User == flag.User { + return false + } + } + + post.flags = append(post.flags, flag) + return true +} + +func (post *Post) FlagsCount() int { + return len(post.flags) +} + +func (post *Post) SetVisible(isVisible bool) { + post.isHidden = !isVisible +} + +func (post *Post) IsHidden() bool { + return post.isHidden +} + func (post *Post) AddReply(creator std.Address, body string) *Post { board := post.board pid := board.incGetPostID() @@ -219,6 +245,11 @@ func (post *Post) RenderSummary() string { if !found { return "reposted post does not exist" } + + if thread.isHidden { + return "reposted post was hidden" + } + return "Repost: " + post.GetSummary() + "\n" + thread.RenderSummary() } @@ -267,8 +298,14 @@ func (post *Post) Render(indent string, levels int) string { if levels > 0 { if post.replies.Size() > 0 { post.replies.Iterate("", "", func(_ string, value interface{}) bool { + reply := value.(*Post) + if reply.isHidden { + // TODO: change this in case of pagination + return false + } + s += indent + "\n" - s += value.(*Post).Render(indent+"> ", levels-1) + s += reply.Render(indent+"> ", levels-1) return false }) } diff --git a/examples/gno.land/r/demo/boards2/post_test.gno b/examples/gno.land/r/demo/boards2/post_test.gno index 05888f9e297..232b4ea75cd 100644 --- a/examples/gno.land/r/demo/boards2/post_test.gno +++ b/examples/gno.land/r/demo/boards2/post_test.gno @@ -23,6 +23,27 @@ func TestPostUpdate(t *testing.T) { uassert.False(t, post.GetUpdatedAt().IsZero()) } +func TestPostAddFlag(t *testing.T) { + addr := testutils.TestAddress("creator") + post := createTestThread(t) + + flag := NewFlag(addr, "foobar") + uassert.True(t, post.AddFlag(flag)) + uassert.False(t, post.AddFlag(flag), "should reject flag from duplicate user") + uassert.Equal(t, post.FlagsCount(), 1) +} + +func TestPostSetVisible(t *testing.T) { + post := createTestThread(t) + uassert.False(t, post.IsHidden(), "post should be visible by default") + + post.SetVisible(false) + uassert.True(t, post.IsHidden(), "post should be hidden") + + post.SetVisible(true) + uassert.False(t, post.IsHidden(), "post should be visible") +} + func TestPostAddRepostTo(t *testing.T) { cases := []struct { name, title, body string diff --git a/examples/gno.land/r/demo/boards2/public.gno b/examples/gno.land/r/demo/boards2/public.gno index 083f8daff65..62015f9d690 100644 --- a/examples/gno.land/r/demo/boards2/public.gno +++ b/examples/gno.land/r/demo/boards2/public.gno @@ -36,6 +36,21 @@ func CreateBoard(name string) BoardID { return id } +func FlagThread(bid BoardID, postID PostID, reason string) { + caller := std.GetOrigCaller() + board := mustGetBoard(bid) + assertHasBoardPermission(board, caller, PermissionThreadFlag) + + t, ok := board.GetThread(postID) + if !ok { + panic("post doesn't exist") + } + + if flagItem(t, NewFlag(caller, reason)) { + t.SetVisible(false) + } +} + func CreateThread(bid BoardID, title, body string) PostID { assertIsUserCall() @@ -57,9 +72,10 @@ func CreateReply(bid BoardID, threadID, replyID PostID, body string) PostID { board := mustGetBoard(bid) thread := mustGetThread(board, threadID) + assertThreadVisible(thread) + // TODO: Assert thread is not locked // TODO: Assert that caller is a board member (when board type is invite only) - var reply *Post if replyID == threadID { // When the parent reply is the thread just add reply to thread @@ -67,11 +83,27 @@ func CreateReply(bid BoardID, threadID, replyID PostID, body string) PostID { } else { // Try to get parent reply and add a new child reply post := mustGetReply(thread, replyID) + assertReplyVisible(post) + reply = post.AddReply(caller, body) } return reply.id } +func FlagReply(bid BoardID, threadID, replyID PostID, reason string) { + caller := std.GetOrigCaller() + + board := mustGetBoard(bid) + assertHasBoardPermission(board, caller, PermissionThreadFlag) + + thread := mustGetThread(board, threadID) + reply := mustGetReply(thread, replyID) + + if hide := flagItem(reply, NewFlag(caller, reason)); hide { + reply.SetVisible(false) + } +} + func CreateRepost(bid BoardID, threadID PostID, title, body string, dstBoardID BoardID) PostID { assertIsUserCall() @@ -216,3 +248,15 @@ func assertReplyExists(thread *Post, replyID PostID) { panic("reply not found: " + replyID.String()) } } + +func assertThreadVisible(thread *Post) { + if thread.IsHidden() { + panic("thread with ID: " + thread.GetPostID().String() + " was hidden") + } +} + +func assertReplyVisible(thread *Post) { + if thread.IsHidden() { + panic("reply with ID: " + thread.GetPostID().String() + " was hidden") + } +} diff --git a/examples/gno.land/r/demo/boards2/render.gno b/examples/gno.land/r/demo/boards2/render.gno index 02e7fda9e81..ea575bc4fb1 100644 --- a/examples/gno.land/r/demo/boards2/render.gno +++ b/examples/gno.land/r/demo/boards2/render.gno @@ -59,7 +59,9 @@ func renderThread(res *mux.ResponseWriter, req *mux.Request) { board := v.(*Board) thread, found := board.GetThread(PostID(tID)) if !found { - res.Write("Thread does not exist with ID: " + req.GetVar("thread")) + res.Write("Thread does not exist with ID: " + rawID) + } else if thread.IsHidden() { + res.Write("Thread with ID: " + rawID + " has been flagged as inappropriate") } else { res.Write(thread.Render("", 5)) } @@ -96,7 +98,9 @@ func renderReply(res *mux.ResponseWriter, req *mux.Request) { reply, found := thread.GetReply(PostID(rID)) if !found { - res.Write("Reply does not exist with ID: " + req.GetVar("reply")) + res.Write("Reply does not exist with ID: " + rawID) + } else if reply.IsHidden() { + res.Write("Reply with ID: " + rawID + " was hidden") } else { res.Write(reply.RenderInner()) } diff --git a/examples/gno.land/r/demo/boards2/z_2_a_filetest.gno b/examples/gno.land/r/demo/boards2/z_2_a_filetest.gno new file mode 100644 index 00000000000..d95759e4236 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/z_2_a_filetest.gno @@ -0,0 +1,23 @@ +package main + +import ( + "std" + + "gno.land/r/demo/boards2" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOrigCaller(owner) +} + +func main() { + bid := boards2.CreateBoard("test1") + pid := boards2.CreateThread(bid, "thread", "thread") + boards2.FlagThread(bid, pid, "reason") + _ = boards2.CreateReply(bid, pid, pid, "reply") +} + +// Error: +// thread with ID: 1 was hidden diff --git a/examples/gno.land/r/demo/boards2/z_2_b_filetest.gno b/examples/gno.land/r/demo/boards2/z_2_b_filetest.gno new file mode 100644 index 00000000000..a4f4d403c8c --- /dev/null +++ b/examples/gno.land/r/demo/boards2/z_2_b_filetest.gno @@ -0,0 +1,26 @@ +package main + +import ( + "std" + + "gno.land/r/demo/boards2" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOrigCaller(owner) +} + +func main() { + // ensure that nested replies denied if root thread is hidden. + bid := boards2.CreateBoard("test1") + pid := boards2.CreateThread(bid, "thread", "thread") + rid := boards2.CreateReply(bid, pid, pid, "reply1") + + boards2.FlagThread(bid, pid, "reason") + _ = boards2.CreateReply(bid, pid, rid, "reply1.1") +} + +// Error: +// thread with ID: 1 was hidden diff --git a/examples/gno.land/r/demo/boards2/z_2_c_filetest.gno b/examples/gno.land/r/demo/boards2/z_2_c_filetest.gno new file mode 100644 index 00000000000..3a27b4497cd --- /dev/null +++ b/examples/gno.land/r/demo/boards2/z_2_c_filetest.gno @@ -0,0 +1,26 @@ +package main + +import ( + "std" + + "gno.land/r/demo/boards2" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOrigCaller(owner) +} + +func main() { + // ensure that nested replies denied if root thread is hidden. + bid := boards2.CreateBoard("test1") + pid := boards2.CreateThread(bid, "thread", "thread") + rid := boards2.CreateReply(bid, pid, pid, "reply1") + + boards2.FlagReply(bid, pid, rid, "reason") + _ = boards2.CreateReply(bid, pid, rid, "reply1.1") +} + +// Error: +// reply with ID: 2 was hidden diff --git a/examples/gno.land/r/demo/boards2/z_2_d_filetest.gno b/examples/gno.land/r/demo/boards2/z_2_d_filetest.gno new file mode 100644 index 00000000000..dbd809c84cd --- /dev/null +++ b/examples/gno.land/r/demo/boards2/z_2_d_filetest.gno @@ -0,0 +1,25 @@ +package main + +import ( + "std" + + "gno.land/r/demo/boards2" +) + +const owner = std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + +func init() { + std.TestSetOrigCaller(owner) +} + +func main() { + // Only single user per flag can't be tested atm, as flagThreshold = 1. + bid := boards2.CreateBoard("test1") + pid := boards2.CreateThread(bid, "thread", "thread") + + boards2.FlagThread(bid, pid, "reason1") + boards2.FlagThread(bid, pid, "reason2") +} + +// Error: +// item flag count threshold exceeded: 1