Compare commits

...

32 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
109ef398ee paginating comments and letting tweet authors delete comments on their post 2026-04-06 14:15:36 -04:00
faa96d88f5 added comments to tweets 2026-04-06 14:11:10 -04:00
6927f6eb9b Login with password now requires email to be confirmed 2026-04-06 13:18:00 -04:00
cc6586587f Added some meta tags for "SEO" (fake ass concept for this) but hopefully works for embeds 2026-04-05 15:19:47 -04:00
315b108fa1 Added page titles and proper favicon 2026-04-05 15:15:07 -04:00
4ec41ad4b3 Added /profile to see your profile 2026-04-05 14:50:50 -04:00
4b36131183 Added following page to see posts from yourself and people you follow 2026-04-05 14:41:29 -04:00
8077e570f4 changed feed page to paginate requests 2026-04-05 14:32:51 -04:00
33c83e188e fixed mobile ui and ux 2026-04-04 13:02:10 -04:00
193ff815a1 Mobile nav and drafting setup 2026-04-04 12:38:40 -04:00
1ed136e637 fixed tweet time display to show when the tweet was actually posted 2026-04-04 12:29:54 -04:00
bd0f5d52c2 fixing auth login flow and making custom login flow 2026-04-03 19:54:18 -04:00
874fec835d some reformatting and adjusting so logged in users get moved directly to their feed 2026-04-03 19:40:17 -04:00
77 changed files with 5300 additions and 1440 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

@@ -152,15 +152,6 @@ html, body {
margin: 0 auto; margin: 0 auto;
} }
@media (max-width: 960px) {
.mx-root { grid-template-columns: 64px 1fr; }
.mx-rightbar { display: none; }
}
@media (max-width: 640px) {
.mx-root { grid-template-columns: 1fr; }
.mx-sidebar { display: none; }
}
/* ── Sidebar ── */ /* ── Sidebar ── */
.mx-sidebar { .mx-sidebar {
position: sticky; position: sticky;
@@ -312,6 +303,12 @@ html, body {
user-select: none; user-select: none;
} }
.mx-tweet-avatar--lg {
width: 56px;
height: 56px;
font-size: 1.25rem;
}
.mx-compose-body { flex: 1; } .mx-compose-body { flex: 1; }
.mx-compose-textarea, .mx-edit-textarea { .mx-compose-textarea, .mx-edit-textarea {
@@ -699,6 +696,104 @@ html, body {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* ── Comment button on tweet cards ── */
.mx-comment-btn {
text-decoration: none;
margin-left: 0.5rem;
color: var(--mx-fg2);
transition: color 0.15s, border-color 0.15s, background 0.15s, transform 0.15s;
}
.mx-comment-btn:hover {
color: var(--mx-accent);
border-color: color-mix(in oklch, var(--mx-accent) 35%, transparent);
background: color-mix(in oklch, var(--mx-accent) 10%, transparent);
transform: translateY(-1px);
}
/* Non-interactive reply count badge in detail view */
.mx-comment-count-badge {
margin-left: 0.5rem;
cursor: default;
pointer-events: none;
color: var(--mx-fg2);
}
/* ── Comments section (below tweet detail) ── */
.mx-comments-section {
border-top: 1px solid var(--mx-border);
margin-top: 0.5rem;
padding: 0 1.5rem 1.5rem;
}
.mx-comments-divider {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 0 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mx-fg2);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mx-comments-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--mx-border);
}
.mx-comments-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
/* Comment card — slightly indented, more compact */
.mx-comment {
padding: 0.75rem 1rem;
border-radius: var(--mx-radius-sm);
}
/* Small avatar variant for comments and compose-comment */
.mx-tweet-avatar--sm {
width: 28px;
height: 28px;
min-width: 28px;
font-size: 0.75rem;
}
/* Compact compose box for replies */
.mx-compose--comment {
padding: 0.75rem 0;
border-bottom: 1px solid var(--mx-border);
margin-bottom: 0.25rem;
}
.mx-compose--comment .mx-compose-avatar--sm { align-self: flex-start; }
.mx-compose-textarea--sm {
min-height: 2.5rem;
padding: 0.4rem 0.6rem;
font-size: 0.9375rem;
}
.mx-btn-post--sm {
padding: 0.35rem 0.875rem;
font-size: 0.8125rem;
}
/* Small empty state */
.mx-empty--sm {
padding: 1.5rem 0.5rem;
}
/* Small sign-in CTA */
.mx-signin-cta--sm {
padding: 0.75rem 0;
font-size: 0.875rem;
color: var(--mx-muted);
}
/* ── Clickable media thumb (used in detail view) ── */ /* ── Clickable media thumb (used in detail view) ── */
.mx-media-thumb { .mx-media-thumb {
background: none; background: none;
@@ -802,3 +897,330 @@ html, body {
background: var(--mx-border); background: var(--mx-border);
margin: 4px 0; margin: 4px 0;
} }
/* ─────────────────────────────────────────────────────────────────────────────
Mobile bottom navigation bar
Only shown at ≤640 px (sidebar is hidden at that breakpoint).
───────────────────────────────────────────────────────────────────────────── */
.mx-mobile-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
height: 60px;
align-items: center;
justify-content: space-around;
background: color-mix(in oklch, var(--mx-bg) 92%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid var(--mx-border);
/* respect iPhone home indicator */
padding-bottom: env(safe-area-inset-bottom, 0px);
}
@media (max-width: 960px) {
.mx-mobile-nav { display: flex; }
}
.mx-mobile-nav-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--mx-muted);
text-decoration: none;
font-size: 0.65rem;
font-weight: 500;
padding: 0.25rem 0;
transition: color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.mx-mobile-nav-item--active { color: var(--mx-accent); }
.mx-mobile-nav-item svg {
flex-shrink: 0;
}
/* Centred compose button — raised pill */
.mx-mobile-nav-compose {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--mx-accent);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 16px color-mix(in oklch, var(--mx-accent) 55%, transparent);
transition: background 0.15s, transform 0.12s, box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
}
.mx-mobile-nav-compose:hover {
background: var(--mx-accent2);
box-shadow: 0 6px 20px color-mix(in oklch, var(--mx-accent) 65%, transparent);
transform: scale(1.06);
}
.mx-mobile-nav-compose:active { transform: scale(0.94); }
/* ─────────────────────────────────────────────────────────────────────────────
Mobile compose overlay (full-screen drafting page)
Hidden on desktop — only the mobile nav can trigger it.
───────────────────────────────────────────────────────────────────────────── */
@keyframes mx-overlay-in {
from { opacity: 0; transform: translateY(28px); }
to { opacity: 1; transform: translateY(0); }
}
.mx-compose-overlay {
display: none;
}
@media (max-width: 960px) {
.mx-compose-overlay {
display: flex;
flex-direction: column;
position: fixed;
inset: 0;
z-index: 100;
background: var(--mx-bg);
animation: mx-overlay-in 0.22s cubic-bezier(0.34, 1.1, 0.64, 1);
}
}
.mx-compose-overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--mx-border);
background: color-mix(in oklch, var(--mx-bg) 85%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 1;
}
.mx-compose-overlay-title {
font-family: 'Instrument Serif', Georgia, serif;
font-size: 1.125rem;
font-style: italic;
letter-spacing: -0.01em;
color: var(--mx-fg);
}
.mx-compose-overlay-cancel {
background: none;
border: none;
color: var(--mx-fg2);
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
padding: 0.25rem 0;
min-width: 60px;
transition: color 0.15s;
}
.mx-compose-overlay-cancel:hover { color: var(--mx-fg); }
.mx-compose-overlay-body {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
/* ───────────────────────────────────────────────────────────────────────────────
Responsive layout overrides
IMPORTANT: these rules must live AFTER all component base rules so that
the cascade works correctly (later rule of equal specificity wins).
─────────────────────────────────────────────────────────────────────────────── */
/* Tablet + mobile (≤ 960 px): single column, no side panels, bottom nav */
@media (max-width: 960px) {
.mx-root { grid-template-columns: 1fr; }
.mx-sidebar { display: none; }
.mx-rightbar { display: none; }
/* room for fixed bottom nav */
.mx-main { padding-bottom: 72px; }
/* hide inline compose — the overlay FAB handles it */
.mx-compose-wrapper { display: none; }
}
/* ── Avatar image ── */
.mx-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
display: block;
}
/* ── Tweet sub-handle (@username) ── */
.mx-tweet-subhandle {
font-size: 0.78rem;
color: var(--mx-muted);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
/* ── Profile editor ── */
.mx-profile-editor {
padding: 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 480px;
}
.mx-profile-avatar-section {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.mx-profile-avatar-wrap {
position: relative;
width: 80px;
height: 80px;
}
.mx-profile-avatar-img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
display: block;
border: 2px solid var(--mx-border2);
}
.mx-profile-avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--mx-accent) 0%, var(--mx-accent2) 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
color: white;
user-select: none;
}
.mx-profile-avatar-edit-btn {
position: absolute;
bottom: 0;
right: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--mx-accent);
border: 2px solid var(--mx-bg);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.mx-profile-avatar-edit-btn:hover { background: var(--mx-accent2); }
.mx-profile-avatar-edit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.mx-profile-stats {
display: flex;
gap: 1.25rem;
font-size: 0.875rem;
color: var(--mx-muted);
}
.mx-profile-stats strong { color: var(--mx-fg); }
.mx-profile-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.mx-profile-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--mx-fg2);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mx-profile-input {
background: var(--mx-surface);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
padding: 0.5rem 0.75rem;
color: var(--mx-fg);
font-family: inherit;
font-size: 0.9375rem;
width: 100%;
transition: border-color 0.15s;
outline: none;
}
.mx-profile-input:focus { border-color: var(--mx-accent); }
.mx-profile-input--readonly { color: var(--mx-muted); cursor: not-allowed; }
.mx-profile-input-wrap {
display: flex;
align-items: center;
background: var(--mx-surface);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
padding: 0 0.75rem;
transition: border-color 0.15s;
}
.mx-profile-input-wrap:focus-within { border-color: var(--mx-accent); }
.mx-profile-at {
color: var(--mx-muted);
font-size: 0.9375rem;
pointer-events: none;
user-select: none;
}
.mx-profile-input--handle {
border: none;
border-radius: 0;
padding-left: 0.25rem;
background: transparent;
}
.mx-profile-input--handle:focus { border-color: transparent; }
.mx-profile-hint {
font-size: 0.72rem;
color: var(--mx-muted);
margin-top: 0.125rem;
}
/* Narrow phones (≤ 640 px): tighten spacing */
@media (max-width: 640px) {
.mx-feed { padding: 0.625rem; gap: 0.5rem; }
.mx-tweet { padding: 0.875rem; }
.mx-tweet-handle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
.mx-header { padding: 0.75rem 1rem; }
.mx-detail { padding: 0.875rem 1rem; }
/* 5-item nav: slightly smaller labels so nothing wraps */
.mx-mobile-nav-item { font-size: 0.6rem; }
}

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>[];
@@ -644,6 +721,7 @@ export async function validateReadMedia(
export type CreateTweetInput = { export type CreateTweetInput = {
content: string; content: string;
parentTweetId?: UUID | null;
mediaId?: UUID; mediaId?: UUID;
}; };
@@ -843,6 +921,73 @@ export async function validateLikeTweet(
} }
export type ReadFollowingFeedFields = UnifiedFieldSelection<tweetsResourceSchema>[];
export type InferReadFollowingFeedResult<
Fields extends ReadFollowingFeedFields,
> = Array<InferResult<tweetsResourceSchema, Fields>>;
export type ReadFollowingFeedResult<Fields extends ReadFollowingFeedFields> = | { success: true; data: InferReadFollowingFeedResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Read Tweet records
*
* @ashActionType :read
*/
export async function readFollowingFeed<Fields extends ReadFollowingFeedFields>(
config: {
tenant?: string;
fields: Fields;
filter?: tweetsFilterInput;
sort?: SortString<tweetsSortField> | SortString<tweetsSortField>[];
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ReadFollowingFeedResult<Fields>> {
const payload = {
action: "read_following_feed",
...(config.tenant !== undefined && { tenant: config.tenant }),
...(config.fields !== undefined && { fields: config.fields }),
...(config.filter && { filter: config.filter }),
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort })
};
return executeActionRpcRequest<ReadFollowingFeedResult<Fields>>(
payload,
config
);
}
/**
* Validate: Read Tweet records
*
* @ashActionType :read
* @validation true
*/
export async function validateReadFollowingFeed(
config: {
tenant?: string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "read_following_feed",
...(config.tenant !== undefined && { tenant: config.tenant })
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[]; export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];

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,16 +77,23 @@ export type mediaAttributesOnlySchema = {
// tweets Schema // tweets Schema
export type tweetsResourceSchema = { export type tweetsResourceSchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "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;
userId: UUID; userId: UUID;
insertedAt: UtcDateTimeUsec; insertedAt: UtcDateTimeUsec;
state: "posted" | "drafted"; state: "posted" | "drafted";
parentTweetId: UUID | null;
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; };
comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; };
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; }; media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
}; };
@@ -88,13 +101,14 @@ export type tweetsResourceSchema = {
export type tweetsAttributesOnlySchema = { export type tweetsAttributesOnlySchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state"; __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId";
id: UUID; id: UUID;
content: string; content: string;
likes: number; likes: number;
userId: UUID; userId: UUID;
insertedAt: UtcDateTimeUsec; insertedAt: UtcDateTimeUsec;
state: "posted" | "drafted"; state: "posted" | "drafted";
parentTweetId: UUID | null;
}; };
@@ -129,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;
@@ -251,6 +286,13 @@ export type tweetsFilterInput = {
in?: Array<"posted" | "drafted">; in?: Array<"posted" | "drafted">;
}; };
parentTweetId?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
isNil?: boolean;
};
userEmail?: { userEmail?: {
eq?: string; eq?: string;
notEq?: string; notEq?: string;
@@ -258,6 +300,38 @@ 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?: {
eq?: number;
notEq?: number;
greaterThan?: number;
greaterThanOrEqual?: number;
lessThan?: number;
lessThanOrEqual?: number;
in?: Array<number>;
isNil?: boolean;
};
likedByMe?: { likedByMe?: {
eq?: boolean; eq?: boolean;
notEq?: boolean; notEq?: boolean;
@@ -266,6 +340,10 @@ export type tweetsFilterInput = {
user?: usersFilterInput; user?: usersFilterInput;
parentTweet?: tweetsFilterInput;
comments?: tweetsFilterInput;
media?: mediaFilterInput; media?: mediaFilterInput;
}; };
@@ -274,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", "userEmail", "likedByMe", "user", "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", "userEmail", "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]
@@ -126,7 +126,17 @@ config :esbuild,
args: args:
~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm), ~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")} env: %{
"NODE_PATH" =>
Enum.join(
[
Path.expand("../deps", __DIR__),
Path.expand(Mix.Project.build_path()),
Path.expand("../_build/dev", __DIR__)
],
":"
)
}
] ]
# Configure tailwind (the version is required) # Configure tailwind (the version is required)
@@ -148,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

@@ -1,6 +1,19 @@
defmodule Mixer.Accounts do defmodule Mixer.Accounts do
use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain] use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain]
typescript_rpc do
resource Mixer.Accounts.User do
rpc_action :read_user, :read
rpc_action :update_profile, :update_profile
end
resource Mixer.Accounts.Follow do
rpc_action :read_follow, :read
rpc_action :follow_user, :follow
rpc_action :unfollow_user, :unfollow
end
end
admin do admin do
show? true show? true
end end
@@ -12,15 +25,4 @@ defmodule Mixer.Accounts do
resource Mixer.Accounts.Follow resource Mixer.Accounts.Follow
end end
typescript_rpc do
resource Mixer.Accounts.User do
rpc_action :read_user, :read
end
resource Mixer.Accounts.Follow do
rpc_action :read_follow, :read
rpc_action :follow_user, :follow
rpc_action :unfollow_user, :unfollow
end
end
end end

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

@@ -1,5 +1,6 @@
defmodule Mixer.Accounts.Follow do defmodule Mixer.Accounts.Follow do
require Ash.Query require Ash.Query
use Ash.Resource, use Ash.Resource,
domain: Mixer.Accounts, domain: Mixer.Accounts,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
@@ -20,25 +21,6 @@ defmodule Mixer.Accounts.Follow do
type_name "follows" type_name "follows"
end end
attributes do
uuid_primary_key :id
create_timestamp :created_at
end
relationships do
belongs_to :follower, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
belongs_to :following, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
end
actions do actions do
defaults [:read, :destroy] defaults [:read, :destroy]
@@ -48,11 +30,12 @@ defmodule Mixer.Accounts.Follow do
upsert_identity :unique_follow upsert_identity :unique_follow
accept [:following_id] accept [:following_id]
change relate_actor(:follower) change relate_actor(:follower)
validate fn changeset, _context ->
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id) validate fn changeset, context ->
actor_id = context.actor && context.actor.id
following_id = Ash.Changeset.get_attribute(changeset, :following_id) 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
@@ -82,10 +65,6 @@ defmodule Mixer.Accounts.Follow do
end end
end end
identities do
identity :unique_follow, [:follower_id, :following_id]
end
policies do policies do
policy action_type(:read) do policy action_type(:read) do
authorize_if always() authorize_if always()
@@ -99,4 +78,27 @@ defmodule Mixer.Accounts.Follow do
authorize_if actor_present() authorize_if actor_present()
end end
end end
attributes do
uuid_primary_key :id
create_timestamp :created_at
end
relationships do
belongs_to :follower, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
belongs_to :following, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
end
identities do
identity :unique_follow, [:follower_id, :following_id]
end
end end

View File

@@ -37,6 +37,7 @@ defmodule Mixer.Accounts.User do
password :password do password :password do
identity_field :email identity_field :email
hash_provider AshAuthentication.BcryptProvider hash_provider AshAuthentication.BcryptProvider
require_confirmed_with :confirmed_at
resettable do resettable do
sender Mixer.Accounts.User.Senders.SendPasswordResetEmail sender Mixer.Accounts.User.Senders.SendPasswordResetEmail
@@ -176,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
@@ -210,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
@@ -255,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]
@@ -265,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
@@ -292,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
@@ -307,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
@@ -349,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

@@ -31,14 +31,21 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
defp body(params) do defp body(params) do
# NOTE: You may have to change this to match your magic link acceptance URL. # NOTE: You may have to change this to match your magic link acceptance URL.
link = url(~p"/magic_link/#{params[:token]}") link = url(~p"/magic_link/#{params[:token]}")
email_template("Your magic link", "Hello, #{params[:email]}!", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;"> email_template(
Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once. "Your magic link",
</p> "Hello, #{params[:email]}!",
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;"> """
If you didn't request this, you can safely ignore this email. <p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
</p> Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once.
""", link, "Sign In to Mixer") </p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request this, you can safely ignore this email.
</p>
""",
link,
"Sign In to Mixer"
)
end end
defp email_template(title, greeting, content, button_url, button_label) do defp email_template(title, greeting, content, button_url, button_label) do

View File

@@ -22,14 +22,21 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
defp body(params) do defp body(params) do
link = url(~p"/confirm_new_user/#{params[:token]}") link = url(~p"/confirm_new_user/#{params[:token]}")
email_template("Confirm your email", "Welcome to Mixer!", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;"> email_template(
Thanks for signing up. Just one more step — confirm your email address to activate your account. "Confirm your email",
</p> "Welcome to Mixer!",
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;"> """
If you didn't create an account on Mixer, you can safely ignore this email. <p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
</p> Thanks for signing up. Just one more step — confirm your email address to activate your account.
""", link, "Confirm Email Address") </p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't create an account on Mixer, you can safely ignore this email.
</p>
""",
link,
"Confirm Email Address"
)
end end
defp email_template(title, greeting, content, button_url, button_label) do defp email_template(title, greeting, content, button_url, button_label) do

View File

@@ -22,14 +22,21 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
defp body(params) do defp body(params) do
link = url(~p"/password-reset/#{params[:token]}") link = url(~p"/password-reset/#{params[:token]}")
email_template("Reset your password", "Password reset request", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;"> email_template(
We received a request to reset the password for your Mixer account. Click the button below to choose a new one. "Reset your password",
</p> "Password reset request",
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;"> """
If you didn't request a password reset, you can safely ignore this email — your password will not change. <p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
</p> We received a request to reset the password for your Mixer account. Click the button below to choose a new one.
""", link, "Reset My Password") </p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request a password reset, you can safely ignore this email — your password will not change.
</p>
""",
link,
"Reset My Password"
)
end end
defp email_template(title, greeting, content, button_url, button_label) do defp email_template(title, greeting, content, button_url, button_label) do

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

@@ -3,6 +3,22 @@ defmodule Mixer.Posts do
otp_app: :mixer, otp_app: :mixer,
extensions: [AshTypescript.Rpc, AshAdmin.Domain] extensions: [AshTypescript.Rpc, AshAdmin.Domain]
typescript_rpc do
resource Mixer.Posts.Tweet do
rpc_action :create_tweet, :create
rpc_action :like_tweet, :like
rpc_action :read_tweet, :read
rpc_action :read_following_feed, :following_feed
rpc_action :unlike_tweet, :unlike
rpc_action :update_tweet, :update
rpc_action :destroy_tweet, :destroy
end
resource Mixer.Posts.Media do
rpc_action :read_media, :read
end
end
admin do admin do
show? true show? true
end end
@@ -12,19 +28,4 @@ defmodule Mixer.Posts do
resource Mixer.Posts.TweetLike resource Mixer.Posts.TweetLike
resource Mixer.Posts.Media resource Mixer.Posts.Media
end end
typescript_rpc do
resource Mixer.Posts.Tweet do
rpc_action :create_tweet, :create
rpc_action :like_tweet, :like
rpc_action :read_tweet, :read
rpc_action :unlike_tweet, :unlike
rpc_action :update_tweet, :update
rpc_action :destroy_tweet, :destroy
end
resource Mixer.Posts.Media do
rpc_action :read_media, :read
end
end
end end

View File

@@ -38,6 +38,24 @@ defmodule Mixer.Posts.Media do
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
attributes do attributes do
uuid_primary_key :id uuid_primary_key :id
@@ -64,22 +82,4 @@ defmodule Mixer.Posts.Media do
public? true public? true
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
end end

View File

@@ -10,7 +10,8 @@ defmodule Mixer.Posts.MediaUploader do
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"} if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
end end
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.user_id}/#{scope.media_id}" def storage_dir(_version, {_file, scope}),
do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
def filename(_version, {file, _scope}) do def filename(_version, {file, _scope}) do
Path.basename(file.file_name, Path.extname(file.file_name)) Path.basename(file.file_name, Path.extname(file.file_name))

View File

@@ -12,10 +12,10 @@ defmodule Mixer.Posts.Tweet do
postgres do postgres do
table "tweets" table "tweets"
repo Mixer.Repo repo Mixer.Repo
end
typescript do references do
type_name "tweets" reference :parent_tweet, on_delete: :delete
end
end end
state_machine do state_machine do
@@ -27,15 +27,27 @@ defmodule Mixer.Posts.Tweet do
end end
end end
typescript do
type_name "tweets"
end
actions do actions do
defaults [:read, :destroy] defaults [:read]
read :following_feed do
filter expr(
user_id == ^actor(:id) or
exists(user.followers, follower_id == ^actor(:id))
)
end
create :create do create :create do
upsert? true upsert? true
accept [:content] accept [:content, :parent_tweet_id]
argument :media_id, :uuid, allow_nil?: true argument :media_id, :uuid, allow_nil?: true
change relate_actor(:user) change relate_actor(:user)
change transition_state(:posted) change transition_state(:posted)
change fn changeset, context -> change fn changeset, context ->
case Ash.Changeset.get_argument(changeset, :media_id) do case Ash.Changeset.get_argument(changeset, :media_id) do
nil -> nil ->
@@ -45,13 +57,58 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet -> Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
Mixer.Posts.Media Mixer.Posts.Media
|> Ash.get!(media_id, authorize?: false) |> Ash.get!(media_id, authorize?: false)
|> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id}, actor: context.actor) |> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id},
actor: context.actor
)
|> Ash.update!() |> Ash.update!()
{:ok, tweet} {:ok, tweet}
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
@@ -66,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}
@@ -86,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}
@@ -107,7 +166,34 @@ 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
policies do
policy action_type(:read) do
authorize_if always()
end
policy action_type(:create) do
authorize_if actor_present()
end
policy action(:update) do
authorize_if relates_to_actor_via(:user)
end
policy action(:destroy) do
authorize_if relates_to_actor_via(:user)
authorize_if relates_to_actor_via([:parent_tweet, :user])
end
policy action(:like) do
authorize_if actor_present()
end
policy action(:unlike) do
authorize_if actor_present()
end end
end end
@@ -145,6 +231,18 @@ defmodule Mixer.Posts.Tweet do
public? true public? true
end end
belongs_to :parent_tweet, Mixer.Posts.Tweet do
attribute_type :uuid
attribute_writable? true
allow_nil? true
public? true
end
has_many :comments, Mixer.Posts.Tweet do
destination_attribute :parent_tweet_id
public? true
end
has_many :media, Mixer.Posts.Media do has_many :media, Mixer.Posts.Media do
public? true public? true
end end
@@ -156,41 +254,31 @@ 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
count :comment_count, :comments do
public? true
end
exists :liked_by_me, :tweet_likes do exists :liked_by_me, :tweet_likes do
public? true public? true
filter expr(user_id == ^actor(:id)) filter expr(user_id == ^actor(:id))
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action_type(:create) do
authorize_if actor_present()
end
policy action(:update) do
authorize_if relates_to_actor_via(:user)
end
policy action(:destroy) do
authorize_if relates_to_actor_via(:user)
end
policy action(:like) do
authorize_if actor_present()
end
policy action(:unlike) do
authorize_if actor_present()
end
end
defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])} defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
defp ensure_like(tweet, actor) do defp ensure_like(tweet, actor) do

View File

@@ -23,6 +23,20 @@ defmodule Mixer.Posts.TweetLike do
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:create) do
authorize_if actor_present()
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
attributes do attributes do
uuid_primary_key :id uuid_primary_key :id
@@ -52,18 +66,4 @@ defmodule Mixer.Posts.TweetLike do
identities do identities do
identity :unique_user_tweet, [:tweet_id, :user_id] identity :unique_user_tweet, [:tweet_id, :user_id]
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:create) do
authorize_if actor_present()
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
end end

View File

@@ -9,12 +9,15 @@ defmodule MixerWeb.AuthOverrides do
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html # For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
# override AshAuthentication.Phoenix.Components.Banner do override AshAuthentication.Phoenix.Components.Banner do
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif" set :image_url, nil
# set :text_class, "bg-red-500" set :dark_image_url, nil
# end set :text, "⬡ Mixer"
set :text_class, "text-3xl font-bold tracking-tight"
end
# override AshAuthentication.Phoenix.Components.SignIn do # Inject the username field into the password registration form
# set :show_banner, false override AshAuthentication.Phoenix.Components.Password do
# end 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

@@ -4,8 +4,23 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Mixer" suffix=" · Phoenix Framework"> <link rel="icon" href={~p"/favicon.ico"} sizes="any" />
{assigns[:page_title]} <% meta_title = assigns[:page_title] || "Mixer"
meta_description =
assigns[:page_description] ||
"Mixer is a social feed for all. Come join the conversation — built with Elixir." %>
<meta name="description" content={meta_description} />
<meta name="robots" content={assigns[:robots] || "index, follow"} />
<meta property="og:site_name" content="Mixer" />
<meta property="og:title" content={meta_title} />
<meta property="og:description" content={meta_description} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={meta_title} />
<meta name="twitter:description" content={meta_description} />
<.live_title suffix=" · Mixer">
{assigns[:page_title] || "Mixer"}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="module" src={~p"/assets/app.js"}> <script defer phx-track-static type="module" src={~p"/assets/app.js"}>

View File

@@ -8,7 +8,37 @@ SPDX-License-Identifier: MIT
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="AshTypescript">Page</.live_title> <link rel="icon" href={~p"/favicon.ico"} sizes="any" />
<% {spa_title, spa_description} =
case @page do
"feed" -> {"Mixer · Feed", "See the latest posts from everyone on Mixer."}
"tweet" -> {"Mixer · Post", "Read this post and join the conversation on Mixer."}
"following" -> {"Mixer · Following", "Posts from the people you follow on Mixer."}
"profile" -> {"Mixer · My Profile", "View and manage your Mixer profile."}
"users" -> {"Mixer · People", "Discover and follow people on Mixer."}
"user-detail" -> {"Mixer · Profile", "View this user's profile and posts on Mixer."}
_ -> {"Mixer", "A social feed built in Elixir."}
end %>
<meta name="description" content={spa_description} />
<meta name="robots" content="index, follow" />
<meta property="og:site_name" content="Mixer" />
<meta property="og:title" content={spa_title} />
<meta property="og:description" content={spa_description} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={spa_title} />
<meta name="twitter:description" content={spa_description} />
<.live_title default="Mixer">
{case @page do
"feed" -> "Mixer · Feed"
"tweet" -> "Mixer · Post"
"following" -> "Mixer · Following"
"profile" -> "Mixer · My Profile"
"users" -> "Mixer · People"
"user-detail" -> "Mixer · Profile"
_ -> "Mixer"
end}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
</head> </head>
<body> <body>

View File

@@ -35,8 +35,11 @@ defmodule MixerWeb.AuthController do
You can confirm your account using the link we sent to you, or by resetting your password. You can confirm your account using the link we sent to you, or by resetting your password.
""" """
{_, %AshAuthentication.Errors.UnconfirmedUser{}} ->
"You must confirm your email address before signing in. Please check your inbox for a confirmation email."
_ -> _ ->
"Incorrect email or password" "Incorrect email or password or unconfirmed email"
end end
conn conn

View File

@@ -2,7 +2,13 @@ defmodule MixerWeb.PageController do
use MixerWeb, :controller use MixerWeb, :controller
def home(conn, _params) do def home(conn, _params) do
render(conn, :home) if conn.assigns[:current_user] do
redirect(conn, to: ~p"/feed")
else
conn
|> assign(:page_title, "Mixer")
|> render(:home)
end
end end
def index(conn, _params) do def index(conn, _params) do
@@ -10,9 +16,19 @@ 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
def following(conn, _params) do
render_spa(conn, %{page: "following", tweet_id: nil, user_id: nil})
end
def profile(conn, _params) do
render_spa(conn, %{page: "profile", tweet_id: nil, user_id: nil})
end
def users_index(conn, _params) do def users_index(conn, _params) do
render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil}) render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil})
end end
@@ -28,11 +44,11 @@ defmodule MixerWeb.PageController do
conn conn
|> put_root_layout(html: {MixerWeb.Layouts, :spa_root}) |> put_root_layout(html: {MixerWeb.Layouts, :spa_root})
|> render(:index, |> render(:index,
current_user: conn.assigns[:current_user], current_user: conn.assigns[:current_user],
media_host: "#{asset_host}/#{bucket}", media_host: "#{asset_host}/#{bucket}",
page: page, page: page,
tweet_id: tweet_id, tweet_id: tweet_id,
user_id: user_id user_id: user_id
) )
end end
end end

View File

@@ -5,7 +5,7 @@
<p class="text-base-content/60 text-lg mb-10">A social feed built with Ash &amp; Phoenix.</p> <p class="text-base-content/60 text-lg mb-10">A social feed built with Ash &amp; Phoenix.</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center"> <div class="flex flex-col sm:flex-row gap-3 justify-center">
<a href="/register" class="btn btn-primary btn-lg rounded-full px-8">Create account</a> <a href="/register" class="btn btn-primary btn-lg rounded-full px-8">Create account</a>
<a href="/auth/sign-in" class="btn btn-ghost btn-lg rounded-full px-8">Sign in</a> <a href="/sign-in" class="btn btn-ghost btn-lg rounded-full px-8">Sign in</a>
</div> </div>
<p class="mt-8 text-sm text-base-content/40"> <p class="mt-8 text-sm text-base-content/40">
Already have an account? Already have an account?

View File

@@ -1,8 +1,15 @@
<div id="app" <div
data-current-user-id={if @current_user, do: @current_user.id, else: ""} id="app"
data-current-user-email={if @current_user, do: @current_user.email, else: ""} data-current-user-id={if @current_user, do: @current_user.id, else: ""}
data-asset-host={@media_host} data-current-user-email={if @current_user, do: @current_user.email, else: ""}
data-page={@page} data-current-user-username={if @current_user, do: @current_user.username || "", else: ""}
data-tweet-id={@tweet_id || ""} data-current-user-display-name={
data-user-id={@user_id || ""}> if @current_user, do: @current_user.display_name || "", else: ""
}
data-current-user-avatar-url={if @current_user, do: @current_user.avatar_url || "", else: ""}
data-asset-host={@media_host}
data-page={@page}
data-tweet-id={@tweet_id || ""}
data-user-id={@user_id || ""}
>
</div> </div>

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

@@ -40,11 +40,14 @@ defmodule MixerWeb.Router do
get "/", PageController, :home get "/", PageController, :home
get "/feed", PageController, :index get "/feed", PageController, :index
get "/feed/:tweet_id", PageController, :show get "/feed/:tweet_id", PageController, :show
get "/following", PageController, :following
get "/profile", PageController, :profile
get "/users", PageController, :users_index get "/users", PageController, :users_index
get "/users/:user_id", PageController, :user_show get "/users/:user_id", PageController, :user_show
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
@@ -72,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]
) )

16
mix.exs
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
@@ -133,18 +134,21 @@ defmodule Mixer.MixProject do
build: [ build: [
"ash-framework": [ "ash-framework": [
# The description tells people how to use this skill. # The description tells people how to use this skill.
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.", description:
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
# Include all Ash dependencies # Include all Ash dependencies
usage_rules: [:ash, ~r/^ash_/] usage_rules: [:ash, ~r/^ash_/]
], ],
"phoenix-framework": [ "phoenix-framework": [
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.", description:
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
# Include all Phoenix dependencies # Include all Phoenix dependencies
usage_rules: [:phoenix, ~r/^phoenix_/] usage_rules: [:phoenix, ~r/^phoenix_/]
] ]
] ]
] ]
] ]
[ [
file: "AGENTS.md", file: "AGENTS.md",
usage_rules: ["usage_rules:all"], usage_rules: ["usage_rules:all"],
@@ -152,11 +156,13 @@ defmodule Mixer.MixProject do
location: ".agents/skills", location: ".agents/skills",
build: [ build: [
"ash-framework": [ "ash-framework": [
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.", description:
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
usage_rules: [:ash, ~r/^ash_/] usage_rules: [:ash, ~r/^ash_/]
], ],
"phoenix-framework": [ "phoenix-framework": [
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.", description:
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
usage_rules: [:phoenix, ~r/^phoenix_/] usage_rules: [:phoenix, ~r/^phoenix_/]
] ]
] ]

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

@@ -18,7 +18,8 @@ defmodule Mixer.Repo.Migrations.SetupPostsAndTweets do
name: "tweets_user_id_fkey", name: "tweets_user_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
add :state, :text, null: false, default: "drafted" add :state, :text, null: false, default: "drafted"
end end

View File

@@ -22,7 +22,8 @@ defmodule Mixer.Repo.Migrations.AddPostsMediaS3 do
name: "media_tweet_id_fkey", name: "media_tweet_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
end end
end end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddUserIdToMediaAndAllowNullTweetId do
name: "media_user_id_fkey", name: "media_user_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
end end
end end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
name: "tweet_likes_tweet_id_fkey", name: "tweet_likes_tweet_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
add :user_id, add :user_id,
references(:users, references(:users,
@@ -25,7 +26,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
name: "tweet_likes_user_id_fkey", name: "tweet_likes_user_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
end end
create unique_index(:tweet_likes, [:tweet_id, :user_id], create unique_index(:tweet_likes, [:tweet_id, :user_id],

View File

@@ -0,0 +1,30 @@
defmodule Mixer.Repo.Migrations.AddTweetComments 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(:tweets) do
add :parent_tweet_id,
references(:tweets,
column: :id,
name: "tweets_parent_tweet_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
end
end
def down do
drop constraint(:tweets, "tweets_parent_tweet_id_fkey")
alter table(:tweets) do
remove :parent_tweet_id
end
end
end

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,154 @@
{
"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": "content",
"type": "text"
},
{
"allow_nil?": false,
"default": "0",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "likes",
"type": "bigint"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "tweets_user_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "users"
},
"scale": null,
"size": null,
"source": "user_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "\"drafted\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "state",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "tweets_parent_tweet_id_fkey",
"on_delete": "delete",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "tweets"
},
"scale": null,
"size": null,
"source": "parent_tweet_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "090E928120B2CFAA2B8D5D2EB43AD6E782ABB552AFC211BB6173D6337F487218",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mixer.Repo",
"schema": null,
"table": "tweets"
}

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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 15 KiB

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

@@ -1,8 +1,32 @@
defmodule MixerWeb.PageControllerTest do defmodule MixerWeb.PageControllerTest do
use MixerWeb.ConnCase use MixerWeb.ConnCase
test "GET /", %{conn: conn} do test "GET / redirects to /feed when logged in", %{conn: conn} do
user =
Mixer.Accounts.User
|> Ash.Changeset.for_create(
:register_with_password,
%{
email: "test@example.com",
password: "Password1!",
password_confirmation: "Password1!",
username: "testuser"
},
authorize?: false
)
|> Ash.create!()
conn =
conn
|> Plug.Test.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|> get(~p"/")
assert redirected_to(conn) == ~p"/feed"
end
test "GET / renders the home page for unauthenticated users", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production" assert html_response(conn, 200) =~ "Mixer"
end end
end end