Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ API server runs on http://localhost:8081

```bash
cd web
npm install
npm run dev
bun install
bun run dev
```

Dev server runs on http://localhost:4321 and proxies API requests to the backend.
Expand All @@ -70,9 +70,9 @@ Built with [WXT](https://wxt.dev):

```bash
cd extension
npm install
npm run dev # Chrome dev mode
npm run dev:firefox # Firefox dev mode
bun install
bun run dev # Chrome dev mode
bun run dev:firefox # Firefox dev mode
```

## Architecture
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/firehose/ingester.go
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@ func (i *Ingester) handlePreferences(event *FirehoseEvent) {
ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"`
SubscribedLabelers json.RawMessage `json:"subscribedLabelers"`
LabelPreferences json.RawMessage `json:"labelPreferences"`
DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"`
CreatedAt string `json:"createdAt"`
}

Expand Down Expand Up @@ -837,6 +838,7 @@ func (i *Ingester) handlePreferences(event *FirehoseEvent) {
AuthorDID: event.Repo,
ExternalLinkSkippedHostnames: skippedHostnamesPtr,
SubscribedLabelers: subscribedLabelersPtr,
DisableExternalLinkWarning: record.DisableExternalLinkWarning,
LabelPreferences: labelPrefsPtr,
CreatedAt: createdAt,
IndexedAt: time.Now(),
Expand Down
72 changes: 72 additions & 0 deletions web/bun.lock

Large diffs are not rendered by default.

23 changes: 13 additions & 10 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { atom } from "nanostores";
import type {
UserProfile,
FeedResponse,
AnnotationItem,
Collection,
FeedResponse,
HydratedLabel,
NotificationItem,
Target,
Selector,
HydratedLabel,
Target,
UserProfile,
} from "../types";

export type { Collection } from "../types";

export const sessionAtom = atom<UserProfile | null>(null);
Expand Down Expand Up @@ -113,7 +114,7 @@ async function apiRequest(
return response;
}

interface GetFeedParams {
export interface GetFeedParams {
source?: string;
type?: string;
limit?: number;
Expand Down Expand Up @@ -264,7 +265,9 @@ export async function getFeed({
});
if (!res.ok) throw new Error("Failed to fetch feed");
const data = await res.json();
const normalizedItems = (data.items || []).map(normalizeItem);
const normalizedItems: AnnotationItem[] = (data.items || []).map(
normalizeItem,
);

const groupedItems: AnnotationItem[] = [];
if (normalizedItems.length > 0) {
Expand Down Expand Up @@ -292,7 +295,6 @@ export async function getFeed({
}

return {
cursor: data.cursor,
items: groupedItems,
hasMore: normalizedItems.length >= limit,
fetchedCount: normalizedItems.length,
Expand Down Expand Up @@ -1046,10 +1048,11 @@ export async function getUserTargetItems(
return { annotations: [], highlights: [] };
}
}

import type {
LabelerInfo,
LabelerSubscription,
LabelPreference,
LabelerInfo,
} from "../types";

export interface PreferencesResponse {
Expand Down Expand Up @@ -1104,10 +1107,10 @@ export async function getLabelerInfo(): Promise<LabelerInfo | null> {
}

import type {
ModerationRelationship,
BlockedUser,
MutedUser,
ModerationRelationship,
ModerationReport,
MutedUser,
ReportReasonType,
} from "../types";

Expand Down
158 changes: 158 additions & 0 deletions web/src/components/feed/FeedItems.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { Clock, Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { type GetFeedParams, getFeed } from "../../api/client";
import Card from "../../components/common/Card";
import { EmptyState } from "../../components/ui";
import type { AnnotationItem } from "../../types";

const LIMIT = 50;

export interface FeedItemsProps extends Omit<
GetFeedParams,
"limit" | "offset"
> {
layout: "list" | "mosaic";
emptyMessage: string;
}

export default function FeedItems({
creator,
source,
tag,
type,
motivation,
emptyMessage,
layout,
}: FeedItemsProps) {
const [items, setItems] = useState<AnnotationItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);

useEffect(() => {
let cancelled = false;

getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 })
.then((data) => {
if (cancelled) return;
const fetched = data.items;
setItems(fetched);
setHasMore(data.hasMore);
setOffset(data.fetchedCount);
setLoading(false);
})
.catch((e) => {
if (cancelled) return;
console.error(e);
setItems([]);
setHasMore(false);
setLoading(false);
});

return () => {
cancelled = true;
};
}, [type, motivation, tag, creator, source]);

const loadMore = useCallback(async () => {
setLoadingMore(true);
try {
const data = await getFeed({
type,
motivation,
tag,
creator,
source,
limit: LIMIT,
offset,
});
const fetched = data?.items || [];
setItems((prev) => [...prev, ...fetched]);
setHasMore(data.hasMore);
setOffset((prev) => prev + data.fetchedCount);
} catch (e) {
console.error(e);
} finally {
setLoadingMore(false);
}
}, [type, motivation, tag, creator, source, offset]);

const handleDelete = (uri: string) => {
setItems((prev) => prev.filter((i) => i.uri !== uri));
};

if (loading) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<Loader2
className="animate-spin text-primary-600 dark:text-primary-400"
size={32}
/>
<p className="text-sm text-surface-400 dark:text-surface-500">
Loading...
</p>
</div>
);
}

if (items.length === 0) {
return (
<EmptyState
icon={<Clock size={48} />}
title="Nothing here yet"
message={emptyMessage}
/>
);
}

const loadMoreButton = hasMore && (
<div className="flex justify-center py-6">
<button
type="button"
onClick={loadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50"
>
{loadingMore ? (
<>
<Loader2 size={16} className="animate-spin" />
Loading...
</>
) : (
"Load more"
)}
</button>
</div>
);

if (layout === "mosaic") {
return (
<>
<div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in">
{items.map((item) => (
<div key={item.uri || item.cid} className="break-inside-avoid mb-4">
<Card item={item} onDelete={handleDelete} layout="mosaic" />
</div>
))}
</div>
{loadMoreButton}
</>
);
}

return (
<>
<div className="space-y-3 animate-fade-in">
{items.map((item) => (
<Card
key={item.uri || item.cid}
item={item}
onDelete={handleDelete}
/>
))}
</div>
{loadMoreButton}
</>
);
}
Loading