Compare commits

...

67 Commits

Author SHA1 Message Date
d973c6c2ee update eir theme - bg
Some checks are pending
Build and Publish Docker / build (push) Waiting to run
2026-02-19 21:20:09 -06:00
ec60cfc24d Initial commit, my theme + changes + about.md 2026-02-19 21:20:08 -06:00
Cynthia Foxwell
da09fbcaf5
dont redirect twice on direct video 2026-01-12 12:46:24 -07:00
Cynthia Foxwell
7b15b8f0a2
force no referrer 2025-12-19 21:53:19 -07:00
Cynthia Foxwell
413882f650
discord redirect hack for .mp4 2025-12-08 18:27:32 -07:00
Cynthia Foxwell
0bc6b33251
bunch of upstream changes 2025-12-06 17:12:26 -07:00
Cynthia Foxwell
116652c2a5
update endpoints 2025-11-19 15:14:57 -07:00
Cynthia Foxwell
a7d056a550
fix 2025-11-13 21:02:12 -07:00
Cynthia Foxwell
e96606eba3
cleanup stats 2025-11-13 20:58:04 -07:00
Cynthia Foxwell
1b8275d1b8
disable retweeters and favorites endpoints entirely 2025-11-13 20:54:35 -07:00
Cynthia Foxwell
ec019eef72
fix headers so UserByRestId works 2025-11-13 20:48:34 -07:00
Cynthia Foxwell
0e74c1e9bd
filter promoted tweets the sequel 2025-11-12 19:05:38 -07:00
Cynthia Foxwell
26853a83ca
filter promoted tweets 2025-11-12 13:03:50 -07:00
Cynthia Foxwell
bed4014d4e
new endpoints 2025-11-06 11:59:14 -07:00
Cynthia Foxwell
1834742bb9
mastoapi: fields for AS response, add verified and pcf fields 2025-06-20 17:21:10 -06:00
Cynthia Foxwell
d3d6558913
hide "Rate proposed Community Notes" 2025-06-13 10:12:09 -06:00
Cynthia Foxwell
cef5429cdc
fix tweet cursor 2025-06-07 17:16:18 -06:00
Cynthia Foxwell
c3bcf30826
Limited actions 2025-05-15 12:06:51 -06:00
Cynthia Foxwell
f6e9887ddb
discord ios fix 2025-05-07 20:48:28 -06:00
Cynthia Foxwell
0f6afc2764
fix suspended users 2025-05-05 18:34:34 -06:00
Cynthia Foxwell
4fcbf7ba53
fix {formattedTime} not replacing 2025-04-28 10:24:19 -06:00
Cynthia Foxwell
9751237316
unproxy gifs so discord can play them 2025-04-20 21:23:50 -06:00
Cynthia Foxwell
b9d8ec6773
stupid and buggy 2025-04-18 21:41:24 -06:00
Cynthia Foxwell
3445b183cd
fix same origin urls in mastoapi 2025-04-18 21:37:39 -06:00
Cynthia Foxwell
a943767f42
update tweet endpoint 2025-04-17 22:21:04 -06:00
Cynthia Foxwell
1fcf017359
stats on embeds 2025-04-17 22:08:25 -06:00
Cynthia Foxwell
71b19ae72b
forgot to proxy brand label images 2025-04-16 13:43:08 -06:00
Cynthia Foxwell
b2d71407ba
brand labels, pcf labels, bookmark and view counts 2025-04-16 13:34:35 -06:00
Cynthia Foxwell
80cca7b070
telegram can have a little /photo/ and /video/ as a treat 2025-04-15 14:52:56 -06:00
Cynthia Foxwell
c09266cd17
replace urls in t.co redirect and fix missing X urls in replacer 2025-04-15 14:41:55 -06:00
Cynthia Foxwell
9b3862de69
mastoapi: comma the poll vote numbers 2025-04-09 20:44:01 -06:00
Cynthia Foxwell
4064bd5c11
less breaking and better way to transmit query params in ids
since discord will never have a fully compliant activitystream parser
2025-04-09 20:30:50 -06:00
Cynthia Foxwell
a6412968fe
mixed media in tweets, allow discord embeds to select shown media, ...more
- direct image linking (buggy if you try and do /photo or /video with no index
or slash)
- fix activitypub images not being images
- gross hack to tell discord to fetch a single image for fedi (broken for videos
lol, discord's media proxy is not activitystream spec compliant)
2025-04-09 19:54:13 -06:00
Cynthia Foxwell
71c772d6c9
forgot to finish the bot owner field in mastoapi 2025-04-09 12:58:45 -06:00
Cynthia Foxwell
f76cfcc154
automation labels 2025-04-09 12:53:07 -06:00
Cynthia Foxwell
536f9d7fab
okay everything account related fixed 2025-04-09 11:58:17 -06:00
Cynthia Foxwell
ce4dc8a9e2
oop 2025-04-09 11:18:43 -06:00
Cynthia Foxwell
a5c9e78f5b
tried to fix account id lookup but failed gn 2025-04-09 01:20:29 -06:00
Cynthia Foxwell
4da3904b89
mastoapi users 2025-04-09 00:51:31 -06:00
Cynthia Foxwell
68e90344d6
re-enable redis and rss im too lazy to make another sort of caching system 2025-04-09 00:15:25 -06:00
Cynthia Foxwell
91d3f4138c
relative to absolute links in mastoapi content 2025-04-07 17:38:16 -06:00
Cynthia Foxwell
900054bab0
fix missing newline in community note in embeds 2025-04-05 10:46:46 -06:00
Cynthia Foxwell
33db7b46cb
polls in embeds 2025-04-04 19:54:16 -06:00
Cynthia Foxwell
8042c65ce3
Refactor images to use an Image type to supply alt text 2025-04-04 17:11:40 -06:00
Cynthia Foxwell
f2cac5190a
guh 2025-04-04 14:17:01 -06:00
Cynthia Foxwell
d985bbcf5c
Context consistency and fetch reply user 2025-04-04 14:14:04 -06:00
Cynthia Foxwell
38f2ede5c0
attempt to fix a weird edge case with replies 2025-04-04 13:40:50 -06:00
Cynthia Foxwell
7a6548cb2b
fix gifs in discord embeds 2025-03-29 20:31:10 -06:00
Cynthia Foxwell
24a267da50
forgot some urls 2025-03-27 11:11:28 -06:00
Cynthia Foxwell
42e8e7219e
upstream search page change (users -> tweets) 2025-03-27 10:55:32 -06:00
Cynthia Foxwell
8304d99f15
update source url since itll be a while until gitdab comes back 2025-03-27 10:54:01 -06:00
Cynthia Foxwell
0023af4311
just make the apple touch icon transparent 2025-03-27 10:51:03 -06:00
Cynthia Foxwell
5772e4089c
rearrange icons so discord uses the transparent favicon 2025-03-27 10:42:21 -06:00
Cynthia Foxwell
d9aa2d1723
missed one 2025-03-23 21:48:00 -06:00
Cynthia Foxwell
1a520f5792
oop 2025-03-23 14:00:01 -06:00
Cynthia Foxwell
0d9ffa6aa2
forgot a check 2025-03-22 22:52:28 -06:00
Cynthia Foxwell
5240ccff2a
fix direct video route 2025-03-22 22:50:31 -06:00
Cynthia Foxwell
b3e35dba12
mastoapi/activitypub spoof so i can have peak discord embeds :) 2025-03-22 22:17:34 -06:00
Cynthia Foxwell
c7d6b4291c
Fix tweets not loading 2025-03-20 11:42:34 -06:00
Cynthia Foxwell
6e86e086c3
combine context for quoted tweet in reply 2025-03-08 12:41:19 -07:00
Cynthia Foxwell
271287e6f3
Parse reply-mixer-conversation replies 2025-02-14 11:25:28 -07:00
Cynthia Foxwell
3208e7d25f
fix images in embeds 2025-02-11 17:02:33 -07:00
Cynthia Foxwell
5e2d126aea
no more redis 2025-02-11 14:04:53 -07:00
Cynthia Foxwell
be4c83bfb0
i tried my hardest to ratelimit myself locally and it didnt work so this code fixes ratelimit issues :) 2025-02-11 13:15:17 -07:00
Cynthia Foxwell
c9b2e94ba9
dont try to use guest tokens period 2025-01-28 18:19:16 -07:00
Cynthia Foxwell
1cf37e4e84 Add replying to and quoting text where applicable 2024-12-18 23:53:30 -07:00
Cynthia Foxwell
396322772f Update about to include fork info and link to correct repo 2024-12-18 22:59:43 -07:00
69 changed files with 2803 additions and 1450 deletions

2
.rgignore Normal file
View File

@ -0,0 +1,2 @@
pnpm-lock.yaml
public/fonts/

View File

@ -26,12 +26,7 @@ enableRSS = true # set this to false to disable RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.accounts) enableDebug = false # enable request logs and debug endpoints (/.accounts)
proxy = "" # http/https url, SOCKS proxies are not supported proxy = "" # http/https url, SOCKS proxies are not supported
proxyAuth = "" proxyAuth = ""
tokenCount = 10 disableTid = false # enable this if cookie-based auth is failing
# minimum amount of usable tokens. tokens are used to authorize API requests,
# but they expire after ~1 hour, and have a limit of 500 requests per endpoint.
# the limits reset every 15 minutes, and the pool is filled up so there's
# always at least `tokenCount` usable tokens. only increase this if you receive
# major bursts all the time and don't have a rate limiting setup via e.g. nginx
# Change default preferences here, see src/prefs_impl.nim for a complete list # Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences] [Preferences]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,53 +1,121 @@
@font-face { @font-face {
font-family: 'fontello'; font-family: "fontello";
src: url('/fonts/fontello.eot?21002321'); src: url("/fonts/fontello.eot?76162212");
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'), src:
url('/fonts/fontello.woff2?21002321') format('woff2'), url("/fonts/fontello.eot?76162212#iefix") format("embedded-opentype"),
url('/fonts/fontello.woff?21002321') format('woff'), url("/fonts/fontello.woff2?76162212") format("woff2"),
url('/fonts/fontello.ttf?21002321') format('truetype'), url("/fonts/fontello.woff?76162212") format("woff"),
url('/fonts/fontello.svg?21002321#fontello') format('svg'); url("/fonts/fontello.ttf?76162212") format("truetype"),
url("/fonts/fontello.svg?76162212#fontello") format("svg");
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
[class^="icon-"]:before, [class*=" icon-"]:before { /* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
/*
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'fontello';
src: url('../font/fontello.svg?76162212#fontello') format('svg');
}
}
*/
[class^="icon-"]:before,
[class*=" icon-"]:before {
font-family: "fontello"; font-family: "fontello";
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
speak: never; speak: never;
display: inline-block; display: inline-block;
text-decoration: inherit; text-decoration: inherit;
width: 1em; width: 1em;
margin-right: 0.2em;
text-align: center; text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/ /* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal; font-variant: normal;
text-transform: none; text-transform: none;
/* fix buttons height, for twitter bootstrap */ /* fix buttons height, for twitter bootstrap */
line-height: 1em; line-height: 1em;
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: 0.2em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
/* Font smoothing. That was taken from TWBS */ /* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
/* Uncomment for 3D effect */
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
} }
.icon-heart:before { content: '\2665'; } /* '♥' */ .icon-heart:before {
.icon-quote:before { content: '\275e'; } /* '❞' */ content: "\2665";
.icon-comment:before { content: '\e802'; } /* '' */ } /* '♥' */
.icon-ok:before { content: '\e803'; } /* '' */ .icon-quote:before {
.icon-play:before { content: '\e804'; } /* '' */ content: "\275e";
.icon-link:before { content: '\e805'; } /* '' */ } /* '❞' */
.icon-calendar:before { content: '\e806'; } /* '' */ .icon-ok:before {
.icon-location:before { content: '\e807'; } /* '' */ content: "\e800";
.icon-picture:before { content: '\e809'; } /* '' */ } /* '' */
.icon-lock:before { content: '\e80a'; } /* '' */ .icon-play:before {
.icon-down:before { content: '\e80b'; } /* '' */ content: "\e801";
.icon-retweet:before { content: '\e80d'; } /* '' */ } /* '' */
.icon-search:before { content: '\e80e'; } /* '' */ .icon-comment:before {
.icon-pin:before { content: '\e80f'; } /* '' */ content: "\e802";
.icon-cog:before { content: '\e812'; } /* '' */ } /* '' */
.icon-rss-feed:before { content: '\e813'; } /* '' */ .icon-link:before {
.icon-info:before { content: '\f128'; } /* '' */ content: "\e803";
.icon-bird:before { content: '\f309'; } /* '' */ } /* '' */
.icon-calendar:before {
content: "\e804";
} /* '' */
.icon-picture:before {
content: "\e805";
} /* '' */
.icon-lock:before {
content: "\e806";
} /* '' */
.icon-down:before {
content: "\e807";
} /* '' */
.icon-retweet:before {
content: "\e808";
} /* '' */
.icon-search:before {
content: "\e809";
} /* '' */
.icon-pin:before {
content: "\e80a";
} /* '' */
.icon-cog:before {
content: "\e80b";
} /* '' */
.icon-info:before {
content: "\e80c";
} /* '' */
.icon-bookmark:before {
content: "\e80d";
} /* '' */
.icon-eye:before {
content: "\e80e";
} /* '' */
.icon-pcf:before {
content: "\e83a";
} /* '' */
.icon-location:before {
content: "\f031";
} /* '' */
.icon-bird:before {
content: "\f099";
} /* '' */
.icon-rss-feed:before {
content: "\f09e";
} /* '' */

BIN
public/css/fonts/Hack-Bold.ttf Executable file

Binary file not shown.

Binary file not shown.

BIN
public/css/fonts/Hack-Italic.ttf Executable file

Binary file not shown.

BIN
public/css/fonts/Hack-Regular.ttf Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

98
public/css/themes/eir.css Executable file
View File

@ -0,0 +1,98 @@
@font-face {
font-family: hack;
src: url("../fonts/Hack-Regular.ttf");
}
@font-face {
font-family: hack;
src: url("../fonts/Hack-Bold.ttf");
font-weight: bold;
}
@font-face {
font-family: hack;
src: url("../fonts/Hack-Italic.ttf");
font-style: italic;
}
@font-face {
font-family: hack;
src: url("../fonts/Hack-BoldItalic.ttf");
font-weight: bold;
font-style: italic;
}
@font-face {
font-family: Terminess;
src: url("../fonts/TerminessNerdFontMono-Regular.ttf");
}
@font-face {
font-family: Terminess;
src: url("../fonts/TerminessNerdFontMono-Bold.ttf");
font-weight: bold;
}
@font-face {
font-family: Terminess;
src: url("../fonts/TerminessNerdFontMono-Italic.ttf");
font-style: italic;
}
@font-face {
font-family: Terminess;
src: url("../fonts/TerminessNerdFontMono-BoldItalic.ttf");
font-weight: bold;
font-style: italic;
}
body {
background-image: url("https://eir-nya.gay/static/images/background/night_sky.png");
background-repeat: no-repeat;
background-size: cover;
background-position: 50% 0%;
background-attachment: fixed;
font-family: Terminess, hack, 'Courier New', courier, monospace;
}
.tweet-content {
font-family: hack, 'Courier New', courier, monospace;
}
.show-more {
background-color: transparent;
}
body {
--bg_color: transparent; /*#282a36;*/
--fg_color: #f8f8f2;
--fg_faded: #818eb6;
--fg_dark: var(--fg_faded);
--fg_nav: var(--accent);
--bg_panel: rgba(0.9803921568627451, 0.6666666666666666, 0.6705882352941176, 0.875); /*#343746;*/
--bg_elements: #292b36;
--bg_overlays: #20202080; /*#44475a;*/
--bg_hover: #2f323f;
--grey: var(--fg_faded);
--dark_grey: #44475a;
--darker_grey: #3d4051;
--darkest_grey: #363948;
--border_grey: #44475a;
--accent: #faaaab;
--accent_light: #facdce;
--accent_dark: #ab7475;
--accent_border: #e36f7196;
--play_button: #ffb86c;
--play_button_hover: #ffc689;
--more_replies_dots: #bd93f9;
--error_red: #ff5555;
--verified_blue: var(--accent);
--icon_text: ##F8F8F2;
--tab: #6272a4;
--tab_selected: var(--accent);
--profile_stat: #919cbf;
}
.search-bar > form input::placeholder{
color: var(--fg_faded);
}

Binary file not shown.

View File

@ -1,46 +1,52 @@
<?xml version="1.0" standalone="no"?> <?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata> <metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
<defs> <defs>
<font id="fontello" horiz-adv-x="1000" > <font id="fontello" horiz-adv-x="1000" >
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" /> <font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" /> <missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="heart" unicode="&#x2665;" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" /> <glyph glyph-name="heart" unicode="&#x2665;" d="M500-79q-14 0-25 10l-348 336q-5 5-15 15t-31 37-38 54-30 67-13 77q0 123 71 192t196 70q34 0 70-12t67-33 54-38 42-38q20 20 42 38t54 38 67 33 70 12q125 0 196-70t71-192q0-123-128-251l-347-335q-10-10-25-10z" horiz-adv-x="1000" />
<glyph glyph-name="quote" unicode="&#x275e;" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" /> <glyph glyph-name="quote" unicode="&#x275e;" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
<glyph glyph-name="ok" unicode="&#xe800;" d="M249 0q-34 0-56 28l-180 236q-16 24-12 52t26 46 51 14 47-28l118-154 296 474q16 24 43 30t53-8q24-16 30-43t-8-53l-350-560q-20-32-56-32z" horiz-adv-x="667" />
<glyph glyph-name="play" unicode="&#xe801;" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
<glyph glyph-name="comment" unicode="&#xe802;" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" /> <glyph glyph-name="comment" unicode="&#xe802;" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
<glyph glyph-name="ok" unicode="&#xe803;" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" /> <glyph glyph-name="link" unicode="&#xe803;" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
<glyph glyph-name="play" unicode="&#xe804;" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" /> <glyph glyph-name="calendar" unicode="&#xe804;" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
<glyph glyph-name="link" unicode="&#xe805;" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" /> <glyph glyph-name="picture" unicode="&#xe805;" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
<glyph glyph-name="calendar" unicode="&#xe806;" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" /> <glyph glyph-name="lock" unicode="&#xe806;" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" />
<glyph glyph-name="location" unicode="&#xe807;" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" /> <glyph glyph-name="down" unicode="&#xe807;" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
<glyph glyph-name="picture" unicode="&#xe809;" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" /> <glyph glyph-name="retweet" unicode="&#xe808;" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
<glyph glyph-name="lock" unicode="&#xe80a;" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" /> <glyph glyph-name="search" unicode="&#xe809;" d="M643 386q0 103-73 176t-177 74-177-74-73-176 73-177 177-73 177 73 73 177z m286-465q0-29-22-50t-50-21q-30 0-50 21l-191 191q-100-69-223-69-80 0-153 31t-125 84-84 125-31 153 31 152 84 126 125 84 153 31 153-31 125-84 84-126 31-152q0-123-69-223l191-191q21-21 21-51z" horiz-adv-x="928.6" />
<glyph glyph-name="down" unicode="&#xe80b;" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" /> <glyph glyph-name="pin" unicode="&#xe80a;" d="M573 37q0-23-15-38t-37-15q-21 0-37 16l-169 169-315-236 236 315-168 169q-24 23-12 56 14 32 48 32 157 0 270 57 90 45 151 171 9 24 36 32t50-13l208-209q21-23 14-50t-32-36q-127-63-172-152-56-110-56-268z" horiz-adv-x="834" />
<glyph glyph-name="retweet" unicode="&#xe80d;" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" /> <glyph glyph-name="cog" unicode="&#xe80b;" d="M571 350q0 59-41 101t-101 42-101-42-42-101 42-101 101-42 101 42 41 101z m286 61v-124q0-7-4-13t-11-7l-104-16q-10-30-21-51 19-27 59-77 6-6 6-13t-5-13q-15-21-55-61t-53-39q-7 0-14 5l-77 60q-25-13-51-21-9-76-16-104-4-16-20-16h-124q-8 0-14 5t-6 12l-16 103q-27 9-50 21l-79-60q-6-5-14-5-8 0-14 6-70 64-92 94-4 5-4 13 0 6 5 12 8 12 28 37t30 40q-15 28-23 55l-102 15q-7 1-11 7t-5 13v124q0 7 5 13t10 7l104 16q8 25 22 51-23 32-60 77-6 7-6 14 0 5 5 12 15 20 55 60t53 40q7 0 15-5l77-60q24 13 50 21 9 76 17 104 3 16 20 16h124q7 0 13-5t7-12l15-103q28-9 51-20l79 59q5 5 13 5 7 0 14-5 72-67 92-95 4-5 4-12 0-7-4-13-9-12-29-37t-30-40q15-28 23-54l102-16q7-1 12-7t4-13z" horiz-adv-x="857.1" />
<glyph glyph-name="search" unicode="&#xe80e;" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" /> <glyph glyph-name="info" unicode="&#xe80c;" d="M494 740q86-62 86-184 0-64-42-124-12-20-88-80l-46-30q-40-34-48-60-6-16-8-44 0-14-16-14l-128 0q-16 0-16 12 4 98 28 124 16 22 48 48t56 42l24 14q22 16 34 34 28 44 28 70 0 40-26 78-28 36-92 36-68 0-94-44-28-42-28-92l-166 0q6 162 114 232 70 42 166 42 130 0 214-60z m-216-636q44 0 73-30t27-74q-2-46-32-73t-74-25q-44 0-73 29t-27 75 32 73 74 25z" horiz-adv-x="580" />
<glyph glyph-name="pin" unicode="&#xe80f;" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" /> <glyph glyph-name="bookmark" unicode="&#xe80d;" d="M650 779q12 0 24-5 19-8 29-23t11-35v-719q0-19-11-35t-29-23q-10-4-24-4-27 0-47 18l-246 236-246-236q-20-19-46-19-13 0-25 5-18 7-29 23t-11 35v719q0 19 11 35t29 23q12 5 25 5h585z" horiz-adv-x="714.3" />
<glyph glyph-name="cog" unicode="&#xe812;" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" /> <glyph glyph-name="eye" unicode="&#xe80e;" d="M0 350q6 49 64 110 79 80 176 129 129 60 260 60 137-2 260-60 103-53 176-129 64-73 64-110-6-49-64-109-79-80-176-129-129-61-260-61-137 2-260 61-103 53-176 129-64 72-64 109z m264 0q0-94 69-159t167-65 167 65 69 159-69 159-167 66-167-66-69-159z m86 1q0 60 44 102t106 42 106-42 44-102-44-102-106-43-106 43-44 102z" horiz-adv-x="1000" />
<glyph glyph-name="rss-feed" unicode="&#xe813;" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" /> <glyph glyph-name="pcf" unicode="&#xe83a;" d="M50 800c-24 0-50-25-50-50l0-350c0-111 50-250 250-250 18 0 34 1 50 3l0 147-200 0s25 100 150 100c25 0 35-4 50-9l0 109c0 46 18 84 50 100 50 25 70 15 100 0 50-25 50-25 50-25l0 175c0 25-26 50-50 50-50 0-100-50-200-50-100 0-150 50-200 50z m88-175c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m262-75c-24 0-50-25-50-50l0-350c0-111 50-250 250-250 200 0 250 139 250 250l0 350c0 25-26 50-50 50-50 0-100-50-200-50-100 0-150 50-200 50z m88-175c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m225 0c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m-263-275l300 0s-25-100-150-100-150 100-150 100z" horiz-adv-x="850" />
<glyph glyph-name="info" unicode="&#xf128;" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" /> <glyph glyph-name="location" unicode="&#xf031;" d="M0 473q0 70 27 134 25 61 74 111 47 47 110 73 63 27 134 27 72 0 135-27 62-26 110-73 48-49 74-111 27-64 27-134t-27-134q-18-40-36-65l-229-347q-22-35-53-35t-55 35l-228 347q-22 31-36 65-27 64-27 134z m217 0q0-53 37-91 38-38 91-38 54 0 91 38 38 37 38 91 0 53-38 91-37 38-91 38-53 0-91-38-37-38-37-91z" horiz-adv-x="691.4" />
<glyph glyph-name="bird" unicode="&#xf309;" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" /> <glyph glyph-name="bird" unicode="&#xf099;" d="M904 622q-37-54-90-93 0-8 0-23 0-73-21-145t-64-139-103-117-144-82-181-30q-151 0-276 81 19-2 43-2 126 0 224 77-59 1-105 36t-64 89q19-3 34-3 24 0 48 6-63 13-104 62t-41 115v2q38-21 82-23-37 25-59 64t-22 86q0 49 25 91 68-83 164-133t208-55q-5 21-5 41 0 75 53 127t127 53q79 0 132-57 61 12 115 44-21-64-80-100 52 6 104 28z" horiz-adv-x="928.6" />
<glyph glyph-name="rss-feed" unicode="&#xf09e;" d="M214 100q0-45-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m286-69q1-15-9-26-10-12-27-12h-75q-14 0-24 9t-11 23q-12 128-103 219t-219 103q-14 1-23 11t-9 24v75q0 16 12 26 9 10 24 10h3q89-7 170-45t145-101q63-63 101-145t45-171z m286-1q1-15-10-26-10-11-26-11h-80q-14 0-25 10t-10 23q-7 120-57 228t-129 188-188 129-227 57q-14 1-24 11t-10 24v80q0 16 11 26 10 10 25 10h1q147-8 280-67t238-164q104-104 164-238t67-280z" horiz-adv-x="785.7" />
</font> </font>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
public/js/eirResources.js Executable file
View File

@ -0,0 +1,7 @@
let eirTheme = document.querySelector("link[href='/css/themes/eir.css']");
if (eirTheme != null) {
let cursorScr = document.createElement("script");
cursorScr.src = "/res/js/cursors.js";
cursorScr.defer = "";
document.getElementsByTagName("head")[0].appendChild(cursorScr);
}

0
public/js/hls.min.js → public/js/hls.light.min.js vendored Normal file → Executable file
View File

View File

@ -1,54 +1,89 @@
# About # My instance
Nitter is a free and open source alternative Twitter front-end focused on **This instance is running a fork, whose source can be found at**
privacy and performance. The source is available on GitHub at <https://git.eir-nya.gay/eir/nitter>.
<https://github.com/zedeus/nitter>
* No JavaScript or ads My fork is based on [Cynthia Foxwell's fork](https://gitlab.com/Cynosphere/nitter).
* All requests go through the backend, client never talks to Twitter Nitter is created by Zedeus, whose source can be found at <https://github.com/zedeus/nitter>.
* Prevents Twitter from tracking your IP or JavaScript fingerprint
* Uses Twitter's unofficial API (no rate limits or developer account required)
* Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
* RSS feeds
* Themes
* Mobile support (responsive design)
* AGPLv3 licensed, no proprietary instances permitted
Nitter's GitHub wiki contains The rest of this page is copied from Cynthia's fork:
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
maintained by the community.
## Why use Nitter? > # About
>
It's impossible to use Twitter without JavaScript enabled. For privacy-minded > Nitter is a free and open source alternative Twitter front-end focused on
folks, preventing JavaScript analytics and IP-based tracking is important, but > privacy and performance.
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind >
a VPN and using heavy-duty adblockers, you can get accurately tracked with your > * No JavaScript or ads
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/), > * All requests go through the backend, client never talks to Twitter
[no JavaScript required](https://noscriptfingerprint.com/). This all became > * Prevents Twitter from tracking your IP or JavaScript fingerprint
particularly important after Twitter [removed the > * Uses Twitter's unofficial API (no rate limits or developer account required)
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws) > * Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
for users to control whether their data gets sent to advertisers. > * RSS feeds
> * Themes
Using an instance of Nitter (hosted on a VPS for example), you can browse > * Mobile support (responsive design)
Twitter without JavaScript while retaining your privacy. In addition to > * AGPLv3 licensed, no proprietary instances permitted (source code below)
respecting your privacy, Nitter is on average around 15 times lighter than >
Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster). > Nitter's GitHub wiki contains
> [instances](https://github.com/zedeus/nitter/wiki/Instances) and
In the future a simple account system will be added that lets you follow Twitter > [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
users, allowing you to have a clean chronological timeline without needing a > maintained by the community.
Twitter account. >
> ### Fork features by Cynthia Foxwell
## Donating >
> * Localized following via cookies (list exportable and editable in preferences)
Liberapay: <https://liberapay.com/zedeus> \ > * Image zooming/carousel (requires JavaScript)
Patreon: <https://patreon.com/nitter> \ > * Up to date Twitter features, e.g. Community Notes
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \ > * Embeds for chat services on-par with services like [FxTwitter](https://github.com/FixTweet/FxTwitter) and [vxTwitter](https://github.com/dylanpdx/BetterTwitFix)
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \ >
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \ > ## Why use Nitter?
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL >
> It's impossible to use Twitter without JavaScript enabled. For privacy-minded
## Contact > folks, preventing JavaScript analytics and IP-based tracking is important, but
> apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org). > a VPN and using heavy-duty adblockers, you can get accurately tracked with your
> [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
> [no JavaScript required](https://noscriptfingerprint.com/). This all became
> particularly important after Twitter [removed the
> ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
> for users to control whether their data gets sent to advertisers.
>
> Using an instance of Nitter (hosted on a VPS for example), you can browse
> Twitter without JavaScript while retaining your privacy. In addition to
> respecting your privacy, Nitter is on average around 15 times lighter than
> Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
>
> ## Donating
>
> Even though I could be selfish and point people to donate to me instead of
> Zedeus, it would be disrespectful.
>
> GitHub Sponsors: <https://github.com/sponsors/zedeus> \
> Donations go to zedeus, original creator of Nitter.
>
> Liberapay: <https://liberapay.com/zedeus> \
> Patreon: <https://patreon.com/nitter> \
> BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
> ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
> LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
> XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
>
> ## Credits
>
> * Zedeus for this project
> * PrivacyDevel, cmj, and taskylizard for keeping this project alive with forks after the main repo went inactive
> * Every other contributors who've committed to the main repo in the past
>
> ## To any law enforcement agencies and copyright holders
>
> **All illegal content should be reported to Twitter directly.** This service is
> merely a proxy of Twitter and no content is hosted on this server. Do not waste
> your time contacting internet service providers, hosting providers and/or domain
> registrars.
>
> If you would like more context, you can read about this exact issue happening to
> [PussTheCat.org's instance](https://pussthecat.org/nitter/).
>
> I emplore all Nitter instance hosts to not enable media proxying, even if it
> "phones home" to Twitter's CDN (which doesn't really pose a tracking risk and
> breaks videos anyways), as it [has been used as an attack vector to take down
> nitter.net](https://github.com/zedeus/nitter/issues/1150#issuecomment-1890855255).

View File

@ -1,6 +1,6 @@
{ {
"name": "Nitter", "name": "Kitter",
"short_name": "Nitter", "short_name": "Kitter",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",
@ -18,7 +18,7 @@
"type": "image/png" "type": "image/png"
} }
], ],
"theme_color": "#333333", "theme_color": "#faaaab",
"background_color": "#333333", "background_color": "#faaaab",
"display": "standalone" "display": "standalone"
} }

View File

@ -1,59 +1,114 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar import asyncdispatch, httpclient, strutils, sequtils, sugar
import packedjson import packedjson
import types, query, formatters, consts, apiutils, parser import types, query, formatters, consts, apiutils, parser
import experimental/parser as newParser import experimental/parser as newParser
# Helper to generate params object for GraphQL requests
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
result.add ("variables", variables)
result.add ("features", gqlFeatures)
if fieldToggles.len > 0:
result.add ("fieldToggles", fieldToggles)
proc apiUrl*(endpoint, variables: string; fieldToggles = ""): ApiUrl =
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
proc apiReq*(endpoint, variables: string; fieldToggles = ""): ApiReq =
let url = apiUrl(endpoint, variables, fieldToggles)
return ApiReq(cookie: url, oauth: url)
proc mediaUrl*(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor], """{"withArticlePlainText":false}"""),
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
)
proc userTweetsUrl*(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
)
proc userTweetsAndRepliesUrl*(id: string; cursor: string): ApiReq =
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
result = ApiReq(
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
)
proc tweetDetailUrl*(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphTweetDetail, tweetDetailVars % [id, cursor], tweetDetailFieldToggles),
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
)
proc userUrl*(username: string): ApiReq =
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
result = ApiReq(
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
)
proc getGraphUser*(username: string): Future[User] {.async.} = proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return if username.len == 0: return
let
variables = """{"screen_name": "$1"}""" % username let headers = newHttpHeaders()
params = {"variables": variables, "features": gqlFeatures} headers.add("Referer", """https://x.com/$1""" % username)
js = await fetchRaw(graphUser ? params, Api.userScreenName)
let js = await fetchRaw(userUrl(username), headers)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} =
if id.len == 0 or id.any(c => not c.isDigit): return if id.len == 0 or id.any(c => not c.isDigit): return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/user/$1""" % id)
let let
variables = """{"rest_id": "$1"}""" % id url = apiReq(graphUserById, """{"userId":"$1"}""" % id)
params = {"variables": variables, "features": gqlFeatures} js = await fetchRaw(url, headers)
js = await fetchRaw(graphUserById ? params, Api.userRestId)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
if id.len == 0: return if id.len == 0: return
let
endpoint = case kind
of TimelineKind.tweets: ""
of TimelineKind.replies: "/with_replies"
of TimelineKind.media: "/media"
headers = newHttpHeaders()
headers.add("Referer", """https://x.com/$1$2""" % [id, endpoint])
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = if kind == TimelineKind.media: userMediaVariables % [id, cursor] else: userTweetsVariables % [id, cursor] url = case kind
fieldToggles = """{"withArticlePlainText":true}""" of TimelineKind.tweets: userTweetsUrl(id, cursor)
params = {"variables": variables, "features": gqlFeatures, "fieldToggles": fieldToggles} of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
(url, apiId) = case kind of TimelineKind.media: mediaUrl(id, cursor)
of TimelineKind.tweets: (graphUserTweets, Api.userTweets) js = await fetch(url, headers)
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
of TimelineKind.media: (graphUserMedia, Api.userMedia)
js = await fetch(url ? params, apiId)
result = parseGraphTimeline(js, if kind == TimelineKind.media: "" else: "user", after) result = parseGraphTimeline(js, if kind == TimelineKind.media: "" else: "user", after)
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = listTweetsVariables % [id, cursor] url = apiReq(graphListTweets, restIdVars % [id, cursor])
params = {"variables": variables, "features": gqlFeatures} js = await fetch(url)
js = await fetch(graphListTweets ? params, Api.listTweets)
result = parseGraphTimeline(js, "list", after).tweets result = parseGraphTimeline(js, "list", after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let let
variables = %*{"screenName": name, "listSlug": list} variables = %*{"screenName": name, "listSlug": list}
params = {"variables": $variables, "features": gqlFeatures} url = apiReq(graphListBySlug, $variables)
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug)) js = await fetch(url)
result = parseGraphList(js)
proc getGraphList*(id: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} =
let let
variables = """{"listId": "$1"}""" % id url = apiReq(graphListById, """{"listId": "$1"}""" % id)
params = {"variables": variables, "features": gqlFeatures} js = await fetch(url)
result = parseGraphList(await fetch(graphListById ? params, Api.list)) result = parseGraphList(js)
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
if list.id.len == 0: return if list.id.len == 0: return
@ -67,77 +122,45 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} let
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) url = apiReq(graphListMembers, $variables)
js = await fetchRaw(url)
proc getFavorites*(id: string; cfg: Config; after=""): Future[Profile] {.async.} = result = parseGraphListMembers(js, after)
if id.len == 0: return
var
variables = %*{
"userId": id,
"includePromotedContent":false,
"withClientEventToken":false,
"withBirdwatchNotes":false,
"withVoice":true,
"withV2Timeline":false
}
if after.len > 0:
variables["cursor"] = % after
let
url = consts.favorites ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphTimeline(await fetch(url, Api.favorites), after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/status/$1""" % id)
let let
variables = """{"rest_id": "$1"}""" % id url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
params = {"variables": variables, "features": gqlFeatures} js = await fetch(url, headers)
js = await fetch(graphTweetResult ? params, Api.tweetResult)
result = parseGraphTweetResult(js) result = parseGraphTweetResult(js)
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = proc getGraphTweet*(id: string; after=""): Future[Conversation] {.async.} =
if id.len == 0: return if id.len == 0: return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/status/$1""" % id)
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = tweetVariables % [id, cursor] js = await fetch(tweetDetailUrl(id, cursor), headers)
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphTweet ? params, Api.tweetDetail)
result = parseGraphConversation(js, id) result = parseGraphConversation(js, id)
proc getGraphFavoriters*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = reactorsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFavoriters ? params, Api.favoriters)
result = parseGraphFavoritersTimeline(js, id)
proc getGraphRetweeters*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = reactorsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphRetweeters ? params, Api.retweeters)
result = parseGraphRetweetersTimeline(js, id)
proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} = proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = followVariables % [id, cursor] js = await fetch(apiReq(graphFollowing, followVars % [id, cursor]))
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowing ? params, Api.following)
result = parseGraphFollowTimeline(js, id) result = parseGraphFollowTimeline(js, id)
proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} = proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = followVariables % [id, cursor] js = await fetch(apiReq(graphFollowers, followVars % [id, cursor]))
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowers ? params, Api.followers)
result = parseGraphFollowTimeline(js, id) result = parseGraphFollowTimeline(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
@ -158,15 +181,16 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
variables = %*{ variables = %*{
"rawQuery": q, "rawQuery": q,
"count": 20, "count": 20,
"query_source": "typed_query",
"product": "Latest", "product": "Latest",
"withDownvotePerspective": false, "withGrokTranslatedBio": false
"withReactionsMetadata": false,
"withReactionsPerspective": false
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} let
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[Tweets](js, after)
result.query = query result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
@ -177,26 +201,24 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
variables = %*{ variables = %*{
"rawQuery": query.text, "rawQuery": query.text,
"count": 20, "count": 20,
"query_source": "typed_query",
"product": "People", "product": "People",
"withDownvotePerspective": false, "withGrokTranslatedBio": false
"withReactionsMetadata": false,
"withReactionsPerspective": false
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
result.beginning = false result.beginning = false
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} let
result = parseGraphSearch[User](await fetch(url, Api.search), after) url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[User](js, after)
result.query = query result.query = query
proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if name.len == 0: return if id.len == 0: return
let let js = await fetch(mediaUrl(id, ""))
ps = genParams({"screen_name": name, "trim_user": "true"}, result = parseGraphPhotoRail(js)
count="18", ext=false)
url = photoRail ? ps
result = parsePhotoRail(await fetch(url, Api.photoRail))
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
let client = newAsyncHttpClient(maxRedirects=0) let client = newAsyncHttpClient(maxRedirects=0)

View File

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import httpclient, asyncdispatch, options, strutils, uri, times, tables import httpclient, asyncdispatch, options, strutils, uri, times, tables, math
import jsony, packedjson, zippy import jsony, packedjson, zippy
import types, tokens, consts, parserutils, http_pool import types, consts, parserutils, http_pool, tid
import experimental/types/common import experimental/types/common
import config import config
@ -9,66 +9,63 @@ const
rlRemaining = "x-rate-limit-remaining" rlRemaining = "x-rate-limit-remaining"
rlReset = "x-rate-limit-reset" rlReset = "x-rate-limit-reset"
var pool: HttpPool var
pool: HttpPool
disableTid: bool
proc genParams*(pars: openArray[(string, string)] = @[]; cursor=""; proc setDisableTid*(disable: bool) =
count="20"; ext=true): seq[(string, string)] = disableTid = disable
result = timelineParams
for p in pars:
result &= p
if ext:
result &= ("include_ext_alt_text", "1")
result &= ("include_ext_media_stats", "1")
result &= ("include_ext_media_availability", "1")
if count.len > 0:
result &= ("count", count)
if cursor.len > 0:
# The raw cursor often has plus signs, which sometimes get turned into spaces,
# so we need to turn them back into a plus
if " " in cursor:
result &= ("cursor", cursor.replace(" ", "+"))
else:
result &= ("cursor", cursor)
proc genHeaders*(token: Token = nil): HttpHeaders = proc toUrl(req: ApiReq): Uri =
let c = req.cookie
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
proc rateLimitError*(): ref RateLimitError =
newException(RateLimitError, "rate limited")
proc genHeaders*(): HttpHeaders =
let
t = getTime()
ffVersion = floor(124 + (t.toUnix() - 1710892800) / 2419200)
result = newHttpHeaders({ result = newHttpHeaders({
"connection": "keep-alive", "Connection": "keep-alive",
"authorization": auth, "Authorization": bearerToken,
"content-type": "application/json", "Content-Type": "application/json",
"x-guest-token": if token == nil: "" else: token.tok, "Accept-Encoding": "gzip",
"Accept-Language": "en-US,en;q=0.5",
"Accept": "*/*",
"DNT": "1",
"Host": "x.com",
"Origin": "https://x.com",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Sec-GPC": "1",
"TE": "trailers",
"User-Agent": """Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:$1.0) Gecko/20100101 Firefox/$1.0""" % $ffVersion,
"x-twitter-active-user": "yes", "x-twitter-active-user": "yes",
"authority": "api.twitter.com", "x-twitter-auth-type": "OAuth2Session",
"accept-encoding": "gzip", "x-twitter-client-language": "en"
"accept-language": "en-US,en;q=0.9", }, true)
"accept": "*/*",
"DNT": "1"
})
template updateToken() =
if resp.headers.hasKey(rlRemaining):
let
remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset])
token.setRateLimit(api, remaining, reset)
template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
once: once:
pool = HttpPool() pool = HttpPool()
var token = await getToken(api)
if token.tok.len == 0:
raise rateLimitError()
if len(cfg.cookieHeader) != 0: if len(cfg.cookieHeader) != 0:
additional_headers.add("Cookie", cfg.cookieHeader) additional_headers.add("Cookie", cfg.cookieHeader)
if not disableTid:
additional_headers.add("x-client-transaction-id", await genTid(url.path))
if len(cfg.xCsrfToken) != 0: if len(cfg.xCsrfToken) != 0:
additional_headers.add("x-csrf-token", cfg.xCsrfToken) additional_headers.add("x-csrf-token", cfg.xCsrfToken)
try: try:
var resp: AsyncResponse var resp: AsyncResponse
var headers = genHeaders(token) var headers = genHeaders()
for key, value in additional_headers.pairs(): for key, value in additional_headers.pairs():
headers.add(key, value) headers.add(key, value)
pool.use(headers): pool.use(headers):
template getContent = template getContent =
resp = await c.get($url) resp = await c.get($url)
@ -87,7 +84,6 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
let let
remaining = parseInt(resp.headers[rlRemaining]) remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset]) reset = parseInt(resp.headers[rlReset])
token.setRateLimit(api, remaining, reset)
if result.len > 0: if result.len > 0:
if resp.headers.getOrDefault("content-encoding") == "gzip": if resp.headers.getOrDefault("content-encoding") == "gzip":
@ -96,75 +92,55 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
if result.startsWith("{\"errors"): if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors) let errors = result.fromJson(Errors)
if errors in {expiredToken, badToken, authorizationError}: if errors in {expiredToken, badToken, authorizationError}:
echo "fetch error: ", errors
release(token, invalid=true)
raise rateLimitError() raise rateLimitError()
elif errors in {rateLimited}: elif errors in {rateLimited}:
# rate limit hit, resets after 24 hours
#setLimited(account, api)
raise rateLimitError() raise rateLimitError()
elif result.startsWith("429 Too Many Requests"): elif result.startsWith("429 Too Many Requests"):
echo "[accounts] 429 error, API: ", api, ", token: ", token[]
#account.apis[api].remaining = 0
# rate limit hit, resets after the 15 minute window
raise rateLimitError() raise rateLimitError()
fetchBody fetchBody
release(token, used=true)
if resp.status == $Http400: if resp.status == $Http400:
echo "ERROR 400, ", url.path, ": ", result
raise newException(InternalError, $url) raise newException(InternalError, $url)
except InternalError as e: except InternalError as e:
raise e raise e
except BadClientError as e: except BadClientError as e:
release(token, used=true)
raise e raise e
except OSError as e: except OSError as e:
raise e raise e
except ProtocolError as e:
raise e
except Exception as e: except Exception as e:
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url.path
if "length" notin e.msg and "descriptor" notin e.msg:
release(token, invalid=true)
raise rateLimitError() raise rateLimitError()
template retry(bod) = template retry(bod) =
try: try:
bod bod
except RateLimitError: except ProtocolError:
echo "[accounts] Rate limited, retrying ", api, " request..."
bod bod
proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} = proc fetch*(req: ApiReq; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
retry: retry:
var body: string var body: string
let url = req.toUrl()
fetchImpl(body, additional_headers): fetchImpl(body, additional_headers):
if body.startsWith('{') or body.startsWith('['): if body.startsWith('{') or body.startsWith('['):
result = parseJson(body) result = parseJson(body)
else: else:
echo resp.status, ": ", body, " --- url: ", url echo resp.status, " - non-json for: ", url, ", body: ", result
result = newJNull() result = newJNull()
updateToken()
let error = result.getError let error = result.getError
if error in {expiredToken, badToken}: if error in {expiredToken, badToken}:
echo "fetch error: ", result.getError echo "Fetch error, API: ", url.path, ", error: ", result.getError
release(token, invalid=true)
raise rateLimitError() raise rateLimitError()
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} = proc fetchRaw*(req: ApiReq; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
retry: retry:
let url = req.toUrl()
fetchImpl(result, additional_headers): fetchImpl(result, additional_headers):
if not (result.startsWith('{') or result.startsWith('[')): if not (result.startsWith('{') or result.startsWith('[')):
echo resp.status, ": ", result, " --- url: ", url echo resp.status, " - non-json for: ", url, ", body: ", result
result.setLen(0) result.setLen(0)
updateToken()
if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors)
if errors in {expiredToken, badToken}:
echo "fetch error: ", errors
release(token, invalid=true)
raise rateLimitError()

View File

@ -126,7 +126,7 @@ proc getAccountPoolDebug*(): JsonNode =
proc rateLimitError*(): ref RateLimitError = proc rateLimitError*(): ref RateLimitError =
newException(RateLimitError, "rate limited") newException(RateLimitError, "rate limited")
proc isLimited(account: GuestAccount; api: Api): bool = proc isLimited(account: GuestAccount; req: ApiReq): bool =
if account.isNil: if account.isNil:
return true return true
@ -157,9 +157,9 @@ proc release*(account: GuestAccount) =
if account.isNil: return if account.isNil: return
dec account.pending dec account.pending
proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} = proc getGuestAccount*(req: ApiReq): Future[GuestAccount] {.async.} =
for i in 0 ..< accountPool.len: for i in 0 ..< accountPool.len:
if result.isReady(api): break if result.isReady(req): break
result = accountPool.sample() result = accountPool.sample()
if not result.isNil and result.isReady(api): if not result.isNil and result.isReady(api):

View File

@ -22,6 +22,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
httpMaxConns: cfg.get("Server", "httpMaxConnections", 100), httpMaxConns: cfg.get("Server", "httpMaxConnections", 100),
staticDir: cfg.get("Server", "staticDir", "./public"), staticDir: cfg.get("Server", "staticDir", "./public"),
title: cfg.get("Server", "title", "Nitter"), title: cfg.get("Server", "title", "Nitter"),
oembedColor: cfg.get("Server", "oembedColor", "#1F1F1F"),
hostname: cfg.get("Server", "hostname", "nitter.net"), hostname: cfg.get("Server", "hostname", "nitter.net"),
# Cache # Cache
@ -42,6 +43,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""), proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""),
disableTid: cfg.get("Config", "disableTid", false),
cookieHeader: cfg.get("Config", "cookieHeader", ""), cookieHeader: cfg.get("Config", "cookieHeader", ""),
xCsrfToken: cfg.get("Config", "xCsrfToken", "") xCsrfToken: cfg.get("Config", "xCsrfToken", "")
) )

View File

@ -1,105 +1,77 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import uri, sequtils, strutils import strutils
const const
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
api = parseUri("https://api.twitter.com") graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
activate* = $(api / "1.1/guest/activate.json") graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
graphUserById* = "Bbaot8ySMtJD7K2t01gW7A/UserByRestId"
photoRail* = api / "1.1/statuses/media_timeline.json" graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
timelineApi = api / "2/timeline" graphUserTweets* = "lZRf8IC-GTuGxDwcsHW8aw/UserTweets"
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphql = api / "graphql" graphUserMedia* = "1D04dx9H2pseMQAbMjXTvQ/UserMedia"
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery" graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery" graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2" graphTweetDetail* = "6QzqakNMdh_YzBAR9SYPkQ/TweetDetail"
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2" graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia" graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail" graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" graphFollowers* = "Efm7xwLreAw77q2Fq7rX-Q/Followers"
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" graphFollowing* = "e0UtTAwQqgLKBllQxMgVxQ/Following"
graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"
graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers"
graphFollowing* = graphql / "JPZiqKjET7_M1r5Tlr8pyA/Following"
favorites* = graphql / "eSSNbhECHHWWALkkQq-YTA/Likes"
timelineParams* = {
"include_can_media_tag": "1",
"include_cards": "1",
"include_entities": "1",
"include_profile_interstitial_type": "0",
"include_quote_count": "0",
"include_reply_count": "0",
"include_user_entities": "0",
"include_ext_reply_count": "0",
"include_ext_media_color": "0",
"cards_platform": "Web-13",
"tweet_mode": "extended",
"send_error_codes": "1",
"simple_quoted_tweet": "1"
}.toSeq
gqlFeatures* = """{ gqlFeatures* = """{
"android_graphql_skip_api_media_color_palette": false, "rweb_video_screen_enabled": false,
"articles_preview_enabled": false, "profile_label_improvements_pcf_label_in_post_enabled": true,
"blue_business_profile_image_shape_enabled": false, "responsive_web_profile_redirect_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": false, "rweb_tipjar_consumption_enabled": true,
"communities_web_enable_tweet_community_results_fetch": false,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"creator_subscriptions_subscription_count_enabled": false,
"creator_subscriptions_tweet_preview_api_enabled": true,
"freedom_of_speech_not_reach_fetch_enabled": false,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
"hidden_profile_likes_enabled": false,
"highlights_tweets_tab_ui_enabled": false,
"interactive_text_enabled": false,
"longform_notetweets_consumption_enabled": true,
"longform_notetweets_inline_media_enabled": false,
"longform_notetweets_richtext_consumption_enabled": true,
"longform_notetweets_rich_text_read_enabled": false,
"responsive_web_edit_tweet_api_enabled": false,
"responsive_web_enhance_cards_enabled": false,
"responsive_web_graphql_exclude_directive_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"responsive_web_graphql_timeline_navigation_enabled": false,
"responsive_web_media_download_video_enabled": false,
"responsive_web_text_conversations_enabled": false,
"responsive_web_twitter_article_tweet_consumption_enabled": false,
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
"rweb_lists_timeline_redesign_enabled": true,
"rweb_tipjar_consumption_enabled": false,
"rweb_video_timestamps_enabled": true,
"spaces_2022_h2_clipping": true,
"spaces_2022_h2_spaces_communities": true,
"standardized_nudges_misinfo": false,
"subscriptions_verification_info_enabled": true,
"subscriptions_verification_info_reason_enabled": true,
"subscriptions_verification_info_verified_since_enabled": true,
"super_follow_badge_privacy_enabled": false,
"super_follow_exclusive_tweet_notifications_enabled": false,
"super_follow_tweet_api_enabled": false,
"super_follow_user_api_enabled": false,
"tweet_awards_web_tipping_enabled": false,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
"tweetypie_unmention_optimization_enabled": false,
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
"verified_phone_label_enabled": false, "verified_phone_label_enabled": false,
"vibe_api_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true,
"view_counts_everywhere_api_enabled": false "responsive_web_graphql_timeline_navigation_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true,
"responsive_web_jetfuel_frame": true,
"responsive_web_grok_share_attachment_enabled": true,
"articles_preview_enabled": true,
"responsive_web_edit_tweet_api_enabled": true,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
"view_counts_everywhere_api_enabled": true,
"longform_notetweets_consumption_enabled": true,
"responsive_web_twitter_article_tweet_consumption_enabled": true,
"tweet_awards_web_tipping_enabled": false,
"responsive_web_grok_show_grok_translated_post": true,
"responsive_web_grok_analysis_button_from_backend": true,
"creator_subscriptions_quote_tweet_preview_enabled": false,
"freedom_of_speech_not_reach_fetch_enabled": true,
"standardized_nudges_misinfo": true,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
"longform_notetweets_rich_text_read_enabled": true,
"longform_notetweets_inline_media_enabled": true,
"responsive_web_grok_image_annotation_enabled": true,
"responsive_web_grok_imagine_annotation_enabled": true,
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
"responsive_web_enhance_cards_enabled": false,
"payments_enabled": false,
"responsive_web_twitter_article_notes_tab_enabled": false,
"hidden_profile_subscriptions_enabled": false,
"subscriptions_verification_info_verified_since_enabled": false,
"subscriptions_verification_info_is_identity_verified_enabled": false,
"highlights_tweets_tab_ui_enabled": false,
"subscriptions_feature_can_gift_premium": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVariables* = """{ tweetVars* = """{
"focalTweetId": "$1", "postId": "$1",
$2 $2
"includeHasBirdwatchNotes": false, "includeHasBirdwatchNotes": false,
"includePromotedContent": false, "includePromotedContent": false,
@ -108,47 +80,57 @@ const
"withV2Timeline": true "withV2Timeline": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
# oldUserTweetsVariables* = """{ tweetDetailVars* = """{
# "userId": "$1", $2 "focalTweetId": "$1",
# "count": 20,
# "includePromotedContent": false,
# "withDownvotePerspective": false,
# "withReactionsMetadata": false,
# "withReactionsPerspective": false,
# "withVoice": false,
# "withV2Timeline": true
# }
# """.replace(" ", "").replace("\n", "")
userTweetsVariables* = """{
"rest_id": "$1",
$2 $2
"count": 20 "referrer": "profile",
"with_rux_injections": false,
"rankingMode": "Relevance",
"includePromotedContent": true,
"withCommunity": true,
"withQuickPromoteEligibilityTweetFields": true,
"withBirdwatchNotes": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
listTweetsVariables* = """{ restIdVars* = """{
"rest_id": "$1", "rest_id": "$1", $2
$2
"count": 20 "count": 20
}""".replace(" ", "").replace("\n", "") }"""
reactorsVariables* = """{ userMediaVars* = """{
"tweetId": "$1", "userId": "$1", $2
$2
"count": 20, "count": 20,
"includePromotedContent": false "includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
followVariables* = """{ userTweetsVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
"withQuickPromoteEligibilityTweetFields": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
userTweetsAndRepliesVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
"withCommunity": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
followVars* = """{
"userId": "$1", "userId": "$1",
$2 $2
"count": 20, "count": 20,
"includePromotedContent": false "includePromotedContent": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userMediaVariables* = """{
"userId": "$1", userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
$2 userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
"count": 20, tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
"includePromotedContent": false
}""".replace(" ", "").replace("\n", "")

View File

@ -1,21 +1,53 @@
import options import options, strutils
import jsony import jsony
import user, ../types/[graphuser, graphlistmembers] import user, utils, ../types/[graphuser, graphlistmembers]
from ../../types import User, VerifiedType, Result, Query, QueryKind from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User =
result = userResult.legacy
if result.verifiedType == none and userResult.isBlueVerified:
result.verifiedType = blue
if result.username.len == 0 and userResult.core.screenName.len > 0:
result.id = userResult.restId
result.username = userResult.core.screenName
result.fullname = userResult.core.name
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
if userResult.privacy.isSome:
result.protected = userResult.privacy.get.protected
if userResult.location.isSome:
result.location = userResult.location.get.location
if userResult.core.createdAt.len > 0:
result.joinDate = parseTwitterDate(userResult.core.createdAt)
if userResult.verification.isSome:
let v = userResult.verification.get
if v.verifiedType != VerifiedType.none:
result.verifiedType = v.verifiedType
if userResult.profileBio.isSome and result.bio.len == 0:
result.bio = userResult.profileBio.get.description
proc parseGraphUser*(json: string): User = proc parseGraphUser*(json: string): User =
if json.len == 0 or json[0] != '{': if json.len == 0 or json[0] != '{':
return return
let raw = json.fromJson(GraphUser) let
raw = json.fromJson(GraphUser)
userResult =
if raw.data.userResult.isSome: raw.data.userResult.get.result
elif raw.data.user.isSome: raw.data.user.get.result
else: UserResult()
if raw.data.userResult.result.unavailableReason.get("") == "Suspended": if userResult.unavailableReason.get("") == "Suspended" or
userResult.reason.get("") == "Suspended":
return User(suspended: true) return User(suspended: true)
result = raw.data.userResult.result.legacy result = parseUserResult(userResult)
result.id = raw.data.userResult.result.restId
if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified:
result.verifiedType = blue
proc parseGraphListMembers*(json, cursor: string): Result[User] = proc parseGraphListMembers*(json, cursor: string): Result[User] =
result = Result[User]( result = Result[User](
@ -31,7 +63,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
of TimelineTimelineItem: of TimelineTimelineItem:
let userResult = entry.content.itemContent.userResults.result let userResult = entry.content.itemContent.userResults.result
if userResult.restId.len > 0: if userResult.restId.len > 0:
result.content.add userResult.legacy result.content.add parseUserResult(userResult)
of TimelineTimelineCursor: of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom": if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value result.bottom = entry.content.value

View File

@ -0,0 +1,8 @@
import jsony
import ../types/tid
export TidPair
proc parseTidPairs*(raw: string): seq[TidPair] =
result = raw.fromJson(seq[TidPair])
if result.len == 0:
raise newException(ValueError, "Parsing pairs failed: " & raw)

View File

@ -64,7 +64,7 @@ proc toUser*(raw: RawUser): User =
) )
if raw.pinnedTweetIdsStr.len > 0: if raw.pinnedTweetIdsStr.len > 0:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) result.pinnedTweet = raw.pinnedTweetIdsStr[0]
result.expandUserEntities(raw) result.expandUserEntities(raw)

View File

@ -1,15 +1,48 @@
import options import options, strutils
from ../../types import User from ../../types import User, VerifiedType
type type
GraphUser* = object GraphUser* = object
data*: tuple[userResult: UserData] data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object UserData* = object
result*: UserResult result*: UserResult
UserResult = object UserCore* = object
name*: string
screenName*: string
createdAt*: string
UserBio* = object
description*: string
UserAvatar* = object
imageUrl*: string
Verification* = object
verifiedType*: VerifiedType
Location* = object
location*: string
Privacy* = object
protected*: bool
UserResult* = object
legacy*: User legacy*: User
restId*: string restId*: string
isBlueVerified*: bool isBlueVerified*: bool
core*: UserCore
avatar*: UserAvatar
unavailableReason*: Option[string] unavailableReason*: Option[string]
reason*: Option[string]
privacy*: Option[Privacy]
profileBio*: Option[UserBio]
verification*: Option[Verification]
location*: Option[Location]
proc enumHook*(s: string; v: var VerifiedType) =
v = try:
parseEnum[VerifiedType](s)
except:
VerifiedType.none

View File

@ -0,0 +1,4 @@
type
TidPair* = object
animationKey*: string
verification*: string

View File

@ -7,10 +7,11 @@ const
cards = "cards.twitter.com/cards" cards = "cards.twitter.com/cards"
tco = "https://t.co" tco = "https://t.co"
twitter = parseUri("https://twitter.com") twitter = parseUri("https://twitter.com")
sameProto = "//"
let let
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com" twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?(twitter|x)\.com"
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>""" twLinkRegex = re"""<a href="https:\/\/(twitter|x).com([^"]+)">(twitter|x)\.com(\S+)</a>"""
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase}) ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
@ -31,8 +32,10 @@ let
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]" illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
proc getUrlPrefix*(cfg: Config): string = proc getUrlPrefix*(cfg: Config): string =
if cfg.useHttps: https & cfg.hostname var proto = "http"
else: "http://" & cfg.hostname if cfg.useHttps: proto &= "s"
proto &= "://"
result = proto & cfg.hostname
proc shortLink*(text: string; length=28): string = proc shortLink*(text: string; length=28): string =
result = text.replace(wwwRegex, "") result = text.replace(wwwRegex, "")
@ -59,12 +62,12 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
if prefs.replaceYouTube.len > 0 and "youtu" in result: if prefs.replaceYouTube.len > 0 and "youtu" in result:
result = result.replace(ytRegex, prefs.replaceYouTube) result = result.replace(ytRegex, prefs.replaceYouTube)
if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body): if prefs.replaceTwitter.len > 0 and ("twitter.com" in result or "/x.com" in result or tco in result):
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co") result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
result = result.replace(cards, prefs.replaceTwitter & "/cards") result = result.replace(cards, prefs.replaceTwitter & "/cards")
result = result.replace(twRegex, prefs.replaceTwitter) result = result.replace(twRegex, prefs.replaceTwitter)
result = result.replacef(twLinkRegex, a( result = result.replacef(twLinkRegex, a(
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1")) prefs.replaceTwitter & "$4", href = prefs.replaceTwitter & "$2"))
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result): if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/") result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
@ -150,7 +153,7 @@ proc getShortTime*(tweet: Tweet): string =
result = "now" result = "now"
proc getLink*(tweet: Tweet; focus=true): string = proc getLink*(tweet: Tweet; focus=true): string =
if tweet.id == 0: return if tweet.id.len == 0: return
var username = tweet.user.username var username = tweet.user.username
if username.len == 0: if username.len == 0:
username = "i" username = "i"

View File

@ -6,14 +6,15 @@ from htmlgen import a
import jester import jester
import types, config, prefs, formatters, redis_cache, http_pool import types, config, prefs, formatters, redis_cache, http_pool, apiutils
import views/[general, about] import views/[general, about]
import routes/[ import routes/[
preferences, timeline, status, media, search, rss, list, #debug, preferences, timeline, status, media, search, list, rss, #debug,
unsupported, embed, resolver, router_utils, home, follow, twitter_api] unsupported, embed, resolver, router_utils, home, follow, twitter_api,
activityspoof]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues" const issuesUrl = "https://git.eir-nya.gay/eir/nitter/issues"
#let accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json") #let accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
@ -33,6 +34,7 @@ setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media) setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns) setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth) setHttpProxy(cfg.proxy, cfg.proxyAuth)
setDisableTid(cfg.disableTid)
initAboutPage(cfg.staticDir) initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg) waitFor initRedisPool(cfg)
@ -51,6 +53,7 @@ createEmbedRouter(cfg)
createRssRouter(cfg) createRssRouter(cfg)
#createDebugRouter(cfg) #createDebugRouter(cfg)
createTwitterApiRouter(cfg) createTwitterApiRouter(cfg)
createActivityPubRouter(cfg)
settings: settings:
port = Port(cfg.port) port = Port(cfg.port)
@ -78,7 +81,7 @@ routes:
error InternalError: error InternalError:
echo error.exc.name, ": ", error.exc.msg echo error.exc.name, ": ", error.exc.msg
const link = a("open a GitHub issue", href = issuesUrl) const link = a("open an issue", href = issuesUrl)
resp Http500, showError( resp Http500, showError(
&"An error occurred, please {link} with the URL you tried to visit.", cfg) &"An error occurred, please {link} with the URL you tried to visit.", cfg)
@ -102,6 +105,6 @@ routes:
extend preferences, "" extend preferences, ""
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""
#extend debug, "" extend activityspoof, ""
extend api, "" extend api, ""
extend unsupported, "" extend unsupported, ""

View File

@ -2,7 +2,7 @@
import strutils, options, times, math import strutils, options, times, math
import packedjson, packedjson/deserialiser import packedjson, packedjson/deserialiser
import types, parserutils, utils import types, parserutils, utils
import experimental/parser/unifiedcard import experimental/parser/[unifiedcard, utils]
import std/tables import std/tables
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
@ -24,17 +24,52 @@ proc parseUser(js: JsonNode; id=""): User =
media: js{"media_count"}.getInt, media: js{"media_count"}.getInt,
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")), verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
protected: js{"protected"}.getBool, protected: js{"protected"}.getBool,
joinDate: js{"created_at"}.getTime joinDate: parseTwitterDate(js{"created_at"}.getStr)
) )
result.expandUserEntities(js) result.expandUserEntities(js)
proc parseGraphUser(js: JsonNode): User = proc parseGraphUser*(js: JsonNode): User =
var user = js{"user_result", "result"} var user = js{"data", "user", "result"}
if user.isNull: if user.isNull:
user = ? js{"user_results", "result"} user = js{"user_results", "result"}
if user.isNull:
result = parseUser(user{"legacy"}) user = js{"user_result", "result"}
if user{"__typename"}.getStr == "UserUnavailable" and user{"reason"}.getStr == "Suspended":
return User(suspended: true)
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
result.username = user{"core", "screen_name"}.getStr
result.fullname = user{"core", "name"}.getStr
result.joinDate = parseTwitterDate(user{"core", "created_at"}.getStr)
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
result.protected = user{"privacy", "protected"}.getBool
let label = user{"affiliates_highlighted_label", "label"}
if not label.isNull:
let labelType = label{"userLabelType"}.getStr
if labelType == "AutomatedLabel":
result.bot = true
let entities = label{"longDescription", "entities"}
if not entities.isNull:
for ent in entities:
if ent{"ref", "type"}.getStr != "TimelineRichTextMention": continue
result.botOwner = ent{"ref", "screen_name"}.getStr
break
# TODO: are there other types than the two
# TODO: find profile with "userLabelDisplayType" not equal to "Badge"
elif labelType == "BusinessLabel":
result.badge = Badge(
name: label{"description"}.getStr,
icon: label{"badge", "url"}.getStr,
url: label{"url", "url"}.getStr
)
let pcf = user{"parody_commentary_fan_label"}.getStr
if pcf.len > 0 and pcf != "None":
result.pcf = pcf
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false): if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
result.verifiedType = blue result.verifiedType = blue
@ -205,9 +240,10 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
if js.isNull: return if js.isNull: return
result = Tweet( result = Tweet(
id: js{"id_str"}.getId, id: js{"id_str"}.getStr,
threadId: js{"conversation_id_str"}.getId, threadId: js{"conversation_id_str"}.getStr,
replyId: js{"in_reply_to_status_id_str"}.getId, replyId: js{"in_reply_to_status_id_str"}.getStr,
replyHandle: js{"in_reply_to_screen_name"}.getStr,
text: js{"full_text"}.getStr, text: js{"full_text"}.getStr,
time: js{"created_at"}.getTime, time: js{"created_at"}.getTime,
hasThread: js{"self_thread"}.notNull, hasThread: js{"self_thread"}.notNull,
@ -217,22 +253,23 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
replies: js{"reply_count"}.getInt, replies: js{"reply_count"}.getInt,
retweets: js{"retweet_count"}.getInt, retweets: js{"retweet_count"}.getInt,
likes: js{"favorite_count"}.getInt, likes: js{"favorite_count"}.getInt,
quotes: js{"quote_count"}.getInt quotes: js{"quote_count"}.getInt,
bookmarks: js{"bookmark_count"}.getInt
) )
) )
# fix for pinned threads # fix for pinned threads
if result.hasThread and result.threadId == 0: if result.hasThread and result.threadId.len == 0:
result.threadId = js{"self_thread", "id_str"}.getId result.threadId = js{"self_thread", "id_str"}.getStr
if "retweeted_status" in js: if "retweeted_status" in js:
result.retweet = some Tweet() result.retweet = some Tweet()
elif js{"is_quote_status"}.getBool: elif js{"is_quote_status"}.getBool:
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId) result.quote = some Tweet(id: js{"quoted_status_id_str"}.getStr)
# legacy # legacy
with rt, js{"retweeted_status_id_str"}: with rt, js{"retweeted_status_id_str"}:
result.retweet = some Tweet(id: rt.getId) result.retweet = some Tweet(id: rt.getStr)
return return
# graphql # graphql
@ -249,7 +286,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
let name = jsCard{"name"}.getStr let name = jsCard{"name"}.getStr
if "poll" in name: if "poll" in name:
if "image" in name: if "image" in name:
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal result.photos.add Image(
url: jsCard{"binding_values", "image_large"}.getImageVal
)
result.poll = some parsePoll(jsCard) result.poll = some parsePoll(jsCard)
elif name == "amplify": elif name == "amplify":
@ -263,7 +302,10 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
for m in jsMedia: for m in jsMedia:
case m{"type"}.getStr case m{"type"}.getStr
of "photo": of "photo":
result.photos.add m{"media_url_https"}.getImageStr result.photos.add Image(
url: m{"media_url_https"}.getImageStr,
description: m{"ext_alt_text"}.getStr,
)
of "video": of "video":
result.video = some(parseVideo(m)) result.video = some(parseVideo(m))
with user, m{"additional_media_info", "source_user"}: with user, m{"additional_media_info", "source_user"}:
@ -318,11 +360,10 @@ proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
result.content.add @[parsed] result.content.add @[parsed]
if result.content.len > 0: if result.content.len > 0:
result.bottom = $(result.content[^1][0].id - 1) result.bottom = $(result.content[^1][0].id.parseBiggestInt() - 1)
proc finalizeTweet(global: GlobalObjects; id: string): Tweet = proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
let intId = if id.len > 0: parseBiggestInt(id) else: 0 result = global.tweets.getOrDefault(id, Tweet(id: id))
result = global.tweets.getOrDefault(id, Tweet(id: intId))
if result.quote.isSome: if result.quote.isSome:
let quote = get(result.quote).id let quote = get(result.quote).id
@ -409,23 +450,6 @@ proc parseTimeline*(js: JsonNode; after=""): Profile =
else: else:
result.tweets.top = cursor{"value"}.getStr result.tweets.top = cursor{"value"}.getStr
proc parsePhotoRail*(js: JsonNode): PhotoRail =
with error, js{"error"}:
if error.getStr == "Not authorized.":
return
for tweet in js:
let
t = parseTweet(tweet, js{"tweet_card"})
url = if t.photos.len > 0: t.photos[0]
elif t.video.isSome: get(t.video).thumb
elif t.gif.isSome: get(t.gif).thumb
elif t.card.isSome: get(t.card).image
else: ""
if url.len == 0: continue
result.add GalleryPhoto(url: url, tweetId: $t.id)
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet = proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
if js.kind == JNull: if js.kind == JNull:
return Tweet() return Tweet()
@ -442,7 +466,17 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
of "TweetPreviewDisplay": of "TweetPreviewDisplay":
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.") return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
of "TweetWithVisibilityResults": of "TweetWithVisibilityResults":
return parseGraphTweet(js{"tweet"}, isLegacy) result = parseGraphTweet(js{"tweet"}, isLegacy)
if js.hasKey("limitedActionResults"):
for actionRes in js{"limitedActionResults", "limited_actions"}:
let action = LimitedActions(
title: actionRes{"prompt" , "headline", "text"}.getStr,
text: actionRes{"prompt", "subtext", "text"}.getStr
)
result.limitedActions = some(action)
break
return result
else: else:
discard discard
@ -457,7 +491,7 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
jsCard["binding_values"] = values jsCard["binding_values"] = values
result = parseTweet(js{"legacy"}, jsCard) result = parseTweet(js{"legacy"}, jsCard)
result.id = js{"rest_id"}.getId result.id = js{"rest_id"}.getStr
result.user = parseGraphUser(js{"core"}) result.user = parseGraphUser(js{"core"})
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
@ -467,12 +501,47 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy)) result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
with communityNote, js{"birdwatch_pivot"}: with communityNote, js{"birdwatch_pivot"}:
let title = communityNote{"title"}.getStr
let note = BirdwatchNote( let note = BirdwatchNote(
id: communityNote{"note", "rest_id"}.getId, id: communityNote{"note", "rest_id"}.getStr,
title: communityNote{"title"}.getStr, title: title,
) )
note.expandBirdwatchEntities(communityNote{"subtitle"}) note.expandBirdwatchEntities(communityNote{"subtitle"})
result.birdwatch = some(note) if title != "Rate proposed Community Notes":
result.birdwatch = some(note)
if not js{"views", "count"}.isNull:
result.stats.views = parseInt(js{"views", "count"}.getStr)
else:
result.stats.views = -1
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
result = @[]
let instructions =
? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
for i in instructions:
if i{"__typename"}.getStr == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e{"entryId"}.getStr
if entryId.startsWith("tweet"):
with tweetResult, e{"content", "content", "tweetResult", "result"}:
let t = parseGraphTweet(tweetResult, false)
if not t.available:
t.id = $parseBiggestInt(entryId.getId())
let url =
if t.photos.len > 0: t.photos[0].url
elif t.video.isSome: get(t.video).thumb
elif t.gif.isSome: get(t.gif).thumb
elif t.card.isSome: get(t.card).image
else: ""
result.add GalleryPhoto(url: url, tweetId: t.id)
if result.len == 16:
break
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
for t in js{"content", "items"}: for t in js{"content", "items"}:
@ -481,7 +550,7 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
let cursor = t{"item", "content", "value"} let cursor = t{"item", "content", "value"}
result.thread.cursor = cursor.getStr result.thread.cursor = cursor.getStr
result.thread.hasMore = true result.thread.hasMore = true
elif "tweet" in entryId: elif "tweet" in entryId and "promoted-" notin entryId:
let let
isLegacy = t{"item"}.hasKey("itemContent") isLegacy = t{"item"}.hasKey("itemContent")
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results") (contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
@ -504,67 +573,53 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if instructions.len == 0: if instructions.len == 0:
return return
for e in instructions[0]{"entries"}: for i in instructions:
let entryId = e{"entryId"}.getStr if i{"type"}.getStr == "TimelineAddEntries":
if entryId.startsWith("tweet"): for e in i{"entries"}:
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: let entryId = e{"entryId"}.getStr
let tweet = parseGraphTweet(tweetResult, true) if entryId.startsWith("tweet"):
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, true)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = entryId.getId()
if $tweet.id == tweetId: if tweet.id == tweetId:
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("tombstone"): elif entryId.startsWith("tombstone"):
let id = entryId.getId() let id = entryId.getId()
let tweet = Tweet( let tweet = Tweet(
id: parseBiggestInt(id), id: id,
available: false, available: false,
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
) )
if id == tweetId: if id == tweetId:
result.tweet = tweet result.tweet = tweet
else: else:
result.before.content.add tweet result.before.content.add tweet
elif entryId.startsWith("conversationthread"): elif (entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation")) and "promoted-tweet" notin entryId:
let (thread, self) = parseGraphThread(e) let (thread, self) = parseGraphThread(e)
if self: if self:
result.after = thread result.after = thread
else: else:
result.replies.content.add thread result.replies.content.add thread
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
result.replies.bottom = e{"content", "itemContent", "value"}.getStr result.replies.bottom = e.getCursor
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
result = Profile(tweets: Timeline(beginning: after.len == 0)) result = Profile(tweets: Timeline(beginning: after.len == 0))
let instructions = let instructions =
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"} if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
elif root == "user": ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
else: ? js{"data", "user", "result", "timeline", "timeline", "instructions"} else: ? js{"data", "user", "result", "timeline", "timeline", "instructions"}
if instructions.len == 0: if instructions.len == 0:
return return
for i in instructions: for i in instructions:
if i{"__typename"}.getStr == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e{"entryId"}.getStr
if entryId.startsWith("tweet"):
with tweetResult, e{"content", "content", "tweetResult", "result"}:
let tweet = parseGraphTweet(tweetResult, false)
if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId())
result.tweets.content.add tweet
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
let (thread, self) = parseGraphThread(e)
result.tweets.content.add thread.content
elif entryId.startsWith("cursor-bottom"):
result.tweets.bottom = e{"content", "value"}.getStr
# TODO cleanup
if i{"type"}.getStr == "TimelineAddEntries": if i{"type"}.getStr == "TimelineAddEntries":
for e in i{"entries"}: for e in i{"entries"}:
let entryId = e{"entryId"}.getStr let entryId = e{"entryId"}.getStr
@ -572,21 +627,21 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, false) let tweet = parseGraphTweet(tweetResult, false)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = entryId.getId()
result.tweets.content.add tweet result.tweets.content.add tweet
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"): elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
let (thread, self) = parseGraphThread(e) let (thread, self) = parseGraphThread(e)
result.tweets.content.add thread.content result.tweets.content.add thread.content
elif entryId.startsWith("cursor-bottom"): elif entryId.startsWith("cursor-bottom"):
result.tweets.bottom = e{"content", "value"}.getStr result.tweets.bottom = e{"content", "value"}.getStr
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry": if after.len == 0 and i{"type"}.getStr == "TimelinePinEntry":
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}: with tweetResult, i{"entry", "content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, false) let tweet = parseGraphTweet(tweetResult, false)
tweet.pinned = true tweet.pinned = true
if not tweet.available and tweet.tombstone.len == 0: if not tweet.available and tweet.tombstone.len == 0:
let entryId = i{"entry", "entryId"}.getEntryId let entryId = i{"entry", "entryId"}.getEntryId
if entryId.len > 0: if entryId.len > 0:
tweet.id = parseBiggestInt(entryId) tweet.id = entryId
result.pinned = some tweet result.pinned = some tweet
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline = proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
@ -636,7 +691,7 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}: with tweetRes, e{"content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetRes) let tweet = parseGraphTweet(tweetRes)
if not tweet.available: if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId()) tweet.id = entryId.getId()
result.content.add tweet result.content.add tweet
elif T is User: elif T is User:
if entryId.startsWith("user"): if entryId.startsWith("user"):

View File

@ -48,7 +48,13 @@ template with*(ident; value: JsonNode; body): untyped =
if notNull(value): body if notNull(value): body
template getCursor*(js: JsonNode): string = template getCursor*(js: JsonNode): string =
js{"content", "operation", "cursor", "value"}.getStr var cursor = js{"content", "operation", "cursor", "value"}
if cursor.isNull:
cursor = js{"content", "value"}
if cursor.isNull:
cursor = js{"content", "itemContent", "value"}
cursor.getStr
template getError*(js: JsonNode): Error = template getError*(js: JsonNode): Error =
if js.kind != JArray or js.len == 0: null if js.kind != JArray or js.len == 0: null
@ -286,7 +292,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
url: "/" & name, display: mention["name"].getStr) url: "/" & name, display: mention["name"].getStr)
if idx > -1 and name != replyTo: if idx > -1 and name != replyTo:
tweet.reply.delete idx tweet.reply.delete idx
elif idx == -1 and tweet.replyId != 0: elif idx == -1 and tweet.replyId.len != 0:
tweet.reply.add name tweet.reply.add name
replacements.deduplicate replacements.deduplicate
@ -303,7 +309,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
var replyTo = "" var replyTo = ""
if tweet.replyId != 0: if tweet.replyId.len != 0:
with reply, js{"in_reply_to_screen_name"}: with reply, js{"in_reply_to_screen_name"}:
replyTo = reply.getStr replyTo = reply.getStr
tweet.reply.add replyTo tweet.reply.add replyTo

View File

@ -59,6 +59,9 @@ genPrefs:
theme(select, "Nitter"): theme(select, "Nitter"):
"Theme" "Theme"
eirResources(checkbox, true):
"Some extra silly js I added, like cursors :3"
infiniteScroll(checkbox, false): infiniteScroll(checkbox, false):
"Infinite scrolling (experimental, requires JavaScript)" "Infinite scrolling (experimental, requires JavaScript)"

View File

@ -66,7 +66,8 @@ proc initRedisPool*(cfg: Config) {.async.} =
template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000) template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
template userKey(name: string): string = "p:" & name template userKey(name: string): string = "p:" & name
template listKey(l: List): string = "l:" & l.id template listKey(l: List): string = "l:" & l.id
template tweetKey(id: int64): string = "t:" & $id template tweetKey(id: string): string = "t:" & id
template convKey(id: string): string = "c:" & id
proc get(query: string): Future[string] {.async.} = proc get(query: string): Future[string] {.async.} =
pool.withAcquire(r): pool.withAcquire(r):
@ -86,7 +87,7 @@ proc cache*(data: List) {.async.} =
await setEx(data.listKey, listCacheTime, compress(toFlatty(data))) await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
proc cache*(data: PhotoRail; name: string) {.async.} = proc cache*(data: PhotoRail; name: string) {.async.} =
await setEx("pr:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data))) await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
proc cache*(data: User) {.async.} = proc cache*(data: User) {.async.} =
if data.username.len == 0: return if data.username.len == 0: return
@ -96,10 +97,15 @@ proc cache*(data: User) {.async.} =
dawait r.setEx(name.userKey, baseCacheTime, compress(toFlatty(data))) dawait r.setEx(name.userKey, baseCacheTime, compress(toFlatty(data)))
proc cache*(data: Tweet) {.async.} = proc cache*(data: Tweet) {.async.} =
if data.isNil or data.id == 0: return if data.isNil or data.id.len == 0: return
pool.withAcquire(r): pool.withAcquire(r):
dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data))) dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data)))
proc cache*(data: Conversation) {.async.} =
if data.isNil or data.tweet.isNil or data.tweet.id.len == 0: return
pool.withAcquire(r):
dawait r.setEx(data.tweet.id.convKey, baseCacheTime, compress(toFlatty(data)))
proc cacheRss*(query: string; rss: Rss) {.async.} = proc cacheRss*(query: string; rss: Rss) {.async.} =
let key = "rss:" & query let key = "rss:" & query
pool.withAcquire(r): pool.withAcquire(r):
@ -114,7 +120,13 @@ template deserialize(data, T) =
except: except:
echo "Decompression failed($#): '$#'" % [astToStr(T), data] echo "Decompression failed($#): '$#'" % [astToStr(T), data]
proc getUserId*(username: string): Future[string] {.async.} = proc deserializeConversation(data: string): Conversation =
try:
result = fromFlatty(uncompress(data), Conversation)
except:
echo "Decompression failed(Conversation): '$#'" % [data]
proc getCachedUserId*(username: string): Future[string] {.async.} =
let name = toLower(username) let name = toLower(username)
pool.withAcquire(r): pool.withAcquire(r):
result = await r.hGet(name.uidKey, name) result = await r.hGet(name.uidKey, name)
@ -133,13 +145,16 @@ proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
elif fetch: elif fetch:
result = await getGraphUser(username) result = await getGraphUser(username)
await cache(result) await cache(result)
if result.id.len > 0:
await setEx("i:" & result.id, baseCacheTime, result.username)
await cacheUserId(result.username, result.id)
proc getCachedUsername*(userId: string): Future[string] {.async.} = proc getCachedUsername*(userId: string): Future[string] {.async.} =
let let
key = "i:" & userId key = "i:" & userId
username = await get(key) username = await get(key)
if username != redisNil: if username != redisNil and username.len > 0:
result = username result = username
else: else:
let user = await getGraphUserById(userId) let user = await getGraphUserById(userId)
@ -148,24 +163,28 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
if result.len > 0 and user.id.len > 0: if result.len > 0 and user.id.len > 0:
await all(cacheUserId(result, user.id), cache(user)) await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = proc getCachedTweet*(id: string; after=""): Future[Conversation] {.async.} =
# if id == 0: return if id.len == 0: return
# let tweet = await get(id.tweetKey) let tweet = await get(id.tweetKey)
# if tweet != redisNil:
# tweet.deserialize(Tweet)
# else:
# result = await getGraphTweetResult($id)
# if not result.isNil:
# await cache(result)
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} = if tweet != redisNil:
if name.len == 0: return result = deserializeConversation(tweet)
let rail = await get("pr:" & toLower(name)) else:
result = await getGraphTweet(id)
if not result.isNil:
await cache(result)
if not result.isNil and after.len > 0:
result.replies = await getReplies(id, after)
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return
let rail = await get("pr2:" & toLower(id))
if rail != redisNil: if rail != redisNil:
rail.deserialize(PhotoRail) rail.deserialize(PhotoRail)
else: else:
result = await getPhotoRail(name) result = await getPhotoRail(id)
await cache(result, name) await cache(result, id)
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} = proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
let list = if id.len == 0: redisNil let list = if id.len == 0: redisNil

View File

@ -0,0 +1,350 @@
# SPDX-License-Identifier: AGPL-3.0-only
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
import jester
import router_utils
import ".."/[types, formatters, api, redis_cache]
import ../views/[mastoapi]
export json, uri, sequtils, options, sugar, times
export router_utils
export api, formatters
export mastoapi
proc createActivityPubRouter*(cfg: Config) =
router activityspoof:
get "/api/v1/accounts/?":
resp Http200, {"Content-Type": "application/json"}, """[]"""
get "/api/v1/accounts/@id":
let id = @"id"
#if id.len > 19 or id.any(c => not c.isDigit):
if not id.allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid account ID"}"""
#var username = await getCachedUsername(id)
#if username.len == 0:
#resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
let user = await getCachedUser(id)
if user.suspended or user.id.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
resp Http200, {"Content-Type": "application/json"}, $getMastoAPIUser(user, cfg)
get "/api/v1/statuses/@id":
var
id = @"id"
query = ""
# stupid hack to trick discord lmao
if id.startsWith("422209040515"):
query = "video"
id.removePrefix("422209040515")
elif id.startsWith("421608152015"):
query = "photo:"
id.removePrefix("421608152015")
query &= id[0]
id = id[1..^1]
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
let prefs = cookiePrefs()
let conv = await getCachedTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
var errJson = newJObject()
errJson["error"] = %error
resp Http404, {"Content-Type": "application/json"}, $errJson
var
mediaType = ""
mediaIndex = ""
if query.len > 0:
let parts = query.split(":")
mediaType = parts[0]
if parts.len == 2:
mediaIndex = parts[1]
let
tweet = conv.tweet
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}"
var media: seq[JsonNode] = @[]
if mediaType.len > 0:
if mediaType == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif mediaType == "photo" and tweet.photos.len > 0:
if mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if tweet.video.isSome or tweet.gif.isSome:
useVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
tweet.photos = @[]
else:
index -= 1
tweet.video = none(Video)
let image = tweet.photos[index]
tweet.photos = @[]
tweet.photos.add(image)
if tweet.photos.len > 0:
for imageObj in tweet.photos:
let image = getUrlPrefix(cfg) & getPicUrl(imageObj.url)
var mediaObj = newJObject()
mediaObj["id"] = %"138733266285887488" # idk if discord even parses this snowflake, but its my user id why not
mediaObj["type"] = %"image"
mediaObj["url"] = %image
mediaObj["preview_url"] = %image
mediaObj["remote_url"] = %image
mediaObj["preview_remote_url"] = %image
mediaObj["text_url"] = newJNull()
mediaObj["description"] = %imageObj.description
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
media.add(mediaObj)
if tweet.video.isSome():
let
videoObj = get(tweet.video)
vars = videoObj.variants.filterIt(it.contentType == mp4)
videoUrl = vars[^1].url.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", "")
videoPreview = getUrlPrefix(cfg) & getPicUrl(videoObj.thumb)
var mediaObj = newJObject()
var description = videoObj.title
if videoObj.description.len > 0:
description = videoObj.description
mediaObj["id"] = %"138733266285887488"
mediaObj["type"] = %"video"
mediaObj["url"] = %videoUrl
mediaObj["preview_url"] = %videoPreview
mediaObj["remote_url"] = %videoUrl
mediaObj["preview_remote_url"] = %videoPreview
mediaObj["text_url"] = newJNull()
mediaObj["description"] = %description
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
media.add(mediaObj)
elif tweet.gif.isSome():
let
gif = get(tweet.gif)
gifUrl = (https & gif.url).replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", "")
gifPreview = getUrlPrefix(cfg) & getPicUrl(gif.thumb)
var mediaObj = newJObject()
mediaObj["id"] = %"138733266285887488"
mediaObj["type"] = %"video"
mediaObj["url"] = %gifUrl
mediaObj["preview_url"] = %gifPreview
mediaObj["remote_url"] = %gifUrl
mediaObj["preview_remote_url"] = %gifPreview
mediaObj["text_url"] = newJNull()
mediaObj["description"] = newJNull() # FIXME this requires refactoring gifs
# FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject()
media.add(mediaObj)
var fields: seq[JsonNode] = @[]
if tweet.user.location.len > 0:
var location = newJObject()
location["name"] = %"Location"
location["value"] = %tweet.user.location
location["verified_at"] = newJNull()
fields.add(location)
if tweet.user.website.len > 0:
var website = newJObject()
website["name"] = %"Website"
website["value"] = %(&"<a href=\"{tweet.user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{tweet.user.website}</a>")
website["verified_at"] = newJNull()
fields.add(website)
var postJson = newJObject()
postJson["id"] = %tweet.id
postJson["url"] = %tweetUrl
postJson["uri"] = %tweetUrl
postJson["created_at"] = %($tweet.time)
postJson["edited_at"] = newJNull()
postJson["reblog"] = newJNull()
if tweet.replyId.len != 0:
postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
postJson["in_reply_to_account_id"] = %tweet.replyHandle
else:
postJson["in_reply_to_id"] = newJNull()
postJson["in_reply_to_account_id"] = newJNull()
postJson["language"] = %"en" # FIXME
postJson["content"] = %formatTweetForMastoAPI(tweet, cfg, prefs)
postJson["spoiler_text"] = %""
postJson["visibility"] = %"public"
postJson["application"] = %*{
"name": "Nitter",
"website": getUrlPrefix(cfg)
}
postJson["media_attachments"] = %media
postJson["account"] = getMastoAPIUser(tweet.user, cfg)
postJson["mentions"] = newJArray() # TODO: parse?
postJson["tags"] = newJArray() # TODO: parse?
postJson["emojis"] = newJArray()
postJson["card"] = newJNull()
postJson["poll"] = newJNull() # TODO: parse?
resp Http200, {"Content-Type": "application/json"}, $postJson
get "/users/@name/statuses/@id":
var
id = @"id"
query = ""
# stupid hack to trick discord lmao
if id.startsWith("422209040515"):
query = "video"
id.removePrefix("422209040515")
elif id.startsWith("421608152015"):
query = "photo:"
id.removePrefix("421608152015")
query &= id[0]
id = id[1..^1]
var
mediaType = ""
mediaIndex = ""
if query.len > 0:
let parts = query.split(":")
mediaType = parts[0]
if parts.len == 2:
mediaIndex = parts[1]
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
let prefs = cookiePrefs()
let conv = await getCachedTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
var errJson = newJObject()
errJson["error"] = %error
resp Http404, {"Content-Type": "application/json"}, $errJson
let tweet = conv.tweet
if mediaType.len > 0:
if mediaType == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif mediaType == "photo" and tweet.photos.len > 0:
if mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if tweet.video.isSome or tweet.gif.isSome:
useVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
tweet.photos = @[]
else:
index -= 1
tweet.video = none(Video)
let image = tweet.photos[index]
tweet.photos = @[]
tweet.photos.add(image)
let postJson = getActivityStream(tweet, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $postJson
redirect("/$1/status/$2" % [@"name", @"id"])
get "/users/@name":
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
let user = await getCachedUser(@"name")
if user.suspended or user.id.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
let prefs = cookiePrefs()
let userJson = getActivityStream(user, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $userJson
redirect("/" & @"name")
# might as well
get "/.well-known/nodeinfo":
var nodeinfo = newJObject()
let link: JsonNode = %*{
"href": &"{getUrlPrefix(cfg)}/nodeinfo/2.1.json",
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1"
}
var links: seq[JsonNode] = @[]
links.add(link)
nodeinfo["links"] = %links
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
get "/nodeinfo/2.1.json":
var nodeinfo = newJObject()
nodeinfo["version"] = %"2.1"
nodeinfo["software"] = %*{
"name": cfg.title,
"repository": "https://git.eir-nya.gay/eir/nitter"
}
var metadata = newJObject()
metadata["features"] = newJArray()
metadata["federation"] = newJObject()
metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)"
metadata["nodeName"] = %cfg.title
metadata["private"] = %true
metadata["maintainer"] = %*{
"name": "Eir",
"email": "eir@eir-nya.gay"
}
nodeinfo["metadata"] = metadata
nodeinfo["openRegistrations"] = %false
nodeinfo["protocols"] = newJArray()
var services = newJObject()
services["inbound"] = newJArray()
services["outbound"] = newJArray()
nodeinfo["services"] = services
nodeinfo["usage"] = newJObject()
resp Http200, {"Content-Type": "application/json"}, $nodeinfo

View File

@ -1,17 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
import jester
import router_utils
import ".."/[tokens, types]
proc createDebugRouter*(cfg: Config) =
router debug:
get "/.tokens":
cond cfg.enableDebug
respJson getPoolJson()
#get "/.health":
#respJson getAccountPoolHealth()
#get "/.accounts":
#cond cfg.enableDebug
#respJson getAccountPoolDebug()

View File

@ -4,7 +4,7 @@ import strutils, strformat, uri
import jester import jester
import router_utils import router_utils
import ".."/[types, redis_cache, api] import ".."/[types, api, redis_cache]
import ../views/[general, timeline, list] import ../views/[general, timeline, list]
template respList*(list, timeline, title, vnode: typed) = template respList*(list, timeline, title, vnode: typed) =
@ -20,6 +20,14 @@ template respList*(list, timeline, title, vnode: typed) =
proc title*(list: List): string = proc title*(list: List): string =
&"@{list.username}/{list.name}" &"@{list.username}/{list.name}"
proc getList*(username=""; slug=""; id=""): Future[List] {.async.} =
if id.len > 0:
result = await getGraphList(id)
else:
result = await getGraphListBySlug(username, slug)
proc createListRouter*(cfg: Config) = proc createListRouter*(cfg: Config) =
router list: router list:
get "/@name/lists/@slug/?": get "/@name/lists/@slug/?":

View File

@ -141,3 +141,6 @@ proc createMediaRouter*(cfg: Config) =
content = proxifyVideo(vid, cookiePref(proxyVideos)) content = proxifyVideo(vid, cookiePref(proxyVideos))
resp content, m3u8Mime resp content, m3u8Mime
get re"^\/tvid\/(.+)$":
redirect("https://video.twimg.com/" & request.matches[0] & ".mp4")

View File

@ -4,22 +4,26 @@ import strutils
import jester import jester
import router_utils import router_utils
import ".."/[types, api] import ".."/[types, api, formatters]
import ../views/general import ../views/general
template respResolved*(url, kind: string): untyped = template respResolved*(url, kind: string; prefs: Prefs): untyped =
let u = url let u = url
if u.len == 0: if u.len == 0:
resp showError("Invalid $1 link" % kind, cfg) resp showError("Invalid $1 link" % kind, cfg)
else: else:
redirect(u) redirect(replaceUrls(u, prefs))
proc createResolverRouter*(cfg: Config) = proc createResolverRouter*(cfg: Config) =
router resolver: router resolver:
get "/cards/@card/@id": get "/cards/@card/@id":
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] let
respResolved(await resolve(url, cookiePrefs()), "card") prefs = cookiePrefs()
url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
respResolved(await resolve(url, prefs), "card", prefs)
get "/t.co/@url": get "/t.co/@url":
let url = "https://t.co/" & @"url" let
respResolved(await resolve(url, cookiePrefs()), "t.co") prefs = cookiePrefs()
url = "https://t.co/" & @"url"
respResolved(await resolve(url, prefs), "t.co", prefs)

View File

@ -1,16 +1,29 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, sequtils, uri, options, sugar import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
import jester, karax/vdom import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, formatters, api] import ".."/[types, formatters, api, redis_cache]
import ../views/[general, status, search] import ../views/[general, status, mastoapi]
export uri, sequtils, options, sugar export json, uri, sequtils, options, sugar, times
export router_utils export router_utils
export api, formatters export api, formatters
export status export status, mastoapi
proc formatStat*(stat: int): string =
#if stat > 1000000000000:
# result = formatBiggestFloat(stat / 1000000000000, ffDecimal, precision = 1) & "T"
#el
if stat > 1000000000:
result = formatBiggestFloat(stat / 1000000000, ffDecimal, precision = 1) & "B"
elif stat > 1000000:
result = formatBiggestFloat(stat / 1000000, ffDecimal, precision = 1) & "M"
elif stat > 1000:
result = formatBiggestFloat(stat / 1000, ffDecimal, precision = 1) & "K"
else:
result = $stat
proc createStatusRouter*(cfg: Config) = proc createStatusRouter*(cfg: Config) =
router status: router status:
@ -30,16 +43,86 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, "" resp Http404, ""
resp $renderReplies(replies, prefs, getPath()) resp $renderReplies(replies, prefs, getPath())
if @"reactors" == "favoriters": #if @"reactors" == "favoriters":
resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs), # resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
request, cfg, prefs) # request, cfg, prefs)
elif @"reactors" == "retweeters": #elif @"reactors" == "retweeters":
resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs), # resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
request, cfg, prefs) # request, cfg, prefs)
get "/@name/status/@id/?": get "/@name/status/@id/?@m?/?@i?/?":
cond '.' notin @"name" cond '.' notin @"name"
let id = @"id" var
id = @"id"
media = @"m"
mediaIndex = @"i"
let url = $request.getNativeReq().url
var
rawVideo = false
rawImage = false
if url.endsWith(".mp4") or url.endsWith(".gif"):
rawVideo = true
elif url.endsWith(".png") or url.endsWith(".jpg"):
rawImage = true
for ext in @[".mp4", ".gif", ".png", ".jpg"]:
if id.endsWith(ext):
id.removeSuffix(ext)
if media.endsWith(ext):
media.removeSuffix(ext)
if mediaIndex.endsWith(ext):
mediaIndex.removeSuffix(ext)
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
let prefs = cookiePrefs()
let conv = await getCachedTweet(id)
if conv == nil:
echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
var errJson = newJObject()
errJson["error"] = %error
resp Http404, {"Content-Type": "application/json"}, $errJson
let tweet = conv.tweet
if media.len > 0:
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif media == "photo" and tweet.photos.len > 0:
if mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if tweet.video.isSome or tweet.gif.isSome:
useVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
tweet.photos = @[]
else:
index -= 1
tweet.video = none(Video)
let image = tweet.photos[index]
tweet.photos = @[]
tweet.photos.add(image)
let postJson = getActivityStream(tweet, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $postJson
if id.len > 19 or id.any(c => not c.isDigit): if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, showError("Invalid tweet ID", cfg) resp Http404, showError("Invalid tweet ID", cfg)
@ -53,56 +136,151 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, "" resp Http404, ""
resp $renderReplies(replies, prefs, getPath()) resp $renderReplies(replies, prefs, getPath())
let conv = await getTweet(id, getCursor()) let conv = await getCachedTweet(id, getCursor())
if conv == nil: if conv == nil:
echo "nil conv" echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id == 0: if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
var error = "Tweet not found" var error = "Tweet not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone error = conv.tweet.tombstone
resp Http404, showError(error, cfg) resp Http404, showError(error, cfg)
let let
title = pageTitle(conv.tweet) tweet = conv.tweet
ogTitle = pageTitle(conv.tweet.user) title = pageTitle(tweet)
desc = conv.tweet.text ogTitle = pageTitle(tweet.user)
avatar = conv.tweet.user.userPic desc = tweet.text
time = some(conv.tweet.time) avatar = tweet.user.userPic
time = some(tweet.time)
let
ua = request.headers.getOrDefault("User-Agent").toString()
isChatEmbedder = ua.contains("Discordbot") or ua.contains("TelegramBot")
var
realMediaIndex = mediaIndex
realUseVideo = false
if isChatEmbedder and media.len > 0:
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif media == "photo" and tweet.photos.len > 0:
if mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if tweet.video.isSome or tweet.gif.isSome:
useVideo = true
realUseVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
tweet.photos = @[]
else:
realMediaIndex = $index
index -= 1
tweet.video = none(Video)
let image = tweet.photos[index]
tweet.photos = @[]
tweet.photos.add(image)
var var
images = conv.tweet.photos images = tweet.photos
video = "" video = ""
context = ""
contextUrl = ""
if conv.tweet.video.isSome(): if tweet.quote.isSome():
let videoObj = get(conv.tweet.video) let
images = @[videoObj.thumb] quote = get(tweet.quote)
quoteUser = quote.user
if tweet.replyId.len != 0:
let replyUser = await getCachedUser(tweet.replyHandle)
context = &"↩ {replyUser.fullname} (@{tweet.replyHandle})\n↘ {quoteUser.fullname} (@{quoteUser.username})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
else:
context = &"↘ {quoteUser.fullname} (@{quoteUser.username})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
elif tweet.replyId.len != 0:
let replyUser = await getCachedUser(tweet.replyHandle)
context = &"↩ {replyUser.fullname} (@{tweet.replyHandle})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
if tweet.video.isSome():
let videoObj = get(tweet.video)
images.add(Image(url:videoObj.thumb))
let vars = videoObj.variants.filterIt(it.contentType == mp4) let vars = videoObj.variants.filterIt(it.contentType == mp4)
# idk why this wont sort when it sorts everywhere else # idk why this wont sort when it sorts everywhere else
#video = vars.sortedByIt(it.bitrate)[^1].url #video = vars.sortedByIt(it.bitrate)[^1].url
video = vars[^1].url video = vars[^1].url
elif conv.tweet.gif.isSome(): elif tweet.gif.isSome():
let gif = get(conv.tweet.gif) let gif = get(tweet.gif)
images = @[gif.thumb] images.add(Image(url:gif.thumb))
video = getPicUrl(gif.url) video = getUrlPrefix(cfg) & getPicUrl(gif.url)
#elif conv.tweet.card.isSome(): #elif tweet.card.isSome():
# let card = conv.tweet.card.get() # let card = tweet.card.get()
# if card.image.len > 0: # if card.image.len > 0:
# images = @[card.image] # images = @[card.image]
# elif card.video.isSome(): # elif card.video.isSome():
# images = @[card.video.get().thumb] # images = @[card.video.get().thumb]
if rawVideo and video != "":
redirect(video)
elif rawImage and images.len > 0:
if media == "photo" and mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if video != "":
useVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
redirect(video)
else:
index -= 1
redirect(getPicUrl(images[index].url))
else:
redirect(getPicUrl(images[0].url))
var query = ""
if media == "video":
query = "video"
elif media == "photo" and mediaIndex.len > 0:
if realUseVideo and video != "":
query = "video"
else:
query = &"photo:{realMediaIndex}"
var stats: seq[string] = @[]
if tweet.stats.replies > 0:
stats.add("" & formatStat(tweet.stats.replies))
if tweet.stats.retweets > 0:
stats.add("🔁 " & formatStat(tweet.stats.retweets))
if tweet.stats.quotes > 0:
stats.add("" & formatStat(tweet.stats.quotes))
if tweet.stats.likes > 0:
stats.add("" & formatStat(tweet.stats.likes))
if tweet.stats.bookmarks > 0:
stats.add("🔖 " & formatStat(tweet.stats.bookmarks))
if tweet.stats.views > 0:
stats.add("👁️ " & formatStat(tweet.stats.views))
let statsStr = stats.join(" ")
let html = renderConversation(conv, prefs, getPath() & "#m") let html = renderConversation(conv, prefs, getPath() & "#m")
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
images=images, video=video, avatar=avatar, time=time) images=images, video=video, avatar=avatar, time=time,
context=context, contextUrl=contextUrl, id=id,
media=query, stats=statsStr)
get "/@name/@s/@id/@m/?@i?": get "/@name/statuses/@id/?@m?/?@i?":
cond @"s" in ["status", "statuses"]
cond @"m" in ["video", "photo"]
redirect("/$1/status/$2" % [@"name", @"id"])
get "/@name/statuses/@id/?":
redirect("/$1/status/$2" % [@"name", @"id"]) redirect("/$1/status/$2" % [@"name", @"id"])
get "/i/web/status/@id": get "/i/web/status/@id":

View File

@ -1,16 +1,16 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, strutils, sequtils, uri, options, times import asyncdispatch, strutils, sequtils, uri, options, times, json
import jester, karax/vdom import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, redis_cache, formatters, query, api] import ".."/[types, formatters, query, api, redis_cache]
import ../views/[general, profile, timeline, status, search] import ../views/[general, profile, timeline, status, search, mastoapi]
export vdom export vdom
export uri, sequtils export uri, sequtils, json
export router_utils export router_utils
export redis_cache, formatters, query, api export formatters, query, api, redis_cache
export profile, timeline, status export profile, timeline, status, mastoapi
proc getQuery*(request: Request; tab, name: string): Query = proc getQuery*(request: Request; tab, name: string): Query =
case tab case tab
@ -32,7 +32,7 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
skipPinned=false): Future[Profile] {.async.} = skipPinned=false): Future[Profile] {.async.} =
let let
name = query.fromUser[0] name = query.fromUser[0]
userId = await getUserId(name) userId = await getCachedUserId(name)
if userId.len == 0: if userId.len == 0:
return Profile(user: User(username: name)) return Profile(user: User(username: name))
@ -48,7 +48,7 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
let let
rail = rail =
skipIf(skipRail or query.kind == media, @[]): skipIf(skipRail or query.kind == media, @[]):
getCachedPhotoRail(name) getCachedPhotoRail(userId)
user = getCachedUser(name) user = getCachedUser(name)
@ -83,7 +83,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
let pHtml = renderProfile(profile, cfg, prefs, getPath()) let pHtml = renderProfile(profile, cfg, prefs, getPath())
result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u), result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
rss=rss, images = @[u.getUserPic("_400x400")], rss=rss, images = @[Image(url: u.getUserPic("_400x400"))],
banner=u.banner) banner=u.banner)
template respTimeline*(timeline: typed) = template respTimeline*(timeline: typed) =
@ -111,6 +111,7 @@ proc createTimelineRouter*(cfg: Config) =
get "/@name/?@tab?/?": get "/@name/?@tab?/?":
cond '.' notin @"name" cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
cond @"tab" in ["with_replies", "media", "search", "following", "followers", ""] cond @"tab" in ["with_replies", "media", "search", "following", "followers", ""]
let let
prefs = cookiePrefs() prefs = cookiePrefs()
@ -120,10 +121,22 @@ proc createTimelineRouter*(cfg: Config) =
case tab: case tab:
of "followers": of "followers":
resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) resp renderMain(renderUserList(await getGraphFollowers(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
of "following": of "following":
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) resp renderMain(renderUserList(await getGraphFollowing(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
else: else:
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
let userId = await getCachedUserId(@"name")
if userId == "suspended" or userId.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
let user = await getCachedUser(@"name")
let userJson = getActivityStream(user, cfg, prefs)
resp Http200, {"Content-Type": "application/json"}, $userJson
var query = request.getQuery(@"tab", @"name") var query = request.getQuery(@"tab", @"name")
if names.len != 1: if names.len != 1:
query.fromUser = names query.fromUser = names

View File

@ -4,7 +4,7 @@ import json, asyncdispatch, options, uri
import times import times
import jester import jester
import router_utils import router_utils
import ".."/[types, api, apiutils, query, consts] import ".."/[types, api, apiutils, redis_cache]
import httpclient, strutils import httpclient, strutils
import sequtils import sequtils
@ -52,7 +52,7 @@ proc tweetToJson*(t: Tweet): JsonNode =
result["photos"] = %t.photos result["photos"] = %t.photos
proc getUserProfileJson*(username: string): Future[JsonNode] {.async.} = proc getUserProfileJson*(username: string): Future[JsonNode] {.async.} =
let user: User = await getGraphUser(username) let user: User = await getCachedUser(username)
let response: JsonNode = %*{ let response: JsonNode = %*{
"id": user.id, "id": user.id,
"username": user.username "username": user.username
@ -81,52 +81,49 @@ proc getUserTweetsJson*(id: string): Future[JsonNode] {.async.} =
result = response result = response
proc searchTimeline*(query: Query; after=""): Future[string] {.async.} =
let q = genQueryParam(query)
var
variables = %*{
"rawQuery": q,
"count": 20,
"product": "Latest",
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false
}
if after.len > 0:
variables["cursor"] = % after
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
result = await fetchRaw(url, Api.search)
proc getUserTweets*(id: string; after=""): Future[string] {.async.} = proc getUserTweets*(id: string; after=""): Future[string] {.async.} =
if id.len == 0: return if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" let headers = newHttpHeaders()
variables = userTweetsVariables % [id, cursor] headers.add("Referer", """https://x.com/$1""" % id)
params = {"variables": variables, "features": gqlFeatures}
result = await fetchRaw(graphUserTweets ? params, Api.userTweets) let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
result = await fetchRaw(userTweetsUrl(id, cursor), headers)
proc getUserReplies*(id: string; after=""): Future[string] {.async.} = proc getUserReplies*(id: string; after=""): Future[string] {.async.} =
if id.len == 0: return if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" let headers = newHttpHeaders()
variables = userTweetsVariables % [id, cursor] headers.add("Referer", """https://x.com/$1/with_replies""" % id)
params = {"variables": variables, "features": gqlFeatures}
result = await fetchRaw(graphUserTweets ? params, Api.userTweetsAndReplies) let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
result = await fetchRaw(userTweetsAndRepliesUrl(id, cursor), headers)
proc getUserMedia*(id: string; after=""): Future[string] {.async.} = proc getUserMedia*(id: string; after=""): Future[string] {.async.} =
if id.len == 0: return if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" let headers = newHttpHeaders()
variables = userMediaVariables % [id, cursor] headers.add("Referer", """https://x.com/$1/media""" % id)
params = {"variables": variables, "features": gqlFeatures}
result = await fetchRaw(graphUserMedia ? params, Api.userMedia) let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
result = await fetchRaw(mediaUrl(id, cursor), headers)
proc getTweetById*(id: string; after=""): Future[string] {.async.} = proc getTweetById*(id: string; after=""): Future[string] {.async.} =
let if id.len == 0: return
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = tweetVariables % [id, cursor] let headers = newHttpHeaders()
params = {"variables": variables, "features": gqlFeatures} headers.add("Referer", """https://x.com/i/status/$1""" % id)
result = await fetchRaw(graphTweet ? params, Api.tweetDetail)
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
result = await fetchRaw(tweetDetailUrl(id, cursor), headers)
proc getUser*(username: string): Future[string] {.async.} =
if username.len == 0: return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/$1""" % username)
result = await fetchRaw(userUrl(username), headers)
proc createTwitterApiRouter*(cfg: Config) = proc createTwitterApiRouter*(cfg: Config) =
router api: router api:
@ -135,20 +132,15 @@ proc createTwitterApiRouter*(cfg: Config) =
get "/api/user/@username": get "/api/user/@username":
let username = @"username" let username = @"username"
let response = await getUserProfileJson(username) let response = await getUser(username)
respJson response
# get "/api/user/@id/tweets":
# let id = @"id"
# let response = await getUserTweetsJson(id)
# respJson response
get "/api/user/@username/timeline":
let username = @"username"
let query = Query(fromUser: @[username])
let response = await searchTimeline(query)
resp Http200, { "Content-Type": "application/json" }, response resp Http200, { "Content-Type": "application/json" }, response
#get "/api/user/@username/timeline":
# let username = @"username"
# let query = Query(fromUser: @[username])
# let response = await searchTimeline(query)
# resp Http200, { "Content-Type": "application/json" }, response
get "/api/user/@id/tweets": get "/api/user/@id/tweets":
let id = @"id" let id = @"id"
let after = getCursor() let after = getCursor()

View File

@ -1,39 +1,50 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.panel-container { .panel-container {
margin: auto; margin: auto;
font-size: 130%; font-size: 130%;
} }
.error-panel { .error-panel {
@include center-panel(var(--error_red)); @include center-panel(var(--error_red));
text-align: center; text-align: center;
} }
.search-bar > form { .search-bar > form {
@include center-panel(var(--darkest_grey)); @include center-panel(var(--darkest_grey));
button { button {
background: var(--bg_elements); background: var(--bg_elements);
color: var(--fg_color); color: var(--fg_color);
border: 0; border: 0;
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
font-weight: bold; font-weight: bold;
width: 30px; width: 30px;
height: 30px; height: 30px;
} padding: 0px 5px 1px 8px;
}
input { input {
font-size: 16px; font-size: 16px;
width: 100%; width: 100%;
background: var(--bg_elements); background: var(--bg_elements);
color: var(--fg_color); color: var(--fg_color);
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
padding: 4px; padding: 4px;
margin-right: 8px; margin-right: 8px;
height: unset; height: unset;
} }
}
.brand-badge {
margin-left: 4px;
}
.brand-badge-image {
width: 16px;
height: 16px;
border: 1px solid var(--accent_border);
margin-bottom: -4px;
} }

View File

@ -1,180 +1,202 @@
@import '_variables'; @import "_variables";
@import 'tweet/_base'; @import "tweet/_base";
@import 'profile/_base'; @import "profile/_base";
@import 'general'; @import "general";
@import 'navbar'; @import "navbar";
@import 'inputs'; @import "inputs";
@import 'timeline'; @import "timeline";
@import 'search'; @import "search";
body { body {
// colors // colors
--bg_color: #{$bg_color}; --bg_color: #{$bg_color};
--fg_color: #{$fg_color}; --fg_color: #{$fg_color};
--fg_faded: #{$fg_faded}; --fg_faded: #{$fg_faded};
--fg_dark: #{$fg_dark}; --fg_dark: #{$fg_dark};
--fg_nav: #{$fg_nav}; --fg_nav: #{$fg_nav};
--bg_panel: #{$bg_panel}; --bg_panel: #{$bg_panel};
--bg_elements: #{$bg_elements}; --bg_elements: #{$bg_elements};
--bg_overlays: #{$bg_overlays}; --bg_overlays: #{$bg_overlays};
--bg_hover: #{$bg_hover}; --bg_hover: #{$bg_hover};
--grey: #{$grey}; --grey: #{$grey};
--dark_grey: #{$dark_grey}; --dark_grey: #{$dark_grey};
--darker_grey: #{$darker_grey}; --darker_grey: #{$darker_grey};
--darkest_grey: #{$darkest_grey}; --darkest_grey: #{$darkest_grey};
--border_grey: #{$border_grey}; --border_grey: #{$border_grey};
--accent: #{$accent}; --accent: #{$accent};
--accent_light: #{$accent_light}; --accent_light: #{$accent_light};
--accent_dark: #{$accent_dark}; --accent_dark: #{$accent_dark};
--accent_border: #{$accent_border}; --accent_border: #{$accent_border};
--play_button: #{$play_button}; --play_button: #{$play_button};
--play_button_hover: #{$play_button_hover}; --play_button_hover: #{$play_button_hover};
--more_replies_dots: #{$more_replies_dots}; --more_replies_dots: #{$more_replies_dots};
--error_red: #{$error_red}; --error_red: #{$error_red};
--verified_blue: #{$verified_blue}; --verified_blue: #{$verified_blue};
--verified_business: #{$verified_business}; --verified_business: #{$verified_business};
--verified_government: #{$verified_government}; --verified_government: #{$verified_government};
--icon_text: #{$icon_text}; --icon_text: #{$icon_text};
--tab: #{$fg_color}; --tab: #{$fg_color};
--tab_selected: #{$accent}; --tab_selected: #{$accent};
--profile_stat: #{$fg_color}; --profile_stat: #{$fg_color};
background-color: var(--bg_color); background-color: var(--bg_color);
color: var(--fg_color); color: var(--fg_color);
font-family: $font_0, $font_1, $font_2, $font_3; font-family: $font_0, $font_1;
font-size: 14px; font-size: 14px;
line-height: 1.3; line-height: 1.3;
margin: 0; margin: 0;
} }
* { * {
outline: unset; outline: unset;
margin: 0; margin: 0;
text-decoration: none; text-decoration: none;
} }
h1 { h1 {
display: inline; display: inline;
} }
h2, h3 { h2,
font-weight: normal; h3 {
font-weight: normal;
} }
p { p {
margin: 14px 0; margin: 14px 0;
} }
a { a {
color: var(--accent); color: var(--accent);
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
fieldset { fieldset {
border: 0; border: 0;
padding: 0; padding: 0;
margin-top: -0.6em; margin-top: -0.6em;
} }
legend { legend {
width: 100%; width: 100%;
padding: .6em 0 .3em 0; padding: 0.6em 0 0.3em 0;
border: 0; border: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border-bottom: 1px solid var(--border_grey); border-bottom: 1px solid var(--border_grey);
margin-bottom: 8px; margin-bottom: 8px;
} }
.preferences .note { .preferences .note {
border-top: 1px solid var(--border_grey); border-top: 1px solid var(--border_grey);
border-bottom: 1px solid var(--border_grey); border-bottom: 1px solid var(--border_grey);
padding: 6px 0 8px 0; padding: 6px 0 8px 0;
margin-bottom: 8px; margin-bottom: 8px;
margin-top: 16px; margin-top: 16px;
} }
ul { ul {
padding-left: 1.3em; padding-left: 1.3em;
} }
.container { .container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
box-sizing: border-box; box-sizing: border-box;
padding-top: 50px; padding-top: 50px;
margin: auto; margin: auto;
min-height: 100vh; min-height: 100vh;
} }
.icon-container { .icon-container {
display: inline; display: inline;
} }
.overlay-panel { .overlay-panel {
max-width: 600px; max-width: 600px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
margin-top: 10px; margin-top: 10px;
background-color: var(--bg_overlays); background-color: var(--bg_overlays);
padding: 10px 15px; padding: 10px 15px;
align-self: start; align-self: start;
ul { ul {
margin-bottom: 14px; margin-bottom: 14px;
} }
p { p {
word-break: break-word; word-break: break-word;
} }
} }
.verified-icon { .verified-icon {
color: var(--icon_text); display: inline-block;
border-radius: 50%; width: 14px;
flex-shrink: 0; height: 14px;
margin: 2px 0 3px 3px; margin-left: 2px;
padding-top: 3px;
height: 11px;
width: 14px;
font-size: 8px;
display: inline-block;
text-align: center;
vertical-align: middle;
&.blue { .verified-icon-circle {
background-color: var(--verified_blue); position: absolute;
font-size: 15px;
}
.verified-icon-check {
position: absolute;
font-size: 9px;
margin: 5px 3px;
}
&.blue {
.verified-icon-circle {
color: var(--verified_blue);
} }
&.business { .verified-icon-check {
color: var(--bg_panel); color: var(--icon_text);
background-color: var(--verified_business); }
}
&.business {
.verified-icon-circle {
color: var(--verified_business);
} }
&.government { .verified-icon-check {
color: var(--bg_panel); color: var(--bg_panel);
background-color: var(--verified_government);
} }
}
&.government {
.verified-icon-circle {
color: var(--verified_government);
}
.verified-icon-check {
color: var(--bg_panel);
}
}
} }
@media(max-width: 600px) { @media (max-width: 600px) {
.preferences-container { .preferences-container {
max-width: 95vw; max-width: 95vw;
} }
.nav-item, .nav-item .icon-container { .nav-item,
font-size: 16px; .nav-item .icon-container {
} font-size: 16px;
}
} }

View File

@ -1,89 +1,87 @@
@import '_variables'; @import "_variables";
nav { nav {
display: flex; display: flex;
align-items: center; align-items: center;
position: fixed; position: fixed;
background-color: var(--bg_overlays); background-color: var(--bg_overlays);
box-shadow: 0 0 4px $shadow; box-shadow: 0 0 4px $shadow;
padding: 0; padding: 0;
width: 100%; width: 100%;
height: 50px; height: 50px;
z-index: 1000; z-index: 1000;
font-size: 16px; font-size: 16px;
a, .icon-button button { a,
color: var(--fg_nav); .icon-button button {
} color: var(--fg_nav);
}
} }
.inner-nav { .inner-nav {
margin: auto; margin: auto;
box-sizing: border-box; box-sizing: border-box;
padding: 0 10px; padding: 0 10px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-basis: 920px; flex-basis: 920px;
height: 50px; height: 50px;
} }
.site-name { .site-name {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
&:hover { &:hover {
color: var(--accent_light); color: var(--accent_light);
text-decoration: unset; text-decoration: unset;
} }
} }
.site-logo { .site-logo {
display: block; display: block;
width: 35px; width: 35px;
height: 35px; height: 35px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
flex: 1; flex: 1;
line-height: 50px; line-height: 50px;
height: 50px; height: 50px;
overflow: hidden; overflow: hidden;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
&.right { &.right {
text-align: right; text-align: right;
justify-content: flex-end; justify-content: flex-end;
} }
&.right a { &.right a:hover {
padding-left: 4px; color: var(--accent_light);
text-decoration: unset;
&:hover { }
color: var(--accent_light);
text-decoration: unset;
}
}
} }
.lp { .lp {
height: 14px; height: 14px;
display: inline-block; display: inline-block;
position: relative; position: relative;
top: 2px; top: 2px;
fill: var(--fg_nav); fill: var(--fg_nav);
&:hover { &:hover {
fill: var(--accent_light); fill: var(--accent_light);
} }
} }
.icon-info:before { .icon-info {
margin: 0 -3px; margin: 0 -3px;
} }
.icon-cog { .icon-cog {
font-size: 15px; font-size: 15px;
padding-left: 0 !important;
} }

View File

@ -82,7 +82,9 @@
.profile-joindate, .profile-joindate,
.profile-location, .profile-location,
.profile-website { .profile-website,
.profile-automated,
.profile-pcf {
color: var(--fg_faded); color: var(--fg_faded);
margin: 1px 0; margin: 1px 0;
width: 100%; width: 100%;

View File

@ -1,120 +1,121 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.search-title { .search-title {
font-weight: bold; font-weight: bold;
display: inline-block; display: inline-block;
margin-top: 4px; margin-top: 4px;
} }
.search-field { .search-field {
display: flex;
flex-wrap: wrap;
button {
margin: 0 2px 0 0;
padding: 0px 1px 1px 4px;
height: 23px;
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
}
button { .pref-input {
margin: 0 2px 0 0; margin: 0 4px 0 0;
height: 23px; flex-grow: 1;
display: flex; height: 23px;
align-items: center; }
}
.pref-input { input[type="text"] {
margin: 0 4px 0 0; height: calc(100% - 4px);
flex-grow: 1; width: calc(100% - 8px);
height: 23px; }
}
input[type="text"] { > label {
height: calc(100% - 4px); display: inline;
width: calc(100% - 8px); background-color: var(--bg_elements);
} color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 1px 2px 4px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
> label { @include input-colors;
display: inline; }
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 6px 2px 6px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
@include input-colors; @include create-toggle(search-panel, 200px);
}
@include create-toggle(search-panel, 200px);
} }
.search-panel { .search-panel {
width: 100%; width: 100%;
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
transition: max-height 0.4s; transition: max-height 0.4s;
flex-grow: 1; flex-grow: 1;
font-weight: initial; font-weight: initial;
text-align: left; text-align: left;
> div { > div {
line-height: 1.7em; line-height: 1.7em;
} }
.checkbox-container { .checkbox-container {
display: inline; display: inline;
padding-right: unset; padding-right: unset;
margin-bottom: unset; margin-bottom: 5px;
margin-left: 23px; margin-left: 23px;
} }
.checkbox { .checkbox {
right: unset; right: unset;
left: -22px; left: -22px;
} }
.checkbox-container .checkbox:after { .checkbox-container .checkbox:after {
top: -4px; top: -4px;
} }
} }
.search-row { .search-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
line-height: unset; line-height: unset;
> div { > div {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
} }
input {
height: 21px;
}
.pref-input {
display: block;
padding-bottom: 5px;
input { input {
height: 21px; height: 21px;
} margin-top: 1px;
.pref-input {
display: block;
padding-bottom: 5px;
input {
height: 21px;
margin-top: 1px;
}
} }
}
} }
.search-toggles { .search-toggles {
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
grid-template-columns: repeat(6, auto); grid-template-columns: repeat(6, auto);
grid-column-gap: 10px; grid-column-gap: 10px;
} }
.profile-tabs { .profile-tabs {
@include search-resize(820px, 5); @include search-resize(820px, 5);
@include search-resize(725px, 4); @include search-resize(725px, 4);
@include search-resize(600px, 6); @include search-resize(600px, 6);
@include search-resize(560px, 5); @include search-resize(560px, 5);
@include search-resize(480px, 4); @include search-resize(480px, 4);
@include search-resize(410px, 3); @include search-resize(410px, 3);
} }
@include search-resize(560px, 5); @include search-resize(560px, 5);

View File

@ -1,242 +1,260 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
@import 'thread'; @import "thread";
@import 'media'; @import "media";
@import 'video'; @import "video";
@import 'embed'; @import "embed";
@import 'card'; @import "card";
@import 'poll'; @import "poll";
@import 'quote'; @import "quote";
@import 'community_note'; @import "community_note";
@import "limited_actions";
.tweet-body { .tweet-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-left: 58px; margin-left: 58px;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
} }
.tweet-content { .tweet-content {
font-family: $font_3; font-family: $font_3;
line-height: 1.3em; line-height: 1.3em;
pointer-events: all; pointer-events: all;
display: inline; display: inline;
} }
.tweet-bidi { .tweet-bidi {
display: block !important; display: block !important;
} }
.tweet-header { .tweet-header {
padding: 0; padding: 0;
vertical-align: bottom; vertical-align: bottom;
flex-basis: 100%; flex-basis: 100%;
margin-bottom: .2em; margin-bottom: 0.2em;
a { a {
display: inline-block; display: inline-block;
word-break: break-all; word-break: break-all;
max-width: 100%; max-width: 100%;
pointer-events: all; pointer-events: all;
} }
} }
.tweet-name-row { .tweet-name-row {
padding: 0; padding: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
}
.tweet-label-row {
padding: 0;
display: flex;
gap: 0.4em;
} }
.fullname-and-username { .fullname-and-username {
display: flex; display: flex;
min-width: 0; min-width: 0;
} }
.fullname { .fullname {
@include ellipsis; @include ellipsis;
flex-shrink: 2; flex-shrink: 2;
max-width: 80%; max-width: 80%;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: var(--fg_color); color: var(--fg_color);
} }
.username { .username {
@include ellipsis; @include ellipsis;
min-width: 1.6em; min-width: 1.6em;
margin-left: .4em; margin-left: 0.4em;
word-wrap: normal; word-wrap: normal;
}
.user-automated,
.user-pcf {
@include ellipsis;
min-width: 1px;
color: var(--fg_faded);
} }
.tweet-date { .tweet-date {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
margin-left: 4px; margin-left: 4px;
} }
.tweet-date a, .username, .show-more a { .tweet-date a,
color: var(--fg_dark); .username,
.show-more a {
color: var(--fg_dark);
} }
.tweet-published { .tweet-published {
margin: 0; margin: 0;
margin-top: 5px; margin-top: 5px;
color: var(--grey); color: var(--grey);
pointer-events: all; pointer-events: all;
} }
.tweet-avatar { .tweet-avatar {
display: contents !important; display: contents !important;
img { img {
float: left; float: left;
margin-top: 3px; margin-top: 3px;
margin-left: -58px; margin-left: -58px;
width: 48px; width: 48px;
height: 48px; height: 48px;
} }
} }
.avatar { .avatar {
&.round { &.round {
border-radius: 50%; border-radius: 50%;
-webkit-user-select: none; -webkit-user-select: none;
} }
&.mini { &.mini {
position: unset; position: unset;
margin-right: 5px; margin-right: 5px;
margin-top: -1px; margin-top: -1px;
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
} }
.tweet-embed { .tweet-embed {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
background-color: var(--bg_panel);
.tweet-content {
font-size: 18px;
}
.tweet-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; max-height: calc(100vh - 0.75em * 2);
height: 100%; }
background-color: var(--bg_panel);
.tweet-content { .card-image img {
font-size: 18px; height: auto;
} }
.tweet-body {
display: flex;
flex-direction: column;
max-height: calc(100vh - 0.75em * 2);
}
.card-image img { .avatar {
height: auto; position: absolute;
} }
.avatar {
position: absolute;
}
} }
.attribution { .attribution {
display: flex; display: flex;
pointer-events: all; pointer-events: all;
margin: 5px 0; margin: 5px 0;
strong { strong {
color: var(--fg_color); color: var(--fg_color);
} }
} }
.media-tag-block { .media-tag-block {
padding-top: 5px; padding-top: 5px;
pointer-events: all; pointer-events: all;
color: var(--fg_faded);
.icon-container {
padding-right: 2px;
}
.media-tag,
.icon-container {
color: var(--fg_faded); color: var(--fg_faded);
}
.icon-container {
padding-right: 2px;
}
.media-tag, .icon-container {
color: var(--fg_faded);
}
} }
.timeline-container .media-tag-block { .timeline-container .media-tag-block {
font-size: 13px; font-size: 13px;
} }
.tweet-geo { .tweet-geo {
color: var(--fg_faded); color: var(--fg_faded);
} }
.replying-to { .replying-to {
color: var(--fg_faded); color: var(--fg_faded);
margin: -2px 0 4px; margin: -2px 0 4px;
a { a {
pointer-events: all; pointer-events: all;
} }
} }
.retweet-header, .pinned, .tweet-stats { .retweet-header,
align-content: center; .pinned,
color: var(--grey); .tweet-stats {
display: flex; align-content: center;
flex-shrink: 0; color: var(--grey);
flex-wrap: wrap; display: flex;
font-size: 14px; flex-shrink: 0;
font-weight: 600; flex-wrap: wrap;
line-height: 22px; font-size: 14px;
font-weight: 600;
line-height: 22px;
span { span {
@include ellipsis; @include ellipsis;
} }
} }
.retweet-header { .retweet-header {
margin-top: -5px !important; margin-top: -5px !important;
} }
.tweet-stats { .tweet-stats {
margin-bottom: -3px; margin-bottom: -3px;
-webkit-user-select: none; -webkit-user-select: none;
} }
.tweet-stat { .tweet-stat {
padding-top: 5px; padding-top: 5px;
min-width: 1em; min-width: 1em;
margin-right: 0.8em; margin-right: 0.8em;
pointer-events: all; pointer-events: all;
} }
.show-thread { .show-thread {
display: block; display: block;
pointer-events: all; pointer-events: all;
padding-top: 2px; padding-top: 2px;
} }
.unavailable-box { .unavailable-box {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 12px; padding: 12px;
border: solid 1px var(--dark_grey); border: solid 1px var(--dark_grey);
box-sizing: border-box; box-sizing: border-box;
border-radius: 10px; border-radius: 10px;
background-color: var(--bg_color); background-color: var(--bg_color);
z-index: 2; z-index: 2;
} }
.tweet-link { .tweet-link {
height: 100%; height: 100%;
width: 100%; width: 100%;
left: 0; left: 0;
top: 0; top: 0;
position: absolute; position: absolute;
-webkit-user-select: none; -webkit-user-select: none;
&:hover { &:hover {
background-color: var(--bg_hover); background-color: var(--bg_hover);
} }
} }

View File

@ -0,0 +1,32 @@
@import "_variables";
.limited-actions {
margin-top: 10px;
border: solid 1px var(--dark_grey);
border-radius: 10px;
background-color: var(--bg_overlays);
overflow: hidden;
pointer-events: all;
position: relative;
width: 100%;
&:hover {
border-color: var(--grey);
}
.limited-actions-title {
font-weight: bold;
padding: 6px 8px;
padding-bottom: 2px;
margin-top: 1px;
font-size: 16px;
}
.limited-actions-text {
overflow: hidden;
white-space: pre-wrap;
word-wrap: break-word;
padding: 8px;
padding-top: 2px;
}
}

View File

@ -48,6 +48,19 @@
margin: 0; margin: 0;
max-height: 530px; max-height: 530px;
} }
.alt {
position: relative;
bottom: 15px;
left: 4px;
padding: 4px;
background: #101010;
color: white;
border-radius: 4px;
pointer-events: none;
font-size: 10px;
font-weight: 600;
}
} }
.gallery-gif video { .gallery-gif video {

View File

@ -1,66 +1,64 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
video { video {
max-height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.gallery-video { .gallery-video {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
.gallery-video.card-container { .gallery-video.card-container {
flex-direction: column; flex-direction: column;
width: 100%;
} }
.video-container { .video-container {
max-height: 530px; max-height: 530px;
margin: 0; margin: 0;
display: flex;
align-items: center;
justify-content: center;
img { img {
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
} }
} }
.video-overlay { .video-overlay {
@include play-button; @include play-button;
background-color: $shadow; background-color: $shadow;
p { p {
position: relative; position: relative;
z-index: 0; z-index: 0;
text-align: center; text-align: center;
top: calc(50% - 20px); top: calc(50% - 20px);
font-size: 20px; font-size: 20px;
line-height: 1.3; line-height: 1.3;
margin: 0 20px; margin: 0 20px;
} }
div { div {
position: relative; position: relative;
z-index: 0; z-index: 0;
top: calc(50% - 20px); top: calc(50% - 20px);
margin: 0 auto; margin: 0 auto;
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
form { form {
width: 100%; width: 100%;
height: 100%; height: 100%;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
display: flex; display: flex;
} }
button { button {
padding: 5px 8px; padding: 5px 8px;
font-size: 16px; font-size: 16px;
} }
} }

62
src/tid.nim Normal file
View File

@ -0,0 +1,62 @@
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
import nimcrypto
import experimental/parser/tid
randomize()
const defaultKeyword = "obfiowerehiring";
const pairsUrl =
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
var
cachedPairs: seq[TidPair] = @[]
lastCached = 0
# refresh every hour
ttlSec = 60 * 60
proc getPair(): Future[TidPair] {.async.} =
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
lastCached = int(epochTime())
let client = newAsyncHttpClient()
defer: client.close()
let resp = await client.get(pairsUrl)
if resp.status == $Http200:
cachedPairs = parseTidPairs(await resp.body)
return sample(cachedPairs)
proc encodeSha256(text: string): array[32, byte] =
let
data = cast[ptr byte](addr text[0])
dataLen = uint(len(text))
digest = sha256.digest(data, dataLen)
return digest.data
proc encodeBase64[T](data: T): string =
return encode(data).replace("=", "")
proc decodeBase64(data: string): seq[byte] =
return cast[seq[byte]](decode(data))
proc genTid*(path: string): Future[string] {.async.} =
let
pair = await getPair()
timeNow = int(epochTime() - 1682924400)
timeNowBytes = @[
byte(timeNow and 0xff),
byte((timeNow shr 8) and 0xff),
byte((timeNow shr 16) and 0xff),
byte((timeNow shr 24) and 0xff)
]
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
hashBytes = encodeSha256(data)
keyBytes = decodeBase64(pair.verification)
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
randomNum = byte(rand(256))
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
return encodeBase64(tid)

View File

@ -1,168 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, times, sequtils, json, random
import strutils, tables
import types, consts
const
maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions
maxLastUse = 1.hours # if a token is unused for 60 minutes, it expires
maxAge = 2.hours + 55.minutes # tokens expire after 3 hours
failDelay = initDuration(minutes=30)
var
tokenPool: seq[Token]
lastFailed: Time
enableLogging = false
let headers = newHttpHeaders({"authorization": auth})
template log(str) =
if enableLogging: echo "[tokens] ", str
proc getPoolJson*(): JsonNode =
var
list = newJObject()
totalReqs = 0
totalPending = 0
reqsPerApi: Table[string, int]
for token in tokenPool:
totalPending.inc(token.pending)
list[token.tok] = %*{
"apis": newJObject(),
"pending": token.pending,
"init": $token.init,
"lastUse": $token.lastUse
}
for api in token.apis.keys:
list[token.tok]["apis"][$api] = %token.apis[api]
let
maxReqs =
case api
of Api.photoRail: 180
#of Api.timeline: 187
#of Api.userTweets, Api.userTimeline: 300
of Api.userTweets: 300
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
Api.userTweetsAndReplies, Api.userMedia,
Api.userRestId, Api.userScreenName, Api.tweetDetail,
Api.tweetResult, Api.search, Api.favorites,
Api.retweeters, Api.favoriters, Api.following, Api.followers: 500
#of Api.userSearch: 900
reqs = maxReqs - token.apis[api].remaining
reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs
totalReqs.inc(reqs)
return %*{
"amount": tokenPool.len,
"requests": totalReqs,
"pending": totalPending,
"apis": reqsPerApi,
"tokens": list
}
proc rateLimitError*(): ref RateLimitError =
newException(RateLimitError, "rate limited")
proc fetchToken(): Future[Token] {.async.} =
if getTime() - lastFailed < failDelay:
raise rateLimitError()
let client = newAsyncHttpClient(headers=headers)
try:
let
resp = await client.postContent(activate)
tokNode = parseJson(resp)["guest_token"]
tok = tokNode.getStr($(tokNode.getInt))
time = getTime()
return Token(tok: tok, init: time, lastUse: time)
except Exception as e:
echo "[tokens] fetching token failed: ", e.msg
if "Try again" notin e.msg:
echo "[tokens] fetching tokens paused, resuming in 30 minutes"
lastFailed = getTime()
finally:
client.close()
proc expired(token: Token): bool =
let time = getTime()
token.init < time - maxAge or token.lastUse < time - maxLastUse
proc isLimited(token: Token; api: Api): bool =
if token.isNil or token.expired:
return true
if api in token.apis:
let limit = token.apis[api]
return (limit.remaining <= 10 and limit.reset > epochTime().int)
else:
return false
proc isReady(token: Token; api: Api): bool =
not (token.isNil or token.pending > maxConcurrentReqs or token.isLimited(api))
proc release*(token: Token; used=false; invalid=false) =
if token.isNil: return
if invalid or token.expired:
if invalid: log "discarding invalid token"
elif token.expired: log "discarding expired token"
let idx = tokenPool.find(token)
if idx > -1: tokenPool.delete(idx)
elif used:
dec token.pending
token.lastUse = getTime()
proc getToken*(api: Api): Future[Token] {.async.} =
for i in 0 ..< tokenPool.len:
if result.isReady(api): break
release(result)
result = tokenPool.sample()
if not result.isReady(api):
release(result)
result = await fetchToken()
log "added new token to pool"
tokenPool.add result
if not result.isNil:
inc result.pending
else:
raise rateLimitError()
proc setRateLimit*(token: Token; api: Api; remaining, reset: int) =
# avoid undefined behavior in race conditions
if api in token.apis:
let limit = token.apis[api]
if limit.reset >= reset and limit.remaining < remaining:
return
token.apis[api] = RateLimit(remaining: remaining, reset: reset)
proc poolTokens*(amount: int) {.async.} =
var futs: seq[Future[Token]]
for i in 0 ..< amount:
futs.add fetchToken()
for token in futs:
var newToken: Token
try: newToken = await token
except: discard
if not newToken.isNil:
log "added new token to pool"
tokenPool.add newToken
proc initTokenPool*(cfg: Config) {.async.} =
enableLogging = cfg.enableDebug
while true:
if tokenPool.countIt(not it.isLimited(Api.userTweets)) < cfg.minTokens:
await poolTokens(min(4, cfg.minTokens - tokenPool.len))
await sleepAsync(2000)

View File

@ -12,25 +12,13 @@ type
TimelineKind* {.pure.} = enum TimelineKind* {.pure.} = enum
tweets, replies, media tweets, replies, media
Api* {.pure.} = enum ApiUrl* = object
tweetDetail endpoint*: string
tweetResult params*: seq[(string, string)]
photoRail
search ApiReq* = object
list oauth*: ApiUrl
listBySlug cookie*: ApiUrl
listMembers
listTweets
userRestId
userScreenName
favorites
userTweets
userTweetsAndReplies
userMedia
favoriters
retweeters
following
followers
RateLimit* = object RateLimit* = object
remaining*: int remaining*: int
@ -43,14 +31,14 @@ type
init*: Time init*: Time
lastUse*: Time lastUse*: Time
pending*: int pending*: int
apis*: Table[Api, RateLimit] apis*: Table[string, RateLimit]
GuestAccount* = ref object GuestAccount* = ref object
id*: int64 id*: int64
oauthToken*: string oauthToken*: string
oauthSecret*: string oauthSecret*: string
pending*: int pending*: int
apis*: Table[Api, RateLimit] apis*: Table[string, RateLimit]
Error* = enum Error* = enum
null = 0 null = 0
@ -80,6 +68,11 @@ type
business = "Business" business = "Business"
government = "Government" government = "Government"
Badge* = object
name*: string
icon*: string
url*: string
User* = object User* = object
id*: string id*: string
username*: string username*: string
@ -89,7 +82,7 @@ type
bio*: string bio*: string
userPic*: string userPic*: string
banner*: string banner*: string
pinnedTweet*: int64 pinnedTweet*: string
following*: int following*: int
followers*: int followers*: int
tweets*: int tweets*: int
@ -99,6 +92,10 @@ type
protected*: bool protected*: bool
suspended*: bool suspended*: bool
joinDate*: DateTime joinDate*: DateTime
bot*: bool
botOwner*: string
pcf*: string
badge*: Badge
VideoType* = enum VideoType* = enum
m3u8 = "application/x-mpegURL" m3u8 = "application/x-mpegURL"
@ -123,6 +120,10 @@ type
playbackType*: VideoType playbackType*: VideoType
variants*: seq[VideoVariant] variants*: seq[VideoVariant]
Image* = object
url*: string
description*: string
QueryKind* = enum QueryKind* = enum
posts, replies, media, users, tweets, userList, favorites posts, replies, media, users, tweets, userList, favorites
@ -197,20 +198,27 @@ type
retweets*: int retweets*: int
likes*: int likes*: int
quotes*: int quotes*: int
bookmarks*: int
views*: int
BirdwatchNote* = ref object BirdwatchNote* = ref object
id*: int64 id*: string
title*: string
text*: string
LimitedActions* = ref object
title*: string title*: string
text*: string text*: string
Tweet* = ref object Tweet* = ref object
id*: int64 id*: string
threadId*: int64 threadId*: string
replyId*: int64 replyId*: string
user*: User user*: User
text*: string text*: string
time*: DateTime time*: DateTime
reply*: seq[string] reply*: seq[string]
replyHandle*: string
pinned*: bool pinned*: bool
hasThread*: bool hasThread*: bool
available*: bool available*: bool
@ -227,8 +235,9 @@ type
poll*: Option[Poll] poll*: Option[Poll]
gif*: Option[Gif] gif*: Option[Gif]
video*: Option[Video] video*: Option[Video]
photos*: seq[string] photos*: seq[Image]
birdwatch*: Option[BirdwatchNote] birdwatch*: Option[BirdwatchNote]
limitedActions*: Option[LimitedActions]
Tweets* = seq[Tweet] Tweets* = seq[Tweet]
@ -277,6 +286,7 @@ type
useHttps*: bool useHttps*: bool
httpMaxConns*: int httpMaxConns*: int
title*: string title*: string
oembedColor*: string
hostname*: string hostname*: string
staticDir*: string staticDir*: string
@ -287,6 +297,7 @@ type
enableDebug*: bool enableDebug*: bool
proxy*: string proxy*: string
proxyAuth*: string proxyAuth*: string
disableTid*: bool
cookieHeader*: string cookieHeader*: string
xCsrfToken*: string xCsrfToken*: string

View File

@ -5,7 +5,7 @@ import karax/[karaxdsl, vdom]
const const
date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"") date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"")
hash = staticExec("git show -s --format=\"%h\"") hash = staticExec("git show -s --format=\"%h\"")
link = "https://github.com/zedeus/nitter/commit/" & hash link = "https://git.eir-nya.gay/eir/nitter/commit/" & hash
version = &"{date}-{hash}" version = &"{date}-{hash}"
var aboutHtml: string var aboutHtml: string

View File

@ -15,7 +15,7 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
let vidUrl = vars.sortedByIt(it.bitrate)[^1].url let vidUrl = vars.sortedByIt(it.bitrate)[^1].url
let prefs = Prefs(hlsPlayback: true, mp4Playback: true) let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) renderHead(prefs, cfg, req, video=vidUrl, images=(@[Image(url:thumb)]))
body: body:
tdiv(class="embed-video"): tdiv(class="embed-video"):
@ -23,11 +23,11 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
result = doctype & $node result = doctype & $node
proc generateOembed*(cfg: Config; typ, title, user, url, provider: string): JsonNode = proc generateOembed*(cfg: Config; typ, title, user, url: string, provider: string): JsonNode =
%*{ %*{
"type": typ, "type": typ,
"version": "1.0", "version": "1.0",
"provider_name": provider, "provider_name": provider, #cfg.title,
"provider_url": getUrlPrefix(cfg), "provider_url": getUrlPrefix(cfg),
"title": title, "title": title,
"author_name": user, "author_name": user,

View File

@ -3,7 +3,7 @@ import uri, strutils, strformat, times, options
import karax/[karaxdsl, vdom] import karax/[karaxdsl, vdom]
import renderutils import renderutils
import ../utils, ../types, ../prefs, ../formatters import ".."/[utils, types, prefs, formatters]
import jester import jester
@ -37,16 +37,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle=""; video=""; images: seq[Image] = @[]; banner=""; ogTitle="";
rss=""; canonical=""; avatar=""; rss=""; canonical=""; avatar=""; context=""; contextUrl="";
time: Option[DateTime] = none(DateTime)): VNode = id=""; time: Option[DateTime] = none(DateTime); media="";
stats = ""): VNode =
var theme = prefs.theme.toTheme var theme = prefs.theme.toTheme
if "theme" in req.params: if "theme" in req.params:
theme = req.params["theme"].toTheme theme = req.params["theme"].toTheme
let ogType = let ogType =
if video.len > 0: "video.other" if video.len > 0: "video.other"
elif rss.len > 0: "object" #elif rss.len > 0: "object"
elif images.len > 0: "photo" elif images.len > 0: "photo"
else: "article" else: "article"
@ -54,17 +55,19 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=20") link(rel="stylesheet", type="text/css", href="/css/style.css?v=20")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
link(rel="stylesheet", href="/css/baguetteBox.min.css") link(rel="stylesheet", href="/css/baguetteBox.min.css")
script(src="/js/baguetteBox.min.js", `async`="") script(src="/js/baguetteBox.min.js", `async`="")
script(src="/js/zoom.js") script(src="/js/zoom.js")
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
if theme.len > 0: if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png") link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png"))
link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png") link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png"))
link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png") link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png?v=2")
link(rel="manifest", href="/site.webmanifest") link(rel="manifest", href="/site.webmanifest")
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60") link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
@ -83,6 +86,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if prefs.infiniteScroll: if prefs.infiniteScroll:
script(src="/js/infiniteScroll.js", `defer`="") script(src="/js/infiniteScroll.js", `defer`="")
# Eir: load custom js
if prefs.eirResources:
script(src="/js/eirResources.js", `defer`="")
title: title:
if titleText.len > 0: if titleText.len > 0:
text titleText & " | " & cfg.title text titleText & " | " & cfg.title
@ -93,7 +100,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let finalizedDesc = stripHtml(desc) let finalizedDesc = stripHtml(desc)
meta(name="viewport", content="width=device-width, initial-scale=1.0") meta(name="viewport", content="width=device-width, initial-scale=1.0")
meta(name="theme-color", content="#1F1F1F") meta(name="theme-color", content=cfg.oembedColor)
meta(property="og:type", content=ogType) meta(property="og:type", content=ogType)
if video.len > 0 and len(finalizedDesc) <= 67: if video.len > 0 and len(finalizedDesc) <= 67:
meta(property="og:title", content=finalizedDesc) meta(property="og:title", content=finalizedDesc)
@ -101,17 +108,23 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
meta(property="og:title", content=finalizedTitleText) meta(property="og:title", content=finalizedTitleText)
meta(property="og:description", content=finalizedDesc) meta(property="og:description", content=finalizedDesc)
meta(property="og:locale", content="en_US") meta(property="og:locale", content="en_US")
meta(name="referrer", content="no-referrer")
var siteName = "Nitter" var siteName = cfg.title
if time.isSome: if time.isSome:
let timeObj = time.get let timeObj = time.get
let timeStr = $timeObj let timeStr = $timeObj
meta(property="og:article:published_time", content=timeStr) meta(property="og:article:published_time", content=timeStr)
let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss") if not isDiscord:
siteName = &"Nitter • {formattedTime}" let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss")
siteName = &"{siteName} • {formattedTime}"
if stats.len > 0:
siteName &= "\n" & stats
if isDiscord and stats.len > 0:
siteName &= "" & stats
meta(property="og:site_name", content=siteName) meta(property="og:site_name", content=siteName)
if banner.len > 0 and not banner.startsWith('#'): if banner.len > 0 and not banner.startsWith('#'):
@ -119,19 +132,21 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="preload", type="image/png", href=bannerUrl, `as`="image") link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
if images.len > 0: if images.len > 0:
for url in images: for imageObj in images:
let preloadUrl = if "400x400" in url: getPicUrl(url) let
url = imageObj.url
preloadUrl = if "400x400" in url: getPicUrl(url)
else: getSmallPic(url) else: getSmallPic(url)
link(rel="preload", type="image/png", href=preloadUrl, `as`="image") link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
let image = getUrlPrefix(cfg) & getPicUrl(url) let image = getUrlPrefix(cfg) & getPicUrl(url)
meta(property="og:image", content=image) meta(property="og:image", content=image)
meta(property="og:image:alt", content=imageObj.description)
if video.len == 0: if video.len == 0:
meta(property="twitter:image:src", content=image) meta(property="twitter:image:src", content=image)
if rss.len > 0:
meta(property="twitter:card", content="summary")
elif video.len == 0:
meta(property="twitter:card", content="summary_large_image") meta(property="twitter:card", content="summary_large_image")
else:
meta(property="twitter:card", content="summary")
elif avatar.len > 0: elif avatar.len > 0:
let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar) let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar)
meta(property="og:image", content=avatarUrl) meta(property="og:image", content=avatarUrl)
@ -140,32 +155,59 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
meta(property="og:video:url", content=video) meta(property="og:video:url", content=video)
meta(property="og:video:secure_url", content=video) meta(property="og:video:secure_url", content=video)
meta(property="og:video:type", content="video/mp4") meta(property="og:video:type", content="video/mp4")
var title = encodeUrl(finalizedDesc)
var author = encodeUrl(finalizedTitleText) var
title = encodeUrl(finalizedDesc)
author = encodeUrl(finalizedTitleText)
url = req.path
if len(finalizedDesc) > 67: if len(finalizedDesc) > 67:
title = author title = author
author = encodeUrl(finalizedDesc) author = encodeUrl(finalizedDesc)
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(req.path)}\" type=\"application/json+oembed\" />" if context != "":
#link(rel="alternate", author = encodeUrl(context & "\n") & author
# href=&"{getUrlPrefix(cfg)}/oembed.json?type=video&title={encodeUrl(stripHtml(desc))}&user={encodeUrl(finalizedTitleText)}&url={encodeUrl(req.path)}",
# `type`="application/json+oembed") if contextUrl != "":
url = contextUrl
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed")
elif context != "" and contextUrl != "":
var
title = encodeUrl(finalizedTitleText)
author = encodeUrl(context)
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed")
var fediUrl = &"{getUrlPrefix(cfg)}/users/i/statuses/"
if media.len > 0:
if media == "video":
fediUrl &= "422209040515" # 42 + "video"
else:
let parts = media.split(":")
fediUrl &= "421608152015" # 42 + "photo"
if parts.len == 2:
fediUrl &= parts[1] # + index
fediUrl &= id
link(rel="alternate", href=fediUrl, type="application/activity+json")
# this is last so images are also preloaded # this is last so images are also preloaded
# if this is done earlier, Chrome only preloads one image for some reason # if this is done earlier, Chrome only preloads one image for some reason
link(rel="preload", type="font/woff2", `as`="font", link(rel="preload", type="font/woff2", `as`="font",
href="/fonts/fontello.woff2?21002321", crossorigin="anonymous") href="/fonts/fontello.woff2?76162212", crossorigin="anonymous")
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video=""; titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""; avatar=""; images: seq[Image] = @[]; banner=""; avatar=""; context="";
time: Option[DateTime] = none(DateTime)): string = contextUrl=""; id=""; time: Option[DateTime] = none(DateTime);
media=""; stats=""): string =
let canonical = getTwitterLink(req.path, req.params) let canonical = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical, avatar, time) rss, canonical, avatar, context, contextUrl, id, time, media,
stats)
body: body:
renderNavbar(cfg, req, rss, canonical) renderNavbar(cfg, req, rss, canonical)

341
src/views/mastoapi.nim Normal file
View File

@ -0,0 +1,341 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, options, json, sequtils, times, math
import ".."/[types, formatters, utils]
proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string =
var content = replaceUrls(tweet.text, prefs, absolute=getUrlPrefix(cfg))
if tweet.poll.isSome():
let poll = get(tweet.poll)
content &= "\n<blockquote>"
for i in 0 ..< poll.options.len:
let
leader = if poll.leader == i: " leader" else: ""
val = poll.values[i]
perc = if val > 0: val / poll.votes * 100 else: 0
percStr = (&"{perc:>3.0f}").strip(chars={'.'}) & '%'
barLen = round((perc / 100) * 32).int
bar = repeat("", barLen)
notBar = repeat("&nbsp;", 32 - barLen)
content &= &"<b>{poll.options[i]}</b> ({insertSep($val, ',')}, {percStr})\n<code>{bar}{notBar}</code>\n"
content &= &"\n{insertSep($poll.votes, ',')} votes • {poll.status}</blockquote>"
if tweet.quote.isSome():
let
quote = get(tweet.quote)
quoteContent = replaceUrls(quote.text, prefs, absolute=getUrlPrefix(cfg))
quoteUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
content &= &"\n\n<blockquote><b>↘ <a href=\"{quoteUrl}\">{quote.user.fullName} (@{quote.user.username})</a></b>\n{quoteContent}"
if quote.video.isSome() or quote.gif.isSome():
content &= "\n📹"
if quote.gif.isSome():
content &= " (GIF)"
elif quote.photos.len > 0:
content &= "\n🖼️"
if quote.photos.len > 1:
content &= &" ({quote.photos.len})"
content &= "</blockquote>"
if tweet.birdwatch.isSome():
let
note = get(tweet.birdwatch)
noteContent = replaceUrls(note.text, prefs, absolute=getUrlPrefix(cfg))
content &= &"\n\n<blockquote><b>ⓘ {note.title}</b>\n{noteContent}</blockquote>"
result = content.replace("\n", "<br>")
proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
let
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.id}"
tweetContent = formatTweetForMastoAPI(tweet, cfg, prefs)
var media: seq[JsonNode] = @[]
if tweet.photos.len > 0:
for imageObj in tweet.photos:
let
image = getUrlPrefix(cfg) & getPicUrl(imageObj.url)
splitUrl = imageObj.url.split('.')
var filetype = splitUrl[^1]
if filetype == "jpg":
filetype = "jpeg"
var mediaObj = newJObject()
mediaObj["type"] = %"Image"
mediaObj["mediaType"] = %("image/" & filetype)
mediaObj["url"] = %image
mediaObj["name"] = %imageObj.description
media.add(mediaObj)
if tweet.video.isSome():
let
videoObj = get(tweet.video)
vars = videoObj.variants.filterIt(it.contentType == mp4)
var description = videoObj.title
if videoObj.description.len > 0:
description = videoObj.description
let splitUrl = videoObj.thumb.split('.')
var filetype = splitUrl[^1]
if filetype == "jpg":
filetype = "jpeg"
var url: seq[JsonNode] = @[]
var thumb = newJObject()
thumb["type"] = %"Link"
thumb["mediaType"] = %("image/" & filetype)
thumb["href"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb))
url.add(thumb)
var mediaObj = newJObject()
mediaObj["type"] = %"Link"
mediaObj["mediaType"] = %"video/mp4"
mediaObj["href"] = %(vars[^1].url.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", ""))
url.add(mediaObj)
var wrapper = newJObject()
wrapper["type"] = %"Video"
wrapper["name"] = %description
wrapper["url"] = %url
media.add(wrapper)
elif tweet.gif.isSome():
let
gif = get(tweet.gif)
gifUrl = https & gif.url
let splitUrl = gif.thumb.split('.')
var filetype = splitUrl[^1]
if filetype == "jpg":
filetype = "jpeg"
var url: seq[JsonNode] = @[]
var thumb = newJObject()
thumb["type"] = %"Link"
thumb["mediaType"] = %("image/" & filetype)
thumb["href"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
url.add(thumb)
var mediaObj = newJObject()
mediaObj["type"] = %"Link"
mediaObj["mediaType"] = %"video/mp4"
mediaObj["href"] = %(gifUrl.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", ""))
url.add(mediaObj)
var wrapper = newJObject()
wrapper["type"] = %"Video"
wrapper["name"] = newJNull()
wrapper["url"] = %url
media.add(wrapper)
var context: seq[JsonNode] = @[]
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
context.add(contextUrl)
let asProps: JsonNode = %*{
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"sensitive": "as:sensitive",
}
context.add(asProps)
var postJson = newJObject()
postJson["@context"] = %context
postJson["id"] = %tweetUrl
postJson["type"] = %"Note"
postJson["summary"] = newJNull()
if tweet.replyId.len != 0:
let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
postJson["inReplyTo"] = %replyUrl
postJson["inReplyToAtomUri"] = %replyUrl
else:
postJson["inReplyTo"] = newJNull()
postJson["inReplyToAtomUri"] = newJNull()
postJson["published"] = %($tweet.time)
postJson["url"] = %tweetUrl
postJson["attributedTo"] = %(&"{getUrlPrefix(cfg)}/users/{tweet.user.username}")
postJson["to"] = newJArray()
postJson["cc"] = %(@["https://www.w3.org/ns/activitystreams#Public"])
postJson["sensitive"] = %false # FIXME
postJson["atomUri"] = %tweetUrl
postJson["conversation"] = %""
postJson["content"] = %tweetContent
postJson["contentMap"] = %*{
"en": tweetContent
}
postJson["attachment"] = %media
postJson["tag"] = newJArray() # TODO: parse?
postJson["replies"] = newJObject()
result = postJson
proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
let userUrl = &"{getUrlPrefix(cfg)}/{user.username}"
var context: seq[JsonNode] = @[]
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
context.add(contextUrl)
let contextUrl2: JsonNode = %"https://w3id.org/security/v1"
context.add(contextUrl2)
let contextAka: JsonNode = %*{
"@id": "as:alsoKnownAs",
"@type": "@id"
}
let contextMovedTo = %*{
"@id": "as:movedTo",
"@type": "@id"
}
var asProps: JsonNode = %*{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
}
asProps["alsoKnownAs"] = contextAka
asProps["movedTo"] = contextMovedTo
context.add(asProps)
var fields: seq[JsonNode] = @[]
if user.location.len > 0:
var location = newJObject()
location["type"] = %"PropertyValue"
location["name"] = %"Location"
location["value"] = %user.location
fields.add(location)
if user.website.len > 0:
var website = newJObject()
website["type"] = %"PropertyValue"
website["name"] = %"Website"
website["value"] = %(&"<a href=\"{user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{user.website}</a>")
fields.add(website)
if user.botOwner.len > 0:
var botOwner = newJObject()
botOwner["type"] = %"PropertyValue"
botOwner["name"] = %"Automated by"
botOwner["value"] = %(&"<a href=\"{getUrlPrefix(cfg)}/{user.botOwner}\" translate=\"no\">{user.botOwner}</a>")
fields.add(botOwner)
if user.pcf != "" and user.pcf != "None":
var pcf = newJObject()
pcf["type"] = %"PropertyValue"
pcf["name"] = %"PCF Label"
pcf["value"] = %user.pcf
fields.add(pcf)
if user.verifiedType != none:
var verified = newJObject()
verified["type"] = %"PropertyValue"
verified["name"] = %"Verified Type"
verified["value"] = %user.verifiedType
fields.add(verified)
var userJson = newJObject()
userJson["@context"] = %context
userJson["id"] = %userUrl
userJson["type"] = %"Person"
userJson["following"] = %(userUrl & "/following")
userJson["followers"] = %(userUrl & "/followers")
userJson["inbox"] = newJNull()
userJson["outbox"] = newJNull()
userJson["featured"] = newJNull()
userJson["featuredTags"] = newJNull()
userJson["preferredUsername"] = %user.username
userJson["name"] = %user.fullname
userJson["summary"] = %user.bio
userJson["url"] = %userUrl
userJson["manuallyApprovesFollowers"] = %user.protected
userJson["discoverable"] = %true
userJson["indexable"] = %false
userJson["published"] = %($user.joinDate)
userJson["memorial"] = %false
userJson["publicKey"] = newJNull()
userJson["tag"] = newJArray()
userJson["attachment"] = %fields
userJson["endpoints"] = newJObject()
userJson["icon"] = %*{
"type": "Image",
"mediaType": "image/jpeg",
"url": getUrlPrefix(cfg) & getPicUrl(user.userPic)
}
userJson["image"] = %*{
"type": "Image",
"mediaType": "image/jpeg",
"url": getUrlPrefix(cfg) & getPicUrl(user.banner)
}
result = userJson
proc getMastoAPIUser*(user: User, cfg: Config): JsonNode =
var fields: seq[JsonNode] = @[]
if user.location.len > 0:
var location = newJObject()
location["name"] = %"Location"
location["value"] = %user.location
location["verified_at"] = newJNull()
fields.add(location)
if user.website.len > 0:
var website = newJObject()
website["name"] = %"Website"
website["value"] = %(&"<a href=\"{user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{user.website}</a>")
website["verified_at"] = newJNull()
fields.add(website)
if user.botOwner.len > 0:
var botOwner = newJObject()
botOwner["name"] = %"Automated by"
botOwner["value"] = %(&"<a href=\"{getUrlPrefix(cfg)}/{user.botOwner}\" translate=\"no\">{user.botOwner}</a>")
botOwner["verified_at"] = newJNull()
fields.add(botOwner)
if user.pcf != "" and user.pcf != "None":
var pcf = newJObject()
pcf["name"] = %"PCF Label"
pcf["value"] = %user.pcf
pcf["verified_at"] = newJNull()
fields.add(pcf)
if user.verifiedType != none:
var verified = newJObject()
verified["name"] = %"Verified Type"
verified["value"] = %user.verifiedType
verified["verified_at"] = newJNull()
fields.add(verified)
var userJson = newJObject()
userJson["id"] = %user.id
userJson["username"] = %user.username
userJson["acct"] = %user.username
userJson["display_name"] = %user.fullname
userJson["locked"] = %user.protected
userJson["bot"] = %user.bot
userJson["discoverable"] = %true
userJson["indexable"] = %false
userJson["group"] = %false
userJson["created_at"] = %($user.joinDate)
userJson["note"] = %user.bio
userJson["url"] = %(&"{getUrlPrefix(cfg)}/{user.username}")
userJson["uri"] = %(&"{getUrlPrefix(cfg)}/{user.username}")
userJson["avatar"] = %(getUrlPrefix(cfg) & getPicUrl(user.userPic))
userJson["avatar_static"] = %(getUrlPrefix(cfg) & getPicUrl(user.userPic))
userJson["header"] = %(getUrlPrefix(cfg) & getPicUrl(user.banner))
userJson["header_static"] = %(getUrlPrefix(cfg) & getPicUrl(user.banner))
userJson["followers_count"] = %user.followers
userJson["following_count"] = %user.following
userJson["statuses_count"] = %user.tweets
userJson["hide_collections"] = %false
userJson["noindex"] = %false
userJson["emojis"] = %(@[])
userJson["roles"] = %(@[])
userJson["fields"] = %fields
result = userJson

View File

@ -26,8 +26,8 @@ proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
tdiv(class="profile-card-tabs-name-and-follow"): tdiv(class="profile-card-tabs-name-and-follow"):
tdiv(): tdiv():
linkUser(user, class="profile-card-fullname") linkUser(user, class="profile-card-fullname", prefs)
linkUser(user, class="profile-card-username") linkUser(user, class="profile-card-username", prefs)
let following = isFollowing(user.username, prefs.following) let following = isFollowing(user.username, prefs.following)
if not following: if not following:
buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button" buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button"
@ -35,6 +35,20 @@ proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
buttonReferer "/unfollow/" & user.username, "Unfollow", path, "profile-card-follow-button" buttonReferer "/unfollow/" & user.username, "Unfollow", path, "profile-card-follow-button"
tdiv(class="profile-card-extra"): tdiv(class="profile-card-extra"):
if user.bot:
tdiv(class="profile-automated"):
span:
if user.botOwner.len > 0:
icon "cog", "Automated by "
a(href=(&"/{user.botOwner}")): text &"@{user.botOwner}"
else:
icon "cog", "Automated"
if user.pcf.len > 0:
tdiv(class="profile-pcf"):
span:
icon "pcf", &"{user.pcf} account"
if user.bio.len > 0: if user.bio.len > 0:
tdiv(class="profile-bio"): tdiv(class="profile-bio"):
p(dir="auto"): p(dir="auto"):

View File

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat import strutils, strformat
import karax/[karaxdsl, vdom, vstyles] import karax/[karaxdsl, vdom, vstyles]
import ".."/[types, utils] import ".."/[types, utils, formatters]
const smallWebp* = "?name=small&format=webp" const smallWebp* = "?name=small&format=webp"
@ -26,11 +26,13 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
template verifiedIcon*(user: User): untyped {.dirty.} = template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none: if user.verifiedType != VerifiedType.none:
let lower = ($user.verifiedType).toLowerAscii() let lower = ($user.verifiedType).toLowerAscii()
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") buildHtml(tdiv(class=(&"verified-icon {lower}"))):
icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
else: else:
text "" text ""
proc linkUser*(user: User, class=""): VNode = proc linkUser*(user: User, class="", prefs: Prefs): VNode =
let let
isName = "username" notin class isName = "username" notin class
href = "/" & user.username href = "/" & user.username
@ -44,6 +46,10 @@ proc linkUser*(user: User, class=""): VNode =
if user.protected: if user.protected:
text " " text " "
icon "lock", title="Protected account" icon "lock", title="Protected account"
if user.badge.name.len > 0:
span(class="brand-badge"):
a(href=replaceUrls(user.badge.url, prefs), title=user.badge.name):
img(class="brand-badge-image", src=getPicUrl(user.badge.icon), alt=user.badge.name)
proc linkText*(text: string; class=""): VNode = proc linkText*(text: string; class=""): VNode =
let url = if "http" notin text: https & text else: text let url = if "http" notin text: https & text else: text
@ -89,9 +95,9 @@ proc genDate*(pref, state: string): VNode =
input(name=pref, `type`="date", value=state) input(name=pref, `type`="date", value=state)
icon "calendar" icon "calendar"
proc genImg*(url: string; class=""): VNode = proc genImg*(url: string; alt=""; class=""): VNode =
buildHtml(): buildHtml():
img(src=getPicUrl(url), class=class, alt="") img(src=getPicUrl(url), class=class, alt=alt)
proc getTabClass*(query: Query; tab: QueryKind): string = proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active" if query.kind == tab: "tab-item active"

View File

@ -2,6 +2,9 @@
## SPDX-License-Identifier: AGPL-3.0-only ## SPDX-License-Identifier: AGPL-3.0-only
#import strutils, xmltree, strformat, options, unicode #import strutils, xmltree, strformat, options, unicode
#import ../types, ../utils, ../formatters, ../prefs #import ../types, ../utils, ../formatters, ../prefs
## Snowflake ID cutoff for RSS GUID format transition
## Corresponds to approximately December 14, 2025 UTC
#const guidCutoff = 2000000000000000000'i64
# #
#proc getTitle(tweet: Tweet; retweet: string): string = #proc getTitle(tweet: Tweet; retweet: string): string =
#if tweet.pinned: result = "Pinned: " #if tweet.pinned: result = "Pinned: "
@ -25,7 +28,25 @@
#end proc #end proc
# #
#proc getDescription(desc: string; cfg: Config): string = #proc getDescription(desc: string; cfg: Config): string =
Twitter feed for: ${desc}. Generated by ${cfg.hostname} Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
#end proc
#
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
#result = profile.tweets.content
#if profile.pinned.isSome and result.len > 0:
# let pinnedTweet = profile.pinned.get
# var inserted = false
# for threadIdx in 0 ..< result.len:
# if not inserted:
# for tweetIdx in 0 ..< result[threadIdx].len:
# if result[threadIdx][tweetIdx].id < pinnedTweet.id:
# result[threadIdx].insert(pinnedTweet, tweetIdx)
# inserted = true
# end if
# end for
# end if
# end for
#end if
#end proc #end proc
# #
#proc renderRssTweet(tweet: Tweet; cfg: Config): string = #proc renderRssTweet(tweet: Tweet; cfg: Config): string =
@ -35,10 +56,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<p>${text.replace("\n", "<br>\n")}</p> <p>${text.replace("\n", "<br>\n")}</p>
#if tweet.photos.len > 0: #if tweet.photos.len > 0:
# for photo in tweet.photos: # for photo in tweet.photos:
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" /> <img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
# end for # end for
#elif tweet.video.isSome: #elif tweet.video.isSome:
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" /> <a href="${urlPrefix}${tweet.getLink}">
<br>Video<br>
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
</a>
#elif tweet.gif.isSome: #elif tweet.gif.isSome:
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}" # let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}" # let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
@ -51,10 +75,18 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
# end if # end if
#end if #end if
#if tweet.quote.isSome and get(tweet.quote).available: #if tweet.quote.isSome and get(tweet.quote).available:
# let quoteLink = getLink(get(tweet.quote)) # let quoteTweet = get(tweet.quote)
# let quoteLink = urlPrefix & getLink(quoteTweet)
<hr/> <hr/>
<p>Quoting: <a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p> <blockquote>
${renderRssTweet(get(tweet.quote), cfg)} <b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
<p>
${renderRssTweet(quoteTweet, cfg)}
</p>
<footer>
— <cite><a href="${quoteLink}">${quoteLink}</a>
</footer>
</blockquote>
#end if #end if
#end proc #end proc
# #
@ -72,12 +104,17 @@ ${renderRssTweet(get(tweet.quote), cfg)}
# if link in links: continue # if link in links: continue
# end if # end if
# links.add link # links.add link
# let useGlobalGuid = parseBiggestInt(tweet.id) >= guidCutoff
<item> <item>
<title>${getTitle(tweet, retweet)}</title> <title>${getTitle(tweet, retweet)}</title>
<dc:creator>@${tweet.user.username}</dc:creator> <dc:creator>@${tweet.user.username}</dc:creator>
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description> <description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
<pubDate>${getRfc822Time(tweet)}</pubDate> <pubDate>${getRfc822Time(tweet)}</pubDate>
#if useGlobalGuid:
<guid isPermaLink="false">${tweet.id}</guid>
#else:
<guid>${urlPrefix & link}</guid> <guid>${urlPrefix & link}</guid>
#end if
<link>${urlPrefix & link}</link> <link>${urlPrefix & link}</link>
</item> </item>
# end for # end for
@ -108,8 +145,9 @@ ${renderRssTweet(get(tweet.quote), cfg)}
<width>128</width> <width>128</width>
<height>128</height> <height>128</height>
</image> </image>
#if profile.tweets.content.len > 0: #let tweetsList = getTweetsWithPinned(profile)
${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)} #if tweetsList.len > 0:
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
#end if #end if
</channel> </channel>
</rss> </rss>

View File

@ -24,9 +24,9 @@ proc renderSearch*(): VNode =
buildHtml(tdiv(class="panel-container")): buildHtml(tdiv(class="panel-container")):
tdiv(class="search-bar"): tdiv(class="search-bar"):
form(`method`="get", action="/search", autocomplete="off"): form(`method`="get", action="/search", autocomplete="off"):
hiddenField("f", "users") hiddenField("f", "tweets")
input(`type`="text", name="q", autofocus="", input(`type`="text", name="q", autofocus="",
placeholder="Enter username...", dir="auto") placeholder="Search...", dir="auto")
button(`type`="submit"): icon "search" button(`type`="submit"): icon "search"
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode = proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =

View File

@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore: if thread.hasMore:
renderMoreReplies(thread) renderMoreReplies(thread)
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
buildHtml(tdiv(class="replies", id="r")): buildHtml(tdiv(class="replies", id="r")):
var hasReplies = false
var replyCount = 0
for thread in replies.content: for thread in replies.content:
if thread.content.len == 0: continue if thread.content.len == 0: continue
hasReplies = true
replyCount += thread.content.len
renderReplyThread(thread, prefs, path) renderReplyThread(thread, prefs, path)
if replies.bottom.len > 0: if hasReplies and replies.bottom.len > 0:
renderMore(Query(), replies.bottom, focus="#r") if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
renderMore(Query(), replies.bottom, focus="#r")
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
let hasAfter = conv.after.content.len > 0 let hasAfter = conv.after.content.len > 0
@ -45,7 +50,7 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if conv.before.content.len > 0: if conv.before.content.len > 0:
tdiv(class="before-tweet thread-line"): tdiv(class="before-tweet thread-line"):
let first = conv.before.content[0] let first = conv.before.content[0]
if threadId != first.id and (first.replyId > 0 or not first.available): if threadId != first.id and (first.replyId.len > 0 or not first.available):
renderEarlier(conv.before) renderEarlier(conv.before)
for i, tweet in conv.before.content: for i, tweet in conv.before.content:
renderTweet(tweet, prefs, path, index=i) renderTweet(tweet, prefs, path, index=i)
@ -70,6 +75,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning: if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r") renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
renderReplies(conv.replies, prefs, path) renderReplies(conv.replies, prefs, path, conv.tweet)
renderToTop(focus="#m") renderToTop(focus="#m")

View File

@ -55,7 +55,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
renderTweet(tweet, prefs, path, class=(header & "thread"), renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high), showThread=show) index=i, last=(i == thread.high), showThread=show)
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] = proc threadFilter(tweets: openArray[Tweet]; threads: openArray[string]; it: Tweet): seq[Tweet] =
result = @[it] result = @[it]
if it.retweet.isSome or it.replyId in threads: return if it.retweet.isSome or it.replyId in threads: return
for t in tweets: for t in tweets:
@ -74,8 +74,8 @@ proc renderUser*(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(user, class="fullname") linkUser(user, class="fullname", prefs)
linkUser(user, class="username") linkUser(user, class="username", prefs)
tdiv(class="tweet-content media-body", dir="auto"): tdiv(class="tweet-content media-body", dir="auto"):
verbatim replaceUrls(user.bio, prefs) verbatim replaceUrls(user.bio, prefs)
@ -112,20 +112,20 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else: else:
renderNoneFound() renderNoneFound()
else: else:
var retweets: seq[int64] var retweets: seq[string]
for thread in results.content: for thread in results.content:
if thread.len == 1: if thread.len == 1:
let let
tweet = thread[0] tweet = thread[0]
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: ""
if retweetId in retweets or tweet.id in retweets or if retweetId in retweets or tweet.id in retweets or
tweet.pinned and prefs.hidePins: tweet.pinned and prefs.hidePins:
continue continue
var hasThread = tweet.hasThread var hasThread = tweet.hasThread
if retweetId != 0 and tweet.retweet.isSome: if retweetId.len != 0 and tweet.retweet.isSome:
retweets &= retweetId retweets &= retweetId
hasThread = get(tweet.retweet).hasThread hasThread = get(tweet.retweet).hasThread
renderTweet(tweet, prefs, path, showThread=hasThread) renderTweet(tweet, prefs, path, showThread=hasThread)

View File

@ -15,30 +15,40 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
img(class=(prefs.getAvatarClass & " mini"), src=url) img(class=(prefs.getAvatarClass & " mini"), src=url)
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode = proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
let user = tweet.user
buildHtml(tdiv): buildHtml(tdiv):
if pinned: if pinned:
tdiv(class="pinned"): tdiv(class="pinned"):
span: icon "pin", "Pinned Tweet" span: icon "pin", "Pinned"
elif retweet.len > 0: elif retweet.len > 0:
tdiv(class="retweet-header"): tdiv(class="retweet-header"):
span: icon "retweet", retweet & " retweeted" span: icon "retweet", retweet & " retweeted"
tdiv(class="tweet-header"): tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.user.username)): a(class="tweet-avatar", href=("/" & user.username)):
var size = "_bigger" var size = "_bigger"
if not prefs.autoplayGifs and tweet.user.userPic.endsWith("gif"): if not prefs.autoplayGifs and user.userPic.endsWith("gif"):
size = "_400x400" size = "_400x400"
genImg(tweet.user.getUserPic(size), class=prefs.getAvatarClass) genImg(user.getUserPic(size), class=prefs.getAvatarClass)
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
linkUser(tweet.user, class="fullname") linkUser(user, class="fullname", prefs)
linkUser(tweet.user, class="username") linkUser(user, class="username", prefs)
span(class="tweet-date"): span(class="tweet-date"):
a(href=getLink(tweet), title=tweet.getTime): a(href=getLink(tweet), title=tweet.getTime):
text tweet.getShortTime text tweet.getShortTime
if user.pcf.len > 0 or user.bot:
tdiv(class="tweet-label-row"):
if user.bot:
tdiv(class="user-automated"): icon "cog", "Automated"
if user.pcf.len > 0:
tdiv(class="user-pcf"): icon "pcf", &"{user.pcf} account"
proc renderAlbum(tweet: Tweet): VNode = proc renderAlbum(tweet: Tweet): VNode =
let let
groups = if tweet.photos.len < 3: @[tweet.photos] groups = if tweet.photos.len < 3: @[tweet.photos]
@ -49,12 +59,15 @@ proc renderAlbum(tweet: Tweet): VNode =
let margin = if i > 0: ".25em" else: "" let margin = if i > 0: ".25em" else: ""
tdiv(class="gallery-row", style={marginTop: margin}): tdiv(class="gallery-row", style={marginTop: margin}):
for photo in photos: for photo in photos:
tdiv(class="attachment image"): tdiv(class="attachment image", title=photo.description):
let let
named = "name=" in photo url = photo.url
small = if named: photo else: photo & smallWebp named = "name=" in url
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"): small = if named: url else: url & smallWebp
genImg(small) a(href=getOrigPicUrl(url), class="still-image", target="_blank", data-caption=photo.description):
genImg(small, alt=photo.description)
if photo.description.len > 0:
span(class="alt"): text "ALT"
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
case playbackType case playbackType
@ -180,19 +193,16 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',') if stat > 0: insertSep($stat, ',')
else: "" else: ""
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode = proc renderStats(stats: TweetStats; tweet: Tweet): VNode =
buildHtml(tdiv(class="tweet-stats")): buildHtml(tdiv(class="tweet-stats")):
a(href=getLink(tweet)): span(class="tweet-stat", title="Replies", "aria-label"="Replies"): icon "comment", formatStat(stats.replies)
span(class="tweet-stat"): icon "comment", formatStat(stats.replies) span(class="tweet-stat", title="Reposts", "aria-label"="Reposts"): icon "retweet", formatStat(stats.retweets)
a(href=getLink(tweet, false) & "/retweeters"): span(class="tweet-stat"):
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) a(href="/search?q=quoted_tweet_id:" & $tweet.id, title="Quotes", "aria-label"="Quotes"): icon "quote", formatStat(stats.quotes)
a(href="/search?q=quoted_tweet_id:" & $tweet.id): span(class="tweet-stat", title="Likes", "aria-label"="Likes"): icon "heart", formatStat(stats.likes)
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes) span(class="tweet-stat", title="Bookmarks", "aria-label"="Bookmarks"): icon "bookmark", formatStat(stats.bookmarks)
a(): if stats.views > -1:
span(class="tweet-stat"): icon "heart", formatStat(stats.likes) span(class="tweet-stat", title="Views", "aria-label"="Views"): icon "eye", formatStat(stats.views)
a(href=getLink(tweet)):
if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',')
proc renderReply(tweet: Tweet): VNode = proc renderReply(tweet: Tweet): VNode =
buildHtml(tdiv(class="replying-to")): buildHtml(tdiv(class="replying-to")):
@ -220,7 +230,8 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="quote-media-container")): buildHtml(tdiv(class="quote-media-container")):
if quote.photos.len > 0: if quote.photos.len > 0:
renderAlbum(quote) renderAlbum(quote)
elif quote.video.isSome:
if quote.video.isSome:
renderVideo(quote.video.get(), prefs, path) renderVideo(quote.video.get(), prefs, path)
elif quote.gif.isSome: elif quote.gif.isSome:
renderGif(quote.gif.get(), prefs) renderGif(quote.gif.get(), prefs)
@ -242,8 +253,8 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
tdiv(class="tweet-name-row"): tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"): tdiv(class="fullname-and-username"):
renderMiniAvatar(quote.user, prefs) renderMiniAvatar(quote.user, prefs)
linkUser(quote.user, class="fullname") linkUser(quote.user, class="fullname", prefs)
linkUser(quote.user, class="username") linkUser(quote.user, class="username", prefs)
span(class="tweet-date"): span(class="tweet-date"):
a(href=getLink(quote), title=quote.getTime): a(href=getLink(quote), title=quote.getTime):
@ -271,6 +282,11 @@ proc renderCommunityNote(note: BirdwatchNote; prefs: Prefs): VNode =
tdiv(class="community-note-text", dir="auto"): tdiv(class="community-note-text", dir="auto"):
verbatim replaceUrls(note.text, prefs) verbatim replaceUrls(note.text, prefs)
proc renderLimitedActions(action: LimitedActions): VNode =
buildHtml(tdiv(class="limited-actions")):
tdiv(class="limited-actions-title"): text action.title
tdiv(class="limited-actions-text"): text action.text
proc renderLocation*(tweet: Tweet): string = proc renderLocation*(tweet: Tweet): string =
let (place, url) = tweet.getLocation() let (place, url) = tweet.getLocation()
if place.len == 0: return if place.len == 0: return
@ -316,7 +332,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
a(class="tweet-link", href=getLink(tweet)) a(class="tweet-link", href=getLink(tweet))
tdiv(class="tweet-body"): tdiv(class="tweet-body"):
var views = ""
renderHeader(tweet, retweet, pinned, prefs) renderHeader(tweet, retweet, pinned, prefs)
if not afterTweet and index == 0 and tweet.reply.len > 0 and if not afterTweet and index == 0 and tweet.reply.len > 0 and
@ -338,12 +353,11 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.photos.len > 0: if tweet.photos.len > 0:
renderAlbum(tweet) renderAlbum(tweet)
elif tweet.video.isSome:
if tweet.video.isSome:
renderVideo(tweet.video.get(), prefs, path) renderVideo(tweet.video.get(), prefs, path)
views = tweet.video.get().views
elif tweet.gif.isSome: elif tweet.gif.isSome:
renderGif(tweet.gif.get(), prefs) renderGif(tweet.gif.get(), prefs)
views = "GIF"
if tweet.poll.isSome: if tweet.poll.isSome:
renderPoll(tweet.poll.get()) renderPoll(tweet.poll.get())
@ -351,7 +365,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.quote.isSome: if tweet.quote.isSome:
renderQuote(tweet.quote.get(), prefs, path) renderQuote(tweet.quote.get(), prefs, path)
if mainTweet and tweet.birdwatch.isSome: if tweet.birdwatch.isSome:
renderCommunityNote(tweet.birdwatch.get(), prefs) renderCommunityNote(tweet.birdwatch.get(), prefs)
if mainTweet: if mainTweet:
@ -361,12 +375,15 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderMediaTags(tweet.mediaTags) renderMediaTags(tweet.mediaTags)
if not prefs.hideTweetStats: if not prefs.hideTweetStats:
renderStats(tweet.stats, views, tweet) renderStats(tweet.stats, tweet)
if showThread: if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)): a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
text "Show this thread" text "Show this thread"
if mainTweet and tweet.limitedActions.isSome:
renderLimitedActions(tweet.limitedActions.get())
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string = proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req) renderHead(prefs, cfg, req)