13 Commits

Author SHA1 Message Date
90c2c1e3e0 feat(realtime): replace polling with a Rust-owned WebSocket connection
MessageList polled every three seconds and ChatPage bumped a refreshKey after
each send. Both are gone; messages now arrive over a socket.

The connection lives in the Tauri Rust layer rather than in the WebView. Two
reasons: the handshake can carry a real Authorization header, which is the only
channel that nginx, Apache and Caddy do not write to their access logs by
default, and tungstenite surfaces the full HTTP response on a rejected
handshake. The browser API is required by spec to collapse every failure into
close code 1006, so "token expired" and "server down" would be
indistinguishable — which matters once the client holds connections to several
servers and has to show a meaningful state for each.

The socket only carries {conversation, seq}. Rust does the backfill over REST
and hands resolved entities to the frontend, so the UI keeps no cursor logic:
it replaces by id and sorts by seq.

The client now supplies the message id. Without it an optimistically rendered
message cannot be matched against the copy that arrives over the realtime
channel and shows up twice; it also makes a retry after an unclear network
error a no-op instead of a duplicate.

icons/icon.ico is generated from the existing icon.png — tauri-build requires
it on Windows, and without it the crate does not build there at all.

Not yet done, marked as TODO in the code: token refresh (the connection ends
at expiry with 4001), the server list replacing BASE_URL, optimistic rendering,
and the DM-versus-channel rule for background fetching.
2026-08-14 18:26:28 +02:00
b93ca7dcb6 ci: add Gitea workflow for Linux and Android release builds
Some checks failed
release / build (push) Failing after 2m6s
2026-08-14 12:04:34 +02:00
fb3bc6e93a docs: add README 2026-08-13 13:10:55 +02:00
dc7678d184 chore: add app icon 2026-08-12 18:11:43 +02:00
ee19de5f94 fix: reset scroll ref on conversation change
prevLengthRef.current was never zeroed when conversationId changed,
so the first batch of messages in a new (shorter) conversation would
not trigger scrollIntoView. Reset it in the polling useEffect cleanup.
2026-08-12 18:03:19 +02:00
455d304885 fix: address final review findings
- Finding 1: add AppState::authed_get/authed_post helpers that attach
  Bearer token when present; all 4 commands now use them
- Finding 2: MessageInput accepts onSent callback, calls it after
  successful send; ChatPage passes refreshKey bump as onSent
- Finding 3: MessageList useEffect uses cancellation flag so stale
  in-flight responses cannot overwrite a newly selected conversation
- Finding 4: scrollIntoView moved to separate effect keyed on
  messages.length so scroll only fires when new messages arrive
2026-08-12 17:58:02 +02:00
91260c8e30 feat: implement chat page with conversation list, message view, and polling 2026-08-12 17:51:00 +02:00
68c5024334 feat: implement workspace list page 2026-08-12 17:47:50 +02:00
b4e9d25b9f feat: implement login page 2026-08-12 17:44:21 +02:00
a432ac976b feat: add React app shell with routing 2026-08-12 17:27:40 +02:00
3109ac4c22 feat: add message Tauri commands 2026-08-12 17:21:44 +02:00
7c2e5ca9a9 feat: add workspace and conversation Tauri commands
Implement list_workspaces and list_conversations commands to enable
frontend access to workspace and conversation data via Tauri IPC.

- Add workspace.rs with list_workspaces command (GET /workspaces)
- Add conversation.rs with list_conversations command (GET /conversations?workspace_id=)
- Register both commands in lib.rs invoke_handler
- Update commands/mod.rs to expose new modules
2026-08-12 17:04:35 +02:00
d384465f2e feat: add login Tauri command 2026-08-12 16:59:55 +02:00
24 changed files with 8299 additions and 2 deletions

View File

@@ -0,0 +1,83 @@
name: release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: android-actions/setup-android@v3
- uses: nttld/setup-ndk@v1
id: ndk
with:
ndk-version: r26d
- name: System dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq libgtk-3-dev libwebkit2gtk-4.1-dev \
libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev
- run: npm ci
# Desktop: deb + rpm + AppImage (bundle.targets = "all")
- name: Build Linux desktop
run: npm run tauri build
- name: Build Android APK
env:
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
npm run tauri android init
npm run tauri android build -- --apk
# ponytail: sign the finished APK with apksigner instead of patching
# gen/android/app/build.gradle.kts — keeps gen/ throwaway, no committed generated code.
- name: Sign APK
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE }}
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
echo "$KEYSTORE_B64" | base64 -d > /tmp/ks.jks
BT="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)"
IN=src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk
"$BT/zipalign" -p -f 4 "$IN" /tmp/aligned.apk
"$BT/apksigner" sign --ks /tmp/ks.jks --ks-pass "env:KS_PASS" \
--out "rustification-${GITHUB_REF_NAME}.apk" /tmp/aligned.apk
- name: Publish release
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
ID=$(curl -sSf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" | jq -r .id)
for f in src-tauri/target/release/bundle/deb/*.deb \
src-tauri/target/release/bundle/rpm/*.rpm \
src-tauri/target/release/bundle/appimage/*.AppImage \
rustification-${GITHUB_REF_NAME}.apk; do
[ -f "$f" ] || continue
curl -sSf -X POST "$API/releases/$ID/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" -F "attachment=@$f"
done

5088
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
# rustification-client
Desktop chat client for Rustification. Built with Tauri v2 (Rust backend) and React + TypeScript (frontend).
## Tech stack
| Layer | Technology |
|-------|-----------|
| Desktop shell | [Tauri](https://tauri.app) v2 |
| Frontend | React 18 + TypeScript, Vite |
| HTTP | reqwest (Rust) via Tauri commands |
| Persistence | tauri-plugin-store (JWT token, user ID) |
| Build (Linux) | nix-shell |
## Features
- **Login / Register** — authenticates against the rustification-server, stores JWT via tauri-plugin-store
- **Workspace list** — browse workspaces the current user belongs to
- **Chat page** — conversation list, message history, and real-time polling for new messages
- **Message input** — send messages to any conversation
- **Member panel** — view conversation members
- **Settings page** — user preferences
## Prerequisites
- [Rust](https://rustup.rs/) + Cargo
- [Node.js](https://nodejs.org/) 18+ and npm
- Tauri v2 system dependencies (see below)
- A running [rustification-server](../rustification-server)
### System dependencies (Linux)
The project ships a `shell.nix`. If you use Nix, enter the dev shell first:
```bash
nix-shell
```
This provides: `cargo`, `rustc`, `pkg-config`, `glib`, `gtk3`, `webkitgtk_4_1`, `libsoup_3`, `openssl`, `librsvg`.
Without Nix, install the equivalent packages via your system package manager. On Debian/Ubuntu:
```bash
sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev \
libayatana-appindicator3-dev librsvg2-dev
```
## Getting started
```bash
# Install frontend dependencies
npm install
# Start in development mode (hot-reload frontend + Tauri window)
# Must be run inside nix-shell on NixOS / Nix systems
npm run tauri dev
```
The client defaults to `http://localhost:3000/api/v1` as the server base URL (configurable at runtime via Settings).
## Build
```bash
# Must be run inside nix-shell on NixOS / Nix systems
npm run tauri build
```
Output bundles (`.deb`, `.AppImage`, etc.) are written to `src-tauri/target/release/bundle/`.
## Project structure
```
src/ # React frontend
├── main.tsx # Entry point
├── App.tsx # Router + AuthContext provider
├── theme.ts # MUI / global theme
├── styles.css
├── contexts/
│ └── AuthContext.tsx # Auth state (token, user ID, login/logout)
├── pages/
│ ├── LoginPage.tsx
│ ├── WorkspacePage.tsx
│ ├── ChatPage.tsx
│ └── SettingsPage.tsx
└── components/
├── ConversationList.tsx
├── MessageList.tsx
├── MessageInput.tsx
├── MemberPanel.tsx
└── Toast.tsx
src-tauri/ # Tauri Rust backend
├── Cargo.toml
└── src/
├── lib.rs # Tauri builder, plugin + command registration
├── main.rs
├── state.rs # AppState: reqwest client, token, base URL
├── models.rs # Shared response types
└── commands/
├── user.rs # login, register, refresh_session, logout
├── workspace.rs # list_workspaces
├── conversation.rs # list_conversations
└── message.rs # list_messages, send_message
```
## Tauri commands
The frontend calls these via `invoke()`:
| Command | Description |
|---------|-------------|
| `login` | Authenticates and stores JWT + user ID |
| `register` | Registers a new account |
| `refresh_session` | Refreshes the JWT using the stored refresh token |
| `logout` | Clears stored credentials |
| `list_workspaces` | Returns workspaces for the current user |
| `list_conversations` | Returns conversations for a workspace |
| `list_messages` | Returns messages for a conversation |
| `send_message` | Posts a new message |
## Server connection
The default base URL is `http://localhost:3000/api/v1`. To point the client at a different server, update the URL in Settings — it is persisted in the Tauri store across sessions.

View File

@@ -16,3 +16,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1.24.0", features = ["v4"] }
futures-util = "0.3.34"
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
rand = "0.10.2"

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1,17 @@
use crate::models::Conversation;
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn list_conversations(
state: tauri::State<'_, AppState>,
workspace_id: String,
) -> Result<Vec<Conversation>, String> {
state
.authed_get(&format!("{}/conversations?workspace_id={}", BASE_URL, workspace_id))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,66 @@
use crate::models::Message;
use crate::state::{AppState, BASE_URL};
#[derive(serde::Deserialize)]
struct History {
cursor: i64,
messages: Vec<Message>,
}
#[tauri::command]
pub async fn list_messages(
state: tauri::State<'_, AppState>,
conversation_id: String,
) -> Result<Vec<Message>, String> {
let history: History = state
.authed_get(&format!(
"{}/conversations/{}/messages",
BASE_URL, conversation_id
))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
// Loading the history *is* a resync: adopt the cursor it came with, so the
// next realtime signal asks for a delta from the right place.
state.cursors.set(&conversation_id, history.cursor);
Ok(history.messages)
}
#[tauri::command]
pub async fn send_message(
state: tauri::State<'_, AppState>,
conversation_id: String,
content: String,
) -> Result<Message, String> {
let user_id = state
.user_id
.lock()
.unwrap()
.clone()
.ok_or_else(|| "Not logged in".to_string())?;
// The id comes from here, not from the server. Without it the optimistically
// rendered message cannot be matched against the copy that arrives over the
// realtime channel, and the same message shows up twice. It also makes a
// retry after an unclear network error a no-op instead of a duplicate.
let body = serde_json::json!({
"id": uuid::Uuid::new_v4().to_string(),
"conversation_id": conversation_id,
"user_id": user_id,
"content": content,
});
state
.authed_post(&format!("{}/messages", BASE_URL))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,4 @@
pub mod conversation;
pub mod message;
pub mod user;
pub mod workspace;

View File

@@ -0,0 +1,48 @@
use crate::models::{AuthTokenResponse, User};
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn login(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
email: String,
) -> Result<User, String> {
let users: Vec<User> = state
.client
.get(format!("{}/users", BASE_URL))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let user = users
.into_iter()
.find(|u| u.email == email)
.ok_or_else(|| "User not found".to_string())?;
let body = serde_json::json!({
"user_id": user.id,
"kind": "refresh_token",
"expires_at": "2027-01-01T00:00:00Z"
});
let token_resp: AuthTokenResponse = state
.client
.post(format!("{}/auth-tokens", BASE_URL))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
*state.token.lock().unwrap() = Some(token_resp.token);
*state.user_id.lock().unwrap() = Some(user.id.clone());
crate::realtime::spawn(app);
Ok(user)
}

View File

@@ -0,0 +1,14 @@
use crate::models::Workspace;
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn list_workspaces(state: tauri::State<'_, AppState>) -> Result<Vec<Workspace>, String> {
state
.authed_get(&format!("{}/workspaces", BASE_URL))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -1,11 +1,21 @@
mod commands;
mod models;
mod realtime;
mod state;
use state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
commands::user::login,
commands::workspace::list_workspaces,
commands::conversation::list_conversations,
commands::message::list_messages,
commands::message::send_message,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -28,12 +28,16 @@ pub struct Conversation {
pub created_by: String,
}
#[derive(Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Message {
pub id: String,
pub conversation_id: String,
pub user_id: String,
pub content: String,
/// Sort key within the conversation. Display order follows this, never
/// `created_at` — that is transaction start time on the server, so it is
/// neither unique nor commit-ordered.
pub seq: i64,
pub created_at: String,
}

219
src-tauri/src/realtime.rs Normal file
View File

@@ -0,0 +1,219 @@
//! The realtime connection, owned by Rust rather than by the WebView.
//!
//! Two reasons it lives here. The handshake can carry a real
//! `Authorization: Bearer` header — the only channel that proxy defaults do not
//! write to their access logs — and `tungstenite` surfaces the full HTTP
//! response on a rejected handshake, whereas the browser API is required by spec
//! to collapse every failure into close code 1006. "Token expired" and "server
//! down" would be indistinguishable in the WebView.
//!
//! The socket only ever says "conversation X is at seq N". This module then
//! pulls the actual entities over REST and hands them to the frontend, so the
//! frontend keeps no cursor logic at all.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
use crate::models::Message as ChatMessage;
use crate::state::{AppState, BASE_URL};
pub const EVENT_CHANGED: &str = "realtime://changed";
pub const EVENT_RESYNC: &str = "realtime://resync";
// TODO: a connection-state event per server once the UI shows it — ticket 10
// wants "connected / reconnecting / signed out" visible per server. Left out
// until something listens, rather than emitting into the void.
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Inbound {
Changed { conversation: String, seq: i64 },
Resync,
}
#[derive(Debug, Serialize, Clone)]
pub struct Changed {
pub conversation_id: String,
pub messages: Vec<ChatMessage>,
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Changes {
Delta {
cursor: i64,
messages: Vec<ChatMessage>,
},
Resync {
cursor: i64,
},
}
/// Cursor per conversation. Lives next to the connection and the token, so the
/// frontend never has to know it exists.
#[derive(Default)]
pub struct Cursors(Mutex<HashMap<String, i64>>);
impl Cursors {
fn get(&self, conversation: &str) -> i64 {
*self.0.lock().unwrap().get(conversation).unwrap_or(&0)
}
pub fn set(&self, conversation: &str, seq: i64) {
self.0.lock().unwrap().insert(conversation.to_string(), seq);
}
fn clear(&self) {
self.0.lock().unwrap().clear();
}
}
/// Close codes the server may send. 4002 and 4003 are terminal: reconnecting
/// after being displaced would just displace somebody else, and two devices
/// would push each other around in circles.
const CLOSE_TOKEN_EXPIRED: u16 = 4001;
const CLOSE_DISPLACED: u16 = 4002;
const CLOSE_UNAUTHORIZED: u16 = 4003;
pub fn spawn(app: AppHandle) {
tauri::async_runtime::spawn(async move {
// TODO: one connection per configured server once the server list
// replaces BASE_URL. The structure below is per-connection already; what
// is missing is the list and the per-server auth UI.
let mut attempt: u32 = 0;
loop {
match connect_once(&app).await {
Ok(terminal) => {
if terminal {
return;
}
attempt = 0;
}
Err(e) => eprintln!("realtime: {e}"),
}
// Full jitter. The random part is not optional: without it every
// client comes back at the same instant after a server restart.
let ceiling = (500u64 << attempt.min(6)).min(30_000);
let wait = rand::random_range(0..=ceiling);
tokio::time::sleep(Duration::from_millis(wait)).await;
attempt = attempt.saturating_add(1);
}
});
}
/// Returns Ok(true) when the server told us to stay down.
async fn connect_once(app: &AppHandle) -> Result<bool, String> {
let state = app.state::<AppState>();
let token = state
.token
.lock()
.unwrap()
.clone()
.ok_or_else(|| "not signed in".to_string())?;
let url = BASE_URL.replacen("http", "ws", 1) + "/ws";
let mut request = url.into_client_request().map_err(|e| e.to_string())?;
request.headers_mut().insert(
"Authorization",
format!("Bearer {token}")
.parse()
.map_err(|_| "bad token".to_string())?,
);
let (socket, _) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| e.to_string())?;
let (mut sink, mut stream) = socket.split();
// The client drives the heartbeat: it sits behind the NAT whose path needs
// keeping open, and it can slow down on battery without the server caring.
let mut ping = tokio::time::interval(Duration::from_secs(15));
loop {
tokio::select! {
_ = ping.tick() => {
if sink.send(Message::Ping(Vec::new().into())).await.is_err() {
return Ok(false);
}
}
received = stream.next() => {
let Some(received) = received else { return Ok(false) };
let message = received.map_err(|e| e.to_string())?;
match message {
Message::Text(text) => match serde_json::from_str::<Inbound>(&text) {
Ok(Inbound::Changed { conversation, seq }) => {
// The server already collapses bursts per connection,
// so there is nothing left to debounce here.
if seq > state.cursors.get(&conversation) {
if let Err(e) = backfill(app, &conversation).await {
eprintln!("realtime: backfill failed: {e}");
}
}
}
Ok(Inbound::Resync) => {
state.cursors.clear();
let _ = app.emit(EVENT_RESYNC, ());
}
Err(e) => eprintln!("realtime: undecodable frame: {e}"),
},
Message::Close(frame) => {
let code = frame.map(|f| u16::from(f.code)).unwrap_or(0);
// TOKEN_EXPIRED counts as terminal only because there is
// no refresh flow yet — reconnecting with the same dead
// token would just spin against 401.
// TODO: refresh over REST and push the new token back
// through the socket as a `reauth` frame (the server
// already accepts one), then treat 4001 as recoverable.
return Ok(matches!(
code,
CLOSE_DISPLACED | CLOSE_UNAUTHORIZED | CLOSE_TOKEN_EXPIRED
));
}
_ => {}
}
}
}
}
}
async fn backfill(app: &AppHandle, conversation: &str) -> Result<(), String> {
let state = app.state::<AppState>();
let since = state.cursors.get(conversation);
let response: Changes = state
.authed_get(&format!(
"{BASE_URL}/conversations/{conversation}/changes?since={since}"
))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
match response {
Changes::Delta { cursor, messages } => {
state.cursors.set(conversation, cursor);
app.emit(
EVENT_CHANGED,
Changed {
conversation_id: conversation.to_string(),
messages,
},
)
.map_err(|e| e.to_string())?;
}
Changes::Resync { cursor } => {
// Not an error — this is the normal answer on a first connection or
// after a long absence. The frontend drops its cache and reloads.
state.cursors.set(conversation, cursor);
app.emit(EVENT_RESYNC, ()).map_err(|e| e.to_string())?;
}
}
Ok(())
}

View File

@@ -6,6 +6,9 @@ pub struct AppState {
pub client: reqwest::Client,
pub token: Mutex<Option<String>>,
pub user_id: Mutex<Option<String>>,
/// Cursor per conversation, kept here rather than in the frontend so the UI
/// has no realtime bookkeeping at all.
pub cursors: crate::realtime::Cursors,
}
impl AppState {
@@ -14,6 +17,24 @@ impl AppState {
client: reqwest::Client::new(),
token: Mutex::new(None),
user_id: Mutex::new(None),
cursors: Default::default(),
}
}
pub fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
let builder = self.client.get(url);
// ponytail: omit header when no token — server may not require auth yet
match self.token.lock().unwrap().clone() {
Some(t) => builder.bearer_auth(t),
None => builder,
}
}
pub fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
let builder = self.client.post(url);
match self.token.lock().unwrap().clone() {
Some(t) => builder.bearer_auth(t),
None => builder,
}
}
}

14
src/App.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Routes, Route } from "react-router-dom";
import LoginPage from "./pages/LoginPage";
import WorkspacePage from "./pages/WorkspacePage";
import ChatPage from "./pages/ChatPage";
export default function App() {
return (
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/workspaces" element={<WorkspacePage />} />
<Route path="/workspaces/:workspaceId" element={<ChatPage />} />
</Routes>
);
}

View File

@@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Conversation {
id: string;
name: string | null;
kind: string;
}
interface Props {
workspaceId: string;
onSelect: (id: string) => void;
selectedId: string | null;
}
export default function ConversationList({ workspaceId, onSelect, selectedId }: Props) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
invoke<Conversation[]>("list_conversations", { workspaceId })
.then(setConversations)
.catch((e) => setError(String(e)));
}, [workspaceId]);
return (
<div style={{ width: 220, borderRight: "1px solid #ccc", overflowY: "auto" }}>
<div style={{ padding: "12px 16px", fontWeight: "bold", borderBottom: "1px solid #ccc" }}>
Conversations
</div>
{error && <p style={{ color: "red", padding: 8 }}>{error}</p>}
{conversations.map((c) => (
<div
key={c.id}
onClick={() => onSelect(c.id)}
style={{
padding: "10px 16px",
cursor: "pointer",
background: selectedId === c.id ? "#e8f0fe" : "transparent",
}}
>
{c.name ?? `(${c.kind})`}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Props {
conversationId: string;
}
export default function MessageInput({ conversationId }: Props) {
const [content, setContent] = useState("");
const [error, setError] = useState<string | null>(null);
async function handleSend() {
if (!content.trim()) return;
try {
await invoke("send_message", { conversationId, content });
setContent("");
setError(null);
// Nothing to trigger: the message comes back over the realtime channel.
// TODO: render it optimistically instead of waiting for the round trip —
// the client already supplies the id, which is what makes reconciling it
// possible without duplicates.
} catch (e) {
setError(String(e));
}
}
return (
<div style={{ padding: 16, borderTop: "1px solid #ccc" }}>
{error && <p style={{ color: "red", margin: "0 0 8px" }}>{error}</p>}
<input
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message and press Enter…"
style={{ width: "100%", padding: 10, boxSizing: "border-box", fontSize: 14 }}
/>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface Message {
id: string;
user_id: string;
content: string;
/** Sort key. Never sort by created_at — see the Rust model for why. */
seq: number;
created_at: string;
}
interface Changed {
conversation_id: string;
messages: Message[];
}
interface Props {
conversationId: string;
}
export default function MessageList({ conversationId }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(0);
// Full load. Also the resync path: the Rust side adopts the cursor that comes
// with the history, so the next realtime signal asks for a delta from the
// right place.
const load = () =>
invoke<Message[]>("list_messages", { conversationId })
.then(setMessages)
.catch((e) => setError(String(e)));
useEffect(() => {
setMessages([]);
load();
return () => { prevLengthRef.current = 0; };
}, [conversationId]);
// Realtime. Rust owns the connection and the cursor and hands over resolved
// entities, so there is nothing to compute here — replace by id, sort by seq.
useEffect(() => {
const changed = listen<Changed>("realtime://changed", (event) => {
if (event.payload.conversation_id !== conversationId) return;
setMessages((current) => {
const byId = new Map(current.map((m) => [m.id, m]));
for (const message of event.payload.messages) byId.set(message.id, message);
return [...byId.values()].sort((a, b) => a.seq - b.seq);
});
});
// The server could not build a delta — throw the cache away and reload.
const resync = listen("realtime://resync", load);
return () => {
changed.then((un) => un());
resync.then((un) => un());
};
}, [conversationId]);
useEffect(() => {
if (messages.length > prevLengthRef.current) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
prevLengthRef.current = messages.length;
}
}, [messages.length]);
return (
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
{error && <p style={{ color: "red" }}>{error}</p>}
{messages.map((m) => (
<div key={m.id} style={{ marginBottom: 12 }}>
<strong style={{ marginRight: 8 }}>{m.user_id}</strong>
<span style={{ color: "#888", fontSize: 12, marginRight: 8 }}>
{new Date(m.created_at).toLocaleTimeString()}
</span>
<div>{m.content}</div>
</div>
))}
<div ref={bottomRef} />
</div>
);
}

View File

@@ -1,8 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<div>Loading…</div>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

34
src/pages/ChatPage.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import ConversationList from "../components/ConversationList";
import MessageList from "../components/MessageList";
import MessageInput from "../components/MessageInput";
export default function ChatPage() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const [conversationId, setConversationId] = useState<string | null>(null);
return (
<div style={{ display: "flex", height: "100vh" }}>
<ConversationList
workspaceId={workspaceId!}
onSelect={setConversationId}
selectedId={conversationId}
/>
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
{conversationId ? (
<>
<MessageList conversationId={conversationId} />
{/* No onSent callback: the sent message arrives back through the
realtime channel like any other. */}
<MessageInput conversationId={conversationId} />
</>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#888" }}>
Select a conversation
</div>
)}
</div>
</div>
);
}

41
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useNavigate } from "react-router-dom";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleLogin() {
setError(null);
setLoading(true);
try {
await invoke("login", { email });
navigate("/workspaces");
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: 400, margin: "100px auto", padding: 24 }}>
<h1>Rustification</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
placeholder="Email address"
style={{ display: "block", width: "100%", marginBottom: 12, padding: 8, boxSizing: "border-box" }}
/>
<button onClick={handleLogin} disabled={loading || !email}>
{loading ? "Logging in…" : "Login"}
</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useNavigate } from "react-router-dom";
interface Workspace {
id: string;
name: string;
slug: string;
}
export default function WorkspacePage() {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
invoke<Workspace[]>("list_workspaces")
.then(setWorkspaces)
.catch((e) => setError(String(e)));
}, []);
return (
<div style={{ maxWidth: 600, margin: "60px auto", padding: 24 }}>
<h1>Workspaces</h1>
{error && <p style={{ color: "red" }}>{error}</p>}
{workspaces.map((ws) => (
<div
key={ws.id}
onClick={() => navigate(`/workspaces/${ws.id}`)}
style={{ padding: 16, border: "1px solid #ccc", marginBottom: 8, cursor: "pointer", borderRadius: 4 }}
>
<strong>{ws.name}</strong>
<span style={{ marginLeft: 8, color: "#888" }}>/{ws.slug}</span>
</div>
))}
{workspaces.length === 0 && !error && <p>No workspaces found.</p>}
</div>
);
}