tangled
alpha
login
or
join now
desertthunder.dev
/
twisted
17
fork
atom
a love letter to tangled (android, iOS, and a search API)
17
fork
atom
overview
issues
pulls
pipelines
feat: constellation frontend integration
desertthunder.dev
2 days ago
b42eac25
cda861ba
+1476
-391
12 changed files
expand all
collapse all
unified
split
apps
twisted
src
core
browse-history
index.ts
config
project.ts
search-history
index.ts
domain
models
activity.ts
features
activity
ActivityPage.vue
explore
ExplorePage.vue
home
HomePage.vue
repo
RepoDetailPage.vue
services
constellation
queries.ts
jetstream
client.ts
docs
roadmap.md
packages
api
internal
store
db.go
+59
apps/twisted/src/core/browse-history/index.ts
···
1
1
+
const REPOS_KEY = "twisted-recent-repos";
2
2
+
const PROFILES_KEY = "twisted-recent-profiles";
3
3
+
const MAX_ITEMS = 10;
4
4
+
5
5
+
export type RecentRepo = {
6
6
+
ownerHandle: string;
7
7
+
name: string;
8
8
+
description?: string;
9
9
+
primaryLanguage?: string;
10
10
+
stars?: number;
11
11
+
visitedAt: number;
12
12
+
};
13
13
+
14
14
+
export type RecentProfile = { handle: string; displayName?: string; bio?: string; visitedAt: number };
15
15
+
16
16
+
function read<T>(key: string): T[] {
17
17
+
try {
18
18
+
const raw = window.localStorage.getItem(key);
19
19
+
if (!raw) return [];
20
20
+
return JSON.parse(raw) as T[];
21
21
+
} catch {
22
22
+
return [];
23
23
+
}
24
24
+
}
25
25
+
26
26
+
function write<T>(key: string, items: T[]): void {
27
27
+
try {
28
28
+
window.localStorage.setItem(key, JSON.stringify(items));
29
29
+
} catch (error) {
30
30
+
console.warn("Failed to write browse history to localStorage", { error });
31
31
+
}
32
32
+
}
33
33
+
34
34
+
export function getRecentRepos(): RecentRepo[] {
35
35
+
return read<RecentRepo>(REPOS_KEY);
36
36
+
}
37
37
+
38
38
+
export function trackRepoVisit(repo: Omit<RecentRepo, "visitedAt">): void {
39
39
+
const existing = read<RecentRepo>(REPOS_KEY).filter(
40
40
+
(r) => !(r.ownerHandle === repo.ownerHandle && r.name === repo.name),
41
41
+
);
42
42
+
existing.unshift({ ...repo, visitedAt: Date.now() });
43
43
+
write(REPOS_KEY, existing.slice(0, MAX_ITEMS));
44
44
+
}
45
45
+
46
46
+
export function getRecentProfiles(): RecentProfile[] {
47
47
+
return read<RecentProfile>(PROFILES_KEY);
48
48
+
}
49
49
+
50
50
+
export function trackProfileVisit(profile: Omit<RecentProfile, "visitedAt">): void {
51
51
+
const existing = read<RecentProfile>(PROFILES_KEY).filter((p) => p.handle !== profile.handle);
52
52
+
existing.unshift({ ...profile, visitedAt: Date.now() });
53
53
+
write(PROFILES_KEY, existing.slice(0, MAX_ITEMS));
54
54
+
}
55
55
+
56
56
+
export function clearBrowseHistory(): void {
57
57
+
write(REPOS_KEY, []);
58
58
+
write(PROFILES_KEY, []);
59
59
+
}
+1
-1
apps/twisted/src/core/config/project.ts
···
1
1
-
const rawTwisterApiBaseUrl = import.meta.env.VITE_TWISTER_API_BASE_URL?.trim() ?? "";
1
1
+
const rawTwisterApiBaseUrl = import.meta.env.VITE_TWISTER_API_BASE_URL?.trim() ?? "http://localhost:8080/";
2
2
3
3
export const twisterApiBaseUrl = rawTwisterApiBaseUrl.replace(/\/+$/, "");
4
4
export const hasTwisterApi = twisterApiBaseUrl.length > 0;
+43
apps/twisted/src/core/search-history/index.ts
···
1
1
+
const STORAGE_KEY = "twisted-search-history";
2
2
+
const MAX_ITEMS = 10;
3
3
+
4
4
+
export type SearchHistoryEntry = { query: string; timestamp: number };
5
5
+
6
6
+
function readHistory(): SearchHistoryEntry[] {
7
7
+
try {
8
8
+
const raw = window.localStorage.getItem(STORAGE_KEY);
9
9
+
if (!raw) return [];
10
10
+
return JSON.parse(raw) as SearchHistoryEntry[];
11
11
+
} catch {
12
12
+
return [];
13
13
+
}
14
14
+
}
15
15
+
16
16
+
function writeHistory(entries: SearchHistoryEntry[]): void {
17
17
+
try {
18
18
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
19
19
+
} catch (error) {
20
20
+
console.warn("Failed to write search history to localStorage", { error });
21
21
+
}
22
22
+
}
23
23
+
24
24
+
export function getSearchHistory(): SearchHistoryEntry[] {
25
25
+
return readHistory();
26
26
+
}
27
27
+
28
28
+
export function addToSearchHistory(query: string): void {
29
29
+
const trimmed = query.trim();
30
30
+
if (!trimmed) return;
31
31
+
32
32
+
const entries = readHistory().filter((e) => e.query !== trimmed);
33
33
+
entries.unshift({ query: trimmed, timestamp: Date.now() });
34
34
+
writeHistory(entries.slice(0, MAX_ITEMS));
35
35
+
}
36
36
+
37
37
+
export function removeFromSearchHistory(query: string): void {
38
38
+
writeHistory(readHistory().filter((e) => e.query !== query));
39
39
+
}
40
40
+
41
41
+
export function clearSearchHistory(): void {
42
42
+
writeHistory([]);
43
43
+
}
+3
-2
apps/twisted/src/domain/models/activity.ts
···
1
1
-
type ItemKind =
1
1
+
type ActivityItemKind =
2
2
| "repo_created"
3
3
| "repo_starred"
4
4
| "user_followed"
···
9
9
10
10
export type ActivityItem = {
11
11
id: string;
12
12
-
kind: ItemKind;
12
12
+
kind: ActivityItemKind;
13
13
actorDid: string;
14
14
actorHandle: string;
15
15
targetUri?: string;
16
16
targetName?: string;
17
17
+
targetOwnerDid?: string;
17
18
createdAt: string;
18
19
};
+299
-5
apps/twisted/src/features/activity/ActivityPage.vue
···
3
3
<ion-header :translucent="true">
4
4
<ion-toolbar>
5
5
<ion-title>Activity</ion-title>
6
6
+
<ion-buttons slot="end">
7
7
+
<ion-button fill="clear" size="small" @click="clearFeed" :disabled="items.length === 0">
8
8
+
<ion-icon slot="icon-only" :icon="trashOutline" />
9
9
+
</ion-button>
10
10
+
</ion-buttons>
6
11
</ion-toolbar>
7
12
</ion-header>
8
13
···
13
18
</ion-toolbar>
14
19
</ion-header>
15
20
21
21
+
<!-- Connection status -->
22
22
+
<div class="status-bar" :class="statusClass">
23
23
+
<ion-icon :icon="statusIcon" class="status-icon" />
24
24
+
<span class="status-text">{{ statusText }}</span>
25
25
+
</div>
26
26
+
27
27
+
<!-- Filter segment -->
28
28
+
<ion-segment v-model="activeFilter" class="filter-segment">
29
29
+
<ion-segment-button value="all">All</ion-segment-button>
30
30
+
<ion-segment-button value="repo_starred">Stars</ion-segment-button>
31
31
+
<ion-segment-button value="user_followed">Follows</ion-segment-button>
32
32
+
<ion-segment-button value="issue_opened">Issues</ion-segment-button>
33
33
+
<ion-segment-button value="pr_opened">PRs</ion-segment-button>
34
34
+
</ion-segment>
35
35
+
36
36
+
<!-- Pull to refresh -->
37
37
+
<ion-refresher slot="fixed" @ionRefresh="handleRefresh($event)">
38
38
+
<ion-refresher-content pulling-text="Pull to refresh feed" refreshing-spinner="crescent" />
39
39
+
</ion-refresher>
40
40
+
41
41
+
<!-- Loading state while waiting for first event -->
42
42
+
<template v-if="status === 'connecting' && filteredItems.length === 0">
43
43
+
<SkeletonLoader v-for="n in 6" :key="n" variant="list-item" />
44
44
+
</template>
45
45
+
46
46
+
<!-- Empty: disconnected with no items -->
16
47
<EmptyState
48
48
+
v-else-if="status === 'disconnected' && filteredItems.length === 0"
17
49
:icon="pulseOutline"
18
18
-
title="Activity is in progress"
19
19
-
message="The public activity feed is still in progress. This tab stays as a placeholder until the indexed feed work is ready." />
50
50
+
title="Disconnected"
51
51
+
message="Could not connect to the Jetstream feed. Pull to refresh to try again."
52
52
+
action-label="Reconnect"
53
53
+
@action="reconnect" />
54
54
+
55
55
+
<!-- Empty: connected but filter has no matches -->
56
56
+
<EmptyState
57
57
+
v-else-if="status === 'connected' && filteredItems.length === 0"
58
58
+
:icon="pulseOutline"
59
59
+
title="Waiting for events…"
60
60
+
message="Connected to the network. Events matching this filter will appear here." />
61
61
+
62
62
+
<!-- Feed -->
63
63
+
<div v-else class="feed">
64
64
+
<ActivityCard
65
65
+
v-for="item in filteredItems"
66
66
+
:key="item.id"
67
67
+
:item="displayItem(item)"
68
68
+
@click="handleItemClick(item)"
69
69
+
@actor-click="handleActorClick(item)" />
70
70
+
</div>
71
71
+
20
72
</ion-content>
21
73
</ion-page>
22
74
</template>
23
75
24
76
<script setup lang="ts">
25
25
-
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from "@ionic/vue";
26
26
-
import { pulseOutline } from "ionicons/icons";
27
27
-
import EmptyState from "@/components/common/EmptyState.vue";
77
77
+
import { computed, ref, onUnmounted, shallowRef } from "vue";
78
78
+
import { useRouter } from "vue-router";
79
79
+
import {
80
80
+
IonPage,
81
81
+
IonHeader,
82
82
+
IonToolbar,
83
83
+
IonTitle,
84
84
+
IonContent,
85
85
+
IonButtons,
86
86
+
IonButton,
87
87
+
IonIcon,
88
88
+
IonSegment,
89
89
+
IonSegmentButton,
90
90
+
IonRefresher,
91
91
+
IonRefresherContent,
92
92
+
onIonViewWillEnter,
93
93
+
onIonViewWillLeave,
94
94
+
} from "@ionic/vue";
95
95
+
import { pulseOutline, trashOutline, wifiOutline, cloudOfflineOutline, syncOutline } from "ionicons/icons";
96
96
+
import ActivityCard from "@/components/common/ActivityCard.vue";
97
97
+
import EmptyState from "@/components/common/EmptyState.vue";
98
98
+
import SkeletonLoader from "@/components/common/SkeletonLoader.vue";
99
99
+
import { JetstreamClient } from "@/services/jetstream/client.js";
100
100
+
import { resolveHandleFromDid } from "@/services/tangled/endpoints.js";
101
101
+
import type { ActivityItem } from "@/domain/models/activity.js";
102
102
+
103
103
+
type ConnectionStatus = "connecting" | "connected" | "disconnected";
104
104
+
type FilterKind = "all" | ActivityItem["kind"];
105
105
+
106
106
+
const MAX_ITEMS = 200;
107
107
+
108
108
+
const router = useRouter();
109
109
+
110
110
+
const items = shallowRef<ActivityItem[]>([]);
111
111
+
const activeFilter = ref<FilterKind>("all");
112
112
+
const status = ref<ConnectionStatus>("connecting");
113
113
+
const handleCache = ref<Map<string, string>>(new Map());
114
114
+
115
115
+
const filteredItems = computed(() => {
116
116
+
if (activeFilter.value === "all") return items.value;
117
117
+
return items.value.filter((item) => item.kind === activeFilter.value);
118
118
+
});
119
119
+
120
120
+
const statusClass = computed(() => ({
121
121
+
"status-connecting": status.value === "connecting",
122
122
+
"status-connected": status.value === "connected",
123
123
+
"status-disconnected": status.value === "disconnected",
124
124
+
}));
125
125
+
126
126
+
const statusText = computed((): string => {
127
127
+
switch (status.value) {
128
128
+
case "connected":
129
129
+
return `Live · ${items.value.length} event${items.value.length === 1 ? "" : "s"}`;
130
130
+
case "disconnected":
131
131
+
return "Disconnected · reconnecting…";
132
132
+
default:
133
133
+
return "Connecting to Jetstream…";
134
134
+
}
135
135
+
});
136
136
+
137
137
+
const statusIcon = computed(() => {
138
138
+
switch (status.value) {
139
139
+
case "connected":
140
140
+
return wifiOutline;
141
141
+
case "disconnected":
142
142
+
return cloudOfflineOutline;
143
143
+
default:
144
144
+
return syncOutline;
145
145
+
}
146
146
+
});
147
147
+
148
148
+
/** Returns a copy of the item with resolved handle if available. */
149
149
+
function displayItem(item: ActivityItem): ActivityItem {
150
150
+
const resolved = handleCache.value.get(item.actorDid);
151
151
+
if (!resolved || resolved === item.actorHandle) return item;
152
152
+
return { ...item, actorHandle: resolved };
153
153
+
}
154
154
+
155
155
+
function resolveHandle(did: string): void {
156
156
+
if (handleCache.value.has(did)) return;
157
157
+
// Optimistically mark as in-progress by setting to DID to avoid re-entrancy
158
158
+
handleCache.value.set(did, did);
159
159
+
160
160
+
resolveHandleFromDid(did)
161
161
+
.then((handle) => {
162
162
+
const next = new Map(handleCache.value);
163
163
+
next.set(did, handle);
164
164
+
handleCache.value = next;
165
165
+
})
166
166
+
.catch(() => {
167
167
+
// Leave the placeholder handle from the item
168
168
+
handleCache.value.delete(did);
169
169
+
});
170
170
+
}
171
171
+
172
172
+
const client = new JetstreamClient({
173
173
+
onEvent(item) {
174
174
+
const next = [item, ...items.value];
175
175
+
if (next.length > MAX_ITEMS) next.length = MAX_ITEMS;
176
176
+
items.value = next;
177
177
+
resolveHandle(item.actorDid);
178
178
+
},
179
179
+
onConnected() {
180
180
+
status.value = "connected";
181
181
+
},
182
182
+
onDisconnected() {
183
183
+
if (status.value !== "connecting") {
184
184
+
status.value = "disconnected";
185
185
+
}
186
186
+
},
187
187
+
onError() {
188
188
+
status.value = "disconnected";
189
189
+
},
190
190
+
});
191
191
+
192
192
+
onIonViewWillEnter(() => {
193
193
+
status.value = "connecting";
194
194
+
client.connect();
195
195
+
});
196
196
+
197
197
+
onIonViewWillLeave(() => {
198
198
+
client.disconnect();
199
199
+
status.value = "connecting"; // Reset so next enter shows "connecting"
200
200
+
});
201
201
+
202
202
+
onUnmounted(() => {
203
203
+
client.disconnect();
204
204
+
});
205
205
+
206
206
+
function clearFeed() {
207
207
+
items.value = [];
208
208
+
}
209
209
+
210
210
+
function reconnect() {
211
211
+
status.value = "connecting";
212
212
+
client.resetCursor();
213
213
+
client.disconnect();
214
214
+
client.connect();
215
215
+
}
216
216
+
217
217
+
async function handleRefresh(event: CustomEvent) {
218
218
+
clearFeed();
219
219
+
client.resetCursor();
220
220
+
client.disconnect();
221
221
+
status.value = "connecting";
222
222
+
client.connect();
223
223
+
// Complete the refresher after a short delay
224
224
+
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
225
225
+
(event.target as HTMLIonRefresherElement).complete();
226
226
+
}
227
227
+
228
228
+
function handleActorClick(item: ActivityItem) {
229
229
+
const handle = handleCache.value.get(item.actorDid);
230
230
+
if (handle && handle !== item.actorDid) {
231
231
+
router.push(`/tabs/activity/user/${handle}`);
232
232
+
} else {
233
233
+
// Resolve then navigate
234
234
+
resolveHandleFromDid(item.actorDid)
235
235
+
.then((h) => {
236
236
+
const next = new Map(handleCache.value);
237
237
+
next.set(item.actorDid, h);
238
238
+
handleCache.value = next;
239
239
+
router.push(`/tabs/activity/user/${h}`);
240
240
+
})
241
241
+
.catch(() => {
242
242
+
// Cannot navigate without a handle
243
243
+
});
244
244
+
}
245
245
+
}
246
246
+
247
247
+
function handleItemClick(item: ActivityItem) {
248
248
+
// Navigate to repo if we can determine owner handle and repo name
249
249
+
if (item.targetName && item.targetOwnerDid) {
250
250
+
const ownerHandle = handleCache.value.get(item.targetOwnerDid);
251
251
+
if (ownerHandle && ownerHandle !== item.targetOwnerDid) {
252
252
+
router.push(`/tabs/activity/repo/${ownerHandle}/${item.targetName}`);
253
253
+
}
254
254
+
// If handle not yet resolved, resolve it in background for future clicks
255
255
+
else {
256
256
+
resolveHandle(item.targetOwnerDid);
257
257
+
}
258
258
+
}
259
259
+
}
28
260
</script>
261
261
+
262
262
+
<style scoped>
263
263
+
/* Status bar */
264
264
+
.status-bar {
265
265
+
display: flex;
266
266
+
align-items: center;
267
267
+
gap: 8px;
268
268
+
padding: 8px 16px;
269
269
+
font-size: 12px;
270
270
+
font-weight: 500;
271
271
+
transition: background 0.2s;
272
272
+
}
273
273
+
274
274
+
.status-connecting {
275
275
+
color: var(--t-text-muted);
276
276
+
background: transparent;
277
277
+
}
278
278
+
279
279
+
.status-connected {
280
280
+
color: #34d399;
281
281
+
}
282
282
+
283
283
+
.status-disconnected {
284
284
+
color: #fb923c;
285
285
+
}
286
286
+
287
287
+
.status-icon {
288
288
+
font-size: 14px;
289
289
+
flex-shrink: 0;
290
290
+
}
291
291
+
292
292
+
/* Filter */
293
293
+
.filter-segment {
294
294
+
padding: 0 16px 8px;
295
295
+
}
296
296
+
297
297
+
/* Feed */
298
298
+
.feed {
299
299
+
padding-bottom: 24px;
300
300
+
}
301
301
+
302
302
+
/* New items pill */
303
303
+
.new-items-pill {
304
304
+
position: sticky;
305
305
+
bottom: 80px;
306
306
+
left: 50%;
307
307
+
transform: translateX(-50%);
308
308
+
width: fit-content;
309
309
+
background: var(--t-accent);
310
310
+
color: #0d1117;
311
311
+
font-size: 12px;
312
312
+
font-weight: 700;
313
313
+
padding: 6px 14px;
314
314
+
border-radius: 999px;
315
315
+
cursor: pointer;
316
316
+
z-index: 10;
317
317
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
318
318
+
margin: 0 auto 8px;
319
319
+
display: block;
320
320
+
text-align: center;
321
321
+
}
322
322
+
</style>
+274
-194
apps/twisted/src/features/explore/ExplorePage.vue
···
13
13
</ion-toolbar>
14
14
</ion-header>
15
15
16
16
-
<section class="hero">
17
17
-
<p class="eyebrow">Indexed Search</p>
18
18
-
<h1 class="hero-title">Search the Tangled network through the project index.</h1>
19
19
-
<p class="hero-copy">
20
20
-
Explore uses the Twister index for global search. Open any result to continue browsing through Tangled's
21
21
-
public repo and profile APIs.
22
22
-
</p>
23
23
-
</section>
24
24
-
25
16
<section class="search-card">
26
26
-
<label class="field-label" for="search-input">Search query</label>
27
17
<ion-input
28
18
id="search-input"
29
19
v-model="draftQuery"
···
32
22
autocapitalize="off"
33
23
:spellcheck="false"
34
24
clear-input
35
35
-
placeholder="Search repos, profiles, issues, and strings"
25
25
+
placeholder="Search repos and people…"
36
26
@keydown.enter="runSearch" />
37
27
38
28
<ion-segment v-model="resultType" class="search-segment">
···
41
31
<ion-segment-button value="profile">People</ion-segment-button>
42
32
</ion-segment>
43
33
44
44
-
<div class="action-row">
45
45
-
<ion-button class="primary-action" expand="block" @click="runSearch" :disabled="!canSearch">
46
46
-
Search
47
47
-
</ion-button>
48
48
-
<ion-button fill="outline" expand="block" @click="clearSearch" :disabled="!hasAnyQuery">Clear</ion-button>
34
34
+
<p v-if="!hasTwisterApi" class="hint-copy">
35
35
+
Set <code>VITE_TWISTER_API_BASE_URL</code> to enable global search.
36
36
+
</p>
37
37
+
</section>
38
38
+
39
39
+
<!-- Recent search history (shown when no active search) -->
40
40
+
<section v-if="showHistory" class="history-section">
41
41
+
<div class="history-header">
42
42
+
<span class="section-label">Recent</span>
43
43
+
<button class="clear-btn" type="button" @click="clearHistory">Clear all</button>
44
44
+
</div>
45
45
+
<div class="history-list">
46
46
+
<div v-for="entry in searchHistory" :key="entry.query" class="history-chip">
47
47
+
<button class="chip-label" type="button" @click="applyHistoryEntry(entry.query)">
48
48
+
<ion-icon :icon="timeOutline" class="chip-icon" />
49
49
+
{{ entry.query }}
50
50
+
</button>
51
51
+
<button class="chip-remove" type="button" @click="removeHistoryEntry(entry.query)" aria-label="Remove">
52
52
+
<ion-icon :icon="closeOutline" />
53
53
+
</button>
54
54
+
</div>
49
55
</div>
50
50
-
51
51
-
<p v-if="hasTwisterApi" class="hint-copy">
52
52
-
Search results and follower counts come from the project index when available.
53
53
-
</p>
54
54
-
<p v-else class="hint-copy">
55
55
-
Set <code>VITE_TWISTER_API_BASE_URL</code> to enable global search and index-backed graph summaries.
56
56
-
</p>
57
56
</section>
58
57
59
58
<section class="results-section">
···
61
60
v-if="!hasTwisterApi"
62
61
:icon="searchOutline"
63
62
title="Index API not configured"
64
64
-
message="Explore can search globally once the Twister API base URL is configured for this app." />
63
63
+
message="Explore can search globally once the Twister API base URL is configured." />
65
64
66
65
<EmptyState
67
66
v-else-if="!hasAttemptedSearch"
68
67
:icon="searchOutline"
69
68
title="Search repos and people"
70
70
-
message="Run a query against the project index, then open any result to continue browsing with Tangled's public APIs." />
69
69
+
message="Start typing to search the project index. Results are filtered by type using the segments above." />
71
70
72
71
<template v-else-if="isLoading">
73
72
<SkeletonLoader v-for="n in 3" :key="`repo-${n}`" variant="card" />
···
85
84
<template v-else-if="hasResults">
86
85
<div class="results-header">
87
86
<div>
88
88
-
<p class="results-label">Indexed results</p>
87
87
+
<p class="results-label">Results for</p>
89
88
<h2 class="results-title">{{ submittedQuery }}</h2>
90
89
</div>
91
90
<p class="results-meta">{{ totalLabel }}</p>
···
114
113
<EmptyState
115
114
v-else
116
115
:icon="searchOutline"
117
117
-
title="No indexed matches"
118
118
-
message="Try a different query, or use Home to jump directly to a known handle." />
116
116
+
title="No results"
117
117
+
message="Try a different query. Use Home to jump directly to a known handle." />
119
118
</section>
120
119
</ion-content>
121
120
</ion-page>
122
121
</template>
123
122
124
123
<script setup lang="ts">
125
125
-
import { computed, ref } from "vue";
126
126
-
import { useRouter } from "vue-router";
127
127
-
import {
128
128
-
IonPage,
129
129
-
IonHeader,
130
130
-
IonToolbar,
131
131
-
IonTitle,
132
132
-
IonContent,
133
133
-
IonInput,
134
134
-
IonButton,
135
135
-
IonSegment,
136
136
-
IonSegmentButton,
137
137
-
} from "@ionic/vue";
138
138
-
import { alertCircleOutline, searchOutline } from "ionicons/icons";
139
139
-
import EmptyState from "@/components/common/EmptyState.vue";
140
140
-
import RepoCard from "@/components/common/RepoCard.vue";
141
141
-
import SkeletonLoader from "@/components/common/SkeletonLoader.vue";
142
142
-
import UserCard from "@/components/common/UserCard.vue";
143
143
-
import { hasTwisterApi } from "@/core/config/project.js";
144
144
-
import type { RepoSummary } from "@/domain/models/repo.js";
145
145
-
import { useProjectSearch } from "@/services/project-api/queries.js";
124
124
+
import { computed, ref, watch, onUnmounted } from "vue";
125
125
+
import { useRouter } from "vue-router";
126
126
+
import {
127
127
+
IonPage,
128
128
+
IonHeader,
129
129
+
IonToolbar,
130
130
+
IonTitle,
131
131
+
IonContent,
132
132
+
IonInput,
133
133
+
IonSegment,
134
134
+
IonSegmentButton,
135
135
+
IonIcon,
136
136
+
} from "@ionic/vue";
137
137
+
import { alertCircleOutline, searchOutline, timeOutline, closeOutline } from "ionicons/icons";
138
138
+
import EmptyState from "@/components/common/EmptyState.vue";
139
139
+
import RepoCard from "@/components/common/RepoCard.vue";
140
140
+
import SkeletonLoader from "@/components/common/SkeletonLoader.vue";
141
141
+
import UserCard from "@/components/common/UserCard.vue";
142
142
+
import { hasTwisterApi } from "@/core/config/project.js";
143
143
+
import {
144
144
+
getSearchHistory,
145
145
+
addToSearchHistory,
146
146
+
removeFromSearchHistory,
147
147
+
clearSearchHistory,
148
148
+
} from "@/core/search-history/index.js";
149
149
+
import { trackRepoVisit, trackProfileVisit } from "@/core/browse-history/index.js";
150
150
+
import type { SearchHistoryEntry } from "@/core/search-history/index.js";
151
151
+
import type { RepoSummary } from "@/domain/models/repo.js";
152
152
+
import { useProjectSearch } from "@/services/project-api/queries.js";
146
153
147
147
-
const router = useRouter();
154
154
+
const router = useRouter();
148
155
149
149
-
const draftQuery = ref("");
150
150
-
const submittedQuery = ref("");
151
151
-
const hasAttemptedSearch = ref(false);
152
152
-
const resultType = ref<"all" | "repo" | "profile">("all");
156
156
+
const draftQuery = ref("");
157
157
+
const submittedQuery = ref("");
158
158
+
const hasAttemptedSearch = ref(false);
159
159
+
const resultType = ref<"all" | "repo" | "profile">("all");
160
160
+
const searchHistory = ref<SearchHistoryEntry[]>(getSearchHistory());
153
161
154
154
-
const hasAnyQuery = computed(() => draftQuery.value.trim().length > 0 || submittedQuery.value.length > 0);
155
155
-
const canSearch = computed(() => hasTwisterApi && draftQuery.value.trim().length > 0);
162
162
+
const showHistory = computed(
163
163
+
() => searchHistory.value.length > 0 && !draftQuery.value.trim() && !hasAttemptedSearch.value,
164
164
+
);
165
165
+
166
166
+
const searchQuery = useProjectSearch(submittedQuery, {
167
167
+
type: resultType,
168
168
+
enabled: computed(() => hasTwisterApi && hasAttemptedSearch.value && submittedQuery.value.length > 0),
169
169
+
});
170
170
+
171
171
+
const repos = computed(() => searchQuery.data.value?.repos ?? []);
172
172
+
const profiles = computed(() => searchQuery.data.value?.profiles ?? []);
173
173
+
const hasResults = computed(() => repos.value.length > 0 || profiles.value.length > 0);
174
174
+
const isLoading = computed(() => searchQuery.isPending.value);
175
175
+
const isError = computed(() => searchQuery.isError.value);
176
176
+
const errorMessage = computed(() => {
177
177
+
const err = searchQuery.error.value;
178
178
+
return err instanceof Error ? err.message : "An unexpected error occurred while searching the project index.";
179
179
+
});
180
180
+
const totalLabel = computed(() => {
181
181
+
const total = searchQuery.data.value?.total ?? repos.value.length + profiles.value.length;
182
182
+
return `${total} result${total === 1 ? "" : "s"}`;
183
183
+
});
184
184
+
185
185
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
186
186
+
187
187
+
watch(draftQuery, (val) => {
188
188
+
if (debounceTimer) clearTimeout(debounceTimer);
189
189
+
190
190
+
const trimmed = val.trim();
191
191
+
if (!trimmed) {
192
192
+
return;
193
193
+
}
194
194
+
195
195
+
if (!hasTwisterApi) return;
196
196
+
197
197
+
debounceTimer = setTimeout(() => {
198
198
+
submittedQuery.value = trimmed;
199
199
+
hasAttemptedSearch.value = true;
200
200
+
addToSearchHistory(trimmed);
201
201
+
searchHistory.value = getSearchHistory();
202
202
+
}, 400);
203
203
+
});
204
204
+
205
205
+
onUnmounted(() => {
206
206
+
if (debounceTimer) clearTimeout(debounceTimer);
207
207
+
});
156
208
157
157
-
const searchQuery = useProjectSearch(submittedQuery, {
158
158
-
type: resultType,
159
159
-
enabled: computed(() => hasTwisterApi && hasAttemptedSearch.value && submittedQuery.value.length > 0),
160
160
-
});
209
209
+
function runSearch() {
210
210
+
if (!hasTwisterApi) return;
211
211
+
const trimmed = draftQuery.value.trim();
212
212
+
if (!trimmed) return;
213
213
+
if (debounceTimer) clearTimeout(debounceTimer);
214
214
+
submittedQuery.value = trimmed;
215
215
+
hasAttemptedSearch.value = true;
216
216
+
addToSearchHistory(trimmed);
217
217
+
searchHistory.value = getSearchHistory();
218
218
+
}
161
219
162
162
-
const repos = computed(() => searchQuery.data.value?.repos ?? []);
163
163
-
const profiles = computed(() => searchQuery.data.value?.profiles ?? []);
164
164
-
const hasResults = computed(() => repos.value.length > 0 || profiles.value.length > 0);
165
165
-
const isLoading = computed(() => searchQuery.isPending.value);
166
166
-
const isError = computed(() => searchQuery.isError.value);
167
167
-
const errorMessage = computed(() => {
168
168
-
const err = searchQuery.error.value;
169
169
-
return err instanceof Error ? err.message : "An unexpected error occurred while searching the project index.";
170
170
-
});
171
171
-
const totalLabel = computed(() => {
172
172
-
const total = searchQuery.data.value?.total ?? repos.value.length + profiles.value.length;
173
173
-
return `${total} indexed result${total === 1 ? "" : "s"}`;
174
174
-
});
220
220
+
function applyHistoryEntry(query: string) {
221
221
+
draftQuery.value = query;
222
222
+
submittedQuery.value = query;
223
223
+
hasAttemptedSearch.value = true;
224
224
+
}
175
225
176
176
-
function runSearch() {
177
177
-
if (!canSearch.value) return;
178
178
-
submittedQuery.value = draftQuery.value.trim();
179
179
-
hasAttemptedSearch.value = true;
180
180
-
}
226
226
+
function removeHistoryEntry(query: string) {
227
227
+
removeFromSearchHistory(query);
228
228
+
searchHistory.value = getSearchHistory();
229
229
+
}
181
230
182
182
-
function clearSearch() {
183
183
-
draftQuery.value = "";
184
184
-
submittedQuery.value = "";
185
185
-
hasAttemptedSearch.value = false;
186
186
-
}
231
231
+
function clearHistory() {
232
232
+
clearSearchHistory();
233
233
+
searchHistory.value = [];
234
234
+
}
187
235
188
188
-
function navigateToRepo(repo: RepoSummary) {
189
189
-
router.push(`/tabs/explore/repo/${repo.ownerHandle}/${repo.name}`);
190
190
-
}
236
236
+
function navigateToRepo(repo: RepoSummary) {
237
237
+
trackRepoVisit({
238
238
+
ownerHandle: repo.ownerHandle,
239
239
+
name: repo.name,
240
240
+
description: repo.description,
241
241
+
primaryLanguage: repo.primaryLanguage,
242
242
+
stars: repo.stars,
243
243
+
});
244
244
+
router.push(`/tabs/explore/repo/${repo.ownerHandle}/${repo.name}`);
245
245
+
}
191
246
192
192
-
function navigateToUser(handle: string) {
193
193
-
router.push(`/tabs/explore/user/${handle}`);
194
194
-
}
247
247
+
function navigateToUser(handle: string) {
248
248
+
trackProfileVisit({ handle });
249
249
+
router.push(`/tabs/explore/user/${handle}`);
250
250
+
}
195
251
</script>
196
252
197
253
<style scoped>
198
198
-
.hero {
199
199
-
padding: 24px 20px 12px;
200
200
-
}
254
254
+
.search-card {
255
255
+
margin: 16px 16px 0;
256
256
+
padding: 14px 14px 12px;
257
257
+
border: 1px solid var(--t-border);
258
258
+
border-radius: var(--t-radius-lg);
259
259
+
background: linear-gradient(180deg, var(--t-surface-raised), var(--t-surface));
260
260
+
}
201
261
202
202
-
.eyebrow {
203
203
-
margin: 0 0 10px;
204
204
-
font-size: 12px;
205
205
-
font-weight: 700;
206
206
-
letter-spacing: 0.08em;
207
207
-
text-transform: uppercase;
208
208
-
color: var(--t-accent);
209
209
-
}
262
262
+
.search-input {
263
263
+
--background: rgba(255, 255, 255, 0.04);
264
264
+
--border-radius: var(--t-radius-md);
265
265
+
--color: var(--t-text-primary);
266
266
+
--padding-start: 14px;
267
267
+
--padding-end: 14px;
268
268
+
margin-bottom: 10px;
269
269
+
border: 1px solid var(--t-border);
270
270
+
border-radius: var(--t-radius-md);
271
271
+
font-family: var(--t-mono);
272
272
+
}
210
273
211
211
-
.hero-title {
212
212
-
margin: 0;
213
213
-
font-size: 28px;
214
214
-
line-height: 1.15;
215
215
-
color: var(--t-text-primary);
216
216
-
}
274
274
+
.search-segment {
275
275
+
margin-bottom: 4px;
276
276
+
}
217
277
218
218
-
.hero-copy {
219
219
-
margin: 12px 0 0;
220
220
-
font-size: 14px;
221
221
-
line-height: 1.6;
222
222
-
color: var(--t-text-secondary);
223
223
-
max-width: 34rem;
224
224
-
}
278
278
+
.hint-copy {
279
279
+
margin: 8px 0 0;
280
280
+
font-size: 12px;
281
281
+
line-height: 1.5;
282
282
+
color: var(--t-text-muted);
283
283
+
}
225
284
226
226
-
.search-card {
227
227
-
margin: 0 16px;
228
228
-
padding: 18px 16px 16px;
229
229
-
border: 1px solid var(--t-border);
230
230
-
border-radius: var(--t-radius-lg);
231
231
-
background: linear-gradient(180deg, var(--t-surface-raised), var(--t-surface));
232
232
-
}
285
285
+
.history-section {
286
286
+
padding: 14px 16px 4px;
287
287
+
}
233
288
234
234
-
.field-label {
235
235
-
display: block;
236
236
-
margin-bottom: 8px;
237
237
-
font-size: 13px;
238
238
-
font-weight: 600;
239
239
-
color: var(--t-text-primary);
240
240
-
}
289
289
+
.history-header {
290
290
+
display: flex;
291
291
+
align-items: center;
292
292
+
justify-content: space-between;
293
293
+
margin-bottom: 10px;
294
294
+
}
241
295
242
242
-
.search-input {
243
243
-
--background: rgba(255, 255, 255, 0.04);
244
244
-
--border-radius: var(--t-radius-md);
245
245
-
--color: var(--t-text-primary);
246
246
-
--padding-start: 14px;
247
247
-
--padding-end: 14px;
248
248
-
margin-bottom: 12px;
249
249
-
border: 1px solid var(--t-border);
250
250
-
border-radius: var(--t-radius-md);
251
251
-
font-family: var(--t-mono);
252
252
-
}
296
296
+
.clear-btn {
297
297
+
appearance: none;
298
298
+
background: transparent;
299
299
+
border: 0;
300
300
+
padding: 0;
301
301
+
cursor: pointer;
302
302
+
font-size: 12px;
303
303
+
color: var(--t-text-muted);
304
304
+
}
253
305
254
254
-
.search-segment {
255
255
-
margin-bottom: 12px;
256
256
-
}
306
306
+
.history-list {
307
307
+
display: flex;
308
308
+
flex-wrap: wrap;
309
309
+
gap: 8px;
310
310
+
}
257
311
258
258
-
.action-row {
259
259
-
display: grid;
260
260
-
grid-template-columns: repeat(2, minmax(0, 1fr));
261
261
-
gap: 10px;
262
262
-
}
312
312
+
.history-chip {
313
313
+
display: flex;
314
314
+
align-items: center;
315
315
+
gap: 0;
316
316
+
border: 1px solid var(--t-border);
317
317
+
border-radius: 999px;
318
318
+
background: var(--t-surface);
319
319
+
overflow: hidden;
320
320
+
}
263
321
264
264
-
.primary-action {
265
265
-
--background: var(--t-accent);
266
266
-
--background-activated: var(--t-accent);
267
267
-
--color: #0d1117;
268
268
-
}
322
322
+
.chip-label {
323
323
+
display: flex;
324
324
+
align-items: center;
325
325
+
gap: 6px;
326
326
+
appearance: none;
327
327
+
background: transparent;
328
328
+
border: 0;
329
329
+
padding: 5px 10px 5px 10px;
330
330
+
cursor: pointer;
331
331
+
font-size: 13px;
332
332
+
color: var(--t-text-primary);
333
333
+
font-family: var(--t-mono);
334
334
+
}
269
335
270
270
-
.hint-copy {
271
271
-
margin: 12px 0 0;
272
272
-
font-size: 12px;
273
273
-
line-height: 1.5;
274
274
-
color: var(--t-text-muted);
275
275
-
}
336
336
+
.chip-icon {
337
337
+
font-size: 13px;
338
338
+
color: var(--t-text-muted);
339
339
+
flex-shrink: 0;
340
340
+
}
341
341
+
342
342
+
.chip-remove {
343
343
+
appearance: none;
344
344
+
background: transparent;
345
345
+
border: 0;
346
346
+
border-left: 1px solid var(--t-border);
347
347
+
padding: 5px 8px;
348
348
+
cursor: pointer;
349
349
+
font-size: 13px;
350
350
+
color: var(--t-text-muted);
351
351
+
display: flex;
352
352
+
align-items: center;
353
353
+
}
276
354
277
277
-
.results-section {
278
278
-
padding: 18px 0 24px;
279
279
-
}
355
355
+
.results-section {
356
356
+
padding: 14px 0 24px;
357
357
+
}
280
358
281
281
-
.results-header {
282
282
-
display: flex;
283
283
-
align-items: flex-end;
284
284
-
justify-content: space-between;
285
285
-
gap: 12px;
286
286
-
margin: 0 16px 12px;
287
287
-
}
359
359
+
.results-header {
360
360
+
display: flex;
361
361
+
align-items: flex-end;
362
362
+
justify-content: space-between;
363
363
+
gap: 12px;
364
364
+
margin: 0 16px 12px;
365
365
+
}
288
366
289
289
-
.results-label {
290
290
-
margin: 0 0 4px;
291
291
-
font-size: 12px;
292
292
-
font-weight: 700;
293
293
-
letter-spacing: 0.08em;
294
294
-
text-transform: uppercase;
295
295
-
color: var(--t-accent);
296
296
-
}
367
367
+
.results-label {
368
368
+
margin: 0 0 2px;
369
369
+
font-size: 11px;
370
370
+
font-weight: 700;
371
371
+
letter-spacing: 0.08em;
372
372
+
text-transform: uppercase;
373
373
+
color: var(--t-accent);
374
374
+
}
297
375
298
298
-
.results-title {
299
299
-
margin: 0;
300
300
-
font-size: 20px;
301
301
-
line-height: 1.2;
302
302
-
color: var(--t-text-primary);
303
303
-
}
376
376
+
.results-title {
377
377
+
margin: 0;
378
378
+
font-size: 18px;
379
379
+
line-height: 1.2;
380
380
+
color: var(--t-text-primary);
381
381
+
}
304
382
305
305
-
.results-meta {
306
306
-
margin: 0;
307
307
-
font-size: 12px;
308
308
-
color: var(--t-text-muted);
309
309
-
}
383
383
+
.results-meta {
384
384
+
margin: 0;
385
385
+
font-size: 12px;
386
386
+
color: var(--t-text-muted);
387
387
+
white-space: nowrap;
388
388
+
}
310
389
311
311
-
.section-label {
312
312
-
margin: 0 16px 8px;
313
313
-
font-size: 12px;
314
314
-
font-weight: 700;
315
315
-
letter-spacing: 0.08em;
316
316
-
text-transform: uppercase;
317
317
-
color: var(--t-text-muted);
318
318
-
}
390
390
+
.section-label {
391
391
+
display: block;
392
392
+
margin: 0 16px 8px;
393
393
+
font-size: 11px;
394
394
+
font-weight: 700;
395
395
+
letter-spacing: 0.08em;
396
396
+
text-transform: uppercase;
397
397
+
color: var(--t-text-muted);
398
398
+
}
319
399
</style>
+371
-180
apps/twisted/src/features/home/HomePage.vue
···
13
13
</ion-toolbar>
14
14
</ion-header>
15
15
16
16
-
<section class="hero">
17
17
-
<p class="eyebrow">Profile Browser</p>
18
18
-
<h1 class="hero-title">Jump straight to a Tangled profile or repo.</h1>
19
19
-
<p class="hero-copy">
20
20
-
Enter an AT Protocol handle, then open the profile directly or resolve the user's Personal Data Server and
21
21
-
browse their public repositories here.
22
22
-
</p>
23
23
-
</section>
24
24
-
25
16
<section class="lookup-card">
26
17
<label class="field-label" for="handle-input">AT Protocol handle</label>
27
18
<ion-input
···
45
36
</ion-button>
46
37
</div>
47
38
48
48
-
<p class="hint-copy">
49
49
-
Home is still the fastest way to jump to a known handle directly.
50
50
-
</p>
39
39
+
<p class="hint-copy">Home is the fastest way to jump to a known handle directly.</p>
40
40
+
</section>
41
41
+
42
42
+
<!-- Recently viewed repos & profiles -->
43
43
+
<section v-if="hasRecentItems && !hasAttemptedBrowse" class="recent-section">
44
44
+
<div class="recent-header">
45
45
+
<span class="section-label">Recently Viewed</span>
46
46
+
<button class="clear-btn" type="button" @click="clearRecent">Clear</button>
47
47
+
</div>
48
48
+
49
49
+
<template v-if="recentProfiles.length">
50
50
+
<p class="recent-group-label">Profiles</p>
51
51
+
<ion-item
52
52
+
v-for="profile in recentProfiles"
53
53
+
:key="profile.handle"
54
54
+
button
55
55
+
lines="none"
56
56
+
class="recent-item"
57
57
+
@click="openRecentProfile(profile.handle)">
58
58
+
<div slot="start" class="recent-avatar" :style="{ background: avatarColor(profile.handle) }">
59
59
+
{{ initials(profile.handle) }}
60
60
+
</div>
61
61
+
<ion-label>
62
62
+
<div class="recent-handle">{{ profile.handle }}</div>
63
63
+
<div v-if="profile.displayName" class="recent-name">{{ profile.displayName }}</div>
64
64
+
</ion-label>
65
65
+
</ion-item>
66
66
+
</template>
67
67
+
68
68
+
<template v-if="recentRepos.length">
69
69
+
<p class="recent-group-label">Repos</p>
70
70
+
<ion-item
71
71
+
v-for="repo in recentRepos"
72
72
+
:key="`${repo.ownerHandle}/${repo.name}`"
73
73
+
button
74
74
+
lines="none"
75
75
+
class="recent-item"
76
76
+
@click="openRecentRepo(repo)">
77
77
+
<ion-label>
78
78
+
<div class="recent-repo-title">
79
79
+
<span class="recent-owner">{{ repo.ownerHandle }}</span
80
80
+
><span class="recent-sep">/</span><span class="recent-repo-name">{{ repo.name }}</span>
81
81
+
</div>
82
82
+
<div v-if="repo.description" class="recent-desc">{{ repo.description }}</div>
83
83
+
</ion-label>
84
84
+
<div v-if="repo.primaryLanguage" slot="end" class="lang-badge">{{ repo.primaryLanguage }}</div>
85
85
+
</ion-item>
86
86
+
</template>
51
87
</section>
52
88
53
89
<section v-if="hasAttemptedBrowse" class="results-section">
···
90
126
v-else
91
127
:icon="folderOpenOutline"
92
128
title="No public repos yet"
93
93
-
message="This handle resolved successfully, but there are no public Tangled repositories to browse yet." />
129
129
+
message="This handle resolved successfully, but there are no public Tangled repositories yet." />
94
130
</template>
95
131
</section>
96
132
97
97
-
<section v-else class="results-section">
133
133
+
<section v-else-if="!hasRecentItems" class="results-section">
98
134
<EmptyState
99
135
:icon="compassOutline"
100
136
title="Browse by handle"
101
101
-
message="Browse Tangled repositories by entering a handle above." />
137
137
+
message="Enter an AT Protocol handle above to view their profile and repos." />
102
138
</section>
103
139
</ion-content>
104
140
</ion-page>
105
141
</template>
106
142
107
143
<script setup lang="ts">
108
108
-
import { computed, ref } from "vue";
109
109
-
import { useRouter } from "vue-router";
110
110
-
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonInput, IonButton } from "@ionic/vue";
111
111
-
import { alertCircleOutline, compassOutline, folderOpenOutline } from "ionicons/icons";
112
112
-
import RepoCard from "@/components/common/RepoCard.vue";
113
113
-
import SkeletonLoader from "@/components/common/SkeletonLoader.vue";
114
114
-
import EmptyState from "@/components/common/EmptyState.vue";
115
115
-
import { useIdentity, useUserRepos, useActorProfile } from "@/services/tangled/queries.js";
116
116
-
import type { RepoSummary } from "@/domain/models/repo.js";
144
144
+
import { computed, ref } from "vue";
145
145
+
import { useRouter } from "vue-router";
146
146
+
import {
147
147
+
IonPage,
148
148
+
IonHeader,
149
149
+
IonToolbar,
150
150
+
IonTitle,
151
151
+
IonContent,
152
152
+
IonInput,
153
153
+
IonButton,
154
154
+
IonItem,
155
155
+
IonLabel,
156
156
+
} from "@ionic/vue";
157
157
+
import { alertCircleOutline, compassOutline, folderOpenOutline } from "ionicons/icons";
158
158
+
import RepoCard from "@/components/common/RepoCard.vue";
159
159
+
import SkeletonLoader from "@/components/common/SkeletonLoader.vue";
160
160
+
import EmptyState from "@/components/common/EmptyState.vue";
161
161
+
import { useIdentity, useUserRepos, useActorProfile } from "@/services/tangled/queries.js";
162
162
+
import {
163
163
+
trackRepoVisit,
164
164
+
trackProfileVisit,
165
165
+
getRecentRepos,
166
166
+
getRecentProfiles,
167
167
+
clearBrowseHistory,
168
168
+
} from "@/core/browse-history/index.js";
169
169
+
import type { RepoSummary } from "@/domain/models/repo.js";
170
170
+
import type { RecentRepo, RecentProfile } from "@/core/browse-history/index.js";
117
171
118
118
-
const router = useRouter();
172
172
+
const router = useRouter();
119
173
120
120
-
const draftHandle = ref("");
121
121
-
const activeHandle = ref("");
122
122
-
const hasAttemptedBrowse = ref(false);
174
174
+
const draftHandle = ref("");
175
175
+
const activeHandle = ref("");
176
176
+
const hasAttemptedBrowse = ref(false);
177
177
+
const recentRepos = ref<RecentRepo[]>(getRecentRepos());
178
178
+
const recentProfiles = ref<RecentProfile[]>(getRecentProfiles());
123
179
124
124
-
const normalizedHandle = computed(() => draftHandle.value.trim().toLowerCase());
125
125
-
const hasHandle = computed(() => normalizedHandle.value.length > 0);
180
180
+
const normalizedHandle = computed(() => draftHandle.value.trim().toLowerCase());
181
181
+
const hasHandle = computed(() => normalizedHandle.value.length > 0);
182
182
+
const hasRecentItems = computed(() => recentRepos.value.length > 0 || recentProfiles.value.length > 0);
126
183
127
127
-
const identity = useIdentity(activeHandle, { enabled: computed(() => !!activeHandle.value) });
128
128
-
const did = computed(() => identity.data.value?.did ?? "");
129
129
-
const pds = computed(() => identity.data.value?.pds ?? "");
130
130
-
const hasResolvedIdentity = computed(() => !!identity.data.value);
184
184
+
const identity = useIdentity(activeHandle, { enabled: computed(() => !!activeHandle.value) });
185
185
+
const did = computed(() => identity.data.value?.did ?? "");
186
186
+
const pds = computed(() => identity.data.value?.pds ?? "");
187
187
+
const hasResolvedIdentity = computed(() => !!identity.data.value);
131
188
132
132
-
const profileQuery = useActorProfile(pds, did, activeHandle, undefined, { enabled: hasResolvedIdentity });
133
133
-
const reposQuery = useUserRepos(pds, did, activeHandle, { enabled: hasResolvedIdentity });
189
189
+
const profileQuery = useActorProfile(pds, did, activeHandle, undefined, { enabled: hasResolvedIdentity });
190
190
+
const reposQuery = useUserRepos(pds, did, activeHandle, { enabled: hasResolvedIdentity });
134
191
135
135
-
const repos = computed(() => reposQuery.data.value ?? []);
136
136
-
const displayName = computed(() => profileQuery.data.value?.displayName ?? "Public Tangled account");
137
137
-
const repoCountLabel = computed(() => `${repos.value.length} repo${repos.value.length === 1 ? "" : "s"}`);
138
138
-
const isBrowsing = computed(() => hasAttemptedBrowse.value && activeHandle.value === normalizedHandle.value && isLoading.value);
139
139
-
const isLoading = computed(
140
140
-
() =>
141
141
-
hasAttemptedBrowse.value &&
142
142
-
activeHandle.value.length > 0 &&
143
143
-
(identity.isPending.value || (hasResolvedIdentity.value && reposQuery.isPending.value)),
144
144
-
);
145
145
-
const isError = computed(() => hasAttemptedBrowse.value && (identity.isError.value || reposQuery.isError.value));
146
146
-
const errorMessage = computed(() => {
147
147
-
const err = identity.error.value ?? reposQuery.error.value;
148
148
-
return err instanceof Error ? err.message : "An unexpected error occurred while resolving this handle.";
149
149
-
});
192
192
+
const repos = computed(() => reposQuery.data.value ?? []);
193
193
+
const displayName = computed(() => profileQuery.data.value?.displayName ?? "Public Tangled account");
194
194
+
const repoCountLabel = computed(() => `${repos.value.length} repo${repos.value.length === 1 ? "" : "s"}`);
195
195
+
const isBrowsing = computed(
196
196
+
() => hasAttemptedBrowse.value && activeHandle.value === normalizedHandle.value && isLoading.value,
197
197
+
);
198
198
+
const isLoading = computed(
199
199
+
() =>
200
200
+
hasAttemptedBrowse.value &&
201
201
+
activeHandle.value.length > 0 &&
202
202
+
(identity.isPending.value || (hasResolvedIdentity.value && reposQuery.isPending.value)),
203
203
+
);
204
204
+
const isError = computed(() => hasAttemptedBrowse.value && (identity.isError.value || reposQuery.isError.value));
205
205
+
const errorMessage = computed(() => {
206
206
+
const err = identity.error.value ?? reposQuery.error.value;
207
207
+
return err instanceof Error ? err.message : "An unexpected error occurred while resolving this handle.";
208
208
+
});
209
209
+
210
210
+
const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"];
211
211
+
212
212
+
function avatarColor(handle: string): string {
213
213
+
let hash = 0;
214
214
+
for (const ch of handle) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff;
215
215
+
return PALETTE[Math.abs(hash) % PALETTE.length];
216
216
+
}
150
217
151
151
-
function openProfile() {
152
152
-
if (!hasHandle.value) return;
153
153
-
router.push(`/tabs/home/user/${normalizedHandle.value}`);
154
154
-
}
218
218
+
function initials(handle: string): string {
219
219
+
const base = handle.split(".")[0];
220
220
+
return base.slice(0, 2).toUpperCase();
221
221
+
}
155
222
156
156
-
function browseRepos() {
157
157
-
if (!hasHandle.value) return;
158
158
-
hasAttemptedBrowse.value = true;
159
159
-
activeHandle.value = normalizedHandle.value;
160
160
-
}
223
223
+
function openProfile() {
224
224
+
if (!hasHandle.value) return;
225
225
+
trackProfileVisit({ handle: normalizedHandle.value });
226
226
+
router.push(`/tabs/home/user/${normalizedHandle.value}`);
227
227
+
}
161
228
162
162
-
function openResolvedProfile() {
163
163
-
if (!activeHandle.value) return;
164
164
-
router.push(`/tabs/home/user/${activeHandle.value}`);
165
165
-
}
229
229
+
function browseRepos() {
230
230
+
if (!hasHandle.value) return;
231
231
+
hasAttemptedBrowse.value = true;
232
232
+
activeHandle.value = normalizedHandle.value;
233
233
+
}
166
234
167
167
-
function navigateToRepo(repo: RepoSummary) {
168
168
-
router.push(`/tabs/home/repo/${repo.ownerHandle}/${repo.name}`);
169
169
-
}
235
235
+
function openResolvedProfile() {
236
236
+
if (!activeHandle.value) return;
237
237
+
trackProfileVisit({
238
238
+
handle: activeHandle.value,
239
239
+
displayName: profileQuery.data.value?.displayName,
240
240
+
bio: profileQuery.data.value?.bio,
241
241
+
});
242
242
+
router.push(`/tabs/home/user/${activeHandle.value}`);
243
243
+
}
244
244
+
245
245
+
function navigateToRepo(repo: RepoSummary) {
246
246
+
trackRepoVisit({
247
247
+
ownerHandle: repo.ownerHandle,
248
248
+
name: repo.name,
249
249
+
description: repo.description,
250
250
+
primaryLanguage: repo.primaryLanguage,
251
251
+
stars: repo.stars,
252
252
+
});
253
253
+
router.push(`/tabs/home/repo/${repo.ownerHandle}/${repo.name}`);
254
254
+
}
255
255
+
256
256
+
function openRecentProfile(handle: string) {
257
257
+
trackProfileVisit({ handle });
258
258
+
router.push(`/tabs/home/user/${handle}`);
259
259
+
}
260
260
+
261
261
+
function openRecentRepo(repo: RecentRepo) {
262
262
+
trackRepoVisit(repo);
263
263
+
recentRepos.value = getRecentRepos();
264
264
+
router.push(`/tabs/home/repo/${repo.ownerHandle}/${repo.name}`);
265
265
+
}
266
266
+
267
267
+
function clearRecent() {
268
268
+
clearBrowseHistory();
269
269
+
recentRepos.value = [];
270
270
+
recentProfiles.value = [];
271
271
+
}
170
272
</script>
171
273
172
274
<style scoped>
173
173
-
.hero {
174
174
-
padding: 24px 20px 12px;
175
175
-
}
275
275
+
.lookup-card {
276
276
+
margin: 16px 16px 0;
277
277
+
padding: 18px 16px 16px;
278
278
+
border: 1px solid var(--t-border);
279
279
+
border-radius: var(--t-radius-lg);
280
280
+
background: linear-gradient(180deg, var(--t-surface-raised), var(--t-surface));
281
281
+
}
282
282
+
283
283
+
.field-label {
284
284
+
display: block;
285
285
+
margin-bottom: 8px;
286
286
+
font-size: 13px;
287
287
+
font-weight: 600;
288
288
+
color: var(--t-text-primary);
289
289
+
}
176
290
177
177
-
.eyebrow {
178
178
-
margin: 0 0 10px;
179
179
-
font-size: 12px;
180
180
-
font-weight: 700;
181
181
-
letter-spacing: 0.08em;
182
182
-
text-transform: uppercase;
183
183
-
color: var(--t-accent);
184
184
-
}
291
291
+
.handle-input {
292
292
+
--background: rgba(255, 255, 255, 0.04);
293
293
+
--border-radius: var(--t-radius-md);
294
294
+
--color: var(--t-text-primary);
295
295
+
--padding-start: 14px;
296
296
+
--padding-end: 14px;
297
297
+
margin-bottom: 12px;
298
298
+
border: 1px solid var(--t-border);
299
299
+
border-radius: var(--t-radius-md);
300
300
+
font-family: var(--t-mono);
301
301
+
}
302
302
+
303
303
+
.action-row {
304
304
+
display: grid;
305
305
+
grid-template-columns: repeat(2, minmax(0, 1fr));
306
306
+
gap: 10px;
307
307
+
}
308
308
+
309
309
+
.primary-action {
310
310
+
--background: var(--t-accent);
311
311
+
--background-activated: var(--t-accent);
312
312
+
--color: #0d1117;
313
313
+
}
185
314
186
186
-
.hero-title {
187
187
-
margin: 0;
188
188
-
font-size: 28px;
189
189
-
line-height: 1.15;
190
190
-
color: var(--t-text-primary);
191
191
-
}
315
315
+
.hint-copy {
316
316
+
margin: 12px 0 0;
317
317
+
font-size: 12px;
318
318
+
line-height: 1.5;
319
319
+
color: var(--t-text-muted);
320
320
+
}
192
321
193
193
-
.hero-copy {
194
194
-
margin: 12px 0 0;
195
195
-
font-size: 14px;
196
196
-
line-height: 1.6;
197
197
-
color: var(--t-text-secondary);
198
198
-
max-width: 34rem;
199
199
-
}
322
322
+
.recent-section {
323
323
+
padding: 16px 0 8px;
324
324
+
}
200
325
201
201
-
.lookup-card {
202
202
-
margin: 0 16px;
203
203
-
padding: 18px 16px 16px;
204
204
-
border: 1px solid var(--t-border);
205
205
-
border-radius: var(--t-radius-lg);
206
206
-
background: linear-gradient(180deg, var(--t-surface-raised), var(--t-surface));
207
207
-
}
326
326
+
.recent-header {
327
327
+
display: flex;
328
328
+
align-items: center;
329
329
+
justify-content: space-between;
330
330
+
padding: 0 16px;
331
331
+
margin-bottom: 10px;
332
332
+
}
208
333
209
209
-
.field-label {
210
210
-
display: block;
211
211
-
margin-bottom: 8px;
212
212
-
font-size: 13px;
213
213
-
font-weight: 600;
214
214
-
color: var(--t-text-primary);
215
215
-
}
334
334
+
.section-label {
335
335
+
font-size: 11px;
336
336
+
font-weight: 700;
337
337
+
letter-spacing: 0.08em;
338
338
+
text-transform: uppercase;
339
339
+
color: var(--t-text-muted);
340
340
+
}
216
341
217
217
-
.handle-input {
218
218
-
--background: rgba(255, 255, 255, 0.04);
219
219
-
--border-radius: var(--t-radius-md);
220
220
-
--color: var(--t-text-primary);
221
221
-
--padding-start: 14px;
222
222
-
--padding-end: 14px;
223
223
-
margin-bottom: 12px;
224
224
-
border: 1px solid var(--t-border);
225
225
-
border-radius: var(--t-radius-md);
226
226
-
font-family: var(--t-mono);
227
227
-
}
342
342
+
.clear-btn {
343
343
+
appearance: none;
344
344
+
background: transparent;
345
345
+
border: 0;
346
346
+
padding: 0;
347
347
+
cursor: pointer;
348
348
+
font-size: 12px;
349
349
+
color: var(--t-text-muted);
350
350
+
}
228
351
229
229
-
.action-row {
230
230
-
display: grid;
231
231
-
grid-template-columns: repeat(2, minmax(0, 1fr));
232
232
-
gap: 10px;
233
233
-
}
352
352
+
.recent-group-label {
353
353
+
margin: 8px 16px 4px;
354
354
+
font-size: 11px;
355
355
+
font-weight: 600;
356
356
+
letter-spacing: 0.05em;
357
357
+
text-transform: uppercase;
358
358
+
color: var(--t-text-muted);
359
359
+
}
234
360
235
235
-
.primary-action {
236
236
-
--background: var(--t-accent);
237
237
-
--background-activated: var(--t-accent);
238
238
-
--color: #0d1117;
239
239
-
}
361
361
+
.recent-item {
362
362
+
--background: transparent;
363
363
+
--padding-start: 16px;
364
364
+
--padding-end: 16px;
365
365
+
--inner-padding-end: 0;
366
366
+
--min-height: 48px;
367
367
+
}
240
368
241
241
-
.hint-copy {
242
242
-
margin: 12px 0 0;
243
243
-
font-size: 12px;
244
244
-
line-height: 1.5;
245
245
-
color: var(--t-text-muted);
246
246
-
}
369
369
+
.recent-avatar {
370
370
+
width: 32px;
371
371
+
height: 32px;
372
372
+
border-radius: var(--t-radius-sm);
373
373
+
display: flex;
374
374
+
align-items: center;
375
375
+
justify-content: center;
376
376
+
font-family: var(--t-mono);
377
377
+
font-size: 11px;
378
378
+
font-weight: 700;
379
379
+
color: #0d1117;
380
380
+
margin-right: 12px;
381
381
+
flex-shrink: 0;
382
382
+
}
247
383
248
248
-
.results-section {
249
249
-
padding: 18px 0 24px;
250
250
-
}
384
384
+
.recent-handle {
385
385
+
font-family: var(--t-mono);
386
386
+
font-size: 13px;
387
387
+
font-weight: 600;
388
388
+
color: var(--t-accent);
389
389
+
}
251
390
252
252
-
.resolved-header {
253
253
-
display: flex;
254
254
-
align-items: flex-start;
255
255
-
justify-content: space-between;
256
256
-
gap: 12px;
257
257
-
margin: 0 16px 10px;
258
258
-
padding: 16px;
259
259
-
border: 1px solid var(--t-border);
260
260
-
border-radius: var(--t-radius-md);
261
261
-
background: var(--t-surface);
262
262
-
}
391
391
+
.recent-name {
392
392
+
font-size: 12px;
393
393
+
color: var(--t-text-secondary);
394
394
+
margin-top: 1px;
395
395
+
}
263
396
264
264
-
.resolved-copy {
265
265
-
min-width: 0;
266
266
-
}
397
397
+
.recent-repo-title {
398
398
+
font-family: var(--t-mono);
399
399
+
font-size: 13px;
400
400
+
font-weight: 500;
401
401
+
color: var(--t-text-primary);
402
402
+
line-height: 1.3;
403
403
+
}
267
404
268
268
-
.resolved-label {
269
269
-
margin: 0 0 6px;
270
270
-
font-size: 12px;
271
271
-
font-weight: 600;
272
272
-
letter-spacing: 0.06em;
273
273
-
text-transform: uppercase;
274
274
-
color: var(--t-text-muted);
275
275
-
}
405
405
+
.recent-owner {
406
406
+
color: var(--t-text-secondary);
407
407
+
}
276
408
277
277
-
.resolved-title {
278
278
-
margin: 0;
279
279
-
font-size: 15px;
280
280
-
color: var(--t-text-primary);
281
281
-
word-break: break-word;
282
282
-
}
409
409
+
.recent-sep {
410
410
+
color: var(--t-text-muted);
411
411
+
margin: 0 1px;
412
412
+
}
283
413
284
284
-
.resolved-meta {
285
285
-
margin: 8px 0 0;
286
286
-
font-size: 13px;
287
287
-
color: var(--t-text-secondary);
288
288
-
}
414
414
+
.recent-repo-name {
415
415
+
color: var(--t-accent);
416
416
+
}
289
417
290
290
-
.meta-separator {
291
291
-
margin: 0 6px;
292
292
-
color: var(--t-text-muted);
293
293
-
}
418
418
+
.recent-desc {
419
419
+
font-size: 12px;
420
420
+
color: var(--t-text-muted);
421
421
+
margin-top: 2px;
422
422
+
overflow: hidden;
423
423
+
text-overflow: ellipsis;
424
424
+
white-space: nowrap;
425
425
+
}
294
426
295
295
-
@media (max-width: 480px) {
296
296
-
.hero-title {
297
297
-
font-size: 24px;
427
427
+
.lang-badge {
428
428
+
font-family: var(--t-mono);
429
429
+
font-size: 11px;
430
430
+
font-weight: 600;
431
431
+
color: var(--t-text-muted);
432
432
+
padding: 2px 6px;
433
433
+
border: 1px solid var(--t-border);
434
434
+
border-radius: 999px;
298
435
}
299
436
300
300
-
.action-row {
301
301
-
grid-template-columns: 1fr;
437
437
+
/* Browse results */
438
438
+
439
439
+
.results-section {
440
440
+
padding: 18px 0 24px;
302
441
}
303
442
304
443
.resolved-header {
305
305
-
flex-direction: column;
306
306
-
align-items: stretch;
444
444
+
display: flex;
445
445
+
align-items: flex-start;
446
446
+
justify-content: space-between;
447
447
+
gap: 12px;
448
448
+
margin: 0 16px 10px;
449
449
+
padding: 16px;
450
450
+
border: 1px solid var(--t-border);
451
451
+
border-radius: var(--t-radius-md);
452
452
+
background: var(--t-surface);
307
453
}
308
308
-
}
454
454
+
455
455
+
.resolved-copy {
456
456
+
min-width: 0;
457
457
+
}
458
458
+
459
459
+
.resolved-label {
460
460
+
margin: 0 0 6px;
461
461
+
font-size: 12px;
462
462
+
font-weight: 600;
463
463
+
letter-spacing: 0.06em;
464
464
+
text-transform: uppercase;
465
465
+
color: var(--t-text-muted);
466
466
+
}
467
467
+
468
468
+
.resolved-title {
469
469
+
margin: 0;
470
470
+
font-size: 15px;
471
471
+
color: var(--t-text-primary);
472
472
+
word-break: break-word;
473
473
+
}
474
474
+
475
475
+
.resolved-meta {
476
476
+
margin: 8px 0 0;
477
477
+
font-size: 13px;
478
478
+
color: var(--t-text-secondary);
479
479
+
}
480
480
+
481
481
+
.meta-separator {
482
482
+
margin: 0 6px;
483
483
+
color: var(--t-text-muted);
484
484
+
}
485
485
+
486
486
+
.mono {
487
487
+
font-family: var(--t-mono);
488
488
+
}
489
489
+
490
490
+
@media (max-width: 480px) {
491
491
+
.action-row {
492
492
+
grid-template-columns: 1fr;
493
493
+
}
494
494
+
495
495
+
.resolved-header {
496
496
+
flex-direction: column;
497
497
+
align-items: stretch;
498
498
+
}
499
499
+
}
309
500
</style>
+4
apps/twisted/src/features/repo/RepoDetailPage.vue
···
94
94
useRepoIssues,
95
95
useRepoPRs,
96
96
} from "@/services/tangled/queries.js";
97
97
+
import { useRepoStarCount } from "@/services/constellation/queries.js";
97
98
import type { RepoDetail } from "@/domain/models/repo.js";
98
99
import type { RepoAssetContext } from "@/services/tangled/repo-assets.js";
99
100
···
163
164
if (!rec) return undefined;
164
165
return {
165
166
...rec,
167
167
+
stars: starCountQuery.data.value ?? rec.stars,
166
168
defaultBranch: defaultBranch.value || undefined,
167
169
languages: languagesQuery.data.value,
168
170
readme: readmeQuery.data.value?.isBinary ? undefined : readmeQuery.data.value?.content,
···
171
173
172
174
const repoAtUri = computed(() => recordQuery.data.value?.atUri ?? "");
173
175
const hasAtUri = computed(() => !!repoAtUri.value);
176
176
+
177
177
+
const starCountQuery = useRepoStarCount(repoAtUri, { enabled: hasAtUri });
174
178
175
179
const issuesQuery = useRepoIssues(pds, did, owner, repoAtUri, { enabled: hasAtUri });
176
180
const prsQuery = useRepoPRs(pds, did, owner, repoAtUri, { enabled: hasAtUri });
+76
apps/twisted/src/services/constellation/queries.ts
···
1
1
+
/**
2
2
+
* TanStack Query hooks for the Constellation backlink API.
3
3
+
* https://constellation.microcosm.blue
4
4
+
*
5
5
+
* Constellation is a public AT Protocol backlink index. It answers
6
6
+
* "how many records link to this subject?" — star counts, follower
7
7
+
* counts, reaction counts — without requiring authentication.
8
8
+
*
9
9
+
* Calling it directly from the app avoids adding per-resource endpoints
10
10
+
* to the Twister API for every social signal we need.
11
11
+
*/
12
12
+
import { useQuery } from "@tanstack/vue-query";
13
13
+
import { computed, toValue } from "vue";
14
14
+
import type { MaybeRef } from "vue";
15
15
+
16
16
+
const CONSTELLATION_BASE = "https://constellation.microcosm.blue";
17
17
+
18
18
+
// AT Protocol collection + field paths used as Constellation "sources".
19
19
+
const SOURCE_STAR = "sh.tangled.feed.star:subject.uri";
20
20
+
const SOURCE_FOLLOW = "sh.tangled.graph.follow:subject";
21
21
+
22
22
+
const MIN = 60_000;
23
23
+
24
24
+
async function fetchBacklinksCount(subject: string, source: string): Promise<number> {
25
25
+
const url = new URL(`${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinksCount`);
26
26
+
url.searchParams.set("subject", subject);
27
27
+
url.searchParams.set("source", source);
28
28
+
29
29
+
const res = await fetch(url.toString(), {
30
30
+
headers: { Accept: "application/json" },
31
31
+
});
32
32
+
33
33
+
if (!res.ok) {
34
34
+
throw new Error(`Constellation request failed: ${res.status}`);
35
35
+
}
36
36
+
37
37
+
const data = (await res.json()) as { count: number };
38
38
+
return data.count;
39
39
+
}
40
40
+
41
41
+
/**
42
42
+
* Fetches the star count for a repo AT URI from Constellation.
43
43
+
* atUri should be in the form "at://did:plc:.../sh.tangled.repo/reponame".
44
44
+
*/
45
45
+
export function useRepoStarCount(atUri: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) {
46
46
+
const normalizedUri = computed(() => toValue(atUri).trim());
47
47
+
const enabled = computed(
48
48
+
() => normalizedUri.value.length > 0 && (options.enabled === undefined || !!toValue(options.enabled)),
49
49
+
);
50
50
+
51
51
+
return useQuery({
52
52
+
queryKey: computed(() => ["constellationStars", normalizedUri.value]),
53
53
+
queryFn: () => fetchBacklinksCount(normalizedUri.value, SOURCE_STAR),
54
54
+
enabled,
55
55
+
staleTime: 5 * MIN,
56
56
+
gcTime: 30 * MIN,
57
57
+
});
58
58
+
}
59
59
+
60
60
+
/**
61
61
+
* Fetches the follower count for a DID from Constellation.
62
62
+
*/
63
63
+
export function useFollowerCount(did: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) {
64
64
+
const normalizedDid = computed(() => toValue(did).trim());
65
65
+
const enabled = computed(
66
66
+
() => normalizedDid.value.length > 0 && (options.enabled === undefined || !!toValue(options.enabled)),
67
67
+
);
68
68
+
69
69
+
return useQuery({
70
70
+
queryKey: computed(() => ["constellationFollowers", normalizedDid.value]),
71
71
+
queryFn: () => fetchBacklinksCount(normalizedDid.value, SOURCE_FOLLOW),
72
72
+
enabled,
73
73
+
staleTime: 5 * MIN,
74
74
+
gcTime: 30 * MIN,
75
75
+
});
76
76
+
}
+252
apps/twisted/src/services/jetstream/client.ts
···
1
1
+
/**
2
2
+
* JetstreamClient — subscribes to the AT Protocol Jetstream WebSocket firehose
3
3
+
* and filters for sh.tangled.* collection events, emitting ActivityItems.
4
4
+
*
5
5
+
* Connects on demand, auto-reconnects after disconnection, and tracks the last
6
6
+
* event cursor so gap-free resume is possible on reconnect.
7
7
+
*
8
8
+
* Data source decision: Jetstream is chosen over PDS polling because it provides
9
9
+
* a public, real-time stream of all network events without requiring authentication
10
10
+
* or prior knowledge of specific user DIDs. PDS polling would require a known list
11
11
+
* of accounts to follow, and the Twister API does not yet expose an activity feed.
12
12
+
*/
13
13
+
import type { ActivityItem } from "@/domain/models/activity.js";
14
14
+
15
15
+
const JETSTREAM_URL = "wss://jetstream2.us-east.bsky.network/subscribe";
16
16
+
const MAX_ITEMS = 200;
17
17
+
const RECONNECT_DELAY_MS = 3_000;
18
18
+
19
19
+
const WANTED_COLLECTIONS = [
20
20
+
"sh.tangled.repo",
21
21
+
"sh.tangled.feed.star",
22
22
+
"sh.tangled.graph.follow",
23
23
+
"sh.tangled.repo.issue",
24
24
+
"sh.tangled.repo.issue.state",
25
25
+
"sh.tangled.repo.pull",
26
26
+
];
27
27
+
28
28
+
type JetstreamRecord = Record<string, unknown>;
29
29
+
30
30
+
type JetstreamCommit = {
31
31
+
rev: string;
32
32
+
operation: "create" | "update" | "delete";
33
33
+
collection: string;
34
34
+
rkey: string;
35
35
+
record?: JetstreamRecord;
36
36
+
cid?: string;
37
37
+
};
38
38
+
39
39
+
type JetstreamEventKind = "commit" | "identity" | "account";
40
40
+
41
41
+
type JetstreamEvent = { did: string; time_us: number; kind: JetstreamEventKind; commit?: JetstreamCommit };
42
42
+
43
43
+
export type JetstreamCallbacks = {
44
44
+
onEvent: (item: ActivityItem) => void;
45
45
+
onConnected?: () => void;
46
46
+
onDisconnected?: () => void;
47
47
+
onError?: () => void;
48
48
+
};
49
49
+
50
50
+
function extractAtUri(record: JetstreamRecord, field: string): string {
51
51
+
const subject = record[field] as { uri?: string } | string | undefined;
52
52
+
if (typeof subject === "string") return subject;
53
53
+
return subject?.uri ?? "";
54
54
+
}
55
55
+
56
56
+
/** Extract the last path segment of an AT URI (the rkey / repo name). */
57
57
+
function atUriRkey(atUri: string): string | undefined {
58
58
+
const seg = atUri.split("/").pop();
59
59
+
return seg || undefined;
60
60
+
}
61
61
+
62
62
+
/** Extract the DID embedded in an AT URI (at://did:plc:.../collection/rkey). */
63
63
+
function atUriDid(atUri: string): string | undefined {
64
64
+
const match = /^at:\/\/(did:[^/]+)/.exec(atUri);
65
65
+
return match?.[1];
66
66
+
}
67
67
+
68
68
+
function toActivityKind(commit: JetstreamCommit): ActivityItem["kind"] | null {
69
69
+
if (commit.operation === "delete") return null;
70
70
+
71
71
+
switch (commit.collection) {
72
72
+
case "sh.tangled.repo":
73
73
+
return commit.operation === "create" ? "repo_created" : null;
74
74
+
case "sh.tangled.feed.star":
75
75
+
return commit.operation === "create" ? "repo_starred" : null;
76
76
+
case "sh.tangled.graph.follow":
77
77
+
return commit.operation === "create" ? "user_followed" : null;
78
78
+
case "sh.tangled.repo.issue":
79
79
+
return commit.operation === "create" ? "issue_opened" : null;
80
80
+
case "sh.tangled.repo.issue.state": {
81
81
+
if (commit.operation !== "create" || !commit.record) return null;
82
82
+
const status = commit.record["status"] as string | undefined;
83
83
+
return status === "closed" ? "issue_closed" : null;
84
84
+
}
85
85
+
case "sh.tangled.repo.pull":
86
86
+
return commit.operation === "create" ? "pr_opened" : null;
87
87
+
default:
88
88
+
return null;
89
89
+
}
90
90
+
}
91
91
+
92
92
+
function extractTargetInfo(commit: JetstreamCommit): { targetName?: string; targetOwnerDid?: string } {
93
93
+
const record = commit.record;
94
94
+
if (!record) return {};
95
95
+
96
96
+
switch (commit.collection) {
97
97
+
case "sh.tangled.repo": {
98
98
+
return { targetName: record["name"] as string | undefined };
99
99
+
}
100
100
+
case "sh.tangled.feed.star": {
101
101
+
const uri = extractAtUri(record, "subject");
102
102
+
return { targetName: atUriRkey(uri), targetOwnerDid: atUriDid(uri) };
103
103
+
}
104
104
+
case "sh.tangled.graph.follow": {
105
105
+
const subject = record["subject"] as string | undefined;
106
106
+
return { targetOwnerDid: subject };
107
107
+
}
108
108
+
case "sh.tangled.repo.issue":
109
109
+
case "sh.tangled.repo.issue.state":
110
110
+
case "sh.tangled.repo.pull": {
111
111
+
const uri = extractAtUri(record, "subject");
112
112
+
return { targetName: atUriRkey(uri), targetOwnerDid: atUriDid(uri) };
113
113
+
}
114
114
+
default:
115
115
+
return {};
116
116
+
}
117
117
+
}
118
118
+
119
119
+
/** Returns a short, human-readable identifier from a DID while the handle is being resolved. */
120
120
+
function placeholderHandle(did: string): string {
121
121
+
const parts = did.split(":");
122
122
+
const id = parts[2] ?? did;
123
123
+
return id.length > 10 ? `${id.slice(0, 10)}…` : id;
124
124
+
}
125
125
+
126
126
+
export class JetstreamClient {
127
127
+
private ws: WebSocket | null = null;
128
128
+
private callbacks: JetstreamCallbacks;
129
129
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
130
130
+
private cursor: number | null = null;
131
131
+
private stopped = false;
132
132
+
readonly maxItems: number;
133
133
+
134
134
+
constructor(callbacks: JetstreamCallbacks, maxItems = MAX_ITEMS) {
135
135
+
this.callbacks = callbacks;
136
136
+
this.maxItems = maxItems;
137
137
+
}
138
138
+
139
139
+
connect(): void {
140
140
+
this.stopped = false;
141
141
+
this._open();
142
142
+
}
143
143
+
144
144
+
disconnect(): void {
145
145
+
this.stopped = true;
146
146
+
this._clearTimer();
147
147
+
this._close();
148
148
+
this.callbacks.onDisconnected?.();
149
149
+
}
150
150
+
151
151
+
resetCursor(): void {
152
152
+
this.cursor = null;
153
153
+
}
154
154
+
155
155
+
private _buildUrl(): string {
156
156
+
const params = new URLSearchParams();
157
157
+
for (const col of WANTED_COLLECTIONS) {
158
158
+
params.append("wantedCollections", col);
159
159
+
}
160
160
+
if (this.cursor !== null) {
161
161
+
params.set("cursor", String(this.cursor));
162
162
+
}
163
163
+
return `${JETSTREAM_URL}?${params.toString()}`;
164
164
+
}
165
165
+
166
166
+
private _open(): void {
167
167
+
this._close();
168
168
+
169
169
+
let ws: WebSocket;
170
170
+
try {
171
171
+
ws = new WebSocket(this._buildUrl());
172
172
+
} catch {
173
173
+
if (!this.stopped) this._scheduleReconnect();
174
174
+
return;
175
175
+
}
176
176
+
177
177
+
this.ws = ws;
178
178
+
179
179
+
ws.onopen = () => {
180
180
+
this.callbacks.onConnected?.();
181
181
+
};
182
182
+
183
183
+
ws.onmessage = (ev: MessageEvent) => {
184
184
+
try {
185
185
+
const event = JSON.parse(ev.data as string) as JetstreamEvent;
186
186
+
this.cursor = event.time_us;
187
187
+
188
188
+
if (event.kind !== "commit" || !event.commit) return;
189
189
+
190
190
+
const kind = toActivityKind(event.commit);
191
191
+
if (!kind) return;
192
192
+
193
193
+
const { targetName, targetOwnerDid } = extractTargetInfo(event.commit);
194
194
+
195
195
+
const item: ActivityItem = {
196
196
+
id: `${event.did}-${event.commit.collection}-${event.commit.rkey}`,
197
197
+
kind,
198
198
+
actorDid: event.did,
199
199
+
actorHandle: placeholderHandle(event.did),
200
200
+
targetUri:
201
201
+
targetOwnerDid && targetName
202
202
+
? `at://${targetOwnerDid}/${event.commit.collection}/${targetName}`
203
203
+
: undefined,
204
204
+
targetName,
205
205
+
targetOwnerDid,
206
206
+
createdAt: new Date(Math.floor(event.time_us / 1_000)).toISOString(),
207
207
+
};
208
208
+
209
209
+
this.callbacks.onEvent(item);
210
210
+
} catch (error) {
211
211
+
console.warn("Failed to parse Jetstream event", { raw: ev.data, error });
212
212
+
}
213
213
+
};
214
214
+
215
215
+
ws.onerror = () => {
216
216
+
this.callbacks.onError?.();
217
217
+
};
218
218
+
219
219
+
ws.onclose = () => {
220
220
+
this.ws = null;
221
221
+
if (!this.stopped) {
222
222
+
this._scheduleReconnect();
223
223
+
}
224
224
+
};
225
225
+
}
226
226
+
227
227
+
private _close(): void {
228
228
+
if (this.ws) {
229
229
+
this.ws.onclose = null;
230
230
+
this.ws.onerror = null;
231
231
+
this.ws.onmessage = null;
232
232
+
this.ws.onopen = null;
233
233
+
this.ws.close();
234
234
+
this.ws = null;
235
235
+
}
236
236
+
}
237
237
+
238
238
+
private _clearTimer(): void {
239
239
+
if (this.reconnectTimer !== null) {
240
240
+
clearTimeout(this.reconnectTimer);
241
241
+
this.reconnectTimer = null;
242
242
+
}
243
243
+
}
244
244
+
245
245
+
private _scheduleReconnect(): void {
246
246
+
this._clearTimer();
247
247
+
this.callbacks.onDisconnected?.();
248
248
+
this.reconnectTimer = setTimeout(() => {
249
249
+
if (!this.stopped) this._open();
250
250
+
}, RECONNECT_DELAY_MS);
251
251
+
}
252
252
+
}
+8
-8
docs/roadmap.md
···
52
52
53
53
**Depends on:** API: Constellation Integration
54
54
55
55
-
- [ ] Search service pointing at Twister API
56
56
-
- [ ] Constellation service for star/follower counts
57
57
-
- [ ] Debounced search on Explore tab with segmented results
58
58
-
- [ ] Recent search history (local)
59
59
-
- [ ] Graceful fallback when search API unavailable
60
60
-
- [ ] Activity feed data source investigation (Jetstream vs polling)
61
61
-
- [ ] Activity tab with filters, infinite scroll, pull-to-refresh
62
62
-
- [ ] Home tab: surface recently viewed repos/profiles
55
55
+
- [x] Search service pointing at Twister API
56
56
+
- [x] Constellation service for star/follower counts
57
57
+
- [x] Debounced search on Explore tab with segmented results
58
58
+
- [x] Recent search history (local)
59
59
+
- [x] Graceful fallback when search API unavailable
60
60
+
- [x] Activity feed data source investigation (Jetstream vs polling)
61
61
+
- [x] Activity tab with filters, infinite scroll, pull-to-refresh
62
62
+
- [x] Home tab: surface recently viewed repos/profiles
63
63
64
64
## App: Authentication & Social
65
65
+86
-1
packages/api/internal/store/db.go
···
50
50
return "libsql", url + "?authToken=" + token
51
51
}
52
52
53
53
-
// Migrate runs all embedded SQL migration files in order.
53
53
+
// Migrate runs all embedded SQL migration files in order, skipping any that
54
54
+
// have already been applied. Applied filenames are recorded in the
55
55
+
// schema_migrations table so re-runs are idempotent.
54
56
func Migrate(db *sql.DB, url string) error {
57
57
+
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
58
58
+
filename TEXT PRIMARY KEY,
59
59
+
applied_at TEXT NOT NULL
60
60
+
)`); err != nil {
61
61
+
return fmt.Errorf("create schema_migrations table: %w", err)
62
62
+
}
63
63
+
64
64
+
// For databases that were created before migration tracking was added,
65
65
+
// backfill schema_migrations by introspecting which tables/columns exist.
66
66
+
if err := backfillMigrationHistory(db); err != nil {
67
67
+
return fmt.Errorf("backfill migration history: %w", err)
68
68
+
}
69
69
+
55
70
mode := migrationMode{
56
71
allowTursoExtensionSkip: strings.HasPrefix(url, "file:"),
57
72
targetDescription: migrationTargetDescription(url),
···
67
82
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
68
83
continue
69
84
}
85
85
+
var already int
86
86
+
_ = db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE filename = ?`, entry.Name()).Scan(&already)
87
87
+
if already > 0 {
88
88
+
slog.Debug("migration already applied, skipping", "file", entry.Name())
89
89
+
continue
90
90
+
}
70
91
data, err := migrationsFS.ReadFile("migrations/" + entry.Name())
71
92
if err != nil {
72
93
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
···
74
95
if err := execMigration(db, entry.Name(), string(data), mode); err != nil {
75
96
return err
76
97
}
98
98
+
if _, err := db.Exec(
99
99
+
`INSERT INTO schema_migrations (filename, applied_at) VALUES (?, datetime('now'))`,
100
100
+
entry.Name(),
101
101
+
); err != nil {
102
102
+
return fmt.Errorf("record migration %s: %w", entry.Name(), err)
103
103
+
}
77
104
slog.Info("migration applied", "file", entry.Name())
78
105
}
79
106
return nil
107
107
+
}
108
108
+
109
109
+
// backfillMigrationHistory records already-applied migrations for databases
110
110
+
// that pre-date the schema_migrations tracking table. It is a no-op if the
111
111
+
// table already has any entries (i.e. tracking was already in place).
112
112
+
func backfillMigrationHistory(db *sql.DB) error {
113
113
+
var count int
114
114
+
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil || count > 0 {
115
115
+
return nil
116
116
+
}
117
117
+
118
118
+
// If the documents table does not exist yet this is a fresh database — nothing to backfill.
119
119
+
if !sqliteTableExists(db, "documents") {
120
120
+
return nil
121
121
+
}
122
122
+
123
123
+
mark := func(filename string) {
124
124
+
_, _ = db.Exec(
125
125
+
`INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, datetime('now'))`,
126
126
+
filename,
127
127
+
)
128
128
+
}
129
129
+
130
130
+
// 001 — documents table is present.
131
131
+
mark("001_initial.sql")
132
132
+
133
133
+
// 002 — identity_handles table.
134
134
+
if sqliteTableExists(db, "identity_handles") {
135
135
+
mark("002_identity_handles.sql")
136
136
+
}
137
137
+
138
138
+
// 003 — documents_fts virtual table.
139
139
+
if sqliteTableExists(db, "documents_fts") {
140
140
+
mark("003_documents_fts.sql")
141
141
+
}
142
142
+
143
143
+
// 004 — web_url column on documents.
144
144
+
if sqliteColumnExists(db, "documents", "web_url") {
145
145
+
mark("004_web_url.sql")
146
146
+
}
147
147
+
148
148
+
return nil
149
149
+
}
150
150
+
151
151
+
func sqliteTableExists(db *sql.DB, table string) bool {
152
152
+
var n int
153
153
+
_ = db.QueryRow(
154
154
+
`SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table','view') AND name = ?`, table,
155
155
+
).Scan(&n)
156
156
+
return n > 0
157
157
+
}
158
158
+
159
159
+
func sqliteColumnExists(db *sql.DB, table, column string) bool {
160
160
+
var n int
161
161
+
_ = db.QueryRow(
162
162
+
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, column,
163
163
+
).Scan(&n)
164
164
+
return n > 0
80
165
}
81
166
82
167
func execMigration(db *sql.DB, name, content string, mode migrationMode) error {