tangled
alpha
login
or
join now
alice.mosphere.at
/
phanpy
0
fork
atom
this repo has no description
0
fork
atom
overview
issues
pulls
pipelines
New experiment: followed tag indicator
Lim Chee Aun
2 years ago
aa8cbe04
b34ef094
+248
-31
10 changed files
expand all
collapse all
unified
split
src
components
shortcuts-settings.jsx
status.css
status.jsx
timeline.jsx
index.css
pages
followed-hashtags.jsx
following.jsx
utils
followed-tags.js
states.js
timeline-utils.jsx
+2
-7
src/components/shortcuts-settings.jsx
reviewed
···
13
13
import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
14
14
15
15
import { api } from '../utils/api';
16
16
+
import { fetchFollowedTags } from '../utils/followed-tags';
16
17
import pmem from '../utils/pmem';
17
18
import showToast from '../utils/show-toast';
18
19
import states from '../utils/states';
···
500
501
(async () => {
501
502
if (currentType !== 'hashtag') return;
502
503
try {
503
503
-
const iterator = masto.v1.followedTags.list();
504
504
-
const tags = [];
505
505
-
do {
506
506
-
const { value, done } = await iterator.next();
507
507
-
if (done || value?.length === 0) break;
508
508
-
tags.push(...value);
509
509
-
} while (true);
504
504
+
const tags = await fetchFollowedTags();
510
505
setFollowedHashtags(tags);
511
506
} catch (e) {
512
507
console.error(e);
+51
-1
src/components/status.css
reviewed
···
14
14
transparent min(160px, 50%)
15
15
);
16
16
}
17
17
+
.status-followed-tags {
18
18
+
background: linear-gradient(
19
19
+
160deg,
20
20
+
var(--hashtag-faded-color),
21
21
+
transparent min(160px, 50%)
22
22
+
);
23
23
+
}
17
24
.status-reply-to {
18
25
background: linear-gradient(
19
26
160deg,
···
21
28
transparent min(160px, 50%)
22
29
);
23
30
}
24
24
-
:is(.status-reblog, .status-group) .status-reply-to {
31
31
+
:is(.status-reblog, .status-group, .status-followed-tags) .status-reply-to {
25
32
background: linear-gradient(
26
33
-20deg,
27
34
var(--reply-to-faded-color),
···
62
69
color: var(--group-color);
63
70
margin-right: 4px;
64
71
vertical-align: text-bottom;
72
72
+
}
73
73
+
.status-followed-tags {
74
74
+
.status-pre-meta {
75
75
+
position: relative;
76
76
+
z-index: 1;
77
77
+
display: flex;
78
78
+
flex-wrap: wrap;
79
79
+
gap: 4px;
80
80
+
align-items: center;
81
81
+
82
82
+
.icon {
83
83
+
color: var(--hashtag-color);
84
84
+
margin-right: 4px;
85
85
+
vertical-align: text-bottom;
86
86
+
}
87
87
+
a {
88
88
+
color: var(--hashtag-text-color);
89
89
+
font-weight: bold;
90
90
+
font-size: 12px;
91
91
+
text-decoration-color: var(--hashtag-faded-color);
92
92
+
text-underline-offset: 2px;
93
93
+
text-decoration-thickness: 2px;
94
94
+
display: inline-block;
95
95
+
padding: 2px;
96
96
+
vertical-align: top;
97
97
+
text-transform: uppercase;
98
98
+
text-shadow: 0 1px var(--bg-color);
99
99
+
100
100
+
&:hover {
101
101
+
color: var(--text-color);
102
102
+
text-decoration-color: var(--hashtag-color);
103
103
+
}
104
104
+
}
105
105
+
}
106
106
+
107
107
+
.status-followed-tag-item {
108
108
+
color: var(--hashtag-text-color);
109
109
+
padding: 2px;
110
110
+
font-weight: bold;
111
111
+
font-size: 12px;
112
112
+
text-transform: uppercase;
113
113
+
margin-inline-end: 0.5em;
114
114
+
}
65
115
}
66
116
67
117
/* STATUS */
+70
-8
src/components/status.jsx
reviewed
···
92
92
statusID,
93
93
status,
94
94
instance: propInstance,
95
95
+
size = 'm',
96
96
+
contentTextWeight,
97
97
+
readOnly,
98
98
+
enableCommentHint,
95
99
withinContext,
96
96
-
size = 'm',
97
100
skeleton,
98
98
-
readOnly,
99
99
-
contentTextWeight,
100
101
enableTranslate,
101
102
forceTranslate: _forceTranslate,
102
103
previewMode,
···
104
105
onMediaClick,
105
106
quoted,
106
107
onStatusLinkClick = () => {},
107
107
-
enableCommentHint,
108
108
+
showFollowedTags,
108
109
}) {
109
110
if (skeleton) {
110
111
return (
···
174
175
uri,
175
176
url,
176
177
emojis,
178
178
+
tags,
177
179
// Non-API props
178
180
_deleted,
179
181
_pinned,
···
214
216
containerProps={{
215
217
onMouseEnter: debugHover,
216
218
}}
219
219
+
showFollowedTags
217
220
/>
218
221
);
219
222
}
···
292
295
<Status
293
296
status={statusID ? null : reblog}
294
297
statusID={statusID ? reblog.id : null}
298
298
+
instance={instance}
299
299
+
size={size}
300
300
+
contentTextWeight={contentTextWeight}
301
301
+
readOnly={readOnly}
302
302
+
enableCommentHint
303
303
+
/>
304
304
+
</div>
305
305
+
);
306
306
+
}
307
307
+
308
308
+
// Check followedTags
309
309
+
if (showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length) {
310
310
+
return (
311
311
+
<div
312
312
+
data-state-post-id={sKey}
313
313
+
class="status-followed-tags"
314
314
+
onMouseEnter={debugHover}
315
315
+
>
316
316
+
<div class="status-pre-meta">
317
317
+
<Icon icon="hashtag" size="l" />{' '}
318
318
+
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
319
319
+
<Link
320
320
+
key={tag}
321
321
+
to={instance ? `/${instance}/t/${tag}` : `/t/${tag}`}
322
322
+
class="status-followed-tag-item"
323
323
+
>
324
324
+
{tag}
325
325
+
</Link>
326
326
+
))}
327
327
+
</div>
328
328
+
<Status
329
329
+
status={statusID ? null : status}
330
330
+
statusID={statusID ? status.id : null}
295
331
instance={instance}
296
332
size={size}
297
333
contentTextWeight={contentTextWeight}
···
2372
2408
2373
2409
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
2374
2410
2375
2375
-
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
2411
2411
+
function FilteredStatus({
2412
2412
+
status,
2413
2413
+
filterInfo,
2414
2414
+
instance,
2415
2415
+
containerProps = {},
2416
2416
+
showFollowedTags,
2417
2417
+
}) {
2418
2418
+
const snapStates = useSnapshot(states);
2376
2419
const {
2377
2420
id: statusID,
2378
2421
account: { avatar, avatarStatic, bot, group },
···
2399
2442
);
2400
2443
2401
2444
const statusPeekRef = useTruncated();
2402
2402
-
const sKey =
2445
2445
+
const sKey = statusKey(status.id, instance);
2446
2446
+
const ssKey =
2403
2447
statusKey(status.id, instance) +
2404
2448
' ' +
2405
2449
(statusKey(reblog?.id, instance) || '');
···
2408
2452
const url = instance
2409
2453
? `/${instance}/s/${actualStatusID}`
2410
2454
: `/s/${actualStatusID}`;
2455
2455
+
const isFollowedTags =
2456
2456
+
showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length;
2411
2457
2412
2458
return (
2413
2459
<div
2414
2414
-
class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''}
2460
2460
+
class={
2461
2461
+
isReblog
2462
2462
+
? group
2463
2463
+
? 'status-group'
2464
2464
+
: 'status-reblog'
2465
2465
+
: isFollowedTags
2466
2466
+
? 'status-followed-tags'
2467
2467
+
: ''
2468
2468
+
}
2415
2469
{...containerProps}
2416
2470
title={statusPeekText}
2417
2471
onContextMenu={(e) => {
···
2420
2474
}}
2421
2475
{...bindLongPressPeek()}
2422
2476
>
2423
2423
-
<article data-state-post-id={sKey} class="status filtered" tabindex="-1">
2477
2477
+
<article data-state-post-id={ssKey} class="status filtered" tabindex="-1">
2424
2478
<b
2425
2479
class="status-filtered-badge clickable badge-meta"
2426
2480
title={filterTitleStr}
···
2443
2497
/>{' '}
2444
2498
{isReblog ? (
2445
2499
'boosted'
2500
2500
+
) : isFollowedTags ? (
2501
2501
+
<span>
2502
2502
+
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
2503
2503
+
<span key={tag} class="status-followed-tag-item">
2504
2504
+
#{tag}
2505
2505
+
</span>
2506
2506
+
))}
2507
2507
+
</span>
2446
2508
) : (
2447
2509
<RelativeTime datetime={createdAtDate} format="micro" />
2448
2510
)}
+9
-1
src/components/timeline.jsx
reviewed
···
44
44
refresh,
45
45
view,
46
46
filterContext,
47
47
+
showFollowedTags,
47
48
}) {
48
49
const snapStates = useSnapshot(states);
49
50
const [items, setItems] = useState([]);
···
391
392
filterContext={filterContext}
392
393
key={status.id + status?._pinned + view}
393
394
view={view}
395
395
+
showFollowedTags={showFollowedTags}
394
396
/>
395
397
))}
396
398
{showMore &&
···
478
480
// allowFilters,
479
481
filterContext,
480
482
view,
483
483
+
showFollowedTags,
481
484
}) {
482
485
const { id: statusID, reblog, items, type, _pinned } = status;
483
486
if (_pinned) useItemID = false;
···
567
570
!_differentAuthor &&
568
571
!items[i - 1]._differentAuthor &&
569
572
!items[i + 1]._differentAuthor)));
573
573
+
const isStart = i === 0;
570
574
const isEnd = i === items.length - 1;
571
575
return (
572
576
<li
573
577
key={`timeline-${statusID}`}
574
578
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
575
575
-
i === 0 ? 'start' : isEnd ? 'end' : 'middle'
579
579
+
isStart ? 'start' : isEnd ? 'end' : 'middle'
576
580
} ${_differentAuthor ? 'timeline-item-diff-author' : ''}`}
577
581
>
578
582
<Link class="status-link timeline-item" to={url}>
···
583
587
statusID={statusID}
584
588
instance={instance}
585
589
enableCommentHint={isEnd}
590
590
+
showFollowedTags={showFollowedTags}
586
591
// allowFilters={allowFilters}
587
592
/>
588
593
) : (
···
590
595
status={item}
591
596
instance={instance}
592
597
enableCommentHint={isEnd}
598
598
+
showFollowedTags={showFollowedTags}
593
599
// allowFilters={allowFilters}
594
600
/>
595
601
)}
···
631
637
statusID={statusID}
632
638
instance={instance}
633
639
enableCommentHint
640
640
+
showFollowedTags={showFollowedTags}
634
641
// allowFilters={allowFilters}
635
642
/>
636
643
) : (
···
638
645
status={status}
639
646
instance={instance}
640
647
enableCommentHint
648
648
+
showFollowedTags={showFollowedTags}
641
649
// allowFilters={allowFilters}
642
650
/>
643
651
)}
+11
src/index.css
reviewed
···
54
54
--reply-to-text-color: #b36200;
55
55
--favourite-color: var(--red-color);
56
56
--reply-to-faded-color: #ffa60020;
57
57
+
--hashtag-color: LightSeaGreen;
58
58
+
--hashtag-faded-color: color-mix(
59
59
+
in srgb,
60
60
+
var(--hashtag-color) 15%,
61
61
+
transparent
62
62
+
);
63
63
+
--hashtag-text-color: color-mix(
64
64
+
in lch,
65
65
+
var(--hashtag-color) 40%,
66
66
+
var(--text-color) 60%
67
67
+
);
57
68
--outline-color: rgba(128, 128, 128, 0.2);
58
69
--outline-hover-color: rgba(128, 128, 128, 0.7);
59
70
--divider-color: rgba(0, 0, 0, 0.1);
+2
-13
src/pages/followed-hashtags.jsx
reviewed
···
5
5
import Loader from '../components/loader';
6
6
import NavMenu from '../components/nav-menu';
7
7
import { api } from '../utils/api';
8
8
+
import { fetchFollowedTags } from '../utils/followed-tags';
8
9
import useTitle from '../utils/useTitle';
9
9
-
10
10
-
const LIMIT = 200;
11
10
12
11
function FollowedHashtags() {
13
12
const { masto, instance } = api();
···
19
18
setUIState('loading');
20
19
(async () => {
21
20
try {
22
22
-
const iterator = masto.v1.followedTags.list({
23
23
-
limit: LIMIT,
24
24
-
});
25
25
-
const tags = [];
26
26
-
do {
27
27
-
const { value, done } = await iterator.next();
28
28
-
if (done || value?.length === 0) break;
29
29
-
tags.push(...value);
30
30
-
} while (true);
31
31
-
tags.sort((a, b) => a.name.localeCompare(b.name));
32
32
-
console.log(tags);
21
21
+
const tags = await fetchFollowedTags();
33
22
setFollowedHashtags(tags);
34
23
setUIState('default');
35
24
} catch (e) {
+8
-1
src/pages/following.jsx
reviewed
···
6
6
import { filteredItems } from '../utils/filters';
7
7
import states from '../utils/states';
8
8
import { getStatus, saveStatus } from '../utils/states';
9
9
-
import { dedupeBoosts } from '../utils/timeline-utils';
9
9
+
import {
10
10
+
assignFollowedTags,
11
11
+
clearFollowedTagsState,
12
12
+
dedupeBoosts,
13
13
+
} from '../utils/timeline-utils';
10
14
import useTitle from '../utils/useTitle';
11
15
12
16
const LIMIT = 20;
···
37
41
saveStatus(item, instance);
38
42
});
39
43
value = dedupeBoosts(value, instance);
44
44
+
if (firstLoad) clearFollowedTagsState();
45
45
+
assignFollowedTags(value, instance);
40
46
41
47
// ENFORCE sort by datetime (Latest first)
42
48
value.sort((a, b) => {
···
118
124
{...props}
119
125
// allowFilters
120
126
filterContext="home"
127
127
+
showFollowedTags
121
128
/>
122
129
);
123
130
}
+62
src/utils/followed-tags.js
reviewed
···
1
1
+
import { api } from '../utils/api';
2
2
+
import store from '../utils/store';
3
3
+
4
4
+
const LIMIT = 200;
5
5
+
const MAX_FETCH = 10;
6
6
+
7
7
+
export async function fetchFollowedTags() {
8
8
+
const { masto } = api();
9
9
+
const iterator = masto.v1.followedTags.list({
10
10
+
limit: LIMIT,
11
11
+
});
12
12
+
const tags = [];
13
13
+
let fetchCount = 0;
14
14
+
do {
15
15
+
const { value, done } = await iterator.next();
16
16
+
if (done || value?.length === 0) break;
17
17
+
tags.push(...value);
18
18
+
fetchCount++;
19
19
+
} while (fetchCount < MAX_FETCH);
20
20
+
tags.sort((a, b) => a.name.localeCompare(b.name));
21
21
+
console.log(tags);
22
22
+
23
23
+
if (tags.length) {
24
24
+
setTimeout(() => {
25
25
+
// Save to local storage, with saved timestamp
26
26
+
store.account.set('followedTags', {
27
27
+
tags,
28
28
+
updatedAt: Date.now(),
29
29
+
});
30
30
+
}, 1);
31
31
+
}
32
32
+
33
33
+
return tags;
34
34
+
}
35
35
+
36
36
+
const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day
37
37
+
export async function getFollowedTags() {
38
38
+
try {
39
39
+
const { tags, updatedAt } = store.account.get('followedTags') || {};
40
40
+
if (!tags?.length) return await fetchFollowedTags();
41
41
+
if (Date.now() - updatedAt > MAX_AGE) {
42
42
+
// Stale-while-revalidate
43
43
+
fetchFollowedTags();
44
44
+
return tags;
45
45
+
}
46
46
+
return tags;
47
47
+
} catch (e) {
48
48
+
return [];
49
49
+
}
50
50
+
}
51
51
+
52
52
+
const fauxDiv = document.createElement('div');
53
53
+
export const extractTagsFromStatus = (content) => {
54
54
+
if (!content) return [];
55
55
+
if (content.indexOf('#') === -1) return [];
56
56
+
fauxDiv.innerHTML = content;
57
57
+
const hashtagLinks = fauxDiv.querySelectorAll('a.hashtag');
58
58
+
if (!hashtagLinks.length) return [];
59
59
+
return Array.from(hashtagLinks).map((a) =>
60
60
+
a.innerText.trim().replace(/^[^#]*#+/, ''),
61
61
+
);
62
62
+
};
+1
src/utils/states.js
reviewed
···
31
31
scrollPositions: {},
32
32
unfurledLinks: {},
33
33
statusQuotes: {},
34
34
+
statusFollowedTags: {},
34
35
accounts: {},
35
36
routeNotification: null,
36
37
// Modals
+32
src/utils/timeline-utils.jsx
reviewed
···
1
1
+
import { extractTagsFromStatus, getFollowedTags } from './followed-tags';
2
2
+
import states, { statusKey } from './states';
1
3
import store from './store';
2
4
3
5
export function groupBoosts(values) {
···
175
177
176
178
return newItems;
177
179
}
180
180
+
181
181
+
export async function assignFollowedTags(items, instance) {
182
182
+
const followedTags = await getFollowedTags(); // [{name: 'tag'}, {...}]
183
183
+
if (!followedTags.length) return;
184
184
+
const { statusFollowedTags } = states;
185
185
+
items.forEach((item) => {
186
186
+
if (item.reblog) return;
187
187
+
const { id, content, tags = [] } = item;
188
188
+
const sKey = statusKey(id, instance);
189
189
+
if (statusFollowedTags[sKey]?.length) return;
190
190
+
const extractedTags = extractTagsFromStatus(content);
191
191
+
if (!extractedTags.length && !tags.length) return;
192
192
+
const itemFollowedTags = followedTags.reduce((acc, tag) => {
193
193
+
if (
194
194
+
extractedTags.some((t) => t.toLowerCase() === tag.name.toLowerCase()) ||
195
195
+
tags.some((t) => t.name.toLowerCase() === tag.name.toLowerCase())
196
196
+
) {
197
197
+
acc.push(tag.name);
198
198
+
}
199
199
+
return acc;
200
200
+
}, []);
201
201
+
if (itemFollowedTags.length) {
202
202
+
statusFollowedTags[sKey] = itemFollowedTags;
203
203
+
}
204
204
+
});
205
205
+
}
206
206
+
207
207
+
export function clearFollowedTagsState() {
208
208
+
states.statusFollowedTags = {};
209
209
+
}