feat(P5C.T2): per-conv 'mentions only' toggle + notification gate
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
muteDurationToIso,
|
||||
setConversationArchived,
|
||||
setConversationMentionsOnly,
|
||||
setConversationMutedUntil,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||
@@ -8,12 +9,13 @@ import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversationId: string;
|
||||
archived: boolean;
|
||||
mutedUntil: string | null;
|
||||
mentionsOnly: boolean;
|
||||
}
|
||||
|
||||
interface MuteOption {
|
||||
@@ -50,7 +52,7 @@ interface MenuPos {
|
||||
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||||
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
|
||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||
@@ -145,6 +147,21 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const handleMentionsOnly = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
try {
|
||||
await setConversationMentionsOnly(supabase, {
|
||||
conversationId,
|
||||
mentionsOnly: next,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.warn('mentions-only toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -196,6 +213,16 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
}}
|
||||
hasSubmenu={!isMuted}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<AtIcon className="h-4 w-4" />}
|
||||
label={
|
||||
(mentionsOnly ? '✓ ' : '') +
|
||||
t('app:chats.mentions_only', {
|
||||
defaultValue: 'Nur bei @Mentions benachrichtigen',
|
||||
})
|
||||
}
|
||||
onClick={() => void handleMentionsOnly(!mentionsOnly)}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -225,7 +225,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
(c) => c.id === row.conversation_id,
|
||||
);
|
||||
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
||||
if (presenceRef.current !== 'dnd' && !muted) {
|
||||
// "Mentions only" silences non-mention messages here. Mentions
|
||||
// still fire via the independent useMentionNotifications
|
||||
// subscription on message_mentions, so this branch doesn't
|
||||
// lose the @-alerts.
|
||||
const mentionsOnly = convForMute?.mentionsOnly ?? false;
|
||||
if (presenceRef.current !== 'dnd' && !muted && !mentionsOnly) {
|
||||
playNotificationTone();
|
||||
const conv = conversationsRef.current.find(
|
||||
(c) => c.id === row.conversation_id,
|
||||
|
||||
@@ -425,6 +425,7 @@ function ConversationRow({
|
||||
conversationId={item.id}
|
||||
archived={item.archived}
|
||||
mutedUntil={item.mutedUntil}
|
||||
mentionsOnly={item.mentionsOnly}
|
||||
/>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@ export type Database = {
|
||||
accepted: boolean
|
||||
conversation_id: string
|
||||
joined_at: string
|
||||
mentions_only: boolean
|
||||
role: Database["public"]["Enums"]["member_role"]
|
||||
user_id: string
|
||||
}
|
||||
@@ -64,6 +65,7 @@ export type Database = {
|
||||
accepted?: boolean
|
||||
conversation_id: string
|
||||
joined_at?: string
|
||||
mentions_only?: boolean
|
||||
role?: Database["public"]["Enums"]["member_role"]
|
||||
user_id: string
|
||||
}
|
||||
@@ -71,6 +73,7 @@ export type Database = {
|
||||
accepted?: boolean
|
||||
conversation_id?: string
|
||||
joined_at?: string
|
||||
mentions_only?: boolean
|
||||
role?: Database["public"]["Enums"]["member_role"]
|
||||
user_id?: string
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
||||
// cast the select to bypass typing.
|
||||
const { data: myMembers, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('conversation_id, role, accepted, archived, muted_until' as '*')
|
||||
.select('conversation_id, role, accepted, archived, muted_until, mentions_only' as '*')
|
||||
.eq('user_id', myId);
|
||||
if (mErr) throw mErr;
|
||||
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
||||
@@ -44,6 +44,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
||||
accepted: boolean;
|
||||
archived: boolean | null;
|
||||
muted_until: string | null;
|
||||
mentions_only: boolean | null;
|
||||
}>;
|
||||
if (myMembersList.length === 0) return [];
|
||||
|
||||
@@ -114,7 +115,13 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
||||
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
||||
: null;
|
||||
const mineRow = mine as
|
||||
| { accepted: boolean; role: string; archived?: boolean; muted_until?: string | null }
|
||||
| {
|
||||
accepted: boolean;
|
||||
role: string;
|
||||
archived?: boolean;
|
||||
muted_until?: string | null;
|
||||
mentions_only?: boolean | null;
|
||||
}
|
||||
| undefined;
|
||||
return {
|
||||
id: c.id,
|
||||
@@ -129,6 +136,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
||||
lastMessageAt: lastSeen.get(c.id) ?? null,
|
||||
archived: mineRow?.archived ?? false,
|
||||
mutedUntil: mineRow?.muted_until ?? null,
|
||||
mentionsOnly: mineRow?.mentions_only ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -164,6 +172,24 @@ export async function setConversationMutedUntil(
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Toggle "mentions only" — when true the renderer's notification gate
|
||||
// suppresses non-mention alerts for this conversation. Mentions still fire
|
||||
// via the independent useMentionNotifications subscription on
|
||||
// message_mentions, so the @-alerts are never lost.
|
||||
export async function setConversationMentionsOnly(
|
||||
client: AppSupabaseClient,
|
||||
params: { conversationId: string; mentionsOnly: boolean },
|
||||
): Promise<void> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { error } = await client
|
||||
.from('conversation_members')
|
||||
.update({ mentions_only: params.mentionsOnly } as never)
|
||||
.eq('conversation_id', params.conversationId)
|
||||
.eq('user_id', session.user.id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Convenience: `null` unmutes, number means minutes from now. For "forever"
|
||||
// pass a very large number (e.g. 100 years worth of minutes).
|
||||
export function muteDurationToIso(minutes: number | null): string | null {
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface ConversationSummary {
|
||||
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
|
||||
// not muted — the server row is kept for history until the next toggle).
|
||||
mutedUntil: string | null;
|
||||
// When true the renderer suppresses non-mention notifications. Mentions
|
||||
// still fire via the independent useMentionNotifications subscription on
|
||||
// message_mentions, so this flag never silences @-alerts.
|
||||
mentionsOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
Reference in New Issue
Block a user