feat(P5C.T2): per-conv 'mentions only' toggle + notification gate
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
muteDurationToIso,
|
muteDurationToIso,
|
||||||
setConversationArchived,
|
setConversationArchived,
|
||||||
|
setConversationMentionsOnly,
|
||||||
setConversationMutedUntil,
|
setConversationMutedUntil,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
@@ -8,12 +9,13 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
mutedUntil: string | null;
|
mutedUntil: string | null;
|
||||||
|
mentionsOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MuteOption {
|
interface MuteOption {
|
||||||
@@ -50,7 +52,7 @@ interface MenuPos {
|
|||||||
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||||||
// computed from the trigger's bounding rect — menu anchors right-aligned
|
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
// 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 { t } = useTranslation(['app']);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||||
@@ -145,6 +147,21 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
|||||||
[conversationId],
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -196,6 +213,16 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
|||||||
}}
|
}}
|
||||||
hasSubmenu={!isMuted}
|
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>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -225,7 +225,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
(c) => c.id === row.conversation_id,
|
(c) => c.id === row.conversation_id,
|
||||||
);
|
);
|
||||||
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
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();
|
playNotificationTone();
|
||||||
const conv = conversationsRef.current.find(
|
const conv = conversationsRef.current.find(
|
||||||
(c) => c.id === row.conversation_id,
|
(c) => c.id === row.conversation_id,
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ function ConversationRow({
|
|||||||
conversationId={item.id}
|
conversationId={item.id}
|
||||||
archived={item.archived}
|
archived={item.archived}
|
||||||
mutedUntil={item.mutedUntil}
|
mutedUntil={item.mutedUntil}
|
||||||
|
mentionsOnly={item.mentionsOnly}
|
||||||
/>
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export type Database = {
|
|||||||
accepted: boolean
|
accepted: boolean
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
joined_at: string
|
joined_at: string
|
||||||
|
mentions_only: boolean
|
||||||
role: Database["public"]["Enums"]["member_role"]
|
role: Database["public"]["Enums"]["member_role"]
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
@@ -64,6 +65,7 @@ export type Database = {
|
|||||||
accepted?: boolean
|
accepted?: boolean
|
||||||
conversation_id: string
|
conversation_id: string
|
||||||
joined_at?: string
|
joined_at?: string
|
||||||
|
mentions_only?: boolean
|
||||||
role?: Database["public"]["Enums"]["member_role"]
|
role?: Database["public"]["Enums"]["member_role"]
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ export type Database = {
|
|||||||
accepted?: boolean
|
accepted?: boolean
|
||||||
conversation_id?: string
|
conversation_id?: string
|
||||||
joined_at?: string
|
joined_at?: string
|
||||||
|
mentions_only?: boolean
|
||||||
role?: Database["public"]["Enums"]["member_role"]
|
role?: Database["public"]["Enums"]["member_role"]
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
// cast the select to bypass typing.
|
// cast the select to bypass typing.
|
||||||
const { data: myMembers, error: mErr } = await client
|
const { data: myMembers, error: mErr } = await client
|
||||||
.from('conversation_members')
|
.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);
|
.eq('user_id', myId);
|
||||||
if (mErr) throw mErr;
|
if (mErr) throw mErr;
|
||||||
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
const myMembersList = (myMembers ?? []) as unknown as Array<{
|
||||||
@@ -44,6 +44,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
archived: boolean | null;
|
archived: boolean | null;
|
||||||
muted_until: string | null;
|
muted_until: string | null;
|
||||||
|
mentions_only: boolean | null;
|
||||||
}>;
|
}>;
|
||||||
if (myMembersList.length === 0) return [];
|
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)
|
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
||||||
: null;
|
: null;
|
||||||
const mineRow = mine as
|
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;
|
| undefined;
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -129,6 +136,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
|
|||||||
lastMessageAt: lastSeen.get(c.id) ?? null,
|
lastMessageAt: lastSeen.get(c.id) ?? null,
|
||||||
archived: mineRow?.archived ?? false,
|
archived: mineRow?.archived ?? false,
|
||||||
mutedUntil: mineRow?.muted_until ?? null,
|
mutedUntil: mineRow?.muted_until ?? null,
|
||||||
|
mentionsOnly: mineRow?.mentions_only ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -164,6 +172,24 @@ export async function setConversationMutedUntil(
|
|||||||
if (error) throw error;
|
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"
|
// Convenience: `null` unmutes, number means minutes from now. For "forever"
|
||||||
// pass a very large number (e.g. 100 years worth of minutes).
|
// pass a very large number (e.g. 100 years worth of minutes).
|
||||||
export function muteDurationToIso(minutes: number | null): string | null {
|
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
|
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
|
||||||
// not muted — the server row is kept for history until the next toggle).
|
// not muted — the server row is kept for history until the next toggle).
|
||||||
mutedUntil: string | null;
|
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 {
|
export interface ChatMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user