Compare commits

...

19 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
3c9910a723 Working metrics for all forms of interactions and updated .env.example 2026-04-07 00:09:10 -04:00
76a8acc731 Adjusted to properly type each of the database interactions 2026-04-06 23:31:17 -04:00
a33ec14c5f Integrating clickhouse for metrics. 2026-04-06 23:05:04 -04:00
57 changed files with 4403 additions and 1902 deletions

View File

@@ -27,3 +27,14 @@ S3_VIRTUAL_HOST=false
# Email (Brevo) # Email (Brevo)
BREVO_API_KEY=your-brevo-api-key BREVO_API_KEY=your-brevo-api-key
# ClickHouse (analytics / metrics)
# single connection URL (overrides all individual vars below)
CLICKHOUSE_URL=http://default:password@localhost:8123/mixer_metrics
# individual vars (used when CLICKHOUSE_URL is not set)
CLICKHOUSE_HOST=localhost
CLICKHOUSE_PORT=8123
CLICKHOUSE_DATABASE=mixer_metrics
CLICKHOUSE_USERNAME=default
CLICKHOUSE_PASSWORD=
CLICKHOUSE_SCHEME=http

3
.gitignore vendored
View File

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

View File

@@ -1,18 +1,92 @@
# Mixer # Mixer
To start your Phoenix server: A social posting platform built with Elixir/Phoenix, Ash Framework, and React. Users can post, reply, like, follow each other, and upload media/avatars. Metrics are tracked in ClickHouse.
* Run `mix setup` to install and setup dependencies ## Stack
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. - **Backend:** Elixir 1.15+, Phoenix, Ash Framework (resources, policies, state machine, authentication)
- **Frontend:** React + TypeScript, bundled via esbuild, styled with Tailwind CSS + DaisyUI
- **Databases:** PostgreSQL (primary data), ClickHouse (metrics/analytics)
- **Storage:** S3-compatible object storage (MinIO locally, any S3-compatible service in prod)
- **Email:** Swoosh (local mailbox in dev, Brevo in prod)
- **API layer:** AshTypescript RPC (type-safe TS client auto-generated from Ash resources)
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). ## Dev environment setup
## Learn more ### Prerequisites
* Official website: https://www.phoenixframework.org/ - Elixir 1.15+ and Erlang/OTP (via [asdf](https://asdf-vm.com) or system package manager)
* Guides: https://hexdocs.pm/phoenix/overview.html - PostgreSQL running locally (default: `postgres`/`postgres` on `localhost:5432`)
* Docs: https://hexdocs.pm/phoenix - ClickHouse running locally (default: `default`/no password on `localhost:8123`, database `mixer_metrics`)
* Forum: https://elixirforum.com/c/phoenix-forum - MinIO running locally on `localhost:9000` with credentials `minioadmin`/`minioadmin`
* Source: https://github.com/phoenixframework/phoenix
### MinIO setup
Start MinIO and create the bucket before running the app:
```bash
# Start MinIO (adjust data dir as needed)
minio server /data --console-address ":9001"
# Create the bucket (using the MinIO CLI or the web console at http://localhost:9001)
mc alias set local http://localhost:9000 minioadmin minioadmin
mc mb local/mixer-bucket
mc anonymous set public local/mixer-bucket
```
### First-time setup
```bash
# Install Elixir dependencies and set up both databases
mix setup
```
`mix setup` runs `mix deps.get`, creates and migrates both the PostgreSQL and ClickHouse databases, and seeds initial data.
### Running the server
```bash
mix phx.server
```
Visit [http://localhost:4000](http://localhost:4000). The frontend assets (esbuild + Tailwind) are compiled and watched automatically.
### Email in development
Magic-link sign-in emails are delivered to the local Swoosh mailbox. View them at [http://localhost:4000/dev/mailbox](http://localhost:4000/dev/mailbox).
### Regenerating the TypeScript RPC client
After changing Ash resource actions or attributes, regenerate the typed TS client:
```bash
mix ash_typescript.generate
```
The output goes to `assets/js/ash_rpc.ts`.
## Production environment variables
| Variable | Description |
|---|---|
| `DATABASE_URL` | PostgreSQL connection URL (`ecto://user:pass@host/db`) |
| `SECRET_KEY_BASE` | Phoenix secret key (generate with `mix phx.gen.secret`) |
| `TOKEN_SIGNING_SECRET` | Ash authentication token signing secret |
| `CLICKHOUSE_URL` | ClickHouse connection URL (or use individual vars below) |
| `CLICKHOUSE_HOST` | ClickHouse host |
| `CLICKHOUSE_PORT` | ClickHouse port (default `8123`) |
| `CLICKHOUSE_DATABASE` | ClickHouse database name (default `mixer_metrics`) |
| `CLICKHOUSE_USERNAME` | ClickHouse username (default `default`) |
| `CLICKHOUSE_PASSWORD` | ClickHouse password |
| `S3_ACCESS_KEY_ID` | S3 access key |
| `S3_SECRET_ACCESS_KEY` | S3 secret key |
| `S3_HOST` | S3 host (e.g. `s3.amazonaws.com`) |
| `S3_BUCKET` | S3 bucket name |
| `S3_ASSET_HOST` | Public base URL for serving assets (e.g. `https://cdn.example.com`) |
| `S3_SCHEME` | S3 scheme (default `https://`) |
| `S3_PORT` | S3 port (default `80`) |
| `S3_VIRTUAL_HOST` | Use virtual-hosted S3 URLs (default `false`) |
| `BREVO_API_KEY` | Brevo (Sendinblue) API key for transactional email |
| `PHX_HOST` | Public hostname (default `mixer.jimweaver.com`) |
| `PORT` | HTTP port (default `4000`) |
| `PHX_SERVER` | Set to `true` to start the HTTP server in a release |

View File

@@ -1056,6 +1056,159 @@ html, body {
.mx-compose-wrapper { display: none; } .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 */ /* Narrow phones (≤ 640 px): tighten spacing */
@media (max-width: 640px) { @media (max-width: 640px) {
.mx-feed { padding: 0.625rem; gap: 0.5rem; } .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>[]; export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];

View File

@@ -25,9 +25,12 @@ export type followsAttributesOnlySchema = {
// users Schema // users Schema
export type usersResourceSchema = { export type usersResourceSchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "email" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId"; __primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
id: UUID; id: UUID;
email: string; email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
followerCount: number; followerCount: number;
followingCount: number; followingCount: number;
amIFollowing: boolean; amIFollowing: boolean;
@@ -38,9 +41,12 @@ export type usersResourceSchema = {
export type usersAttributesOnlySchema = { export type usersAttributesOnlySchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "email"; __primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl";
id: UUID; id: UUID;
email: string; email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
}; };
@@ -71,7 +77,7 @@ export type mediaAttributesOnlySchema = {
// tweets Schema // tweets Schema
export type tweetsResourceSchema = { export type tweetsResourceSchema = {
__type: "Resource"; __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; id: UUID;
content: string; content: string;
likes: number; likes: number;
@@ -82,6 +88,9 @@ export type tweetsResourceSchema = {
commentCount: number; commentCount: number;
likedByMe: boolean; likedByMe: boolean;
userEmail: string | null; userEmail: string | null;
userUsername: string | null;
userDisplayName: string | null;
userAvatarUrl: string | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; }; user: { __type: "Relationship"; __resource: usersResourceSchema; };
parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; }; parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; }; comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; };
@@ -134,6 +143,27 @@ export type usersFilterInput = {
in?: Array<string>; 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?: { followerCount?: {
eq?: number; eq?: number;
notEq?: number; notEq?: number;
@@ -270,6 +300,27 @@ export type tweetsFilterInput = {
isNil?: boolean; 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?: { commentCount?: {
eq?: number; eq?: number;
notEq?: number; notEq?: number;
@@ -301,26 +352,26 @@ export type tweetsFilterInput = {
export const followsFilterFields = ["id"] as const; export const followsFilterFields = ["id"] as const;
export type followsFilterField = (typeof followsFilterFields)[number]; 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 type usersFilterField = (typeof usersFilterFields)[number];
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
export type mediaFilterField = (typeof mediaFilterFields)[number]; 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 type tweetsFilterField = (typeof tweetsFilterFields)[number];
export const followsSortFields = ["id"] as const; export const followsSortFields = ["id"] as const;
export type followsSortField = (typeof followsSortFields)[number]; 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 type usersSortField = (typeof usersSortFields)[number];
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
export type mediaSortField = (typeof mediaSortFields)[number]; 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]; 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; 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( export async function uploadFile(
file: File, file: File,
csrfToken: string 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/*"] "*": ["../deps/*"]
}, },
"allowJs": true, "allowJs": true,
"noEmit": true "noEmit": true,
"target": "es5",
"lib": ["ES2015", "DOM"]
}, },
"include": ["js/**/*"] "include": ["js/**/*"]
} }

View File

@@ -94,7 +94,7 @@ config :spark,
] ]
config :mixer, config :mixer,
ecto_repos: [Mixer.Repo], ecto_repos: [Mixer.Repo, Mixer.ClickhouseRepo],
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
ash_domains: [Mixer.Accounts, Mixer.Posts], ash_domains: [Mixer.Accounts, Mixer.Posts],
ash_authentication: [return_error_on_invalid_magic_link_token?: true] ash_authentication: [return_error_on_invalid_magic_link_token?: true]
@@ -158,6 +158,11 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix # Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason
# ClickHouse repo — migrations live in priv/clickhouse/migrations
config :mixer, Mixer.ClickhouseRepo,
priv: "priv/clickhouse",
migration_source: "ch_schema_migrations"
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

View File

@@ -106,3 +106,12 @@ config :ex_aws, :s3,
config :waffle, config :waffle,
bucket: "mixer-bucket", bucket: "mixer-bucket",
asset_host: "http://localhost:9000" asset_host: "http://localhost:9000"
# ClickHouse (default local install)
config :mixer, Mixer.ClickhouseRepo,
scheme: "http",
hostname: "localhost",
port: 8123,
database: "mixer_metrics",
username: "default",
password: ""

View File

@@ -22,6 +22,11 @@ end
config :mixer, MixerWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] config :mixer, MixerWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]
# ClickHouse is available in all environments via env vars when set
if clickhouse_url = System.get_env("CLICKHOUSE_URL") do
config :mixer, Mixer.ClickhouseRepo, url: clickhouse_url
end
if config_env() == :prod do if config_env() == :prod do
database_url = database_url =
System.get_env("DATABASE_URL") || System.get_env("DATABASE_URL") ||
@@ -40,6 +45,19 @@ if config_env() == :prod do
# pool_count: 4, # pool_count: 4,
socket_options: maybe_ipv6 socket_options: maybe_ipv6
# ClickHouse — configure via CLICKHOUSE_URL or individual vars
unless System.get_env("CLICKHOUSE_URL") do
config :mixer, Mixer.ClickhouseRepo,
scheme: System.get_env("CLICKHOUSE_SCHEME", "http"),
hostname:
System.get_env("CLICKHOUSE_HOST") ||
raise("Missing environment variable `CLICKHOUSE_HOST`!"),
port: String.to_integer(System.get_env("CLICKHOUSE_PORT", "8123")),
database: System.get_env("CLICKHOUSE_DATABASE", "mixer_metrics"),
username: System.get_env("CLICKHOUSE_USERNAME", "default"),
password: System.get_env("CLICKHOUSE_PASSWORD", "")
end
# The secret key base is used to sign/encrypt cookies and other secrets. # The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you # A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want # want to use a different value for prod and you most likely don't want

View File

@@ -42,3 +42,12 @@ config :phoenix_live_view,
# Sort query params output of verified routes for robust url comparisons # Sort query params output of verified routes for robust url comparisons
config :phoenix, config :phoenix,
sort_verified_routes_query_params: true sort_verified_routes_query_params: true
# ClickHouse — point at a dedicated test database
config :mixer, Mixer.ClickhouseRepo,
scheme: "http",
hostname: "localhost",
port: 8123,
database: "mixer_metrics_test",
username: "default",
password: ""

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 typescript_rpc do
resource Mixer.Accounts.User do resource Mixer.Accounts.User do
rpc_action :read_user, :read rpc_action :read_user, :read
rpc_action :update_profile, :update_profile
end end
resource Mixer.Accounts.Follow do 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] accept [:following_id]
change relate_actor(:follower) change relate_actor(:follower)
validate fn changeset, _context -> validate fn changeset, context ->
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id) actor_id = context.actor && context.actor.id
following_id = Ash.Changeset.get_attribute(changeset, :following_id) following_id = Ash.Changeset.get_attribute(changeset, :following_id)
if follower_id == following_id do if actor_id && actor_id == following_id do
{:error, field: :following_id, message: "You cannot follow yourself"} {:error, field: :following_id, message: "You cannot follow yourself"}
else else
:ok :ok

View File

@@ -177,9 +177,21 @@ defmodule Mixer.Accounts.User do
sensitive? true sensitive? true
end 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 # Sets the email from the argument
change set_attribute(:email, arg(:email)) change set_attribute(:email, arg(:email))
# Sets the username from the argument
change set_attribute(:username, arg(:username))
# Hashes the provided password # Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange change AshAuthentication.Strategy.Password.HashPasswordChange
@@ -211,6 +223,18 @@ defmodule Mixer.Accounts.User do
get_by :email get_by :email
end 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 update :reset_password_with_token do
argument :reset_token, :string do argument :reset_token, :string do
allow_nil? false allow_nil? false
@@ -256,6 +280,15 @@ defmodule Mixer.Accounts.User do
allow_nil? true allow_nil? true
end 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? true
upsert_identity :unique_email upsert_identity :unique_email
upsert_fields [:email] upsert_fields [:email]
@@ -266,6 +299,37 @@ defmodule Mixer.Accounts.User do
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange, change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
strategy_name: :remember_me} 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 metadata :token, :string do
allow_nil? false allow_nil? false
end end
@@ -293,6 +357,14 @@ defmodule Mixer.Accounts.User do
policy action_type(:read) do policy action_type(:read) do
authorize_if always() authorize_if always()
end 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 end
attributes do attributes do
@@ -308,6 +380,23 @@ defmodule Mixer.Accounts.User do
end end
attribute :confirmed_at, :utc_datetime_usec 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 end
relationships do relationships do
@@ -350,5 +439,10 @@ defmodule Mixer.Accounts.User do
identities do identities do
identity :unique_email, [:email] identity :unique_email, [:email]
identity :unique_username, [:username] do
eager_check_with Mixer.Accounts
message "is already taken"
nils_distinct? true
end
end end
end end

View File

@@ -10,6 +10,10 @@ defmodule Mixer.Application do
children = [ children = [
MixerWeb.Telemetry, MixerWeb.Telemetry,
Mixer.Repo, Mixer.Repo,
# ClickHouse repo for analytics — started before the metrics buffer
Mixer.ClickhouseRepo,
# In-memory event buffer that batches writes to ClickHouse
Mixer.Metrics.Buffer,
{DNSCluster, query: Application.get_env(:mixer, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:mixer, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mixer.PubSub}, {Phoenix.PubSub, name: Mixer.PubSub},
# Start a worker by calling: Mixer.Worker.start_link(arg) # Start a worker by calling: Mixer.Worker.start_link(arg)

View File

@@ -0,0 +1,13 @@
defmodule Mixer.ClickhouseRepo do
@moduledoc """
Ecto repository for ClickHouse, backed by the `ecto_ch` / `Ch` adapter.
Used exclusively for analytics writes (via `Mixer.Metrics.Buffer`) and
read queries (via `Mixer.Metrics`). It is **not** an Ash repo and must
never be used for transactional application data.
"""
use Ecto.Repo,
otp_app: :mixer,
adapter: Ecto.Adapters.ClickHouse
end

291
lib/mixer/metrics.ex Normal file
View File

@@ -0,0 +1,291 @@
defmodule Mixer.Metrics do
@moduledoc """
Public API for tracking and querying post (tweet) metrics via ClickHouse.
## Tracking events
Tracking calls are non-blocking — events are handed off to the in-memory
`Mixer.Metrics.Buffer` GenServer and written to ClickHouse in batches.
# Record a tweet view (anonymous)
Mixer.Metrics.track_view(tweet_id)
# Record a view with a logged-in user and their IP
Mixer.Metrics.track_view(tweet_id, user_id: user.id, ip_address: conn.remote_ip)
## Querying metrics
Query functions execute synchronous ClickHouse SQL and return plain maps.
{:ok, summary} = Mixer.Metrics.get_summary(tweet_id)
# => %{views: 42, likes: 7, unlikes: 1, comments: 3, shares: 0}
{:ok, rows} = Mixer.Metrics.get_top_posts(10)
# => [%{tweet_id: "...", views: 99}, ...]
"""
require Logger
alias Mixer.ClickhouseRepo
alias Mixer.Metrics.Buffer
# ---------------------------------------------------------------------------
# Event types
# ---------------------------------------------------------------------------
@type event_type ::
:view | :post | :comment | :like | :unlike | :share | :delete_post | :delete_comment
@type track_opt ::
{:user_id, binary() | nil}
| {:ip_address, binary() | :inet.ip_address() | nil}
# ---------------------------------------------------------------------------
# Tracking helpers
# ---------------------------------------------------------------------------
@doc """
Track a tweet view event.
## Options
* `:user_id` — UUID of the viewing user (nil for anonymous)
* `:ip_address` — originating IP; accepts a string or an `:inet` tuple
"""
@spec track_view(binary(), [track_opt()]) :: :ok
def track_view(tweet_id, opts \\ []), do: enqueue("view", tweet_id, opts)
@doc "Track a tweet like event."
@spec track_like(binary(), [track_opt()]) :: :ok
def track_like(tweet_id, opts \\ []), do: enqueue("like", tweet_id, opts)
@doc "Track a tweet unlike event."
@spec track_unlike(binary(), [track_opt()]) :: :ok
def track_unlike(tweet_id, opts \\ []), do: enqueue("unlike", tweet_id, opts)
@doc "Track a comment (reply) event on a tweet."
@spec track_comment(binary(), [track_opt()]) :: :ok
def track_comment(tweet_id, opts \\ []), do: enqueue("comment", tweet_id, opts)
@doc "Track a tweet share / repost event."
@spec track_share(binary(), [track_opt()]) :: :ok
def track_share(tweet_id, opts \\ []), do: enqueue("share", tweet_id, opts)
@doc """
Track a new top-level tweet being published.
The event is recorded against the new tweet's own ID.
"""
@spec track_post(binary(), [track_opt()]) :: :ok
def track_post(tweet_id, opts \\ []), do: enqueue("post", tweet_id, opts)
@doc """
Track a top-level tweet being deleted.
The event is recorded against the deleted tweet's ID.
Note: cascade-deleted comments are not individually tracked — only the
explicit user-initiated destroy action emits this event.
"""
@spec track_delete_post(binary(), [track_opt()]) :: :ok
def track_delete_post(tweet_id, opts \\ []), do: enqueue("delete_post", tweet_id, opts)
@doc """
Track a comment (reply) being deleted.
The event is recorded against the *parent* tweet's ID so that
`get_summary/1` can reflect net comment activity on a tweet.
"""
@spec track_delete_comment(binary(), [track_opt()]) :: :ok
def track_delete_comment(tweet_id, opts \\ []), do: enqueue("delete_comment", tweet_id, opts)
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
@doc """
Return a summary of all event counts for a single tweet.
Returns `{:ok, map}` on success or `{:error, reason}` on failure.
## Example
{:ok, %{views: 12, likes: 3, unlikes: 0, comments: 5, shares: 1}} =
Mixer.Metrics.get_summary(tweet_id)
"""
@spec get_summary(binary()) :: {:ok, map()} | {:error, term()}
def get_summary(tweet_id) do
sql = """
SELECT
countIf(event_type = 'view') AS views,
countIf(event_type = 'like') AS likes,
countIf(event_type = 'unlike') AS unlikes,
countIf(event_type = 'comment') AS comments,
countIf(event_type = 'share') AS shares
FROM post_events
WHERE tweet_id = {tweet_id:String}
"""
case ClickhouseRepo.query(sql, %{"tweet_id" => tweet_id}) do
{:ok, result} ->
{:ok, row_to_summary(result)}
{:error, reason} ->
Logger.error("[Mixer.Metrics] get_summary failed for #{tweet_id}: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Return view counts bucketed by UTC hour for the past `hours` hours.
Useful for rendering a sparkline on a tweet detail page.
## Example
{:ok, rows} = Mixer.Metrics.get_hourly_views(tweet_id, 24)
# => [%{hour: ~N[2026-04-07 00:00:00], views: 5}, ...]
"""
@spec get_hourly_views(binary(), pos_integer()) :: {:ok, [map()]} | {:error, term()}
def get_hourly_views(tweet_id, hours \\ 24) when is_integer(hours) and hours > 0 do
sql = """
SELECT
toStartOfHour(occurred_at) AS hour,
count() AS views
FROM post_events
WHERE
tweet_id = {tweet_id:String}
AND event_type = 'view'
AND occurred_at >= now() - toIntervalHour({hours:UInt32})
GROUP BY hour
ORDER BY hour ASC
"""
case ClickhouseRepo.query(sql, %{"tweet_id" => tweet_id, "hours" => hours}) do
{:ok, %{rows: rows}} ->
{:ok, Enum.map(rows, fn [hour, views] -> %{hour: hour, views: views} end)}
{:error, reason} ->
Logger.error("[Mixer.Metrics] get_hourly_views failed: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Return the top `limit` tweets ordered by total view count across all time.
## Example
{:ok, rows} = Mixer.Metrics.get_top_posts(10)
# => [%{tweet_id: "...", views: 99}, %{tweet_id: "...", views: 72}, ...]
"""
@spec get_top_posts(pos_integer()) :: {:ok, [map()]} | {:error, term()}
def get_top_posts(limit \\ 10) when is_integer(limit) and limit > 0 do
sql = """
SELECT
tweet_id,
countIf(event_type = 'view') AS views
FROM post_events
GROUP BY tweet_id
ORDER BY views DESC
LIMIT {limit:UInt32}
"""
case ClickhouseRepo.query(sql, %{"limit" => limit}) do
{:ok, %{rows: rows}} ->
{:ok, Enum.map(rows, fn [tweet_id, views] -> %{tweet_id: tweet_id, views: views} end)}
{:error, reason} ->
Logger.error("[Mixer.Metrics] get_top_posts failed: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Return per-event-type counts for a list of tweet IDs in a single query.
Handy for batch-enriching a feed with metrics without N+1 queries.
## Example
{:ok, map} = Mixer.Metrics.get_bulk_summaries(tweet_ids)
# => %{"<uuid>" => %{views: 5, likes: 2, ...}, ...}
"""
@spec get_bulk_summaries([binary()]) :: {:ok, %{binary() => map()}} | {:error, term()}
def get_bulk_summaries([]), do: {:ok, %{}}
def get_bulk_summaries(tweet_ids) when is_list(tweet_ids) do
# ecto_ch supports passing arrays as query parameters
sql = """
SELECT
tweet_id,
countIf(event_type = 'view') AS views,
countIf(event_type = 'like') AS likes,
countIf(event_type = 'unlike') AS unlikes,
countIf(event_type = 'comment') AS comments,
countIf(event_type = 'share') AS shares
FROM post_events
WHERE tweet_id IN {tweet_ids:Array(String)}
GROUP BY tweet_id
"""
case ClickhouseRepo.query(sql, %{"tweet_ids" => tweet_ids}) do
{:ok, %{rows: rows}} ->
summaries =
Map.new(rows, fn [tweet_id, views, likes, unlikes, comments, shares] ->
{tweet_id,
%{
views: views,
likes: likes,
unlikes: unlikes,
comments: comments,
shares: shares
}}
end)
{:ok, summaries}
{:error, reason} ->
Logger.error("[Mixer.Metrics] get_bulk_summaries failed: #{inspect(reason)}")
{:error, reason}
end
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp enqueue(event_type, tweet_id, opts) do
event = %{
event_type: event_type,
tweet_id: tweet_id,
user_id: Keyword.get(opts, :user_id),
occurred_at: DateTime.utc_now() |> DateTime.truncate(:second),
ip_address: opts |> Keyword.get(:ip_address) |> format_ip()
}
Buffer.track(event)
end
defp format_ip(nil), do: nil
defp format_ip(ip) when is_binary(ip), do: ip
defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"
defp format_ip({a, b, c, d, e, f, g, h}) do
[a, b, c, d, e, f, g, h]
|> Enum.map_join(":", &Integer.to_string(&1, 16))
end
defp row_to_summary(%{rows: [[views, likes, unlikes, comments, shares] | _]}) do
%{
views: views,
likes: likes,
unlikes: unlikes,
comments: comments,
shares: shares
}
end
# ClickHouse returns no rows when the tweet has zero events — default to 0
defp row_to_summary(_), do: %{views: 0, likes: 0, unlikes: 0, comments: 0, shares: 0}
end

151
lib/mixer/metrics/buffer.ex Normal file
View File

@@ -0,0 +1,151 @@
defmodule Mixer.Metrics.Buffer do
@moduledoc """
GenServer that accumulates post metric events in memory and flushes them
to ClickHouse in batches.
Two conditions trigger a flush:
1. **Timer** — every `@flush_interval` milliseconds (default 10 s).
2. **Threshold** — whenever the in-memory buffer reaches `@max_buffer_size`
rows (default 500).
If ClickHouse is unavailable the error is logged and the buffered events
are discarded rather than retried indefinitely, preventing unbounded memory
growth. For production deployments that require durability, consider adding
a persistent queue in front of this buffer.
"""
use GenServer
require Logger
alias Mixer.Metrics.PostEvent
@flush_interval :timer.seconds(10)
@max_buffer_size 500
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@doc """
Start the buffer process and link it to the calling process.
Accepts an optional keyword list of overrides:
* `:flush_interval` — milliseconds between scheduled flushes
* `:max_buffer_size` — row count that triggers an immediate flush
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Enqueue a single analytics event map for buffered insertion into ClickHouse.
The map must contain at minimum the fields required by `Mixer.Metrics.PostEvent`:
`:event_type`, `:tweet_id`, `:occurred_at`. Other fields are optional.
This call is asynchronous (cast) and returns `:ok` immediately.
"""
@spec track(map()) :: :ok
def track(event) when is_map(event) do
GenServer.cast(__MODULE__, {:track, event})
end
@doc """
Force an immediate flush of all buffered events to ClickHouse, regardless
of the timer or threshold. Returns `:ok` after the flush completes.
Primarily useful in tests.
"""
@spec flush() :: :ok
def flush do
GenServer.call(__MODULE__, :flush)
end
# ---------------------------------------------------------------------------
# GenServer callbacks
# ---------------------------------------------------------------------------
@impl GenServer
def init(opts) do
flush_interval = Keyword.get(opts, :flush_interval, @flush_interval)
max_buffer_size = Keyword.get(opts, :max_buffer_size, @max_buffer_size)
schedule_flush(flush_interval)
state = %{
events: [],
count: 0,
flush_interval: flush_interval,
max_buffer_size: max_buffer_size
}
{:ok, state}
end
@impl GenServer
def handle_cast({:track, event}, state) do
new_count = state.count + 1
new_events = [event | state.events]
if new_count >= state.max_buffer_size do
do_flush(new_events)
{:noreply, %{state | events: [], count: 0}}
else
{:noreply, %{state | events: new_events, count: new_count}}
end
end
@impl GenServer
def handle_call(:flush, _from, state) do
do_flush(state.events)
{:reply, :ok, %{state | events: [], count: 0}}
end
@impl GenServer
def handle_info(:flush, state) do
do_flush(state.events)
schedule_flush(state.flush_interval)
{:noreply, %{state | events: [], count: 0}}
end
@impl GenServer
def terminate(_reason, state) do
# Best-effort flush on shutdown so we don't lose buffered events during
# graceful stops (e.g., deploys).
do_flush(state.events)
:ok
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp do_flush([]), do: :ok
defp do_flush(events) do
rows = Enum.reverse(events)
count = length(rows)
try do
# ClickHouse async inserts acknowledge writes immediately and always
# return num_rows: 0 — the data is queued for background commitment.
# We use our own row count for the log so it is always accurate.
Mixer.ClickhouseRepo.insert_all(PostEvent, rows)
Logger.debug("[Mixer.Metrics.Buffer] Flushed #{count} event(s) to ClickHouse")
rescue
error ->
Logger.error(
"[Mixer.Metrics.Buffer] Failed to flush #{count} event(s) to ClickHouse: " <>
Exception.message(error)
)
end
end
defp schedule_flush(interval) do
Process.send_after(self(), :flush, interval)
end
end

View File

@@ -0,0 +1,47 @@
defmodule Mixer.Metrics.PostEvent do
@moduledoc """
Ecto schema that maps to the `post_events` table in ClickHouse.
Each row represents a single analytics event tied to a tweet (post).
The table uses a MergeTree engine ordered by `(occurred_at, event_type,
tweet_id)` for efficient time-range scans and per-tweet aggregations.
## Event types
| event_type | `tweet_id` refers to | Description |
|--------------------|-----------------------|-------------------------------------------------|
| `"view"` | the viewed tweet | Tweet detail page was loaded |
| `"post"` | the new tweet | A new top-level tweet was published |
| `"comment"` | the parent tweet | A reply was posted; count against the parent |
| `"like"` | the liked tweet | A user liked a tweet |
| `"unlike"` | the unliked tweet | A user removed their like |
| `"share"` | the shared tweet | A user shared / reposted a tweet |
| `"delete_post"` | the deleted tweet | A top-level tweet was deleted by its author |
| `"delete_comment"` | the parent tweet | A reply was deleted; count against the parent |
"""
use Ecto.Schema
@primary_key false
schema "post_events" do
# Must be Ch-typed so ecto_ch emits LowCardinality(String) in the RowBinary
# header, matching the ClickHouse table DDL exactly.
field :event_type, Ch, type: "LowCardinality(String)"
# The tweet that the event relates to
field :tweet_id, Ecto.UUID
# The acting user; may be nil for anonymous views.
# Must be Ch-typed so ecto_ch emits Nullable(UUID) in the RowBinary header,
# matching the ClickHouse table DDL exactly.
field :user_id, Ch, type: "Nullable(UUID)"
# Wall-clock time of the event (UTC, second precision)
field :occurred_at, :utc_datetime
# Optional originating IP, useful for deduplicating anonymous views.
# Nullable(String) for the same reason as user_id above.
field :ip_address, Ch, type: "Nullable(String)"
end
end

View File

@@ -32,7 +32,7 @@ defmodule Mixer.Posts.Tweet do
end end
actions do actions do
defaults [:read, :destroy] defaults [:read]
read :following_feed do read :following_feed do
filter expr( filter expr(
@@ -66,6 +66,49 @@ defmodule Mixer.Posts.Tweet do
end) end)
end end
end end
# Track post / comment creation metrics.
# Root tweets emit a "post" event recorded against their own ID.
# Replies emit a "comment" event recorded against the parent tweet ID so
# that `get_summary/1` can count how many replies a tweet has received.
change fn changeset, context ->
parent_tweet_id = Ash.Changeset.get_attribute(changeset, :parent_tweet_id)
user_id = context.actor && context.actor.id
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
if parent_tweet_id do
Mixer.Metrics.track_comment(parent_tweet_id, user_id: user_id)
else
Mixer.Metrics.track_post(tweet.id, user_id: user_id)
end
{:ok, tweet}
end)
end
end
# Explicit destroy so we can attach a metrics hook. The policy and cascade
# behaviour are identical to the previous default :destroy action.
destroy :destroy do
require_atomic? false
change fn changeset, context ->
# Capture the record's identity *before* deletion — after the action
# completes the row no longer exists.
tweet_id = changeset.data.id
parent_tweet_id = changeset.data.parent_tweet_id
user_id = context.actor && context.actor.id
Ash.Changeset.after_action(changeset, fn _changeset, result ->
if parent_tweet_id do
Mixer.Metrics.track_delete_comment(parent_tweet_id, user_id: user_id)
else
Mixer.Metrics.track_delete_post(tweet_id, user_id: user_id)
end
{:ok, result}
end)
end
end end
update :update do update :update do
@@ -80,10 +123,11 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet -> Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
case ensure_like(tweet, context.actor) do case ensure_like(tweet, context.actor) do
{:created, _like} -> {:created, _like} ->
Mixer.Metrics.track_like(tweet.id, user_id: context.actor && context.actor.id)
increment_likes(tweet, context.actor) increment_likes(tweet, context.actor)
{:noop, _like} -> {:noop, _like} ->
{:ok, tweet} Ash.get(__MODULE__, tweet.id, authorize?: false)
{:error, error} -> {:error, error} ->
{:error, error} {:error, error}
@@ -100,10 +144,11 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet -> Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
case remove_like(tweet, context.actor) do case remove_like(tweet, context.actor) do
{:deleted, _like} -> {:deleted, _like} ->
Mixer.Metrics.track_unlike(tweet.id, user_id: context.actor && context.actor.id)
decrement_likes(tweet, context.actor) decrement_likes(tweet, context.actor)
{:noop, _like} -> {:noop, _like} ->
{:ok, tweet} Ash.get(__MODULE__, tweet.id, authorize?: false)
{:error, error} -> {:error, error} ->
{:error, error} {:error, error}
@@ -121,7 +166,7 @@ defmodule Mixer.Posts.Tweet do
update :decrement_likes do update :decrement_likes do
accept [] accept []
require_atomic? false require_atomic? false
change atomic_update(:likes, expr(likes - 1)) change atomic_update(:likes, expr(fragment("GREATEST(? - 1, 0)", likes)))
end end
end end
@@ -209,6 +254,18 @@ defmodule Mixer.Posts.Tweet do
calculate :user_email, :string, expr(user.email) do calculate :user_email, :string, expr(user.email) do
public? true public? true
end 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 end
aggregates do aggregates do

View File

@@ -15,4 +15,9 @@ defmodule MixerWeb.AuthOverrides do
set :text, "⬡ Mixer" set :text, "⬡ Mixer"
set :text_class, "text-3xl font-bold tracking-tight" set :text_class, "text-3xl font-bold tracking-tight"
end 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 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

@@ -16,6 +16,8 @@ defmodule MixerWeb.PageController do
end end
def show(conn, %{"tweet_id" => tweet_id}) do def show(conn, %{"tweet_id" => tweet_id}) do
user_id = conn.assigns[:current_user] && conn.assigns[:current_user].id
Mixer.Metrics.track_view(tweet_id, user_id: user_id, ip_address: conn.remote_ip)
render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil}) render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil})
end end

View File

@@ -2,6 +2,11 @@
id="app" id="app"
data-current-user-id={if @current_user, do: @current_user.id, else: ""} 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-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-asset-host={@media_host}
data-page={@page} data-page={@page}
data-tweet-id={@tweet_id || ""} data-tweet-id={@tweet_id || ""}

View File

@@ -2,6 +2,7 @@ defmodule MixerWeb.UploadController do
use MixerWeb, :controller use MixerWeb, :controller
alias Mixer.Posts.MediaUploader alias Mixer.Posts.MediaUploader
alias Mixer.Accounts.AvatarUploader
def create(conn, %{"file" => %Plug.Upload{} = upload}) do def create(conn, %{"file" => %Plug.Upload{} = upload}) do
actor = conn.assigns[:current_user] actor = conn.assigns[:current_user]
@@ -46,4 +47,50 @@ defmodule MixerWeb.UploadController do
|> put_status(:bad_request) |> put_status(:bad_request)
|> json(%{error: "no file provided"}) |> json(%{error: "no file provided"})
end 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 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/run", AshTypescriptRpcController, :run
post "/rpc/validate", AshTypescriptRpcController, :validate post "/rpc/validate", AshTypescriptRpcController, :validate
post "/upload", UploadController, :create post "/upload", UploadController, :create
post "/upload/avatar", UploadController, :upload_avatar
auth_routes AuthController, Mixer.Accounts.User, path: "/auth" auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
sign_out_route AuthController sign_out_route AuthController
@@ -74,6 +75,7 @@ defmodule MixerWeb.Router do
# Remove this if you do not use the magic link strategy. # Remove this if you do not use the magic link strategy.
magic_sign_in_route(Mixer.Accounts.User, :magic_link, magic_sign_in_route(Mixer.Accounts.User, :magic_link,
live_view: MixerWeb.MagicSignInLive,
auth_routes_prefix: "/auth", auth_routes_prefix: "/auth",
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI] overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
) )

View File

@@ -91,7 +91,8 @@ defmodule Mixer.MixProject do
{:ex_aws, "~> 2.1.2"}, {:ex_aws, "~> 2.1.2"},
{:ex_aws_s3, "~> 2.0"}, {:ex_aws_s3, "~> 2.0"},
{:hackney, "~> 1.9"}, {:hackney, "~> 1.9"},
{:sweet_xml, "~> 0.6"} {:sweet_xml, "~> 0.6"},
{:ecto_ch, "~> 0.3"}
] ]
end end

View File

@@ -20,6 +20,7 @@
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"ch": {:hex, :ch, "0.7.1", "116c08094b30d095c3bd6a8fe4ebe19fdaaf3dce84e2413cfdd6af157baf6303", [:mix], [{:db_connection, "~> 2.9.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "3c1c900291ff9c4c077cd1dc0c265051a3f1d26320d58b37ed9e91b33d41a868"},
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"}, "cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"},
@@ -29,6 +30,7 @@
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"}, "dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_ch": {:hex, :ecto_ch, "0.8.6", "f31b507e86690c003f46e75d6e742e6b5d8ce34b6b10a86604b1c3aa785e0b56", [:mix], [{:ch, "~> 0.5.0 or ~> 0.6.0 or ~> 0.7.0", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "6ca9f1cf9680452b1925c6a3a7b5e3d8b12e38ee134b03c6a45a8b26434fad97"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},

View File

@@ -0,0 +1,4 @@
[
import_deps: [:ecto_ch],
inputs: ["*.exs"]
]

View File

@@ -0,0 +1,49 @@
defmodule Mixer.ClickhouseRepo.Migrations.CreatePostEvents do
use Ecto.Migration
@doc """
Creates the `post_events` table using a MergeTree engine.
Key design decisions:
* `LowCardinality(String)` for `event_type` — the cardinality is tiny
(510 values), so ClickHouse can store it as a dictionary, giving both
compression and faster filtering.
* `Nullable(UUID)` / `Nullable(String)` for optional columns — ClickHouse
handles NULLs differently from PostgreSQL; we make the nullable fields
explicit so the schema is unambiguous.
* `ORDER BY (occurred_at, event_type, tweet_id)` — optimises the two most
common query patterns:
1. Time-range scans (`WHERE occurred_at >= now() - interval 24 HOUR`)
2. Per-tweet aggregations (`WHERE tweet_id = ?`)
* `PARTITION BY toYYYYMM(occurred_at)` — monthly partitions make it cheap
to drop old data with `ALTER TABLE … DROP PARTITION`.
* `TTL occurred_at + INTERVAL 1 YEAR DELETE` — automatically reclaim disk
space after two years. Adjust as required.
"""
def up do
execute("""
CREATE TABLE IF NOT EXISTS post_events
(
event_type LowCardinality(String),
tweet_id UUID,
user_id Nullable(UUID),
occurred_at DateTime,
ip_address Nullable(String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (occurred_at, event_type, tweet_id)
TTL occurred_at + INTERVAL 1 YEAR DELETE
SETTINGS index_granularity = 8192
""")
end
def down do
execute("DROP TABLE IF EXISTS post_events")
end
end

11
priv/clickhouse/seeds.exs Normal file
View File

@@ -0,0 +1,11 @@
# Script for populating the database. You can run it as:
#
# mix run priv/clickhouse/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Mixer.ClickhouseRepo.insert!(%Mixer.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

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