Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

public.gno

20.77 Kb · 801 lines
  1package boards2
  2
  3import (
  4	"chain"
  5	"chain/runtime/unsafe"
  6	"regexp"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"gno.land/p/gnoland/boards/v0"
 12)
 13
 14const (
 15	// MaxBoardNameLength defines the maximum length allowed for board names.
 16	MaxBoardNameLength = 50
 17
 18	// MaxThreadTitleLength defines the maximum length allowed for thread titles.
 19	MaxThreadTitleLength = 100
 20
 21	// MaxThreadBodyLength defines the maximum length allowed for thread bodies.
 22	// 40,000 mirrors Reddit's self-post body cap.
 23	MaxThreadBodyLength = 40000
 24
 25	// MaxReplyLength defines the maximum length allowed for replies.
 26	// 10,000 mirrors Reddit's comment cap.
 27	MaxReplyLength = 10000
 28)
 29
 30var reBoardName = regexp.MustCompile(`(?i)^[a-z]+[a-z0-9_\-]{2,50}$`)
 31
 32// SetHelp sets or updates boards realm help content.
 33func SetHelp(cur realm, content string) {
 34	content = strings.TrimSpace(content)
 35	caller := cur.Previous().Address()
 36	args := boards.Args{content}
 37	gPerms.WithPermission(caller, PermissionRealmHelpChange, args, func() {
 38		Help = content
 39	})
 40}
 41
 42// SetRequiredAccountAmount sets the required account amount to interact as a non member with open boards.
 43// Amount must be given as ugnot.
 44// The amount requirement is not applied to members that were invited to an open board.
 45func SetRequiredAccountAmount(cur realm, amount int64) {
 46	if amount < 0 {
 47		panic("invalid amount")
 48	}
 49
 50	caller := cur.Previous().Address()
 51	args := boards.Args{amount}
 52	gPerms.WithPermission(caller, PermissionAccountRequiredAmountChange, args, func() {
 53		RequiredAccountAmount = amount
 54
 55		chain.Emit(
 56			"RequiredAccountAmountChanged",
 57			"caller", caller.String(),
 58			"amount", strconv.FormatInt(amount, 10),
 59		)
 60	})
 61}
 62
 63// SetPermissions sets a permissions implementation for boards2 realm or a board.
 64func SetPermissions(cur realm, boardID boards.ID, p boards.Permissions) {
 65	assertRealmIsNotLocked()
 66	assertBoardExists(boardID)
 67
 68	if p == nil {
 69		panic("permissions is required")
 70	}
 71
 72	caller := cur.Previous().Address()
 73	args := boards.Args{boardID}
 74	gPerms.WithPermission(caller, PermissionPermissionsUpdate, args, func() {
 75		assertRealmIsNotLocked()
 76
 77		// When board ID is zero it means that realm permissions are being updated
 78		if boardID == 0 {
 79			gPerms = p
 80
 81			chain.Emit(
 82				"RealmPermissionsChanged",
 83				"caller", caller.String(),
 84			)
 85			return
 86		}
 87
 88		// Otherwise update the permissions of a single board
 89		board := mustGetBoard(boardID)
 90		board.Permissions = p
 91
 92		chain.Emit(
 93			"BoardPermissionsChanged",
 94			"caller", caller.String(),
 95			"boardID", board.ID.String(),
 96		)
 97	})
 98}
 99
100// SetRealmNotice sets a notice to be displayed globally within the realm.
101// An empty message removes the realm notice.
102func SetRealmNotice(cur realm, message string) {
103	message = strings.TrimSpace(message)
104	caller := cur.Previous().Address()
105	args := boards.Args{message}
106	gPerms.WithPermission(caller, PermissionRealmNotice, args, func() {
107		Notice = message
108
109		chain.Emit(
110			"RealmNoticeChanged",
111			"caller", caller.String(),
112			"message", message,
113		)
114	})
115}
116
117// GetBoardIDFromName searches a board by name and returns its ID.
118func GetBoardIDFromName(_ realm, name string) (_ boards.ID, found bool) {
119	board, found := gBoards.GetByName(name)
120	if !found {
121		return 0, false
122	}
123	return board.ID, true
124}
125
126// BoardCount returns the total number of boards.
127func BoardCount() int {
128	return gBoards.Size()
129}
130
131// CreateBoard creates a new board.
132//
133// Listed boards are included in the realm's list of boards.
134// Open boards allow anyone to create threads and comment.
135func CreateBoard(cur realm, name string, listed, open bool) boards.ID {
136	assertRealmIsNotLocked()
137
138	name = strings.TrimSpace(name)
139	assertIsValidBoardName(name)
140	assertBoardNameNotExists(name)
141
142	caller := cur.Previous().Address()
143	id := gBoardsSequence.Next()
144	args := boards.Args{caller, name, id, listed, open}
145	gPerms.WithPermission(caller, PermissionBoardCreate, args, func() {
146		assertRealmIsNotLocked()
147		assertBoardNameNotExists(name)
148		addBoard(id, caller, name, listed, open)
149	})
150	return id
151}
152
153// addBoard creates a board and adds it to the realm storage.
154// It performs no permission or validation checks, so callers are responsible
155// for doing their own.
156func addBoard(id boards.ID, caller address, name string, listed, open bool) {
157	board := boards.New(id)
158	board.Name = name
159	board.Creator = caller
160	board.Meta = &BoardMeta{
161		HiddenThreads: boards.NewPostStorage(),
162	}
163
164	if open {
165		board.Permissions = createOpenBoardPermissions(caller)
166	} else {
167		board.Permissions = createBasicBoardPermissions(caller)
168	}
169
170	if err := gBoards.Add(board); err != nil {
171		panic(err)
172	}
173
174	// Listed boards are also indexed separately for easier iteration and pagination
175	if listed {
176		gListedBoardsByID.Set(board.ID.Key(), board)
177	}
178
179	chain.Emit(
180		"BoardCreated",
181		"caller", caller.String(),
182		"boardID", board.ID.String(),
183		"name", name,
184	)
185}
186
187// RenameBoard changes the name of an existing board.
188//
189// A history of previous board names is kept when boards are renamed.
190// Because of that boards are also accessible using previous name(s).
191func RenameBoard(cur realm, name, newName string) {
192	assertRealmIsNotLocked()
193
194	newName = strings.TrimSpace(newName)
195	assertIsValidBoardName(newName)
196	assertBoardNameNotExists(newName)
197
198	board := mustGetBoardByName(name)
199	assertBoardIsNotFrozen(board)
200
201	caller := cur.Previous().Address()
202	args := boards.Args{caller, board.ID, name, newName}
203	board.Permissions.WithPermission(caller, PermissionBoardRename, args, func() {
204		assertRealmIsNotLocked()
205		assertBoardNameNotExists(newName)
206
207		board.Aliases = append(board.Aliases, board.Name)
208		board.Name = newName
209		board.UpdatedAt = time.Now()
210
211		// Index board for the new name keeping previous indexes for older names
212		gBoards.Add(board)
213
214		chain.Emit(
215			"BoardRenamed",
216			"caller", caller.String(),
217			"boardID", board.ID.String(),
218			"name", name,
219			"newName", newName,
220		)
221	})
222}
223
224// CreateThread creates a new thread within a board.
225func CreateThread(cur realm, boardID boards.ID, title, body string) boards.ID {
226	assertRealmIsNotLocked()
227
228	title = strings.TrimSpace(title)
229	assertTitleIsValid(title)
230	assertThreadBodyIsValid(body)
231
232	caller := cur.Previous().Address()
233	assertUserIsNotBanned(boardID, caller)
234
235	board := mustGetBoard(boardID)
236	assertBoardIsNotFrozen(board)
237
238	thread := boards.MustNewThread(board, caller, title, body)
239	args := boards.Args{caller, board.ID, thread.ID, title, body}
240	board.Permissions.WithPermission(caller, PermissionThreadCreate, args, func() {
241		assertRealmIsNotLocked()
242		assertUserIsNotBanned(board.ID, caller)
243
244		thread.Meta = &ThreadMeta{
245			AllReplies: boards.NewPostStorage(),
246		}
247
248		if err := board.Threads.Add(thread); err != nil {
249			panic(err)
250		}
251
252		chain.Emit(
253			"ThreadCreated",
254			"caller", caller.String(),
255			"boardID", board.ID.String(),
256			"threadID", thread.ID.String(),
257			"title", title,
258		)
259	})
260	return thread.ID
261}
262
263// CreateReply creates a new comment or reply within a thread.
264//
265// The value of `replyID` is only required when creating a reply of another reply.
266func CreateReply(cur realm, boardID, threadID, replyID boards.ID, body string) boards.ID {
267	assertRealmIsNotLocked()
268
269	body = strings.TrimSpace(body)
270	assertReplyBodyIsValid(body)
271
272	caller := cur.Previous().Address()
273	assertUserIsNotBanned(boardID, caller)
274
275	board := mustGetBoard(boardID)
276	assertBoardIsNotFrozen(board)
277
278	thread := mustGetThread(board, threadID)
279	assertThreadIsVisible(thread)
280	assertThreadIsNotFrozen(thread)
281
282	// By default consider that reply's parent is the thread.
283	// Or when replyID is assigned use that reply as the parent.
284	parent := thread
285	if replyID > 0 {
286		parent = mustGetReply(thread, replyID)
287		if parent.Hidden || parent.Readonly {
288			panic("replying to a hidden or frozen reply is not allowed")
289		}
290	}
291
292	reply := boards.MustNewReply(parent, caller, body)
293	args := boards.Args{caller, board.ID, thread.ID, parent.ID, reply.ID, body}
294	board.Permissions.WithPermission(caller, PermissionReplyCreate, args, func() {
295		assertRealmIsNotLocked()
296
297		// Add reply to its parent
298		if err := parent.Replies.Add(reply); err != nil {
299			panic(err)
300		}
301
302		// Always add reply to the thread so it contains all comments and replies.
303		// Comment and reply only contains direct replies.
304		meta := thread.Meta.(*ThreadMeta)
305		if err := meta.AllReplies.Add(reply); err != nil {
306			panic(err)
307		}
308
309		chain.Emit(
310			"ReplyCreate",
311			"caller", caller.String(),
312			"boardID", board.ID.String(),
313			"threadID", thread.ID.String(),
314			"replyID", reply.ID.String(),
315		)
316	})
317	return reply.ID
318}
319
320// CreateRepost reposts a thread into another board.
321func CreateRepost(cur realm, boardID, threadID, destinationBoardID boards.ID, title, body string) boards.ID {
322	assertRealmIsNotLocked()
323
324	title = strings.TrimSpace(title)
325	assertTitleIsValid(title)
326	assertThreadBodyIsValid(body)
327
328	caller := cur.Previous().Address()
329	assertUserIsNotBanned(destinationBoardID, caller)
330
331	dst := mustGetBoard(destinationBoardID)
332	assertBoardIsNotFrozen(dst)
333
334	board := mustGetBoard(boardID)
335	thread := mustGetThread(board, threadID)
336	assertThreadIsVisible(thread)
337
338	repost := boards.MustNewRepost(thread, dst, caller)
339	args := boards.Args{caller, board.ID, thread.ID, dst.ID, repost.ID, title, body}
340	dst.Permissions.WithPermission(caller, PermissionThreadRepost, args, func() {
341		assertRealmIsNotLocked()
342
343		repost.Title = title
344		repost.Body = strings.TrimSpace(body)
345		repost.Meta = &ThreadMeta{
346			AllReplies: boards.NewPostStorage(),
347		}
348
349		if err := dst.Threads.Add(repost); err != nil {
350			panic(err)
351		}
352
353		if err := thread.Reposts.Add(repost); err != nil {
354			panic(err)
355		}
356
357		chain.Emit(
358			"Repost",
359			"caller", caller.String(),
360			"boardID", board.ID.String(),
361			"threadID", thread.ID.String(),
362			"destinationBoardID", dst.ID.String(),
363			"repostID", repost.ID.String(),
364			"title", title,
365		)
366	})
367	return repost.ID
368}
369
370// DeleteThread deletes a thread from a board.
371//
372// Threads can be deleted by the users who created them or otherwise by users with special permissions.
373func DeleteThread(cur realm, boardID, threadID boards.ID) {
374	assertRealmIsNotLocked()
375
376	caller := cur.Previous().Address()
377	board := mustGetBoard(boardID)
378	assertUserIsNotBanned(boardID, caller)
379
380	isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteThread filetest cases for realm owners
381	if !isRealmOwner {
382		assertBoardIsNotFrozen(board)
383	}
384
385	thread := mustGetThread(board, threadID)
386	deleteThread := func() {
387		board.Threads.Remove(thread.ID)
388
389		chain.Emit(
390			"ThreadDeleted",
391			"caller", caller.String(),
392			"boardID", board.ID.String(),
393			"threadID", thread.ID.String(),
394		)
395	}
396
397	// Thread can be directly deleted by user that created it.
398	// It can also be deleted by realm owners, to be able to delete inappropriate content.
399	// TODO: Discuss and decide if realm owners should be able to delete threads.
400	if isRealmOwner || caller == thread.Creator {
401		deleteThread()
402		return
403	}
404
405	args := boards.Args{caller, board.ID, thread.ID}
406	board.Permissions.WithPermission(caller, PermissionThreadDelete, args, func() {
407		assertRealmIsNotLocked()
408		deleteThread()
409	})
410}
411
412// DeleteReply deletes a reply from a thread.
413//
414// Replies can be deleted by the users who created them or otherwise by users with special permissions.
415// Soft deletion is used when the deleted reply contains sub replies, in which case the reply content
416// is replaced by a text informing that reply has been deleted to avoid deleting sub-replies.
417func DeleteReply(cur realm, boardID, threadID, replyID boards.ID) {
418	assertRealmIsNotLocked()
419
420	caller := cur.Previous().Address()
421	board := mustGetBoard(boardID)
422	assertUserIsNotBanned(boardID, caller)
423
424	thread := mustGetThread(board, threadID)
425	reply := mustGetReply(thread, replyID)
426	isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteReply filetest cases for realm owners
427	if !isRealmOwner {
428		assertBoardIsNotFrozen(board)
429		assertThreadIsNotFrozen(thread)
430		assertReplyIsVisible(reply)
431	}
432
433	deleteReply := func() {
434		// Soft delete comment/reply by changing its body when
435		// it contains replies, otherwise hard delete it.
436		if reply.Replies.Size() > 0 {
437			reply.Body = "⚠ This comment has been deleted"
438			reply.UpdatedAt = time.Now()
439		} else {
440			// Remove reply from the flat thread index.
441			meta := thread.Meta.(*ThreadMeta)
442			reply, removed := meta.AllReplies.Remove(replyID)
443			if !removed {
444				panic("reply not found")
445			}
446
447			// Remove reply from its parent's direct-children list too. A
448			// direct thread reply's parent is the thread itself; a nested
449			// reply's parent is another reply (resolved via the flat index).
450			// Missing the thread-parent case left a ghost in thread.Replies
451			// (rendered in the threaded view, with a stale reply count).
452			if reply.ParentID == thread.ID {
453				thread.Replies.Remove(replyID)
454			} else if parent, found := meta.AllReplies.Get(reply.ParentID); found {
455				parent.Replies.Remove(replyID)
456			}
457		}
458
459		chain.Emit(
460			"ReplyDeleted",
461			"caller", caller.String(),
462			"boardID", board.ID.String(),
463			"threadID", thread.ID.String(),
464			"replyID", reply.ID.String(),
465		)
466	}
467
468	// Reply can be directly deleted by user that created it.
469	// It can also be deleted by realm owners, to be able to delete inappropriate content.
470	// TODO: Discuss and decide if realm owners should be able to delete replies.
471	if isRealmOwner || caller == reply.Creator {
472		deleteReply()
473		return
474	}
475
476	args := boards.Args{caller, board.ID, thread.ID, reply.ID}
477	board.Permissions.WithPermission(caller, PermissionReplyDelete, args, func() {
478		assertRealmIsNotLocked()
479		deleteReply()
480	})
481}
482
483// EditThread updates the title and body of a thread.
484//
485// Threads can be updated by the users who created them or otherwise by users with special permissions.
486func EditThread(cur realm, boardID, threadID boards.ID, title, body string) {
487	assertRealmIsNotLocked()
488
489	title = strings.TrimSpace(title)
490	assertTitleIsValid(title)
491	assertThreadBodyIsValid(body)
492
493	board := mustGetBoard(boardID)
494	assertBoardIsNotFrozen(board)
495
496	caller := cur.Previous().Address()
497	assertUserIsNotBanned(boardID, caller)
498
499	thread := mustGetThread(board, threadID)
500	assertThreadIsNotFrozen(thread)
501
502	body = strings.TrimSpace(body)
503	if !boards.IsRepost(thread) {
504		assertBodyIsNotEmpty(body)
505	}
506
507	editThread := func() {
508		thread.Title = title
509		thread.Body = body
510		thread.UpdatedAt = time.Now()
511
512		chain.Emit(
513			"ThreadEdited",
514			"caller", caller.String(),
515			"boardID", board.ID.String(),
516			"threadID", thread.ID.String(),
517			"title", title,
518		)
519	}
520
521	if caller == thread.Creator {
522		editThread()
523		return
524	}
525
526	args := boards.Args{caller, board.ID, thread.ID, title, body}
527	board.Permissions.WithPermission(caller, PermissionThreadEdit, args, func() {
528		assertRealmIsNotLocked()
529		editThread()
530	})
531}
532
533// EditReply updates the body of a comment or reply.
534//
535// Replies can be updated only by the users who created them.
536func EditReply(cur realm, boardID, threadID, replyID boards.ID, body string) {
537	assertRealmIsNotLocked()
538
539	body = strings.TrimSpace(body)
540	assertReplyBodyIsValid(body)
541
542	board := mustGetBoard(boardID)
543	assertBoardIsNotFrozen(board)
544
545	caller := cur.Previous().Address()
546	assertUserIsNotBanned(boardID, caller)
547
548	thread := mustGetThread(board, threadID)
549	assertThreadIsNotFrozen(thread)
550
551	reply := mustGetReply(thread, replyID)
552	assertReplyIsVisible(reply)
553
554	if caller != reply.Creator {
555		panic("only the reply creator is allowed to edit it")
556	}
557
558	reply.Body = body
559	reply.UpdatedAt = time.Now()
560
561	chain.Emit(
562		"ReplyEdited",
563		"caller", caller.String(),
564		"boardID", board.ID.String(),
565		"threadID", thread.ID.String(),
566		"replyID", reply.ID.String(),
567		"body", body,
568	)
569}
570
571// RemoveMember removes a member from the realm or a board.
572//
573// Board ID is only required when removing a member from board.
574func RemoveMember(cur realm, boardID boards.ID, member address) {
575	assertMembersUpdateIsEnabled(boardID)
576	assertMemberAddressIsValid(member)
577
578	perms := mustGetPermissions(boardID)
579	origin := unsafe.OriginCaller()
580	caller := cur.Previous().Address()
581	removeMember := func() {
582		if !perms.RemoveUser(member) {
583			panic("member not found")
584		}
585
586		chain.Emit(
587			"MemberRemoved",
588			"caller", caller.String(),
589			"origin", origin.String(), // When origin and caller match it means self removal
590			"boardID", boardID.String(),
591			"member", member.String(),
592		)
593	}
594
595	// Members can remove themselves without permission
596	if origin == member {
597		removeMember()
598		return
599	}
600
601	args := boards.Args{boardID, member}
602	perms.WithPermission(caller, PermissionMemberRemove, args, func() {
603		assertMembersUpdateIsEnabled(boardID)
604		removeMember()
605	})
606}
607
608// IsMember checks if a user is a member of the realm or a board.
609//
610// Board ID is only required when checking if a user is a member of a board.
611func IsMember(boardID boards.ID, user address) bool {
612	assertUserAddressIsValid(user)
613
614	if boardID != 0 {
615		board := mustGetBoard(boardID)
616		assertBoardIsNotFrozen(board)
617	}
618
619	perms := mustGetPermissions(boardID)
620	return perms.HasUser(user)
621}
622
623// HasMemberRole checks if a realm or board member has a specific role assigned.
624//
625// Board ID is only required when checking a member of a board.
626func HasMemberRole(boardID boards.ID, member address, role boards.Role) bool {
627	assertMemberAddressIsValid(member)
628
629	if boardID != 0 {
630		board := mustGetBoard(boardID)
631		assertBoardIsNotFrozen(board)
632	}
633
634	perms := mustGetPermissions(boardID)
635	return perms.HasRole(member, role)
636}
637
638// ChangeMemberRole changes the role of a realm or board member.
639//
640// Board ID is only required when changing the role for a member of a board.
641func ChangeMemberRole(cur realm, boardID boards.ID, member address, role boards.Role) {
642	assertMemberAddressIsValid(member)
643	assertMembersUpdateIsEnabled(boardID)
644
645	if role == "" {
646		role = RoleGuest
647	}
648
649	perms := mustGetPermissions(boardID)
650	caller := cur.Previous().Address()
651	args := boards.Args{caller, boardID, member, role}
652	perms.WithPermission(caller, PermissionRoleChange, args, func() {
653		assertMembersUpdateIsEnabled(boardID)
654
655		perms.SetUserRoles(member, role)
656
657		chain.Emit(
658			"RoleChanged",
659			"caller", caller.String(),
660			"boardID", boardID.String(),
661			"member", member.String(),
662			"newRole", string(role),
663		)
664	})
665}
666
667func assertMemberAddressIsValid(member address) {
668	if !member.IsValid() {
669		panic("invalid member address: " + member.String())
670	}
671}
672
673func assertUserAddressIsValid(user address) {
674	if !user.IsValid() {
675		panic("invalid user address: " + user.String())
676	}
677}
678
679func assertBoardExists(id boards.ID) {
680	if id == 0 { // ID zero is used to refer to the realm
681		return
682	}
683
684	if _, found := gBoards.Get(id); !found {
685		panic("board not found: " + id.String())
686	}
687}
688
689func assertBoardIsNotFrozen(b *boards.Board) {
690	if b.Readonly {
691		panic("board is frozen")
692	}
693}
694
695func assertIsValidBoardName(name string) {
696	size := len(name)
697	if size == 0 {
698		panic("board name is empty")
699	}
700
701	if size < 3 {
702		panic("board name is too short, minimum length is 3 characters")
703	}
704
705	if size > MaxBoardNameLength {
706		n := strconv.Itoa(MaxBoardNameLength)
707		panic("board name is too long, maximum allowed is " + n + " characters")
708	}
709
710	if !reBoardName.MatchString(name) {
711		panic("board name must start with a letter and have letters, numbers, \"-\" and \"_\"")
712	}
713}
714
715func assertThreadIsNotFrozen(t *boards.Post) {
716	if t.Readonly {
717		panic("thread is frozen")
718	}
719}
720
721func assertNameIsNotEmpty(name string) {
722	if name == "" {
723		panic("name is empty")
724	}
725}
726
727func assertTitleIsValid(title string) {
728	if title == "" {
729		panic("title is empty")
730	}
731
732	if len(title) > MaxThreadTitleLength {
733		n := strconv.Itoa(MaxThreadTitleLength)
734		panic("title is too long, maximum allowed is " + n + " characters")
735	}
736}
737
738func assertBodyIsNotEmpty(body string) {
739	if body == "" {
740		panic("body is empty")
741	}
742}
743
744func assertBoardNameNotExists(name string) {
745	name = strings.ToLower(name)
746	if _, found := gBoards.GetByName(name); found {
747		panic("board already exists")
748	}
749}
750
751func assertThreadExists(b *boards.Board, threadID boards.ID) {
752	if _, found := getThread(b, threadID); !found {
753		panic("thread not found: " + threadID.String())
754	}
755}
756
757func assertReplyExists(thread *boards.Post, replyID boards.ID) {
758	if _, found := getReply(thread, replyID); !found {
759		panic("reply not found: " + replyID.String())
760	}
761}
762
763func assertThreadIsVisible(thread *boards.Post) {
764	if thread.Hidden {
765		panic("thread is hidden")
766	}
767}
768
769func assertReplyIsVisible(thread *boards.Post) {
770	if thread.Hidden {
771		panic("reply is hidden")
772	}
773}
774
775func assertThreadBodyIsValid(body string) {
776	if len(body) > MaxThreadBodyLength {
777		n := strconv.Itoa(MaxThreadBodyLength)
778		panic("thread body is too long, maximum allowed is " + n + " characters")
779	}
780}
781
782func assertReplyBodyIsValid(body string) {
783	assertBodyIsNotEmpty(body)
784
785	if len(body) > MaxReplyLength {
786		n := strconv.Itoa(MaxReplyLength)
787		panic("reply is too long, maximum allowed is " + n + " characters")
788	}
789
790	// No markdown-structure or gno-form blacklist here: reply bodies
791	// render inside a <gno-foreign> sandbox (see indentForeignBody),
792	// which contains block structure and omits forms at render time.
793}
794
795func assertMembersUpdateIsEnabled(boardID boards.ID) {
796	if boardID != 0 {
797		assertRealmIsNotLocked()
798	} else {
799		assertRealmMembersAreNotLocked()
800	}
801}