+3
bun.lock
+3
bun.lock
···
12
12
"@atcute/oauth-browser-client": "^1.0.27",
13
13
"@atcute/tid": "^1.0.3",
14
14
"@solidjs/router": "^0.15.3",
15
+
"@yaireo/relative-time": "^1.1.0",
15
16
"solid-js": "^1.9.5",
16
17
},
17
18
"devDependencies": {
···
266
267
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
267
268
268
269
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
270
+
271
+
"@yaireo/relative-time": ["@yaireo/relative-time@1.1.0", "", {}, "sha512-3XXsDpeKARlMUlGw7pBn+2cihakH0UQMr0m7hD/aZnQEJ+4Ele8IR/FzKlGzM9R+mfU7cbpyzL1PLGPTOBBiUg=="],
269
272
270
273
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.1", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2", "validate-html-nesting": "^1.2.1" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-b4iHuirqK7RgaMzB2Lsl7MqrlDgQtVRSSazyrmx7wB3T759ggGjod5Rkok5MfHjQXhR7tRPmdwoeGPqBnW2KfA=="],
271
274
+1
package.json
+1
package.json
+74
src/components/post.tsx
+74
src/components/post.tsx
···
1
+
import RelativeTime from "@yaireo/relative-time";
2
+
import { Show } from "solid-js";
3
+
import type { Post } from "../types/post";
4
+
5
+
type PostProps = {
6
+
data: Post;
7
+
};
8
+
9
+
// todo: don't just copy FA svgs in from akko-fe
10
+
const BoostIcon = () => {
11
+
return (
12
+
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
13
+
<title>Boost</title>
14
+
<path
15
+
class=""
16
+
fill="#5dc94a"
17
+
d="M272 416c17.7 0 32-14.3 32-32s-14.3-32-32-32H160c-17.7 0-32-14.3-32-32V192h32c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 128c0 53 43 96 96 96H272zM304 96c-17.7 0-32 14.3-32 32s14.3 32 32 32l112 0c17.7 0 32 14.3 32 32l0 128H416c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0V192c0-53-43-96-96-96L304 96z"
18
+
></path>
19
+
</svg>
20
+
);
21
+
};
22
+
23
+
const Post = (props: PostProps) => {
24
+
return (
25
+
<div class="post">
26
+
<Show when={props.data.context}>
27
+
<div class="post-context">
28
+
<img
29
+
src={props.data.context?.invoker.avatar}
30
+
alt={`Profile picture of ${props.data.context?.invoker.handle}`}
31
+
/>
32
+
<span class="post-context-user">
33
+
{props.data.context?.invoker.displayName}
34
+
</span>
35
+
<BoostIcon />
36
+
<span>reposted</span>
37
+
</div>
38
+
</Show>
39
+
<div class="post-content">
40
+
<img
41
+
class="post-avatar"
42
+
src={props.data.avatar}
43
+
alt={`Profile picture of ${props.data.handle}`}
44
+
/>
45
+
<div class="post-main">
46
+
<div class="post-header">
47
+
<div class="post-author">
48
+
<span>{props.data.displayName}</span>
49
+
<span class="post-author-handle">@{props.data.handle}</span>
50
+
</div>
51
+
<span class="post-time">
52
+
{new RelativeTime({ options: { style: "narrow" } }).from(
53
+
props.data.createdAt,
54
+
)}
55
+
</span>
56
+
</div>
57
+
<div class="post-body">{props.data.record.text}</div>
58
+
</div>
59
+
</div>
60
+
<div class="post-interactions">
61
+
<p>
62
+
{props.data.counts.replyCount}{" "}
63
+
{props.data.counts.replyCount === 1 ? "reply" : "replies"} |{" "}
64
+
{props.data.counts.repostCount}{" "}
65
+
{props.data.counts.repostCount === 1 ? "repost" : "reposts"} |{" "}
66
+
{props.data.counts.likeCount}{" "}
67
+
{props.data.counts.likeCount === 1 ? "like" : "likes"}
68
+
</p>
69
+
</div>
70
+
</div>
71
+
);
72
+
};
73
+
74
+
export default Post;
-1
src/components/postForm.tsx
-1
src/components/postForm.tsx
+22
-1
src/routes/dashboard.tsx
+22
-1
src/routes/dashboard.tsx
···
1
+
import { createResource, For, Match, Show, Switch } from "solid-js";
1
2
import Container from "../components/container";
2
3
import { agent, killSession, loginState } from "../components/login";
3
4
import MiniProfile from "../components/miniProfile";
4
5
import PostForm from "../components/postForm";
6
+
import { createPostElements, getFollowingTimeline } from "../utils/posts";
7
+
import Post from "../components/post";
8
+
9
+
async function renderTimeline() {
10
+
const feed = await getFollowingTimeline();
11
+
return await createPostElements(feed.feed);
12
+
}
5
13
6
14
const Dashboard = () => {
7
15
if (!loginState()) {
8
16
location.href = "/";
9
17
}
18
+
19
+
const [feed] = createResource(renderTimeline);
10
20
11
21
return (
12
22
<>
···
28
38
children={
29
39
<div class="container-content">
30
40
<div class="dashboard-feed">
31
-
<p>No more posts</p>
41
+
<Switch>
42
+
<Match when={feed.loading}>
43
+
<p>Loading...</p>
44
+
</Match>
45
+
<Match when={feed.error}>
46
+
<p>Error while loading timeline: {feed.error}</p>
47
+
</Match>
48
+
<Match when={feed()}>
49
+
<For each={feed()}>{(item) => <Post data={item} />}</For>
50
+
<p>No more posts</p>
51
+
</Match>
52
+
</Switch>
32
53
</div>
33
54
</div>
34
55
}
-1
src/routes/splash.tsx
-1
src/routes/splash.tsx
+224
src/styles/components/post.scss
+224
src/styles/components/post.scss
···
1
+
@use "../vars";
2
+
3
+
$currentColor: #1185fe;
4
+
5
+
.dashboard-feed {
6
+
font-size: 0.95rem;
7
+
display: flex;
8
+
flex-direction: column;
9
+
overflow: scroll;
10
+
p {
11
+
color: #8d8d8d;
12
+
}
13
+
max-width: 600px;
14
+
width: 100%;
15
+
min-width: 0;
16
+
17
+
@media (max-width: 850px) {
18
+
max-width: 500px;
19
+
}
20
+
21
+
@media (max-width: 768px) {
22
+
max-width: 100%;
23
+
margin: 0;
24
+
padding: 0;
25
+
}
26
+
}
27
+
28
+
.post {
29
+
display: flex;
30
+
flex-direction: column;
31
+
gap: 0.1rem;
32
+
margin: 0.5rem 0;
33
+
border-bottom: 1px solid #444;
34
+
min-width: 0;
35
+
width: 100%;
36
+
}
37
+
38
+
.post-context {
39
+
display: flex;
40
+
gap: 0.5rem;
41
+
padding-left: 2.5rem;
42
+
padding-bottom: 0.5rem;
43
+
max-height: 32px;
44
+
align-items: center;
45
+
text-align: left;
46
+
47
+
.post-context-user {
48
+
color: $currentColor;
49
+
}
50
+
51
+
span {
52
+
color: rgba(185, 185, 186, 0.5);
53
+
}
54
+
55
+
span:first-of-type {
56
+
margin-left: 0.5rem;
57
+
}
58
+
59
+
img {
60
+
max-height: 24px;
61
+
border-radius: 5px;
62
+
}
63
+
64
+
svg {
65
+
max-height: 16px;
66
+
border-radius: 5px;
67
+
}
68
+
69
+
@media (max-width: 850px) {
70
+
span:first-of-type {
71
+
margin-left: 0rem;
72
+
}
73
+
}
74
+
75
+
@media (max-width: 768px) {
76
+
padding-left: 2rem;
77
+
gap: 0.34rem;
78
+
79
+
span:first-of-type {
80
+
margin-left: 0.1rem;
81
+
}
82
+
}
83
+
}
84
+
85
+
.post-content {
86
+
display: flex;
87
+
flex-direction: row;
88
+
gap: 1rem;
89
+
padding-left: 1rem;
90
+
min-width: 0;
91
+
92
+
@media (max-width: 850px) {
93
+
gap: 0.75rem;
94
+
padding-left: 0.75rem;
95
+
}
96
+
97
+
@media (max-width: 768px) {
98
+
padding-left: 0.5rem;
99
+
gap: 0.5rem;
100
+
}
101
+
}
102
+
103
+
.post-main {
104
+
display: flex;
105
+
flex-direction: column;
106
+
gap: 0.5rem;
107
+
flex: 1;
108
+
min-width: 0;
109
+
overflow-wrap: break-word;
110
+
}
111
+
112
+
.post-avatar {
113
+
width: 48px;
114
+
height: 48px;
115
+
border-radius: 5px;
116
+
flex-shrink: 0;
117
+
118
+
@media (max-width: 768px) {
119
+
width: 48px;
120
+
height: 48px;
121
+
}
122
+
}
123
+
124
+
.post-header {
125
+
display: flex;
126
+
flex-direction: row;
127
+
align-items: flex-start;
128
+
justify-content: space-between;
129
+
text-align: left;
130
+
width: 100%;
131
+
min-width: 0;
132
+
gap: 1rem;
133
+
134
+
.post-author {
135
+
display: flex;
136
+
gap: 0.5rem;
137
+
align-items: baseline;
138
+
min-width: 0;
139
+
flex: 1;
140
+
overflow: hidden;
141
+
142
+
span {
143
+
white-space: nowrap;
144
+
overflow: hidden;
145
+
text-overflow: ellipsis;
146
+
}
147
+
148
+
.post-author-handle {
149
+
color: #1185fe;
150
+
}
151
+
}
152
+
153
+
.post-time {
154
+
color: #8d8d8d;
155
+
white-space: nowrap;
156
+
flex-shrink: 0;
157
+
margin-left: auto;
158
+
margin-right: 1rem;
159
+
}
160
+
161
+
@media (max-width: 850px) {
162
+
.post-author {
163
+
span {
164
+
max-width: 150px;
165
+
}
166
+
}
167
+
}
168
+
169
+
@media (max-width: 768px) {
170
+
flex-direction: row;
171
+
align-items: flex-start;
172
+
justify-content: space-between;
173
+
174
+
.post-author {
175
+
flex-direction: column;
176
+
align-items: flex-start;
177
+
gap: 0.25rem;
178
+
flex: none;
179
+
max-width: calc(100% - 80px);
180
+
181
+
span {
182
+
white-space: normal;
183
+
overflow-wrap: break-word;
184
+
word-break: break-word;
185
+
max-width: none;
186
+
overflow: visible;
187
+
text-overflow: clip;
188
+
}
189
+
}
190
+
191
+
.post-time {
192
+
align-self: flex-start;
193
+
}
194
+
}
195
+
}
196
+
197
+
.post-body {
198
+
text-align: left;
199
+
margin-top: 0.25rem;
200
+
margin-right: 1rem;
201
+
overflow-wrap: break-word;
202
+
word-break: break-word;
203
+
204
+
@media (max-width: 850px) {
205
+
margin-right: 0.75rem;
206
+
}
207
+
208
+
@media (max-width: 768px) {
209
+
margin-right: 0.5rem;
210
+
}
211
+
}
212
+
213
+
.post-interactions {
214
+
text-align: left;
215
+
margin-left: 1rem;
216
+
217
+
@media (max-width: 850px) {
218
+
margin-left: 0.75rem;
219
+
}
220
+
221
+
@media (max-width: 768px) {
222
+
margin-left: 0.5rem;
223
+
}
224
+
}
+11
-3
src/styles/container.scss
+11
-3
src/styles/container.scss
···
6
6
margin: 1em;
7
7
padding: 0 0 1em 0;
8
8
max-height: 100%;
9
+
min-width: 0;
10
+
box-sizing: border-box;
9
11
box-shadow:
10
12
0px 0px 3px 0px rgba(0, 0, 0, 0.5),
11
13
0px 4px 6px 3px rgba(0, 0, 0, 0.3);
12
14
13
15
.container-content {
14
-
padding: 0 1rem;
16
+
padding: 0;
17
+
margin-top: 1rem;
18
+
min-width: 0;
19
+
overflow-wrap: break-word;
20
+
}
21
+
22
+
@media (max-width: 768px) {
23
+
margin: 0.5em;
15
24
}
16
25
}
17
26
18
27
.container-header {
19
28
background-color: vars.$foregroundColor;
20
29
text-align: left;
21
-
padding: 1em;
30
+
padding: 1rem;
22
31
height: 1rem;
23
32
border-radius: vars.$containerBorderRadius vars.$containerBorderRadius 0 0;
24
-
margin-bottom: 1em;
25
33
box-shadow:
26
34
0px 1px 3px 0px rgba(0, 0, 0, 0.4),
27
35
0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset;
+7
-3
src/styles/main.scss
+7
-3
src/styles/main.scss
···
1
1
@use "./button";
2
2
@use "./container";
3
+
@use "./components/post";
3
4
@use "./nav";
4
5
@use "./profile";
5
6
@use "./routes/dashboard";
···
12
13
background-color: rgba(12, 17, 24, 1);
13
14
font-family: Arial, Helvetica, sans-serif;
14
15
margin: 0;
15
-
overflow: hidden;
16
16
}
17
17
18
18
main {
···
21
21
justify-content: center;
22
22
margin: 0 auto;
23
23
max-width: 75%;
24
+
min-width: 0;
25
+
width: 100%;
24
26
}
25
27
26
28
@media (max-width: 768px) {
27
29
main {
28
30
flex-direction: column;
29
-
max-width: 90%;
30
-
margin: 0 1rem;
31
+
max-width: 100%;
32
+
margin: 0;
33
+
padding: 0 0.5rem;
34
+
box-sizing: border-box;
31
35
}
32
36
}
33
37
-6
src/styles/routes/dashboard.scss
-6
src/styles/routes/dashboard.scss
+26
src/types/post.ts
+26
src/types/post.ts
···
1
+
import { AppBskyFeedPost } from "@atcute/bluesky";
2
+
import { ProfileViewBasic } from "@atcute/bluesky/types/app/actor/defs";
3
+
4
+
export type Post = {
5
+
avatar?: string;
6
+
context?: PostContext;
7
+
counts: PostCounts;
8
+
createdAt: Date;
9
+
displayName: string;
10
+
handle: string;
11
+
indexedAt: Date;
12
+
record: AppBskyFeedPost.Main;
13
+
};
14
+
15
+
type PostCounts = {
16
+
bookmarkCount?: number;
17
+
likeCount?: number;
18
+
quoteCount?: number;
19
+
repostCount?: number;
20
+
replyCount?: number;
21
+
};
22
+
23
+
type PostContext = {
24
+
invoker: ProfileViewBasic;
25
+
reason: string;
26
+
};
+91
src/utils/posts.ts
+91
src/utils/posts.ts
···
1
+
import { Client } from "@atcute/client";
2
+
import { agent } from "../components/login";
3
+
import { FeedViewPost } from "@atcute/bluesky/types/app/feed/defs";
4
+
import type { Post } from "../types/post";
5
+
import { is } from "@atcute/lexicons";
6
+
import { AppBskyFeedPost } from "@atcute/bluesky";
7
+
8
+
export async function getFollowingTimeline(
9
+
cursor: string = "",
10
+
limit: number = 50,
11
+
) {
12
+
const rpc = new Client({ handler: agent });
13
+
14
+
const res = await rpc.get("app.bsky.feed.getTimeline", {
15
+
params: {
16
+
cursor,
17
+
limit,
18
+
},
19
+
});
20
+
21
+
if (!res.ok) {
22
+
throw new Error(
23
+
`Failed to fetch user's following timeline: ${res.data.error}/${res.data.message}`,
24
+
);
25
+
}
26
+
27
+
return { feed: res.data.feed, cursor: res.data.cursor };
28
+
}
29
+
30
+
export async function createPostElements(feed: FeedViewPost[]) {
31
+
let elms: Post[] = [];
32
+
const seenCreators = new Set<string>();
33
+
34
+
feed.forEach((post) => {
35
+
if (is(AppBskyFeedPost.mainSchema, post.post.record)) {
36
+
const record = post.post.record as unknown as AppBskyFeedPost.Main;
37
+
const isReply = record.reply !== undefined;
38
+
const creatorDid = post.post.author.did;
39
+
40
+
// Skip replies from creators who already have a post in elms
41
+
if (isReply && seenCreators.has(creatorDid)) {
42
+
return;
43
+
}
44
+
45
+
if (post.reason) {
46
+
if (post.reason.$type === "app.bsky.feed.defs#reasonRepost") {
47
+
elms.push({
48
+
avatar: post.post.author.avatar,
49
+
context: {
50
+
invoker: post.reason.by,
51
+
reason: post.reason.$type,
52
+
},
53
+
counts: {
54
+
bookmarkCount: post.post.bookmarkCount,
55
+
likeCount: post.post.likeCount,
56
+
quoteCount: post.post.quoteCount,
57
+
repostCount: post.post.repostCount,
58
+
replyCount: post.post.replyCount,
59
+
},
60
+
createdAt: new Date(post.post.record.createdAt),
61
+
displayName:
62
+
post.post.author.displayName || post.post.author.handle,
63
+
handle: post.post.author.handle,
64
+
indexedAt: new Date(post.post.indexedAt),
65
+
record: record,
66
+
});
67
+
seenCreators.add(creatorDid);
68
+
}
69
+
} else {
70
+
elms.push({
71
+
avatar: post.post.author.avatar,
72
+
counts: {
73
+
bookmarkCount: post.post.bookmarkCount,
74
+
likeCount: post.post.likeCount,
75
+
quoteCount: post.post.quoteCount,
76
+
repostCount: post.post.repostCount,
77
+
replyCount: post.post.replyCount,
78
+
},
79
+
createdAt: new Date(post.post.record.createdAt),
80
+
displayName: post.post.author.displayName || post.post.author.handle,
81
+
handle: post.post.author.handle,
82
+
indexedAt: new Date(post.post.indexedAt),
83
+
record: record,
84
+
});
85
+
seenCreators.add(creatorDid);
86
+
}
87
+
}
88
+
});
89
+
90
+
return elms;
91
+
}