mirror of
https://github.com/dpup/meshstream.git
synced 2026-05-04 20:42:27 +02:00
Add channel page
This commit is contained in:
@@ -3,12 +3,55 @@ import { Link } from "@tanstack/react-router";
|
||||
import { ConnectionStatus } from "./ConnectionStatus";
|
||||
import { Separator } from "./Separator";
|
||||
import { SITE_TITLE } from "../lib/config";
|
||||
import { Info, Layers, LayoutDashboard, Radio, Palette } from "lucide-react";
|
||||
import {
|
||||
Info,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
MessageSquare,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
// Define navigation item structure
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
// Define props for the component
|
||||
interface NavProps {
|
||||
connectionStatus: string;
|
||||
}
|
||||
|
||||
// Navigation items data
|
||||
const navigationItems: NavItem[] = [
|
||||
{
|
||||
to: "/",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
to: "/packets",
|
||||
label: "Stream",
|
||||
icon: Radio,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
to: "/channels",
|
||||
label: "Messages",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
to: "/info",
|
||||
label: "Information",
|
||||
icon: Info,
|
||||
exact: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const Nav: React.FC<NavProps> = ({ connectionStatus }) => {
|
||||
return (
|
||||
<aside className="w-64 text-neutral-100 h-screen fixed left-0 top-0 flex flex-col">
|
||||
@@ -24,70 +67,25 @@ export const Nav: React.FC<NavProps> = ({ connectionStatus }) => {
|
||||
|
||||
<nav className="flex-1">
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center px-4 py-2.5 transition-colors"
|
||||
inactiveProps={{
|
||||
className: "text-neutral-400 hover:text-neutral-200 font-thin",
|
||||
}}
|
||||
activeProps={{
|
||||
exact: true,
|
||||
className: "text-neutral-200 font-normal",
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4 mr-3" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/packets"
|
||||
className="flex items-center px-4 py-2.5 transition-colors "
|
||||
inactiveProps={{
|
||||
className: "text-neutral-400 hover:text-neutral-200 font-thin",
|
||||
}}
|
||||
activeProps={{
|
||||
exact: true,
|
||||
className: "text-neutral-200 font-normal",
|
||||
}}
|
||||
>
|
||||
<Radio className="h-4 w-4 mr-3" />
|
||||
Stream
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/demo"
|
||||
className="flex items-center px-4 py-2.5 transition-colors "
|
||||
inactiveProps={{
|
||||
className: "text-neutral-400 hover:text-neutral-200 font-thin",
|
||||
}}
|
||||
activeProps={{
|
||||
exact: true,
|
||||
className: "text-neutral-200 font-normal",
|
||||
}}
|
||||
>
|
||||
<Palette className="h-4 w-4 mr-3" />
|
||||
Component Demo
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/info"
|
||||
className="flex items-center px-4 py-2.5 transition-colors "
|
||||
inactiveProps={{
|
||||
className: "text-neutral-400 hover:text-neutral-200 font-thin",
|
||||
}}
|
||||
activeProps={{
|
||||
exact: true,
|
||||
className: "text-neutral-200 font-normal",
|
||||
}}
|
||||
>
|
||||
<Info className="h-4 w-4 mr-3" />
|
||||
Information
|
||||
</Link>
|
||||
</li>
|
||||
{navigationItems.map((item) => (
|
||||
<li key={item.to}>
|
||||
<Link
|
||||
to={item.to}
|
||||
className="flex items-center px-4 py-2.5 transition-colors"
|
||||
inactiveProps={{
|
||||
className:
|
||||
"text-neutral-400 hover:text-neutral-200 font-thin",
|
||||
}}
|
||||
activeProps={{
|
||||
exact: item.exact,
|
||||
className: "text-neutral-200 font-normal",
|
||||
}}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-3" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -9,4 +9,5 @@ export * from './Separator';
|
||||
export * from './StreamControl';
|
||||
export * from './PageWrapper';
|
||||
export * from './Counter';
|
||||
export * from './dashboard';
|
||||
export * from './dashboard';
|
||||
export * from './messages';
|
||||
71
web/src/components/messages/ChannelCard.tsx
Normal file
71
web/src/components/messages/ChannelCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChannelData } from "../../store/slices/aggregatorSlice";
|
||||
|
||||
interface ChannelCardProps {
|
||||
channel: ChannelData;
|
||||
}
|
||||
|
||||
export const ChannelCard: React.FC<ChannelCardProps> = ({ channel }) => {
|
||||
// Generate a random color for the channel icon
|
||||
const getChannelColor = () => {
|
||||
const colors = [
|
||||
'bg-green-500', 'bg-blue-500', 'bg-amber-500', 'bg-purple-500',
|
||||
'bg-pink-500', 'bg-indigo-500', 'bg-red-500', 'bg-teal-500'
|
||||
];
|
||||
|
||||
// Use a hash of the channel ID to pick a consistent color
|
||||
const hash = channel.channelId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return colors[hash % colors.length];
|
||||
};
|
||||
|
||||
// Get short identifier for channel
|
||||
const getChannelInitials = () => {
|
||||
// Use first 2 characters
|
||||
return channel.channelId.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
// Format last message time as relative time
|
||||
const formatLastMessageTime = () => {
|
||||
if (!channel.lastMessage) return '';
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - channel.lastMessage;
|
||||
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/channel/$channelId"
|
||||
params={{ channelId: channel.channelId }}
|
||||
className="bg-neutral-800 rounded-lg p-4 hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className={`w-10 h-10 rounded-full ${getChannelColor()} flex items-center justify-center text-white text-sm font-bold`}>
|
||||
{getChannelInitials()}
|
||||
</div>
|
||||
<div className="ml-3 flex-1 truncate">
|
||||
<h3 className="text-lg text-neutral-200 font-medium truncate">{channel.channelId}</h3>
|
||||
{channel.lastMessage && (
|
||||
<p className="text-xs text-neutral-400">{formatLastMessageTime()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-neutral-400 text-sm">
|
||||
<div>
|
||||
<span className="text-amber-500 font-medium">{channel.textMessageCount}</span> messages
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-green-500 font-medium">{channel.nodes.length}</span> nodes
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-blue-500 font-medium">{channel.gateways.length}</span> gateways
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
67
web/src/components/messages/MessageBubble.tsx
Normal file
67
web/src/components/messages/MessageBubble.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { TextMessage } from "../../store/slices/aggregatorSlice";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: TextMessage;
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
// Helper to format timestamp
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Generate a deterministic color based on node ID
|
||||
const getNodeColor = (nodeId: number) => {
|
||||
const colors = [
|
||||
'bg-blue-500', 'bg-green-500', 'bg-yellow-500', 'bg-purple-500',
|
||||
'bg-pink-500', 'bg-indigo-500', 'bg-red-500', 'bg-orange-500'
|
||||
];
|
||||
return colors[nodeId % colors.length];
|
||||
};
|
||||
|
||||
// Get node initials or ID fragment
|
||||
const getNodeInitials = (nodeId: number, nodeName?: string) => {
|
||||
if (nodeName) {
|
||||
// Use first 2 characters or extract initials
|
||||
if (nodeName.length <= 2) return nodeName.toUpperCase();
|
||||
|
||||
// Try to get initials from words
|
||||
const words = nodeName.split(/\s+/);
|
||||
if (words.length >= 2) {
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
return nodeName.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// Use last 4 chars of hex ID
|
||||
return nodeId.toString(16).slice(-4).toUpperCase();
|
||||
};
|
||||
|
||||
export const MessageBubble: React.FC<MessageBubbleProps> = ({ message, nodeName }) => {
|
||||
const initials = getNodeInitials(message.from, nodeName);
|
||||
const nodeColor = getNodeColor(message.from);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className={`flex-shrink-0 w-9 h-9 rounded-full ${nodeColor} flex items-center justify-center text-white text-xs font-bold`}>
|
||||
{initials}
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-neutral-200 font-medium">
|
||||
{nodeName || `Node ${message.from.toString(16)}`}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-neutral-500">
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 bg-neutral-800 rounded-lg py-2 px-3 text-neutral-300 break-words">
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
web/src/components/messages/index.ts
Normal file
2
web/src/components/messages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ChannelCard';
|
||||
export * from './MessageBubble';
|
||||
@@ -15,8 +15,10 @@ import { Route as RootImport } from './routes/root'
|
||||
import { Route as PacketsImport } from './routes/packets'
|
||||
import { Route as HomeImport } from './routes/home'
|
||||
import { Route as DemoImport } from './routes/demo'
|
||||
import { Route as ChannelsImport } from './routes/channels'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as NodeNodeIdImport } from './routes/node.$nodeId'
|
||||
import { Route as ChannelChannelIdImport } from './routes/channel.$channelId'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
@@ -44,6 +46,12 @@ const DemoRoute = DemoImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ChannelsRoute = ChannelsImport.update({
|
||||
id: '/channels',
|
||||
path: '/channels',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -56,6 +64,12 @@ const NodeNodeIdRoute = NodeNodeIdImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ChannelChannelIdRoute = ChannelChannelIdImport.update({
|
||||
id: '/channel/$channelId',
|
||||
path: '/channel/$channelId',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -67,6 +81,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/channels': {
|
||||
id: '/channels'
|
||||
path: '/channels'
|
||||
fullPath: '/channels'
|
||||
preLoaderRoute: typeof ChannelsImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/demo': {
|
||||
id: '/demo'
|
||||
path: '/demo'
|
||||
@@ -95,6 +116,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof RootImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/channel/$channelId': {
|
||||
id: '/channel/$channelId'
|
||||
path: '/channel/$channelId'
|
||||
fullPath: '/channel/$channelId'
|
||||
preLoaderRoute: typeof ChannelChannelIdImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/node/$nodeId': {
|
||||
id: '/node/$nodeId'
|
||||
path: '/node/$nodeId'
|
||||
@@ -109,63 +137,91 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/channel/$channelId': typeof ChannelChannelIdRoute
|
||||
'/node/$nodeId': typeof NodeNodeIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/channel/$channelId': typeof ChannelChannelIdRoute
|
||||
'/node/$nodeId': typeof NodeNodeIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
'/channel/$channelId': typeof ChannelChannelIdRoute
|
||||
'/node/$nodeId': typeof NodeNodeIdRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/demo' | '/home' | '/packets' | '/root' | '/node/$nodeId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/demo' | '/home' | '/packets' | '/root' | '/node/$nodeId'
|
||||
id:
|
||||
| '__root__'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/packets'
|
||||
| '/root'
|
||||
| '/channel/$channelId'
|
||||
| '/node/$nodeId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/packets'
|
||||
| '/root'
|
||||
| '/channel/$channelId'
|
||||
| '/node/$nodeId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/packets'
|
||||
| '/root'
|
||||
| '/channel/$channelId'
|
||||
| '/node/$nodeId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ChannelsRoute: typeof ChannelsRoute
|
||||
DemoRoute: typeof DemoRoute
|
||||
HomeRoute: typeof HomeRoute
|
||||
PacketsRoute: typeof PacketsRoute
|
||||
RootRoute: typeof RootRoute
|
||||
ChannelChannelIdRoute: typeof ChannelChannelIdRoute
|
||||
NodeNodeIdRoute: typeof NodeNodeIdRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ChannelsRoute: ChannelsRoute,
|
||||
DemoRoute: DemoRoute,
|
||||
HomeRoute: HomeRoute,
|
||||
PacketsRoute: PacketsRoute,
|
||||
RootRoute: RootRoute,
|
||||
ChannelChannelIdRoute: ChannelChannelIdRoute,
|
||||
NodeNodeIdRoute: NodeNodeIdRoute,
|
||||
}
|
||||
|
||||
@@ -180,16 +236,21 @@ export const routeTree = rootRoute
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/channels",
|
||||
"/demo",
|
||||
"/home",
|
||||
"/packets",
|
||||
"/root",
|
||||
"/channel/$channelId",
|
||||
"/node/$nodeId"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/channels": {
|
||||
"filePath": "channels.tsx"
|
||||
},
|
||||
"/demo": {
|
||||
"filePath": "demo.tsx"
|
||||
},
|
||||
@@ -202,6 +263,9 @@ export const routeTree = rootRoute
|
||||
"/root": {
|
||||
"filePath": "root.tsx"
|
||||
},
|
||||
"/channel/$channelId": {
|
||||
"filePath": "channel.$channelId.tsx"
|
||||
},
|
||||
"/node/$nodeId": {
|
||||
"filePath": "node.$nodeId.tsx"
|
||||
}
|
||||
|
||||
113
web/src/routes/channel.$channelId.tsx
Normal file
113
web/src/routes/channel.$channelId.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppSelector } from "../hooks";
|
||||
import { PageWrapper, MessageBubble } from "../components";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute('/channel/$channelId')({
|
||||
component: ChannelPage,
|
||||
});
|
||||
|
||||
// Generate a deterministic color based on channel ID
|
||||
const getChannelColor = (channelId: string) => {
|
||||
const colors = [
|
||||
'bg-green-500', 'bg-blue-500', 'bg-amber-500', 'bg-purple-500',
|
||||
'bg-pink-500', 'bg-indigo-500', 'bg-red-500', 'bg-teal-500'
|
||||
];
|
||||
|
||||
// Use a hash of the channel ID to pick a consistent color
|
||||
const hash = channelId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return colors[hash % colors.length];
|
||||
};
|
||||
|
||||
function ChannelPage() {
|
||||
const { channelId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { channels, messages, nodes } = useAppSelector(state => state.aggregator);
|
||||
const channel = channels[channelId];
|
||||
// Create the channel key in the same format used by the aggregator
|
||||
const channelKey = `channel_${channelId}`;
|
||||
|
||||
// Get channel messages and sort by timestamp (oldest to newest)
|
||||
// This creates a shallow copy of the message array to sort it without modifying the original
|
||||
const channelMessages = messages[channelKey] ?
|
||||
[...messages[channelKey]].sort((a, b) => a.timestamp - b.timestamp) :
|
||||
[];
|
||||
|
||||
useEffect(() => {
|
||||
if (!channel) {
|
||||
console.log(`[Channel] Channel ${channelId} not found`);
|
||||
// Navigate back to channels page if this channel doesn't exist
|
||||
setTimeout(() => navigate({ to: '/channels' }), 500);
|
||||
} else {
|
||||
console.log(`[Channel] Displaying ${channelMessages.length} messages for channel ${channelId}`);
|
||||
console.log(`[Channel] Message count from channel data: ${channel.messageCount}`);
|
||||
console.log(`[Channel] Available messages:`, messages);
|
||||
console.log(`[Channel] Looking for key:`, channelKey);
|
||||
}
|
||||
}, [channel, channelId, channelMessages.length, navigate, messages]);
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="text-center py-10">
|
||||
<p className="text-neutral-400">Channel not found. Redirecting...</p>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Get the channel color based on its ID
|
||||
const channelColor = getChannelColor(channelId);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-4xl mx-auto h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-4">
|
||||
<Link
|
||||
to="/channels"
|
||||
className="mr-3 text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<div className={`w-10 h-10 rounded-full ${channelColor} flex items-center justify-center text-white text-sm font-bold`}>
|
||||
{channelId.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h1 className="text-xl font-semibold text-neutral-100">{channelId}</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
{channel.nodes.length} nodes · {channel.textMessageCount} messages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message List */}
|
||||
<div className="flex-1 overflow-y-auto pb-4">
|
||||
{channelMessages.length === 0 ? (
|
||||
<div className="bg-neutral-800 rounded-lg p-6 text-center text-neutral-400">
|
||||
<p>No messages in this channel yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{channelMessages.map((message) => {
|
||||
const nodeName = nodes[message.from]?.shortName || nodes[message.from]?.longName;
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
key={`${message.from}-${message.id}`}
|
||||
message={message}
|
||||
nodeName={nodeName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
60
web/src/routes/channels.tsx
Normal file
60
web/src/routes/channels.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppSelector } from "../hooks";
|
||||
import { PageWrapper, ChannelCard } from "../components";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute('/channels')({
|
||||
component: ChannelsPage,
|
||||
});
|
||||
|
||||
function ChannelsPage() {
|
||||
const { channels, messages } = useAppSelector(state => state.aggregator);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`[Channels] Displaying ${Object.keys(channels).length} channels`);
|
||||
console.log(`[Channels] Messages in store:`, messages);
|
||||
|
||||
// Check if any channels have text messages but none are displayed
|
||||
Object.entries(channels).forEach(([channelId, channel]) => {
|
||||
const channelKey = `channel_${channelId}`;
|
||||
if (channel.textMessageCount > 0 && (!messages[channelKey] || messages[channelKey].length === 0)) {
|
||||
console.log(`[Channels] Mismatch: Channel ${channelId} reports ${channel.textMessageCount} text messages but has ${messages[channelKey]?.length || 0} in store`);
|
||||
}
|
||||
});
|
||||
}, [channels, messages]);
|
||||
|
||||
// Extract and sort channels by last message time (most recent first)
|
||||
const sortedChannels = Object.values(channels).sort((a, b) => {
|
||||
// First sort by activity (last message time)
|
||||
if (a.lastMessage && b.lastMessage) {
|
||||
return b.lastMessage - a.lastMessage;
|
||||
}
|
||||
// If one has activity and other doesn't, prioritize the active one
|
||||
if (a.lastMessage) return -1;
|
||||
if (b.lastMessage) return 1;
|
||||
|
||||
// Fall back to text message count if no last message time
|
||||
return (b.textMessageCount || 0) - (a.textMessageCount || 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-semibold text-neutral-100 mb-6">Channels</h1>
|
||||
|
||||
{sortedChannels.length === 0 ? (
|
||||
<div className="bg-neutral-800 rounded-lg p-6 text-center text-neutral-400">
|
||||
<p>No channels discovered yet.</p>
|
||||
<p className="text-sm mt-2">Channels will appear here as they are discovered on the Meshtastic network.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedChannels.map((channel) => (
|
||||
<ChannelCard key={channel.channelId} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export interface ChannelData {
|
||||
gateways: string[]; // Changed from Set to array for Redux serialization
|
||||
nodes: number[]; // Changed from Set to array for Redux serialization
|
||||
messageCount: number;
|
||||
textMessageCount: number; // Count specifically for text messages
|
||||
lastMessage?: number;
|
||||
}
|
||||
|
||||
@@ -152,12 +153,18 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
|
||||
gateways: [],
|
||||
nodes: [],
|
||||
messageCount: 0,
|
||||
textMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const channel = state.channels[channelId];
|
||||
channel.messageCount++;
|
||||
|
||||
|
||||
// Track text messages separately
|
||||
if (data.textMessage) {
|
||||
channel.textMessageCount++;
|
||||
}
|
||||
|
||||
channel.lastMessage = timestamp;
|
||||
|
||||
if (gatewayId && !channel.gateways.includes(gatewayId)) {
|
||||
@@ -212,7 +219,12 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Process text messages.
|
||||
// Process text messages - only for new packets to avoid duplicates
|
||||
// Debug text message processing
|
||||
if (data.textMessage) {
|
||||
console.log(`[Aggregator] Processing text message: "${data.textMessage}" on channel ${channelId}, isNewPacket: ${isNewPacket}`);
|
||||
}
|
||||
|
||||
if (data.textMessage && nodeId !== undefined && channelId) {
|
||||
const channelKey = getChannelKey(channelId);
|
||||
|
||||
@@ -224,15 +236,24 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
|
||||
const nodeName =
|
||||
state.nodes[nodeId]?.shortName || state.nodes[nodeId]?.longName;
|
||||
|
||||
state.messages[channelKey].push({
|
||||
id: data.id || Math.random(),
|
||||
// Ensure we have a stable ID for the message
|
||||
const messageId = data.id !== undefined ? data.id : Math.floor(Math.random() * 1000000);
|
||||
|
||||
const newMessage = {
|
||||
id: messageId,
|
||||
from: nodeId,
|
||||
fromName: nodeName,
|
||||
text: data.textMessage,
|
||||
timestamp,
|
||||
channelId,
|
||||
gatewayId: gatewayId || "",
|
||||
});
|
||||
};
|
||||
|
||||
state.messages[channelKey].push(newMessage);
|
||||
|
||||
console.log(`[Aggregator] Added message to channel ${channelId}:`, newMessage);
|
||||
console.log(`[Aggregator] Channel ${channelId} now has ${state.messages[channelKey].length} messages`);
|
||||
|
||||
|
||||
// Sort messages by timestamp (newest first)
|
||||
state.messages[channelKey].sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
Reference in New Issue
Block a user