From 4420d448385a73b736d6b891ffba2888bbc5bc24 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 2 Apr 2026 00:19:25 -0700 Subject: [PATCH] Add bulk room add --- app/routers/channels.py | 271 +++++++++++++++--- frontend/src/App.tsx | 41 ++- frontend/src/api.ts | 6 + frontend/src/components/AppShell.tsx | 16 ++ .../components/BulkAddChannelResultModal.tsx | 101 +++++++ frontend/src/components/NewMessageModal.tsx | 207 ++++++++++--- frontend/src/components/Sidebar.tsx | 2 +- frontend/src/hooks/useContactsAndChannels.ts | 21 +- .../test/bulkAddChannelResultModal.test.tsx | 46 +++ frontend/src/test/newMessageModal.test.tsx | 49 ++++ .../src/test/useContactsAndChannels.test.ts | 40 ++- frontend/src/types.ts | 9 + frontend/src/utils/urlHash.ts | 2 +- tests/test_channels_router.py | 52 +++- 14 files changed, 764 insertions(+), 99 deletions(-) create mode 100644 frontend/src/components/BulkAddChannelResultModal.tsx create mode 100644 frontend/src/test/bulkAddChannelResultModal.test.tsx diff --git a/app/routers/channels.py b/app/routers/channels.py index fb3f94f..8a9e7b4 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -1,7 +1,8 @@ import logging +import re from hashlib import sha256 -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status from pydantic import BaseModel, Field from app.channel_constants import ( @@ -10,10 +11,12 @@ from app.channel_constants import ( is_public_channel_key, is_public_channel_name, ) +from app.decoder import parse_packet, try_decrypt_packet_with_channel_key from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender +from app.packet_processor import create_message_from_decrypted from app.region_scope import normalize_region_scope -from app.repository import ChannelRepository, MessageRepository -from app.websocket import broadcast_event +from app.repository import ChannelRepository, MessageRepository, RawPacketRepository +from app.websocket import broadcast_event, broadcast_success logger = logging.getLogger(__name__) router = APIRouter(prefix="/channels", tags=["channels"]) @@ -31,12 +34,154 @@ class CreateChannelRequest(BaseModel): ) +class BulkCreateHashtagChannelsRequest(BaseModel): + channel_names: list[str] = Field( + min_length=1, + description="List of hashtag room names. Leading # is optional per entry.", + ) + try_historical: bool = Field( + default=False, + description="Attempt one background historical decrypt sweep for the newly added rooms.", + ) + + +class BulkCreateHashtagChannelsResponse(BaseModel): + created_channels: list[Channel] + existing_count: int + invalid_names: list[str] + decrypt_started: bool = False + decrypt_total_packets: int = 0 + message: str + + class ChannelFloodScopeOverrideRequest(BaseModel): flood_scope_override: str = Field( description="Blank clears the override; non-empty values temporarily override flood scope" ) +def _derive_channel_identity( + requested_name: str, + request_key: str | None = None, +) -> tuple[str, str, bool]: + is_hashtag = requested_name.startswith("#") + + if is_public_channel_name(requested_name): + if request_key: + try: + key_bytes = bytes.fromhex(request_key) + if len(key_bytes) != 16: + raise HTTPException( + status_code=400, + detail="Channel key must be exactly 16 bytes (32 hex chars)", + ) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid hex string for key") from None + if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY: + raise HTTPException( + status_code=400, + detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key', + ) + return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False + + if request_key and not is_hashtag: + try: + key_bytes = bytes.fromhex(request_key) + if len(key_bytes) != 16: + raise HTTPException( + status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)" + ) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid hex string for key") from None + key_hex = key_bytes.hex().upper() + if is_public_channel_key(key_hex): + raise HTTPException( + status_code=400, + detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"', + ) + return key_hex, requested_name, False + + key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16] + return key_bytes.hex().upper(), requested_name, is_hashtag + + +def _normalize_bulk_hashtag_name(name: str) -> str | None: + trimmed = name.strip() + if not trimmed: + return None + normalized = trimmed.lstrip("#").strip() + if not normalized: + return None + if len(normalized) > 31: + return None + if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized): + return None + return f"#{normalized}" + + +async def _run_historical_channel_decryption_for_channels( + channels: list[tuple[bytes, str, str]], +) -> None: + packets = await RawPacketRepository.get_all_undecrypted() + total = len(packets) + decrypted_count = 0 + matched_channel_names: set[str] = set() + + if total == 0: + logger.info("No undecrypted packets to process for bulk channel decrypt") + return + + logger.info( + "Starting bulk historical channel decryption of %d packets across %d channels", + total, + len(channels), + ) + + for packet_id, packet_data, packet_timestamp in packets: + packet_info = parse_packet(packet_data) + path_hex = packet_info.path.hex() if packet_info else None + path_len = packet_info.path_length if packet_info else None + + for channel_key_bytes, channel_key_hex, channel_name in channels: + result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes) + if result is None: + continue + + msg_id = await create_message_from_decrypted( + packet_id=packet_id, + channel_key=channel_key_hex, + channel_name=channel_name, + sender=result.sender, + message_text=result.message, + timestamp=result.timestamp, + received_at=packet_timestamp, + path=path_hex, + path_len=path_len, + realtime=False, + ) + if msg_id is not None: + decrypted_count += 1 + matched_channel_names.add(channel_name) + break + + logger.info( + "Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels", + decrypted_count, + total, + len(matched_channel_names), + ) + + if decrypted_count > 0: + broadcast_success( + "Bulk historical decrypt complete", + ( + f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} " + f"across {len(matched_channel_names)} room" + f"{'s' if len(matched_channel_names) != 1 else ''}" + ), + ) + + @router.get("", response_model=list[Channel]) async def list_channels() -> list[Channel]: """List all channels from the database.""" @@ -69,50 +214,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel: automatically when sending a message (see messages.py send_channel_message). """ requested_name = request.name - is_hashtag = requested_name.startswith("#") - - # Reserve the canonical Public channel so it cannot drift to another key, - # and the well-known Public key cannot be renamed to something else. - if is_public_channel_name(requested_name): - if request.key: - try: - key_bytes = bytes.fromhex(request.key) - if len(key_bytes) != 16: - raise HTTPException( - status_code=400, - detail="Channel key must be exactly 16 bytes (32 hex chars)", - ) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid hex string for key") from None - if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY: - raise HTTPException( - status_code=400, - detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key', - ) - key_hex = PUBLIC_CHANNEL_KEY - channel_name = PUBLIC_CHANNEL_NAME - is_hashtag = False - elif request.key and not is_hashtag: - try: - key_bytes = bytes.fromhex(request.key) - if len(key_bytes) != 16: - raise HTTPException( - status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)" - ) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid hex string for key") from None - key_hex = key_bytes.hex().upper() - if is_public_channel_key(key_hex): - raise HTTPException( - status_code=400, - detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"', - ) - channel_name = requested_name - else: - # Derive key from name hash (same as meshcore library does) - key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16] - key_hex = key_bytes.hex().upper() - channel_name = requested_name + key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key) logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag) @@ -132,6 +234,81 @@ async def create_channel(request: CreateChannelRequest) -> Channel: return stored +@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse) +async def bulk_create_hashtag_channels( + request: BulkCreateHashtagChannelsRequest, + background_tasks: BackgroundTasks, + response: Response, +) -> BulkCreateHashtagChannelsResponse: + created_channels: list[Channel] = [] + existing_count = 0 + invalid_names: list[str] = [] + decrypt_started = False + decrypt_total_packets = 0 + decrypt_targets: list[tuple[bytes, str, str]] = [] + + for raw_name in request.channel_names: + normalized_name = _normalize_bulk_hashtag_name(raw_name) + if normalized_name is None: + invalid_names.append(raw_name) + continue + + key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name) + existing = await ChannelRepository.get_by_key(key_hex) + if existing is not None: + existing_count += 1 + continue + + await ChannelRepository.upsert( + key=key_hex, + name=channel_name, + is_hashtag=is_hashtag, + on_radio=False, + ) + stored = await ChannelRepository.get_by_key(key_hex) + if stored is None: + raise HTTPException( + status_code=500, + detail="Channel was created but could not be reloaded", + ) + + created_channels.append(stored) + decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name)) + _broadcast_channel_update(stored) + + if request.try_historical and decrypt_targets: + decrypt_total_packets = await RawPacketRepository.get_undecrypted_count() + if decrypt_total_packets > 0: + background_tasks.add_task( + _run_historical_channel_decryption_for_channels, decrypt_targets + ) + decrypt_started = True + response.status_code = status.HTTP_202_ACCEPTED + + message = ( + f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}" + if created_channels + else "No new rooms were added" + ) + if request.try_historical and decrypt_targets: + if decrypt_started: + message += ( + f" and started background decrypt of {decrypt_total_packets} packet" + f"{'s' if decrypt_total_packets != 1 else ''}" + ) + else: + message += "; no undecrypted packets were available" + + return BulkCreateHashtagChannelsResponse( + created_channels=created_channels, + existing_count=existing_count, + invalid_names=invalid_names, + decrypt_started=decrypt_started, + decrypt_total_packets=decrypt_total_packets, + message=message, + ) + + @router.post("/{key}/mark-read") async def mark_channel_read(key: str) -> dict: """Mark a channel as read (update last_read_at timestamp).""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c547e67..44fdb7d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useRef, useState, useMemo } from 'react'; +import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react'; import { api } from './api'; import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; @@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput'; import { DistanceUnitProvider } from './contexts/DistanceUnitContext'; import { messageContainsMention } from './utils/messageParser'; import { getStateKey } from './utils/conversationState'; -import type { Conversation, Message, RawPacket } from './types'; +import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types'; import { CONTACT_TYPE_ROOM } from './types'; interface ChannelUnreadMarker { @@ -85,6 +85,8 @@ export function App() { const [channelUnreadMarker, setChannelUnreadMarker] = useState(null); const [newMessagePrefillRequest, setNewMessagePrefillRequest] = useState(null); + const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false); + const [bulkAddResult, setBulkAddResult] = useState(null); const [visibilityVersion, setVisibilityVersion] = useState(0); const lastUnreadBackfillAttemptRef = useRef(null); const { @@ -190,6 +192,7 @@ export function App() { handleCreateContact, handleCreateChannel, handleCreateHashtagChannel, + handleBulkCreateHashtagChannels, handleDeleteChannel, handleDeleteContact, } = useContactsAndChannels({ @@ -421,16 +424,25 @@ export function App() { [fetchUndecryptedCount, setChannels] ); - const handleOpenNewMessage = useCallback(() => { - setNewMessagePrefillRequest(null); - openNewMessageModal(); - }, [openNewMessageModal]); + const handleOpenNewMessage = useCallback( + (event?: MouseEvent) => { + setNewMessagePrefillRequest(null); + setShowBulkAddChannelTab(event?.altKey === true); + openNewMessageModal(); + }, + [openNewMessageModal] + ); const handleCloseNewMessage = useCallback(() => { setNewMessagePrefillRequest(null); + setShowBulkAddChannelTab(false); closeNewMessageModal(); }, [closeNewMessageModal]); + const handleCloseBulkAddResults = useCallback(() => { + setBulkAddResult(null); + }, []); + const handleChannelReferenceClick = useCallback( (channelName: string) => { const existingChannel = channels.find((channel) => channel.name === channelName); @@ -444,11 +456,20 @@ export function App() { hashtagName: channelName.slice(1), nonce: (previous?.nonce ?? 0) + 1, })); + setShowBulkAddChannelTab(false); openNewMessageModal(); }, [channels, handleNavigateToChannel, openNewMessageModal] ); + const handleBulkAddChannels = useCallback( + async (channelNames: string[], tryHistorical: boolean) => { + const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical); + setBulkAddResult(result); + }, + [handleBulkCreateHashtagChannels] + ); + const statusProps = { health, config, @@ -474,6 +495,9 @@ export function App() { blockedKeys: appSettings?.blocked_keys ?? [], blockedNames: appSettings?.blocked_names ?? [], }; + const bulkAddChannelResultModalProps = { + result: bulkAddResult, + }; const conversationPaneProps = { activeConversation, contacts, @@ -570,10 +594,12 @@ export function App() { }; const newMessageModalProps = { undecryptedCount, + showBulkAddChannelTab, prefillRequest: newMessagePrefillRequest, onCreateContact: handleCreateContact, onCreateChannel: handleCreateChannel, onCreateHashtagChannel: handleCreateHashtagChannel, + onBulkAddHashtagChannels: handleBulkAddChannels, }; const contactInfoPaneProps = { contactKey: infoPaneContactKey, @@ -637,6 +663,7 @@ export function App() { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1cce13e..fc0a8af 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,7 @@ import type { AppSettings, AppSettingsUpdate, + BulkCreateHashtagChannelsResult, Channel, ChannelDetail, CommandResponse, @@ -190,6 +191,11 @@ export const api = { method: 'POST', body: JSON.stringify({ name, key }), }), + bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) => + fetchJson('/channels/bulk-hashtag', { + method: 'POST', + body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }), + }), deleteChannel: (key: string) => fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }), getChannelDetail: (key: string) => fetchJson(`/channels/${key}/detail`), diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index e72ede6..ba6842a 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -5,6 +5,7 @@ import { StatusBar } from './StatusBar'; import { Sidebar } from './Sidebar'; import { ConversationPane } from './ConversationPane'; import { NewMessageModal } from './NewMessageModal'; +import { BulkAddChannelResultModal } from './BulkAddChannelResultModal'; import { ContactInfoPane } from './ContactInfoPane'; import { ChannelInfoPane } from './ChannelInfoPane'; import { SecurityWarningModal } from './SecurityWarningModal'; @@ -33,12 +34,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S type SidebarProps = ComponentProps; type ConversationPaneProps = ComponentProps; type NewMessageModalProps = Omit, 'open' | 'onClose'>; +type BulkAddChannelResultModalProps = Omit< + ComponentProps, + 'open' | 'onClose' +>; type ContactInfoPaneProps = ComponentProps; type ChannelInfoPaneProps = ComponentProps; interface AppShellProps { localLabel: LocalLabel; showNewMessage: boolean; + showBulkAddResults: boolean; showSettings: boolean; settingsSection: SettingsSection; sidebarOpen: boolean; @@ -50,6 +56,7 @@ interface AppShellProps { onToggleSettingsView: () => void; onCloseSettingsView: () => void; onCloseNewMessage: () => void; + onCloseBulkAddResults: () => void; onLocalLabelChange: (label: LocalLabel) => void; statusProps: Pick, 'health' | 'config'>; sidebarProps: SidebarProps; @@ -61,6 +68,7 @@ interface AppShellProps { >; crackerProps: Omit; newMessageModalProps: NewMessageModalProps; + bulkAddChannelResultModalProps: BulkAddChannelResultModalProps; contactInfoPaneProps: ContactInfoPaneProps; channelInfoPaneProps: ChannelInfoPaneProps; } @@ -68,6 +76,7 @@ interface AppShellProps { export function AppShell({ localLabel, showNewMessage, + showBulkAddResults, showSettings, settingsSection, sidebarOpen, @@ -79,6 +88,7 @@ export function AppShell({ onToggleSettingsView, onCloseSettingsView, onCloseNewMessage, + onCloseBulkAddResults, onLocalLabelChange, statusProps, sidebarProps, @@ -87,6 +97,7 @@ export function AppShell({ settingsProps, crackerProps, newMessageModalProps, + bulkAddChannelResultModalProps, contactInfoPaneProps, channelInfoPaneProps, }: AppShellProps) { @@ -306,6 +317,11 @@ export function AppShell({ open={showNewMessage} onClose={onCloseNewMessage} /> + diff --git a/frontend/src/components/BulkAddChannelResultModal.tsx b/frontend/src/components/BulkAddChannelResultModal.tsx new file mode 100644 index 0000000..735f396 --- /dev/null +++ b/frontend/src/components/BulkAddChannelResultModal.tsx @@ -0,0 +1,101 @@ +import type { BulkCreateHashtagChannelsResult, Channel } from '../types'; +import { getConversationHash } from '../utils/urlHash'; +import { Button } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; + +interface BulkAddChannelResultModalProps { + open: boolean; + result: BulkCreateHashtagChannelsResult | null; + onClose: () => void; +} + +function getChannelHref(channel: Channel): string { + const hash = getConversationHash({ + type: 'channel', + id: channel.key, + name: channel.name, + }); + if (typeof window === 'undefined') { + return hash; + } + return `${window.location.origin}${window.location.pathname}${hash}`; +} + +export function BulkAddChannelResultModal({ + open, + result, + onClose, +}: BulkAddChannelResultModalProps) { + const createdChannels = result?.created_channels ?? []; + + return ( + !isOpen && onClose()}> + + + Bulk Add Complete + + {result?.message ?? 'Review the newly added rooms below.'} + + + +
+ {result && ( +
+
+
Created
+
{createdChannels.length}
+
+
+
+ Already Present +
+
{result.existing_count}
+
+
+ )} + + {createdChannels.length > 0 ? ( +
+

+ Ctrl+click any room to open it in a new tab. +

+
+ +
+
+ ) : ( +

No new rooms were added.

+ )} + + {result && result.invalid_names.length > 0 && ( +
+ Ignored invalid room names: {result.invalid_names.join(', ')} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/NewMessageModal.tsx b/frontend/src/components/NewMessageModal.tsx index 7cb8885..ae28e44 100644 --- a/frontend/src/components/NewMessageModal.tsx +++ b/frontend/src/components/NewMessageModal.tsx @@ -3,23 +3,29 @@ import { Dice5 } from 'lucide-react'; import { Dialog, DialogContent, - DialogHeader, - DialogTitle, DialogDescription, DialogFooter, + DialogHeader, + DialogTitle, } from './ui/dialog'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { Checkbox } from './ui/checkbox'; import { Button } from './ui/button'; import { toast } from './ui/sonner'; -type Tab = 'new-contact' | 'new-channel' | 'hashtag'; +type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag'; + +interface BulkParseResult { + channelNames: string[]; + invalidNames: string[]; +} interface NewMessageModalProps { open: boolean; undecryptedCount: number; + showBulkAddChannelTab?: boolean; prefillRequest?: { tab: 'hashtag'; hashtagName: string; @@ -29,53 +35,121 @@ interface NewMessageModalProps { onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise; onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise; onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise; + onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise; +} + +function validateHashtagName(channelName: string): string | null { + if (!channelName) { + return 'Channel name is required'; + } + if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) { + return 'Use letters, numbers, and single dashes (no leading/trailing dashes)'; + } + return null; +} + +function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult { + const tokens = rawText + .split(/[\s,]+/) + .map((token) => token.trim()) + .filter(Boolean); + + const invalidNames: string[] = []; + const channelNames: string[] = []; + const seen = new Set(); + + for (const token of tokens) { + const stripped = token.replace(/^#+/, ''); + const validationError = validateHashtagName(stripped); + if (validationError) { + invalidNames.push(token); + continue; + } + + const normalized = permitCapitals ? stripped : stripped.toLowerCase(); + const channelName = `#${normalized}`; + if (seen.has(channelName)) { + continue; + } + seen.add(channelName); + channelNames.push(channelName); + } + + return { channelNames, invalidNames }; } export function NewMessageModal({ open, undecryptedCount, + showBulkAddChannelTab = false, prefillRequest = null, onClose, onCreateContact, onCreateChannel, onCreateHashtagChannel, + onBulkAddHashtagChannels, }: NewMessageModalProps) { const [tab, setTab] = useState('new-contact'); const [name, setName] = useState(''); const [contactKey, setContactKey] = useState(''); const [channelKey, setChannelKey] = useState(''); + const [bulkChannelText, setBulkChannelText] = useState(''); const [tryHistorical, setTryHistorical] = useState(false); const [permitCapitals, setPermitCapitals] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const hashtagInputRef = useRef(null); + const bulkTextareaRef = useRef(null); const resetForm = () => { setName(''); setContactKey(''); setChannelKey(''); + setBulkChannelText(''); setTryHistorical(false); setPermitCapitals(false); setError(''); }; useEffect(() => { - if (!open || !prefillRequest) { + if (!open) { return; } - setTab(prefillRequest.tab); - setName(prefillRequest.hashtagName); - setContactKey(''); - setChannelKey(''); - setTryHistorical(false); - setPermitCapitals(false); - setError(''); - setLoading(false); - requestAnimationFrame(() => { - hashtagInputRef.current?.focus(); - }); - }, [open, prefillRequest]); + if (prefillRequest) { + setTab(prefillRequest.tab); + setName(prefillRequest.hashtagName); + setContactKey(''); + setChannelKey(''); + setBulkChannelText(''); + setTryHistorical(false); + setPermitCapitals(false); + setError(''); + setLoading(false); + requestAnimationFrame(() => { + hashtagInputRef.current?.focus(); + }); + return; + } + + if (showBulkAddChannelTab) { + setTab('bulk-hashtag'); + setName(''); + setContactKey(''); + setChannelKey(''); + setBulkChannelText(''); + setTryHistorical(false); + setPermitCapitals(false); + setError(''); + setLoading(false); + requestAnimationFrame(() => { + bulkTextareaRef.current?.focus(); + }); + return; + } + + setTab('new-contact'); + }, [open, prefillRequest, showBulkAddChannelTab]); const handleCreate = async () => { setError(''); @@ -87,7 +161,6 @@ export function NewMessageModal({ setError('Name and public key are required'); return; } - // handleCreateContact sets activeConversation with the backend-normalized key await onCreateContact(name.trim(), contactKey.trim(), tryHistorical); } else if (tab === 'new-channel') { if (!name.trim() || !channelKey.trim()) { @@ -102,10 +175,24 @@ export function NewMessageModal({ setError(validationError); return; } - // Normalize to lowercase unless user explicitly permits capitals const normalizedName = permitCapitals ? channelName : channelName.toLowerCase(); await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical); + } else { + const { channelNames, invalidNames } = parseBulkHashtagNames( + bulkChannelText, + permitCapitals + ); + if (channelNames.length === 0) { + setError('Enter at least one valid room name'); + return; + } + if (invalidNames.length > 0) { + setError(`Invalid room names: ${invalidNames.join(', ')}`); + return; + } + await onBulkAddHashtagChannels(channelNames, tryHistorical); } + resetForm(); onClose(); } catch (err) { @@ -118,16 +205,6 @@ export function NewMessageModal({ } }; - const validateHashtagName = (channelName: string): string | null => { - if (!channelName) { - return 'Channel name is required'; - } - if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) { - return 'Use letters, numbers, and single dashes (no leading/trailing dashes)'; - } - return null; - }; - const handleCreateAndAddAnother = async () => { setError(''); const channelName = name.trim(); @@ -139,7 +216,6 @@ export function NewMessageModal({ setLoading(true); try { - // Normalize to lowercase unless user explicitly permits capitals const normalizedName = permitCapitals ? channelName : channelName.toLowerCase(); await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical); setName(''); @@ -166,28 +242,36 @@ export function NewMessageModal({ } }} > - + New Conversation {tab === 'new-contact' && 'Add a new contact by entering their name and public key'} {tab === 'new-channel' && 'Create a private channel with a shared encryption key'} {tab === 'hashtag' && 'Join a public hashtag channel'} + {tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'} { - setTab(v as Tab); + onValueChange={(value) => { + setTab(value as Tab); resetForm(); }} className="w-full" > - + Contact Private Channel Hashtag Channel + {showBulkAddChannelTab && ( + Bulk Add Channel + )} @@ -239,7 +323,7 @@ export function NewMessageModal({ const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); const hex = Array.from(bytes) - .map((b) => b.toString(16).padStart(2, '0')) + .map((byte) => byte.toString(16).padStart(2, '0')) .join(''); setChannelKey(hex); }} @@ -268,20 +352,55 @@ export function NewMessageModal({
-
+ + {showBulkAddChannelTab && ( + +
+ +