tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
297
fork
atom
a tool for shared writing and social publishing
297
fork
atom
overview
issues
31
pulls
pipelines
get basic mentions working
awarm.space
1 week ago
71b1daae
5f078591
+83
-44
9 changed files
expand all
collapse all
unified
split
app
[leaflet_id]
publish
BskyPostEditorProsemirror.tsx
api
rpc
[command]
proxy_mention_search.ts
components
AtMentionLink.tsx
Blocks
TextBlock
RenderYJSFragment.tsx
schema.ts
Mention.tsx
lexicons
parts
page
mention
searchService.json
src
mentionService.ts
mentions
services
wikipedia.ts
+4
-3
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
reviewed
···
435
435
const atMentionNode = schema.nodes.atMention.create({
436
436
atURI: mention.uri,
437
437
text,
438
438
-
...(mention.type === "service_result" && mention.href
439
439
-
? { href: mention.href }
440
440
-
: {}),
438
438
+
...(mention.type === "service_result" && {
439
439
+
href: mention.href,
440
440
+
icon: mention.icon,
441
441
+
}),
441
442
});
442
443
tr.insert(from, atMentionNode);
443
444
}
+1
app/api/rpc/[command]/proxy_mention_search.ts
reviewed
···
67
67
uri: String(r.uri || ""),
68
68
name: String(r.name || ""),
69
69
href: r.href ? String(r.href) : undefined,
70
70
+
icon: r.icon ? String(r.icon) : undefined,
70
71
})),
71
72
},
72
73
};
+18
-12
components/AtMentionLink.tsx
reviewed
···
8
8
export function AtMentionLink({
9
9
atURI,
10
10
href,
11
11
+
icon: iconUrl,
11
12
children,
12
13
className = "",
13
14
}: {
14
15
atURI: string;
15
16
href?: string;
17
17
+
icon?: string;
16
18
children: React.ReactNode;
17
19
className?: string;
18
20
}) {
19
21
const { isPublication, isDocument } = classifyAtUri(atURI);
20
22
21
21
-
// Show publication icon if available
22
22
-
const icon =
23
23
-
isPublication || isDocument ? (
24
24
-
<img
25
25
-
src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
26
26
-
className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top"
27
27
-
alt=""
28
28
-
width="20"
29
29
-
height="20"
30
30
-
loading="lazy"
31
31
-
/>
32
32
-
) : null;
23
23
+
// Show publication icon, or service-provided icon
24
24
+
const iconSrc =
25
25
+
isPublication || isDocument
26
26
+
? `/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`
27
27
+
: iconUrl ?? null;
28
28
+
29
29
+
const icon = iconSrc ? (
30
30
+
<img
31
31
+
src={iconSrc}
32
32
+
className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top"
33
33
+
alt=""
34
34
+
width="20"
35
35
+
height="20"
36
36
+
loading="lazy"
37
37
+
/>
38
38
+
) : null;
33
39
34
40
const linkHref = href || atUriToUrl(atURI);
35
41
+2
-1
components/Blocks/TextBlock/RenderYJSFragment.tsx
reviewed
···
114
114
const atURI = node.getAttribute("atURI") || "";
115
115
const text = node.getAttribute("text") || "";
116
116
const href = node.getAttribute("href") || undefined;
117
117
+
const icon = node.getAttribute("icon") || undefined;
117
118
return (
118
118
-
<AtMentionLink key={index} atURI={atURI} href={href}>
119
119
+
<AtMentionLink key={index} atURI={atURI} href={href} icon={icon}>
119
120
{text}
120
121
</AtMentionLink>
121
122
);
+13
-3
components/Blocks/TextBlock/schema.ts
reviewed
···
128
128
atURI: {},
129
129
text: { default: "" },
130
130
href: { default: undefined },
131
131
+
icon: { default: undefined },
131
132
},
132
133
group: "inline",
133
134
inline: true,
···
142
143
atURI: dom.getAttribute("data-at-uri"),
143
144
text: dom.textContent || "",
144
145
href: dom.getAttribute("data-href") || undefined,
146
146
+
icon: dom.getAttribute("data-icon") || undefined,
145
147
};
146
148
},
147
149
},
···
161
163
if (node.attrs.href) {
162
164
attrs["data-href"] = node.attrs.href;
163
165
}
166
166
+
if (node.attrs.icon) {
167
167
+
attrs["data-icon"] = node.attrs.icon;
168
168
+
}
164
169
165
165
-
// For publications and documents, show icon
166
166
-
if (isPublication || isDocument) {
170
170
+
// Show icon for publications/documents or service results
171
171
+
const iconSrc =
172
172
+
isPublication || isDocument
173
173
+
? `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`
174
174
+
: node.attrs.icon ?? null;
175
175
+
176
176
+
if (iconSrc) {
167
177
return [
168
178
"span",
169
179
attrs,
170
180
[
171
181
"img",
172
182
{
173
173
-
src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`,
183
183
+
src: iconSrc,
174
184
class:
175
185
"inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top",
176
186
alt: "",
+32
-24
components/Mention.tsx
reviewed
···
323
323
}}
324
324
onMouseDown={(e) => e.preventDefault()}
325
325
name={result.name}
326
326
+
icon={result.icon}
326
327
selected={index === suggestionIndex}
327
328
/>
328
329
) : (
···
507
508
508
509
const ServiceSearchResult = (props: {
509
510
name: string;
511
511
+
icon?: string;
510
512
onClick: () => void;
511
513
onMouseDown: (e: React.MouseEvent) => void;
512
514
selected?: boolean;
513
515
}) => {
514
516
return (
515
517
<Result
518
518
+
icon={
519
519
+
props.icon ? (
520
520
+
<img
521
521
+
src={props.icon}
522
522
+
alt=""
523
523
+
className="w-5 h-5 rounded-full shrink-0"
524
524
+
/>
525
525
+
) : undefined
526
526
+
}
516
527
result={<div className="truncate w-full">{props.name}</div>}
517
528
onClick={props.onClick}
518
529
onMouseDown={props.onMouseDown}
···
569
580
uri: string;
570
581
name: string;
571
582
href?: string;
583
583
+
icon?: string;
572
584
};
573
585
574
586
export type MentionScope =
···
690
702
691
703
useEffect(() => {
692
704
let stale = false;
693
693
-
// Only skip debounce for purely local operations (showing service list)
694
694
-
const delay =
695
695
-
!query && hasServices && scope.type === "default" ? 0 : 300;
705
705
+
// Default scope with services: show local filter instantly, debounce network fallback
706
706
+
if (hasServices && scope.type === "default") {
707
707
+
const filtered = allServices.filter((s) =>
708
708
+
s.type === "service"
709
709
+
? s.name.toLowerCase().includes((query || "").toLowerCase())
710
710
+
: true,
711
711
+
);
712
712
+
setSuggestions(filtered);
713
713
+
714
714
+
// If local filter found matches, no need for network search
715
715
+
if (!query || filtered.length > 0) return;
716
716
+
}
696
717
697
718
const handler = setTimeout(async () => {
698
719
if (stale || !open) return;
699
720
700
700
-
if (!query && scope.type === "default") {
701
701
-
// No query: show services if available, otherwise clear (sync, no race)
702
702
-
setSuggestions(hasServices ? allServices : []);
703
703
-
return;
704
704
-
}
705
705
-
706
721
let results: Array<Mention>;
707
722
708
723
if (scope.type === "identities") {
···
716
731
query: query || "",
717
732
limit: 10,
718
733
});
719
719
-
results = documents.result.documents.map((d) => ({
734
734
+
results = (documents?.result?.documents ?? []).map((d) => ({
720
735
type: "post" as const,
721
736
uri: d.uri,
722
737
title: d.title,
···
728
743
service_uri: scope.serviceUri,
729
744
search: query || "",
730
745
});
731
731
-
results = res.result.results.map(
732
732
-
(r: { uri: string; name: string; href?: string }) => ({
746
746
+
const items = res?.result?.results ?? [];
747
747
+
results = items.map(
748
748
+
(r: { uri: string; name: string; href?: string; icon?: string }) => ({
733
749
type: "service_result" as const,
734
750
uri: r.uri,
735
751
name: r.name,
736
752
href: r.href,
753
753
+
icon: r.icon,
737
754
}),
738
755
);
739
756
} else if (hasServices) {
740
740
-
// Default scope with services: filter locally, fall back to identity search
741
741
-
const filtered = allServices.filter((s) =>
742
742
-
s.type === "service"
743
743
-
? s.name.toLowerCase().includes((query || "").toLowerCase())
744
744
-
: true,
745
745
-
);
746
746
-
if (filtered.length > 0) {
747
747
-
results = filtered;
748
748
-
} else {
749
749
-
results = await searchIdentities(query || "", 10);
750
750
-
}
757
757
+
// Default scope with services: local filter showed no matches, fall back to identity search
758
758
+
results = await searchIdentities(query || "", 10);
751
759
} else {
752
760
// Default scope, no services: search people & publications together
753
761
const [identities, publications] = await Promise.all([
···
760
768
if (!stale) {
761
769
setSuggestions(results);
762
770
}
763
763
-
}, delay);
771
771
+
}, 300);
764
772
765
773
return () => {
766
774
stale = true;
+5
lexicons/parts/page/mention/searchService.json
reviewed
···
69
69
"type": "string",
70
70
"format": "uri",
71
71
"description": "Optional web URL for the mentioned entity"
72
72
+
},
73
73
+
"icon": {
74
74
+
"type": "string",
75
75
+
"format": "uri",
76
76
+
"description": "Optional icon URL for the mentioned entity, displayed next to the mention"
72
77
}
73
78
}
74
79
}
+6
lexicons/src/mentionService.ts
reviewed
···
90
90
format: "uri",
91
91
description: "Optional web URL for the mentioned entity",
92
92
},
93
93
+
icon: {
94
94
+
type: "string",
95
95
+
format: "uri",
96
96
+
description:
97
97
+
"Optional icon URL for the mentioned entity, displayed next to the mention",
98
98
+
},
93
99
},
94
100
},
95
101
},
+2
-1
mentions/services/wikipedia.ts
reviewed
···
1
1
-
type Result = { uri: string; name: string; href?: string };
1
1
+
type Result = { uri: string; name: string; href?: string; icon?: string };
2
2
3
3
export async function wikipedia(search: string, limit: number): Promise<Result[]> {
4
4
if (!search.trim()) return [];
···
21
21
uri: urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`,
22
22
name: title,
23
23
href: urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`,
24
24
+
icon: "https://en.wikipedia.org/static/apple-touch/wikipedia.png",
24
25
}));
25
26
}