diff --git a/apps/desktop/src/components/ConversationRowMenu.tsx b/apps/desktop/src/components/ConversationRowMenu.tsx
index a06e924..ef6cec6 100644
--- a/apps/desktop/src/components/ConversationRowMenu.tsx
+++ b/apps/desktop/src/components/ConversationRowMenu.tsx
@@ -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 (
<>
+ }
+ label={
+ (mentionsOnly ? '✓ ' : '') +
+ t('app:chats.mentions_only', {
+ defaultValue: 'Nur bei @Mentions benachrichtigen',
+ })
+ }
+ onClick={() => void handleMentionsOnly(!mentionsOnly)}
+ />
,
document.body,
)}
diff --git a/apps/desktop/src/context/ConversationsContext.tsx b/apps/desktop/src/context/ConversationsContext.tsx
index 5a45f56..cee2fe6 100644
--- a/apps/desktop/src/context/ConversationsContext.tsx
+++ b/apps/desktop/src/context/ConversationsContext.tsx
@@ -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,
diff --git a/apps/desktop/src/pages/ChatsPage.tsx b/apps/desktop/src/pages/ChatsPage.tsx
index 4d6268c..02ca059 100644
--- a/apps/desktop/src/pages/ChatsPage.tsx
+++ b/apps/desktop/src/pages/ChatsPage.tsx
@@ -425,6 +425,7 @@ function ConversationRow({
conversationId={item.id}
archived={item.archived}
mutedUntil={item.mutedUntil}
+ mentionsOnly={item.mentionsOnly}
/>
);
diff --git a/packages/db-types/src/index.ts b/packages/db-types/src/index.ts
index 5684173..2cd8ee4 100644
--- a/packages/db-types/src/index.ts
+++ b/packages/db-types/src/index.ts
@@ -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
}
diff --git a/packages/shared/src/chat/conversations.ts b/packages/shared/src/chat/conversations.ts
index 73730dc..08379ba 100644
--- a/packages/shared/src/chat/conversations.ts
+++ b/packages/shared/src/chat/conversations.ts
@@ -35,7 +35,7 @@ export async function listConversations(client: AppSupabaseClient): Promise;
if (myMembersList.length === 0) return [];
@@ -114,7 +115,13 @@ export async function listConversations(client: AppSupabaseClient): Promise 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 {
+ 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 {
diff --git a/packages/shared/src/chat/types.ts b/packages/shared/src/chat/types.ts
index ada3f07..20a9305 100644
--- a/packages/shared/src/chat/types.ts
+++ b/packages/shared/src/chat/types.ts
@@ -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 {