Adding likes to tweets

This commit is contained in:
2026-03-31 15:18:46 -04:00
parent 7e0d7d8888
commit 1c1830b086
12 changed files with 737 additions and 10 deletions

View File

@@ -474,6 +474,46 @@ html, body {
word-break: break-word;
}
.mx-tweet-footer {
display: flex;
align-items: center;
margin-top: 0.875rem;
}
.mx-like-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid var(--mx-border2);
border-radius: 999px;
background: color-mix(in oklch, var(--mx-surface2) 72%, transparent);
color: var(--mx-fg2);
cursor: pointer;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
line-height: 1;
padding: 0.45rem 0.75rem;
transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
}
.mx-like-btn:hover:not(:disabled) {
color: var(--mx-red);
border-color: color-mix(in oklch, var(--mx-red) 35%, transparent);
background: color-mix(in oklch, var(--mx-red) 10%, transparent);
transform: translateY(-1px);
}
.mx-like-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.mx-like-btn-active {
color: var(--mx-red);
border-color: color-mix(in oklch, var(--mx-red) 35%, transparent);
background: color-mix(in oklch, var(--mx-red) 12%, transparent);
}
/* ── Edit ── */
.mx-edit-area { margin-top: 0.25rem; }

View File

@@ -435,6 +435,74 @@ export async function validateDestroyTweet(
}
export type LikeTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
export type InferLikeTweetResult<
Fields extends LikeTweetFields | undefined,
> = InferResult<tweetsResourceSchema, Fields>;
export type LikeTweetResult<Fields extends LikeTweetFields | undefined = undefined> = | { success: true; data: InferLikeTweetResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Update an existing Tweet
*
* @ashActionType :update
*/
export async function likeTweet<Fields extends LikeTweetFields | undefined = undefined>(
config: {
tenant?: string;
identity: UUID;
fields?: Fields;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<LikeTweetResult<Fields extends undefined ? [] : Fields>> {
const payload = {
action: "like_tweet",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity,
...(config.fields !== undefined && { fields: config.fields })
};
return executeActionRpcRequest<LikeTweetResult<Fields extends undefined ? [] : Fields>>(
payload,
config
);
}
/**
* Validate: Update an existing Tweet
*
* @ashActionType :update
* @validation true
*/
export async function validateLikeTweet(
config: {
tenant?: string;
identity: UUID | string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "like_tweet",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
@@ -536,11 +604,76 @@ export async function validateReadTweet(
}
export type UnlikeTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
export type InferUnlikeTweetResult<
Fields extends UnlikeTweetFields | undefined,
> = InferResult<tweetsResourceSchema, Fields>;
export type UnlikeTweetResult<Fields extends UnlikeTweetFields | undefined = undefined> = | { success: true; data: InferUnlikeTweetResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Update an existing Tweet
*
* @ashActionType :update
*/
export async function unlikeTweet<Fields extends UnlikeTweetFields | undefined = undefined>(
config: {
tenant?: string;
identity: UUID;
fields?: Fields;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<UnlikeTweetResult<Fields extends undefined ? [] : Fields>> {
const payload = {
action: "unlike_tweet",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity,
...(config.fields !== undefined && { fields: config.fields })
};
return executeActionRpcRequest<UnlikeTweetResult<Fields extends undefined ? [] : Fields>>(
payload,
config
);
}
/**
* Validate: Update an existing Tweet
*
* @ashActionType :update
* @validation true
*/
export async function validateUnlikeTweet(
config: {
tenant?: string;
identity: UUID | string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "unlike_tweet",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type UpdateTweetInput = {
content?: string;
likes?: number;
userId?: UUID;
state?: "posted" | "drafted";
};
export type UpdateTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];

View File

@@ -31,12 +31,13 @@ export type mediaAttributesOnlySchema = {
// tweets Schema
export type tweetsResourceSchema = {
__type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "state";
__primitiveFields: "id" | "content" | "likes" | "userId" | "state" | "likedByMe";
id: UUID;
content: string;
likes: number;
userId: UUID;
state: "posted" | "drafted";
likedByMe: boolean;
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
};
@@ -126,6 +127,11 @@ export type tweetsFilterInput = {
in?: Array<"posted" | "drafted">;
};
likedByMe?: {
eq?: boolean;
notEq?: boolean;
isNil?: boolean;
};
media?: mediaFilterInput;
@@ -135,14 +141,14 @@ export type tweetsFilterInput = {
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
export type mediaFilterField = (typeof mediaFilterFields)[number];
export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "user", "media"] as const;
export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "likedByMe", "user", "media"] as const;
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
export type mediaSortField = (typeof mediaSortFields)[number];
export const tweetsSortFields = ["id", "content", "likes", "userId", "state"] as const;
export const tweetsSortFields = ["id", "content", "likes", "userId", "state", "likedByMe"] as const;
export type tweetsSortField = (typeof tweetsSortFields)[number];

View File

@@ -11,6 +11,8 @@ import {
createTweet,
readTweet,
destroyTweet,
likeTweet,
unlikeTweet,
updateTweet,
buildCSRFHeaders,
} from "./ash_rpc";
@@ -23,7 +25,15 @@ const queryClient = new QueryClient({
// ── Types ──────────────────────────────────────────────────────────────────────
type MediaItem = { id: string; s3Key: string };
type Tweet = { id: string; content: string; userId: string; state: string; media?: MediaItem[] };
type Tweet = {
id: string;
content: string;
likes: number;
likedByMe?: boolean;
userId: string;
state: string;
media?: MediaItem[];
};
// ── Auth context ───────────────────────────────────────────────────────────────
@@ -298,6 +308,7 @@ function TweetMedia({ media }: { media: MediaItem[] }) {
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);
@@ -335,6 +346,23 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
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"] });
setError(null);
},
onError: (e: Error) => setError(e.message),
});
function saveEdit() {
const trimmed = editText.trim();
if (!trimmed) return;
@@ -431,6 +459,26 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
<TweetMedia media={tweet.media} />
)}
<div className="mx-tweet-footer">
<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>
</div>
{error && !editing && <p className="mx-compose-error">{error}</p>}
</div>
</article>
@@ -442,7 +490,7 @@ function Feed() {
queryKey: ["tweets"],
queryFn: async () => {
const res = await readTweet({
fields: ["id", "content", "userId", "state", { media: ["id", "s3Key"] }],
fields: ["id", "content", "likes", "likedByMe", "userId", "state", { media: ["id", "s3Key"] }],
sort: "-id",
headers: buildCSRFHeaders(),
});