feat: initial KosmoConnect platform v0.1
Includes: - Backend services: ingestion (:8001), weather API (:8002), gateway (:8003), billing (:8004) with BTCPay integration - Shared asyncpg pool, TimescaleDB hypertable, Redis, Mosquitto MQTT - React frontend: Dashboard (MapLibre) and Messaging (chat UI) - Bridge daemon for Pi + Meshtastic (Serial/TCP T-Deck support) - Production Docker Compose, Nginx reverse proxy, ops scripts - DEPLOY.md with step-by-step deployment guide
This commit is contained in:
35
web/messaging/README.md
Normal file
35
web/messaging/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# KosmoConnect Messaging Client
|
||||
|
||||
A subscriber-only web application for sending and receiving messages with the Meshtastic mesh network.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React 18** + **Vite**
|
||||
- Plain CSS (no heavy UI framework)
|
||||
|
||||
## Running Locally
|
||||
|
||||
Make sure the **Gateway Service** is running on `http://localhost:8003` (see `backend/gateway/README.md`).
|
||||
|
||||
```bash
|
||||
cd web/messaging
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3001 in your browser.
|
||||
|
||||
## Features (v0.1)
|
||||
|
||||
- **User switcher** (dev mode): select between test subscription tiers (Wanderer / Guardian)
|
||||
- **Conversation list**: auto-refreshing sidebar with latest message preview and unread badges
|
||||
- **Message thread**: chat-style bubbles with timestamps and delivery status indicators
|
||||
- `⏳` pending / `✓` queued / `✓✓` transmitted or delivered
|
||||
- **Auto-refresh**: polls for new replies every 5 seconds
|
||||
- **Subscription enforcement**: errors surfaced as browser alerts (e.g., quota exceeded, node not allowed)
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- The Vite dev server proxies `/api` requests to `localhost:8003` to avoid CORS issues during development.
|
||||
- In production, the messaging client is served as static files and talks directly to the API gateway host.
|
||||
- Authentication is currently mocked with a simple `X-User-ID` header selector. Production will use JWT.
|
||||
12
web/messaging/index.html
Normal file
12
web/messaging/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KosmoConnect Messaging</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1753
web/messaging/package-lock.json
generated
Normal file
1753
web/messaging/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
web/messaging/package.json
Normal file
21
web/messaging/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "kosmoconnect-messaging",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
92
web/messaging/src/App.jsx
Normal file
92
web/messaging/src/App.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import ConversationList from './components/ConversationList'
|
||||
import MessageThread from './components/MessageThread'
|
||||
import UserSwitcher from './components/UserSwitcher'
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
background: '#0f172a',
|
||||
},
|
||||
header: {
|
||||
padding: '0.75rem 1rem',
|
||||
background: '#0f172a',
|
||||
borderBottom: '1px solid #1e293b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
color: '#38bdf8',
|
||||
},
|
||||
main: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sidebar: {
|
||||
width: '320px',
|
||||
minWidth: '260px',
|
||||
borderRight: '1px solid #1e293b',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thread: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#64748b',
|
||||
fontSize: '0.95rem',
|
||||
},
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [userId, setUserId] = useState('11111111-1111-1111-1111-111111111111')
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(null)
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<header style={styles.header}>
|
||||
<h1 style={styles.title}>KosmoConnect Messaging</h1>
|
||||
<UserSwitcher userId={userId} onChange={setUserId} />
|
||||
</header>
|
||||
|
||||
<main style={styles.main}>
|
||||
<aside style={styles.sidebar}>
|
||||
<ConversationList
|
||||
userId={userId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onSelect={setSelectedNodeId}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section style={styles.thread}>
|
||||
{selectedNodeId ? (
|
||||
<MessageThread
|
||||
userId={userId}
|
||||
nodeId={selectedNodeId}
|
||||
key={selectedNodeId + userId}
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.empty}>Select a conversation to start messaging</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
web/messaging/src/components/ConversationList.jsx
Normal file
96
web/messaging/src/components/ConversationList.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useConversations } from '../hooks/useApi'
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return ''
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const sec = Math.floor(diff / 1000)
|
||||
if (sec < 60) return `${sec}s`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min}m`
|
||||
const hr = Math.floor(min / 60)
|
||||
return `${hr}h`
|
||||
}
|
||||
|
||||
export default function ConversationList({ userId, selectedNodeId, onSelect }) {
|
||||
const { conversations, error } = useConversations(userId)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.6rem 0.9rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#64748b',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
borderBottom: '1px solid #1e293b',
|
||||
}}
|
||||
>
|
||||
Conversations
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: '0.5rem 0.75rem', color: '#fecaca', fontSize: '0.85rem' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{conversations.length === 0 && (
|
||||
<div style={{ padding: '1rem', color: '#64748b', fontSize: '0.875rem' }}>
|
||||
No conversations yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conversations.map((c) => {
|
||||
const isSelected = c.node_id === selectedNodeId
|
||||
return (
|
||||
<button
|
||||
key={c.node_id}
|
||||
onClick={() => onSelect(c.node_id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: isSelected ? '#1e293b' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: '1px solid #1e293b',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: '#f8fafc', fontSize: '0.95rem' }}>
|
||||
{c.nickname || c.node_id}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>{timeAgo(c.latest_at)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#94a3b8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{c.latest_text || 'No messages'}
|
||||
</div>
|
||||
{c.unread_count > 0 && (
|
||||
<span
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: '0.2rem',
|
||||
background: '#38bdf8',
|
||||
color: '#0f172a',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
padding: '0.15rem 0.4rem',
|
||||
borderRadius: '999px',
|
||||
}}
|
||||
>
|
||||
{c.unread_count} new
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
web/messaging/src/components/MessageThread.jsx
Normal file
133
web/messaging/src/components/MessageThread.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useMessages } from '../hooks/useApi'
|
||||
|
||||
function statusIcon(status) {
|
||||
if (status === 'pending') return '⏳'
|
||||
if (status === 'queued') return '✓'
|
||||
if (status === 'transmitted') return '✓✓'
|
||||
if (status === 'delivered') return '✓✓'
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function MessageThread({ userId, nodeId }) {
|
||||
const { messages, error, sendMessage } = useMessages(userId, nodeId)
|
||||
const [text, setText] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const bottomRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleSend = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!text.trim() || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
await sendMessage(text.trim())
|
||||
setText('')
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid #1e293b',
|
||||
fontWeight: 600,
|
||||
color: '#f8fafc',
|
||||
background: '#0f172a',
|
||||
}}
|
||||
>
|
||||
{nodeId}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{error && (
|
||||
<div style={{ color: '#fecaca', fontSize: '0.85rem' }}>Error loading messages: {error}</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => {
|
||||
const isOutbound = msg.direction === 'outbound'
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
alignSelf: isOutbound ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '70%',
|
||||
background: isOutbound ? '#075985' : '#1e293b',
|
||||
color: '#f8fafc',
|
||||
padding: '0.6rem 0.9rem',
|
||||
borderRadius: '0.75rem',
|
||||
borderBottomRightRadius: isOutbound ? '0.25rem' : '0.75rem',
|
||||
borderBottomLeftRadius: isOutbound ? '0.75rem' : '0.25rem',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{msg.text}
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.35rem', justifyContent: isOutbound ? 'flex-end' : 'flex-start' }}>
|
||||
<span>{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
{isOutbound && <span title={msg.status}>{statusIcon(msg.status)}</span>}
|
||||
{msg.hop_count != null && (
|
||||
<span title={`${msg.hop_count} hops`}>· {msg.hop_count} hops</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSend}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderTop: '1px solid #1e293b',
|
||||
background: '#0f172a',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
maxLength={200}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
color: '#f8fafc',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || !text.trim()}
|
||||
style={{
|
||||
background: sending ? '#334155' : '#0284c7',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.5rem 1rem',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
web/messaging/src/components/UserSwitcher.jsx
Normal file
30
web/messaging/src/components/UserSwitcher.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
const USERS = [
|
||||
{ id: '11111111-1111-1111-1111-111111111111', label: 'Wanderer (any node)' },
|
||||
{ id: '22222222-2222-2222-2222-222222222222', label: 'Guardian (allowed nodes)' },
|
||||
]
|
||||
|
||||
export default function UserSwitcher({ userId, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>User:</span>
|
||||
<select
|
||||
value={userId}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
background: '#1e293b',
|
||||
color: '#f8fafc',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.35rem 0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{USERS.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
web/messaging/src/hooks/useApi.js
Normal file
77
web/messaging/src/hooks/useApi.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8003'
|
||||
|
||||
async function apiFetch(path, userId, options = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': userId,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function useConversations(userId, refreshMs = 5000) {
|
||||
const [conversations, setConversations] = useState([])
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function fetchConversations() {
|
||||
try {
|
||||
const json = await apiFetch('/api/v1/messages/conversations', userId)
|
||||
if (!cancelled) setConversations(json.data || [])
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message)
|
||||
}
|
||||
}
|
||||
fetchConversations()
|
||||
const id = setInterval(fetchConversations, refreshMs)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [userId, refreshMs])
|
||||
|
||||
return { conversations, error }
|
||||
}
|
||||
|
||||
export function useMessages(userId, nodeId, refreshMs = 5000) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function fetchMessages() {
|
||||
try {
|
||||
const json = await apiFetch(`/api/v1/messages/conversations/${encodeURIComponent(nodeId)}`, userId)
|
||||
if (!cancelled) setMessages(json.data || [])
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message)
|
||||
}
|
||||
}
|
||||
fetchMessages()
|
||||
const id = setInterval(fetchMessages, refreshMs)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [userId, nodeId, refreshMs])
|
||||
|
||||
const sendMessage = useCallback(async (text) => {
|
||||
const json = await apiFetch('/api/v1/messages', userId, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target_node_id: nodeId, text }),
|
||||
})
|
||||
return json
|
||||
}, [userId, nodeId])
|
||||
|
||||
return { messages, error, sendMessage }
|
||||
}
|
||||
31
web/messaging/src/index.css
Normal file
31
web/messaging/src/index.css
Normal file
@@ -0,0 +1,31 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.5;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background-color: #0f172a;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
10
web/messaging/src/main.jsx
Normal file
10
web/messaging/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
15
web/messaging/vite.config.js
Normal file
15
web/messaging/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8003',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user