Skip to content
12 changes: 12 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,17 @@ To be released.
are removed; UnoCSS emits a single _src/public/uno.css_ whose
URL is cache-busted by file mtime.

- Added public reaction list pages anchored to each local post:
`/@:handle/:id/likes` lists the accounts that liked the post,
`/@:handle/:id/shares` lists the accounts that boosted it,
`/@:handle/:id/reactions/:emoji` lists the accounts that reacted with
a specific emoji, and `/@:handle/:id/quotes` lists the posts that
quote it. Each page features the original post above the list. On
the profile feed and post permalink page, the per-post like, share,
quote, and reaction-emoji indicators now link into these pages for
local posts; remote posts continue to display the counts as plain
text. [[#490]]

- Added avatar and header image upload to the admin account creation and
editing forms, with drag-and-drop support and in-page image preview.
Files are stored using the same storage backend as the Mastodon-compatible
Expand Down Expand Up @@ -371,6 +382,7 @@ To be released.
[#487]: https://github.com/fedify-dev/hollo/pull/487
[#488]: https://github.com/fedify-dev/hollo/issues/488
[#489]: https://github.com/fedify-dev/hollo/issues/489
[#490]: https://github.com/fedify-dev/hollo/pull/490


Version 0.8.4
Expand Down
181 changes: 127 additions & 54 deletions src/components/Post.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,48 +4,53 @@ import { proxyUrl } from "../media-proxy";
import type { PreviewCard } from "../previewcard";
import type {
Account,
AccountOwner,
Medium as DbMedium,
Poll as DbPoll,
Post as DbPost,
PollOption,
Reaction,
} from "../schema";

export type PostAccount = Account & { owner?: AccountOwner | null };

export type PostForView = DbPost & {
account: PostAccount;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
sharing:
| (DbPost & {
account: PostAccount;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
replyTarget: (DbPost & { account: PostAccount }) | null;
quoteTarget:
| (DbPost & {
account: PostAccount;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
replyTarget: (DbPost & { account: PostAccount }) | null;
reactions: Reaction[];
})
| null;
reactions: Reaction[];
})
| null;
replyTarget: (DbPost & { account: PostAccount }) | null;
quoteTarget:
| (DbPost & {
account: PostAccount;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
replyTarget: (DbPost & { account: PostAccount }) | null;
reactions: Reaction[];
})
| null;
reactions: Reaction[];
};

export interface PostProps {
readonly post: DbPost & {
account: Account;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
sharing:
| (DbPost & {
account: Account;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
replyTarget: (DbPost & { account: Account }) | null;
quoteTarget:
| (DbPost & {
account: Account;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
replyTarget: (DbPost & { account: Account }) | null;
reactions: Reaction[];
})
| null;
reactions: Reaction[];
})
| null;
replyTarget: (DbPost & { account: Account }) | null;
quoteTarget:
| (DbPost & {
account: Account;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
replyTarget: (DbPost & { account: Account }) | null;
reactions: Reaction[];
})
| null;
reactions: Reaction[];
};
readonly post: PostForView;
readonly shared?: Date;
readonly pinned?: boolean;
readonly quoted?: boolean;
Expand Down Expand Up @@ -85,6 +90,8 @@ export function Post({
);
const authorUrl = account.url ?? account.iri;
const avatar = proxyUrl(account.avatarUrl, baseUrl);
const localPermalink =
account.owner == null ? null : `/@${account.owner.handle}/${post.id}`;
const wrapperClass = quoted
? "rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-900/60"
: featured
Expand Down Expand Up @@ -188,37 +195,68 @@ export function Post({
{post.likesCount != null && post.likesCount > 0 && (
<>
<span aria-hidden="true">·</span>
<span class="inline-flex items-center gap-1">
<span class="i-lucide-heart" aria-hidden="true" />
{numberFormatter.format(post.likesCount)}
</span>
<CountLink
localPermalink={localPermalink}
path="/likes"
icon="i-lucide-heart"
count={post.likesCount}
label="Liked by"
Comment thread
dahlia marked this conversation as resolved.
/>
</>
)}
{post.sharesCount != null && post.sharesCount > 0 && (
<>
<span aria-hidden="true">·</span>
<span class="inline-flex items-center gap-1">
<span class="i-lucide-repeat-2" aria-hidden="true" />
{numberFormatter.format(post.sharesCount)}
</span>
<CountLink
localPermalink={localPermalink}
path="/shares"
icon="i-lucide-repeat-2"
count={post.sharesCount}
label="Shared by"
Comment thread
dahlia marked this conversation as resolved.
/>
</>
)}
{post.quotesCount != null && post.quotesCount > 0 && (
<>
<span aria-hidden="true">·</span>
<CountLink
localPermalink={localPermalink}
path="/quotes"
icon="i-lucide-quote"
count={post.quotesCount}
label="Quoted by"
Comment thread
dahlia marked this conversation as resolved.
/>
</>
)}
{post.reactions.length > 0 && (
<>
<span aria-hidden="true">·</span>
<span class="inline-flex flex-wrap items-center gap-1">
{Object.entries(groupByEmojis(post.reactions, baseUrl)).map(
([emoji, { src, count }]) =>
src == null ? (
<span title={`${emoji} × ${count}`}>{emoji}</span>
([emoji, { src, count }]) => {
const inner =
src == null ? (
<span>{emoji}</span>
) : (
<img
src={src}
alt={emoji}
class="inline h-4 align-text-bottom"
/>
);
const title = `${emoji} × ${count}`;
return localPermalink == null ? (
<span title={title}>{inner}</span>
) : (
<img
src={src}
alt={emoji}
title={`${emoji} × ${count}`}
class="inline h-4 align-text-bottom"
/>
),
<a
href={`${localPermalink}/reactions/${encodeURIComponent(emoji)}`}
title={title}
class="hover:text-brand-700 dark:hover:text-brand-400"
>
{inner}
</a>
);
},
)}
</span>
</>
Expand All @@ -228,6 +266,41 @@ export function Post({
);
}

interface CountLinkProps {
readonly localPermalink: string | null;
readonly path: string;
readonly icon: string;
readonly count: number;
readonly label: string;
}

function CountLink({
localPermalink,
path,
icon,
count,
label,
}: CountLinkProps) {
const inner = (
<>
<span class={icon} aria-hidden="true" />
{numberFormatter.format(count)}
</>
);
if (localPermalink == null) {
return <span class="inline-flex items-center gap-1">{inner}</span>;
}
return (
<a
href={`${localPermalink}${path}`}
title={`${label} ${numberFormatter.format(count)}`}
class="inline-flex items-center gap-1 hover:text-brand-700 dark:hover:text-brand-400"
>
{inner}
</a>
);
}

function groupByEmojis(
reactions: Reaction[],
baseUrl: URL | string,
Expand Down Expand Up @@ -255,10 +328,10 @@ interface PostContentProps {
poll: (DbPoll & { options: PollOption[] }) | null;
quoteTarget:
| (DbPost & {
account: Account;
account: PostAccount;
media: DbMedium[];
poll: (DbPoll & { options: PollOption[] }) | null;
replyTarget: (DbPost & { account: Account }) | null;
replyTarget: (DbPost & { account: PostAccount }) | null;
reactions: Reaction[];
})
| null;
Expand Down
79 changes: 79 additions & 0 deletions src/components/PublicAccountList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { escape } from "es-toolkit";

import { renderCustomEmojis } from "../custom-emoji";
import { proxyUrl } from "../media-proxy";
import type { Account } from "../schema";
import { sanitizeHtml } from "../xss";

export interface PublicAccountListProps {
readonly accounts: readonly Account[];
readonly baseUrl: URL | string;
}

export function PublicAccountList({
accounts,
baseUrl,
}: PublicAccountListProps) {
return (
<ul class="mt-4 divide-y divide-neutral-200 dark:divide-neutral-800">
{accounts.map((account) => (
Comment thread
dahlia marked this conversation as resolved.
<li key={account.id}>
<PublicAccountItem account={account} baseUrl={baseUrl} />
</li>
))}
</ul>
);
}

interface PublicAccountItemProps {
readonly account: Account;
readonly baseUrl: URL | string;
}

function PublicAccountItem({ account, baseUrl }: PublicAccountItemProps) {
const nameHtml = renderCustomEmojis(
escape(account.name),
account.emojis,
baseUrl,
);
const bioHtml = renderCustomEmojis(
sanitizeHtml(account.bioHtml ?? ""),
account.emojis,
baseUrl,
);
const href = account.url ?? account.iri;
const avatar = proxyUrl(account.avatarUrl, baseUrl);
return (
<article class="flex items-start gap-4 py-6">
<a href={href} class="shrink-0">
{avatar ? (
<img
src={avatar}
alt=""
width={48}
height={48}
class="size-12 rounded-full object-cover"
/>
) : (
<span class="block size-12 rounded-full bg-neutral-200 dark:bg-neutral-800" />
)}
</a>
<div class="min-w-0 flex-1">
<a
href={href}
class="block font-semibold text-neutral-900 hover:underline dark:text-neutral-100"
dangerouslySetInnerHTML={{ __html: nameHtml }}
/>
<p class="select-all text-xs text-neutral-500 dark:text-neutral-400">
{account.handle}
</p>
{bioHtml && (
<div
class="prose prose-sm prose-neutral dark:prose-invert prose-a:text-brand-700 dark:prose-a:text-brand-400 mt-2 max-w-none break-words"
dangerouslySetInnerHTML={{ __html: bioHtml }}
/>
)}
</div>
</article>
);
}
Loading
Loading