Skip to content

Commit 2f067c0

Browse files
authored
Merge pull request #326 from omsimos/dev
feat(social): disable group chat behind a kill switch to cut polling
2 parents 8f2c32c + ba30bdc commit 2f067c0

17 files changed

Lines changed: 141 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ any that are empty.
1818
- **Deprecated** — features marked for removal.
1919
- **Removed** — features removed.
2020

21+
## [6.0.1] - 2026-07-10
22+
23+
### Changed
24+
25+
- Group chat is temporarily unavailable while we work on it. Groups, tags, members, and invites are unaffected — opening a group now shows a short notice in place of the chat.
26+
2127
## [6.0.0] - 2026-07-04
2228

2329
### Added
@@ -818,6 +824,7 @@ Turso query cost, and a set of audit-driven correctness and security fixes.
818824
- Stopped logging raw errors that could contain usernames or token internals.
819825
- Added a daily cron that prunes expired sessions.
820826

827+
[6.0.1]: https://github.com/omsimos/umamin/compare/v6.0.0...v6.0.1
821828
[6.0.0]: https://github.com/omsimos/umamin/compare/v5.26.0...v6.0.0
822829
[5.26.0]: https://github.com/omsimos/umamin/compare/v5.25.0...v5.26.0
823830
[5.25.0]: https://github.com/omsimos/umamin/compare/v5.24.0...v5.25.0

apps/org/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "0.1.0",
44
"private": true,
55
"scripts": {
6-
"dev": "next dev --turbopack",
6+
"dev": "next dev --turbopack -p 3001",
77
"ui:add": "pnpm dlx shadcn@canary add",
88
"build": "next build --turbopack",
99
"check-types": "tsc --noEmit",

apps/www/app/(private)/groups/groups-hub.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { respondToInviteAction } from "@/app/actions/group";
1919
import { CreateGroupDialog } from "@/components/create-group-dialog";
2020
import { useSingleFlightAction } from "@/hooks/use-single-flight-action";
2121
import {
22+
GROUP_CHAT_ENABLED,
2223
type GroupAccent,
2324
type GroupIcon,
2425
OWNED_GROUPS_CAP,
@@ -71,10 +72,12 @@ export function GroupsHub() {
7172
});
7273

7374
// Unread dots — refetched on focus so returning to the hub reflects new
74-
// activity (and clears once a room is opened + marked read).
75+
// activity (and clears once a room is opened + marked read). Skipped while
76+
// group chat is off so the hub stops polling /api/groups/unread.
7577
const { data: unread } = useQuery({
7678
queryKey: queryKeys.groupUnread(),
7779
queryFn: fetchGroupUnread,
80+
enabled: GROUP_CHAT_ENABLED,
7881
staleTime: PRIVATE_STALE_TIME,
7982
refetchOnWindowFocus: true,
8083
});
@@ -237,16 +240,18 @@ export function GroupsHub() {
237240
<div>
238241
{/* Row → info page (unchanged); this icon jumps straight to
239242
chat. The unread dot lives on the glyph above. */}
240-
<Button
241-
asChild
242-
size="icon"
243-
variant="ghost"
244-
aria-label={`Open ${group.name} chat`}
245-
>
246-
<Link href={`/groups/${group.tag}/chat`}>
247-
<MessageCircleIcon />
248-
</Link>
249-
</Button>
243+
{GROUP_CHAT_ENABLED && (
244+
<Button
245+
asChild
246+
size="icon"
247+
variant="ghost"
248+
aria-label={`Open ${group.name} chat`}
249+
>
250+
<Link href={`/groups/${group.tag}/chat`}>
251+
<MessageCircleIcon />
252+
</Link>
253+
</Button>
254+
)}
250255

251256
{role === "owner" && (
252257
<Button

apps/www/app/(public)/groups/[tag]/chat/page.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { Button } from "@umamin/ui/components/button";
2+
import { MessageCircleOffIcon } from "lucide-react";
13
import type { Metadata } from "next";
4+
import Link from "next/link";
25
import { notFound, redirect } from "next/navigation";
36
import { Suspense } from "react";
47
import { getSession } from "@/lib/auth";
8+
import { GROUP_CHAT_ENABLED } from "@/lib/group";
59
import {
610
getGroupPageData,
711
getGroupViewerRelationship,
@@ -27,8 +31,32 @@ export default function GroupChatPage({
2731
);
2832
}
2933

34+
function ChatDisabled({ tag }: { tag: string }) {
35+
return (
36+
<div className="mx-auto flex min-h-[60vh] max-w-md flex-col items-center justify-center px-6 text-center">
37+
<div className="flex size-14 items-center justify-center rounded-full bg-muted">
38+
<MessageCircleOffIcon className="size-7 text-muted-foreground" />
39+
</div>
40+
<h1 className="mt-4 text-lg font-semibold">Group chat is unavailable</h1>
41+
<p className="mt-2 text-sm text-muted-foreground">
42+
We've temporarily turned off group chat while we work on it. Your group
43+
and its members are unchanged — check back soon.
44+
</p>
45+
<Button asChild variant="outline" className="mt-6">
46+
<Link href={`/groups/${tag}`}>Back to group</Link>
47+
</Button>
48+
</div>
49+
);
50+
}
51+
3052
async function ChatLoader({ params }: { params: Promise<{ tag: string }> }) {
3153
const { tag } = await params;
54+
55+
// Feature is off — bail before any session/DB work and show a disabled state.
56+
if (!GROUP_CHAT_ENABLED) {
57+
return <ChatDisabled tag={tag} />;
58+
}
59+
3260
const [group, { session }] = await Promise.all([
3361
getGroupPageData(tag),
3462
getSession(),

apps/www/app/(public)/groups/[tag]/group-page-client.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
EllipsisIcon,
4848
Loader2Icon,
4949
MessageCircleIcon,
50+
MessageCircleOffIcon,
5051
ScanFaceIcon,
5152
SquarePenIcon,
5253
TagIcon,
@@ -73,7 +74,11 @@ import {
7374
} from "@/app/actions/group";
7475
import { GroupEditDialog } from "@/components/group-edit-dialog";
7576
import { useSingleFlightAction } from "@/hooks/use-single-flight-action";
76-
import type { GroupAccent, GroupIcon } from "@/lib/group";
77+
import {
78+
GROUP_CHAT_ENABLED,
79+
type GroupAccent,
80+
type GroupIcon,
81+
} from "@/lib/group";
7782
import { GROUP_ACCENT_CLASSES, GROUP_ICON_MAP } from "@/lib/group-icons";
7883
import { vibrate } from "@/lib/haptics";
7984
import {
@@ -402,12 +407,20 @@ export function GroupPageClient({
402407
{isMember && (
403408
<div className="space-y-2">
404409
{/* Open chat is the sole primary action; the tag toggle stays
405-
secondary so two filled CTAs never compete. */}
406-
<Button asChild className="w-full">
407-
<Link href={`/groups/${tag}/chat`}>
408-
<MessageCircleIcon /> Open chat
409-
</Link>
410-
</Button>
410+
secondary so two filled CTAs never compete. While chat is off it's
411+
a quiet notice (not a CTA), leaving the tag toggle as the action. */}
412+
{GROUP_CHAT_ENABLED ? (
413+
<Button asChild className="w-full">
414+
<Link href={`/groups/${tag}/chat`}>
415+
<MessageCircleIcon /> Open chat
416+
</Link>
417+
</Button>
418+
) : (
419+
<div className="flex items-center gap-2.5 rounded-lg border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
420+
<MessageCircleOffIcon className="size-4 shrink-0" />
421+
<span>Group chat is temporarily unavailable.</span>
422+
</div>
423+
)}
411424

412425
<div className="flex items-center gap-2">
413426
<Button

apps/www/app/actions/group-chat.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ vi.mock("@umamin/encryption", () => ({
8282
aesEncrypt: vi.fn(async (s: string) => `enc:${s}`),
8383
}));
8484

85+
// Group chat ships OFF behind a kill switch; force it on here so these tests
86+
// verify the underlying send/react/delete logic (which must still work when the
87+
// flag is flipped back on).
88+
vi.mock("@/lib/group", async (importOriginal) => ({
89+
...(await importOriginal<typeof import("@/lib/group")>()),
90+
GROUP_CHAT_ENABLED: true,
91+
}));
92+
8593
import {
8694
deleteGroupMessageAction,
8795
reactToGroupMessageAction,

apps/www/app/actions/group-chat.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { aesEncrypt } from "@umamin/encryption";
1212
import { and, desc, eq, inArray, sql } from "drizzle-orm";
1313
import { updateTag } from "next/cache";
1414
import * as z from "zod";
15-
import { GROUP_CHAT_REACTION_EMOJIS } from "@/lib/group";
15+
import {
16+
GROUP_CHAT_DISABLED_ERROR,
17+
GROUP_CHAT_ENABLED,
18+
GROUP_CHAT_REACTION_EMOJIS,
19+
} from "@/lib/group";
1620
import { redis } from "@/lib/redis";
1721
import { idSchema } from "@/lib/schema";
1822
import { UNAUTHORIZED_ERROR } from "@/lib/server/errors";
@@ -74,6 +78,9 @@ export const sendGroupMessageAction = withAction(
7478
},
7579
},
7680
async ({ groupId, content, replyToMessageId }, { session }) => {
81+
if (!GROUP_CHAT_ENABLED) {
82+
return { error: GROUP_CHAT_DISABLED_ERROR };
83+
}
7784
if (!(await isGroupMember(groupId, session.userId))) {
7885
return { error: UNAUTHORIZED_ERROR };
7986
}
@@ -185,6 +192,9 @@ export const markGroupChatReadAction = withAction(
185192
},
186193
},
187194
async ({ groupId, lastReadMessageId }, { session }) => {
195+
if (!GROUP_CHAT_ENABLED) {
196+
return { error: GROUP_CHAT_DISABLED_ERROR };
197+
}
188198
if (!(await isGroupMember(groupId, session.userId))) {
189199
return { error: UNAUTHORIZED_ERROR };
190200
}
@@ -220,6 +230,9 @@ export const deleteGroupMessageAction = withAction(
220230
},
221231
},
222232
async ({ groupId, messageId }, { session }) => {
233+
if (!GROUP_CHAT_ENABLED) {
234+
return { error: GROUP_CHAT_DISABLED_ERROR };
235+
}
223236
const [message] = await db
224237
.select({ senderId: groupMessageTable.senderId })
225238
.from(groupMessageTable)
@@ -298,6 +311,9 @@ export const reactToGroupMessageAction = withAction(
298311
},
299312
},
300313
async ({ groupId, messageId, emoji }, { session }) => {
314+
if (!GROUP_CHAT_ENABLED) {
315+
return { error: GROUP_CHAT_DISABLED_ERROR };
316+
}
301317
if (!(GROUP_CHAT_REACTION_EMOJIS as readonly string[]).includes(emoji)) {
302318
return { error: "Invalid reaction" };
303319
}

apps/www/app/api/groups/[tag]/chat/head/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { NextRequest } from "next/server";
2+
import { GROUP_CHAT_ENABLED } from "@/lib/group";
23
import { publicJson } from "@/lib/public-json";
34
import { redis } from "@/lib/redis";
45
import {
@@ -19,6 +20,12 @@ import {
1920
const HEAD_CACHE_SECONDS = 8;
2021

2122
export async function GET(req: NextRequest) {
23+
// Chat is off — keep the response cacheable so stale clients' polls collapse
24+
// at the CDN and never touch Redis.
25+
if (!GROUP_CHAT_ENABLED) {
26+
return publicJson({ tail: null, rxn: null }, HEAD_CACHE_SECONDS);
27+
}
28+
2229
const groupId = new URL(req.url).searchParams.get("id");
2330

2431
if (!redis || !groupId) {

apps/www/app/api/groups/[tag]/chat/reactions/[messageId]/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { NextRequest } from "next/server";
22
import { getSession } from "@/lib/auth";
3+
import { GROUP_CHAT_DISABLED_ERROR, GROUP_CHAT_ENABLED } from "@/lib/group";
34
import { privateJson } from "@/lib/private-json";
45
import { checkRateLimit, RATE_LIMIT_ERROR } from "@/lib/ratelimit";
56
import {
@@ -16,6 +17,10 @@ export async function GET(
1617
ctx: { params: Promise<{ tag: string; messageId: string }> },
1718
) {
1819
try {
20+
if (!GROUP_CHAT_ENABLED) {
21+
return privateJson({ error: GROUP_CHAT_DISABLED_ERROR }, { status: 403 });
22+
}
23+
1924
const { session } = await getSession();
2025
if (!session) {
2126
return privateJson({ error: "Unauthorized" }, { status: 401 });

apps/www/app/api/groups/[tag]/chat/reactions/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { NextRequest } from "next/server";
22
import { getSession } from "@/lib/auth";
3+
import { GROUP_CHAT_DISABLED_ERROR, GROUP_CHAT_ENABLED } from "@/lib/group";
34
import { privateJson } from "@/lib/private-json";
45
import { checkRateLimit, RATE_LIMIT_ERROR } from "@/lib/ratelimit";
56
import {
@@ -16,6 +17,10 @@ export async function GET(
1617
ctx: { params: Promise<{ tag: string }> },
1718
) {
1819
try {
20+
if (!GROUP_CHAT_ENABLED) {
21+
return privateJson({ error: GROUP_CHAT_DISABLED_ERROR }, { status: 403 });
22+
}
23+
1924
const { session } = await getSession();
2025
if (!session) {
2126
return privateJson({ error: "Unauthorized" }, { status: 401 });

0 commit comments

Comments
 (0)