mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
@@ -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,
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
|
||||
207
frontend/src/hooks/useBrowserNotifications.ts
Normal file
207
frontend/src/hooks/useBrowserNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
151
frontend/src/test/useBrowserNotifications.test.ts
Normal file
151
frontend/src/test/useBrowserNotifications.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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%));
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user