Skip to content

Commit 5d1a96f

Browse files
authored
release: v5.20.0
RC: v5.20.0
2 parents f74e6dc + 6bd560a commit 5d1a96f

27 files changed

Lines changed: 3142 additions & 14 deletions

CHANGELOG.md

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

21+
## [5.20.0] - 2026-06-14
22+
23+
### Added
24+
25+
- Push notifications: turn them on from Settings to be notified about replies, new followers, anonymous messages, and group activity even when Umamin isn't open. They're off until you opt in, never show message contents, and keep anonymous messages anonymous. Works on Android and desktop browsers, and on iPhone or iPad once you add Umamin to your Home Screen.
26+
27+
### Fixed
28+
29+
- When Umamin runs as an installed app, the post composer, account menu, and full-screen image viewer no longer overlap the status bar or notch.
30+
2131
## [5.19.0] - 2026-06-13
2232

2333
### Added
@@ -742,6 +752,7 @@ Turso query cost, and a set of audit-driven correctness and security fixes.
742752
- Stopped logging raw errors that could contain usernames or token internals.
743753
- Added a daily cron that prunes expired sessions.
744754

755+
[5.20.0]: https://github.com/omsimos/umamin/compare/v5.19.0...v5.20.0
745756
[5.19.0]: https://github.com/omsimos/umamin/compare/v5.18.0...v5.19.0
746757
[5.18.0]: https://github.com/omsimos/umamin/compare/v5.17.0...v5.18.0
747758
[5.17.0]: https://github.com/omsimos/umamin/compare/v5.16.0...v5.17.0

apps/www/app/(private)/settings/components/privacy-settings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
import type { UserWithAccount } from "@/types/user";
2020
import { BlockedUsersSection } from "./blocked-users-section";
2121
import { BlockedWordsSection } from "./blocked-words-section";
22+
import { PushNotificationToggle } from "./push-notification-toggle";
2223

2324
export function PrivacySettings({ user }: { user: UserWithAccount }) {
2425
const queryClient = useQueryClient();
@@ -140,6 +141,8 @@ export function PrivacySettings({ user }: { user: UserWithAccount }) {
140141
onCheckedChange={() => quietModeMutation.mutate()}
141142
/>
142143
</div>
144+
145+
<PushNotificationToggle />
143146
</section>
144147

145148
<BlockedWordsSection user={user} />
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"use client";
2+
3+
import { useMutation, useQueryClient } from "@tanstack/react-query";
4+
import { Switch } from "@umamin/ui/components/switch";
5+
import { cn } from "@umamin/ui/lib/utils";
6+
import { BellIcon } from "lucide-react";
7+
import { useEffect, useState } from "react";
8+
import { toast } from "sonner";
9+
import {
10+
registerPushSubscriptionAction,
11+
unregisterPushSubscriptionAction,
12+
} from "@/app/actions/push";
13+
import { useSingleFlightAction } from "@/hooks/use-single-flight-action";
14+
import {
15+
getExistingSubscription,
16+
isIosWebPushBlocked,
17+
isPushSupported,
18+
subscribeToPush,
19+
unsubscribeFromPush,
20+
} from "@/lib/push-client";
21+
import { queryKeys } from "@/lib/query";
22+
import { patchCurrentUser } from "@/lib/query-cache";
23+
import type { CurrentUserResponse } from "@/lib/query-types";
24+
25+
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
26+
27+
// Effective state is reconciled from LIVE browser facts on mount, not from the
28+
// stored pushPrefs alone — the device's permission/subscription can diverge
29+
// from the server bit (cleared browser data, revoked permission, new device).
30+
type PushState =
31+
| "loading"
32+
| "unsupported"
33+
| "ios-install"
34+
| "denied"
35+
| "off"
36+
| "on";
37+
38+
const COPY: Record<Exclude<PushState, "loading">, string> = {
39+
unsupported: "Not available in this browser.",
40+
"ios-install":
41+
"Add Umamin to your Home Screen first to enable notifications.",
42+
denied:
43+
"Notifications are blocked. Re-enable them in your browser or device settings.",
44+
off: "Get notified about replies, follows, messages, and group activity.",
45+
on: "Get notified about replies, follows, messages, and group activity.",
46+
};
47+
48+
export function PushNotificationToggle() {
49+
const queryClient = useQueryClient();
50+
const [state, setState] = useState<PushState>("loading");
51+
52+
const register = useSingleFlightAction(registerPushSubscriptionAction);
53+
const unregister = useSingleFlightAction(unregisterPushSubscriptionAction);
54+
55+
// Sync the master pushPrefs into the current-user cache (read by /api/me).
56+
// null = unchanged (a per-device unsubscribe that left other devices on).
57+
const patchPushPrefs = (pushPrefs: number | null) => {
58+
if (pushPrefs === null) return;
59+
queryClient.setQueryData<CurrentUserResponse>(
60+
queryKeys.currentUser(),
61+
(current) =>
62+
patchCurrentUser(current, (user) => ({ ...user, pushPrefs })),
63+
);
64+
};
65+
66+
useEffect(() => {
67+
let active = true;
68+
69+
(async () => {
70+
if (!isPushSupported() || !VAPID_PUBLIC_KEY) {
71+
if (active) setState("unsupported");
72+
return;
73+
}
74+
if (isIosWebPushBlocked()) {
75+
if (active) setState("ios-install");
76+
return;
77+
}
78+
if (Notification.permission === "denied") {
79+
// Self-heal: a denied device can't show notifications, so prune any
80+
// lingering subscription server-side instead of fanning out to it.
81+
const sub = await getExistingSubscription();
82+
if (sub) {
83+
const { endpoint } = sub;
84+
await sub.unsubscribe().catch(() => {});
85+
void unregister({ endpoint });
86+
}
87+
if (active) setState("denied");
88+
return;
89+
}
90+
91+
const sub = await getExistingSubscription();
92+
if (active) setState(sub ? "on" : "off");
93+
})();
94+
95+
return () => {
96+
active = false;
97+
};
98+
}, [unregister]);
99+
100+
const enable = useMutation({
101+
mutationFn: async () => {
102+
const permission = await Notification.requestPermission();
103+
if (permission !== "granted") {
104+
throw new Error(
105+
permission === "denied"
106+
? "Notifications are blocked. Re-enable them in your browser or device settings."
107+
: "Notification permission was not granted.",
108+
);
109+
}
110+
const sub = await subscribeToPush(VAPID_PUBLIC_KEY as string);
111+
const res = await register(sub);
112+
if ("error" in res) throw new Error(res.error);
113+
return res.pushPrefs;
114+
},
115+
onSuccess: (pushPrefs) => {
116+
patchPushPrefs(pushPrefs);
117+
setState("on");
118+
toast.success("Push notifications enabled.");
119+
},
120+
onError: (err: Error) => {
121+
if (
122+
typeof Notification !== "undefined" &&
123+
Notification.permission === "denied"
124+
) {
125+
setState("denied");
126+
}
127+
toast.error(err.message ?? "Couldn't enable notifications.");
128+
},
129+
});
130+
131+
const disable = useMutation({
132+
mutationFn: async () => {
133+
const endpoint = await unsubscribeFromPush();
134+
if (!endpoint) return null;
135+
const res = await unregister({ endpoint });
136+
if ("error" in res) throw new Error(res.error);
137+
return res.pushPrefs;
138+
},
139+
onSuccess: (pushPrefs) => {
140+
patchPushPrefs(pushPrefs);
141+
setState("off");
142+
toast.success("Push notifications disabled.");
143+
},
144+
onError: (err: Error) => {
145+
toast.error(err.message ?? "Couldn't disable notifications.");
146+
},
147+
});
148+
149+
const isPending = enable.isPending || disable.isPending;
150+
const actionable = state === "on" || state === "off";
151+
const warn =
152+
state === "denied" || state === "ios-install" || state === "unsupported";
153+
154+
return (
155+
<div className="flex items-center space-x-4 rounded-md border p-4 mt-4">
156+
<BellIcon className="size-6" />
157+
<div className="flex-1 space-y-1">
158+
<p className="text-sm font-medium leading-none">Push Notifications</p>
159+
<p
160+
className={cn(
161+
"text-sm",
162+
warn ? "text-yellow-600" : "text-muted-foreground",
163+
)}
164+
>
165+
{state === "loading" ? "Checking notification status…" : COPY[state]}
166+
</p>
167+
</div>
168+
<Switch
169+
disabled={isPending || !actionable}
170+
checked={state === "on"}
171+
onCheckedChange={(checked) =>
172+
checked ? enable.mutate() : disable.mutate()
173+
}
174+
/>
175+
</div>
176+
);
177+
}

apps/www/app/(public)/user/[username]/user-profile.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ describe("UserProfile", () => {
9696
username: "viewer",
9797
hasPassword: true,
9898
blockedWords: null,
99+
pushPrefs: 0,
99100
accounts: [],
100101
},
101102
} satisfies CurrentUserResponse);

apps/www/app/actions/push.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"use server";
2+
3+
import { db } from "@umamin/db";
4+
import { pushSubscriptionTable } from "@umamin/db/schema/push-subscription";
5+
import { userTable } from "@umamin/db/schema/user";
6+
import { and, desc, eq, notInArray } from "drizzle-orm";
7+
import { updateTag } from "next/cache";
8+
import { z } from "zod";
9+
import { isAllowedPushEndpoint } from "@/lib/push-endpoint";
10+
import { ALL_PUSH_CATEGORIES } from "@/lib/push-prefs";
11+
import { withAction } from "@/lib/server/with-action";
12+
13+
// Generous bound on devices per account (a real user has 1-5). Caps
14+
// self-inflicted row growth / fan-out bloat, mirroring the codebase's
15+
// cap-everything convention; register can't touch another user's rows.
16+
const PUSH_DEVICE_CAP = 20;
17+
18+
// The endpoint becomes an outbound request target (web-push), so constrain it
19+
// to a public https:// URL here — z.url() alone accepts file:/internal-IP/etc.
20+
const endpointSchema = z
21+
.string()
22+
.max(512)
23+
.refine(isAllowedPushEndpoint, "Invalid push endpoint");
24+
25+
const registerSchema = z.object({
26+
endpoint: endpointSchema,
27+
p256dh: z.string().min(1).max(255),
28+
auth: z.string().min(1).max(255),
29+
});
30+
31+
// Invalidate the caller's own cached records (pushPrefs rides the current-user
32+
// read). updateTag is read-your-writes + Server-Actions-only.
33+
function revalidateSelf(userId: string, username: string) {
34+
updateTag(`user:${username}`);
35+
updateTag(`user:${userId}`);
36+
updateTag(`user:${userId}:accounts`);
37+
}
38+
39+
/**
40+
* Persists this device's push subscription and turns the master toggle on.
41+
* The endpoint is a per-device bearer capability; the row is always written
42+
* with the SESSION's userId (never a client-supplied id). Idempotent: a
43+
* re-subscribe (same endpoint) updates in place, and a device that moved
44+
* accounts re-points its userId. Programmatic-call only (never <form action>).
45+
*/
46+
export const registerPushSubscriptionAction = withAction(
47+
{
48+
schema: registerSchema,
49+
auth: "user",
50+
rateLimit: { name: "write", key: ({ user }) => `push:${user.id}` },
51+
},
52+
async ({ endpoint, p256dh, auth }, { user }) => {
53+
await db
54+
.insert(pushSubscriptionTable)
55+
.values({ userId: user.id, endpoint, p256dh, auth })
56+
.onConflictDoUpdate({
57+
target: pushSubscriptionTable.endpoint,
58+
set: { userId: user.id, p256dh, auth, failureCount: 0 },
59+
});
60+
61+
// Keep only the most recent PUSH_DEVICE_CAP devices for this user (bounded,
62+
// user-indexed; a no-op while under the cap).
63+
await db
64+
.delete(pushSubscriptionTable)
65+
.where(
66+
and(
67+
eq(pushSubscriptionTable.userId, user.id),
68+
notInArray(
69+
pushSubscriptionTable.id,
70+
db
71+
.select({ id: pushSubscriptionTable.id })
72+
.from(pushSubscriptionTable)
73+
.where(eq(pushSubscriptionTable.userId, user.id))
74+
.orderBy(desc(pushSubscriptionTable.createdAt))
75+
.limit(PUSH_DEVICE_CAP),
76+
),
77+
),
78+
);
79+
80+
// Opting in on a device enables push for the account. v1 is all-or-nothing
81+
// (ALL or 0); Phase 2's per-category UI will preserve an existing subset.
82+
await db
83+
.update(userTable)
84+
.set({ pushPrefs: ALL_PUSH_CATEGORIES })
85+
.where(eq(userTable.id, user.id));
86+
87+
revalidateSelf(user.id, user.username);
88+
return { pushPrefs: ALL_PUSH_CATEGORIES };
89+
},
90+
);
91+
92+
const unregisterSchema = z.object({
93+
endpoint: endpointSchema,
94+
});
95+
96+
/**
97+
* Removes this device's subscription. The delete is scoped to the caller's own
98+
* rows (endpoint AND userId) so one user can't prune another's subscription.
99+
* Clears the master toggle only when no devices remain — so disabling on one
100+
* device (or self-healing a stale row) doesn't silence the user's other
101+
* devices. Returns the new pushPrefs, or null when it's unchanged.
102+
*/
103+
export const unregisterPushSubscriptionAction = withAction(
104+
{
105+
schema: unregisterSchema,
106+
auth: "user",
107+
rateLimit: { name: "write", key: ({ user }) => `push:${user.id}` },
108+
},
109+
async ({ endpoint }, { user }) => {
110+
await db
111+
.delete(pushSubscriptionTable)
112+
.where(
113+
and(
114+
eq(pushSubscriptionTable.endpoint, endpoint),
115+
eq(pushSubscriptionTable.userId, user.id),
116+
),
117+
);
118+
119+
const [remaining] = await db
120+
.select({ id: pushSubscriptionTable.id })
121+
.from(pushSubscriptionTable)
122+
.where(eq(pushSubscriptionTable.userId, user.id))
123+
.limit(1);
124+
125+
if (remaining) {
126+
return { pushPrefs: null };
127+
}
128+
129+
await db
130+
.update(userTable)
131+
.set({ pushPrefs: 0 })
132+
.where(eq(userTable.id, user.id));
133+
134+
revalidateSelf(user.id, user.username);
135+
return { pushPrefs: 0 };
136+
},
137+
);

apps/www/components/compose-dialog.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,12 @@ export function ComposeDialog({
192192
)}
193193

194194
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
195-
<header className="flex items-center justify-between border-b px-4 py-3">
195+
{/* Full-screen on mobile (h-dvh): inset the header below the notch in
196+
standalone PWA. Resolves to py-3 off-PWA / on desktop. */}
197+
<header
198+
className="flex items-center justify-between border-b px-4 py-3"
199+
style={{ paddingTop: "calc(0.75rem + env(safe-area-inset-top))" }}
200+
>
196201
<Button
197202
type="button"
198203
variant="ghost"
@@ -287,7 +292,12 @@ export function ComposeDialog({
287292
</div>
288293
</div>
289294

290-
<footer className="flex items-center gap-1 border-t px-3 py-2">
295+
<footer
296+
className="flex items-center gap-1 border-t px-3 py-2"
297+
style={{
298+
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
299+
}}
300+
>
291301
{imagesAvailable && (
292302
<>
293303
<input

0 commit comments

Comments
 (0)