Skip to main content

max / makenotwork

Add communities.chat_policy, defaulting every community to off Chat is a live moderation burden, so the column backfills existing communities to off rather than enabling a surface nobody asked for. The enum lands in mt-core alongside CommunityState and carries the predicates the route gates will read: is_enabled (off must be total, no route and no hub room), allows_logged_out_read, and requires_fan_plus_to_write.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 00:08 UTC
Signed with PGP, not checked
Commit: 23544298594a3f1ca8118fdaf4ae6eb4099a897f
Parent: 2c67c72
2 files changed, +139 insertions, -0 deletions
@@ -126,6 +126,86 @@
126 126 }
127 127 }
128 128
129 + // ChatPolicy, per-community chat read/write policy
130 +
131 + /// Per-community chat policy (`communities.chat_policy`).
132 + ///
133 + /// Read access here is on top of what the community already allows; chat never
134 + /// widens forum access. Write access is always additionally gated by
135 + /// `check_write_access` (not suspended, not banned, not muted) and by
136 + /// [`CommunityState`], which puts `Frozen` and `Archived` communities in
137 + /// read-only regardless of the policy.
138 + ///
139 + /// - `Off`: no route, no hub room, no UI affordance. Nobody reads or writes.
140 + /// - `Members`: anyone who can view the forum reads; members write.
141 + /// - `PublicRead`: anyone reads, including logged out; members write.
142 + /// - `FanPlus`: anyone who can view the forum reads; Fan+ writes.
143 + ///
144 + /// The default is `Off`, including for communities that predate the column.
145 + /// Chat is a live moderation burden, so an owner opts into carrying it.
146 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, sqlx::Type)]
147 + #[sqlx(type_name = "TEXT", rename_all = "snake_case")]
148 + pub enum ChatPolicy {
149 + #[default]
150 + Off,
151 + Members,
152 + PublicRead,
153 + FanPlus,
154 + }
155 +
156 + impl Serialize for ChatPolicy {
157 + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
158 + serializer.serialize_str(self.as_str())
159 + }
160 + }
161 +
162 + impl ChatPolicy {
163 + /// Every policy, in the order the owner settings UI offers them.
164 + pub const ALL: [Self; 4] = [Self::Off, Self::Members, Self::PublicRead, Self::FanPlus];
165 +
166 + pub fn from_db(s: &str) -> Option<Self> {
167 + match s {
168 + "off" => Some(Self::Off),
169 + "members" => Some(Self::Members),
170 + "public_read" => Some(Self::PublicRead),
171 + "fan_plus" => Some(Self::FanPlus),
172 + _ => None,
173 + }
174 + }
175 +
176 + pub fn as_str(self) -> &'static str {
177 + match self {
178 + Self::Off => "off",
179 + Self::Members => "members",
180 + Self::PublicRead => "public_read",
181 + Self::FanPlus => "fan_plus",
182 + }
183 + }
184 +
185 + /// Whether chat exists at all for this community. `Off` must be total: the
186 + /// route 404s, no hub room is created, and no affordance renders.
187 + pub fn is_enabled(self) -> bool {
188 + !matches!(self, Self::Off)
189 + }
190 +
191 + /// Whether a logged-out visitor may read the room.
192 + pub fn allows_logged_out_read(self) -> bool {
193 + matches!(self, Self::PublicRead)
194 + }
195 +
196 + /// Whether writing requires `UserPerks::effective_plus()` on top of the
197 + /// usual good-standing check.
198 + pub fn requires_fan_plus_to_write(self) -> bool {
199 + matches!(self, Self::FanPlus)
200 + }
201 + }
202 +
203 + impl fmt::Display for ChatPolicy {
204 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 + f.write_str(self.as_str())
206 + }
207 + }
208 +
129 209 // BanType, ban or mute
130 210
131 211 /// Type of community restriction.
@@ -375,6 +455,43 @@
375 455 assert!(!CommunityState::Active.is_hidden_from_default_listing());
376 456 }
377 457
458 + #[test]
459 + fn chat_policy_roundtrip() {
460 + for p in ChatPolicy::ALL {
461 + assert_eq!(ChatPolicy::from_db(p.as_str()), Some(p));
462 + }
463 + assert_eq!(ChatPolicy::from_db("bogus"), None);
464 + // The stored strings are the migration's CHECK values, snake_case.
465 + assert_eq!(ChatPolicy::PublicRead.as_str(), "public_read");
466 + assert_eq!(ChatPolicy::FanPlus.as_str(), "fan_plus");
467 + }
468 +
469 + #[test]
470 + fn chat_policy_defaults_to_off() {
471 + assert_eq!(ChatPolicy::default(), ChatPolicy::Off);
472 + assert!(!ChatPolicy::Off.is_enabled());
473 + }
474 +
475 + #[test]
476 + fn chat_policy_predicates() {
477 + // Only public_read is readable logged out.
478 + assert!(ChatPolicy::PublicRead.allows_logged_out_read());
479 + for p in [ChatPolicy::Off, ChatPolicy::Members, ChatPolicy::FanPlus] {
480 + assert!(!p.allows_logged_out_read());
481 + }
482 +
483 + // Only fan_plus adds a tier requirement on top of good standing.
484 + assert!(ChatPolicy::FanPlus.requires_fan_plus_to_write());
485 + for p in [ChatPolicy::Off, ChatPolicy::Members, ChatPolicy::PublicRead] {
486 + assert!(!p.requires_fan_plus_to_write());
487 + }
488 +
489 + // Every mode but off is a live room.
490 + assert!(ChatPolicy::Members.is_enabled());
491 + assert!(ChatPolicy::PublicRead.is_enabled());
492 + assert!(ChatPolicy::FanPlus.is_enabled());
493 + }
494 +
378 495 #[test]
379 496 fn mod_action_display() {
380 497 assert_eq!(ModAction::PinThread.as_str(), "pin_thread");
@@ -1,0 +1,22 @@
1 + -- Per-community chat write/read policy.
2 + --
3 + -- Modes (see mt-core `ChatPolicy`, wiki livechat-design "Write policy"):
4 + -- off: no route, no hub room, no UI affordance. Nobody reads, nobody writes.
5 + -- members: anyone who can view the forum reads; members in good standing write.
6 + -- public_read: anyone reads, including logged out; members in good standing write.
7 + -- fan_plus: anyone who can view the forum reads; Fan+ writes.
8 + --
9 + -- "Members in good standing" is the existing `check_write_access` gate: not
10 + -- suspended, not banned, not muted. Read visibility above is on top of whatever
11 + -- the community itself already allows; chat never widens forum access.
12 + --
13 + -- Default is `off` for existing communities too, which is what the column
14 + -- default backfills here. Chat is a live moderation burden and enabling it
15 + -- silently would hand every owner a surface they never asked to carry.
16 + --
17 + -- Frozen and Archived communities are read-only for chat regardless of this
18 + -- column, matching `CommunityState::allows_writes_for_members()`.
19 +
20 + ALTER TABLE communities
21 + ADD COLUMN chat_policy TEXT NOT NULL DEFAULT 'off'
22 + CHECK (chat_policy IN ('off', 'members', 'public_read', 'fan_plus'));