JavaScript-optional public web frontend for Bluesky
anartia.kelinci.net
sveltekit
atcute
bluesky
typescript
svelte
1import type { AppBskyFeedDefs } from '@atcute/bluesky';
2import { mapDefined } from '@mary/array-fns';
3
4export type TimelineItem = AppBskyFeedDefs.FeedViewPost;
5
6// #region TimelineSlice
7export interface TimelineSlice {
8 items: TimelineItem[];
9}
10
11// #region UiTimelineItem
12const enum TimelineFlags {
13 HAS_PREV = 1 << 0,
14 HAS_NEXT = 1 << 1,
15 IS_REPOSTED = 1 << 2,
16 IS_PINNED = 1 << 3,
17}
18
19export interface UiTimelineItem extends TimelineItem {
20 id: string;
21 prev: boolean;
22 next: boolean;
23}
24
25// #region Filters
26export type SliceFilter = (slice: TimelineSlice) => boolean | TimelineSlice[];
27export type PostFilter = (item: TimelineItem) => boolean;
28
29const isNextInThread = (slice: TimelineSlice, item: TimelineItem) => {
30 const items = slice.items;
31 const last = items[items.length - 1];
32
33 const parent = item.reply?.parent;
34
35 return parent?.$type === 'app.bsky.feed.defs#postView' && last.post.cid == parent.cid;
36};
37
38const isFirstInThread = (slice: TimelineSlice, item: TimelineItem) => {
39 const items = slice.items;
40 const first = items[0];
41
42 const parent = first.reply?.parent;
43
44 return parent?.$type === 'app.bsky.feed.defs#postView' && parent.cid === item.post.cid;
45};
46
47export const buildTimelineSlices = (
48 arr: TimelineItem[],
49 filterSlice?: SliceFilter,
50 filterPost?: PostFilter,
51): TimelineSlice[] => {
52 let slices: TimelineSlice[] = [];
53 let jlen = 0;
54
55 // arrange the posts into connected slices
56 loop: for (let i = arr.length - 1; i >= 0; i--) {
57 const item = arr[i];
58
59 if (filterPost && !filterPost(item)) {
60 continue;
61 }
62
63 for (let j = 0; j < jlen; j++) {
64 const slice = slices[j];
65
66 // skip, we already have too much.
67 if (slice.items.length >= 7) {
68 continue;
69 }
70
71 if (isFirstInThread(slice, item)) {
72 slice.items.unshift(item);
73
74 if (j !== 0) {
75 slices.splice(j, 1);
76 slices.unshift(slice);
77 }
78
79 continue loop;
80 } else if (isNextInThread(slice, item)) {
81 slice.items.push(item);
82
83 if (j !== 0) {
84 slices.splice(j, 1);
85 slices.unshift(slice);
86 }
87
88 continue loop;
89 }
90 }
91
92 slices.unshift({ items: [item] });
93 jlen++;
94 }
95
96 if (filterSlice && jlen > 0) {
97 const unfiltered = slices;
98 slices = [];
99
100 for (let j = 0; j < jlen; j++) {
101 const slice = unfiltered[j];
102 const result = filterSlice(slice);
103
104 if (result) {
105 if (Array.isArray(result)) {
106 for (let k = 0, klen = result.length; k < klen; k++) {
107 slices.push(result[k]);
108 }
109 } else {
110 slices.push(slice);
111 }
112 }
113 }
114 }
115
116 return slices;
117};
118
119export const flattenTimelineSlices = (slices: TimelineSlice[]): UiTimelineItem[] => {
120 return slices.flatMap((slice) => {
121 const items = slice.items;
122 const len = items.length;
123
124 return items.map((item, idx): UiTimelineItem => {
125 const post = item.post;
126 const reason = item.reason;
127
128 let flags = 0;
129
130 if (idx !== 0) {
131 flags |= TimelineFlags.HAS_PREV;
132 }
133 if (idx !== len - 1) {
134 flags |= TimelineFlags.HAS_NEXT;
135 }
136
137 switch (reason?.$type) {
138 case 'app.bsky.feed.defs#reasonRepost': {
139 flags |= TimelineFlags.IS_REPOSTED;
140 break;
141 }
142 case 'app.bsky.feed.defs#reasonPin': {
143 flags |= TimelineFlags.IS_PINNED;
144 break;
145 }
146 }
147
148 return {
149 ...item,
150 id: `${post.author.did}-${post.cid}-${flags}`,
151 prev: !!(flags & TimelineFlags.HAS_PREV),
152 next: !!(flags & TimelineFlags.HAS_NEXT),
153 };
154 });
155 });
156};
157
158export const mapTimelineItems = (arr: TimelineItem[], filterPost?: PostFilter): UiTimelineItem[] => {
159 return mapDefined(arr, (item) => {
160 if (filterPost && !filterPost(item)) {
161 return;
162 }
163
164 const post = item.post;
165 const reason = item.reason;
166
167 let flags = 0;
168
169 switch (reason?.$type) {
170 case 'app.bsky.feed.defs#reasonRepost': {
171 flags |= TimelineFlags.IS_REPOSTED;
172 break;
173 }
174 case 'app.bsky.feed.defs#reasonPin': {
175 flags |= TimelineFlags.IS_PINNED;
176 break;
177 }
178 }
179
180 return {
181 ...item,
182 id: `${post.author.did}-${post.cid}-${flags}`,
183 prev: false,
184 next: false,
185 };
186 });
187};