init project
This commit is contained in:
@@ -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>TLM Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@tlm/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.11.8",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { gql, useMutation, useQuery, useSubscription } from "@apollo/client";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const LOGIN = gql`
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
accessToken
|
||||
refreshToken
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const REGISTER = gql`
|
||||
mutation Register(
|
||||
$email: String!
|
||||
$password: String!
|
||||
$displayName: String!
|
||||
) {
|
||||
register(email: $email, password: $password, displayName: $displayName) {
|
||||
accessToken
|
||||
refreshToken
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ME = gql`
|
||||
query Me {
|
||||
me {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CHATS = gql`
|
||||
query Chats {
|
||||
chats {
|
||||
__typename
|
||||
id
|
||||
... on DirectThread {
|
||||
peer {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
... on Group {
|
||||
title
|
||||
memberCount
|
||||
}
|
||||
... on Channel {
|
||||
title
|
||||
subscriberCount
|
||||
}
|
||||
lastMessage {
|
||||
id
|
||||
body
|
||||
seq
|
||||
}
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MESSAGES = gql`
|
||||
query Messages($chatId: ID!, $first: Int) {
|
||||
messages(chatId: $chatId, first: $first) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
body
|
||||
seq
|
||||
sender {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SEND = gql`
|
||||
mutation Send($chatId: ID!, $body: String) {
|
||||
sendMessage(chatId: $chatId, body: $body) {
|
||||
id
|
||||
seq
|
||||
body
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CREATE_DM = gql`
|
||||
mutation CreateDm($peerUserId: ID!) {
|
||||
createDirectThread(peerUserId: $peerUserId) {
|
||||
id
|
||||
peer {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ON_CHAT = gql`
|
||||
subscription OnChat($chatId: ID!) {
|
||||
onChatEvent(chatId: $chatId) {
|
||||
type
|
||||
chatId
|
||||
message {
|
||||
id
|
||||
body
|
||||
seq
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function chatLabel(c: {
|
||||
__typename: string;
|
||||
id: string;
|
||||
peer?: { displayName: string };
|
||||
title?: string | null;
|
||||
}) {
|
||||
if (c.__typename === "DirectThread") return c.peer?.displayName ?? c.id;
|
||||
return c.title ?? c.id;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [email, setEmail] = useState("alice@example.com");
|
||||
const [password, setPassword] = useState("password123");
|
||||
const [displayName, setDisplayName] = useState("Alice");
|
||||
const [peerId, setPeerId] = useState("");
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const token = localStorage.getItem("accessToken");
|
||||
const authed = Boolean(token);
|
||||
|
||||
const meQ = useQuery(ME, { skip: !authed });
|
||||
const chatsQ = useQuery(CHATS, { skip: !authed });
|
||||
const msgsQ = useQuery(MESSAGES, {
|
||||
skip: !authed || !activeChatId,
|
||||
variables: { chatId: activeChatId!, first: 50 },
|
||||
});
|
||||
|
||||
const [login, loginM] = useMutation(LOGIN);
|
||||
const [register, regM] = useMutation(REGISTER);
|
||||
const [send, sendM] = useMutation(SEND);
|
||||
const [createDm, dmM] = useMutation(CREATE_DM);
|
||||
|
||||
useSubscription(ON_CHAT, {
|
||||
skip: !authed || !activeChatId,
|
||||
variables: { chatId: activeChatId! },
|
||||
onData: () => {
|
||||
void msgsQ.refetch();
|
||||
void chatsQ.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const sortedEdges = useMemo(() => {
|
||||
const edges = [...(msgsQ.data?.messages.edges ?? [])];
|
||||
edges.sort((a, b) => a.node.seq - b.node.seq);
|
||||
return edges;
|
||||
}, [msgsQ.data]);
|
||||
|
||||
async function doLogin() {
|
||||
const r = await login({ variables: { email, password } });
|
||||
const t = r.data?.login.accessToken;
|
||||
if (t) {
|
||||
localStorage.setItem("accessToken", t);
|
||||
localStorage.setItem("refreshToken", r.data!.login.refreshToken);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
const r = await register({
|
||||
variables: { email, password, displayName },
|
||||
});
|
||||
const t = r.data?.register.accessToken;
|
||||
if (t) {
|
||||
localStorage.setItem("accessToken", t);
|
||||
localStorage.setItem("refreshToken", r.data!.register.refreshToken);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function doCreateDm() {
|
||||
if (!peerId) return;
|
||||
const r = await createDm({ variables: { peerUserId: peerId } });
|
||||
const id = r.data?.createDirectThread.id;
|
||||
if (id) {
|
||||
setActiveChatId(id);
|
||||
await chatsQ.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
async function doSend() {
|
||||
if (!activeChatId || !draft.trim()) return;
|
||||
await send({ variables: { chatId: activeChatId, body: draft } });
|
||||
setDraft("");
|
||||
await msgsQ.refetch();
|
||||
await chatsQ.refetch();
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<div style={{ maxWidth: 420, margin: "48px auto", fontFamily: "system-ui" }}>
|
||||
<h1>TLM</h1>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{ width: "100%", display: "block", marginBottom: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ width: "100%", display: "block", marginBottom: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Display name (register)
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
style={{ width: "100%", display: "block", marginBottom: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" onClick={() => void doLogin()}>
|
||||
Login
|
||||
</button>
|
||||
<button type="button" onClick={() => void doRegister()}>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: "#666" }}>
|
||||
{(loginM.error ?? regM.error)?.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px 1fr",
|
||||
height: "100vh",
|
||||
fontFamily: "system-ui",
|
||||
}}
|
||||
>
|
||||
<aside style={{ borderRight: "1px solid #ddd", padding: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<strong>{meQ.data?.me.displayName}</strong>
|
||||
<button type="button" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<hr />
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
|
||||
<input
|
||||
placeholder="Peer user id"
|
||||
value={peerId}
|
||||
onChange={(e) => setPeerId(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" onClick={() => void doCreateDm()}>
|
||||
DM
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#666", marginBottom: 8 }}>
|
||||
Your id: {meQ.data?.me.id}
|
||||
</div>
|
||||
<div style={{ overflow: "auto" }}>
|
||||
{(chatsQ.data?.chats ?? []).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setActiveChatId(c.id)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: 8,
|
||||
marginBottom: 4,
|
||||
border:
|
||||
c.id === activeChatId ? "2px solid #2a6" : "1px solid #eee",
|
||||
background: c.id === activeChatId ? "#f6fff8" : "#fff",
|
||||
}}
|
||||
>
|
||||
<div>{chatLabel(c)}</div>
|
||||
<div style={{ fontSize: 12, color: "#666" }}>
|
||||
unread {c.unreadCount}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<main style={{ display: "flex", flexDirection: "column" }}>
|
||||
<header style={{ padding: 12, borderBottom: "1px solid #ddd" }}>
|
||||
{activeChatId ? `Chat ${activeChatId}` : "Select a chat"}
|
||||
</header>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 12 }}>
|
||||
{sortedEdges.map((e) => (
|
||||
<div key={e.node.id} style={{ marginBottom: 8 }}>
|
||||
<strong>{e.node.sender?.displayName ?? "?"}</strong>:{" "}
|
||||
{e.node.body}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<footer style={{ padding: 12, borderTop: "1px solid #ddd" }}>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Message"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void doSend();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!activeChatId || sendM.loading}
|
||||
onClick={() => void doSend()}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
split,
|
||||
} from "@apollo/client/core";
|
||||
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
|
||||
import { getMainDefinition } from "@apollo/client/utilities";
|
||||
import { createClient } from "graphql-ws";
|
||||
|
||||
const httpUri = import.meta.env.VITE_GRAPHQL_HTTP ?? "/graphql";
|
||||
const wsUri = import.meta.env.VITE_GRAPHQL_WS ?? `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/graphql`;
|
||||
|
||||
function getAuthHeader() {
|
||||
const t = localStorage.getItem("accessToken");
|
||||
return t ? { authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
uri: httpUri,
|
||||
headers: getAuthHeader,
|
||||
});
|
||||
|
||||
const wsLink = new GraphQLWsLink(
|
||||
createClient({
|
||||
url: wsUri,
|
||||
connectionParams: () => getAuthHeader(),
|
||||
lazy: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const link = split(
|
||||
({ query }) => {
|
||||
const def = getMainDefinition(query);
|
||||
return def.kind === "OperationDefinition" && def.operation === "subscription";
|
||||
},
|
||||
wsLink,
|
||||
httpLink,
|
||||
);
|
||||
|
||||
export const apollo = new ApolloClient({
|
||||
link,
|
||||
cache: new InMemoryCache(),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import App from "./App";
|
||||
import { apollo } from "./apollo";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ApolloProvider client={apollo}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GRAPHQL_HTTP?: string;
|
||||
readonly VITE_GRAPHQL_WS?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/graphql": { target: "http://127.0.0.1:4000", changeOrigin: true, ws: true },
|
||||
"/signal": { target: "ws://127.0.0.1:4000", ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user