Merge pull request #50 from jkingsman/notifications

Notifications
This commit is contained in:
Jack Kingsman
2026-03-10 20:49:12 -07:00
committed by GitHub
19 changed files with 1062 additions and 63 deletions

View File

@@ -13,6 +13,7 @@ import {
useConversationActions,
useConversationNavigation,
useRealtimeAppState,
useBrowserNotifications,
} from './hooks';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
@@ -22,6 +23,13 @@ import type { Conversation, RawPacket } from './types';
export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const {
notificationsSupported,
notificationsPermission,
isConversationNotificationsEnabled,
toggleConversationNotifications,
notifyIncomingMessage,
} = useBrowserNotifications();
const {
showNewMessage,
showSettings,
@@ -202,6 +210,7 @@ export function App() {
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
notifyIncomingMessage,
});
const {
handleSendMessage,
@@ -237,7 +246,10 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const statusProps = { health, config };
const statusProps = {
health,
config,
};
const sidebarProps = {
contacts,
channels,
@@ -258,6 +270,7 @@ export function App() {
onSortOrderChange: (sortOrder: 'recent' | 'alpha') => {
void handleSortOrderChange(sortOrder);
},
isConversationNotificationsEnabled,
};
const conversationPaneProps = {
activeConversation,
@@ -289,6 +302,21 @@ export function App() {
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
notificationsSupported,
notificationsPermission,
notificationsEnabled:
activeConversation?.type === 'contact' || activeConversation?.type === 'channel'
? isConversationNotificationsEnabled(activeConversation.type, activeConversation.id)
: false,
onToggleNotifications: () => {
if (activeConversation?.type === 'contact' || activeConversation?.type === 'channel') {
void toggleConversationNotifications(
activeConversation.type,
activeConversation.id,
activeConversation.name
);
}
},
};
const searchProps = {
contacts,

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -14,7 +14,11 @@ interface ChatHeaderProps {
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
onTrace: () => void;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onDeleteChannel: (key: string) => void;
@@ -29,7 +33,11 @@ export function ChatHeader({
channels,
config,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
onTrace,
onToggleNotifications,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onDeleteChannel,
@@ -47,6 +55,12 @@ export function ChatHeader({
conversation.type === 'channel'
? channels.find((channel) => channel.key === conversation.id)
: undefined;
const activeFloodScopeOverride =
conversation.type === 'channel' ? (activeChannel?.flood_scope_override ?? null) : null;
const activeFloodScopeLabel = activeFloodScopeOverride
? stripRegionScopePrefix(activeFloodScopeOverride)
: null;
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const titleClickable =
@@ -65,7 +79,7 @@ export function ChatHeader({
if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return;
const nextValue = window.prompt(
'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.',
stripRegionScopePrefix(activeChannel?.flood_scope_override)
activeFloodScopeLabel ?? ''
);
if (nextValue === null) return;
onSetChannelFloodScopeOverride(conversation.id, nextValue);
@@ -164,12 +178,6 @@ export function ChatHeader({
</span>
)}
</span>
{conversation.type === 'channel' && activeChannel?.flood_scope_override && (
<span className="min-w-0 basis-full text-[11px] text-amber-700 dark:text-amber-300 truncate">
Regional override active:{' '}
{stripRegionScopePrefix(activeChannel.flood_scope_override)}
</span>
)}
{conversation.type === 'contact' &&
(() => {
const contact = contacts.find((c) => c.public_key === conversation.id);
@@ -185,9 +193,25 @@ export function ChatHeader({
);
})()}
</span>
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
)}
</span>
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -198,14 +222,51 @@ export function ChatHeader({
<Route className="h-4 w-4" aria-hidden="true" />
</button>
)}
{notificationsSupported && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On
</span>
)}
</button>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2 className="h-4 w-4" aria-hidden="true" />
<Globe2
className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : ''}`}
aria-hidden="true"
/>
{activeFloodScopeDisplay && (
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
{activeFloodScopeDisplay}
</span>
)}
</button>
)}
{(conversation.type === 'channel' || conversation.type === 'contact') && (

View File

@@ -31,6 +31,9 @@ interface ConversationPaneProps {
rawPackets: RawPacket[];
config: RadioConfig | null;
health: HealthStatus | null;
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[];
messagesLoading: boolean;
@@ -54,6 +57,7 @@ interface ConversationPaneProps {
onLoadNewer: () => Promise<void>;
onJumpToBottom: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
}
function LoadingPane({ label }: { label: string }) {
@@ -69,6 +73,9 @@ export function ConversationPane({
rawPackets,
config,
health,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
favorites,
messages,
messagesLoading,
@@ -92,6 +99,7 @@ export function ConversationPane({
onLoadNewer,
onJumpToBottom,
onSendMessage,
onToggleNotifications,
}: ConversationPaneProps) {
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
@@ -155,10 +163,14 @@ export function ConversationPane({
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
radioLat={config?.lat ?? null}
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={onTrace}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
/>
@@ -174,7 +186,11 @@ export function ConversationPane({
channels={channels}
config={config}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
onTrace={onTrace}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
onDeleteChannel={onDeleteChannel}

View File

@@ -1,6 +1,6 @@
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Route, Star, Trash2 } from 'lucide-react';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
@@ -25,10 +25,14 @@ interface RepeaterDashboardProps {
conversation: Conversation;
contacts: Contact[];
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
onTrace: () => void;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
}
@@ -37,10 +41,14 @@ export function RepeaterDashboard({
conversation,
contacts,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
radioLat,
radioLon,
radioName,
onTrace,
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
}: RepeaterDashboardProps) {
@@ -120,6 +128,35 @@ export function RepeaterDashboard({
>
<Route className="h-4 w-4" aria-hidden="true" />
</button>
{notificationsSupported && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On
</span>
)}
</button>
)}
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onToggleFavorite('contact', conversation.id)}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
CheckCheck,
ChevronDown,
ChevronRight,
@@ -36,6 +37,7 @@ type ConversationRow = {
name: string;
unreadCount: number;
isMention: boolean;
notificationsEnabled: boolean;
contact?: Contact;
};
@@ -93,6 +95,7 @@ interface SidebarProps {
sortOrder?: SortOrder;
/** Callback when sort order changes */
onSortOrderChange?: (order: SortOrder) => void;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}
export function Sidebar({
@@ -111,6 +114,7 @@ export function Sidebar({
favorites,
sortOrder: sortOrderProp = 'recent',
onSortOrderChange,
isConversationNotificationsEnabled,
}: SidebarProps) {
const sortOrder = sortOrderProp;
const [searchQuery, setSearchQuery] = useState('');
@@ -405,6 +409,7 @@ export function Sidebar({
name: channel.name,
unreadCount: getUnreadCount('channel', channel.key),
isMention: hasMention('channel', channel.key),
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
});
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
@@ -414,6 +419,8 @@ export function Sidebar({
name: getContactDisplayName(contact.name, contact.public_key),
unreadCount: getUnreadCount('contact', contact.public_key),
isMention: hasMention('contact', contact.public_key),
notificationsEnabled:
isConversationNotificationsEnabled?.('contact', contact.public_key) ?? false,
contact,
});
@@ -446,19 +453,26 @@ export function Sidebar({
/>
)}
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
{row.unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
row.isMention
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
)}
<span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
</span>
)}
{row.unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
row.isMention
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
)}
</span>
</div>
);
@@ -487,10 +501,10 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate}
onClick={onClick}
>
<span className="text-muted-foreground" aria-hidden="true">
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
{icon}
</span>
<span className="flex-1 truncate text-muted-foreground">{label}</span>
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
</div>
);
@@ -653,44 +667,39 @@ export function Sidebar({
aria-label="Conversations"
>
{/* Header */}
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Conversations
</h2>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<div className="relative min-w-0 flex-1">
<Input
type="text"
placeholder="Search conversations..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 text-[13px] pr-8 bg-background/50"
/>
{searchQuery && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={onNewMessage}
title="New Message"
aria-label="New message"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground transition-colors"
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
>
<SquarePen className="h-4 w-4" />
</Button>
</div>
{/* Search */}
<div className="relative px-3 py-2">
<Input
type="text"
placeholder="Search..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 text-[13px] pr-8 bg-background/50"
/>
{searchQuery && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
{/* Tools */}

View File

@@ -1,9 +1,10 @@
import { useState } from 'react';
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Menu, Moon, Sun } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme';
import { cn } from '@/lib/utils';
interface StatusBarProps {
@@ -29,6 +30,19 @@ export function StatusBar({
? 'Radio OK'
: 'Radio Disconnected';
const [reconnecting, setReconnecting] = useState(false);
const [currentTheme, setCurrentTheme] = useState(getSavedTheme);
useEffect(() => {
const handleThemeChange = (event: Event) => {
const themeId = (event as CustomEvent<string>).detail;
setCurrentTheme(typeof themeId === 'string' && themeId ? themeId : getSavedTheme());
};
window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener);
return () => {
window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener);
};
}, []);
const handleReconnect = async () => {
setReconnecting(true);
@@ -46,6 +60,12 @@ export function StatusBar({
}
};
const handleThemeToggle = () => {
const nextTheme = currentTheme === 'light' ? 'original' : 'light';
applyTheme(nextTheme);
setCurrentTheme(nextTheme);
};
return (
<header className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
{/* Mobile menu button - only visible on small screens */}
@@ -128,6 +148,18 @@ export function StatusBar({
>
{settingsMode ? 'Back to Chat' : 'Settings'}
</button>
<button
onClick={handleThemeToggle}
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
title={currentTheme === 'light' ? 'Switch to classic theme' : 'Switch to light theme'}
aria-label={currentTheme === 'light' ? 'Switch to classic theme' : 'Switch to light theme'}
>
{currentTheme === 'light' ? (
<Moon className="h-4 w-4" aria-hidden="true" />
) : (
<Sun className="h-4 w-4" aria-hidden="true" />
)}
</button>
</header>
);
}

View File

@@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels';
export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';

View File

@@ -0,0 +1,207 @@
import { useCallback, useEffect, useState } from 'react';
import { toast } from '../components/ui/sonner';
import type { Message } from '../types';
import { getStateKey } from '../utils/conversationState';
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>;
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
return getStateKey(type, id);
}
function readStoredEnabledMap(): ConversationNotificationMap {
if (typeof window === 'undefined') {
return {};
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(parsed).filter(([key, value]) => typeof key === 'string' && value === true)
);
} catch {
return {};
}
}
function writeStoredEnabledMap(enabledByConversation: ConversationNotificationMap) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(enabledByConversation));
}
function getInitialPermission(): NotificationPermissionState {
if (typeof window === 'undefined' || !('Notification' in window)) {
return 'unsupported';
}
return window.Notification.permission;
}
function shouldShowDesktopNotification(): boolean {
if (typeof document === 'undefined') {
return false;
}
return document.visibilityState !== 'visible' || !document.hasFocus();
}
function getMessageConversationNotificationKey(message: Message): string | null {
if (message.type === 'PRIV' && message.conversation_key) {
return getConversationNotificationKey('contact', message.conversation_key);
}
if (message.type === 'CHAN' && message.conversation_key) {
return getConversationNotificationKey('channel', message.conversation_key);
}
return null;
}
function buildNotificationTitle(message: Message): string {
if (message.type === 'PRIV') {
return message.sender_name
? `New message from ${message.sender_name}`
: `New message from ${message.conversation_key.slice(0, 12)}`;
}
const roomName = message.channel_name || message.conversation_key.slice(0, 8);
return `New message in ${roomName}`;
}
function buildPreviewNotificationTitle(type: 'channel' | 'contact', label: string): string {
return type === 'contact' ? `New message from ${label}` : `New message in ${label}`;
}
function buildMessageNotificationHash(message: Message): string | null {
if (message.type === 'PRIV' && message.conversation_key) {
const label = message.sender_name || message.conversation_key.slice(0, 12);
return `#contact/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
}
if (message.type === 'CHAN' && message.conversation_key) {
const label = message.channel_name || message.conversation_key.slice(0, 8);
return `#channel/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
}
return null;
}
export function useBrowserNotifications() {
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
const [enabledByConversation, setEnabledByConversation] =
useState<ConversationNotificationMap>(readStoredEnabledMap);
useEffect(() => {
setPermission(getInitialPermission());
}, []);
const isConversationNotificationsEnabled = useCallback(
(type: 'channel' | 'contact', id: string) =>
permission === 'granted' &&
enabledByConversation[getConversationNotificationKey(type, id)] === true,
[enabledByConversation, permission]
);
const toggleConversationNotifications = useCallback(
async (type: 'channel' | 'contact', id: string, label: string) => {
const conversationKey = getConversationNotificationKey(type, id);
if (enabledByConversation[conversationKey]) {
setEnabledByConversation((prev) => {
const next = { ...prev };
delete next[conversationKey];
writeStoredEnabledMap(next);
return next;
});
toast.success(`${label} notifications disabled`);
return;
}
if (permission === 'unsupported') {
toast.error('Browser notifications unavailable', {
description: 'This browser does not support desktop notifications.',
});
return;
}
if (permission === 'denied') {
toast.error('Browser notifications blocked', {
description: 'Allow notifications in your browser settings, then try again.',
});
return;
}
const nextPermission = await window.Notification.requestPermission();
setPermission(nextPermission);
if (nextPermission === 'granted') {
setEnabledByConversation((prev) => {
const next = {
...prev,
[conversationKey]: true,
};
writeStoredEnabledMap(next);
return next;
});
new window.Notification(buildPreviewNotificationTitle(type, label), {
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
icon: NOTIFICATION_ICON_PATH,
tag: `meshcore-notification-preview-${conversationKey}`,
});
toast.success(`${label} notifications enabled`);
return;
}
toast.error('Browser notifications not enabled', {
description:
nextPermission === 'denied'
? 'Permission was denied by the browser.'
: 'Permission request was dismissed.',
});
},
[enabledByConversation, permission]
);
const notifyIncomingMessage = useCallback(
(message: Message) => {
const conversationKey = getMessageConversationNotificationKey(message);
if (
permission !== 'granted' ||
!conversationKey ||
enabledByConversation[conversationKey] !== true ||
!shouldShowDesktopNotification()
) {
return;
}
const notification = new window.Notification(buildNotificationTitle(message), {
body: message.text,
icon: NOTIFICATION_ICON_PATH,
tag: `meshcore-message-${message.id}`,
});
notification.onclick = () => {
const hash = buildMessageNotificationHash(message);
if (hash) {
window.open(`${window.location.origin}${window.location.pathname}${hash}`, '_self');
}
window.focus();
notification.close();
};
},
[enabledByConversation, permission]
);
return {
notificationsSupported: permission !== 'unsupported',
notificationsPermission: permission,
isConversationNotificationsEnabled,
toggleConversationNotifications,
notifyIncomingMessage,
};
}

View File

@@ -44,6 +44,7 @@ interface UseRealtimeAppStateArgs {
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
notifyIncomingMessage?: (msg: Message) => void;
maxRawPackets?: number;
}
@@ -103,6 +104,7 @@ export function useRealtimeAppState({
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
notifyIncomingMessage,
maxRawPackets = 500,
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
const mergeChannelIntoList = useCallback(
@@ -180,18 +182,19 @@ export function useRealtimeAppState({
activeConversationRef.current,
msg
);
let isNewMessage = false;
if (isForActiveConversation && !hasNewerMessagesRef.current) {
addMessageIfNew(msg);
isNewMessage = addMessageIfNew(msg);
}
trackNewMessage(msg);
const contentKey = getMessageContentKey(msg);
if (!isForActiveConversation) {
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey);
if (!msg.outgoing && isNew) {
if (!msg.outgoing && isNewMessage) {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
@@ -203,6 +206,10 @@ export function useRealtimeAppState({
}
}
}
if (!msg.outgoing && isNewMessage) {
notifyIncomingMessage?.(msg);
}
},
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
@@ -259,6 +266,7 @@ export function useRealtimeAppState({
trackNewMessage,
triggerReconcile,
updateMessageAck,
notifyIncomingMessage,
]
);
}

View File

@@ -40,6 +40,7 @@
--success-foreground: 0 0% 100%;
--info: 217 91% 60%;
--info-foreground: 0 0% 100%;
--region-override: 270 80% 74%;
/* Favorites */
--favorite: 43 96% 56%;

View File

@@ -14,7 +14,11 @@ const baseProps = {
contacts: [],
config: null,
favorites: [] as Favorite[],
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
onTrace: noop,
onToggleNotifications: noop,
onToggleFavorite: noop,
onSetChannelFloodScopeOverride: noop,
onDeleteChannel: noop,
@@ -107,7 +111,7 @@ describe('ChatHeader key visibility', () => {
expect(writeText).toHaveBeenCalledWith(key);
});
it('shows active regional override banner for channels', () => {
it('shows active regional override badge for channels', () => {
const key = 'AB'.repeat(16);
const channel = {
...makeChannel(key, '#flightless', true),
@@ -117,7 +121,27 @@ describe('ChatHeader key visibility', () => {
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.getByText('Regional override active: Esperance')).toBeInTheDocument();
expect(screen.getAllByText('#Esperance')).toHaveLength(2);
});
it('shows enabled notification state and toggles when clicked', () => {
const conversation: Conversation = { type: 'contact', id: '11'.repeat(32), name: 'Alice' };
const onToggleNotifications = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
notificationsEnabled
onToggleNotifications={onToggleNotifications}
/>
);
fireEvent.click(screen.getByText('Notifications On'));
expect(screen.getByText('Notifications On')).toBeInTheDocument();
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('prompts for regional override when globe button is clicked', () => {

View File

@@ -99,6 +99,9 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
rawPackets: [],
config,
health,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
favorites: [] as Favorite[],
messages: [message],
messagesLoading: false,
@@ -122,6 +125,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onLoadNewer: vi.fn(async () => {}),
onJumpToBottom: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
...overrides,
};
}

View File

@@ -99,10 +99,14 @@ const defaultProps = {
conversation,
contacts,
favorites,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
radioLat: null,
radioLon: null,
radioName: null,
onTrace: vi.fn(),
onToggleNotifications: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
};
@@ -190,6 +194,21 @@ describe('RepeaterDashboard', () => {
expect(mockHook.loadAll).toHaveBeenCalledTimes(1);
});
it('shows enabled notification state and toggles when clicked', () => {
render(
<RepeaterDashboard
{...defaultProps}
notificationsEnabled
onToggleNotifications={defaultProps.onToggleNotifications}
/>
);
fireEvent.click(screen.getByText('Notifications On'));
expect(screen.getByText('Notifications On')).toBeInTheDocument();
expect(defaultProps.onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('shows login error when present', () => {
mockHook.loginError = 'Invalid password';

View File

@@ -41,6 +41,7 @@ function renderSidebar(overrides?: {
favorites?: Favorite[];
lastMessageTimes?: ConversationTimes;
channels?: Channel[];
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}) {
const aliceName = 'Alice';
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
@@ -76,6 +77,7 @@ function renderSidebar(overrides?: {
favorites={favorites}
sortOrder="recent"
onSortOrderChange={vi.fn()}
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
/>
);
@@ -218,4 +220,37 @@ describe('Sidebar section summaries', () => {
const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id);
expect(new Set(selectedIds)).toEqual(new Set([channelA.key, channelB.key]));
});
it('shows a notification bell for conversations with notifications enabled', () => {
const { aliceName } = renderSidebar({
unreadCounts: {},
isConversationNotificationsEnabled: (type, id) =>
(type === 'contact' && id === '11'.repeat(32)) ||
(type === 'channel' && id === 'BB'.repeat(16)),
});
const aliceRow = screen.getByText(aliceName).closest('div');
const flightRow = screen.getByText('#flight').closest('div');
if (!aliceRow || !flightRow) throw new Error('Missing sidebar rows');
expect(within(aliceRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
expect(within(flightRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
});
it('keeps the notification bell to the left of the unread pill when both are present', () => {
const { aliceName } = renderSidebar({
unreadCounts: {
[getStateKey('contact', '11'.repeat(32))]: 3,
},
isConversationNotificationsEnabled: (type, id) =>
type === 'contact' && id === '11'.repeat(32),
});
const aliceRow = screen.getByText(aliceName).closest('div');
if (!aliceRow) throw new Error('Missing Alice row');
const bell = within(aliceRow).getByLabelText('Notifications enabled');
const unread = within(aliceRow).getByText('3');
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { StatusBar } from '../components/StatusBar';
@@ -47,4 +47,21 @@ describe('StatusBar', () => {
expect(screen.getByRole('status', { name: 'Radio Disconnected' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
});
it('toggles between classic and light themes from the shortcut button', () => {
localStorage.setItem('remoteterm-theme', 'cyberpunk');
render(<StatusBar health={baseHealth} config={null} onSettingsClick={vi.fn()} />);
const themeToggle = screen.getByRole('button', { name: 'Switch to light theme' });
fireEvent.click(themeToggle);
expect(localStorage.getItem('remoteterm-theme')).toBe('light');
expect(document.documentElement.dataset.theme).toBe('light');
fireEvent.click(screen.getByRole('button', { name: 'Switch to classic theme' }));
expect(localStorage.getItem('remoteterm-theme')).toBe('original');
expect(document.documentElement.dataset.theme).toBeUndefined();
});
});

View File

@@ -0,0 +1,151 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useBrowserNotifications } from '../hooks/useBrowserNotifications';
import type { Message } from '../types';
const mocks = vi.hoisted(() => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: mocks.toast,
}));
const incomingChannelMessage: Message = {
id: 42,
type: 'CHAN',
conversation_key: 'ab'.repeat(16),
text: 'hello room',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: 'cd'.repeat(32),
outgoing: false,
acked: 0,
sender_name: 'Alice',
channel_name: '#flightless',
};
describe('useBrowserNotifications', () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.location.hash = '';
vi.spyOn(window, 'open').mockReturnValue(null);
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
});
vi.spyOn(document, 'hasFocus').mockReturnValue(false);
const NotificationMock = vi.fn().mockImplementation(function (this: Record<string, unknown>) {
this.close = vi.fn();
this.onclick = null;
});
Object.assign(NotificationMock, {
permission: 'granted',
requestPermission: vi.fn(async () => 'granted'),
});
Object.defineProperty(window, 'Notification', {
configurable: true,
value: NotificationMock,
});
});
it('stores notification opt-in per conversation', async () => {
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
expect(
result.current.isConversationNotificationsEnabled(
'channel',
incomingChannelMessage.conversation_key
)
).toBe(true);
expect(result.current.isConversationNotificationsEnabled('contact', 'ef'.repeat(32))).toBe(
false
);
expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', {
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
icon: '/favicon-256x256.png',
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
});
});
it('only sends desktop notifications for opted-in conversations', async () => {
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
act(() => {
result.current.notifyIncomingMessage(incomingChannelMessage);
result.current.notifyIncomingMessage({
...incomingChannelMessage,
id: 43,
conversation_key: '34'.repeat(16),
channel_name: '#elsewhere',
});
});
expect(window.Notification).toHaveBeenCalledTimes(2);
expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', {
body: 'hello room',
icon: '/favicon-256x256.png',
tag: 'meshcore-message-42',
});
});
it('notification click deep-links to the conversation hash', async () => {
const focusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {});
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
act(() => {
result.current.notifyIncomingMessage(incomingChannelMessage);
});
const notificationInstance = (window.Notification as unknown as ReturnType<typeof vi.fn>).mock
.instances[1] as {
onclick: (() => void) | null;
close: ReturnType<typeof vi.fn>;
};
act(() => {
notificationInstance.onclick?.();
});
expect(window.open).toHaveBeenCalledWith(
`${window.location.origin}${window.location.pathname}#channel/${incomingChannelMessage.conversation_key}/%23flightless`,
'_self'
);
expect(focusSpy).toHaveBeenCalledTimes(1);
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
});
});

View File

@@ -81,6 +81,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
updateMessageAck: vi.fn(),
notifyIncomingMessage: vi.fn(),
...overrides,
},
fns: {
@@ -163,6 +164,7 @@ describe('useRealtimeAppState', () => {
`contact-${incomingDm.conversation_key}`,
true
);
expect(args.notifyIncomingMessage).toHaveBeenCalledWith(incomingDm);
});
it('deleting the active contact clears it and marks fallback recovery pending', () => {

View File

@@ -35,6 +35,7 @@
--success-foreground: 0 0% 100%;
--info: 217 91% 48%;
--info-foreground: 0 0% 100%;
--region-override: 274 78% 24%;
--favorite: 43 96% 50%;
--console: 153 50% 22%;
--console-command: 153 55% 18%;
@@ -48,6 +49,10 @@
--overlay: 220 20% 10%;
}
:root[data-theme='light'] .sidebar-tool-label {
color: hsl(var(--foreground));
}
/* ── Cyberpunk ("Neon Bleed") ──────────────────────────────── */
:root[data-theme='cyberpunk'] {
--background: 210 18% 3%;
@@ -80,6 +85,7 @@
--success-foreground: 135 100% 6%;
--info: 185 100% 42%;
--info-foreground: 185 100% 6%;
--region-override: 292 100% 68%;
--favorite: 62 100% 52%;
--console: 135 100% 50%;
--console-command: 135 100% 62%;
@@ -126,6 +132,7 @@
--success-foreground: 0 0% 100%;
--info: 212 100% 58%;
--info-foreground: 0 0% 100%;
--region-override: 286 100% 76%;
--favorite: 43 100% 54%;
--console: 212 100% 62%;
--console-command: 212 100% 74%;
@@ -172,6 +179,7 @@
--success-foreground: 0 0% 100%;
--info: 210 50% 56%;
--info-foreground: 0 0% 100%;
--region-override: 273 72% 72%;
--favorite: 38 70% 56%;
--console: 30 40% 58%;
--console-command: 30 40% 70%;
@@ -267,6 +275,7 @@
--success-foreground: 0 0% 100%;
--info: 198 80% 54%;
--info-foreground: 0 0% 100%;
--region-override: 282 100% 72%;
--favorite: 46 100% 54%;
--console: 338 100% 54%;
--console-command: 338 100% 68%;
@@ -373,3 +382,317 @@
[data-theme='solar-flare'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(338 90% 48%), hsl(24 90% 48%));
}
/* ── Lagoon Pop ("Tidal Candy") ───────────────────────────── */
:root[data-theme='lagoon-pop'] {
--background: 197 62% 9%;
--foreground: 42 33% 92%;
--card: 197 46% 13%;
--card-foreground: 42 33% 92%;
--popover: 197 46% 14%;
--popover-foreground: 42 33% 92%;
--primary: 175 72% 49%;
--primary-foreground: 196 60% 9%;
--secondary: 197 34% 18%;
--secondary-foreground: 42 22% 84%;
--muted: 197 30% 16%;
--muted-foreground: 195 16% 64%;
--accent: 205 46% 22%;
--accent-foreground: 42 33% 92%;
--destructive: 8 88% 61%;
--destructive-foreground: 0 0% 100%;
--border: 191 34% 24%;
--input: 191 34% 24%;
--ring: 175 72% 49%;
--radius: 1rem;
--msg-outgoing: 184 46% 16%;
--msg-incoming: 204 34% 14%;
--status-connected: 167 76% 46%;
--status-disconnected: 204 12% 46%;
--warning: 41 100% 58%;
--warning-foreground: 38 100% 10%;
--success: 167 76% 42%;
--success-foreground: 196 60% 9%;
--info: 229 90% 72%;
--info-foreground: 232 56% 14%;
--region-override: 277 88% 76%;
--favorite: 49 100% 63%;
--console: 175 72% 54%;
--console-command: 175 78% 68%;
--console-bg: 198 68% 7%;
--toast-error: 8 38% 14%;
--toast-error-foreground: 10 86% 77%;
--toast-error-border: 8 30% 24%;
--code-editor-bg: 198 44% 11%;
--font-sans: 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
--scrollbar: 191 34% 22%;
--scrollbar-hover: 191 40% 30%;
--overlay: 198 80% 4%;
}
[data-theme='lagoon-pop'] body {
background:
radial-gradient(circle at top left, hsl(175 72% 49% / 0.1), transparent 28%),
radial-gradient(circle at top right, hsl(229 90% 72% / 0.1), transparent 24%),
radial-gradient(circle at bottom center, hsl(8 88% 61% / 0.08), transparent 26%),
hsl(197 62% 9%);
}
[data-theme='lagoon-pop'] .bg-card {
background: linear-gradient(145deg, hsl(197 46% 14%), hsl(205 40% 16%));
}
[data-theme='lagoon-pop'] .bg-popover {
background: linear-gradient(145deg, hsl(197 46% 15%), hsl(205 40% 17%));
}
[data-theme='lagoon-pop'] .bg-msg-outgoing {
background: linear-gradient(135deg, hsl(184 48% 16%), hsl(175 38% 19%));
border-left: 2px solid hsl(175 72% 49% / 0.45);
}
[data-theme='lagoon-pop'] .bg-msg-incoming {
background: linear-gradient(135deg, hsl(204 34% 14%), hsl(214 30% 16%));
border-left: 2px solid hsl(229 90% 72% / 0.35);
}
[data-theme='lagoon-pop'] .bg-primary {
background: linear-gradient(135deg, hsl(175 72% 49%), hsl(191 78% 56%));
}
[data-theme='lagoon-pop'] button {
transition:
transform 0.12s ease,
filter 0.2s ease,
background-color 0.15s ease,
color 0.15s ease;
}
[data-theme='lagoon-pop'] button:hover {
filter: drop-shadow(0 0 10px hsl(175 72% 49% / 0.18));
}
[data-theme='lagoon-pop'] button:active {
transform: translateY(1px);
}
[data-theme='lagoon-pop'] ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, hsl(175 40% 32%), hsl(229 38% 40%));
}
[data-theme='lagoon-pop'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(175 52% 42%), hsl(229 52% 54%));
}
/* ── Candy Dusk ("Dream Arcade") ──────────────────────────── */
:root[data-theme='candy-dusk'] {
--background: 258 38% 10%;
--foreground: 302 30% 93%;
--card: 258 30% 15%;
--card-foreground: 302 30% 93%;
--popover: 258 30% 16%;
--popover-foreground: 302 30% 93%;
--primary: 325 100% 74%;
--primary-foreground: 258 38% 12%;
--secondary: 255 24% 20%;
--secondary-foreground: 291 20% 85%;
--muted: 255 20% 18%;
--muted-foreground: 265 12% 66%;
--accent: 251 28% 24%;
--accent-foreground: 302 30% 93%;
--destructive: 9 88% 66%;
--destructive-foreground: 0 0% 100%;
--border: 256 24% 28%;
--input: 256 24% 28%;
--ring: 325 100% 74%;
--radius: 1.25rem;
--msg-outgoing: 307 32% 20%;
--msg-incoming: 250 24% 18%;
--status-connected: 164 78% 58%;
--status-disconnected: 255 10% 48%;
--warning: 43 100% 63%;
--warning-foreground: 36 100% 12%;
--success: 164 78% 54%;
--success-foreground: 258 38% 12%;
--info: 191 90% 76%;
--info-foreground: 242 32% 18%;
--region-override: 278 100% 82%;
--favorite: 43 100% 66%;
--console: 191 90% 76%;
--console-command: 325 100% 82%;
--console-bg: 252 42% 8%;
--toast-error: 352 34% 16%;
--toast-error-foreground: 8 92% 82%;
--toast-error-border: 352 24% 26%;
--code-editor-bg: 255 28% 13%;
--font-sans: 'Nunito', 'Trebuchet MS', 'Segoe UI', sans-serif;
--scrollbar: 256 28% 24%;
--scrollbar-hover: 256 34% 32%;
--overlay: 258 40% 6%;
}
[data-theme='candy-dusk'] body {
background:
radial-gradient(circle at 20% 10%, hsl(325 100% 74% / 0.16), transparent 22%),
radial-gradient(circle at 85% 12%, hsl(191 90% 76% / 0.12), transparent 18%),
radial-gradient(circle at 50% 100%, hsl(43 100% 63% / 0.08), transparent 24%), hsl(258 38% 10%);
}
[data-theme='candy-dusk'] .bg-card {
background: linear-gradient(160deg, hsl(258 30% 16%), hsl(248 28% 18%));
box-shadow: inset 0 1px 0 hsl(302 50% 96% / 0.04);
}
[data-theme='candy-dusk'] .bg-popover {
background: linear-gradient(160deg, hsl(258 30% 17%), hsl(248 28% 19%));
}
[data-theme='candy-dusk'] .bg-msg-outgoing {
background: linear-gradient(135deg, hsl(307 34% 21%), hsl(325 28% 24%));
border-left: 2px solid hsl(325 100% 74% / 0.55);
}
[data-theme='candy-dusk'] .bg-msg-incoming {
background: linear-gradient(135deg, hsl(250 24% 18%), hsl(258 20% 20%));
border-left: 2px solid hsl(191 90% 76% / 0.38);
}
[data-theme='candy-dusk'] .bg-primary {
background: linear-gradient(135deg, hsl(325 100% 74%), hsl(289 84% 74%));
}
[data-theme='candy-dusk'] button {
border-radius: 999px;
transition:
transform 0.12s ease,
filter 0.2s ease,
background-color 0.15s ease,
color 0.15s ease;
}
[data-theme='candy-dusk'] button:hover {
filter: drop-shadow(0 0 10px hsl(325 100% 74% / 0.22))
drop-shadow(0 0 18px hsl(191 90% 76% / 0.08));
}
[data-theme='candy-dusk'] button:active {
transform: scale(0.98);
}
[data-theme='candy-dusk'] ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, hsl(325 48% 44%), hsl(256 46% 42%));
}
[data-theme='candy-dusk'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(325 66% 58%), hsl(191 58% 56%));
}
/* ── Paper Grove ("Field Notes") ──────────────────────────── */
:root[data-theme='paper-grove'] {
--background: 41 43% 93%;
--foreground: 148 16% 18%;
--card: 43 52% 97%;
--card-foreground: 148 16% 18%;
--popover: 43 52% 98%;
--popover-foreground: 148 16% 18%;
--primary: 157 54% 40%;
--primary-foreground: 45 60% 98%;
--secondary: 42 26% 87%;
--secondary-foreground: 148 14% 26%;
--muted: 42 22% 89%;
--muted-foreground: 148 10% 44%;
--accent: 36 42% 83%;
--accent-foreground: 148 16% 18%;
--destructive: 12 76% 58%;
--destructive-foreground: 0 0% 100%;
--border: 38 22% 76%;
--input: 38 22% 76%;
--ring: 157 54% 40%;
--radius: 0.9rem;
--msg-outgoing: 151 32% 90%;
--msg-incoming: 40 30% 94%;
--status-connected: 157 54% 38%;
--status-disconnected: 148 8% 58%;
--warning: 39 92% 46%;
--warning-foreground: 39 100% 12%;
--success: 157 54% 34%;
--success-foreground: 45 60% 98%;
--info: 227 78% 64%;
--info-foreground: 228 40% 20%;
--region-override: 273 56% 44%;
--favorite: 43 92% 48%;
--console: 157 54% 34%;
--console-command: 224 48% 42%;
--console-bg: 45 24% 89%;
--toast-error: 8 52% 94%;
--toast-error-foreground: 9 58% 40%;
--toast-error-border: 10 34% 78%;
--code-editor-bg: 42 30% 90%;
--font-sans: 'Avenir Next', 'Segoe UI', sans-serif;
--scrollbar: 36 18% 68%;
--scrollbar-hover: 36 22% 58%;
--overlay: 148 20% 12%;
}
[data-theme='paper-grove'] body {
background:
linear-gradient(hsl(157 20% 50% / 0.04) 1px, transparent 1px),
linear-gradient(90deg, hsl(157 20% 50% / 0.04) 1px, transparent 1px), hsl(41 43% 93%);
background-size:
32px 32px,
32px 32px,
auto;
}
[data-theme='paper-grove'] .bg-card {
background: linear-gradient(180deg, hsl(43 52% 98%), hsl(40 42% 95%));
box-shadow:
0 1px 0 hsl(0 0% 100% / 0.8),
0 8px 22px hsl(148 18% 20% / 0.06);
}
[data-theme='paper-grove'] .bg-popover {
background: linear-gradient(180deg, hsl(43 52% 98%), hsl(40 38% 96%));
}
[data-theme='paper-grove'] .bg-msg-outgoing {
background: linear-gradient(135deg, hsl(151 34% 90%), hsl(157 30% 87%));
border-left: 2px solid hsl(157 54% 40% / 0.45);
}
[data-theme='paper-grove'] .bg-msg-incoming {
background: linear-gradient(135deg, hsl(40 30% 95%), hsl(38 26% 92%));
border-left: 2px solid hsl(227 78% 64% / 0.28);
}
[data-theme='paper-grove'] .bg-primary {
background: linear-gradient(135deg, hsl(157 54% 40%), hsl(180 42% 42%));
}
[data-theme='paper-grove'] button {
box-shadow: 0 1px 0 hsl(0 0% 100% / 0.7);
transition:
transform 0.12s ease,
box-shadow 0.18s ease,
background-color 0.15s ease,
color 0.15s ease;
}
[data-theme='paper-grove'] button:hover {
transform: translateY(-1px);
box-shadow:
0 1px 0 hsl(0 0% 100% / 0.8),
0 6px 14px hsl(148 20% 20% / 0.08);
}
[data-theme='paper-grove'] button:active {
transform: translateY(0);
}
[data-theme='paper-grove'] ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, hsl(157 26% 54%), hsl(227 26% 60%));
}
[data-theme='paper-grove'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(157 34% 46%), hsl(227 34% 52%));
}

View File

@@ -7,6 +7,8 @@ export interface Theme {
metaThemeColor: string;
}
export const THEME_CHANGE_EVENT = 'remoteterm-theme-change';
export const THEMES: Theme[] = [
{
id: 'original',
@@ -44,6 +46,24 @@ export const THEMES: Theme[] = [
swatches: ['#0D0607', '#151012', '#FF0066', '#2D1D22', '#FF8C1A', '#30ACD4'],
metaThemeColor: '#0D0607',
},
{
id: 'lagoon-pop',
name: 'Lagoon Pop',
swatches: ['#081A22', '#0F2630', '#23D7C6', '#173844', '#FF7A66', '#7C83FF'],
metaThemeColor: '#081A22',
},
{
id: 'candy-dusk',
name: 'Candy Dusk',
swatches: ['#140F24', '#201736', '#FF79C9', '#2A2144', '#FFC857', '#8BE9FD'],
metaThemeColor: '#140F24',
},
{
id: 'paper-grove',
name: 'Paper Grove',
swatches: ['#F7F1E4', '#FFF9EE', '#2F9E74', '#E7DEC8', '#E76F51', '#5C7CFA'],
metaThemeColor: '#F7F1E4',
},
];
const THEME_KEY = 'remoteterm-theme';
@@ -77,4 +97,8 @@ export function applyTheme(themeId: string): void {
meta.setAttribute('content', theme.metaThemeColor);
}
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: themeId }));
}
}