mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 08:43:36 +02:00
Add bulk room add
This commit is contained in:
@@ -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<typeof Sidebar>;
|
||||
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
||||
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
||||
type BulkAddChannelResultModalProps = Omit<
|
||||
ComponentProps<typeof BulkAddChannelResultModal>,
|
||||
'open' | 'onClose'
|
||||
>;
|
||||
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
||||
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
||||
|
||||
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<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
||||
sidebarProps: SidebarProps;
|
||||
@@ -61,6 +68,7 @@ interface AppShellProps {
|
||||
>;
|
||||
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
||||
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}
|
||||
/>
|
||||
<BulkAddChannelResultModal
|
||||
{...bulkAddChannelResultModalProps}
|
||||
open={showBulkAddResults}
|
||||
onClose={onCloseBulkAddResults}
|
||||
/>
|
||||
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Add Complete</DialogTitle>
|
||||
<DialogDescription>
|
||||
{result?.message ?? 'Review the newly added rooms below.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{result && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div>
|
||||
<div className="mt-1 font-medium">{createdChannels.length}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Already Present
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{result.existing_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdChannels.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ctrl+click any room to open it in a new tab.
|
||||
</p>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
|
||||
<ul className="divide-y divide-border/70">
|
||||
{createdChannels.map((channel) => (
|
||||
<li key={channel.key}>
|
||||
<a
|
||||
href={getChannelHref(channel)}
|
||||
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
|
||||
>
|
||||
{channel.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
|
||||
)}
|
||||
|
||||
{result && result.invalid_names.length > 0 && (
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
Ignored invalid room names: {result.invalid_names.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
|
||||
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<Tab>('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<HTMLInputElement>(null);
|
||||
const bulkTextareaRef = useRef<HTMLTextAreaElement>(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({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{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'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(v as Tab);
|
||||
onValueChange={(value) => {
|
||||
setTab(value as Tab);
|
||||
resetForm();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList
|
||||
className={
|
||||
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
|
||||
}
|
||||
>
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||
{showBulkAddChannelTab && (
|
||||
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permitCapitals}
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
<p className="pl-7 text-xs text-muted-foreground">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{showBulkAddChannelTab && (
|
||||
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
|
||||
<textarea
|
||||
ref={bulkTextareaRef}
|
||||
id="bulk-hashtag-names"
|
||||
aria-label="Bulk channel names"
|
||||
value={bulkChannelText}
|
||||
onChange={(e) => setBulkChannelText(e.target.value)}
|
||||
placeholder={'#ops\nmesh-room\nanother-room'}
|
||||
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste room names separated by lines, spaces, or commas. Leading # marks are
|
||||
stripped automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permitCapitals}
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="pl-7 text-xs text-muted-foreground">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{showHistoricalOption && (
|
||||
@@ -289,7 +408,7 @@ export function NewMessageModal({
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Label
|
||||
htmlFor="try-historical"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
className="cursor-pointer text-sm text-muted-foreground"
|
||||
>
|
||||
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
||||
{undecryptedCount !== 1 ? 's' : ''}
|
||||
@@ -301,7 +420,7 @@ export function NewMessageModal({
|
||||
/>
|
||||
</div>
|
||||
{tryHistorical && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
Messages will stream in as they decrypt in the background
|
||||
</p>
|
||||
)}
|
||||
@@ -330,7 +449,13 @@ export function NewMessageModal({
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
{loading
|
||||
? tab === 'bulk-hashtag'
|
||||
? 'Adding...'
|
||||
: 'Creating...'
|
||||
: tab === 'bulk-hashtag'
|
||||
? 'Add Channels'
|
||||
: 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -97,7 +97,7 @@ interface SidebarProps {
|
||||
channels: Channel[];
|
||||
activeConversation: Conversation | null;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onNewMessage: () => void;
|
||||
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
lastMessageTimes: ConversationTimes;
|
||||
unreadCounts: Record<string, number>;
|
||||
/** Tracks which conversations have unread messages that mention the user */
|
||||
|
||||
Reference in New Issue
Block a user