Compare commits
13 Commits
55133751cb
...
prototype/
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c76e12142 | |||
| b93ca7dcb6 | |||
| fb3bc6e93a | |||
| dc7678d184 | |||
| ee19de5f94 | |||
| 455d304885 | |||
| 91260c8e30 | |||
| 68c5024334 | |||
| b4e9d25b9f | |||
| a432ac976b | |||
| 3109ac4c22 | |||
| 7c2e5ca9a9 | |||
| d384465f2e |
83
.gitea/workflows/release.yml
Normal file
83
.gitea/workflows/release.yml
Normal 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
|
||||
493
PROTOTYPE-frontend-zustandsmodell.html
Normal file
493
PROTOTYPE-frontend-zustandsmodell.html
Normal file
@@ -0,0 +1,493 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Prototyp — Frontend-Zustandsmodell nach dem WebSocket-Umbau</title>
|
||||
<style>
|
||||
:root {
|
||||
--akzent: #b4491f;
|
||||
--grund: #fbfaf8;
|
||||
--papier: #ffffff;
|
||||
--linie: #e3ded6;
|
||||
--text: #23201d;
|
||||
--leise: #7d7469;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 2.5rem 1.5rem 6rem;
|
||||
background: var(--grund); color: var(--text);
|
||||
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
.huelle { max-width: 66rem; margin: 0 auto; }
|
||||
h1 { font-size: 1.55rem; margin: 0 0 .4rem; letter-spacing: -.01em; }
|
||||
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .09em;
|
||||
color: var(--leise); margin: 2.4rem 0 .9rem; font-weight: 600; }
|
||||
p { margin: 0 0 1rem; }
|
||||
.frage { background: var(--papier); border: 1px solid var(--linie);
|
||||
border-left: 3px solid var(--akzent); padding: 1.1rem 1.3rem;
|
||||
border-radius: 3px; margin-bottom: .5rem; }
|
||||
.frage strong { display: block; margin-bottom: .35rem; }
|
||||
.frage p:last-child { margin-bottom: 0; }
|
||||
.klein { font-size: .87rem; color: var(--leise); }
|
||||
.schalter { display: flex; gap: 1.6rem; flex-wrap: wrap; margin: 1rem 0 0; }
|
||||
.schalter label { display: flex; gap: .5rem; align-items: flex-start;
|
||||
font-size: .88rem; cursor: pointer; max-width: 30rem; }
|
||||
.schalter input { margin-top: .35rem; }
|
||||
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
|
||||
@media (max-width: 62rem) { .raster { grid-template-columns: 1fr; } }
|
||||
.tafel { background: var(--papier); border: 1px solid var(--linie);
|
||||
border-radius: 3px; padding: 1.1rem 1.3rem; }
|
||||
.server { border-bottom: 1px solid var(--linie); padding: .7rem 0; }
|
||||
.server:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
.server:first-child { padding-top: 0; }
|
||||
.serverkopf { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
|
||||
.name { font-weight: 600; }
|
||||
.zustand { font-size: .78rem; padding: .1rem .5rem; border-radius: 2rem;
|
||||
border: 1px solid var(--linie); color: var(--leise); white-space: nowrap; }
|
||||
.zustand.verbunden { color: #1f6b3a; border-color: #bfe0cb; background: #f2faf5; }
|
||||
.zustand.neuverbinden { color: #96601a; border-color: #ecd8b4; background: #fdf7ec; }
|
||||
.zustand.getrennt { color: #8f2f2f; border-color: #eec8c8; background: #fdf3f3; }
|
||||
.zustand.abgemeldet { color: var(--leise); background: #f3f1ed; }
|
||||
.conv { display: flex; justify-content: space-between; gap: .8rem;
|
||||
padding: .28rem 0 .28rem .9rem; font-size: .9rem; }
|
||||
.conv.offen { border-left: 2px solid var(--akzent); padding-left: .75rem; font-weight: 600; }
|
||||
.marke { font-size: .78rem; color: var(--akzent); white-space: nowrap; }
|
||||
.msgs { margin: .35rem 0 .2rem 1.6rem; padding: 0; list-style: none;
|
||||
font-size: .84rem; color: var(--leise); }
|
||||
.msgs li { padding: .1rem 0; }
|
||||
.msgs .pending { opacity: .5; font-style: italic; }
|
||||
.msgs .deleted { text-decoration: line-through; opacity: .45; }
|
||||
.msgs .seq { display: inline-block; min-width: 2.4rem; color: #b3aa9d; }
|
||||
button { font: inherit; font-size: .87rem; padding: .42rem .85rem;
|
||||
border: 1px solid var(--linie); background: var(--papier);
|
||||
border-radius: 3px; cursor: pointer; color: var(--text); }
|
||||
button:hover:not(:disabled) { border-color: var(--akzent); color: var(--akzent); }
|
||||
button:disabled { opacity: .35; cursor: default; }
|
||||
button.jetzt { border-color: var(--akzent); background: var(--akzent); color: #fff; }
|
||||
.knopfreihe { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.reiter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
|
||||
.reiter button.aktiv { border-color: var(--akzent); color: var(--akzent); font-weight: 600; }
|
||||
ol.schritte { margin: 1rem 0 0; padding: 0; list-style: none; }
|
||||
ol.schritte li { display: flex; gap: .7rem; align-items: baseline;
|
||||
padding: .3rem 0; border-top: 1px dotted var(--linie); }
|
||||
ol.schritte li:first-child { border-top: 0; }
|
||||
ol.schritte .nr { color: #b3aa9d; font-size: .8rem; min-width: 1.2rem; }
|
||||
ol.schritte .erledigt { color: var(--leise); text-decoration: line-through; }
|
||||
.protokoll { margin-top: 1.4rem; font-size: .84rem; color: var(--leise);
|
||||
max-height: 13rem; overflow-y: auto; }
|
||||
.protokoll div { padding: .16rem 0; border-top: 1px dotted var(--linie); }
|
||||
.protokoll div:first-child { border-top: 0; }
|
||||
.protokoll .neu { color: var(--akzent); }
|
||||
footer { margin-top: 3rem; font-size: .82rem; color: var(--leise);
|
||||
border-top: 1px solid var(--linie); padding-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="huelle">
|
||||
|
||||
<h1>Frontend-Zustandsmodell nach dem WebSocket-Umbau</h1>
|
||||
<p class="klein">Wegwerf-Prototyp zu Ticket 10 der Map <em>websocket-realtime</em>. Nur zum Durchklicken — kein Produktionscode.</p>
|
||||
|
||||
<div class="frage">
|
||||
<strong>Die Frage, die dieser Prototyp beantwortet</strong>
|
||||
<p>Der Client hält Verbindungen zu <strong>mehreren unabhängigen Servern</strong>. Der Rust-Layer besitzt die Verbindungen, führt die Cursor und lädt selbst nach; das Frontend bekommt <strong>fertige Entitäten</strong> geliefert und hat keine Cursor-Logik. Trägt dieses Zustandsmodell die unangenehmen Fälle — Nachricht in einer nicht geöffneten Conversation, optimistisch gesendete Nachricht, die gleich nochmal vom Server kommt, Bearbeitung, Löschung, erzwungener Neuabgleich, und ein einzelner ausgefallener Server neben funktionierenden?</p>
|
||||
<p class="klein">Die beiden Schalter unten sind die eigentlich offenen Entscheidungen. Stell sie um und fahr dieselben Durchläufe nochmal — der Unterschied ist der Punkt.</p>
|
||||
<div class="schalter">
|
||||
<label><input type="checkbox" id="s-immer">
|
||||
<span><strong>Rust lädt auch für nicht geöffnete Conversations nach.</strong> Aus: es kommt nur ein Signal, der Inhalt wird erst beim Öffnen geholt.</span></label>
|
||||
<label><input type="checkbox" id="s-clientid" checked>
|
||||
<span><strong>Der Client vergibt die Nachrichten-ID.</strong> Aus: der Server vergibt sie, der Client muss die eigene Nachricht am Echo wiedererkennen.</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="raster">
|
||||
<div>
|
||||
<h2>Aktueller Zustand</h2>
|
||||
<div class="tafel" id="zustand"></div>
|
||||
<h2>Was gerade passiert ist</h2>
|
||||
<div class="tafel protokoll" id="protokoll"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Freies Ausprobieren</h2>
|
||||
<div class="tafel">
|
||||
<div class="knopfreihe" id="freispiel"></div>
|
||||
</div>
|
||||
|
||||
<h2>Geführte Durchläufe</h2>
|
||||
<div class="tafel">
|
||||
<div class="reiter" id="reiter"></div>
|
||||
<p id="szenario-text" class="klein"></p>
|
||||
<ol class="schritte" id="schritte"></ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Prototyp — wird nicht ausgeliefert. Der Reducer im ersten <code><script></code>-Block ist der Teil, der in echten Code übernommen werden kann; die Seite drumherum nicht.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ------------------------------------------------------------------ *
|
||||
* REINER REDUCER — dieser Block ist der übernehmbare Teil.
|
||||
* Kein DOM, keine Knöpfe, keine Seite. (Zustand, Aktion) => Zustand.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const A = "server-a", B = "server-b";
|
||||
|
||||
function anfang() {
|
||||
return {
|
||||
optionen: { rustLaedtImmer: false, clientVergibtId: true },
|
||||
offen: { serverId: A, conversationId: "allgemein" },
|
||||
naechsteSeq: { "server-a/allgemein": 3, "server-a/design": 8, "server-b/team": 5 },
|
||||
server: {
|
||||
[A]: {
|
||||
name: "chat.example.org", verbindung: "verbunden",
|
||||
conversations: {
|
||||
allgemein: { name: "#allgemein", geladen: true, ungelesen: 0, nachrichten: [
|
||||
{ id: "m1", seq: 1, autor: "Ida", text: "Moin.", zustand: "ok" },
|
||||
{ id: "m2", seq: 2, autor: "Ruben", text: "Läuft der Server?", zustand: "ok" },
|
||||
]},
|
||||
design: { name: "#design", geladen: false, ungelesen: 0, nachrichten: [] },
|
||||
},
|
||||
},
|
||||
[B]: {
|
||||
name: "intern.firma.de", verbindung: "verbunden",
|
||||
conversations: {
|
||||
team: { name: "#team", geladen: false, ungelesen: 0, nachrichten: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const istOffen = (s, srv, conv) =>
|
||||
s.offen.serverId === srv && s.offen.conversationId === conv;
|
||||
|
||||
function sortiert(liste) {
|
||||
// Anzeigereihenfolge ist ausschliesslich seq. Optimistische Nachrichten
|
||||
// haben noch keine und hängen hinten an.
|
||||
return [...liste].sort((x, y) => (x.seq ?? Infinity) - (y.seq ?? Infinity));
|
||||
}
|
||||
|
||||
function reduce(zustand, aktion) {
|
||||
const s = structuredClone(zustand);
|
||||
const notiz = (t) => { s.letzteNotiz = t; };
|
||||
|
||||
switch (aktion.art) {
|
||||
|
||||
case "option": {
|
||||
s.optionen[aktion.name] = aktion.wert;
|
||||
notiz(`Option „${aktion.name}“ ist jetzt ${aktion.wert ? "an" : "aus"}.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
case "conversationGeoeffnet": {
|
||||
const { serverId, conversationId } = aktion;
|
||||
s.offen = { serverId, conversationId };
|
||||
const c = s.server[serverId].conversations[conversationId];
|
||||
if (!c.geladen) {
|
||||
// Beim Öffnen wird geholt, was noch fehlt.
|
||||
c.geladen = true;
|
||||
c.nachrichten = aktion.inhalt ?? c.nachrichten;
|
||||
notiz(`${c.name} geöffnet — Inhalt wurde beim Öffnen nachgeladen.`);
|
||||
} else {
|
||||
notiz(`${c.name} geöffnet.`);
|
||||
}
|
||||
c.ungelesen = 0;
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Rust hat ein Signal bekommen, nachgeladen und liefert Entitäten hoch.
|
||||
Ob überhaupt nachgeladen wurde, entscheidet die Option. */
|
||||
case "entitaetenEingetroffen": {
|
||||
const { serverId, conversationId, entitaeten } = aktion;
|
||||
const srv = s.server[serverId];
|
||||
const c = srv.conversations[conversationId];
|
||||
const sichtbar = istOffen(s, serverId, conversationId);
|
||||
|
||||
if (!sichtbar && !s.optionen.rustLaedtImmer) {
|
||||
// Nur ein Signal: wir wissen DASS sich etwas geändert hat, nicht WAS.
|
||||
c.ungelesen = "•";
|
||||
notiz(`${srv.name} · ${c.name}: Signal ohne Inhalt — Markierung gesetzt, Anzahl unbekannt.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
for (const e of entitaeten) {
|
||||
const i = c.nachrichten.findIndex((m) => m.id === e.id);
|
||||
if (i === -1) {
|
||||
c.nachrichten.push({ ...e, zustand: e.zustand ?? "ok" });
|
||||
} else {
|
||||
// Ersetzen, nicht anhängen. seq der Nachricht bleibt, also
|
||||
// bewegt sich eine bearbeitete Nachricht NICHT im Verlauf.
|
||||
c.nachrichten[i] = { ...c.nachrichten[i], ...e };
|
||||
}
|
||||
}
|
||||
c.geladen = true;
|
||||
c.nachrichten = sortiert(c.nachrichten);
|
||||
|
||||
if (sichtbar) {
|
||||
c.ungelesen = 0;
|
||||
notiz(`${c.name}: ${entitaeten.length} Entität(en) übernommen — offen, also direkt sichtbar.`);
|
||||
} else {
|
||||
c.ungelesen = (typeof c.ungelesen === "number" ? c.ungelesen : 0) + entitaeten.length;
|
||||
notiz(`${srv.name} · ${c.name}: nachgeladen im Hintergrund, Anzahl bekannt (${c.ungelesen}).`);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Optimistisches Senden. */
|
||||
case "gesendet": {
|
||||
const { serverId, conversationId, text } = aktion;
|
||||
const c = s.server[serverId].conversations[conversationId];
|
||||
const id = s.optionen.clientVergibtId ? aktion.clientId : null;
|
||||
c.nachrichten.push({
|
||||
id, clientId: aktion.clientId, seq: null,
|
||||
autor: "ich", text, zustand: "pending",
|
||||
});
|
||||
notiz(`Gesendet: „${text}“ — erscheint sofort, noch ohne seq.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Die eigene Nachricht kommt über den Realtime-Weg zurück. */
|
||||
case "eigeneNachrichtZurueck": {
|
||||
const { serverId, conversationId, clientId, text } = aktion;
|
||||
const c = s.server[serverId].conversations[conversationId];
|
||||
const schluessel = `${serverId}/${conversationId}`;
|
||||
const seq = s.naechsteSeq[schluessel]++;
|
||||
|
||||
// Zusammenführen: nur möglich, wenn es etwas gibt, woran man
|
||||
// die eigene Nachricht wiedererkennt.
|
||||
const i = s.optionen.clientVergibtId
|
||||
? c.nachrichten.findIndex((m) => m.clientId === clientId)
|
||||
: -1;
|
||||
|
||||
if (i === -1) {
|
||||
c.nachrichten.push({
|
||||
id: `srv-${seq}`, seq, autor: "ich", text, zustand: "ok",
|
||||
});
|
||||
notiz(s.optionen.clientVergibtId
|
||||
? `Zurück vom Server — nichts zum Zusammenführen gefunden, angehängt.`
|
||||
: `DOPPELT: Server vergab eine eigene ID, die optimistische Nachricht bleibt daneben stehen.`);
|
||||
} else {
|
||||
c.nachrichten[i] = { ...c.nachrichten[i], id: clientId, seq, zustand: "ok" };
|
||||
notiz(`Zurück vom Server — über die Client-ID zusammengeführt, kein Duplikat.`);
|
||||
}
|
||||
c.nachrichten = sortiert(c.nachrichten);
|
||||
return s;
|
||||
}
|
||||
|
||||
case "bearbeitet": {
|
||||
const { serverId, conversationId, id, text } = aktion;
|
||||
const c = s.server[serverId].conversations[conversationId];
|
||||
const m = c.nachrichten.find((x) => x.id === id);
|
||||
if (m) { m.text = text; m.bearbeitet = true; }
|
||||
notiz(`Bearbeitet — seq bleibt ${m ? m.seq : "?"}, die Nachricht springt also nicht.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
case "geloescht": {
|
||||
const { serverId, conversationId, id } = aktion;
|
||||
const c = s.server[serverId].conversations[conversationId];
|
||||
const m = c.nachrichten.find((x) => x.id === id);
|
||||
if (m) { m.zustand = "deleted"; m.text = "(gelöscht)"; }
|
||||
notiz(`Gelöscht — kommt als Grabstein mit, nicht als Auslassung.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Server kann kein Delta bilden: Zwischenspeicher wegwerfen. */
|
||||
case "resync": {
|
||||
const { serverId, conversationId, inhalt } = aktion;
|
||||
const srv = s.server[serverId];
|
||||
const c = srv.conversations[conversationId];
|
||||
const sichtbar = istOffen(s, serverId, conversationId);
|
||||
c.nachrichten = sichtbar ? inhalt : [];
|
||||
c.geladen = sichtbar;
|
||||
c.ungelesen = sichtbar ? 0 : "•";
|
||||
notiz(sichtbar
|
||||
? `${c.name}: Neuabgleich — Zwischenspeicher verworfen und frisch geladen.`
|
||||
: `${srv.name} · ${c.name}: Neuabgleich — Zwischenspeicher verworfen, Laden beim Öffnen.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
case "verbindungVerloren": {
|
||||
s.server[aktion.serverId].verbindung = "neuverbinden";
|
||||
notiz(`${s.server[aktion.serverId].name} getrennt — versucht neu zu verbinden. Andere Server unberührt.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
case "verbindungZurueck": {
|
||||
const srv = s.server[aktion.serverId];
|
||||
srv.verbindung = "verbunden";
|
||||
notiz(`${srv.name} wieder verbunden — Rust zieht das Manifest und gleicht ab.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
case "verdraengt": {
|
||||
s.server[aktion.serverId].verbindung = "abgemeldet";
|
||||
notiz(`${s.server[aktion.serverId].name}: von neuerer Verbindung verdrängt (4002) — es wird NICHT neu verbunden.`);
|
||||
return s;
|
||||
}
|
||||
|
||||
case "zuruecksetzen":
|
||||
return { ...anfang(), optionen: zustand.optionen, letzteNotiz: "Zurückgesetzt." };
|
||||
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Ende des übernehmbaren Teils. Alles darunter ist Wegwerf-Seite.
|
||||
* ------------------------------------------------------------------ */
|
||||
</script>
|
||||
|
||||
<script>
|
||||
let zustand = anfang();
|
||||
let protokoll = [];
|
||||
let aktivesSzenario = 0;
|
||||
let schrittIndex = 0;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
function schicke(aktion) {
|
||||
zustand = reduce(zustand, aktion);
|
||||
if (zustand.letzteNotiz) protokoll.unshift(zustand.letzteNotiz);
|
||||
protokoll = protokoll.slice(0, 40);
|
||||
zeichne();
|
||||
}
|
||||
|
||||
/* ---- Aktionen für das freie Ausprobieren ---- */
|
||||
let zaehler = 0;
|
||||
const neuerText = () => ["Kurze Frage.", "Bin gleich da.", "Sieht gut aus.", "Passt so.", "Melde mich später."][zaehler++ % 5];
|
||||
|
||||
const freispiel = [
|
||||
{ titel: "#allgemein öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "allgemein" }) },
|
||||
{ titel: "#design öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "design",
|
||||
inhalt: [{ id: "d1", seq: 6, autor: "Ida", text: "Entwurf liegt bereit.", zustand: "ok" },
|
||||
{ id: "d2", seq: 7, autor: "Nour", text: "Schaue ich mir an.", zustand: "ok" }] }) },
|
||||
{ titel: "#team öffnen (Server B)", tu: () => schicke({ art: "conversationGeoeffnet", serverId: B, conversationId: "team",
|
||||
inhalt: [{ id: "t1", seq: 4, autor: "Milan", text: "Standup um zehn.", zustand: "ok" }] }) },
|
||||
{ titel: "Neue Nachricht in #allgemein", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "allgemein",
|
||||
entitaeten: [{ id: `x${++zaehler}`, seq: zustand.naechsteSeq["server-a/allgemein"]++, autor: "Ruben", text: neuerText() }] }) },
|
||||
{ titel: "Neue Nachricht in #design", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "design",
|
||||
entitaeten: [{ id: `y${++zaehler}`, seq: zustand.naechsteSeq["server-a/design"]++, autor: "Nour", text: neuerText() }] }) },
|
||||
{ titel: "Neue Nachricht in #team (Server B)", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: B, conversationId: "team",
|
||||
entitaeten: [{ id: `z${++zaehler}`, seq: zustand.naechsteSeq["server-b/team"]++, autor: "Milan", text: neuerText() }] }) },
|
||||
{ titel: "Ich sende etwas", tu: () => { const cid = `c${++zaehler}`; letzteEigene = { cid, text: neuerText() };
|
||||
schicke({ art: "gesendet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: cid, text: letzteEigene.text }); } },
|
||||
{ titel: "…und sie kommt zurück", tu: () => { if (!letzteEigene) return;
|
||||
schicke({ art: "eigeneNachrichtZurueck", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: letzteEigene.cid, text: letzteEigene.text }); } },
|
||||
{ titel: "Älteste Nachricht bearbeiten", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
|
||||
if (m) schicke({ art: "bearbeitet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id, text: m.text + " (korrigiert)" }); } },
|
||||
{ titel: "Älteste Nachricht löschen", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
|
||||
if (m) schicke({ art: "geloescht", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id }); } },
|
||||
{ titel: "Server verlangt Neuabgleich", tu: () => schicke({ art: "resync", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId,
|
||||
inhalt: [{ id: "r1", seq: 20, autor: "Ida", text: "Frisch geladen nach Neuabgleich.", zustand: "ok" }] }) },
|
||||
{ titel: "Server A verliert Verbindung", tu: () => schicke({ art: "verbindungVerloren", serverId: A }) },
|
||||
{ titel: "Server A ist zurück", tu: () => schicke({ art: "verbindungZurueck", serverId: A }) },
|
||||
{ titel: "Server A: verdrängt (4002)", tu: () => schicke({ art: "verdraengt", serverId: A }) },
|
||||
{ titel: "Alles zurücksetzen", tu: () => { protokoll = []; letzteEigene = null; schicke({ art: "zuruecksetzen" }); } },
|
||||
];
|
||||
|
||||
let letzteEigene = null;
|
||||
const aktuelleConv = () => zustand.server[zustand.offen.serverId].conversations[zustand.offen.conversationId];
|
||||
|
||||
/* ---- Geführte Durchläufe ---- */
|
||||
const szenarien = [
|
||||
{ name: "Nachricht kommt an",
|
||||
text: "Der einfache Fall. Eine Nachricht trifft in der geöffneten Conversation ein. Achte darauf, dass das Frontend nur fertige Entitäten einsetzt — es rechnet nichts aus und fragt nichts nach.",
|
||||
schritte: [freispiel[0], freispiel[3], freispiel[3]] },
|
||||
|
||||
{ name: "Nachricht anderswo",
|
||||
text: "Die Nachricht kommt in einer Conversation an, die NICHT geöffnet ist. Fahr diesen Durchlauf zweimal — einmal mit dem Schalter „Rust lädt auch für nicht geöffnete Conversations nach“ aus, einmal an. Aus bekommst du nur einen Punkt, weil niemand die Anzahl kennt; an bekommst du eine Zahl, aber Requests für Inhalt, den vielleicht niemand ansieht.",
|
||||
schritte: [freispiel[0], freispiel[4], freispiel[4], freispiel[1]] },
|
||||
|
||||
{ name: "Optimistisch senden",
|
||||
text: "Die eigene Nachricht erscheint sofort und kommt gleich darauf über den Realtime-Weg zurück. Mit Client-ID wird zusammengeführt. Schalte „Der Client vergibt die Nachrichten-ID“ AUS und fahr den Durchlauf erneut — dann steht dieselbe Nachricht zweimal da.",
|
||||
schritte: [freispiel[0], freispiel[6], freispiel[7]] },
|
||||
|
||||
{ name: "Bearbeiten und Löschen",
|
||||
text: "Eine bereits sichtbare Nachricht wird geändert und dann gelöscht. Achte auf die Position: die Sortierung folgt der seq der Nachricht, nicht dem Änderungszeitpunkt — sie springt also nicht ans Ende. Und Löschen erzeugt einen Grabstein, keine Lücke.",
|
||||
schritte: [freispiel[0], freispiel[8], freispiel[9]] },
|
||||
|
||||
{ name: "Neuabgleich",
|
||||
text: "Der Server kann kein Delta bilden — Cursor zu alt, Limit überschritten, oder der Listener hatte einen Aussetzer. Der Zwischenspeicher dieser Conversation wird verworfen und frisch geladen. Das ist derselbe Pfad, den auch die Erstverbindung nimmt.",
|
||||
schritte: [freispiel[0], freispiel[3], freispiel[10]] },
|
||||
|
||||
{ name: "Ein Server fällt aus",
|
||||
text: "Server A verliert die Verbindung, während Server B weiterläuft — der Zustand ist pro Server, nicht global. Danach wird Server A verdrängt (Close-Code 4002): dieser Zustand ist endgültig, es wird bewusst NICHT neu verbunden, sonst verdrängen sich zwei Geräte im Kreis.",
|
||||
schritte: [freispiel[0], freispiel[11], freispiel[5], freispiel[12], freispiel[13]] },
|
||||
];
|
||||
|
||||
/* ---- Zeichnen ---- */
|
||||
function zeichne() {
|
||||
el("s-immer").checked = zustand.optionen.rustLaedtImmer;
|
||||
el("s-clientid").checked = zustand.optionen.clientVergibtId;
|
||||
|
||||
el("zustand").innerHTML = Object.entries(zustand.server).map(([sid, srv]) => {
|
||||
const zk = { verbunden: "verbunden", neuverbinden: "verbindet neu", getrennt: "getrennt", abgemeldet: "abgemeldet (4002)" }[srv.verbindung];
|
||||
const convs = Object.entries(srv.conversations).map(([cid, c]) => {
|
||||
const offen = istOffen(zustand, sid, cid);
|
||||
const marke = c.ungelesen === "•" ? "• ungelesen"
|
||||
: (typeof c.ungelesen === "number" && c.ungelesen > 0) ? `${c.ungelesen} neu` : "";
|
||||
const liste = offen && c.nachrichten.length
|
||||
? `<ul class="msgs">${sortiert(c.nachrichten).map(m =>
|
||||
`<li class="${m.zustand === "pending" ? "pending" : m.zustand === "deleted" ? "deleted" : ""}">` +
|
||||
`<span class="seq">${m.seq ?? "—"}</span>${m.autor}: ${m.text}${m.bearbeitet ? " ✎" : ""}</li>`).join("")}</ul>`
|
||||
: (offen ? `<ul class="msgs"><li>— leer —</li></ul>` : "");
|
||||
return `<div class="conv ${offen ? "offen" : ""}"><span>${c.name}${c.geladen ? "" : ' <span class="klein">(nicht geladen)</span>'}</span><span class="marke">${marke}</span></div>${liste}`;
|
||||
}).join("");
|
||||
return `<div class="server"><div class="serverkopf"><span class="name">${srv.name}</span>` +
|
||||
`<span class="zustand ${srv.verbindung}">${zk}</span></div>${convs}</div>`;
|
||||
}).join("");
|
||||
|
||||
el("protokoll").innerHTML = protokoll.length
|
||||
? protokoll.map((t, i) => `<div class="${i === 0 ? "neu" : ""}">${t}</div>`).join("")
|
||||
: `<div class="klein">Noch nichts passiert. Drück einen Knopf.</div>`;
|
||||
|
||||
el("freispiel").innerHTML = "";
|
||||
freispiel.forEach((a, i) => {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = a.titel;
|
||||
b.onclick = a.tu;
|
||||
el("freispiel").appendChild(b);
|
||||
});
|
||||
|
||||
el("reiter").innerHTML = "";
|
||||
szenarien.forEach((sz, i) => {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = sz.name;
|
||||
if (i === aktivesSzenario) b.className = "aktiv";
|
||||
b.onclick = () => { aktivesSzenario = i; schrittIndex = 0; protokoll = [];
|
||||
letzteEigene = null; schicke({ art: "zuruecksetzen" }); };
|
||||
el("reiter").appendChild(b);
|
||||
});
|
||||
|
||||
const sz = szenarien[aktivesSzenario];
|
||||
el("szenario-text").textContent = sz.text;
|
||||
el("schritte").innerHTML = "";
|
||||
sz.schritte.forEach((schritt, i) => {
|
||||
const li = document.createElement("li");
|
||||
const nr = document.createElement("span");
|
||||
nr.className = "nr"; nr.textContent = `${i + 1}.`;
|
||||
const b = document.createElement("button");
|
||||
b.textContent = schritt.titel;
|
||||
b.disabled = i !== schrittIndex;
|
||||
if (i === schrittIndex) b.className = "jetzt";
|
||||
if (i < schrittIndex) b.classList.add("erledigt");
|
||||
b.onclick = () => { schritt.tu(); schrittIndex = i + 1; zeichne(); };
|
||||
li.append(nr, b);
|
||||
el("schritte").appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
el("s-immer").onchange = (e) => schicke({ art: "option", name: "rustLaedtImmer", wert: e.target.checked });
|
||||
el("s-clientid").onchange = (e) => schicke({ art: "option", name: "clientVergibtId", wert: e.target.checked });
|
||||
|
||||
zeichne();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
123
README.md
Normal file
123
README.md
Normal 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.
|
||||
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
17
src-tauri/src/commands/conversation.rs
Normal file
17
src-tauri/src/commands/conversation.rs
Normal 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())
|
||||
}
|
||||
47
src-tauri/src/commands/message.rs
Normal file
47
src-tauri/src/commands/message.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::models::Message;
|
||||
use crate::state::{AppState, BASE_URL};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_messages(
|
||||
state: tauri::State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<Message>, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
#[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())?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"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())
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
4
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod conversation;
|
||||
pub mod message;
|
||||
pub mod user;
|
||||
pub mod workspace;
|
||||
42
src-tauri/src/commands/user.rs
Normal file
42
src-tauri/src/commands/user.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use crate::models::{AuthTokenResponse, User};
|
||||
use crate::state::{AppState, BASE_URL};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login(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());
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
14
src-tauri/src/commands/workspace.rs
Normal file
14
src-tauri/src/commands/workspace.rs
Normal 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())
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
mod commands;
|
||||
mod models;
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -16,4 +16,21 @@ impl AppState {
|
||||
user_id: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
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
14
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/ConversationList.tsx
Normal file
47
src/components/ConversationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/MessageInput.tsx
Normal file
37
src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface Props {
|
||||
conversationId: string;
|
||||
onSent: () => void;
|
||||
}
|
||||
|
||||
export default function MessageInput({ conversationId, onSent }: 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);
|
||||
onSent();
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
62
src/components/MessageList.tsx
Normal file
62
src/components/MessageList.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conversationId: string;
|
||||
refreshKey: number;
|
||||
}
|
||||
|
||||
export default function MessageList({ conversationId, refreshKey }: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const prevLengthRef = useRef(0);
|
||||
|
||||
// Fix 3: cancellation flag prevents stale responses from overwriting new conversation
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () =>
|
||||
invoke<Message[]>("list_messages", { conversationId })
|
||||
.then((msgs) => {
|
||||
if (!cancelled) setMessages(msgs);
|
||||
})
|
||||
.catch((e) => { if (!cancelled) setError(String(e)); });
|
||||
|
||||
load();
|
||||
// ponytail: polling every 3s — replace with WebSocket when server supports it
|
||||
const id = setInterval(load, 3000);
|
||||
return () => { cancelled = true; clearInterval(id); prevLengthRef.current = 0; };
|
||||
// Fix 2: refreshKey in deps triggers immediate reload after send
|
||||
}, [conversationId, refreshKey]);
|
||||
|
||||
// Fix 4: scroll only when message count increases, not on every poll
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
36
src/pages/ChatPage.tsx
Normal file
36
src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
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} refreshKey={refreshKey} />
|
||||
<MessageInput
|
||||
conversationId={conversationId}
|
||||
onSent={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<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
41
src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/pages/WorkspacePage.tsx
Normal file
39
src/pages/WorkspacePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user