diff --git a/app/migrations/_059_channel_muted.py b/app/migrations/_059_channel_muted.py new file mode 100644 index 0000000..21b6401 --- /dev/null +++ b/app/migrations/_059_channel_muted.py @@ -0,0 +1,23 @@ +import logging + +import aiosqlite + +logger = logging.getLogger(__name__) + + +async def migrate(conn: aiosqlite.Connection) -> None: + """Add muted column to channels table.""" + table_check = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='channels'" + ) + if not await table_check.fetchone(): + await conn.commit() + return + + cursor = await conn.execute("PRAGMA table_info(channels)") + columns = {row[1] for row in await cursor.fetchall()} + + if "muted" not in columns: + await conn.execute("ALTER TABLE channels ADD COLUMN muted INTEGER DEFAULT 0") + + await conn.commit() diff --git a/app/models.py b/app/models.py index b3ab9c3..9167319 100644 --- a/app/models.py +++ b/app/models.py @@ -346,6 +346,7 @@ class Channel(BaseModel): ) last_read_at: int | None = None # Server-side read state tracking favorite: bool = False + muted: bool = False class ChannelMessageCounts(BaseModel): diff --git a/app/push/manager.py b/app/push/manager.py index b9dad29..85d00a6 100644 --- a/app/push/manager.py +++ b/app/push/manager.py @@ -14,6 +14,7 @@ from pywebpush import WebPushException from app.push.send import send_push from app.push.vapid import get_vapid_private_key +from app.repository.channels import ChannelRepository from app.repository.push_subscriptions import PushSubscriptionRepository from app.repository.settings import AppSettingsRepository @@ -102,6 +103,15 @@ class PushManager: if state_key not in push_conversations: return + # Skip muted channels + if data.get("type") == "CHAN" and data.get("conversation_key"): + try: + ch = await ChannelRepository.get_by_key(data["conversation_key"]) + if ch and ch.muted: + return + except Exception: + logger.debug("Push dispatch: failed to check channel mute state", exc_info=True) + try: subs = await PushSubscriptionRepository.get_all() except Exception: diff --git a/app/repository/channels.py b/app/repository/channels.py index efe5a90..19f8ccf 100644 --- a/app/repository/channels.py +++ b/app/repository/channels.py @@ -28,7 +28,7 @@ class ChannelRepository: async with db.readonly() as conn: async with conn.execute( """ - SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite + SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted FROM channels WHERE key = ? """, @@ -45,6 +45,7 @@ class ChannelRepository: path_hash_mode_override=row["path_hash_mode_override"], last_read_at=row["last_read_at"], favorite=bool(row["favorite"]), + muted=bool(row["muted"]), ) return None @@ -53,7 +54,7 @@ class ChannelRepository: async with db.readonly() as conn: async with conn.execute( """ - SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite + SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted FROM channels ORDER BY name """ @@ -69,6 +70,7 @@ class ChannelRepository: path_hash_mode_override=row["path_hash_mode_override"], last_read_at=row["last_read_at"], favorite=bool(row["favorite"]), + muted=bool(row["muted"]), ) for row in rows ] @@ -84,6 +86,17 @@ class ChannelRepository: rowcount = cursor.rowcount return rowcount > 0 + @staticmethod + async def set_muted(key: str, value: bool) -> bool: + """Set or clear the muted flag for a channel. Returns True if row was found.""" + async with db.tx() as conn: + async with conn.execute( + "UPDATE channels SET muted = ? WHERE key = ?", + (1 if value else 0, key.upper()), + ) as cursor: + rowcount = cursor.rowcount + return rowcount > 0 + @staticmethod async def delete(key: str) -> None: """Delete a channel by key.""" diff --git a/app/repository/messages.py b/app/repository/messages.py index 186effb..e109b80 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -701,6 +701,7 @@ class MessageRepository: JOIN channels c ON m.conversation_key = c.key WHERE m.type = 'CHAN' AND m.outgoing = 0 AND m.received_at > COALESCE(c.last_read_at, 0) + AND COALESCE(c.muted, 0) = 0 {blocked_sql} GROUP BY m.conversation_key """, diff --git a/app/routers/settings.py b/app/routers/settings.py index e071200..b99d046 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -94,6 +94,15 @@ class FavoriteToggleResponse(BaseModel): favorite: bool +class MuteChannelRequest(BaseModel): + key: str = Field(description="Channel key to toggle mute status") + + +class MuteChannelToggleResponse(BaseModel): + key: str + muted: bool + + class TrackedTelemetryRequest(BaseModel): public_key: str = Field(description="Public key of the repeater to toggle tracking") @@ -260,6 +269,25 @@ async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse: return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value) +@router.post("/muted-channels/toggle", response_model=MuteChannelToggleResponse) +async def toggle_muted_channel(request: MuteChannelRequest) -> MuteChannelToggleResponse: + """Toggle a channel's muted status.""" + channel = await ChannelRepository.get_by_key(request.key) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + new_value = not channel.muted + await ChannelRepository.set_muted(request.key, new_value) + logger.info("%s channel mute: %s", "Muted" if new_value else "Unmuted", request.key[:12]) + + refreshed = await ChannelRepository.get_by_key(request.key) + if refreshed: + from app.websocket import broadcast_event + + broadcast_event("channel", refreshed.model_dump()) + + return MuteChannelToggleResponse(key=request.key, muted=new_value) + + @router.post("/blocked-keys/toggle", response_model=AppSettings) async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings: """Toggle a public key's blocked status.""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6cf83b4..2265ade 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,7 +25,13 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext'; import { usePush } from './contexts/PushSubscriptionContext'; import { messageContainsMention } from './utils/messageParser'; import { getStateKey } from './utils/conversationState'; -import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types'; +import type { + BulkCreateHashtagChannelsResult, + Channel, + Conversation, + Message, + RawPacket, +} from './types'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types'; import { shouldAutoFocusInput } from './utils/autoFocusInput'; @@ -207,6 +213,12 @@ export function App() { removeConversationMessagesRef.current(conversationId), }); + // Keep channels in a ref for WS callback mute filtering + const channelsRef = useRef([]); + useEffect(() => { + channelsRef.current = channels; + }, [channels]); + const handleToggleFavorite = useCallback( async (type: 'channel' | 'contact', id: string) => { // Optimistically toggle the favorite flag @@ -343,6 +355,20 @@ export function App() { useFaviconBadge(unreadCounts, mentions, channels); useUnreadTitle(unreadCounts, contacts, channels); + const handleToggleMute = useCallback( + async (key: string) => { + setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c))); + try { + await api.toggleChannelMute(key); + await refreshUnreads(); + } catch { + setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c))); + toast.error('Failed to update mute'); + } + }, + [setChannels, refreshUnreads] + ); + useEffect(() => { if (activeConversation?.type !== 'channel') { setChannelUnreadMarker(null); @@ -408,6 +434,7 @@ export function App() { setContacts, blockedKeysRef, blockedNamesRef, + channelsRef, activeConversationRef, observeMessage, recordMessageEvent, @@ -586,6 +613,7 @@ export function App() { onRunTracePath: api.requestRadioTrace, onPathDiscovery: handlePathDiscovery, onToggleFavorite: handleToggleFavorite, + onToggleMute: handleToggleMute, onDeleteContact: handleDeleteContact, onDeleteChannel: handleDeleteChannel, onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 302ca12..e65ee82 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -343,6 +343,12 @@ export const api = { body: JSON.stringify({ type, id }), }), + toggleChannelMute: (key: string) => + fetchJson<{ key: string; muted: boolean }>('/settings/muted-channels/toggle', { + method: 'POST', + body: JSON.stringify({ key }), + }), + // Fanout getFanoutConfigs: () => fetchJson('/fanout'), createFanoutConfig: (config: { diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index ebbce43..00f24a9 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; +import { Bell, BellOff, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; import { toast } from './ui/sonner'; import { DirectTraceIcon } from './DirectTraceIcon'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; @@ -32,6 +32,7 @@ interface ChatHeaderProps { onTogglePush?: () => void; onOpenPushSettings?: () => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; + onToggleMute?: (key: string) => void; onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void; onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void; onDeleteChannel: (key: string) => void; @@ -57,6 +58,7 @@ export function ChatHeader({ onTogglePush, onOpenPushSettings, onToggleFavorite, + onToggleMute, onSetChannelFloodScopeOverride, onSetChannelPathHashModeOverride, onDeleteChannel, @@ -313,95 +315,125 @@ export function ChatHeader({ )} - {(notificationsSupported || pushSupported) && !activeContactIsRoomServer && ( -
- - {notifDropdownOpen && ( -
- {notificationsSupported && ( - - )} - {pushSupported && onTogglePush && ( - <> + + {notifDropdownOpen && ( +
+ {notificationsSupported && ( - - All notification types require a trusted HTTPS context. Depending on your - browser, a snakeoil certificate may not be sufficient. - - {onOpenPushSettings && ( -

- Manage Web Push enabled devices in{' '} - - . -

- )} - - )} -
- )} -
- )} + )} + {pushSupported && onTogglePush && ( + <> + + + All notification types require a trusted HTTPS context. Depending on your + browser, a snakeoil certificate may not be sufficient. + + {onOpenPushSettings && ( +

+ Manage Web Push enabled devices in{' '} + + . +

+ )} + + )} + {conversation.type === 'channel' && onToggleMute && ( + <> +
+ + + )} +
+ )} + + )} {conversation.type === 'channel' && onSetChannelFloodScopeOverride && (