tangled
alpha
login
or
join now
jordanreger.com
/
htmlsky
0
fork
atom
An HTML-only Bluesky frontend
0
fork
atom
overview
issues
pulls
pipelines
mvp
jordanreger.com
2 years ago
b48989a0
44795b1b
+207
-379
11 changed files
expand all
collapse all
unified
split
actor.ts
components
head.tsx
header.tsx
deno.json
deno.lock
facets.ts
facets.tsx
main.ts
main.tsx
pages
actor.tsx
static
avatar.jpg
+71
actor.ts
···
1
1
+
import { ProfileView } from "npm:@atproto/api";
2
2
+
3
3
+
import { agent } from "./main.ts";
4
4
+
import { getDescriptionFacets } from "./facets.ts";
5
5
+
6
6
+
export default class Actor {
7
7
+
uri: string;
8
8
+
actor: ProfileView;
9
9
+
10
10
+
constructor(uri: string) {
11
11
+
this.uri = uri;
12
12
+
this.actor = this.#get();
13
13
+
}
14
14
+
15
15
+
async #get() {
16
16
+
const { data: actor } = await agent.api.app.bsky.actor.getProfile({
17
17
+
actor: this.uri,
18
18
+
});
19
19
+
return actor;
20
20
+
}
21
21
+
22
22
+
async Raw(): string {
23
23
+
const actor = await this.actor;
24
24
+
25
25
+
return JSON.stringify(actor, null, 2);
26
26
+
}
27
27
+
28
28
+
async HTML(): string {
29
29
+
const actor = await this.actor;
30
30
+
31
31
+
actor.username = actor.displayName ? actor.displayName : actor.handle;
32
32
+
actor.avatar = actor.avatar ? actor.avatar : "/static/avatar.jpg";
33
33
+
34
34
+
return `
35
35
+
<head>
36
36
+
<meta name="color-scheme" content="light dark">
37
37
+
<title>${actor.username} (@${actor.handle}) — HTMLsky</title>
38
38
+
</head>
39
39
+
<table>
40
40
+
<tr>
41
41
+
<td valign="top" height="60" width="60">
42
42
+
<img src="${actor.avatar}" alt="${actor.username}'s avatar" height="60" width="60">
43
43
+
</td>
44
44
+
<td>
45
45
+
<h1>
46
46
+
<span>${actor.username}</span><br>
47
47
+
<small><small><small>@${actor.handle}</small></small></small>
48
48
+
</h1>
49
49
+
</td>
50
50
+
</tr>
51
51
+
${actor.description ? `<tr>
52
52
+
<td colspan="2">
53
53
+
<p>${await getDescriptionFacets(actor.description).then(res => res.replaceAll("\n", "<br>"))}</p>
54
54
+
</td>
55
55
+
</tr>
56
56
+
<tr>
57
57
+
<td colspan="2"> </td>
58
58
+
</tr>`
59
59
+
: ``}
60
60
+
<tr>
61
61
+
<td colspan="2">
62
62
+
<b>${actor.followersCount}</b> followers
63
63
+
<b>${actor.followsCount}</b> following
64
64
+
<b>${actor.postsCount}</b> posts
65
65
+
</td>
66
66
+
</tr>
67
67
+
</table>
68
68
+
<hr>
69
69
+
`;
70
70
+
}
71
71
+
}
-8
components/head.tsx
···
1
1
-
export const Head = ({ title }: { title: string }) => {
2
2
-
return (
3
3
-
<head>
4
4
-
<meta name="color-scheme" content="light dark" />
5
5
-
<title>{title} — HTMLsky</title>
6
6
-
</head>
7
7
-
);
8
8
-
};
-23
components/header.tsx
···
1
1
-
import { AppBskyActorDefs } from "npm:@atproto/api";
2
2
-
3
3
-
export function ActorHeader(actor: AppBskyActorDefs.ProfileViewDetailed) {
4
4
-
return (
5
5
-
<header>
6
6
-
<nav>
7
7
-
<span>
8
8
-
<b>
9
9
-
<i>HTMLsky</i>
10
10
-
</b>
11
11
-
</span>
12
12
-
[ <a href="/">Home</a> ] [{" "}
13
13
-
<a href="https://sr.ht/~jordanreger/htmlsky">Source</a> ] [{" "}
14
14
-
<a href={`https://bsky.app/profile/${actor.did}`}>
15
15
-
View on Bluesky
16
16
-
</a>{" "}
17
17
-
]
18
18
-
</nav>
19
19
-
20
20
-
<hr />
21
21
-
</header>
22
22
-
);
23
23
-
}
+1
-1
deno.json
···
1
1
{
2
2
"tasks": {
3
3
-
"dev": "deno task clean && deno run -A --watch main.tsx",
3
3
+
"dev": "deno task clean && deno run -A --watch main.ts",
4
4
"clean": "deno fmt && deno lint"
5
5
},
6
6
"compilerOptions": {
+47
deno.lock
···
2
2
"version": "3",
3
3
"packages": {
4
4
"specifiers": {
5
5
+
"jsr:@std/cli@^0.224.7": "jsr:@std/cli@0.224.7",
6
6
+
"jsr:@std/encoding@1.0.0-rc.2": "jsr:@std/encoding@1.0.0-rc.2",
7
7
+
"jsr:@std/fmt@^0.225.4": "jsr:@std/fmt@0.225.4",
8
8
+
"jsr:@std/http": "jsr:@std/http@0.224.5",
9
9
+
"jsr:@std/media-types": "jsr:@std/media-types@0.224.1",
10
10
+
"jsr:@std/media-types@^1.0.0-rc.1": "jsr:@std/media-types@1.0.0",
11
11
+
"jsr:@std/net@^0.224.3": "jsr:@std/net@0.224.3",
12
12
+
"jsr:@std/path@1.0.0-rc.2": "jsr:@std/path@1.0.0-rc.2",
13
13
+
"jsr:@std/streams@^0.224.5": "jsr:@std/streams@0.224.5",
5
14
"npm:@atproto/api": "npm:@atproto/api@0.12.8",
6
15
"npm:preact": "npm:preact@10.22.0",
7
16
"npm:preact-render-to-string": "npm:preact-render-to-string@6.5.5_preact@10.22.0",
8
17
"npm:preact@10.22.0": "npm:preact@10.22.0",
9
18
"npm:sanitize-html": "npm:sanitize-html@2.13.0"
19
19
+
},
20
20
+
"jsr": {
21
21
+
"@std/cli@0.224.7": {
22
22
+
"integrity": "654ca6477518e5e3a0d3fabafb2789e92b8c0febf1a1d24ba4b567aba94b5977"
23
23
+
},
24
24
+
"@std/encoding@1.0.0-rc.2": {
25
25
+
"integrity": "160d7674a20ebfbccdf610b3801fee91cf6e42d1c106dd46bbaf46e395cd35ef"
26
26
+
},
27
27
+
"@std/fmt@0.225.4": {
28
28
+
"integrity": "584c681cf422b70e28959b57e59012823609c087384cbf12d05f67814797fda3"
29
29
+
},
30
30
+
"@std/http@0.224.5": {
31
31
+
"integrity": "b03b5d1529f6c423badfb82f6640f9f2557b4034cd7c30655ba5bb447ff750a4",
32
32
+
"dependencies": [
33
33
+
"jsr:@std/cli@^0.224.7",
34
34
+
"jsr:@std/encoding@1.0.0-rc.2",
35
35
+
"jsr:@std/fmt@^0.225.4",
36
36
+
"jsr:@std/media-types@^1.0.0-rc.1",
37
37
+
"jsr:@std/net@^0.224.3",
38
38
+
"jsr:@std/path@1.0.0-rc.2",
39
39
+
"jsr:@std/streams@^0.224.5"
40
40
+
]
41
41
+
},
42
42
+
"@std/media-types@0.224.1": {
43
43
+
"integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1"
44
44
+
},
45
45
+
"@std/media-types@1.0.0": {
46
46
+
"integrity": "58ff81ce1f7af8c46304a52482db804c42936492daad19c0bd5d887832737d2b"
47
47
+
},
48
48
+
"@std/net@0.224.3": {
49
49
+
"integrity": "a6257b9a35a4f299a0a9d4a4b76aef1f90ad05572374c5267c6c51a2ec41dfba"
50
50
+
},
51
51
+
"@std/path@1.0.0-rc.2": {
52
52
+
"integrity": "39f20d37a44d1867abac8d91c169359ea6e942237a45a99ee1e091b32b921c7d"
53
53
+
},
54
54
+
"@std/streams@0.224.5": {
55
55
+
"integrity": "bcde7818dd5460d474cdbd674b15f6638b9cd73cd64e52bd852fba2bd4d8ec91"
56
56
+
}
10
57
},
11
58
"npm": {
12
59
"@atproto/api@0.12.8": {
+25
facets.ts
···
1
1
+
import { AtpAgent, RichText } from "npm:@atproto/api";
2
2
+
import { agent } from "./main.ts";
3
3
+
4
4
+
export async function getDescriptionFacets(
5
5
+
description: string,
6
6
+
): Promise<string> {
7
7
+
const rt = new RichText({ text: description });
8
8
+
await rt.detectFacets(agent);
9
9
+
10
10
+
let descriptionWithFacets = "";
11
11
+
12
12
+
for (const segment of rt.segments()) {
13
13
+
if (segment.isLink()) {
14
14
+
descriptionWithFacets +=
15
15
+
`<a href="${segment.link?.uri}">${segment.text}</a>`;
16
16
+
} else if (segment.isMention()) {
17
17
+
descriptionWithFacets +=
18
18
+
`<a href="/profile/${segment.mention?.did}">${segment.text}</a>`;
19
19
+
} else {
20
20
+
descriptionWithFacets += segment.text;
21
21
+
}
22
22
+
}
23
23
+
24
24
+
return descriptionWithFacets;
25
25
+
}
-28
facets.tsx
···
1
1
-
import { RichText } from "npm:@atproto/api";
2
2
-
3
3
-
import { agent } from "./main.tsx";
4
4
-
5
5
-
export async function GetDescriptionFacets(
6
6
-
description: string,
7
7
-
): Promise<preact.VNode[]> {
8
8
-
const rt = new RichText({ text: description });
9
9
-
await rt.detectFacets(agent);
10
10
-
11
11
-
const descriptionWithFacets: preact.VNode[] = [];
12
12
-
13
13
-
for (const segment of rt.segments()) {
14
14
-
if (segment.isLink()) {
15
15
-
descriptionWithFacets.push(
16
16
-
<a href={`${segment.link?.uri}`}>{segment.text}</a>,
17
17
-
);
18
18
-
} else if (segment.isMention()) {
19
19
-
descriptionWithFacets.push(
20
20
-
<a href={`/profile/${segment.mention?.did}`}>{segment.text}</a>,
21
21
-
);
22
22
-
} else {
23
23
-
descriptionWithFacets.push(<>{segment.text</>);
24
24
-
}
25
25
-
}
26
26
-
27
27
-
return descriptionWithFacets;
28
28
-
}
+63
main.ts
···
1
1
+
import { AtpAgent } from "npm:@atproto/api";
2
2
+
import { serveDir } from "jsr:@std/http/file-server";
3
3
+
4
4
+
import Actor from "./actor.ts";
5
5
+
6
6
+
export const agent = new AtpAgent({ service: "https://public.api.bsky.app" });
7
7
+
8
8
+
Deno.serve(async (req) => {
9
9
+
const url = new URL(req.url);
10
10
+
const path = url.pathname;
11
11
+
12
12
+
if (path.startsWith("/static")) {
13
13
+
return serveDir(req, { quiet: true });
14
14
+
}
15
15
+
16
16
+
// handle trailing slashes
17
17
+
if (path.at(-1) !== "/") {
18
18
+
return Response.redirect(
19
19
+
`${url.protocol}${url.host}${url.pathname}/`,
20
20
+
);
21
21
+
}
22
22
+
23
23
+
try {
24
24
+
// PROFILE
25
25
+
const profilePattern = new URLPattern({ pathname: "/profile/:actor/" });
26
26
+
if (profilePattern.test(url)) {
27
27
+
const actorName = profilePattern.exec(url)?.pathname.groups.actor;
28
28
+
const actor = new Actor(actorName);
29
29
+
30
30
+
return new Response(await actor.HTML(), html_headers);
31
31
+
}
32
32
+
33
33
+
// RAW PROFILE
34
34
+
const rawProfilePattern = new URLPattern({ pathname: "/raw/profile/:actor/" });
35
35
+
if (rawProfilePattern.test(url)) {
36
36
+
const actorName = rawProfilePattern.exec(url)?.pathname.groups.actor;
37
37
+
const actor = new Actor(actorName);
38
38
+
39
39
+
return new Response(await actor.Raw(), json_headers);
40
40
+
}
41
41
+
} catch (error) {
42
42
+
return new Response(
43
43
+
`<head><meta name="color-scheme" content="light dark"></head>\n${error}`,
44
44
+
headers,
45
45
+
);
46
46
+
}
47
47
+
48
48
+
return Response.redirect(
49
49
+
`${url.protocol}${url.host}/profile/htmlsky.app/`,
50
50
+
303,
51
51
+
);
52
52
+
});
53
53
+
54
54
+
const html_headers: ResponseInit = {
55
55
+
"headers": {
56
56
+
"Content-Type": "text/html;charset=utf-8",
57
57
+
},
58
58
+
};
59
59
+
const json_headers: ResponseInit = {
60
60
+
"headers": {
61
61
+
"Content-Type": "application/json;charset=utf-8",
62
62
+
},
63
63
+
};
-91
main.tsx
···
1
1
-
import { renderToStringAsync } from "npm:preact-render-to-string";
2
2
-
import { AtpAgent } from "npm:@atproto/api";
3
3
-
4
4
-
import {
5
5
-
Actor,
6
6
-
ActorFeed,
7
7
-
ActorFollowers,
8
8
-
ActorFollows,
9
9
-
} from "./pages/actor.tsx";
10
10
-
11
11
-
export const agent = new AtpAgent({ service: "https://public.api.bsky.app" });
12
12
-
13
13
-
Deno.serve({
14
14
-
port: 8080,
15
15
-
}, async (req) => {
16
16
-
const url = new URL(req.url);
17
17
-
const path = url.pathname;
18
18
-
19
19
-
// handle trailing slashes
20
20
-
if (path.at(-1) !== "/") {
21
21
-
return Response.redirect(
22
22
-
`${url.protocol}${url.host}${url.pathname}/`,
23
23
-
);
24
24
-
}
25
25
-
26
26
-
const profilePattern = new URLPattern({ pathname: "/profile/:actor/" });
27
27
-
if (profilePattern.test(url)) {
28
28
-
const actorName = profilePattern.exec(url)?.pathname.groups.actor;
29
29
-
30
30
-
try {
31
31
-
const actor = await agent.api.app.bsky.actor.getProfile({
32
32
-
actor: actorName!,
33
33
-
}).then((res) => res.data);
34
34
-
35
35
-
return new Response(
36
36
-
await renderToStringAsync(await Actor({ actor: actor })),
37
37
-
headers,
38
38
-
);
39
39
-
} catch (e) {
40
40
-
// TODO: add error page
41
41
-
return new Response(e.message, headers);
42
42
-
}
43
43
-
}
44
44
-
const profilePagePattern = new URLPattern({
45
45
-
pathname: "/profile/:actor/:page/",
46
46
-
});
47
47
-
if (profilePagePattern.test(url)) {
48
48
-
const actorName = profilePagePattern.exec(url)?.pathname.groups.actor;
49
49
-
const actor = await agent.api.app.bsky.actor.getProfile({
50
50
-
actor: actorName!,
51
51
-
}).then((res) => res.data);
52
52
-
const page = profilePagePattern.exec(url)?.pathname.groups.page;
53
53
-
54
54
-
if (page === "followers") {
55
55
-
return new Response(
56
56
-
await renderToStringAsync(
57
57
-
await ActorFollowers({ url: url, actor: actor }),
58
58
-
),
59
59
-
headers,
60
60
-
);
61
61
-
}
62
62
-
if (page === "follows") {
63
63
-
return new Response(
64
64
-
await renderToStringAsync(
65
65
-
await ActorFollows({ url: url, actor: actor }),
66
66
-
),
67
67
-
headers,
68
68
-
);
69
69
-
}
70
70
-
71
71
-
if (page === "posts") {
72
72
-
return new Response(
73
73
-
await renderToStringAsync(
74
74
-
await ActorFeed({ url: url, actor: actor }),
75
75
-
),
76
76
-
headers,
77
77
-
);
78
78
-
}
79
79
-
}
80
80
-
81
81
-
return Response.redirect(
82
82
-
`${url.protocol}${url.host}/profile/htmlsky.app/`,
83
83
-
303,
84
84
-
);
85
85
-
});
86
86
-
87
87
-
const headers: ResponseInit = {
88
88
-
"headers": {
89
89
-
"Content-Type": "text/html;charset=utf-8",
90
90
-
},
91
91
-
};
-228
pages/actor.tsx
···
1
1
-
import { AppBskyActorDefs /*AppBskyFeedDefs*/ } from "npm:@atproto/api";
2
2
-
import sanitizeHtml from "npm:sanitize-html";
3
3
-
4
4
-
import { agent } from "../main.tsx";
5
5
-
import { GetDescriptionFacets } from "../facets.tsx";
6
6
-
7
7
-
// Components
8
8
-
import { Head } from "../components/head.tsx";
9
9
-
10
10
-
/* MAIN PAGE */
11
11
-
export async function Actor(
12
12
-
{ actor }: { actor: AppBskyActorDefs.ProfileViewDetailed },
13
13
-
) {
14
14
-
return (
15
15
-
<>
16
16
-
<Head
17
17
-
title={actor.displayName
18
18
-
? actor.displayName + ` (@${actor.handle})`
19
19
-
: `@${actor.handle}`}
20
20
-
/>
21
21
-
<table draggable={false}>
22
22
-
<tr>
23
23
-
<td>
24
24
-
<img
25
25
-
src={actor.avatar
26
26
-
? actor.avatar
27
27
-
: "https://htmlsky.app/avatar.jpeg"}
28
28
-
alt={`${sanitizeHtml(actor.displayName)}'s avatar`}
29
29
-
width="90"
30
30
-
/>
31
31
-
</td>
32
32
-
<td> </td>
33
33
-
<td>
34
34
-
<small>
35
35
-
<small>
36
36
-
<small>
37
37
-
<span> </span>
38
38
-
</small>
39
39
-
</small>
40
40
-
</small>
41
41
-
<h1>
42
42
-
<span
43
43
-
dangerouslySetInnerHTML={{
44
44
-
__html: actor.displayName ? actor.displayName : actor.handle,
45
45
-
}}
46
46
-
>
47
47
-
</span>
48
48
-
<br />
49
49
-
<small>
50
50
-
<small>
51
51
-
<small>
52
52
-
<i>@{actor.handle}</i>
53
53
-
</small>
54
54
-
</small>
55
55
-
</small>
56
56
-
</h1>
57
57
-
</td>
58
58
-
</tr>
59
59
-
</table>
60
60
-
61
61
-
<p>
62
62
-
<span>
63
63
-
<a href="./followers">
64
64
-
<b>{actor.followersCount}</b> followers
65
65
-
</a>
66
66
-
</span>
67
67
-
<span>
68
68
-
<a href="./follows">
69
69
-
<b>{actor.followsCount}</b> following
70
70
-
</a>
71
71
-
</span>
72
72
-
<span>
73
73
-
<b>{actor.postsCount}</b> posts
74
74
-
</span>
75
75
-
</p>
76
76
-
77
77
-
<p>
78
78
-
{await GetDescriptionFacets(sanitizeHtml(actor.description))}
79
79
-
</p>
80
80
-
81
81
-
<hr />
82
82
-
83
83
-
<p>A list of posts will be here eventually.</p>
84
84
-
</>
85
85
-
);
86
86
-
}
87
87
-
88
88
-
/* FOLLOWS */
89
89
-
export async function ActorFollows(
90
90
-
{ url, actor }: { url: URL; actor: AppBskyActorDefs.ProfileViewDetailed },
91
91
-
) {
92
92
-
const cursor = url.searchParams.get("cursor")!;
93
93
-
94
94
-
const follows: AppBskyActorDefs.ProfileViewDetailed[] = await agent.api.app
95
95
-
.bsky.graph.getFollows({ actor: actor.did, cursor: cursor }).then((res) =>
96
96
-
res.data
97
97
-
).then(
98
98
-
(res) => {
99
99
-
//cursor = res.cursor!;
100
100
-
return res.follows;
101
101
-
},
102
102
-
);
103
103
-
104
104
-
const followList: preact.VNode[] = [];
105
105
-
106
106
-
follows.forEach((follow) => {
107
107
-
followList.push(
108
108
-
<p>
109
109
-
<b>{follow.displayName ? follow.displayName : follow.handle}</b>
110
110
-
<br />{" "}
111
111
-
<a
112
112
-
href={`/profile/${
113
113
-
follow.handle !== "handle.invalid" ? follow.handle : follow.did
114
114
-
}/`}
115
115
-
>
116
116
-
@{follow.handle}
117
117
-
</a>
118
118
-
</p>,
119
119
-
);
120
120
-
});
121
121
-
122
122
-
if (cursor) {
123
123
-
followList.push(<a href={`?cursor=${cursor}`}>Next page</a>);
124
124
-
}
125
125
-
126
126
-
return (
127
127
-
<>
128
128
-
<Head title={`People followed by @${actor.handle}`} />
129
129
-
<header>
130
130
-
<a href="..">Back</a>
131
131
-
<span>
132
132
-
<b>
133
133
-
<i>
134
134
-
{actor.displayName ? actor.displayName : actor.handle}'s Follows
135
135
-
</i>
136
136
-
</b>
137
137
-
</span>
138
138
-
139
139
-
<hr />
140
140
-
</header>
141
141
-
{followList}
142
142
-
</>
143
143
-
);
144
144
-
}
145
145
-
146
146
-
/* FOLLOWERS */
147
147
-
export async function ActorFollowers(
148
148
-
{ url, actor }: { url: URL; actor: AppBskyActorDefs.ProfileViewDetailed },
149
149
-
) {
150
150
-
const cursor = url.searchParams.get("cursor")!;
151
151
-
152
152
-
const followers: AppBskyActorDefs.ProfileViewDetailed[] = await agent.api.app
153
153
-
.bsky.graph.getFollowers({ actor: actor.did, cursor: cursor }).then((res) =>
154
154
-
res.data
155
155
-
).then(
156
156
-
(res) => {
157
157
-
//cursor = res.cursor!;
158
158
-
return res.followers;
159
159
-
},
160
160
-
);
161
161
-
162
162
-
const followersList: preact.VNode[] = [];
163
163
-
164
164
-
followers.forEach((follower) => {
165
165
-
followersList.push(
166
166
-
<p>
167
167
-
<b>{follower.displayName ? follower.displayName : follower.handle}</b>
168
168
-
<br /> <a href={`/profile/${follower.handle}/`}>@{follower.handle}</a>
169
169
-
</p>,
170
170
-
);
171
171
-
});
172
172
-
173
173
-
if (cursor) {
174
174
-
followersList.push(<a href={`?cursor=${cursor}`}>Next page</a>);
175
175
-
}
176
176
-
177
177
-
return (
178
178
-
<>
179
179
-
<Head title={`People following @${actor.handle}`} />
180
180
-
<header>
181
181
-
<a href="..">Back</a>
182
182
-
<span>
183
183
-
<b>
184
184
-
<i>
185
185
-
{actor.displayName ? actor.displayName : actor.handle}'s Followers
186
186
-
</i>
187
187
-
</b>
188
188
-
</span>
189
189
-
190
190
-
<hr />
191
191
-
</header>
192
192
-
{followersList}
193
193
-
</>
194
194
-
);
195
195
-
}
196
196
-
197
197
-
/* FEED */
198
198
-
export async function ActorFeed(
199
199
-
{ url, actor }: { url: URL; actor: AppBskyActorDefs.ProfileViewDetailed },
200
200
-
) {
201
201
-
// TODO: implement cursor
202
202
-
const _prevCursor = url.searchParams.get("cursor")!;
203
203
-
204
204
-
const { data } = await agent.api.app
205
205
-
.bsky.feed.getAuthorFeed({ actor: actor.did });
206
206
-
207
207
-
const { feed, cursor } = data;
208
208
-
209
209
-
const feedList: preact.VNode[] = [];
210
210
-
211
211
-
feed.forEach((post) => {
212
212
-
feedList.push(
213
213
-
<p>
214
214
-
{post.post}
215
215
-
</p>,
216
216
-
);
217
217
-
});
218
218
-
219
219
-
if (cursor) {
220
220
-
feedList.push(<a href={`?cursor=${cursor}`}>Next page</a>);
221
221
-
}
222
222
-
223
223
-
return (
224
224
-
<>
225
225
-
{feedList}
226
226
-
</>
227
227
-
);
228
228
-
}
static/avatar.jpg
This is a binary file and will not be displayed.