birdclaw 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +205 -0
- package/src/lib/db.ts +35 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +1024 -189
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +63 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
package/src/routes/index.tsx
CHANGED
|
@@ -1,138 +1,32 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { TimelineCard } from "#/components/TimelineCard";
|
|
5
|
-
import type {
|
|
6
|
-
QueryEnvelope,
|
|
7
|
-
QueryResponse,
|
|
8
|
-
ReplyFilter,
|
|
9
|
-
TimelineItem,
|
|
10
|
-
} from "#/lib/types";
|
|
11
|
-
import {
|
|
12
|
-
cx,
|
|
13
|
-
emptyStateClass,
|
|
14
|
-
feedClass,
|
|
15
|
-
pageHeaderClass,
|
|
16
|
-
pageHeaderRowClass,
|
|
17
|
-
pageSubtitleClass,
|
|
18
|
-
pageTitleClass,
|
|
19
|
-
searchFieldIconClass,
|
|
20
|
-
searchFieldInputClass,
|
|
21
|
-
searchFieldShellClass,
|
|
22
|
-
tabButtonActiveClass,
|
|
23
|
-
tabButtonClass,
|
|
24
|
-
tabButtonIndicatorClass,
|
|
25
|
-
tabStripClass,
|
|
26
|
-
} from "#/lib/ui";
|
|
2
|
+
import { TimelineRouteFrame } from "#/components/TimelineRouteFrame";
|
|
3
|
+
import type { QueryEnvelope } from "#/lib/types";
|
|
27
4
|
|
|
28
5
|
export const Route = createFileRoute("/")({
|
|
29
6
|
component: HomeRoute,
|
|
30
7
|
});
|
|
31
8
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
];
|
|
9
|
+
function homeSubtitle(meta: QueryEnvelope | null) {
|
|
10
|
+
if (!meta) return "Loading local context...";
|
|
11
|
+
return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
|
|
12
|
+
}
|
|
37
13
|
|
|
38
14
|
function HomeRoute() {
|
|
39
|
-
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
40
|
-
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
41
|
-
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("all");
|
|
42
|
-
const [search, setSearch] = useState("");
|
|
43
|
-
const [refreshTick, setRefreshTick] = useState(0);
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
fetch("/api/status")
|
|
47
|
-
.then((response) => response.json())
|
|
48
|
-
.then((data: QueryEnvelope) => setMeta(data));
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
const url = new URL("/api/query", window.location.origin);
|
|
53
|
-
url.searchParams.set("resource", "home");
|
|
54
|
-
url.searchParams.set("replyFilter", replyFilter);
|
|
55
|
-
url.searchParams.set("refresh", String(refreshTick));
|
|
56
|
-
if (search.trim()) {
|
|
57
|
-
url.searchParams.set("search", search.trim());
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
fetch(url)
|
|
61
|
-
.then((response) => response.json())
|
|
62
|
-
.then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
|
|
63
|
-
}, [refreshTick, replyFilter, search]);
|
|
64
|
-
|
|
65
|
-
const subtitle = useMemo(() => {
|
|
66
|
-
if (!meta) return "Loading local context...";
|
|
67
|
-
return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
|
|
68
|
-
}, [meta]);
|
|
69
|
-
|
|
70
|
-
async function replyToTweet(tweetId: string) {
|
|
71
|
-
const text = window.prompt("Reply text");
|
|
72
|
-
if (!text?.trim()) return;
|
|
73
|
-
|
|
74
|
-
await fetch("/api/action", {
|
|
75
|
-
method: "POST",
|
|
76
|
-
headers: { "content-type": "application/json" },
|
|
77
|
-
body: JSON.stringify({
|
|
78
|
-
kind: "replyTweet",
|
|
79
|
-
accountId: "acct_primary",
|
|
80
|
-
tweetId,
|
|
81
|
-
text,
|
|
82
|
-
}),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
setRefreshTick((value) => value + 1);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
15
|
return (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
value={search}
|
|
105
|
-
/>
|
|
106
|
-
</label>
|
|
107
|
-
</div>
|
|
108
|
-
<div className={tabStripClass}>
|
|
109
|
-
{TABS.map((tab) => {
|
|
110
|
-
const active = replyFilter === tab.value;
|
|
111
|
-
return (
|
|
112
|
-
<button
|
|
113
|
-
key={tab.value}
|
|
114
|
-
type="button"
|
|
115
|
-
aria-pressed={active}
|
|
116
|
-
className={cx(tabButtonClass, active && tabButtonActiveClass)}
|
|
117
|
-
onClick={() => setReplyFilter(tab.value)}
|
|
118
|
-
>
|
|
119
|
-
<span className="relative inline-flex flex-col items-center justify-center py-1">
|
|
120
|
-
{tab.value}
|
|
121
|
-
{active ? <span className={tabButtonIndicatorClass} /> : null}
|
|
122
|
-
</span>
|
|
123
|
-
</button>
|
|
124
|
-
);
|
|
125
|
-
})}
|
|
126
|
-
</div>
|
|
127
|
-
</header>
|
|
128
|
-
<section className={feedClass}>
|
|
129
|
-
{items.length === 0 ? (
|
|
130
|
-
<div className={emptyStateClass}>No posts to show.</div>
|
|
131
|
-
) : null}
|
|
132
|
-
{items.map((item) => (
|
|
133
|
-
<TimelineCard key={item.id} item={item} onReply={replyToTweet} />
|
|
134
|
-
))}
|
|
135
|
-
</section>
|
|
136
|
-
</>
|
|
16
|
+
<TimelineRouteFrame
|
|
17
|
+
emptyDetail="Try a different filter or sync the timeline again."
|
|
18
|
+
emptyLabel="No posts in this view"
|
|
19
|
+
errorFallback="Timeline unavailable"
|
|
20
|
+
errorTitle="Could not load posts"
|
|
21
|
+
initialReplyFilter="all"
|
|
22
|
+
loadingDetail="Reading the local timeline store"
|
|
23
|
+
loadingLabel="Loading posts"
|
|
24
|
+
resource="home"
|
|
25
|
+
searchPlaceholder="Search local timeline"
|
|
26
|
+
subtitle={homeSubtitle}
|
|
27
|
+
syncKind="timeline"
|
|
28
|
+
syncLabel="Sync timeline"
|
|
29
|
+
title="Home"
|
|
30
|
+
/>
|
|
137
31
|
);
|
|
138
32
|
}
|
package/src/routes/links.tsx
CHANGED
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
} from "lucide-react";
|
|
12
12
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
13
|
import { AvatarChip } from "#/components/AvatarChip";
|
|
14
|
+
import {
|
|
15
|
+
FeedEmpty,
|
|
16
|
+
FeedError,
|
|
17
|
+
FeedLoading,
|
|
18
|
+
LinkSkeletonRows,
|
|
19
|
+
} from "#/components/FeedState";
|
|
14
20
|
import { ProfilePreview } from "#/components/ProfilePreview";
|
|
15
21
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
16
22
|
import type {
|
|
@@ -26,7 +32,6 @@ import type {
|
|
|
26
32
|
} from "#/lib/types";
|
|
27
33
|
import {
|
|
28
34
|
cx,
|
|
29
|
-
emptyStateClass,
|
|
30
35
|
pageHeaderClass,
|
|
31
36
|
pageHeaderRowClass,
|
|
32
37
|
pageSubtitleClass,
|
|
@@ -649,6 +654,7 @@ function LinksRoute() {
|
|
|
649
654
|
const cacheRef = useRef(new Map<string, LinkInsightResponse>());
|
|
650
655
|
const inFlightRef = useRef(new Set<string>());
|
|
651
656
|
const mountedRef = useRef(true);
|
|
657
|
+
const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
|
|
652
658
|
const [, bumpCacheVersion] = useState(0);
|
|
653
659
|
const currentCacheKey = insightCacheKey(
|
|
654
660
|
kind,
|
|
@@ -658,6 +664,8 @@ function LinksRoute() {
|
|
|
658
664
|
refreshTick,
|
|
659
665
|
);
|
|
660
666
|
const data = cacheRef.current.get(currentCacheKey) ?? null;
|
|
667
|
+
const error = errorByKey[currentCacheKey] ?? null;
|
|
668
|
+
const loading = !data && !error;
|
|
661
669
|
|
|
662
670
|
useEffect(() => {
|
|
663
671
|
return () => {
|
|
@@ -672,6 +680,11 @@ function LinksRoute() {
|
|
|
672
680
|
return;
|
|
673
681
|
}
|
|
674
682
|
inFlightRef.current.add(key);
|
|
683
|
+
setErrorByKey((current) => {
|
|
684
|
+
const rest = { ...current };
|
|
685
|
+
delete rest[key];
|
|
686
|
+
return rest;
|
|
687
|
+
});
|
|
675
688
|
fetch(linkInsightsUrl(fetchKind, range, sort, source, refreshTick))
|
|
676
689
|
.then((response) => response.json())
|
|
677
690
|
.then((response: LinkInsightResponse) => {
|
|
@@ -684,7 +697,17 @@ function LinksRoute() {
|
|
|
684
697
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
685
698
|
return;
|
|
686
699
|
}
|
|
700
|
+
if (!mountedRef.current) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
687
703
|
console.warn("Link insights failed", error);
|
|
704
|
+
setErrorByKey((current) => ({
|
|
705
|
+
...current,
|
|
706
|
+
[key]:
|
|
707
|
+
error instanceof Error
|
|
708
|
+
? error.message
|
|
709
|
+
: "Link insights unavailable",
|
|
710
|
+
}));
|
|
688
711
|
})
|
|
689
712
|
.finally(() => {
|
|
690
713
|
inFlightRef.current.delete(key);
|
|
@@ -865,10 +888,32 @@ function LinksRoute() {
|
|
|
865
888
|
</header>
|
|
866
889
|
|
|
867
890
|
<section className="flex flex-col">
|
|
868
|
-
{
|
|
869
|
-
<
|
|
870
|
-
|
|
871
|
-
|
|
891
|
+
{loading ? (
|
|
892
|
+
<FeedLoading
|
|
893
|
+
detail="Ranking URLs and collecting local discussion"
|
|
894
|
+
label="Loading links"
|
|
895
|
+
>
|
|
896
|
+
<LinkSkeletonRows />
|
|
897
|
+
</FeedLoading>
|
|
898
|
+
) : error ? (
|
|
899
|
+
<FeedError
|
|
900
|
+
action={
|
|
901
|
+
<button
|
|
902
|
+
className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
|
|
903
|
+
onClick={() => setRefreshTick((value) => value + 1)}
|
|
904
|
+
type="button"
|
|
905
|
+
>
|
|
906
|
+
Retry
|
|
907
|
+
</button>
|
|
908
|
+
}
|
|
909
|
+
message={error}
|
|
910
|
+
title="Could not load links"
|
|
911
|
+
/>
|
|
912
|
+
) : items.length === 0 ? (
|
|
913
|
+
<FeedEmpty
|
|
914
|
+
detail="Try a different range, source, or search term."
|
|
915
|
+
label="No links in this window"
|
|
916
|
+
/>
|
|
872
917
|
) : null}
|
|
873
918
|
{items.map((item, index) => (
|
|
874
919
|
<LinkInsightRow
|
package/src/routes/mentions.tsx
CHANGED
|
@@ -1,138 +1,32 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { TimelineCard } from "#/components/TimelineCard";
|
|
5
|
-
import type {
|
|
6
|
-
QueryEnvelope,
|
|
7
|
-
QueryResponse,
|
|
8
|
-
ReplyFilter,
|
|
9
|
-
TimelineItem,
|
|
10
|
-
} from "#/lib/types";
|
|
11
|
-
import {
|
|
12
|
-
cx,
|
|
13
|
-
emptyStateClass,
|
|
14
|
-
feedClass,
|
|
15
|
-
pageHeaderClass,
|
|
16
|
-
pageHeaderRowClass,
|
|
17
|
-
pageSubtitleClass,
|
|
18
|
-
pageTitleClass,
|
|
19
|
-
searchFieldIconClass,
|
|
20
|
-
searchFieldInputClass,
|
|
21
|
-
searchFieldShellClass,
|
|
22
|
-
tabButtonActiveClass,
|
|
23
|
-
tabButtonClass,
|
|
24
|
-
tabButtonIndicatorClass,
|
|
25
|
-
tabStripClass,
|
|
26
|
-
} from "#/lib/ui";
|
|
2
|
+
import { TimelineRouteFrame } from "#/components/TimelineRouteFrame";
|
|
3
|
+
import type { QueryEnvelope } from "#/lib/types";
|
|
27
4
|
|
|
28
5
|
export const Route = createFileRoute("/mentions")({
|
|
29
6
|
component: MentionsRoute,
|
|
30
7
|
});
|
|
31
8
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
];
|
|
9
|
+
function mentionsSubtitle(meta: QueryEnvelope | null) {
|
|
10
|
+
if (!meta) return "Loading mentions...";
|
|
11
|
+
return `${String(meta.stats.mentions)} mention/reply items in local store`;
|
|
12
|
+
}
|
|
37
13
|
|
|
38
14
|
function MentionsRoute() {
|
|
39
|
-
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
40
|
-
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
41
|
-
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("unreplied");
|
|
42
|
-
const [search, setSearch] = useState("");
|
|
43
|
-
const [refreshTick, setRefreshTick] = useState(0);
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
fetch("/api/status")
|
|
47
|
-
.then((response) => response.json())
|
|
48
|
-
.then((data: QueryEnvelope) => setMeta(data));
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
const url = new URL("/api/query", window.location.origin);
|
|
53
|
-
url.searchParams.set("resource", "mentions");
|
|
54
|
-
url.searchParams.set("replyFilter", replyFilter);
|
|
55
|
-
url.searchParams.set("refresh", String(refreshTick));
|
|
56
|
-
if (search.trim()) {
|
|
57
|
-
url.searchParams.set("search", search.trim());
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
fetch(url)
|
|
61
|
-
.then((response) => response.json())
|
|
62
|
-
.then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
|
|
63
|
-
}, [refreshTick, replyFilter, search]);
|
|
64
|
-
|
|
65
|
-
const subtitle = useMemo(() => {
|
|
66
|
-
if (!meta) return "Loading mentions...";
|
|
67
|
-
return `${String(meta.stats.mentions)} mention/reply items in local store`;
|
|
68
|
-
}, [meta]);
|
|
69
|
-
|
|
70
|
-
async function replyToTweet(tweetId: string) {
|
|
71
|
-
const text = window.prompt("Reply text");
|
|
72
|
-
if (!text?.trim()) return;
|
|
73
|
-
|
|
74
|
-
await fetch("/api/action", {
|
|
75
|
-
method: "POST",
|
|
76
|
-
headers: { "content-type": "application/json" },
|
|
77
|
-
body: JSON.stringify({
|
|
78
|
-
kind: "replyTweet",
|
|
79
|
-
accountId: "acct_primary",
|
|
80
|
-
tweetId,
|
|
81
|
-
text,
|
|
82
|
-
}),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
setRefreshTick((value) => value + 1);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
15
|
return (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
value={search}
|
|
105
|
-
/>
|
|
106
|
-
</label>
|
|
107
|
-
</div>
|
|
108
|
-
<div className={tabStripClass}>
|
|
109
|
-
{TABS.map((tab) => {
|
|
110
|
-
const active = replyFilter === tab.value;
|
|
111
|
-
return (
|
|
112
|
-
<button
|
|
113
|
-
key={tab.value}
|
|
114
|
-
type="button"
|
|
115
|
-
aria-pressed={active}
|
|
116
|
-
className={cx(tabButtonClass, active && tabButtonActiveClass)}
|
|
117
|
-
onClick={() => setReplyFilter(tab.value)}
|
|
118
|
-
>
|
|
119
|
-
<span className="relative inline-flex flex-col items-center justify-center py-1">
|
|
120
|
-
{tab.value}
|
|
121
|
-
{active ? <span className={tabButtonIndicatorClass} /> : null}
|
|
122
|
-
</span>
|
|
123
|
-
</button>
|
|
124
|
-
);
|
|
125
|
-
})}
|
|
126
|
-
</div>
|
|
127
|
-
</header>
|
|
128
|
-
<section className={feedClass}>
|
|
129
|
-
{items.length === 0 ? (
|
|
130
|
-
<div className={emptyStateClass}>No mentions in view.</div>
|
|
131
|
-
) : null}
|
|
132
|
-
{items.map((item) => (
|
|
133
|
-
<TimelineCard key={item.id} item={item} onReply={replyToTweet} />
|
|
134
|
-
))}
|
|
135
|
-
</section>
|
|
136
|
-
</>
|
|
16
|
+
<TimelineRouteFrame
|
|
17
|
+
emptyDetail="Try All, search less narrowly, or sync mentions."
|
|
18
|
+
emptyLabel="No mentions in this view"
|
|
19
|
+
errorFallback="Mentions unavailable"
|
|
20
|
+
errorTitle="Could not load mentions"
|
|
21
|
+
initialReplyFilter="unreplied"
|
|
22
|
+
loadingDetail="Checking local mentions and reply context"
|
|
23
|
+
loadingLabel="Loading mentions"
|
|
24
|
+
resource="mentions"
|
|
25
|
+
searchPlaceholder="Search mentions"
|
|
26
|
+
subtitle={mentionsSubtitle}
|
|
27
|
+
syncKind="mentions"
|
|
28
|
+
syncLabel="Sync mentions"
|
|
29
|
+
title="Mentions"
|
|
30
|
+
/>
|
|
137
31
|
);
|
|
138
32
|
}
|