Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00af2350f4 | |||
| df013731be | |||
| c3ccab5fc5 | |||
| d7345ba234 | |||
| df8bc97bd2 | |||
| 4c67f38fa3 | |||
| 88e84fcec5 | |||
| 7c34323ff4 | |||
| 0e4e46824c | |||
| 56a4ee6c77 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -38,3 +38,6 @@ mixer-*.tar
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
/assets/node_modules/
|
/assets/node_modules/
|
||||||
|
|
||||||
|
# Ralph code claude files
|
||||||
|
/.ralph/
|
||||||
|
.ralphrc
|
||||||
96
README.md
96
README.md
@@ -1,18 +1,92 @@
|
|||||||
# Mixer
|
# Mixer
|
||||||
|
|
||||||
To start your Phoenix server:
|
A social posting platform built with Elixir/Phoenix, Ash Framework, and React. Users can post, reply, like, follow each other, and upload media/avatars. Metrics are tracked in ClickHouse.
|
||||||
|
|
||||||
* Run `mix setup` to install and setup dependencies
|
## Stack
|
||||||
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
|
||||||
|
|
||||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
- **Backend:** Elixir 1.15+, Phoenix, Ash Framework (resources, policies, state machine, authentication)
|
||||||
|
- **Frontend:** React + TypeScript, bundled via esbuild, styled with Tailwind CSS + DaisyUI
|
||||||
|
- **Databases:** PostgreSQL (primary data), ClickHouse (metrics/analytics)
|
||||||
|
- **Storage:** S3-compatible object storage (MinIO locally, any S3-compatible service in prod)
|
||||||
|
- **Email:** Swoosh (local mailbox in dev, Brevo in prod)
|
||||||
|
- **API layer:** AshTypescript RPC (type-safe TS client auto-generated from Ash resources)
|
||||||
|
|
||||||
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
## Dev environment setup
|
||||||
|
|
||||||
## Learn more
|
### Prerequisites
|
||||||
|
|
||||||
* Official website: https://www.phoenixframework.org/
|
- Elixir 1.15+ and Erlang/OTP (via [asdf](https://asdf-vm.com) or system package manager)
|
||||||
* Guides: https://hexdocs.pm/phoenix/overview.html
|
- PostgreSQL running locally (default: `postgres`/`postgres` on `localhost:5432`)
|
||||||
* Docs: https://hexdocs.pm/phoenix
|
- ClickHouse running locally (default: `default`/no password on `localhost:8123`, database `mixer_metrics`)
|
||||||
* Forum: https://elixirforum.com/c/phoenix-forum
|
- MinIO running locally on `localhost:9000` with credentials `minioadmin`/`minioadmin`
|
||||||
* Source: https://github.com/phoenixframework/phoenix
|
|
||||||
|
### MinIO setup
|
||||||
|
|
||||||
|
Start MinIO and create the bucket before running the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start MinIO (adjust data dir as needed)
|
||||||
|
minio server /data --console-address ":9001"
|
||||||
|
|
||||||
|
# Create the bucket (using the MinIO CLI or the web console at http://localhost:9001)
|
||||||
|
mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||||
|
mc mb local/mixer-bucket
|
||||||
|
mc anonymous set public local/mixer-bucket
|
||||||
|
```
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Elixir dependencies and set up both databases
|
||||||
|
mix setup
|
||||||
|
```
|
||||||
|
|
||||||
|
`mix setup` runs `mix deps.get`, creates and migrates both the PostgreSQL and ClickHouse databases, and seeds initial data.
|
||||||
|
|
||||||
|
### Running the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix phx.server
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit [http://localhost:4000](http://localhost:4000). The frontend assets (esbuild + Tailwind) are compiled and watched automatically.
|
||||||
|
|
||||||
|
### Email in development
|
||||||
|
|
||||||
|
Magic-link sign-in emails are delivered to the local Swoosh mailbox. View them at [http://localhost:4000/dev/mailbox](http://localhost:4000/dev/mailbox).
|
||||||
|
|
||||||
|
### Regenerating the TypeScript RPC client
|
||||||
|
|
||||||
|
After changing Ash resource actions or attributes, regenerate the typed TS client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix ash_typescript.generate
|
||||||
|
```
|
||||||
|
|
||||||
|
The output goes to `assets/js/ash_rpc.ts`.
|
||||||
|
|
||||||
|
## Production environment variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection URL (`ecto://user:pass@host/db`) |
|
||||||
|
| `SECRET_KEY_BASE` | Phoenix secret key (generate with `mix phx.gen.secret`) |
|
||||||
|
| `TOKEN_SIGNING_SECRET` | Ash authentication token signing secret |
|
||||||
|
| `CLICKHOUSE_URL` | ClickHouse connection URL (or use individual vars below) |
|
||||||
|
| `CLICKHOUSE_HOST` | ClickHouse host |
|
||||||
|
| `CLICKHOUSE_PORT` | ClickHouse port (default `8123`) |
|
||||||
|
| `CLICKHOUSE_DATABASE` | ClickHouse database name (default `mixer_metrics`) |
|
||||||
|
| `CLICKHOUSE_USERNAME` | ClickHouse username (default `default`) |
|
||||||
|
| `CLICKHOUSE_PASSWORD` | ClickHouse password |
|
||||||
|
| `S3_ACCESS_KEY_ID` | S3 access key |
|
||||||
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key |
|
||||||
|
| `S3_HOST` | S3 host (e.g. `s3.amazonaws.com`) |
|
||||||
|
| `S3_BUCKET` | S3 bucket name |
|
||||||
|
| `S3_ASSET_HOST` | Public base URL for serving assets (e.g. `https://cdn.example.com`) |
|
||||||
|
| `S3_SCHEME` | S3 scheme (default `https://`) |
|
||||||
|
| `S3_PORT` | S3 port (default `80`) |
|
||||||
|
| `S3_VIRTUAL_HOST` | Use virtual-hosted S3 URLs (default `false`) |
|
||||||
|
| `BREVO_API_KEY` | Brevo (Sendinblue) API key for transactional email |
|
||||||
|
| `PHX_HOST` | Public hostname (default `mixer.jimweaver.com`) |
|
||||||
|
| `PORT` | HTTP port (default `4000`) |
|
||||||
|
| `PHX_SERVER` | Set to `true` to start the HTTP server in a release |
|
||||||
|
|||||||
200
assets/js/App.tsx
Normal file
200
assets/js/App.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||||
|
import { AuthCtx } from "./context";
|
||||||
|
import { useIsDesktop } from "./hooks";
|
||||||
|
import { ComposeTweet } from "./components/compose";
|
||||||
|
import { Feed, FollowingFeed, RefreshButton } from "./components/feed";
|
||||||
|
import { TweetDetail } from "./components/tweet-detail";
|
||||||
|
import { UserList, UserDetail } from "./components/users";
|
||||||
|
import { MyProfile } from "./components/profile";
|
||||||
|
import { MobileNav, MobileComposePage } from "./components/nav";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { staleTime: 10_000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const appEl = document.getElementById("app")!;
|
||||||
|
const email = appEl.dataset.currentUserEmail ?? "";
|
||||||
|
const userId = appEl.dataset.currentUserId ?? "";
|
||||||
|
const username = appEl.dataset.currentUserUsername ?? "";
|
||||||
|
const displayName = appEl.dataset.currentUserDisplayName ?? "";
|
||||||
|
const avatarUrl = appEl.dataset.currentUserAvatarUrl ?? "";
|
||||||
|
const tweetId = appEl.dataset.tweetId || null;
|
||||||
|
const page = appEl.dataset.page ?? "feed";
|
||||||
|
const profileUserId = appEl.dataset.userId || null;
|
||||||
|
|
||||||
|
const [mobileCompose, setMobileCompose] = useState(false);
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
|
const onFeedPage = page === "feed" || page === "tweet";
|
||||||
|
const onFollowingPage = page === "following";
|
||||||
|
const onUsersPage = page === "users" || page === "user-detail";
|
||||||
|
const onProfilePage = page === "profile";
|
||||||
|
|
||||||
|
function renderMain() {
|
||||||
|
switch (page) {
|
||||||
|
case "tweet":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Tweet</h1>
|
||||||
|
</header>
|
||||||
|
<TweetDetail tweetId={tweetId!} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "following":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Following</h1>
|
||||||
|
<RefreshButton queryKey={["following_tweets"]} />
|
||||||
|
</header>
|
||||||
|
<FollowingFeed />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "users":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Users</h1>
|
||||||
|
</header>
|
||||||
|
<UserList />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "user-detail":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Profile</h1>
|
||||||
|
</header>
|
||||||
|
<UserDetail userId={profileUserId!} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "profile":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">My Profile</h1>
|
||||||
|
</header>
|
||||||
|
<MyProfile />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Feed</h1>
|
||||||
|
<RefreshButton />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-compose-wrapper">
|
||||||
|
{email ? (
|
||||||
|
<ComposeTweet />
|
||||||
|
) : (
|
||||||
|
<div className="mx-signin-cta">
|
||||||
|
<p>Sign in to start mixing.</p>
|
||||||
|
<a className="mx-btn-post" href="/register">Sign in</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-divider" />
|
||||||
|
|
||||||
|
<Feed />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthCtx.Provider value={{ email, userId, username, displayName, avatarUrl }}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<div className="mx-root">
|
||||||
|
{isDesktop && (
|
||||||
|
<aside className="mx-sidebar">
|
||||||
|
<div className="mx-logo">
|
||||||
|
<span className="mx-logo-icon">⬡</span>
|
||||||
|
<span className="mx-logo-text">Mixer</span>
|
||||||
|
</div>
|
||||||
|
<nav className="mx-nav">
|
||||||
|
<a className={`mx-nav-item${onFeedPage ? " mx-nav-active" : ""}`} href="/feed">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
|
</svg>
|
||||||
|
Feed
|
||||||
|
</a>
|
||||||
|
<a className={`mx-nav-item${onFollowingPage ? " mx-nav-active" : ""}`} href="/following">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||||
|
</svg>
|
||||||
|
Following
|
||||||
|
</a>
|
||||||
|
<a className={`mx-nav-item${onUsersPage ? " mx-nav-active" : ""}`} href="/users">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
|
||||||
|
</svg>
|
||||||
|
Users
|
||||||
|
</a>
|
||||||
|
<a className={`mx-nav-item${onProfilePage ? " mx-nav-active" : ""}`} href="/profile">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
|
</svg>
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div className="mx-sidebar-footer">
|
||||||
|
{email ? (
|
||||||
|
<>
|
||||||
|
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>
|
||||||
|
{displayName || username || email}
|
||||||
|
</span>
|
||||||
|
{username && (
|
||||||
|
<span className="mx-version">@{username}</span>
|
||||||
|
)}
|
||||||
|
<a className="mx-auth-link" href="/sign-out">Sign out</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a className="mx-auth-link" href="/register">Create account</a>
|
||||||
|
<a className="mx-auth-link" href="/sign-in">Sign in</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="mx-version">v0.1.0</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="mx-main">
|
||||||
|
{renderMain()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="mx-rightbar">
|
||||||
|
<div className="mx-info-card">
|
||||||
|
<h3 className="mx-info-title">About Mixer</h3>
|
||||||
|
<p className="mx-info-body">
|
||||||
|
A minimal social feed built with Ash Framework, Phoenix, and React.
|
||||||
|
</p>
|
||||||
|
<div className="mx-stack">
|
||||||
|
{["Ash 3", "Phoenix 1.8", "AshTypescript", "React 19"].map((s) => (
|
||||||
|
<span key={s} className="mx-tag">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobileNav page={page} onCompose={() => setMobileCompose(true)} />
|
||||||
|
|
||||||
|
{mobileCompose && (
|
||||||
|
<MobileComposePage
|
||||||
|
email={email}
|
||||||
|
onClose={() => setMobileCompose(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthCtx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
294
assets/js/components/compose.tsx
Normal file
294
assets/js/components/compose.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||||
|
import { uploadFile } from "../upload";
|
||||||
|
import { AuthCtx } from "../context";
|
||||||
|
import { Avatar, CharCount } from "./ui";
|
||||||
|
|
||||||
|
const MAX = 280;
|
||||||
|
|
||||||
|
export function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
|
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [mediaId, setMediaId] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
const res = await createTweet({
|
||||||
|
input: { content, mediaId: mediaId ?? undefined },
|
||||||
|
fields: ["id", "content", "userId", "state"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||||
|
setText("");
|
||||||
|
setError(null);
|
||||||
|
setMediaId(null);
|
||||||
|
setPendingFile(null);
|
||||||
|
setUploadError(null);
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = "";
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
const localUrl = URL.createObjectURL(file);
|
||||||
|
setPendingFile(file);
|
||||||
|
setPreviewUrl(localUrl);
|
||||||
|
setMediaId(null);
|
||||||
|
setUploadError(null);
|
||||||
|
setUploading(true);
|
||||||
|
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
|
||||||
|
const result = await uploadFile(file, csrfToken);
|
||||||
|
setUploading(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setUploadError(result.error);
|
||||||
|
setPendingFile(null);
|
||||||
|
URL.revokeObjectURL(localUrl);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
} else {
|
||||||
|
setMediaId(result.mediaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment() {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
setPendingFile(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setMediaId(null);
|
||||||
|
setUploadError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (trimmed.length > MAX) {
|
||||||
|
setError(`Max ${MAX} characters`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-compose">
|
||||||
|
<Avatar avatarUrl={avatarUrl} name={displayName || username || email} />
|
||||||
|
<div className="mx-compose-body">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="mx-compose-textarea"
|
||||||
|
placeholder="What's mixing?"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={2}
|
||||||
|
maxLength={MAX + 1}
|
||||||
|
/>
|
||||||
|
{previewUrl && pendingFile && (
|
||||||
|
<div style={{ position: "relative", marginTop: "0.5rem", display: "inline-block" }}>
|
||||||
|
{/\.(mp4|mov)$/i.test(pendingFile.name) ? (
|
||||||
|
<video
|
||||||
|
src={previewUrl}
|
||||||
|
controls
|
||||||
|
style={{ maxWidth: "100%", maxHeight: "200px", borderRadius: "0.5rem", display: "block" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="attachment preview"
|
||||||
|
style={{ maxWidth: "100%", maxHeight: "200px", borderRadius: "0.5rem", display: "block" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{uploading && (
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", inset: 0, background: "rgba(0,0,0,0.4)",
|
||||||
|
borderRadius: "0.5rem", display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
color: "#fff", fontSize: "0.75rem"
|
||||||
|
}}>
|
||||||
|
Uploading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removeAttachment}
|
||||||
|
style={{
|
||||||
|
position: "absolute", top: "4px", right: "4px",
|
||||||
|
background: "rgba(0,0,0,0.6)", border: "none", borderRadius: "50%",
|
||||||
|
width: "20px", height: "20px", cursor: "pointer",
|
||||||
|
color: "#fff", fontSize: "12px", lineHeight: 1,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center"
|
||||||
|
}}
|
||||||
|
title="Remove attachment"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploadError && <p className="mx-compose-error">{uploadError}</p>}
|
||||||
|
{error && <p className="mx-compose-error">{error}</p>}
|
||||||
|
<div className="mx-compose-footer">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mx-action-btn"
|
||||||
|
title="Attach image or video"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading || mutation.isPending}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/mp4,video/quicktime"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
{uploading && (
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "var(--mx-muted)" }}>
|
||||||
|
{pendingFile?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mx-compose-actions">
|
||||||
|
<CharCount current={text.length} max={MAX} />
|
||||||
|
<button
|
||||||
|
className="mx-btn-post"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!text.trim() || mutation.isPending || uploading}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Posting…" : "Post"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposeComment({
|
||||||
|
parentTweetId,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
parentTweetId: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
const res = await createTweet({
|
||||||
|
input: { content, parentTweetId },
|
||||||
|
fields: ["id", "content", "userId", "state"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["comments", parentTweetId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweet", parentTweetId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||||
|
setText("");
|
||||||
|
setError(null);
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (trimmed.length > MAX) { setError(`Max ${MAX} characters`); return; }
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-compose mx-compose--comment">
|
||||||
|
<Avatar avatarUrl={avatarUrl} name={displayName || username || email} size="sm" />
|
||||||
|
<div className="mx-compose-body">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="mx-compose-textarea mx-compose-textarea--sm"
|
||||||
|
placeholder="Post your reply…"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={1}
|
||||||
|
maxLength={MAX + 1}
|
||||||
|
/>
|
||||||
|
{error && <p className="mx-compose-error">{error}</p>}
|
||||||
|
<div className="mx-compose-footer">
|
||||||
|
<div />
|
||||||
|
<div className="mx-compose-actions">
|
||||||
|
<CharCount current={text.length} max={MAX} />
|
||||||
|
<button
|
||||||
|
className="mx-btn-post mx-btn-post--sm"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!text.trim() || mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Replying…" : "Reply"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
assets/js/components/feed.tsx
Normal file
199
assets/js/components/feed.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { useRef, useEffect, useContext, useState } from "react";
|
||||||
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { readTweet, readFollowingFeed, buildCSRFHeaders } from "../ash_rpc";
|
||||||
|
import { AuthCtx } from "../context";
|
||||||
|
import { FEED_PAGE_SIZE } from "../constants";
|
||||||
|
import { Spinner, ErrorBanner } from "./ui";
|
||||||
|
import { TweetCard } from "./tweet-card";
|
||||||
|
import type { Tweet } from "../types";
|
||||||
|
|
||||||
|
export function Feed() {
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["tweets"],
|
||||||
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
|
const res = await readTweet({
|
||||||
|
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
|
sort: "-insertedAt",
|
||||||
|
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||||
|
filter: { parentTweetId: { isNil: true } },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load tweets");
|
||||||
|
const pageData = res.data as any;
|
||||||
|
const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||||
|
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||||
|
return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE };
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load tweets"} />;
|
||||||
|
|
||||||
|
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
|
||||||
|
|
||||||
|
if (tweets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-empty">
|
||||||
|
<div className="mx-empty-icon">◎</div>
|
||||||
|
<p className="mx-empty-title">Nothing posted yet</p>
|
||||||
|
<p className="mx-empty-sub">Be the first to mix something in.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-feed">
|
||||||
|
{tweets.map((t) => (
|
||||||
|
<TweetCard key={t.id} tweet={t} />
|
||||||
|
))}
|
||||||
|
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||||
|
{isFetchingNextPage && <Spinner />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowingFeed() {
|
||||||
|
const { userId } = useContext(AuthCtx);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["following_tweets"],
|
||||||
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
|
const res = await readFollowingFeed({
|
||||||
|
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
|
sort: "-insertedAt",
|
||||||
|
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||||
|
filter: { parentTweetId: { isNil: true } },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load following feed");
|
||||||
|
const pageData = res.data as any;
|
||||||
|
const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||||
|
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||||
|
return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE };
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||||
|
enabled: !!userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return (
|
||||||
|
<div className="mx-empty">
|
||||||
|
<div className="mx-empty-icon">★</div>
|
||||||
|
<p className="mx-empty-title">Your personalised feed</p>
|
||||||
|
<p className="mx-empty-sub">
|
||||||
|
<a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a>
|
||||||
|
{" "}to see posts from people you follow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load following feed"} />;
|
||||||
|
|
||||||
|
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
|
||||||
|
|
||||||
|
if (tweets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-empty">
|
||||||
|
<div className="mx-empty-icon">★</div>
|
||||||
|
<p className="mx-empty-title">Nothing here yet</p>
|
||||||
|
<p className="mx-empty-sub">
|
||||||
|
Follow some people from the{" "}
|
||||||
|
<a href="/users" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Users</a>
|
||||||
|
{" "}page to fill this feed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-feed">
|
||||||
|
{tweets.map((t) => (
|
||||||
|
<TweetCard key={t.id} tweet={t} />
|
||||||
|
))}
|
||||||
|
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||||
|
{isFetchingNextPage && <Spinner />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshButton({ queryKey = ["tweets"] }: { queryKey?: string[] }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [spinning, setSpinning] = useState(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setSpinning(true);
|
||||||
|
await qc.invalidateQueries({ queryKey });
|
||||||
|
setTimeout(() => setSpinning(false), 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className="mx-refresh-btn" onClick={refresh} title="Refresh feed">
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{
|
||||||
|
transition: "transform 0.6s ease",
|
||||||
|
transform: spinning ? "rotate(360deg)" : "rotate(0deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
assets/js/components/media.tsx
Normal file
55
assets/js/components/media.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { getAssetHost } from "../utils";
|
||||||
|
import type { MediaItem } from "../types";
|
||||||
|
|
||||||
|
export function TweetMedia({ media }: { media: MediaItem[] }) {
|
||||||
|
const assetHost = getAssetHost();
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: "0.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
|
{media.map((m) =>
|
||||||
|
/\.(mp4|mov)$/i.test(m.s3Key) ? (
|
||||||
|
<video
|
||||||
|
key={m.id}
|
||||||
|
src={`${assetHost}/${m.s3Key}`}
|
||||||
|
controls
|
||||||
|
style={{ maxWidth: "100%", borderRadius: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
key={m.id}
|
||||||
|
src={`${assetHost}/${m.s3Key}`}
|
||||||
|
alt=""
|
||||||
|
style={{ maxWidth: "100%", borderRadius: "0.5rem", display: "block" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaLightbox({ item, onClose }: { item: MediaItem; onClose: () => void }) {
|
||||||
|
const assetHost = getAssetHost();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="mx-lightbox" onClick={onClose}>
|
||||||
|
<button className="mx-lightbox-close" onClick={onClose}>✕</button>
|
||||||
|
<div className="mx-lightbox-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/\.(mp4|mov)$/i.test(item.s3Key) ? (
|
||||||
|
<video src={`${assetHost}/${item.s3Key}`} controls autoPlay className="mx-lightbox-media" />
|
||||||
|
) : (
|
||||||
|
<img src={`${assetHost}/${item.s3Key}`} alt="" className="mx-lightbox-media" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
109
assets/js/components/nav.tsx
Normal file
109
assets/js/components/nav.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { ComposeTweet } from "./compose";
|
||||||
|
|
||||||
|
export function MobileNav({
|
||||||
|
page,
|
||||||
|
onCompose,
|
||||||
|
}: {
|
||||||
|
page: string;
|
||||||
|
onCompose: () => void;
|
||||||
|
}) {
|
||||||
|
const onFeedPage = page === "feed" || page === "tweet";
|
||||||
|
const onFollowingPage = page === "following";
|
||||||
|
const onUsersPage = page === "users" || page === "user-detail";
|
||||||
|
const onProfilePage = page === "profile";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="mx-mobile-nav">
|
||||||
|
<a
|
||||||
|
href="/feed"
|
||||||
|
className={`mx-mobile-nav-item${onFeedPage ? " mx-mobile-nav-item--active" : ""}`}
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
|
</svg>
|
||||||
|
<span>Feed</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/following"
|
||||||
|
className={`mx-mobile-nav-item${onFollowingPage ? " mx-mobile-nav-item--active" : ""}`}
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||||
|
</svg>
|
||||||
|
<span>Following</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="mx-mobile-nav-compose"
|
||||||
|
onClick={onCompose}
|
||||||
|
aria-label="New post"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/users"
|
||||||
|
className={`mx-mobile-nav-item${onUsersPage ? " mx-mobile-nav-item--active" : ""}`}
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
|
||||||
|
</svg>
|
||||||
|
<span>Users</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
className={`mx-mobile-nav-item${onProfilePage ? " mx-mobile-nav-item--active" : ""}`}
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
|
</svg>
|
||||||
|
<span>Profile</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileComposePage({
|
||||||
|
email,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mx-compose-overlay">
|
||||||
|
<div className="mx-compose-overlay-header">
|
||||||
|
<button className="mx-compose-overlay-cancel" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span className="mx-compose-overlay-title">New Post</span>
|
||||||
|
<div style={{ minWidth: "60px" }} />
|
||||||
|
</div>
|
||||||
|
<div className="mx-compose-overlay-body">
|
||||||
|
{email ? (
|
||||||
|
<ComposeTweet onSuccess={onClose} />
|
||||||
|
) : (
|
||||||
|
<div className="mx-signin-cta">
|
||||||
|
<p>Sign in to start mixing.</p>
|
||||||
|
<a className="mx-btn-post" href="/register">Sign in</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
assets/js/components/profile.tsx
Normal file
208
assets/js/components/profile.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { readUser, updateProfile, buildCSRFHeaders } from "../ash_rpc";
|
||||||
|
import { uploadAvatar } from "../upload";
|
||||||
|
import { AuthCtx } from "../context";
|
||||||
|
import { getAssetHost } from "../utils";
|
||||||
|
import { Spinner } from "./ui";
|
||||||
|
import type { User } from "../types";
|
||||||
|
|
||||||
|
export function ProfileEditor({ userId }: { userId: string }) {
|
||||||
|
const assetHost = getAssetHost();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: user, isLoading } = useQuery({
|
||||||
|
queryKey: ["user", userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await readUser({
|
||||||
|
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||||
|
filter: { id: { eq: userId } },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load user");
|
||||||
|
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||||
|
return (results[0] as User) ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||||
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
|
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null);
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setUsername(user.username ?? "");
|
||||||
|
setDisplayName(user.displayName ?? "");
|
||||||
|
}
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await updateProfile({
|
||||||
|
identity: userId,
|
||||||
|
input: {
|
||||||
|
username: username.trim() || null,
|
||||||
|
displayName: displayName.trim() || null,
|
||||||
|
},
|
||||||
|
fields: ["id", "username", "displayName", "avatarUrl"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Save failed");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["user", userId] });
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setTimeout(() => setSaveSuccess(false), 3000);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setSaveError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = "";
|
||||||
|
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
|
||||||
|
setPreviewAvatarUrl(URL.createObjectURL(file));
|
||||||
|
setAvatarError(null);
|
||||||
|
setAvatarUploading(true);
|
||||||
|
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
|
||||||
|
const result = await uploadAvatar(file, csrfToken);
|
||||||
|
setAvatarUploading(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setAvatarError(result.error);
|
||||||
|
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
|
||||||
|
setPreviewAvatarUrl(null);
|
||||||
|
} else {
|
||||||
|
qc.invalidateQueries({ queryKey: ["user", userId] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !user) return <Spinner />;
|
||||||
|
|
||||||
|
const currentAvatarUrl = previewAvatarUrl
|
||||||
|
? previewAvatarUrl
|
||||||
|
: user.avatarUrl
|
||||||
|
? `${assetHost}/${user.avatarUrl}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-profile-editor">
|
||||||
|
<div className="mx-profile-avatar-section">
|
||||||
|
<div className="mx-profile-avatar-wrap">
|
||||||
|
{currentAvatarUrl ? (
|
||||||
|
<img src={currentAvatarUrl} alt="Your avatar" className="mx-profile-avatar-img" />
|
||||||
|
) : (
|
||||||
|
<div className="mx-profile-avatar-placeholder">
|
||||||
|
<span>{(user.displayName || user.username || user.email || "M")[0].toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="mx-profile-avatar-edit-btn"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
disabled={avatarUploading}
|
||||||
|
title="Change avatar"
|
||||||
|
>
|
||||||
|
{avatarUploading ? (
|
||||||
|
<div className="mx-spinner" style={{ width: "14px", height: "14px", borderWidth: "2px" }} />
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.31-2.31a1 1 0 0 0-1.41 0l-1.79 1.79 3.75 3.75 1.76-1.82z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{avatarError && <p className="mx-compose-error" style={{ marginTop: "0.5rem" }}>{avatarError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-profile-stats">
|
||||||
|
<span><strong>{user.followerCount ?? 0}</strong> followers</span>
|
||||||
|
<span><strong>{user.followingCount ?? 0}</strong> following</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-profile-field">
|
||||||
|
<label className="mx-profile-label">Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mx-profile-input mx-profile-input--readonly"
|
||||||
|
value={String(user.email)}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-profile-field">
|
||||||
|
<label className="mx-profile-label">Display name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mx-profile-input"
|
||||||
|
placeholder="Your display name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-profile-field">
|
||||||
|
<label className="mx-profile-label">Username</label>
|
||||||
|
<div className="mx-profile-input-wrap">
|
||||||
|
<span className="mx-profile-at">@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mx-profile-input mx-profile-input--handle"
|
||||||
|
placeholder="your_handle"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))}
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mx-profile-hint">3–30 characters. Letters, numbers, underscores only.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError && <p className="mx-compose-error">{saveError}</p>}
|
||||||
|
{saveSuccess && <p style={{ fontSize: "0.8rem", color: "var(--mx-green)", marginBottom: "0.5rem" }}>✓ Saved!</p>}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
className="mx-btn-post"
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? "Saving…" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
<a href="/sign-out" className="mx-btn-cancel" style={{ textDecoration: "none" }}>Sign out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyProfile() {
|
||||||
|
const { userId } = useContext(AuthCtx);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return (
|
||||||
|
<div className="mx-empty">
|
||||||
|
<div className="mx-empty-icon">◎</div>
|
||||||
|
<p className="mx-empty-title">Your profile</p>
|
||||||
|
<p className="mx-empty-sub">
|
||||||
|
<a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a>
|
||||||
|
{" "}to view your profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProfileEditor userId={userId} />;
|
||||||
|
}
|
||||||
365
assets/js/components/tweet-card.tsx
Normal file
365
assets/js/components/tweet-card.tsx
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import React, { useState, useContext } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { destroyTweet, updateTweet, likeTweet, unlikeTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||||
|
import { AuthCtx } from "../context";
|
||||||
|
import { timeAgo, userDisplayLabel } from "../utils";
|
||||||
|
import { Avatar, ContextMenu } from "./ui";
|
||||||
|
import { TweetMedia } from "./media";
|
||||||
|
import type { Tweet, ContextMenuItem } from "../types";
|
||||||
|
|
||||||
|
export function CommentIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||||
|
const { userId: currentUserId } = useContext(AuthCtx);
|
||||||
|
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||||
|
const canLike = !!currentUserId;
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editText, setEditText] = useState(tweet.content);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const tweetUrl = `${window.location.origin}/feed/${tweet.id}`;
|
||||||
|
|
||||||
|
const ctxItems: ContextMenuItem[] = canModify
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
label: "Edit",
|
||||||
|
onClick: () => {
|
||||||
|
setEditText(tweet.content);
|
||||||
|
setEditing(true);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
label: "Share",
|
||||||
|
onClick: () => navigator.clipboard.writeText(tweetUrl),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
label: "View",
|
||||||
|
onClick: () => { window.location.href = tweetUrl; },
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
label: "Share",
|
||||||
|
onClick: () => navigator.clipboard.writeText(tweetUrl),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await destroyTweet({ identity: tweet.id, headers: buildCSRFHeaders() });
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
const res = await updateTweet({
|
||||||
|
identity: tweet.id,
|
||||||
|
input: { content },
|
||||||
|
fields: ["id", "content", "userId", "state"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||||
|
setEditing(false);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const likeMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
|
||||||
|
const res = await action({
|
||||||
|
identity: tweet.id,
|
||||||
|
fields: ["id", "likes", "likedByMe"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
const trimmed = editText.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
updateMutation.mutate(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="mx-tweet"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||||
|
>
|
||||||
|
<Avatar avatarUrl={tweet.userAvatarUrl} name={tweet.userDisplayName || tweet.userUsername || tweet.userEmail} />
|
||||||
|
<div className="mx-tweet-body">
|
||||||
|
<div className="mx-tweet-header">
|
||||||
|
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}</span>
|
||||||
|
{tweet.userUsername && (
|
||||||
|
<span className="mx-tweet-subhandle">@{tweet.userUsername}</span>
|
||||||
|
)}
|
||||||
|
<span className="mx-tweet-dot">·</span>
|
||||||
|
<span className="mx-tweet-time" title={tweet.insertedAt ? new Date(tweet.insertedAt).toLocaleString() : undefined}>{timeAgo(tweet.insertedAt)}</span>
|
||||||
|
{canModify && (
|
||||||
|
<div className="mx-tweet-actions">
|
||||||
|
<button
|
||||||
|
className="mx-action-btn"
|
||||||
|
title="Edit"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditText(tweet.content);
|
||||||
|
setEditing(true);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.31-2.31a1 1 0 0 0-1.41 0l-1.79 1.79 3.75 3.75 1.76-1.82z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||||
|
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
|
} else {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<span style={{ fontSize: "0.65rem" }}>…</span>
|
||||||
|
) : confirmDelete ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="mx-edit-area">
|
||||||
|
<textarea
|
||||||
|
className="mx-edit-textarea"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
{error && <p className="mx-compose-error">{error}</p>}
|
||||||
|
<div className="mx-edit-footer">
|
||||||
|
<button
|
||||||
|
className="mx-btn-cancel"
|
||||||
|
onClick={() => { setEditing(false); setError(null); }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mx-btn-save"
|
||||||
|
onClick={saveEdit}
|
||||||
|
disabled={!editText.trim() || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mx-tweet-text">{tweet.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tweet.media && tweet.media.length > 0 && (
|
||||||
|
<TweetMedia media={tweet.media} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-tweet-footer">
|
||||||
|
<button
|
||||||
|
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); likeMutation.mutate(); }}
|
||||||
|
disabled={!canLike || likeMutation.isPending}
|
||||||
|
title={
|
||||||
|
canLike
|
||||||
|
? tweet.likedByMe
|
||||||
|
? "Remove like"
|
||||||
|
: "Like post"
|
||||||
|
: "Sign in to like posts"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||||
|
</svg>
|
||||||
|
<span>{tweet.likes}</span>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/feed/${tweet.id}`}
|
||||||
|
className="mx-like-btn mx-comment-btn"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="View comments"
|
||||||
|
>
|
||||||
|
<CommentIcon />
|
||||||
|
<span>{tweet.commentCount ?? 0}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
items={ctxItems}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentCard({
|
||||||
|
comment,
|
||||||
|
parentTweetOwnerId,
|
||||||
|
}: {
|
||||||
|
comment: Tweet;
|
||||||
|
parentTweetOwnerId?: string;
|
||||||
|
}) {
|
||||||
|
const { userId: currentUserId } = useContext(AuthCtx);
|
||||||
|
const canLike = !!currentUserId;
|
||||||
|
const canModify =
|
||||||
|
!!currentUserId &&
|
||||||
|
(comment.userId === currentUserId || parentTweetOwnerId === currentUserId);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await destroyTweet({ identity: comment.id, headers: buildCSRFHeaders() });
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["comments", comment.parentTweetId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweet", comment.parentTweetId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const likeMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const action = comment.likedByMe ? unlikeTweet : likeTweet;
|
||||||
|
const res = await action({
|
||||||
|
identity: comment.id,
|
||||||
|
fields: ["id", "likes", "likedByMe"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed");
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", comment.parentTweetId] }),
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-tweet mx-comment">
|
||||||
|
<Avatar
|
||||||
|
avatarUrl={comment.userAvatarUrl}
|
||||||
|
name={comment.userDisplayName || comment.userUsername || comment.userEmail}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div className="mx-tweet-body">
|
||||||
|
<div className="mx-tweet-header">
|
||||||
|
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: comment.userDisplayName, username: comment.userUsername, email: comment.userEmail })}</span>
|
||||||
|
{comment.userUsername && (
|
||||||
|
<span className="mx-tweet-subhandle">@{comment.userUsername}</span>
|
||||||
|
)}
|
||||||
|
<span className="mx-tweet-dot">·</span>
|
||||||
|
<span className="mx-tweet-time" title={comment.insertedAt ? new Date(comment.insertedAt).toLocaleString() : undefined}>{timeAgo(comment.insertedAt)}</span>
|
||||||
|
{canModify && (
|
||||||
|
<div className="mx-tweet-actions">
|
||||||
|
<button
|
||||||
|
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||||
|
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
|
} else {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<span style={{ fontSize: "0.65rem" }}>…</span>
|
||||||
|
) : confirmDelete ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mx-tweet-text">{comment.content}</p>
|
||||||
|
{comment.media && comment.media.length > 0 && <TweetMedia media={comment.media} />}
|
||||||
|
<div className="mx-tweet-footer">
|
||||||
|
<button
|
||||||
|
className={`mx-like-btn${comment.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||||
|
onClick={() => likeMutation.mutate()}
|
||||||
|
disabled={!canLike || likeMutation.isPending}
|
||||||
|
title={canLike ? (comment.likedByMe ? "Remove like" : "Like reply") : "Sign in to like replies"}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||||
|
</svg>
|
||||||
|
<span>{comment.likes}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mx-compose-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
assets/js/components/tweet-detail.tsx
Normal file
279
assets/js/components/tweet-detail.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||||
|
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { readTweet, destroyTweet, updateTweet, likeTweet, unlikeTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||||
|
import { AuthCtx } from "../context";
|
||||||
|
import { getAssetHost, userDisplayLabel } from "../utils";
|
||||||
|
import { COMMENTS_PAGE_SIZE } from "../constants";
|
||||||
|
import { Spinner, ErrorBanner, Avatar } from "./ui";
|
||||||
|
import { MediaLightbox } from "./media";
|
||||||
|
import { CommentIcon, CommentCard } from "./tweet-card";
|
||||||
|
import { ComposeComment } from "./compose";
|
||||||
|
import type { Tweet, MediaItem } from "../types";
|
||||||
|
|
||||||
|
export function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||||
|
const { userId: currentUserId, email } = useContext(AuthCtx);
|
||||||
|
const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editText, setEditText] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const assetHost = getAssetHost();
|
||||||
|
|
||||||
|
const { data: tweet, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["tweet", tweetId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await readTweet({
|
||||||
|
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
|
filter: { id: { eq: tweetId } },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load tweet");
|
||||||
|
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||||
|
return (results[0] as Tweet) ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const commentsSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: commentsData,
|
||||||
|
isLoading: commentsLoading,
|
||||||
|
fetchNextPage: fetchNextComments,
|
||||||
|
hasNextPage: hasMoreComments,
|
||||||
|
isFetchingNextPage: isFetchingMoreComments,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["comments", tweetId],
|
||||||
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
|
const res = await readTweet({
|
||||||
|
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
|
filter: { parentTweetId: { eq: tweetId } },
|
||||||
|
sort: "insertedAt",
|
||||||
|
page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load comments");
|
||||||
|
const pageData = res.data as any;
|
||||||
|
const comments: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||||
|
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||||
|
return { comments, hasMore, nextOffset: pageParam + COMMENTS_PAGE_SIZE };
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = commentsSentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMoreComments && !isFetchingMoreComments) {
|
||||||
|
fetchNextComments();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMoreComments, isFetchingMoreComments, fetchNextComments]);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() });
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
|
||||||
|
},
|
||||||
|
onSuccess: () => { window.location.href = "/feed"; },
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
const res = await updateTweet({
|
||||||
|
identity: tweetId,
|
||||||
|
input: { content },
|
||||||
|
fields: ["id", "content", "userId", "state"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweet", tweetId] });
|
||||||
|
setEditing(false);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const likeMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!tweet) return;
|
||||||
|
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
|
||||||
|
const res = await action({ identity: tweetId, fields: ["id", "likes", "likedByMe"], headers: buildCSRFHeaders() });
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweet", tweetId] }),
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError || !tweet) return <ErrorBanner message="Could not load tweet" />;
|
||||||
|
|
||||||
|
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||||
|
const canLike = !!currentUserId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-detail">
|
||||||
|
<div className="mx-detail-header">
|
||||||
|
<a href="/feed" className="mx-back-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
{canModify && (
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
className="mx-action-btn"
|
||||||
|
title="Edit"
|
||||||
|
onClick={() => { setEditText(tweet.content); setEditing(true); }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.31-2.31a1 1 0 0 0-1.41 0l-1.79 1.79 3.75 3.75 1.76-1.82z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||||
|
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
|
} else {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<span style={{ fontSize: "0.65rem" }}>…</span>
|
||||||
|
) : confirmDelete ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-detail-body">
|
||||||
|
<div className="mx-detail-author">
|
||||||
|
<Avatar avatarUrl={tweet.userAvatarUrl} name={tweet.userDisplayName || tweet.userUsername || tweet.userEmail} />
|
||||||
|
<div>
|
||||||
|
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}</span>
|
||||||
|
{tweet.userUsername && (
|
||||||
|
<div style={{ fontSize: "0.8rem", color: "var(--mx-muted)" }}>@{tweet.userUsername}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="mx-edit-area">
|
||||||
|
<textarea
|
||||||
|
className="mx-edit-textarea"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
{error && <p className="mx-compose-error">{error}</p>}
|
||||||
|
<div className="mx-edit-footer">
|
||||||
|
<button className="mx-btn-cancel" onClick={() => { setEditing(false); setError(null); }}>Cancel</button>
|
||||||
|
<button
|
||||||
|
className="mx-btn-save"
|
||||||
|
onClick={() => { const t = editText.trim(); if (t) updateMutation.mutate(t); }}
|
||||||
|
disabled={!editText.trim() || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mx-detail-content">{tweet.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tweet.media && tweet.media.length > 0 && (
|
||||||
|
<div className="mx-detail-media">
|
||||||
|
{tweet.media.map((m) => (
|
||||||
|
<button key={m.id} className="mx-media-thumb" onClick={() => setLightboxItem(m)}>
|
||||||
|
{/\.(mp4|mov)$/i.test(m.s3Key) ? (
|
||||||
|
<video src={`${assetHost}/${m.s3Key}`} />
|
||||||
|
) : (
|
||||||
|
<img src={`${assetHost}/${m.s3Key}`} alt="" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-tweet-footer" style={{ marginTop: "1rem" }}>
|
||||||
|
<button
|
||||||
|
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||||
|
onClick={() => likeMutation.mutate()}
|
||||||
|
disabled={!canLike || likeMutation.isPending}
|
||||||
|
title={canLike ? (tweet.likedByMe ? "Remove like" : "Like post") : "Sign in to like posts"}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||||
|
</svg>
|
||||||
|
<span>{tweet.likes}</span>
|
||||||
|
</button>
|
||||||
|
<span className="mx-like-btn mx-comment-count-badge" style={{ cursor: "default" }}>
|
||||||
|
<CommentIcon />
|
||||||
|
<span>{tweet.commentCount ?? 0} {(tweet.commentCount ?? 0) === 1 ? "reply" : "replies"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightboxItem && <MediaLightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />}
|
||||||
|
|
||||||
|
<div className="mx-comments-section">
|
||||||
|
<div className="mx-comments-divider">
|
||||||
|
<span>Replies</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{email ? (
|
||||||
|
<ComposeComment parentTweetId={tweetId} />
|
||||||
|
) : (
|
||||||
|
<div className="mx-signin-cta mx-signin-cta--sm">
|
||||||
|
<p><a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a> to reply.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{commentsLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (() => {
|
||||||
|
const comments = commentsData?.pages.flatMap((p) => p.comments) ?? [];
|
||||||
|
return comments.length > 0 ? (
|
||||||
|
<div className="mx-comments-list">
|
||||||
|
{comments.map((c) => (
|
||||||
|
<CommentCard key={c.id} comment={c} parentTweetOwnerId={tweet?.userId} />
|
||||||
|
))}
|
||||||
|
<div ref={commentsSentinelRef} style={{ height: "1px" }} />
|
||||||
|
{isFetchingMoreComments && <Spinner />}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mx-empty mx-empty--sm">
|
||||||
|
<p className="mx-empty-sub">No replies yet. Be the first!</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
assets/js/components/ui.tsx
Normal file
129
assets/js/components/ui.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { getAssetHost } from "../utils";
|
||||||
|
import type { ContextMenuItem } from "../types";
|
||||||
|
|
||||||
|
export function Spinner() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
|
||||||
|
<div className="mx-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBanner({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-error-banner">
|
||||||
|
<span className="mx-error-icon">⚠</span>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CharCount({ current, max }: { current: number; max: number }) {
|
||||||
|
const remaining = max - current;
|
||||||
|
const pct = current / max;
|
||||||
|
const color =
|
||||||
|
pct > 0.9 ? "#ef4444" : pct > 0.75 ? "#f59e0b" : "var(--mx-muted)";
|
||||||
|
return (
|
||||||
|
<span style={{ color, fontSize: "0.75rem", fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{remaining}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
avatarUrl,
|
||||||
|
name,
|
||||||
|
size = "md",
|
||||||
|
}: {
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}) {
|
||||||
|
const assetHost = getAssetHost();
|
||||||
|
const initial = ((name ?? "")[0] || "M").toUpperCase();
|
||||||
|
const cls =
|
||||||
|
size === "sm"
|
||||||
|
? "mx-tweet-avatar mx-tweet-avatar--sm"
|
||||||
|
: size === "lg"
|
||||||
|
? "mx-tweet-avatar mx-tweet-avatar--lg"
|
||||||
|
: "mx-tweet-avatar";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cls}>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={`${assetHost}/${avatarUrl}`}
|
||||||
|
alt={name ?? "avatar"}
|
||||||
|
className="mx-avatar-img"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{initial}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: ContextMenuItem[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
}
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const itemCount = items.filter((i) => i.type === "item").length;
|
||||||
|
const sepCount = items.filter((i) => i.type === "separator").length;
|
||||||
|
const menuH = itemCount * 34 + sepCount * 9 + 8;
|
||||||
|
const menuW = 180;
|
||||||
|
const left = Math.min(x, window.innerWidth - menuW - 8);
|
||||||
|
const top = Math.min(y, window.innerHeight - menuH - 8);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="mx-context-menu"
|
||||||
|
style={{ left, top }}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{items.map((item, i) =>
|
||||||
|
item.type === "separator" ? (
|
||||||
|
<div key={i} className="mx-context-menu-separator" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="mx-context-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
288
assets/js/components/users.tsx
Normal file
288
assets/js/components/users.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||||
|
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { readUser, readTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||||
|
import { AuthCtx } from "../context";
|
||||||
|
import { FEED_PAGE_SIZE, USERS_PAGE_SIZE } from "../constants";
|
||||||
|
import { userDisplayLabel } from "../utils";
|
||||||
|
import { useFollowUser } from "../hooks";
|
||||||
|
import { Spinner, ErrorBanner, Avatar, ContextMenu } from "./ui";
|
||||||
|
import { TweetCard } from "./tweet-card";
|
||||||
|
import type { User, Tweet, ContextMenuItem } from "../types";
|
||||||
|
|
||||||
|
export function FollowButton({
|
||||||
|
amIFollowing,
|
||||||
|
isPending,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
amIFollowing: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`mx-follow-btn${amIFollowing ? " mx-follow-btn--following" : ""}`}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
||||||
|
>
|
||||||
|
{isPending ? "…" : amIFollowing ? "Unfollow" : "Follow"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserCard({ user }: { user: User }) {
|
||||||
|
const { userId: currentUserId } = useContext(AuthCtx);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const { follow, unfollow, isPending } = useFollowUser(user.id);
|
||||||
|
|
||||||
|
const userUrl = `${window.location.origin}/users/${user.id}`;
|
||||||
|
const canFollow = !!currentUserId && currentUserId !== user.id;
|
||||||
|
const amIFollowing = user.amIFollowing ?? false;
|
||||||
|
|
||||||
|
const ctxItems: ContextMenuItem[] = [
|
||||||
|
{ type: "item", label: "Share", onClick: () => navigator.clipboard.writeText(userUrl) },
|
||||||
|
...(canFollow ? [
|
||||||
|
{ type: "separator" as const },
|
||||||
|
amIFollowing
|
||||||
|
? { type: "item" as const, label: "Unfollow", onClick: unfollow }
|
||||||
|
: { type: "item" as const, label: "Follow", onClick: follow },
|
||||||
|
] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="mx-tweet"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => { window.location.href = `/users/${user.id}`; }}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||||
|
>
|
||||||
|
<Avatar avatarUrl={user.avatarUrl} name={user.displayName || user.username || user.email} />
|
||||||
|
<div className="mx-tweet-body">
|
||||||
|
<div className="mx-tweet-header">
|
||||||
|
<span className="mx-tweet-handle">{userDisplayLabel(user)}</span>
|
||||||
|
{user.username && (
|
||||||
|
<span className="mx-tweet-subhandle">@{user.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(user.followerCount !== undefined || user.followingCount !== undefined) && (
|
||||||
|
<div className="mx-tweet-meta" style={{ fontSize: "0.8rem", color: "var(--mx-muted)", marginTop: "4px" }}>
|
||||||
|
<span>{user.followerCount ?? 0} followers</span>
|
||||||
|
<span style={{ marginLeft: "12px" }}>{user.followingCount ?? 0} following</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canFollow && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||||
|
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxItems} onClose={() => setCtxMenu(null)} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserList() {
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
|
const res = await readUser({
|
||||||
|
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||||
|
sort: "username",
|
||||||
|
page: { limit: USERS_PAGE_SIZE, offset: pageParam },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load users");
|
||||||
|
const pageData = res.data as any;
|
||||||
|
const users: User[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||||
|
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||||
|
return { users, hasMore, nextOffset: pageParam + USERS_PAGE_SIZE };
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load users"} />;
|
||||||
|
|
||||||
|
const users = data?.pages.flatMap((p) => p.users) ?? [];
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-empty">
|
||||||
|
<div className="mx-empty-icon">◎</div>
|
||||||
|
<p className="mx-empty-title">No users yet</p>
|
||||||
|
<p className="mx-empty-sub">Be the first to sign up.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-feed">
|
||||||
|
{users.map((u) => (
|
||||||
|
<UserCard key={u.id} user={u} />
|
||||||
|
))}
|
||||||
|
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||||
|
{isFetchingNextPage && <Spinner />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserFeed({ userId }: { userId: string }) {
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["user-tweets", userId],
|
||||||
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
|
const res = await readTweet({
|
||||||
|
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||||
|
sort: "-insertedAt",
|
||||||
|
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||||
|
filter: { userId: { eq: userId }, parentTweetId: { isNil: true } },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load tweets");
|
||||||
|
const pageData = res.data as any;
|
||||||
|
const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||||
|
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||||
|
return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE };
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load posts"} />;
|
||||||
|
|
||||||
|
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
|
||||||
|
|
||||||
|
if (tweets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-empty">
|
||||||
|
<div className="mx-empty-icon">◎</div>
|
||||||
|
<p className="mx-empty-title">No posts yet</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-feed">
|
||||||
|
{tweets.map((t) => (
|
||||||
|
<TweetCard key={t.id} tweet={t} />
|
||||||
|
))}
|
||||||
|
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||||
|
{isFetchingNextPage && <Spinner />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserDetail({ userId, isStandalone = false }: { userId: string; isStandalone?: boolean }) {
|
||||||
|
const { userId: currentUserId } = useContext(AuthCtx);
|
||||||
|
const { follow, unfollow, isPending } = useFollowUser(userId);
|
||||||
|
const { data: user, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["user", userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await readUser({
|
||||||
|
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||||
|
filter: { id: { eq: userId } },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load user");
|
||||||
|
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||||
|
return (results[0] as User) ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError || !user) return <ErrorBanner message="Could not load user" />;
|
||||||
|
|
||||||
|
const isOwnProfile = currentUserId === userId;
|
||||||
|
const canFollow = !!currentUserId && !isOwnProfile;
|
||||||
|
const amIFollowing = user.amIFollowing ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-detail">
|
||||||
|
{!isStandalone && (
|
||||||
|
<div className="mx-detail-header">
|
||||||
|
<a href="/users" className="mx-back-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mx-detail-body">
|
||||||
|
<div className="mx-detail-author">
|
||||||
|
<Avatar avatarUrl={user.avatarUrl} name={user.displayName || user.username || user.email} size="lg" />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
|
||||||
|
<div>
|
||||||
|
<div className="mx-tweet-handle" style={{ fontSize: "1.1rem" }}>{userDisplayLabel(user)}</div>
|
||||||
|
{user.username && (
|
||||||
|
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)" }}>@{user.username}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canFollow && (
|
||||||
|
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "8px", display: "flex", gap: "16px" }}>
|
||||||
|
<span><strong style={{ color: "var(--mx-fg)" }}>{user.followerCount ?? 0}</strong> followers</span>
|
||||||
|
<span><strong style={{ color: "var(--mx-fg)" }}>{user.followingCount ?? 0}</strong> following</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UserFeed userId={userId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
assets/js/constants.ts
Normal file
3
assets/js/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const FEED_PAGE_SIZE = 10;
|
||||||
|
export const COMMENTS_PAGE_SIZE = 10;
|
||||||
|
export const USERS_PAGE_SIZE = 20;
|
||||||
9
assets/js/context.ts
Normal file
9
assets/js/context.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export const AuthCtx = createContext({
|
||||||
|
email: "",
|
||||||
|
userId: "",
|
||||||
|
username: "",
|
||||||
|
displayName: "",
|
||||||
|
avatarUrl: "",
|
||||||
|
});
|
||||||
79
assets/js/hooks.ts
Normal file
79
assets/js/hooks.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { followUser, unfollowUser, buildCSRFHeaders } from "./ash_rpc";
|
||||||
|
|
||||||
|
// ── useIsDesktop ──────────────────────────────────────────────────────────────
|
||||||
|
// Returns true when viewport is wider than 960px. Reacts to resize.
|
||||||
|
|
||||||
|
const DESKTOP_MQ =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.matchMedia("(min-width: 961px)")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
DESKTOP_MQ?.addEventListener("change", cb);
|
||||||
|
return () => DESKTOP_MQ?.removeEventListener("change", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsDesktop(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
() => DESKTOP_MQ?.matches ?? true,
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── useFollowUser ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useFollowUser(targetUserId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const followMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await followUser({
|
||||||
|
input: { followingId: targetUserId },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
const message =
|
||||||
|
"errors" in res && Array.isArray(res.errors)
|
||||||
|
? (res.errors[0] as any)?.message
|
||||||
|
: "Follow failed";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["user", targetUserId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unfollowMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await unfollowUser({
|
||||||
|
input: { followingId: targetUserId },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
const message =
|
||||||
|
"errors" in res && Array.isArray(res.errors)
|
||||||
|
? (res.errors[0] as any)?.message
|
||||||
|
: "Unfollow failed";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["user", targetUserId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
follow: () => followMutation.mutate(),
|
||||||
|
unfollow: () => unfollowMutation.mutate(),
|
||||||
|
isPending: followMutation.isPending || unfollowMutation.isPending,
|
||||||
|
error: followMutation.error || unfollowMutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
2202
assets/js/index.tsx
2202
assets/js/index.tsx
File diff suppressed because it is too large
Load Diff
34
assets/js/types.ts
Normal file
34
assets/js/types.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username?: string | null;
|
||||||
|
displayName?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
followerCount?: number;
|
||||||
|
followingCount?: number;
|
||||||
|
amIFollowing?: boolean;
|
||||||
|
myFollowId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaItem = { id: string; s3Key: string };
|
||||||
|
|
||||||
|
export type Tweet = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
likes: number;
|
||||||
|
likedByMe?: boolean;
|
||||||
|
commentCount?: number;
|
||||||
|
parentTweetId?: string | null;
|
||||||
|
userId: string;
|
||||||
|
state: string;
|
||||||
|
media?: MediaItem[];
|
||||||
|
userEmail?: string | null;
|
||||||
|
userUsername?: string | null;
|
||||||
|
userDisplayName?: string | null;
|
||||||
|
userAvatarUrl?: string | null;
|
||||||
|
insertedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContextMenuItem =
|
||||||
|
| { type: "item"; label: string; onClick: () => void }
|
||||||
|
| { type: "separator" };
|
||||||
32
assets/js/utils.ts
Normal file
32
assets/js/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export function timeAgo(insertedAt?: string | null): string {
|
||||||
|
if (!insertedAt) return "just now";
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(insertedAt).getTime();
|
||||||
|
const diffSec = Math.floor((now - then) / 1000);
|
||||||
|
if (diffSec < 5) return "just now";
|
||||||
|
if (diffSec < 60) return `${diffSec}s`;
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
if (diffMin < 60) return `${diffMin}m`;
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
if (diffHr < 24) return `${diffHr}h`;
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
if (diffDay < 7) return `${diffDay}d`;
|
||||||
|
return new Date(insertedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssetHost(): string {
|
||||||
|
const appEl = document.getElementById("app");
|
||||||
|
return appEl?.dataset.assetHost ?? "http://localhost:9000";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userDisplayLabel(u: {
|
||||||
|
displayName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}): string {
|
||||||
|
return u.displayName || u.username || u.email || "@mixer";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userHandle(u: { username?: string | null; email?: string | null }): string {
|
||||||
|
return u.username ? `@${u.username}` : u.email ?? "@mixer";
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@
|
|||||||
"*": ["../deps/*"]
|
"*": ["../deps/*"]
|
||||||
},
|
},
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"noEmit": true
|
"noEmit": true,
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["ES2015", "DOM"]
|
||||||
},
|
},
|
||||||
"include": ["js/**/*"]
|
"include": ["js/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
28
fix_plan.md
Normal file
28
fix_plan.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Fix Plan
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- [x] `tweet_like` tests: `user_fixture` missing `authorize?: false`, `Ash.Query.filter` needed `require Ash.Query`, `Ash.ForbiddenField.forbidden?/1` doesn't exist (use `match?`), `like` noop returned stale tweet struct → fixed all
|
||||||
|
|
||||||
|
## In Progress / Next
|
||||||
|
|
||||||
|
- [x] `unlike` noop returns stale tweet struct — same issue as `like` noop; reload from DB
|
||||||
|
- [x] `decrement_likes` can go below 0 — use `GREATEST(likes - 1, 0)` via SQL fragment
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
- [x] Self-follow validation used `get_attribute(:follower_id)` which is nil at validation time (relate_actor runs after) — fixed to use `context.actor.id`
|
||||||
|
- [x] Follow/unfollow test coverage (9 tests)
|
||||||
|
- [x] User list pagination — useInfiniteQuery + scroll sentinel, USERS_PAGE_SIZE=20, sorted by username
|
||||||
|
- [ ] No CHECK constraint on `likes >= 0` at DB level (low priority, app logic prevents it)
|
||||||
|
- [ ] `read :following_feed` — nil actor returns empty list (not a bug)
|
||||||
|
- [ ] No search for users or tweets
|
||||||
|
- [x] Tweet creation, update, delete, comment tests (13 tests)
|
||||||
|
- [ ] Missing test coverage: auth flows
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Stack: Elixir/Phoenix + Ash Framework + React/TypeScript
|
||||||
|
- Tests: `mix test` — 10 tests, all should pass
|
||||||
|
- Build: `mix precommit` alias runs compile + test + format checks
|
||||||
|
- No ClickHouse in test env (expected, non-fatal errors in test output)
|
||||||
@@ -31,11 +31,11 @@ defmodule Mixer.Accounts.Follow do
|
|||||||
accept [:following_id]
|
accept [:following_id]
|
||||||
change relate_actor(:follower)
|
change relate_actor(:follower)
|
||||||
|
|
||||||
validate fn changeset, _context ->
|
validate fn changeset, context ->
|
||||||
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id)
|
actor_id = context.actor && context.actor.id
|
||||||
following_id = Ash.Changeset.get_attribute(changeset, :following_id)
|
following_id = Ash.Changeset.get_attribute(changeset, :following_id)
|
||||||
|
|
||||||
if follower_id == following_id do
|
if actor_id && actor_id == following_id do
|
||||||
{:error, field: :following_id, message: "You cannot follow yourself"}
|
{:error, field: :following_id, message: "You cannot follow yourself"}
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
increment_likes(tweet, context.actor)
|
increment_likes(tweet, context.actor)
|
||||||
|
|
||||||
{:noop, _like} ->
|
{:noop, _like} ->
|
||||||
{:ok, tweet}
|
Ash.get(__MODULE__, tweet.id, authorize?: false)
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
@@ -148,7 +148,7 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
decrement_likes(tweet, context.actor)
|
decrement_likes(tweet, context.actor)
|
||||||
|
|
||||||
{:noop, _like} ->
|
{:noop, _like} ->
|
||||||
{:ok, tweet}
|
Ash.get(__MODULE__, tweet.id, authorize?: false)
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
@@ -166,7 +166,7 @@ defmodule Mixer.Posts.Tweet do
|
|||||||
update :decrement_likes do
|
update :decrement_likes do
|
||||||
accept []
|
accept []
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
change atomic_update(:likes, expr(likes - 1))
|
change atomic_update(:likes, expr(fragment("GREATEST(? - 1, 0)", likes)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
172
test/mixer/accounts/follow_test.exs
Normal file
172
test/mixer/accounts/follow_test.exs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
defmodule Mixer.Accounts.FollowTest do
|
||||||
|
use Mixer.DataCase, async: true
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mixer.Accounts.Follow
|
||||||
|
alias Mixer.Accounts.User
|
||||||
|
|
||||||
|
describe "follow" do
|
||||||
|
test "a user can follow another user" do
|
||||||
|
alice = user_fixture("alice@example.com", "alice")
|
||||||
|
bob = user_fixture("bob@example.com", "bob")
|
||||||
|
|
||||||
|
assert {:ok, follow} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert follow.follower_id == alice.id
|
||||||
|
assert follow.following_id == bob.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "following the same user twice is a noop (upsert)" do
|
||||||
|
alice = user_fixture("alice2@example.com", "alice2")
|
||||||
|
bob = user_fixture("bob2@example.com", "bob2")
|
||||||
|
|
||||||
|
assert {:ok, _} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert {:ok, _} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert count_follows(alice.id, bob.id) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a user cannot follow themselves" do
|
||||||
|
alice = user_fixture("alice3@example.com", "alice3")
|
||||||
|
|
||||||
|
assert {:error, error} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: alice.id}, actor: alice)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert Exception.message(error) =~ "cannot follow yourself"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "guests cannot follow" do
|
||||||
|
bob = user_fixture("bob3@example.com", "bob3")
|
||||||
|
|
||||||
|
assert {:error, _error} =
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id})
|
||||||
|
|> Ash.create()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unfollow" do
|
||||||
|
test "a user can unfollow someone they follow" do
|
||||||
|
alice = user_fixture("alice4@example.com", "alice4")
|
||||||
|
bob = user_fixture("bob4@example.com", "bob4")
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
assert count_follows(alice.id, bob.id) == 1
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
Follow
|
||||||
|
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.run_action()
|
||||||
|
|
||||||
|
assert count_follows(alice.id, bob.id) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unfollowing when not following is a noop" do
|
||||||
|
alice = user_fixture("alice5@example.com", "alice5")
|
||||||
|
bob = user_fixture("bob5@example.com", "bob5")
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
Follow
|
||||||
|
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.run_action()
|
||||||
|
|
||||||
|
assert count_follows(alice.id, bob.id) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "guests cannot unfollow" do
|
||||||
|
bob = user_fixture("bob6@example.com", "bob6")
|
||||||
|
|
||||||
|
assert {:error, error} =
|
||||||
|
Follow
|
||||||
|
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id})
|
||||||
|
|> Ash.run_action()
|
||||||
|
|
||||||
|
assert Exception.message(error) =~ "forbidden"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "follower/following counts" do
|
||||||
|
test "follower_count and following_count reflect current follows" do
|
||||||
|
alice = user_fixture("alice6@example.com", "alice6")
|
||||||
|
bob = user_fixture("bob7@example.com", "bob7")
|
||||||
|
carol = user_fixture("carol@example.com", "carol")
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: carol.id}, actor: alice)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: carol)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
alice_loaded = User |> Ash.get!(alice.id, load: [:follower_count, :following_count], authorize?: false)
|
||||||
|
bob_loaded = User |> Ash.get!(bob.id, load: [:follower_count, :following_count], authorize?: false)
|
||||||
|
|
||||||
|
assert alice_loaded.following_count == 2
|
||||||
|
assert alice_loaded.follower_count == 0
|
||||||
|
assert bob_loaded.follower_count == 2
|
||||||
|
assert bob_loaded.following_count == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "am_i_following reflects the actor's follow status" do
|
||||||
|
alice = user_fixture("alice7@example.com", "alice7")
|
||||||
|
bob = user_fixture("bob8@example.com", "bob8")
|
||||||
|
|
||||||
|
not_following =
|
||||||
|
User |> Ash.get!(bob.id, actor: alice, load: [:am_i_following], authorize?: false)
|
||||||
|
|
||||||
|
refute not_following.am_i_following
|
||||||
|
|
||||||
|
Follow
|
||||||
|
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
following =
|
||||||
|
User |> Ash.get!(bob.id, actor: alice, load: [:am_i_following], authorize?: false)
|
||||||
|
|
||||||
|
assert following.am_i_following
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── fixtures ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp user_fixture(email, username) do
|
||||||
|
User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: email,
|
||||||
|
password: "password1234",
|
||||||
|
password_confirmation: "password1234",
|
||||||
|
username: username
|
||||||
|
})
|
||||||
|
|> Ash.create!(authorize?: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp count_follows(follower_id, following_id) do
|
||||||
|
Follow
|
||||||
|
|> Ash.Query.filter(
|
||||||
|
Ash.Expr.expr(follower_id == ^follower_id and following_id == ^following_id)
|
||||||
|
)
|
||||||
|
|> Ash.read!(authorize?: false)
|
||||||
|
|> length()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,7 @@ defmodule Mixer.Posts.TweetLikeTest do
|
|||||||
use Mixer.DataCase, async: true
|
use Mixer.DataCase, async: true
|
||||||
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
alias Mixer.Accounts.User
|
alias Mixer.Accounts.User
|
||||||
alias Mixer.Posts.Tweet
|
alias Mixer.Posts.Tweet
|
||||||
@@ -24,14 +25,14 @@ defmodule Mixer.Posts.TweetLikeTest do
|
|||||||
Tweet
|
Tweet
|
||||||
|> Ash.get!(tweet.id, actor: user, load: [:liked_by_me], authorize?: false)
|
|> Ash.get!(tweet.id, actor: user, load: [:liked_by_me], authorize?: false)
|
||||||
|
|
||||||
refute Ash.ForbiddenField.forbidden?(tweet_for_actor.liked_by_me)
|
refute match?(%Ash.ForbiddenField{}, tweet_for_actor.liked_by_me)
|
||||||
assert tweet_for_actor.liked_by_me
|
assert tweet_for_actor.liked_by_me
|
||||||
|
|
||||||
tweet_without_actor =
|
tweet_without_actor =
|
||||||
Tweet
|
Tweet
|
||||||
|> Ash.get!(tweet.id, load: [:liked_by_me], authorize?: false)
|
|> Ash.get!(tweet.id, load: [:liked_by_me], authorize?: false)
|
||||||
|
|
||||||
refute Ash.ForbiddenField.forbidden?(tweet_without_actor.liked_by_me)
|
refute match?(%Ash.ForbiddenField{}, tweet_without_actor.liked_by_me)
|
||||||
refute tweet_without_actor.liked_by_me
|
refute tweet_without_actor.liked_by_me
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ defmodule Mixer.Posts.TweetLikeTest do
|
|||||||
password_confirmation: "password1234",
|
password_confirmation: "password1234",
|
||||||
username: username
|
username: username
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|> Ash.create!(authorize?: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp tweet_fixture(user, content) do
|
defp tweet_fixture(user, content) do
|
||||||
|
|||||||
220
test/mixer/posts/tweet_test.exs
Normal file
220
test/mixer/posts/tweet_test.exs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
defmodule Mixer.Posts.TweetTest do
|
||||||
|
use Mixer.DataCase, async: true
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mixer.Accounts.User
|
||||||
|
alias Mixer.Posts.Tweet
|
||||||
|
|
||||||
|
describe "tweet creation" do
|
||||||
|
test "a user can create a tweet" do
|
||||||
|
user = user_fixture("poster@example.com", "poster")
|
||||||
|
|
||||||
|
assert {:ok, tweet} =
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(:create, %{content: "hello world"}, actor: user)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert tweet.content == "hello world"
|
||||||
|
assert tweet.user_id == user.id
|
||||||
|
assert tweet.state == :posted
|
||||||
|
assert tweet.likes == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tweet content cannot be blank" do
|
||||||
|
user = user_fixture("blank@example.com", "blankuser")
|
||||||
|
|
||||||
|
assert {:error, error} =
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(:create, %{content: nil}, actor: user)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert Exception.message(error) =~ "content"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "guests cannot create tweets" do
|
||||||
|
assert {:error, _error} =
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(:create, %{content: "spam"})
|
||||||
|
|> Ash.create()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "all users can read tweets" do
|
||||||
|
user = user_fixture("readable@example.com", "readable")
|
||||||
|
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(:create, %{content: "public post"}, actor: user)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
tweets = Tweet |> Ash.read!(authorize?: false)
|
||||||
|
assert length(tweets) >= 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "tweet update" do
|
||||||
|
test "owner can edit their tweet" do
|
||||||
|
user = user_fixture("editor@example.com", "editor")
|
||||||
|
tweet = tweet_fixture(user, "original content")
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
tweet
|
||||||
|
|> Ash.Changeset.for_update(:update, %{content: "edited content"}, actor: user)
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
assert updated.content == "edited content"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-owner cannot edit a tweet" do
|
||||||
|
owner = user_fixture("owner@example.com", "tweetowner")
|
||||||
|
other = user_fixture("other@example.com", "otheruser")
|
||||||
|
tweet = tweet_fixture(owner, "owner's post")
|
||||||
|
|
||||||
|
assert {:error, error} =
|
||||||
|
tweet
|
||||||
|
|> Ash.Changeset.for_update(:update, %{content: "hacked"}, actor: other)
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
assert Exception.message(error) =~ "forbidden"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "tweet deletion" do
|
||||||
|
test "owner can delete their tweet" do
|
||||||
|
user = user_fixture("deleter@example.com", "deleter")
|
||||||
|
tweet = tweet_fixture(user, "to be deleted")
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
tweet
|
||||||
|
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: user)
|
||||||
|
|> Ash.destroy()
|
||||||
|
|
||||||
|
assert {:ok, nil} = Tweet |> Ash.get(tweet.id, authorize?: false, not_found_error?: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-owner cannot delete a tweet" do
|
||||||
|
owner = user_fixture("owner2@example.com", "owner2")
|
||||||
|
other = user_fixture("other2@example.com", "other2")
|
||||||
|
tweet = tweet_fixture(owner, "protected post")
|
||||||
|
|
||||||
|
assert {:error, error} =
|
||||||
|
tweet
|
||||||
|
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: other)
|
||||||
|
|> Ash.destroy()
|
||||||
|
|
||||||
|
assert Exception.message(error) =~ "forbidden"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "comments (replies)" do
|
||||||
|
test "a user can reply to a tweet" do
|
||||||
|
author = user_fixture("author@example.com", "author")
|
||||||
|
replier = user_fixture("replier@example.com", "replier")
|
||||||
|
parent = tweet_fixture(author, "parent post")
|
||||||
|
|
||||||
|
assert {:ok, comment} =
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(
|
||||||
|
:create,
|
||||||
|
%{content: "great post!", parent_tweet_id: parent.id},
|
||||||
|
actor: replier
|
||||||
|
)
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
assert comment.parent_tweet_id == parent.id
|
||||||
|
assert comment.user_id == replier.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "comment_count reflects number of replies" do
|
||||||
|
author = user_fixture("countauthor@example.com", "countauthor")
|
||||||
|
replier = user_fixture("countreplier@example.com", "countreplier")
|
||||||
|
parent = tweet_fixture(author, "tweet with replies")
|
||||||
|
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(:create, %{content: "reply 1", parent_tweet_id: parent.id}, actor: replier)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(:create, %{content: "reply 2", parent_tweet_id: parent.id}, actor: replier)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
loaded = Tweet |> Ash.get!(parent.id, load: [:comment_count], authorize?: false)
|
||||||
|
assert loaded.comment_count == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tweet owner can delete a comment on their tweet" do
|
||||||
|
author = user_fixture("tweetowner3@example.com", "tweetowner3")
|
||||||
|
replier = user_fixture("commenter@example.com", "commenter")
|
||||||
|
parent = tweet_fixture(author, "parent tweet")
|
||||||
|
|
||||||
|
comment =
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(
|
||||||
|
:create,
|
||||||
|
%{content: "a comment", parent_tweet_id: parent.id},
|
||||||
|
actor: replier
|
||||||
|
)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
# Tweet owner (author) can delete someone else's comment on their post
|
||||||
|
assert :ok =
|
||||||
|
comment
|
||||||
|
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: author)
|
||||||
|
|> Ash.destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a third party cannot delete a comment they don't own" do
|
||||||
|
author = user_fixture("tweetowner4@example.com", "tweetowner4")
|
||||||
|
replier = user_fixture("commenter2@example.com", "commenter2")
|
||||||
|
bystander = user_fixture("bystander@example.com", "bystander")
|
||||||
|
parent = tweet_fixture(author, "parent tweet 2")
|
||||||
|
|
||||||
|
comment =
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(
|
||||||
|
:create,
|
||||||
|
%{content: "a comment", parent_tweet_id: parent.id},
|
||||||
|
actor: replier
|
||||||
|
)
|
||||||
|
|> Ash.create!()
|
||||||
|
|
||||||
|
assert {:error, error} =
|
||||||
|
comment
|
||||||
|
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: bystander)
|
||||||
|
|> Ash.destroy()
|
||||||
|
|
||||||
|
assert Exception.message(error) =~ "forbidden"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "guests cannot post comments" do
|
||||||
|
author = user_fixture("tweetowner5@example.com", "tweetowner5")
|
||||||
|
parent = tweet_fixture(author, "parent post 3")
|
||||||
|
|
||||||
|
assert {:error, _error} =
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(
|
||||||
|
:create,
|
||||||
|
%{content: "spam comment", parent_tweet_id: parent.id}
|
||||||
|
)
|
||||||
|
|> Ash.create()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp user_fixture(email, username) do
|
||||||
|
User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: email,
|
||||||
|
password: "password1234",
|
||||||
|
password_confirmation: "password1234",
|
||||||
|
username: username
|
||||||
|
})
|
||||||
|
|> Ash.create!(authorize?: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tweet_fixture(user, content) do
|
||||||
|
Tweet
|
||||||
|
|> Ash.Changeset.for_create(:create, %{content: content}, actor: user)
|
||||||
|
|> Ash.create!()
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user