30 |
69 |
74 | {attachments.length !== 0 && (
75 |
76 |
83 | {attachments.map((attachment) => {
84 | if (attachment.type === "image")
85 | return (
86 |
{
93 | if (currentTarget.src.startsWith(CORS_PROXY)) {
94 | onImageLoadError(new URL(avatarUrl).host);
95 | } else {
96 | // Try CORS proxy
97 | currentTarget.src = `${CORS_PROXY}?url=${encodeURIComponent(
98 | avatarUrl,
99 | )}`;
100 | }
101 | }}
102 | />
103 | );
104 | if (attachment.type === "gifv")
105 | return (
106 |
{
116 | if (currentTarget.src.startsWith(CORS_PROXY)) {
117 | onImageLoadError(new URL(avatarUrl).host);
118 | } else {
119 | // Try CORS proxy
120 | currentTarget.src = `${CORS_PROXY}?url=${encodeURIComponent(
121 | avatarUrl,
122 | )}`;
123 | }
124 | }}
125 | />
126 | );
127 | return
;
128 | })}
129 |
130 |
131 | )}
132 | {post.poll && (
133 |
134 | {post.poll.map((option) => (
135 |
136 |
137 | {option.percentage}% {option.title}
138 |
139 |
i.votesCount)
142 | .sort((a, b) => b - a)[0] === option.votesCount
143 | ? "winner"
144 | : ""
145 | }`}
146 | style={{ width: `${option.percentage}% ` }}
147 | />
148 |
149 | ))}
150 |
151 | )}
152 | {post.reactions && (
153 |
154 | {post.reactions?.map((val, index) => (
155 |
156 | {val.value && (
157 |
{val.value}
158 | )}
159 | {val.url && (
160 |
161 | )}
162 |
{val.count}
163 |
164 | ))}
165 |
166 | )}
167 |
168 |
{formatDate(date)}
169 |
170 |
171 | {comments}
172 | Replies
173 |
174 |
175 |
176 | {boosts}
177 | Boosts
178 |
179 | {!post.reactions && (
180 |
181 |
182 | {favourites}
183 | Favourites
184 |
185 | )}
186 |
187 |
188 | );
189 | }
190 |
--------------------------------------------------------------------------------
/src/styles/App.scss:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | }
5 |
6 | .app {
7 | display: flex;
8 | flex-direction: column;
9 | justify-items: center;
10 | }
11 |
12 | .center-text {
13 | text-align: center;
14 | }
15 |
16 | .search-form {
17 | display: flex;
18 | font-family:
19 | system-ui,
20 | -apple-system,
21 | BlinkMacSystemFont,
22 | avenir next,
23 | avenir,
24 | segoe ui,
25 | Inter,
26 | helvetica neue,
27 | helvetica,
28 | Cantarell,
29 | Ubuntu,
30 | roboto,
31 | noto,
32 | arial,
33 | sans-serif,
34 | " Apple Color Emoji",
35 | Segoe UI Emoji,
36 | Segoe UI Symbol,
37 | Noto Color Emoji;
38 | gap: 0rem;
39 | justify-content: center;
40 | margin-bottom: 2rem;
41 | }
42 |
43 | .search {
44 | border: none;
45 | border-radius: 5rem 0 0 5rem;
46 | border-right: none;
47 | max-width: 500px;
48 | padding: 0.75rem;
49 | width: 80vw;
50 |
51 | &:focus {
52 | z-index: 1;
53 | }
54 | }
55 |
56 | .search-button {
57 | aspect-ratio: 1 / 1;
58 | background-color: var(--color-fg);
59 | border: none;
60 | border-left: none;
61 | border-radius: 0 5rem 5rem 0;
62 | color: var(--color-button);
63 | transition: background-color 0.1s;
64 |
65 | &:hover {
66 | background-color: var(--color-button);
67 | color: var(--color-fg);
68 | cursor: pointer;
69 | }
70 | }
71 |
72 | .options-editor {
73 | display: flex;
74 | flex-wrap: wrap;
75 | gap: 1rem;
76 | justify-content: center;
77 | margin: 1rem 0 2rem;
78 |
79 | .option-stack {
80 | display: flex;
81 | flex-direction: column;
82 | gap: 0.5rem;
83 | }
84 |
85 | .option {
86 | display: flex;
87 | flex-direction: column;
88 |
89 | select {
90 | appearance: none;
91 |
92 | /* stylelint-disable-next-line property-disallowed-list */
93 | background: url("data:image/svg+xml,
")
94 | no-repeat;
95 | background-color: #15202b;
96 | background-position: calc(100% - 0.75rem) center;
97 | border: 0;
98 | border-radius: 0.25rem;
99 | color: white;
100 | padding: 0.5rem 2rem 0.5rem 1rem;
101 | }
102 | }
103 |
104 | label {
105 | margin-bottom: 0.5rem;
106 | }
107 |
108 | .color-grid {
109 | display: grid;
110 | gap: 0.5rem;
111 | grid-template-columns: repeat(4, minmax(0, 1fr));
112 |
113 | .color {
114 | border: 0.15rem white solid;
115 | border-radius: 0.5rem;
116 | color: var(--color-button);
117 | content: "";
118 | height: 50px;
119 | transition: all 0.2s;
120 | width: 50px;
121 |
122 | &:hover {
123 | color: var(--color-fg);
124 | cursor: pointer;
125 | outline: 0.25rem #f3f3f350 solid;
126 | }
127 | }
128 | }
129 |
130 | @media (max-width: 630px) {
131 | display: grid;
132 | }
133 |
134 | @media screen and (max-width: 400px) {
135 | flex-direction: column;
136 | }
137 | }
138 |
139 | .alert {
140 | background-color: #f91880bf;
141 | border-radius: 1rem;
142 | margin: 0.5rem 0;
143 | max-width: 600px;
144 | padding: 1rem;
145 |
146 | .alert-title {
147 | align-items: center;
148 | display: flex;
149 | gap: 0.25rem;
150 | justify-content: center;
151 | margin-top: 1rem;
152 | }
153 |
154 | a {
155 | color: white;
156 | }
157 | }
158 |
159 | .flex-center {
160 | display: flex;
161 | justify-content: center;
162 | margin-bottom: 2rem;
163 |
164 | &.button-grid {
165 | gap: 1rem;
166 | }
167 | }
168 |
169 | .toot {
170 | border-radius: 1rem;
171 | }
172 |
173 | .render-button {
174 | background-color: #6364ff;
175 | border: 0;
176 | border-radius: 5rem;
177 | color: var(--color-fg);
178 | font-family:
179 | system-ui,
180 | -apple-system,
181 | BlinkMacSystemFont,
182 | avenir next,
183 | avenir,
184 | segoe ui,
185 | Inter,
186 | helvetica neue,
187 | helvetica,
188 | Cantarell,
189 | Ubuntu,
190 | roboto,
191 | noto,
192 | arial,
193 | sans-serif,
194 | " Apple Color Emoji",
195 | Segoe UI Emoji,
196 | Segoe UI Symbol,
197 | Noto Color Emoji;
198 | font-size: 15px;
199 | font-weight: 500;
200 | line-height: 22px;
201 | padding: 7px 18px;
202 | transition: all 0.2s;
203 |
204 | &:hover {
205 | background-color: var(--color-button);
206 | cursor: pointer;
207 | }
208 | }
209 |
210 | .gradient-box {
211 | /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
212 | position: relative;
213 | }
214 |
215 | .gradient {
216 | /* fallback for old browsers */
217 | /* Chrome 10-25, Safari 5.1-6 */
218 | @media screen and (max-width: 1024px) {
219 | background: transparent;
220 | }
221 | }
222 |
223 | .handlebar {
224 | position: absolute;
225 | touch-action: none;
226 | z-index: 1000;
227 |
228 | &.diagonal-right {
229 | bottom: -8px;
230 | right: -4px;
231 |
232 | svg {
233 | &:hover {
234 | cursor: nwse-resize;
235 | }
236 | }
237 | }
238 |
239 | &.diagonal-left {
240 | left: -4px;
241 | top: -16px;
242 |
243 | svg {
244 | &:hover {
245 | cursor: nwse-resize;
246 | }
247 | }
248 | }
249 |
250 | &.left {
251 | left: -4px;
252 | top: calc(50% - 8px);
253 |
254 | svg {
255 | &:hover {
256 | cursor: e-resize;
257 | }
258 | }
259 | }
260 |
261 | &.right {
262 | right: -4px;
263 | top: calc(50% - 8px);
264 |
265 | svg {
266 | &:hover {
267 | cursor: e-resize;
268 | }
269 | }
270 | }
271 |
272 | &.top {
273 | left: 50%;
274 | top: -12px;
275 |
276 | svg {
277 | &:hover {
278 | cursor: n-resize;
279 | }
280 | }
281 | }
282 |
283 | &.bottom {
284 | bottom: -12px;
285 | left: 50%;
286 |
287 | svg {
288 | &:hover {
289 | cursor: n-resize;
290 | }
291 | }
292 | }
293 |
294 | svg {
295 | color: white;
296 | touch-action: none;
297 | transition: all 0.2s;
298 |
299 | &.active {
300 | color: var(--color-button);
301 | transform: scale(2);
302 | }
303 |
304 | @media screen and (max-width: 1024px) {
305 | display: none;
306 | }
307 | }
308 | }
309 |
310 | .commit-link {
311 | color: #6364ff;
312 | text-decoration: none;
313 | transition: all 0.2s;
314 |
315 | &:hover {
316 | color: var(--color-button);
317 | }
318 | }
319 |
320 | .action-bar {
321 | flex-wrap: wrap;
322 |
323 | .emoji-reaction {
324 | align-items: center;
325 | background-color: #2a2c38;
326 | border-radius: 5px;
327 | display: flex;
328 | margin: 0.5rem;
329 | padding: 0.3rem;
330 |
331 | .emoji-reaction-custom {
332 | max-width: 20px;
333 | object-fit: cover;
334 | }
335 |
336 | .emoji-reaction-count {
337 | margin-left: 0.45rem;
338 | }
339 | }
340 | }
341 |
--------------------------------------------------------------------------------
/src/components/PostItem.tsx:
--------------------------------------------------------------------------------
1 | import { ForwardedRef } from "react";
2 | import { formatDate } from "../utils/util";
3 | import { InteractionsPreference, Options } from "../config";
4 | import DOMPurify from "dompurify";
5 |
6 | export interface Post {
7 | displayName: string;
8 | plainUsername: string;
9 | username: string;
10 | postURL: string; // Embed
11 | profileURL: string; // Embed
12 | avatarUrl: string;
13 | content: string; // HTML!
14 | favourites: number;
15 | boosts: number;
16 | comments: number;
17 | attachments: Attachment[];
18 | date: Date;
19 | poll?: {
20 | title: string;
21 | votesCount: number;
22 | percentage: number;
23 | }[];
24 | reactions?: Reactions[];
25 | }
26 |
27 | export interface Reactions {
28 | value?: string;
29 | url?: string;
30 | count: number;
31 | }
32 |
33 | export interface Attachment {
34 | type: string;
35 | url: string;
36 | aspectRatio: number;
37 | description?: string;
38 | }
39 |
40 | export interface PostItemProps {
41 | post: Post;
42 | refInstance?: ForwardedRef
;
43 | interactionsPref: InteractionsPreference;
44 | onImageLoadError: (host: string) => void;
45 | options: Options;
46 | }
47 |
48 | const CORS_PROXY = "https://corsproxy.io";
49 |
50 | export default function PostItem({
51 | post,
52 | refInstance,
53 | interactionsPref,
54 | onImageLoadError,
55 | options,
56 | }: PostItemProps) {
57 | const {
58 | displayName,
59 | username,
60 | avatarUrl,
61 | content,
62 | favourites,
63 | boosts,
64 | comments,
65 | attachments,
66 | date,
67 | } = post;
68 |
69 | return (
70 |
71 |
72 |
73 |
{
78 | if (currentTarget.src.startsWith(CORS_PROXY)) {
79 | onImageLoadError(new URL(avatarUrl).host);
80 | } else {
81 | // Try CORS proxy
82 | currentTarget.src = `${CORS_PROXY}?url=${encodeURIComponent(
83 | avatarUrl,
84 | )}`;
85 | }
86 | }}
87 | />
88 |
89 |
90 |
91 |
96 |
97 | {username}
98 | {/** Replace with :has when Firefox starts supporting it */}
99 | {options.interactions === "feed" && (
100 | {formatDate(date)}
101 | )}
102 |
103 |
104 |
109 | {attachments.length !== 0 && (
110 |
111 |
118 | {attachments.map((attachment) => {
119 | if (attachment.type === "image")
120 | return (
121 |
{
128 | if (currentTarget.src.startsWith(CORS_PROXY)) {
129 | onImageLoadError(new URL(avatarUrl).host);
130 | } else {
131 | // Try CORS proxy
132 | currentTarget.src = `${CORS_PROXY}?url=${encodeURIComponent(
133 | avatarUrl,
134 | )}`;
135 | }
136 | }}
137 | />
138 | );
139 | if (attachment.type === "gifv")
140 | return (
141 |
{
151 | if (currentTarget.src.startsWith(CORS_PROXY)) {
152 | onImageLoadError(new URL(avatarUrl).host);
153 | } else {
154 | // Try CORS proxy
155 | currentTarget.src = `${CORS_PROXY}?url=${encodeURIComponent(
156 | avatarUrl,
157 | )}`;
158 | }
159 | }}
160 | />
161 | );
162 | return
;
163 | })}
164 |
165 |
166 | )}
167 | {post.poll && (
168 |
169 | {post.poll.map((option) => (
170 |
171 |
172 | {option.percentage}% {option.title}
173 |
174 |
i.votesCount)
177 | .sort((a, b) => b - a)[0] === option.votesCount
178 | ? "winner"
179 | : ""
180 | }`}
181 | style={{ width: `${option.percentage}% ` }}
182 | />
183 |
184 | ))}
185 |
186 | )}
187 | {post.reactions && (
188 |
189 | {post.reactions?.map((val, index) => (
190 |
191 | {val.value && (
192 |
{val.value}
193 | )}
194 | {val.url && (
195 |
196 | )}
197 |
{val.count}
198 |
199 | ))}
200 |
201 | )}
202 |
203 |
{formatDate(date)}
204 |
205 |
206 | {comments}
207 | Replies
208 |
209 |
210 |
211 | {boosts}
212 | Boosts
213 |
214 | {!post.reactions && (
215 |
216 |
217 | {favourites}
218 | Favourites
219 |
220 | )}
221 |
222 |
223 | );
224 | }
225 |
--------------------------------------------------------------------------------
/src/themes/BirdUi.scss:
--------------------------------------------------------------------------------
1 | .theme-bird-ui,
2 | .theme-bird-ui-light {
3 | $font-stack: system-ui,
4 | -apple-system,
5 | BlinkMacSystemFont,
6 | avenir next,
7 | avenir,
8 | segoe ui,
9 | Inter,
10 | helvetica neue,
11 | helvetica,
12 | Cantarell,
13 | Ubuntu,
14 | roboto,
15 | noto,
16 | arial,
17 | sans-serif,
18 | " Apple Color Emoji",
19 | "Segoe UI Emoji",
20 | "Segoe UI Symbol",
21 | "Noto Color Emoji";
22 | $line-height: 22px;
23 | $profile-name-gap: 6px;
24 | $message-gap: 12px;
25 | $avatar-size: 48px;
26 | $icon-boost: url("data:image/svg+xml, %0A%3Csvg viewBox='0 0 24 24' color='inherit' width='18' height='18' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='%23717c9b' d='M6 4h15a1 1 0 0 1 1 1v7h-2V6H6v3L1 5l5-4v3zm12 16H3a1 1 0 0 1-1-1v-7h2v6h14v-3l5 4l-5 4v-3z'/%3E%3C/svg%3E");
27 | $icon-reply: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18' fill='%23717c9b' aria-hidden='true'%3E%3Cg%3E%3Cpath d='M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
28 | $icon-star: url('data:image/svg+xml, %3Csvg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" stroke="%23717c9b" stroke-width="5.5" viewBox="0 0 68 68"%3E%3Cpath d="M31.4 3.8c-.7.4-2.5 5-4.1 10.2l-2.9 9.5-9.9.5c-5.5.3-10.6.9-11.3 1.3-.6.5-1.2 1.9-1.2 3.3 0 2 1.5 3.4 8 7.5 4.4 2.8 8 5.5 8 6s-1.3 4.5-3 9.1c-3.6 9.7-3.7 11.4-.9 12.8 2.7 1.5 3.5 1.1 12.4-5.6l7.5-5.6 8.2 6.1c8.4 6.3 11.2 7.1 13.2 3.9.8-1.4.3-3.9-2.2-11-1.8-5.1-3.2-9.6-3.2-9.9 0-.4 3.6-3 8-5.8 6.5-4.1 8-5.5 8-7.5 0-1.4-.6-2.8-1.2-3.3-.7-.4-5.8-1-11.3-1.3l-9.9-.5-2.9-9.5C37.8 4.6 36.9 3 34 3c-.8 0-2 .4-2.6.8z"/%3E%3C/svg%3E%0A');
29 | font-family: $font-stack;
30 | width: 600px;
31 |
32 | &.theme-bird-ui {
33 | --color-bg: #1e2028;
34 | --color-text: #f7f9f9;
35 | --color-text-dim: #717c9b;
36 | --color-text-link: #858afa;
37 | --color-border: #38384d;
38 | }
39 |
40 | &.theme-bird-ui-light {
41 | --color-bg: #f7f9f9;
42 | --color-text: #1e2028;
43 | --color-text-dim: #9388a6;
44 | --color-text-link: #858afa;
45 | --color-border: #e6e1ed;
46 | }
47 |
48 | .toot {
49 | background-color: var(--color-bg);
50 | color: var(--color-text);
51 | display: block;
52 | max-width: calc(600px - 4rem);
53 | padding: 2rem;
54 | width: 100%;
55 |
56 | .profile {
57 | display: flex;
58 | font-size: 15px;
59 | gap: 0.75em;
60 | height: 0.75rem;
61 |
62 | .display-name {
63 | display: flex;
64 | gap: $profile-name-gap;
65 | min-height: 30px;
66 | overflow: hidden;
67 | text-overflow: ellipsis;
68 |
69 | strong {
70 | font-weight: 500;
71 | overflow: hidden;
72 | text-overflow: ellipsis;
73 | white-space: nowrap;
74 |
75 | .emoji {
76 | height: 1em;
77 | margin: -1px 0 0;
78 | object-fit: contain;
79 | vertical-align: middle;
80 | width: 1em;
81 | }
82 | }
83 |
84 | a {
85 | color: inherit;
86 | text-decoration: none;
87 | }
88 |
89 | a:hover {
90 | text-decoration: underline;
91 | }
92 |
93 | .username {
94 | color: var(--color-text-dim);
95 | height: 1.5rem;
96 | overflow: hidden;
97 | text-overflow: ellipsis;
98 | white-space: nowrap;
99 | }
100 |
101 | .datetime {
102 | color: var(--color-text-dim);
103 |
104 | overflow: hidden;
105 | text-overflow: ellipsis;
106 | white-space: nowrap;
107 |
108 | &::before {
109 | align-items: center;
110 | color: var(--color-text-dim);
111 | content: "·";
112 | line-height: 22px;
113 | margin-right: 8px;
114 | }
115 | }
116 | }
117 |
118 | .avatar img {
119 | border-radius: 100rem;
120 | height: $avatar-size;
121 | width: $avatar-size;
122 | }
123 | }
124 |
125 | .content {
126 | font-size: 15px;
127 | padding-left: calc($avatar-size + $message-gap);
128 |
129 | p {
130 | font-feature-settings: "kern";
131 | font-style: normal;
132 | font-weight: 400;
133 | line-height: $line-height;
134 | text-rendering: optimizelegibility;
135 | text-size-adjust: none;
136 | }
137 |
138 | a {
139 | color: var(--color-text-link);
140 | text-decoration: none;
141 | unicode-bidi: isolate;
142 | }
143 |
144 | .emoji {
145 | height: 24px;
146 | margin: -1px 0 0;
147 | object-fit: contain;
148 | vertical-align: middle;
149 | width: 24px;
150 | }
151 |
152 | .invisible {
153 | display: none;
154 | }
155 |
156 | .ellipsis::after {
157 | content: "…";
158 | }
159 | }
160 |
161 | .poll {
162 | font-size: 14px;
163 | padding-left: calc($avatar-size + $message-gap);
164 |
165 | .poll-option {
166 | margin-bottom: 10px;
167 |
168 | .option-title {
169 | line-height: 16px;
170 | margin: 0;
171 | padding: 6px 0;
172 |
173 | strong {
174 | display: inline-block;
175 | width: 45px;
176 | }
177 | }
178 |
179 | .option-bar {
180 | background-color: var(--color-text-dim);
181 | border-radius: 4px;
182 | display: block;
183 | height: 5px;
184 | min-width: 1%;
185 | }
186 | }
187 | }
188 |
189 | // Notice! Image attachments are not inside content.
190 | .gallery-holder {
191 | padding-left: calc($avatar-size + $message-gap);
192 | .image-gallery {
193 | border: 1px solid var(--color-border);
194 | border-radius: 16px;
195 | box-sizing: border-box;
196 | display: grid;
197 |
198 | width: 100%;
199 |
200 | .attachment {
201 | border-radius: 8px;
202 | height: 100%;
203 | object-fit: cover;
204 | width: 100%;
205 | }
206 | }
207 | }
208 |
209 | .action-bar {
210 | display: flex;
211 | margin-top: 12px;
212 | padding-left: calc($avatar-size + $message-gap);
213 |
214 | &.action-bar-hidden {
215 | display: none;
216 | }
217 |
218 | &.action-bar-feed {
219 | // Action bar is 568px wide, split to 5 (space-between)
220 | // -> 133.6, remove 50px (each buttons width)
221 | // -> 83.6px
222 | gap: 83.6px;
223 | margin-top: 24px;
224 |
225 | .action-bar-datetime {
226 | display: none;
227 | }
228 |
229 | .action {
230 | color: var(--color-text-dim);
231 | display: inline-flex;
232 | width: 50px;
233 |
234 | .icon-boost::before {
235 | content: $icon-boost;
236 | }
237 |
238 | .icon-reply::before {
239 | content: $icon-reply;
240 | }
241 |
242 | .icon-star::before {
243 | content: $icon-star;
244 | }
245 |
246 | .action-counter {
247 | display: inline-block;
248 | font-size: 13px;
249 | font-weight: 500;
250 | margin-inline-start: 4px;
251 | }
252 |
253 | .action-label {
254 | display: none;
255 | }
256 | }
257 | }
258 |
259 | &.action-bar-normal {
260 | font-size: 15px;
261 | gap: 6px;
262 |
263 | .action-bar-datetime {
264 | color: var(--color-text-dim);
265 | }
266 |
267 | &.no-replies {
268 | div:first-of-type {
269 | display: none;
270 | }
271 | }
272 |
273 | .action {
274 | display: inline-flex;
275 | gap: 4px;
276 |
277 | .action-counter {
278 | font-weight: 700;
279 | }
280 |
281 | .action-label {
282 | color: var(--color-text-dim);
283 | font-weight: 500;
284 | }
285 |
286 | &::before {
287 | align-items: center;
288 | color: var(--color-text-dim);
289 | content: "·";
290 | line-height: 22px;
291 | }
292 | }
293 | }
294 | }
295 | }
296 | }
297 |
--------------------------------------------------------------------------------
/src/instance/Misskey.ts:
--------------------------------------------------------------------------------
1 | import { AxiosError } from "axios";
2 | import { Attachment, Post, Reactions } from "../components/PostItem";
3 | import BaseInstance from "./BaseInstance";
4 | import { axiosInstance } from "../utils/axios";
5 |
6 | interface MisskeyReaction {
7 | [emojiName: string]: number;
8 | }
9 |
10 | interface MisskeyGuestReaction {
11 | [emojiName: string]: string;
12 | }
13 |
14 | interface MisskeyInstanceInterface {
15 | name: string;
16 | softwareName: string;
17 | softwareVersion: string;
18 | iconUrl: string;
19 | faviconUrl: string;
20 | themeColor: string;
21 | }
22 |
23 | interface MisskeyUser {
24 | id: string;
25 | name: string;
26 | username: string;
27 | host?: string;
28 | avatarUrl: string;
29 | avatarBlurhash: string;
30 | isAdmin: boolean;
31 | isModerator: boolean;
32 | isBot?: boolean;
33 | isCat?: boolean;
34 | isLocked?: boolean;
35 | speakAsCat?: boolean;
36 | instance?: MisskeyInstanceInterface;
37 | emojis: MisskeyReaction;
38 | }
39 |
40 | interface MisskeyFileProperty {
41 | width: number;
42 | height: number;
43 | orientation: number;
44 | avgColor: string;
45 | }
46 |
47 | interface MisskeyFile {
48 | id: string;
49 | createdAt: string;
50 | name: string;
51 | type: string;
52 | md5: string;
53 | size: number;
54 | isSensitive: boolean;
55 | blurHash: string;
56 | properties: MisskeyFileProperty;
57 | url: string;
58 | thumbnailUrl: string;
59 | comment: string;
60 | userId: string;
61 | user: MisskeyUser;
62 | }
63 |
64 | interface MisskeyNotesResponse {
65 | id: string;
66 | createdAt: string;
67 | deletedAt: string;
68 | text?: string;
69 | cw: string;
70 | userId: string;
71 | user: MisskeyUser;
72 | replyId?: string;
73 | renoteId?: string;
74 | reply?: MisskeyNotesResponse;
75 | renote?: MisskeyNotesResponse;
76 | isHidden: boolean;
77 | visibility: string;
78 | mentions: string[];
79 | visibleUserIds: string[];
80 | fileIds: string[];
81 | files: MisskeyFile[];
82 | tags: string;
83 | channelId: string;
84 | channel: object;
85 | localOnly: boolean;
86 | reactions: MisskeyReaction;
87 | reactionEmojis: MisskeyGuestReaction;
88 | renoteCount: number;
89 | repliesCount: number;
90 | emojis: MisskeyReaction;
91 | uri?: string;
92 | url?: string;
93 | }
94 |
95 | type TEmojiReplacer = { [emoji: string]: string };
96 |
97 | class MisskeyCrossingInstanceException extends Error {}
98 |
99 | export default class MisskeyInstance extends BaseInstance {
100 | private regexEmojiMatch: RegExp = /:[^:\s]*:/gm;
101 | private regexURIMatch: RegExp =
102 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gm;
103 | private regexUserMatch: RegExp =
104 | /(\B@[\w\d-_]+@([\w\d-]+\.)+[\w-]{2,4})|(\B@[\w\d-_]+)/gm;
105 |
106 | public async execute(): Promise
{
107 | // TODO: instance/api/notes/show [POST]
108 | // TODO: instance/api/users/show [POST] [If there's mentions over there]
109 | if (this.url.protocol !== "https:")
110 | throw new Error("Protocol must be HTTPS");
111 |
112 | try {
113 | const uri = new URL(`https://${this.url.host}/api/notes/show`);
114 | const note = await axiosInstance.post(uri.toString(), {
115 | noteId: this.postId,
116 | });
117 | const dataNote: MisskeyNotesResponse = note.data;
118 |
119 | if (dataNote.user.instance)
120 | throw new MisskeyCrossingInstanceException(
121 | `MISKEY_CROSSING_INSTANCE_${dataNote.user.instance.name}`,
122 | );
123 |
124 | const username = `@${dataNote.user.username}@${this.url.host}`;
125 |
126 | const attachments: Attachment[] = dataNote.files.map((val) => {
127 | return {
128 | type: "image",
129 | url: val.url,
130 | aspectRatio: 1,
131 | description: val.comment,
132 | };
133 | });
134 |
135 | const avatarUrl = dataNote.user.avatarUrl;
136 |
137 | let content = "";
138 | if (dataNote.text) {
139 | const regexContentEmoji = dataNote.text.match(this.regexEmojiMatch);
140 | const contentEmoji = await this.parseArrayEmoji(
141 | !regexContentEmoji ? [] : regexContentEmoji,
142 | );
143 | content = this.parseContent(contentEmoji, dataNote.text);
144 | }
145 |
146 | const regexDisplayNameEmoji = dataNote.user.name.match(
147 | this.regexEmojiMatch,
148 | );
149 | const displayNameEmoji = await this.parseArrayEmoji(
150 | !regexDisplayNameEmoji ? [] : regexDisplayNameEmoji,
151 | );
152 | const displayName = this.replaceEmoji(
153 | displayNameEmoji,
154 | dataNote.user.name,
155 | );
156 |
157 | const reactions = await this.fetchReaction(
158 | dataNote.reactions,
159 | dataNote.reactionEmojis,
160 | );
161 | console.log(reactions);
162 |
163 | return {
164 | username,
165 | attachments,
166 | avatarUrl,
167 | content,
168 | displayName,
169 | reactions,
170 | plainUsername: dataNote.user.username,
171 | boosts: dataNote.renoteCount,
172 | comments: dataNote.repliesCount,
173 | postURL: dataNote.url || dataNote.uri || "",
174 | profileURL: `https://${dataNote.user.host}/@${dataNote.user.username}`,
175 | favourites: this.parseReactionToFavourites(dataNote.reactions),
176 | date: new Date(dataNote.createdAt),
177 | };
178 | } catch (e) {
179 | console.error(e);
180 | if (e instanceof AxiosError) {
181 | if (!e.response) throw new Error("Failed to reach API");
182 | if (e.response.status === 404)
183 | throw new Error("Post not found. Is it private?");
184 | }
185 | if (e instanceof MisskeyCrossingInstanceException)
186 | throw new Error("Crossing instance for Misskey is not supported!");
187 |
188 | throw new Error("Unknown error trying to reach Misskey instance");
189 | }
190 | }
191 |
192 | // TODO: Can you change this to reaction list like Misskey things?
193 | private parseReactionToFavourites(reaction: MisskeyReaction): number {
194 | let count = 0;
195 | Object.keys(reaction).forEach((react) => {
196 | if (typeof reaction[react] !== "number") return;
197 | count += reaction[react];
198 | });
199 | return count;
200 | }
201 |
202 | private async getEmojiFromInstance(
203 | emoji: string,
204 | host: string = this.url.host,
205 | ): Promise {
206 | const uri = new URL(`https://${host}/api/emoji`);
207 | const emojiFetch = await axiosInstance.post(uri.toString(), {
208 | name: emoji,
209 | });
210 | return emojiFetch.data.url as string;
211 | }
212 |
213 | private async fetchReaction(
214 | reaction: MisskeyReaction,
215 | guestReaction: MisskeyGuestReaction,
216 | ): Promise {
217 | const data: Reactions[] = [];
218 |
219 | await Promise.all(
220 | Object.keys(reaction).map(async (react) => {
221 | if (react[0] !== ":" && react[react.length - 1] !== ":") {
222 | data.push({ value: react, count: reaction[react] });
223 | return;
224 | }
225 |
226 | const originalReact = react;
227 | react = react.replace("@.:", ":");
228 | react = react.substring(1, react.length - 1);
229 |
230 | if (Object.keys(guestReaction).includes(react)) {
231 | data.push({
232 | url: guestReaction[react],
233 | count: reaction[originalReact],
234 | });
235 | return;
236 | }
237 |
238 | const guestDomain = react.match("@")
239 | ? react.split("@")[1]
240 | : this.url.host;
241 | react = guestDomain === this.url.host ? react : react.split("@")[0];
242 | const emoji = await this.getEmojiFromInstance(react, guestDomain);
243 |
244 | data.push({ url: emoji, count: reaction[originalReact] });
245 | }),
246 | );
247 |
248 | return data;
249 | }
250 |
251 | private async parseArrayEmoji(setlist: string[]): Promise {
252 | setlist = setlist.map((val) => val.substring(1, val.length - 1));
253 | const newSetlist = [...new Set(setlist)];
254 | const retSetlist: {
255 | [emoji: string]: string;
256 | } = {};
257 |
258 | await Promise.all(
259 | newSetlist.map(async (val) => {
260 | retSetlist[val] = await this.getEmojiFromInstance(val);
261 | }),
262 | );
263 |
264 | return retSetlist;
265 | }
266 |
267 | private replaceEmoji(emojiList: TEmojiReplacer, text: string): string {
268 | Object.keys(emojiList).forEach((val) => {
269 | text = text.replaceAll(
270 | `:${val}:`,
271 | ` `,
272 | );
273 | });
274 | return text;
275 | }
276 |
277 | private parseContent(emoji: TEmojiReplacer, text: string): string {
278 | // Set paragraph
279 | text =
280 | "" +
281 | text.replace(/\n([ \t]*\n)+/g, "
").replace("\n", " ") +
282 | "
";
283 |
284 | // Parch URI
285 | const matchURI = text.match(this.regexURIMatch);
286 | if (matchURI)
287 | matchURI.forEach((val) => {
288 | const uri = new URL(val);
289 | text = text.replace(val, `${val} `);
290 | });
291 |
292 | // Parse hashtags
293 | const hashtags = text
294 | .replace(/\s+/g, " ")
295 | .split(" ")
296 | .filter((x) => x[0] === "#");
297 | hashtags.forEach((val) => {
298 | text = text.replace(
299 | val,
300 | `${val} `,
303 | );
304 | });
305 |
306 | // Parse user
307 | const users = text.match(this.regexUserMatch);
308 | if (users)
309 | users.forEach((val) => {
310 | const uri = new URL(`https://${this.url.host}/${val}`);
311 | text = text.replace(val, `${val} `);
312 | });
313 |
314 | // Parse Emoji
315 | text = this.replaceEmoji(emoji, text);
316 |
317 | return text;
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/src/themes/Mastodon.scss:
--------------------------------------------------------------------------------
1 | /* roboto-regular - latin */
2 | @font-face {
3 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
4 | font-family: Roboto;
5 | font-style: normal;
6 | font-weight: 400;
7 | src: url("/roboto-v30-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
8 | }
9 |
10 | @font-face {
11 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
12 | font-family: Roboto;
13 | font-style: normal;
14 | font-weight: 500;
15 | src: url("/roboto-v30-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
16 | }
17 |
18 | .theme-mastodon,
19 | .theme-mastodon-light,
20 | .theme-mastodon-white-interactions {
21 | --color-poll-winner: #5e64f8;
22 |
23 | $font-stack: roboto,
24 | system-ui,
25 | -apple-system,
26 | BlinkMacSystemFont,
27 | avenir next,
28 | avenir,
29 | segoe ui,
30 | Inter,
31 | helvetica neue,
32 | helvetica,
33 | Cantarell,
34 | Ubuntu,
35 | noto,
36 | arial,
37 | sans-serif,
38 | " Apple Color Emoji",
39 | "Segoe UI Emoji",
40 | "Segoe UI Symbol",
41 | "Noto Color Emoji";
42 | $line-height: 22px;
43 | $message-gap: 12px;
44 | $avatar-size: 48px;
45 | $icon-boost: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22px' fill='%23606984' stroke='%23606984' stroke-width='4rem' viewBox='-96 224 2240 1472'%3E%3Cpath d='M1344 1504q0 13-9.5 22.5t-22.5 9.5h-960q-8 0-13.5-2t-9-7-5.5-8-3-11.5-1-11.5v-600h-192q-26 0-45-19t-19-45q0-24 15-41l320-384q19-22 49-22t49 22l320 384q15 17 15 41 0 26-19 45t-45 19h-192v384h576q16 0 25 11l160 192q7 10 7 21zm640-416q0 24-15 41l-320 384q-20 23-49 23t-49-23l-320-384q-15-17-15-41 0-26 19-45t45-19h192v-384h-576q-16 0-25-12l-160-192q-7-9-7-20 0-13 9.5-22.5t22.5-9.5h960q8 0 13.5 2t9 7 5.5 8 3 11.5 1 11.5v600h192q26 0 45 19t19 45z'%3E%3C/path%3E%3C/svg%3E");
46 | $icon-reply: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18px' viewBox='0 0 512 512'%3E%3C!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --%3E%3Cstyle%3Esvg%7Bfill:%23606984%7D%3C/style%3E%3Cpath d='M205 34.8c11.5 5.1 19 16.6 19 29.2v64H336c97.2 0 176 78.8 176 176c0 113.3-81.5 163.9-100.2 174.1c-2.5 1.4-5.3 1.9-8.1 1.9c-10.9 0-19.7-8.9-19.7-19.7c0-7.5 4.3-14.4 9.8-19.5c9.4-8.8 22.2-26.4 22.2-56.7c0-53-43-96-96-96H224v64c0 12.6-7.4 24.1-19 29.2s-25 3-34.4-5.4l-160-144C3.9 225.7 0 217.1 0 208s3.9-17.7 10.6-23.8l160-144c9.4-8.5 22.9-10.6 34.4-5.4z'/%3E%3C/svg%3E");
47 | $icon-star: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18px' viewBox='0 0 576 512'%3E%3C!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --%3E%3Cstyle%3Esvg%7Bfill:%23606984%7D%3C/style%3E%3Cpath d='M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z'/%3E%3C/svg%3E");
48 | font-family: $font-stack;
49 | width: 600px;
50 |
51 | &.theme-mastodon {
52 | --color-bg: #313543;
53 | --color-text: #fff;
54 | --color-text-dim: #9baec8;
55 | --color-text-link: #8c8dff;
56 | --color-text-ultradim: #606984;
57 | --color-text-username: #fff;
58 | --color-interaction: var(--color-text-ultradim);
59 | --interaction-text-weight: 500;
60 | --color-border: #38384d;
61 | --color-hashtag: #d9e1e8;
62 | }
63 |
64 | &.theme-mastodon-white-interactions {
65 | --color-bg: #313543;
66 | --color-text: #f7f9f9;
67 | --color-text-dim: #9baec8;
68 | --color-text-link: #8c8dff;
69 | --color-text-ultradim: #606984;
70 | --color-text-username: #fff;
71 | --color-interaction: #d9e1e8;
72 | --interaction-text-weight: 700;
73 | --color-border: #38384d;
74 | --color-hashtag: #d9e1e8;
75 | }
76 |
77 | &.theme-mastodon-light {
78 | --color-bg: #fff;
79 | --color-text: #040404;
80 | --color-text-dim: #444b5d;
81 | --color-text-link: #5e64f8;
82 | --color-text-ultradim: #606984;
83 | --color-text-username: #000;
84 | --color-interaction: var(--color-text-ultradim);
85 | --interaction-text-weight: 500;
86 | --color-border: #38384d;
87 | --color-hashtag: #5e64f8;
88 | }
89 |
90 | .toot {
91 | background-color: var(--color-bg);
92 | color: var(--color-text);
93 | display: block;
94 | gap: 0rem;
95 | max-width: calc(600px - 4rem);
96 | padding: 2rem;
97 | position: relative;
98 | width: 100%;
99 |
100 | .profile {
101 | display: flex;
102 | gap: 0.75em;
103 | margin-bottom: 1rem;
104 |
105 | .display-name {
106 | display: flex;
107 | flex-direction: column;
108 | font-size: 15px;
109 | min-height: 30px;
110 | overflow: hidden;
111 | text-overflow: ellipsis;
112 |
113 | strong {
114 | font-weight: 500;
115 | overflow: hidden;
116 | text-overflow: ellipsis;
117 | white-space: nowrap;
118 |
119 | .emoji {
120 | height: 1em;
121 | margin: -1px 0 0;
122 | object-fit: contain;
123 | vertical-align: middle;
124 | width: 1em;
125 | }
126 | }
127 |
128 | .username {
129 | color: var(--color-text-username);
130 | height: 1.5rem;
131 | overflow: hidden;
132 | text-overflow: ellipsis;
133 | white-space: nowrap;
134 | }
135 |
136 | .datetime {
137 | color: var(--color-text-ultradim);
138 | overflow: hidden;
139 | position: absolute;
140 | right: 2rem;
141 | text-overflow: ellipsis;
142 | white-space: nowrap;
143 | }
144 | }
145 |
146 | .avatar img {
147 | border-radius: 4px;
148 | height: $avatar-size;
149 | width: $avatar-size;
150 | }
151 | }
152 |
153 | .content {
154 | font-size: 19px;
155 |
156 | p {
157 | font-feature-settings: "kern";
158 | font-style: normal;
159 | font-weight: 400;
160 | line-height: $line-height;
161 | margin: 0;
162 | margin-bottom: 20px;
163 | text-rendering: optimizelegibility;
164 | text-size-adjust: none;
165 | }
166 |
167 | a {
168 | color: var(--color-text-link);
169 | text-decoration: none;
170 | unicode-bidi: isolate;
171 | }
172 |
173 | .emoji {
174 | height: 24px;
175 | margin: -1px 0 0;
176 | object-fit: contain;
177 | vertical-align: middle;
178 | width: 24px;
179 | }
180 |
181 | .invisible {
182 | display: none;
183 | }
184 |
185 | .ellipsis::after {
186 | content: "…";
187 | }
188 |
189 | .hashtag {
190 | color: var(--color-hashtag);
191 | }
192 | }
193 |
194 | .poll {
195 | font-size: 14px;
196 |
197 | .poll-option {
198 | margin-bottom: 10px;
199 |
200 | .option-title {
201 | line-height: 16px;
202 | margin: 0;
203 | padding: 6px 0;
204 |
205 | strong {
206 | display: inline-block;
207 | width: 45px;
208 | }
209 | }
210 |
211 | .option-bar {
212 | background-color: var(--color-text-dim);
213 | border-radius: 4px;
214 | display: block;
215 | height: 5px;
216 | min-width: 1%;
217 |
218 | &.winner {
219 | background-color: var(--color-poll-winner);
220 | }
221 | }
222 | }
223 | }
224 |
225 | // Notice! Image attachments are not inside content.
226 | .gallery-holder {
227 | .image-gallery {
228 | border: 1px solid var(--color-border);
229 | border-radius: 16px;
230 | box-sizing: border-box;
231 | display: grid;
232 |
233 | width: 100%;
234 |
235 | .attachment {
236 | border-radius: 8px;
237 | height: 100%;
238 | object-fit: cover;
239 | width: 100%;
240 | }
241 | }
242 | }
243 |
244 | .action-bar {
245 | display: flex;
246 | margin-top: 12px;
247 |
248 | &.action-bar-hidden {
249 | display: none;
250 | }
251 |
252 | &.action-bar-feed {
253 | // Action bar is 568px wide, split to 5 (space-between)
254 | // -> 133.6, remove 50px (each buttons width)
255 | // -> 83.6px
256 | gap: 83.6px;
257 | margin-top: 24px;
258 |
259 | .action-bar-datetime {
260 | display: none;
261 | }
262 |
263 | .action {
264 | align-items: center;
265 | color: var(--color-interaction);
266 | display: inline-flex;
267 | width: 50px;
268 |
269 | .icon-boost {
270 | height: 22px;
271 | width: 22px;
272 | &::before {
273 | content: $icon-boost;
274 | }
275 | }
276 |
277 | .icon-reply {
278 | height: 18px;
279 | width: 20px;
280 |
281 | &::before {
282 | content: $icon-reply;
283 | }
284 | }
285 |
286 | .icon-star {
287 | height: 18px;
288 | width: 21px;
289 |
290 | &::before {
291 | content: $icon-star;
292 | }
293 | }
294 |
295 | .action-counter {
296 | display: inline-block;
297 | font-size: 12px;
298 | font-weight: var(--interaction-text-weight);
299 | margin-inline-start: 4px;
300 | margin-left: 4px;
301 | }
302 |
303 | .action-label {
304 | display: none;
305 | }
306 | }
307 | }
308 |
309 | &.action-bar-normal {
310 | font-size: 14px;
311 | gap: 6px;
312 |
313 | .action-bar-datetime {
314 | color: var(--color-text-ultradim);
315 | }
316 |
317 | &.no-replies {
318 | div:first-of-type {
319 | display: none;
320 | }
321 | }
322 |
323 | .action {
324 | color: var(--color-text-ultradim);
325 | display: inline-flex;
326 | gap: 4px;
327 |
328 | .action-counter {
329 | color: var(--color-interaction);
330 | font-weight: 500;
331 | }
332 |
333 | &::before {
334 | align-items: center;
335 | content: "·";
336 | font-weight: 500;
337 | line-height: 22px;
338 | }
339 | }
340 | }
341 | }
342 | }
343 | }
344 |
--------------------------------------------------------------------------------