Compare commits

...

16 Commits

Author SHA1 Message Date
00af2350f4 some ralph changes that i tried i guess 2026-04-12 21:37:18 -04:00
df013731be feat: add user list pagination
UserList now uses useInfiniteQuery with offset pagination (20 per page)
and an IntersectionObserver scroll sentinel for infinite scroll.
Users sorted by username. Follows same pattern as Feed/UserFeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:00:59 -04:00
c3ccab5fc5 test: add tweet creation + comment tests (13 tests)
Covers: create, blank content validation, guest restriction, read,
owner edit, non-owner edit forbidden, owner delete, non-owner delete
forbidden, reply creation, comment_count aggregate, tweet owner
deletes comment, third party forbidden, guest comment forbidden.

Key learnings:
- Tweet :destroy is not primary; use Ash.Changeset.for_destroy(:destroy)
- relate_actor fails with Invalid (not Forbidden) when no actor
- Ash.get returns NotFound error on miss; pass not_found_error?: false for nil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:57:35 -04:00
d7345ba234 fix: self-follow validation + add follow/unfollow tests
Self-follow check used get_attribute(:follower_id) which is nil at
validation time because relate_actor runs after validations in Ash.
Fixed to use context.actor.id directly.

Added 9 tests covering: follow, follow idempotency, self-follow
prevention, guest restriction, unfollow, unfollow noop,
guest unfollow, follower/following counts, and am_i_following.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:54:43 -04:00
df8bc97bd2 fix: unlike noop stale struct + likes floor at 0
- unlike noop now reloads tweet from DB (same fix as like noop from prev loop)
- decrement_likes uses GREATEST(likes - 1, 0) to prevent negative counts
- add fix_plan.md to track remaining work

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:51:56 -04:00
4c67f38fa3 fix: make tweet_like tests pass
- Add authorize?: false to user_fixture so register_with_password
  bypasses policy check in test context
- Add require Ash.Query so Ash.Query.filter macro works in count_likes
- Replace nonexistent Ash.ForbiddenField.forbidden?/1 with match?/2
- Fix stale tweet struct in :like noop case by reloading from DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:48:23 -04:00
88e84fcec5 fixed ts compile warnings 2026-04-12 19:19:42 -04:00
7c34323ff4 Merge branch 'dev' 2026-04-10 19:34:35 -04:00
0e4e46824c claude refactor of index.tsx so its humanly editable 2026-04-10 19:33:56 -04:00
56a4ee6c77 Updated README 2026-04-09 18:15:46 -04:00
d194834110 claude fix for making the avatars properly get re-fetched if they are newer than the old avatars 2026-04-09 17:44:13 -04:00
2130d85be5 claude generated code for adding tweet viewership to users pages 2026-04-09 17:10:05 -04:00
f37d554399 fixed auth userflow 2026-04-09 15:24:26 -04:00
2d5914c970 some not working changes trying to fix user login to include a username 2026-04-08 04:34:16 -04:00
31a8f03ab2 gemini fixed it but its ux does not work and lowkey idc 2026-04-08 02:27:52 -04:00
90d7eab7d0 some ai generated code from claude that does not work 2026-04-08 02:03:43 -04:00
41 changed files with 3728 additions and 1899 deletions

3
.gitignore vendored
View File

@@ -38,3 +38,6 @@ mixer-*.tar
npm-debug.log
/assets/node_modules/
# Ralph code claude files
/.ralph/
.ralphrc

View File

@@ -1,18 +1,92 @@
# 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
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
## Stack
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/
* Guides: https://hexdocs.pm/phoenix/overview.html
* Docs: https://hexdocs.pm/phoenix
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix
- Elixir 1.15+ and Erlang/OTP (via [asdf](https://asdf-vm.com) or system package manager)
- PostgreSQL running locally (default: `postgres`/`postgres` on `localhost:5432`)
- ClickHouse running locally (default: `default`/no password on `localhost:8123`, database `mixer_metrics`)
- MinIO running locally on `localhost:9000` with credentials `minioadmin`/`minioadmin`
### 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 |

View File

@@ -1056,6 +1056,159 @@ html, body {
.mx-compose-wrapper { display: none; }
}
/* ── Avatar image ── */
.mx-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
display: block;
}
/* ── Tweet sub-handle (@username) ── */
.mx-tweet-subhandle {
font-size: 0.78rem;
color: var(--mx-muted);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
/* ── Profile editor ── */
.mx-profile-editor {
padding: 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 480px;
}
.mx-profile-avatar-section {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.mx-profile-avatar-wrap {
position: relative;
width: 80px;
height: 80px;
}
.mx-profile-avatar-img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
display: block;
border: 2px solid var(--mx-border2);
}
.mx-profile-avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--mx-accent) 0%, var(--mx-accent2) 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
color: white;
user-select: none;
}
.mx-profile-avatar-edit-btn {
position: absolute;
bottom: 0;
right: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--mx-accent);
border: 2px solid var(--mx-bg);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.mx-profile-avatar-edit-btn:hover { background: var(--mx-accent2); }
.mx-profile-avatar-edit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.mx-profile-stats {
display: flex;
gap: 1.25rem;
font-size: 0.875rem;
color: var(--mx-muted);
}
.mx-profile-stats strong { color: var(--mx-fg); }
.mx-profile-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.mx-profile-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--mx-fg2);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mx-profile-input {
background: var(--mx-surface);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
padding: 0.5rem 0.75rem;
color: var(--mx-fg);
font-family: inherit;
font-size: 0.9375rem;
width: 100%;
transition: border-color 0.15s;
outline: none;
}
.mx-profile-input:focus { border-color: var(--mx-accent); }
.mx-profile-input--readonly { color: var(--mx-muted); cursor: not-allowed; }
.mx-profile-input-wrap {
display: flex;
align-items: center;
background: var(--mx-surface);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
padding: 0 0.75rem;
transition: border-color 0.15s;
}
.mx-profile-input-wrap:focus-within { border-color: var(--mx-accent); }
.mx-profile-at {
color: var(--mx-muted);
font-size: 0.9375rem;
pointer-events: none;
user-select: none;
}
.mx-profile-input--handle {
border: none;
border-radius: 0;
padding-left: 0.25rem;
background: transparent;
}
.mx-profile-input--handle:focus { border-color: transparent; }
.mx-profile-hint {
font-size: 0.72rem;
color: var(--mx-muted);
margin-top: 0.125rem;
}
/* Narrow phones (≤ 640 px): tighten spacing */
@media (max-width: 640px) {
.mx-feed { padding: 0.625rem; gap: 0.5rem; }

200
assets/js/App.tsx Normal file
View 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>
);
}

View File

@@ -541,6 +541,83 @@ export async function validateReadUser(
}
export type UpdateProfileInput = {
username?: string | null;
displayName?: string | null;
};
export type UpdateProfileFields = UnifiedFieldSelection<usersResourceSchema>[];
export type InferUpdateProfileResult<
Fields extends UpdateProfileFields | undefined,
> = InferResult<usersResourceSchema, Fields>;
export type UpdateProfileResult<Fields extends UpdateProfileFields | undefined = undefined> = | { success: true; data: InferUpdateProfileResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Update an existing User
*
* @ashActionType :update
*/
export async function updateProfile<Fields extends UpdateProfileFields | undefined = undefined>(
config: {
tenant?: string;
identity: UUID;
input?: UpdateProfileInput;
fields?: Fields;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<UpdateProfileResult<Fields extends undefined ? [] : Fields>> {
const payload = {
action: "update_profile",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity,
input: config.input,
...(config.fields !== undefined && { fields: config.fields })
};
return executeActionRpcRequest<UpdateProfileResult<Fields extends undefined ? [] : Fields>>(
payload,
config
);
}
/**
* Validate: Update an existing User
*
* @ashActionType :update
* @validation true
*/
export async function validateUpdateProfile(
config: {
tenant?: string;
identity: UUID | string;
input?: UpdateProfileInput;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "update_profile",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity,
input: config.input
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];

View File

@@ -25,9 +25,12 @@ export type followsAttributesOnlySchema = {
// users Schema
export type usersResourceSchema = {
__type: "Resource";
__primitiveFields: "id" | "email" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
__primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
id: UUID;
email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
followerCount: number;
followingCount: number;
amIFollowing: boolean;
@@ -38,9 +41,12 @@ export type usersResourceSchema = {
export type usersAttributesOnlySchema = {
__type: "Resource";
__primitiveFields: "id" | "email";
__primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl";
id: UUID;
email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
};
@@ -71,7 +77,7 @@ export type mediaAttributesOnlySchema = {
// tweets Schema
export type tweetsResourceSchema = {
__type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail" | "userUsername" | "userDisplayName" | "userAvatarUrl";
id: UUID;
content: string;
likes: number;
@@ -82,6 +88,9 @@ export type tweetsResourceSchema = {
commentCount: number;
likedByMe: boolean;
userEmail: string | null;
userUsername: string | null;
userDisplayName: string | null;
userAvatarUrl: string | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; };
parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; };
@@ -134,6 +143,27 @@ export type usersFilterInput = {
in?: Array<string>;
};
username?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
displayName?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
avatarUrl?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
followerCount?: {
eq?: number;
notEq?: number;
@@ -270,6 +300,27 @@ export type tweetsFilterInput = {
isNil?: boolean;
};
userUsername?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
userDisplayName?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
userAvatarUrl?: {
eq?: string;
notEq?: string;
in?: Array<string>;
isNil?: boolean;
};
commentCount?: {
eq?: number;
notEq?: number;
@@ -301,26 +352,26 @@ export type tweetsFilterInput = {
export const followsFilterFields = ["id"] as const;
export type followsFilterField = (typeof followsFilterFields)[number];
export const usersFilterFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
export const usersFilterFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
export type usersFilterField = (typeof usersFilterFields)[number];
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
export type mediaFilterField = (typeof mediaFilterFields)[number];
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const;
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const;
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
export const followsSortFields = ["id"] as const;
export type followsSortField = (typeof followsSortFields)[number];
export const usersSortFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
export const usersSortFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
export type usersSortField = (typeof usersSortFields)[number];
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
export type mediaSortField = (typeof mediaSortFields)[number];
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "commentCount", "likedByMe"] as const;
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "commentCount", "likedByMe"] as const;
export type tweetsSortField = (typeof tweetsSortFields)[number];

View 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>
);
}

View 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>
);
}

View 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
);
}

View 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>
);
}

View 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">330 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} />;
}

View 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>
);
}

View 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
View 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
);
}

View 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
View 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
View 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
View 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,
};
}

File diff suppressed because it is too large Load Diff

34
assets/js/types.ts Normal file
View 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" };

View File

@@ -9,6 +9,29 @@ export interface UploadError {
error: string;
}
export interface AvatarUploadResult {
success: true;
avatarUrl: string;
}
export async function uploadAvatar(
file: File,
csrfToken: string
): Promise<AvatarUploadResult | UploadError> {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/upload/avatar", {
method: "POST",
headers: { "X-CSRF-Token": csrfToken },
body: formData,
});
const json = await res.json();
if (!res.ok || !json.success) {
return { error: json.error ?? "Upload failed" };
}
return json as AvatarUploadResult;
}
export async function uploadFile(
file: File,
csrfToken: string

32
assets/js/utils.ts Normal file
View 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";
}

View File

@@ -28,7 +28,9 @@
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
"noEmit": true,
"target": "es5",
"lib": ["ES2015", "DOM"]
},
"include": ["js/**/*"]
}

28
fix_plan.md Normal file
View 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)

View File

@@ -4,6 +4,7 @@ defmodule Mixer.Accounts do
typescript_rpc do
resource Mixer.Accounts.User do
rpc_action :read_user, :read
rpc_action :update_profile, :update_profile
end
resource Mixer.Accounts.Follow do

View File

@@ -0,0 +1,33 @@
defmodule Mixer.Accounts.AvatarUploader do
use Waffle.Definition
@versions [:original, :thumb]
@extensions ~w(.jpg .jpeg .png .gif .webp)
def validate({file, _scope}) do
ext = file.file_name |> Path.extname() |> String.downcase()
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
end
# Resize to a 256×256 square (centre-crop) and convert to WebP for efficiency
def transform(:thumb, _) do
{:convert, "-strip -thumbnail 256x256^ -gravity center -extent 256x256 -format webp", :webp}
end
# Store both versions under avatars/:user_id/
def storage_dir(_version, {_file, scope}), do: "avatars/#{scope.user_id}"
def filename(:original, {file, _scope}) do
Path.basename(file.file_name, Path.extname(file.file_name))
end
def filename(:thumb, _), do: "thumb"
def s3_object_headers(:thumb, _), do: [content_type: "image/webp"]
def s3_object_headers(_version, {file, _scope}) do
[content_type: MIME.from_path(file.file_name)]
end
def acl(_version, _), do: :public_read
end

View File

@@ -31,11 +31,11 @@ defmodule Mixer.Accounts.Follow do
accept [:following_id]
change relate_actor(:follower)
validate fn changeset, _context ->
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id)
validate fn changeset, context ->
actor_id = context.actor && context.actor.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"}
else
:ok

View File

@@ -177,9 +177,21 @@ defmodule Mixer.Accounts.User do
sensitive? true
end
argument :username, :string do
description "The desired username for the user (letters, numbers, underscores)."
allow_nil? false
constraints match: ~r/^[a-zA-Z0-9_]+$/,
min_length: 3,
max_length: 30
end
# Sets the email from the argument
change set_attribute(:email, arg(:email))
# Sets the username from the argument
change set_attribute(:username, arg(:username))
# Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange
@@ -211,6 +223,18 @@ defmodule Mixer.Accounts.User do
get_by :email
end
update :update_profile do
description "Update the user's public profile (username, display name)."
accept [:username, :display_name]
require_atomic? false
end
update :update_avatar do
description "Store the S3 key of the user's processed avatar thumbnail."
accept [:avatar_url]
require_atomic? false
end
update :reset_password_with_token do
argument :reset_token, :string do
allow_nil? false
@@ -256,6 +280,15 @@ defmodule Mixer.Accounts.User do
allow_nil? true
end
argument :username, :string do
description "Username chosen during first-time magic link registration."
allow_nil? true
constraints match: ~r/^[a-zA-Z0-9_]+$/,
min_length: 3,
max_length: 30
end
upsert? true
upsert_identity :unique_email
upsert_fields [:email]
@@ -266,6 +299,37 @@ defmodule Mixer.Accounts.User do
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
strategy_name: :remember_me}
# Set username on new users (or existing users who haven't set one yet)
change fn changeset, _ctx ->
case Ash.Changeset.get_argument(changeset, :username) do
nil ->
changeset
username ->
# Set the attribute directly so the unique_username identity's
# eager_check_with fires during Form.validate, surfacing "already
# taken" errors in the UI before the action is submitted.
changeset = Ash.Changeset.change_attribute(changeset, :username, username)
# Also update via after_action to handle existing users who have no
# username yet: for upserts, only upsert_fields are applied to the
# conflicting row, so change_attribute above won't touch them.
Ash.Changeset.after_action(changeset, fn _cs, user ->
if is_nil(user.username) do
user
|> Ash.Changeset.for_update(:update_profile, %{username: username},authorize?: false)
|> Ash.update()
|> case do
{:ok, updated} -> {:ok, updated}
{:error, error} -> {:error, error}
end
else
{:ok, user}
end
end)
end
end
metadata :token, :string do
allow_nil? false
end
@@ -293,6 +357,14 @@ defmodule Mixer.Accounts.User do
policy action_type(:read) do
authorize_if always()
end
policy action(:update_profile) do
authorize_if expr(id == ^actor(:id))
end
policy action(:update_avatar) do
authorize_if expr(id == ^actor(:id))
end
end
attributes do
@@ -308,6 +380,23 @@ defmodule Mixer.Accounts.User do
end
attribute :confirmed_at, :utc_datetime_usec
attribute :username, :string do
public? true
constraints match: ~r/^[a-zA-Z0-9_]+$/,
min_length: 3,
max_length: 30
end
attribute :display_name, :string do
public? true
constraints max_length: 50
end
attribute :avatar_url, :string do
public? true
end
end
relationships do
@@ -350,5 +439,10 @@ defmodule Mixer.Accounts.User do
identities do
identity :unique_email, [:email]
identity :unique_username, [:username] do
eager_check_with Mixer.Accounts
message "is already taken"
nils_distinct? true
end
end
end

View File

@@ -127,7 +127,7 @@ defmodule Mixer.Posts.Tweet do
increment_likes(tweet, context.actor)
{:noop, _like} ->
{:ok, tweet}
Ash.get(__MODULE__, tweet.id, authorize?: false)
{:error, error} ->
{:error, error}
@@ -148,7 +148,7 @@ defmodule Mixer.Posts.Tweet do
decrement_likes(tweet, context.actor)
{:noop, _like} ->
{:ok, tweet}
Ash.get(__MODULE__, tweet.id, authorize?: false)
{:error, error} ->
{:error, error}
@@ -166,7 +166,7 @@ defmodule Mixer.Posts.Tweet do
update :decrement_likes do
accept []
require_atomic? false
change atomic_update(:likes, expr(likes - 1))
change atomic_update(:likes, expr(fragment("GREATEST(? - 1, 0)", likes)))
end
end
@@ -254,6 +254,18 @@ defmodule Mixer.Posts.Tweet do
calculate :user_email, :string, expr(user.email) do
public? true
end
calculate :user_username, :string, expr(user.username) do
public? true
end
calculate :user_display_name, :string, expr(user.display_name) do
public? true
end
calculate :user_avatar_url, :string, expr(user.avatar_url) do
public? true
end
end
aggregates do

View File

@@ -15,4 +15,9 @@ defmodule MixerWeb.AuthOverrides do
set :text, "⬡ Mixer"
set :text_class, "text-3xl font-bold tracking-tight"
end
# Inject the username field into the password registration form
override AshAuthentication.Phoenix.Components.Password do
set :register_extra_component, &MixerWeb.AuthComponents.username_field/1
end
end

View File

@@ -0,0 +1,55 @@
defmodule MixerWeb.AuthComponents do
@moduledoc """
Extra components injected into AshAuthentication.Phoenix forms.
"""
use Phoenix.Component
@doc """
Renders a username input field inside the password registration form.
Receives `form` (an `AshPhoenix.Form`) as an assign via the
`register_extra_component` override.
"""
def username_field(assigns) do
field = assigns.form[:username]
assigns =
assigns
|> assign(:field_id, field.id)
|> assign(:field_name, field.name)
|> assign(:field_value, field.value || "")
|> assign(:field_errors, field.errors)
~H"""
<div class="mt-2 mb-2">
<label for={@field_id} class="block text-sm font-medium text-base-content mb-1">
Username
</label>
<div class="flex">
<span class="flex items-center justify-center px-4 bg-base-200 border border-base-300 border-r-0 rounded-l-lg text-base-content/50 select-none">@</span>
<input
type="text"
id={@field_id}
name={@field_name}
value={@field_value}
class={"input w-full rounded-l-none #{if @field_errors != [], do: "input-error", else: ""}"}
placeholder="your_handle"
required
/>
</div>
<p :for={error <- @field_errors} class="mt-1 text-xs text-error">
{translate_error(error)}
</p>
</div>
"""
end
def translate_error({msg, opts}) do
if count = opts[:count] do
Gettext.dngettext(MixerWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(MixerWeb.Gettext, "errors", msg, opts)
end
end
end

View File

@@ -2,6 +2,11 @@
id="app"
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
data-current-user-username={if @current_user, do: @current_user.username || "", else: ""}
data-current-user-display-name={
if @current_user, do: @current_user.display_name || "", else: ""
}
data-current-user-avatar-url={if @current_user, do: @current_user.avatar_url || "", else: ""}
data-asset-host={@media_host}
data-page={@page}
data-tweet-id={@tweet_id || ""}

View File

@@ -2,6 +2,7 @@ defmodule MixerWeb.UploadController do
use MixerWeb, :controller
alias Mixer.Posts.MediaUploader
alias Mixer.Accounts.AvatarUploader
def create(conn, %{"file" => %Plug.Upload{} = upload}) do
actor = conn.assigns[:current_user]
@@ -46,4 +47,50 @@ defmodule MixerWeb.UploadController do
|> put_status(:bad_request)
|> json(%{error: "no file provided"})
end
# ── Avatar upload ──────────────────────────────────────────────────────────
def upload_avatar(conn, %{"file" => %Plug.Upload{} = upload}) do
actor = conn.assigns[:current_user]
unless actor do
conn
|> put_status(:unauthorized)
|> json(%{error: "authentication required"})
else
scope = %{user_id: actor.id}
case AvatarUploader.store({upload, scope}) do
{:ok, _file_name} ->
# The thumb is always stored as avatars/:user_id/thumb.webp.
# Append a timestamp so the browser doesn't serve a stale cached image
# when the user updates their avatar (the URL changes, S3 ignores the param).
thumb_key = "avatars/#{actor.id}/thumb.webp?v=#{System.system_time(:millisecond)}"
actor
|> Ash.Changeset.for_update(:update_avatar, %{avatar_url: thumb_key}, actor: actor)
|> Ash.update()
|> case do
{:ok, _user} ->
json(conn, %{success: true, avatarUrl: thumb_key})
{:error, error} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{success: false, error: inspect(error)})
end
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{success: false, error: reason})
end
end
end
def upload_avatar(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "no file provided"})
end
end

View File

@@ -0,0 +1,188 @@
defmodule MixerWeb.MagicSignInLive do
@moduledoc """
Custom magic-link sign-in LiveView that collects a username for new users.
When a user clicks their magic link, this page is shown instead of the
default auto-submit. If the user is brand new (no account) or has no
username set yet, we ask them to choose one before completing sign-in.
"""
use AshAuthentication.Phoenix.Overrides.Overridable,
root_class: "CSS class for the root `div` element.",
magic_sign_in_id: "Element ID for the `MagicSignIn` LiveComponent."
use AshAuthentication.Phoenix.Web, :live_view
alias AshAuthentication.Info
alias AshPhoenix.Form
alias Phoenix.LiveView.{Rendered, Socket}
import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5]
import PhoenixHTMLHelpers.Form, only: [hidden_input: 3, submit: 2]
import Slug
@doc false
@impl true
def mount(params, session, socket) do
overrides =
session
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
resource = session["resource"]
strategy_name = session["strategy"]
token = params["token"] || params["magic_link"]
strategy = Info.strategy!(resource, strategy_name)
subject_name = Info.authentication_subject_name!(resource)
domain = Info.authentication_domain!(resource)
# Determine whether this user needs to pick a username
needs_username? = needs_username?(token, resource, domain)
form =
resource
|> Form.for_action(strategy.sign_in_action_name,
params: %{"token" => token},
domain: domain,
as: subject_name |> to_string(),
id: "#{subject_name}-#{strategy_name}-sign-in-form" |> slugify(),
context: %{strategy: strategy, private: %{ash_authentication?: true}}
)
socket =
socket
|> assign(overrides: overrides)
|> assign(:token, token)
|> assign(:strategy, strategy)
|> assign(:subject_name, subject_name)
|> assign(:resource, resource)
|> assign(:needs_username?, needs_username?)
|> assign(:form, form)
|> assign(:trigger_action, false)
|> assign(:current_tenant, session["tenant"])
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
{:ok, socket}
end
@doc false
@impl true
@spec handle_params(map, String.t(), Socket.t()) :: {:noreply, Socket.t()}
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@doc false
@impl true
@spec render(Socket.assigns()) :: Rendered.t()
def render(assigns) do
~H"""
<div class="min-h-screen flex flex-col items-center justify-center bg-base-100 p-4">
<div class="w-full max-w-sm mb-8 text-center">
<.live_component
module={AshAuthentication.Phoenix.Components.Banner}
id="magic-sign-in-banner"
overrides={@overrides}
/>
</div>
<div class="w-full max-w-sm p-6 bg-base-100 border border-base-200 rounded-xl shadow-sm">
<.form :let={form} for={@form} phx-change="validate" phx-submit="submit" phx-trigger-action={@trigger_action}
action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :sign_in)}
method="POST">
{hidden_input(form, :token, [])}
<%!-- Using the unified component --%>
<MixerWeb.AuthComponents.username_field :if={@needs_username?} form={form} />
{submit("Sign in", class: "btn btn-primary w-full", phx_disable_with: "Signing in...")}
</.form>
</div>
</div>
"""
end
@doc false
@impl true
@spec handle_event(String.t(), map(), Socket.t()) :: {:noreply, Socket.t()}
def handle_event("submit", params, socket) do
subject_name =
socket.assigns.subject_name
|> to_string()
|> slugify()
form_params = Map.get(params, subject_name, %{})
# Use Form.validate with :all_errors to surface uniqueness constraints
form =
socket.assigns.form
|> Form.validate(form_params, errors: true)
if form.valid? do
# Only trigger the POST redirect if the data is truly valid
{:noreply, assign(socket, form: form, trigger_action: true)}
else
socket =
socket
|> assign(form: form, trigger_action: false)
{:noreply, socket}
end
end
@impl true
def handle_event("validate", params, socket) do
subject_name = socket.assigns.subject_name |> to_string() |> slugify()
form_params = Map.get(params, subject_name, %{})
form = Form.validate(socket.assigns.form, form_params, errors: true)
{:noreply, assign(socket, form: form)}
end
# ── Helpers ──────────────────────────────────────────────────────────────────
# Returns true if the user is new or has no username set yet.
defp needs_username?(nil, _resource, _domain), do: true
defp needs_username?(token, resource, domain) do
with {:ok, claims} <- AshAuthentication.Jwt.peek(token),
# 1. Try to find an existing user from the claims
user <- find_user(claims, resource, domain),
# 2. If a user exists, check if they already have a username
false <- is_nil(user) do
is_nil(user.username) or user.username == ""
else
_ ->
# Unknown / new user — ask for username to be safe
true
end
end
defp find_user(claims, resource, domain) do
# Try 'sub' first if it looks like a user subject (e.g. "User:123")
sub = Map.get(claims, "sub")
user =
if is_binary(sub) and String.contains?(sub, ":") do
case AshAuthentication.subject_to_user(sub, resource) do
{:ok, user} -> user
_ -> nil
end
end
# If not found via subject, try 'identity' (common in magic link tokens)
user ||
case Map.get(claims, "identity") || Map.get(claims, "email") do
email when is_binary(email) ->
# Use for_read with the explicit action and arguments
resource
|> Ash.Query.for_read(:get_by_email, %{email: email})
|> Ash.read_one(domain: domain, authorize?: false)
|> case do
{:ok, user} -> user
_ -> nil
end
_ ->
nil
end
end
end

View File

@@ -47,6 +47,7 @@ defmodule MixerWeb.Router do
post "/rpc/run", AshTypescriptRpcController, :run
post "/rpc/validate", AshTypescriptRpcController, :validate
post "/upload", UploadController, :create
post "/upload/avatar", UploadController, :upload_avatar
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
sign_out_route AuthController
@@ -74,6 +75,7 @@ defmodule MixerWeb.Router do
# Remove this if you do not use the magic link strategy.
magic_sign_in_route(Mixer.Accounts.User, :magic_link,
live_view: MixerWeb.MagicSignInLive,
auth_routes_prefix: "/auth",
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
)

View File

@@ -0,0 +1,29 @@
defmodule Mixer.Repo.Migrations.AddUserProfileFields do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:users) do
add :username, :text
add :display_name, :text
add :avatar_url, :text
end
create unique_index(:users, [:username], name: "users_unique_username_index")
end
def down do
drop_if_exists unique_index(:users, [:username], name: "users_unique_username_index")
alter table(:users) do
remove :avatar_url
remove :display_name
remove :username
end
end
end

View File

@@ -0,0 +1,133 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "confirmed_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "username",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "display_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "avatar_url",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "E57BFA1141A2F4D237E6B3C8FE4BAD93772015179B56AEC9FA1F762C4FF5B6B8",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_username_index",
"keys": [
{
"type": "atom",
"value": "username"
}
],
"name": "unique_username",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mixer.Repo",
"schema": null,
"table": "users"
}

View 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

View File

@@ -2,6 +2,7 @@ defmodule Mixer.Posts.TweetLikeTest do
use Mixer.DataCase, async: true
import Ash.Expr
require Ash.Query
alias Mixer.Accounts.User
alias Mixer.Posts.Tweet
@@ -24,14 +25,14 @@ defmodule Mixer.Posts.TweetLikeTest do
Tweet
|> 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
tweet_without_actor =
Tweet
|> 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
end
@@ -93,13 +94,17 @@ defmodule Mixer.Posts.TweetLikeTest do
end
defp user_fixture(email) do
username =
email |> String.split("@") |> List.first() |> String.replace(~r/[^a-zA-Z0-9_]/, "_")
User
|> Ash.Changeset.for_create(:register_with_password, %{
email: email,
password: "password1234",
password_confirmation: "password1234"
password_confirmation: "password1234",
username: username
})
|> Ash.create!()
|> Ash.create!(authorize?: false)
end
defp tweet_fixture(user, content) do

View 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

View File

@@ -9,7 +9,8 @@ defmodule MixerWeb.PageControllerTest do
%{
email: "test@example.com",
password: "Password1!",
password_confirmation: "Password1!"
password_confirmation: "Password1!",
username: "testuser"
},
authorize?: false
)