diff --git a/app/(app)/chats/[id]/page.tsx b/app/(app)/chats/[id]/page.tsx
index 9ba2d268..72a13118 100644
--- a/app/(app)/chats/[id]/page.tsx
+++ b/app/(app)/chats/[id]/page.tsx
@@ -35,6 +35,7 @@ import { toast } from "sonner";
import { channelAuthorizer } from "@/lib/realtime";
import { cn } from "@/lib/utils";
import { useVisualViewport } from "@/lib/use-visual-viewport";
+import { ConfirmModal } from "@/components/ui/confirm-modal";
import { useNotifications } from "@/components/notifications/notifications-provider";
import {
chat,
@@ -71,6 +72,9 @@ export default function ChatThreadPage() {
const [replyTo, setReplyTo] = useState(null);
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
const [forwarding, setForwarding] = useState(null);
+ // Non-null asks before deleting for everyone. Deleting only for yourself is
+ // reversible by the others still having it, so it goes straight through.
+ const [confirmDelete, setConfirmDelete] = useState(null);
const [starredIds, setStarredIds] = useState>(new Set());
// user id → when they last typed. Kept as timestamps rather than a boolean
// so the indicator expires on its own: there is no "stopped typing" event,
@@ -295,12 +299,6 @@ export default function ChatThreadPage() {
const deleteMessage = useCallback(
async (m: ChatMessage, scope: "me" | "everyone") => {
- if (
- scope === "everyone" &&
- !window.confirm("Delete this message for everyone?")
- ) {
- return;
- }
try {
await chat.deleteMessage(id, m.id, scope);
if (scope === "me") {
@@ -695,7 +693,8 @@ export default function ChatThreadPage() {
// Only the sender may erase a message for everyone.
onDeleteForEveryone:
menu.message.sender_id === user.id
- ? () => void deleteMessage(menu.message, "everyone")
+ ? // Ask first — the modal calls deleteMessage on confirm.
+ () => setConfirmDelete(menu.message)
: undefined,
// Reporting your own message is meaningless.
onReport:
@@ -714,6 +713,17 @@ export default function ChatThreadPage() {
/>
)}
+ setConfirmDelete(null)}
+ title="Delete for everyone?"
+ message="This removes the message from the conversation for all participants. It can't be undone."
+ confirmLabel="Delete"
+ onConfirm={async () => {
+ if (confirmDelete) await deleteMessage(confirmDelete, "everyone");
+ }}
+ />
+
{forwarding && (
([]);
const ref = useRef(null);
const fileRef = useRef(null);
- const [pickingSticker, setPickingSticker] = useState(false);
+ // Non-null opens the sticker picker AND positions it: the popover hangs
+ // off the button's viewport rect, which only exists at click time.
+ const [stickerAnchor, setStickerAnchor] = useState(null);
const lookupSeq = useRef(0);
// Grow with the content, up to a cap.
@@ -248,61 +253,84 @@ export function ChatComposer({
e.target.value = "";
}}
/>
-
-
-
-
)}
+ setConfirmLeave(false)}
+ title="Leave this group?"
+ message={
+ detail.my_role === "owner"
+ ? "You own this group. The longest-serving admin becomes the owner in your place."
+ : "You'll stop receiving messages from this conversation."
+ }
+ confirmLabel="Leave"
+ onConfirm={leave}
+ />
);
}
diff --git a/components/chat/message-group.tsx b/components/chat/message-group.tsx
index bb6a410d..25501fbc 100644
--- a/components/chat/message-group.tsx
+++ b/components/chat/message-group.tsx
@@ -316,43 +316,53 @@ export function MessageGroup({
{m.reactions?.length > 0 && (
-