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)
proxy = "" # http/https url, SOCKS proxies are not supported
proxyAuth = ""
tokenCount = 10
# 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
disableTid = false # enable this if cookie-based auth is failing
# Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,16 +1,27 @@
@font-face {
font-family: 'fontello';
src: url('/fonts/fontello.eot?21002321');
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'),
url('/fonts/fontello.woff2?21002321') format('woff2'),
url('/fonts/fontello.woff?21002321') format('woff'),
url('/fonts/fontello.ttf?21002321') format('truetype'),
url('/fonts/fontello.svg?21002321#fontello') format('svg');
font-family: "fontello";
src: url("/fonts/fontello.eot?76162212");
src:
url("/fonts/fontello.eot?76162212#iefix") format("embedded-opentype"),
url("/fonts/fontello.woff2?76162212") format("woff2"),
url("/fonts/fontello.woff?76162212") format("woff"),
url("/fonts/fontello.ttf?76162212") format("truetype"),
url("/fonts/fontello.svg?76162212#fontello") format("svg");
font-weight: normal;
font-style: normal;
}
[class^="icon-"]:before, [class*=" icon-"]:before {
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
/* 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-style: normal;
font-weight: normal;
@ -19,7 +30,9 @@
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: 0.2em;
text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
@ -28,26 +41,81 @@
/* fix buttons height, for twitter bootstrap */
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 */
-webkit-font-smoothing: antialiased;
-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-quote:before { content: '\275e'; } /* '❞' */
.icon-comment:before { content: '\e802'; } /* '' */
.icon-ok:before { content: '\e803'; } /* '' */
.icon-play:before { content: '\e804'; } /* '' */
.icon-link:before { content: '\e805'; } /* '' */
.icon-calendar:before { content: '\e806'; } /* '' */
.icon-location:before { content: '\e807'; } /* '' */
.icon-picture:before { content: '\e809'; } /* '' */
.icon-lock:before { content: '\e80a'; } /* '' */
.icon-down:before { content: '\e80b'; } /* '' */
.icon-retweet:before { content: '\e80d'; } /* '' */
.icon-search:before { content: '\e80e'; } /* '' */
.icon-pin:before { content: '\e80f'; } /* '' */
.icon-cog:before { content: '\e812'; } /* '' */
.icon-rss-feed:before { content: '\e813'; } /* '' */
.icon-info:before { content: '\f128'; } /* '' */
.icon-bird:before { content: '\f309'; } /* '' */
.icon-heart:before {
content: "\2665";
} /* '♥' */
.icon-quote:before {
content: "\275e";
} /* '❞' */
.icon-ok:before {
content: "\e800";
} /* '' */
.icon-play:before {
content: "\e801";
} /* '' */
.icon-comment:before {
content: "\e802";
} /* '' */
.icon-link:before {
content: "\e803";
} /* '' */
.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"?>
<!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">
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
<defs>
<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" />
<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="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="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>
</defs>
</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
privacy and performance. The source is available on GitHub at
<https://github.com/zedeus/nitter>
**This instance is running a fork, whose source can be found at**
<https://git.eir-nya.gay/eir/nitter>.
* No JavaScript or ads
* All requests go through the backend, client never talks to Twitter
* 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
My fork is based on [Cynthia Foxwell's fork](https://gitlab.com/Cynosphere/nitter).
Nitter is created by Zedeus, whose source can be found at <https://github.com/zedeus/nitter>.
Nitter's GitHub wiki contains
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
maintained by the community.
The rest of this page is copied from Cynthia's fork:
## Why use Nitter?
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
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
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).
In the future a simple account system will be added that lets you follow Twitter
users, allowing you to have a clean chronological timeline without needing a
Twitter account.
## Donating
Liberapay: <https://liberapay.com/zedeus> \
Patreon: <https://patreon.com/nitter> \
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
## Contact
Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org).
> # About
>
> Nitter is a free and open source alternative Twitter front-end focused on
> privacy and performance.
>
> * No JavaScript or ads
> * All requests go through the backend, client never talks to Twitter
> * 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 (source code below)
>
> Nitter's GitHub wiki contains
> [instances](https://github.com/zedeus/nitter/wiki/Instances) and
> [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
> maintained by the community.
>
> ### Fork features by Cynthia Foxwell
>
> * Localized following via cookies (list exportable and editable in preferences)
> * Image zooming/carousel (requires JavaScript)
> * Up to date Twitter features, e.g. Community Notes
> * Embeds for chat services on-par with services like [FxTwitter](https://github.com/FixTweet/FxTwitter) and [vxTwitter](https://github.com/dylanpdx/BetterTwitFix)
>
> ## Why use Nitter?
>
> It's impossible to use Twitter without JavaScript enabled. For privacy-minded
> 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
> 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",
"short_name": "Nitter",
"name": "Kitter",
"short_name": "Kitter",
"icons": [
{
"src": "/android-chrome-192x192.png",
@ -18,7 +18,7 @@
"type": "image/png"
}
],
"theme_color": "#333333",
"background_color": "#333333",
"theme_color": "#faaaab",
"background_color": "#faaaab",
"display": "standalone"
}

View File

@ -1,59 +1,114 @@
# SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
import asyncdispatch, httpclient, strutils, sequtils, sugar
import packedjson
import types, query, formatters, consts, apiutils, parser
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.} =
if username.len == 0: return
let
variables = """{"screen_name": "$1"}""" % username
params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUser ? params, Api.userScreenName)
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/$1""" % username)
let js = await fetchRaw(userUrl(username), headers)
result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} =
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
variables = """{"rest_id": "$1"}""" % id
params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUserById ? params, Api.userRestId)
url = apiReq(graphUserById, """{"userId":"$1"}""" % id)
js = await fetchRaw(url, headers)
result = parseGraphUser(js)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
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
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = if kind == TimelineKind.media: userMediaVariables % [id, cursor] else: userTweetsVariables % [id, cursor]
fieldToggles = """{"withArticlePlainText":true}"""
params = {"variables": variables, "features": gqlFeatures, "fieldToggles": fieldToggles}
(url, apiId) = case kind
of TimelineKind.tweets: (graphUserTweets, Api.userTweets)
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
of TimelineKind.media: (graphUserMedia, Api.userMedia)
js = await fetch(url ? params, apiId)
url = case kind
of TimelineKind.tweets: userTweetsUrl(id, cursor)
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
of TimelineKind.media: mediaUrl(id, cursor)
js = await fetch(url, headers)
result = parseGraphTimeline(js, if kind == TimelineKind.media: "" else: "user", after)
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = listTweetsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphListTweets ? params, Api.listTweets)
url = apiReq(graphListTweets, restIdVars % [id, cursor])
js = await fetch(url)
result = parseGraphTimeline(js, "list", after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let
variables = %*{"screenName": name, "listSlug": list}
params = {"variables": $variables, "features": gqlFeatures}
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
url = apiReq(graphListBySlug, $variables)
js = await fetch(url)
result = parseGraphList(js)
proc getGraphList*(id: string): Future[List] {.async.} =
let
variables = """{"listId": "$1"}""" % id
params = {"variables": variables, "features": gqlFeatures}
result = parseGraphList(await fetch(graphListById ? params, Api.list))
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
js = await fetch(url)
result = parseGraphList(js)
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
if list.id.len == 0: return
@ -67,77 +122,45 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
}
if after.len > 0:
variables["cursor"] = % after
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
proc getFavorites*(id: string; cfg: Config; after=""): Future[Profile] {.async.} =
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)
url = apiReq(graphListMembers, $variables)
js = await fetchRaw(url)
result = parseGraphListMembers(js, after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/status/$1""" % id)
let
variables = """{"rest_id": "$1"}""" % id
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphTweetResult ? params, Api.tweetResult)
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
js = await fetch(url, headers)
result = parseGraphTweetResult(js)
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
proc getGraphTweet*(id: string; after=""): Future[Conversation] {.async.} =
if id.len == 0: return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/status/$1""" % id)
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = tweetVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphTweet ? params, Api.tweetDetail)
js = await fetch(tweetDetailUrl(id, cursor), headers)
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.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = followVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowing ? params, Api.following)
js = await fetch(apiReq(graphFollowing, followVars % [id, cursor]))
result = parseGraphFollowTimeline(js, id)
proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = followVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowers ? params, Api.followers)
js = await fetch(apiReq(graphFollowers, followVars % [id, cursor]))
result = parseGraphFollowTimeline(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
@ -158,15 +181,16 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
variables = %*{
"rawQuery": q,
"count": 20,
"query_source": "typed_query",
"product": "Latest",
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false
"withGrokTranslatedBio": false
}
if after.len > 0:
variables["cursor"] = % after
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
let
url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[Tweets](js, after)
result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
@ -177,26 +201,24 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
variables = %*{
"rawQuery": query.text,
"count": 20,
"query_source": "typed_query",
"product": "People",
"withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false
"withGrokTranslatedBio": false
}
if after.len > 0:
variables["cursor"] = % after
result.beginning = false
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
result = parseGraphSearch[User](await fetch(url, Api.search), after)
let
url = apiReq(graphSearchTimeline, $variables)
js = await fetch(url)
result = parseGraphSearch[User](js, after)
result.query = query
proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} =
if name.len == 0: return
let
ps = genParams({"screen_name": name, "trim_user": "true"},
count="18", ext=false)
url = photoRail ? ps
result = parsePhotoRail(await fetch(url, Api.photoRail))
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
if id.len == 0: return
let js = await fetch(mediaUrl(id, ""))
result = parseGraphPhotoRail(js)
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
let client = newAsyncHttpClient(maxRedirects=0)

View File

@ -1,7 +1,7 @@
# 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 types, tokens, consts, parserutils, http_pool
import types, consts, parserutils, http_pool, tid
import experimental/types/common
import config
@ -9,66 +9,63 @@ const
rlRemaining = "x-rate-limit-remaining"
rlReset = "x-rate-limit-reset"
var pool: HttpPool
var
pool: HttpPool
disableTid: bool
proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
count="20"; ext=true): seq[(string, string)] =
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 setDisableTid*(disable: bool) =
disableTid = disable
proc genHeaders*(token: Token = nil): HttpHeaders =
result = newHttpHeaders({
"connection": "keep-alive",
"authorization": auth,
"content-type": "application/json",
"x-guest-token": if token == nil: "" else: token.tok,
"x-twitter-active-user": "yes",
"authority": "api.twitter.com",
"accept-encoding": "gzip",
"accept-language": "en-US,en;q=0.9",
"accept": "*/*",
"DNT": "1"
})
proc toUrl(req: ApiReq): Uri =
let c = req.cookie
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
template updateToken() =
if resp.headers.hasKey(rlRemaining):
proc rateLimitError*(): ref RateLimitError =
newException(RateLimitError, "rate limited")
proc genHeaders*(): HttpHeaders =
let
remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset])
token.setRateLimit(api, remaining, reset)
t = getTime()
ffVersion = floor(124 + (t.toUnix() - 1710892800) / 2419200)
result = newHttpHeaders({
"Connection": "keep-alive",
"Authorization": bearerToken,
"Content-Type": "application/json",
"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-auth-type": "OAuth2Session",
"x-twitter-client-language": "en"
}, true)
template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
once:
pool = HttpPool()
var token = await getToken(api)
if token.tok.len == 0:
raise rateLimitError()
if len(cfg.cookieHeader) != 0:
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:
additional_headers.add("x-csrf-token", cfg.xCsrfToken)
try:
var resp: AsyncResponse
var headers = genHeaders(token)
var headers = genHeaders()
for key, value in additional_headers.pairs():
headers.add(key, value)
pool.use(headers):
template getContent =
resp = await c.get($url)
@ -87,7 +84,6 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
let
remaining = parseInt(resp.headers[rlRemaining])
reset = parseInt(resp.headers[rlReset])
token.setRateLimit(api, remaining, reset)
if result.len > 0:
if resp.headers.getOrDefault("content-encoding") == "gzip":
@ -96,75 +92,55 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
if result.startsWith("{\"errors"):
let errors = result.fromJson(Errors)
if errors in {expiredToken, badToken, authorizationError}:
echo "fetch error: ", errors
release(token, invalid=true)
raise rateLimitError()
elif errors in {rateLimited}:
# rate limit hit, resets after 24 hours
#setLimited(account, api)
raise rateLimitError()
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()
fetchBody
release(token, used=true)
if resp.status == $Http400:
echo "ERROR 400, ", url.path, ": ", result
raise newException(InternalError, $url)
except InternalError as e:
raise e
except BadClientError as e:
release(token, used=true)
raise e
except OSError as e:
raise e
except ProtocolError as e:
raise e
except Exception as e:
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
if "length" notin e.msg and "descriptor" notin e.msg:
release(token, invalid=true)
echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url.path
raise rateLimitError()
template retry(bod) =
try:
bod
except RateLimitError:
echo "[accounts] Rate limited, retrying ", api, " request..."
except ProtocolError:
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:
var body: string
let url = req.toUrl()
fetchImpl(body, additional_headers):
if body.startsWith('{') or body.startsWith('['):
result = parseJson(body)
else:
echo resp.status, ": ", body, " --- url: ", url
echo resp.status, " - non-json for: ", url, ", body: ", result
result = newJNull()
updateToken()
let error = result.getError
if error in {expiredToken, badToken}:
echo "fetch error: ", result.getError
release(token, invalid=true)
echo "Fetch error, API: ", url.path, ", error: ", result.getError
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:
let url = req.toUrl()
fetchImpl(result, additional_headers):
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)
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 =
newException(RateLimitError, "rate limited")
proc isLimited(account: GuestAccount; api: Api): bool =
proc isLimited(account: GuestAccount; req: ApiReq): bool =
if account.isNil:
return true
@ -157,9 +157,9 @@ proc release*(account: GuestAccount) =
if account.isNil: return
dec account.pending
proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} =
proc getGuestAccount*(req: ApiReq): Future[GuestAccount] {.async.} =
for i in 0 ..< accountPool.len:
if result.isReady(api): break
if result.isReady(req): break
result = accountPool.sample()
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),
staticDir: cfg.get("Server", "staticDir", "./public"),
title: cfg.get("Server", "title", "Nitter"),
oembedColor: cfg.get("Server", "oembedColor", "#1F1F1F"),
hostname: cfg.get("Server", "hostname", "nitter.net"),
# Cache
@ -42,6 +43,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", ""),
disableTid: cfg.get("Config", "disableTid", false),
cookieHeader: cfg.get("Config", "cookieHeader", ""),
xCsrfToken: cfg.get("Config", "xCsrfToken", "")
)

View File

@ -1,105 +1,77 @@
# SPDX-License-Identifier: AGPL-3.0-only
import uri, sequtils, strutils
import strutils
const
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
api = parseUri("https://api.twitter.com")
activate* = $(api / "1.1/guest/activate.json")
photoRail* = api / "1.1/statuses/media_timeline.json"
timelineApi = api / "2/timeline"
graphql = api / "graphql"
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia"
graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail"
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
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
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
graphUserById* = "Bbaot8ySMtJD7K2t01gW7A/UserByRestId"
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
graphUserTweets* = "lZRf8IC-GTuGxDwcsHW8aw/UserTweets"
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphUserMedia* = "1D04dx9H2pseMQAbMjXTvQ/UserMedia"
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
graphTweetDetail* = "6QzqakNMdh_YzBAR9SYPkQ/TweetDetail"
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
graphFollowers* = "Efm7xwLreAw77q2Fq7rX-Q/Followers"
graphFollowing* = "e0UtTAwQqgLKBllQxMgVxQ/Following"
gqlFeatures* = """{
"android_graphql_skip_api_media_color_palette": false,
"articles_preview_enabled": false,
"blue_business_profile_image_shape_enabled": false,
"c9s_tweet_anatomy_moderator_badge_enabled": false,
"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,
"rweb_video_screen_enabled": false,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"responsive_web_profile_redirect_enabled": false,
"rweb_tipjar_consumption_enabled": true,
"verified_phone_label_enabled": false,
"vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": false
"creator_subscriptions_tweet_preview_api_enabled": true,
"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", "")
tweetVariables* = """{
"focalTweetId": "$1",
tweetVars* = """{
"postId": "$1",
$2
"includeHasBirdwatchNotes": false,
"includePromotedContent": false,
@ -108,47 +80,57 @@ const
"withV2Timeline": true
}""".replace(" ", "").replace("\n", "")
# oldUserTweetsVariables* = """{
# "userId": "$1", $2
# "count": 20,
# "includePromotedContent": false,
# "withDownvotePerspective": false,
# "withReactionsMetadata": false,
# "withReactionsPerspective": false,
# "withVoice": false,
# "withV2Timeline": true
# }
# """.replace(" ", "").replace("\n", "")
userTweetsVariables* = """{
"rest_id": "$1",
tweetDetailVars* = """{
"focalTweetId": "$1",
$2
"count": 20
"referrer": "profile",
"with_rux_injections": false,
"rankingMode": "Relevance",
"includePromotedContent": true,
"withCommunity": true,
"withQuickPromoteEligibilityTweetFields": true,
"withBirdwatchNotes": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "")
listTweetsVariables* = """{
"rest_id": "$1",
$2
restIdVars* = """{
"rest_id": "$1", $2
"count": 20
}""".replace(" ", "").replace("\n", "")
}"""
reactorsVariables* = """{
"tweetId": "$1",
$2
userMediaVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false
"includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true
}""".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",
$2
"count": 20,
"includePromotedContent": false
}""".replace(" ", "").replace("\n", "")
userMediaVariables* = """{
"userId": "$1",
$2
"count": 20,
"includePromotedContent": false
}""".replace(" ", "").replace("\n", "")
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""

View File

@ -1,21 +1,53 @@
import options
import options, strutils
import jsony
import user, ../types/[graphuser, graphlistmembers]
import user, utils, ../types/[graphuser, graphlistmembers]
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 =
if json.len == 0 or json[0] != '{':
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)
result = raw.data.userResult.result.legacy
result.id = raw.data.userResult.result.restId
if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified:
result.verifiedType = blue
result = parseUserResult(userResult)
proc parseGraphListMembers*(json, cursor: string): Result[User] =
result = Result[User](
@ -31,7 +63,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
of TimelineTimelineItem:
let userResult = entry.content.itemContent.userResults.result
if userResult.restId.len > 0:
result.content.add userResult.legacy
result.content.add parseUserResult(userResult)
of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom":
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:
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
result.pinnedTweet = raw.pinnedTweetIdsStr[0]
result.expandUserEntities(raw)

View File

@ -1,15 +1,48 @@
import options
from ../../types import User
import options, strutils
from ../../types import User, VerifiedType
type
GraphUser* = object
data*: tuple[userResult: UserData]
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
UserData* = object
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
restId*: string
isBlueVerified*: bool
core*: UserCore
avatar*: UserAvatar
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"
tco = "https://t.co"
twitter = parseUri("https://twitter.com")
sameProto = "//"
let
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?(twitter|x)\.com"
twLinkRegex = re"""<a href="https:\/\/(twitter|x).com([^"]+)">(twitter|x)\.com(\S+)</a>"""
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}]"
proc getUrlPrefix*(cfg: Config): string =
if cfg.useHttps: https & cfg.hostname
else: "http://" & cfg.hostname
var proto = "http"
if cfg.useHttps: proto &= "s"
proto &= "://"
result = proto & cfg.hostname
proc shortLink*(text: string; length=28): string =
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:
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(cards, prefs.replaceTwitter & "/cards")
result = result.replace(twRegex, prefs.replaceTwitter)
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):
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
@ -150,7 +153,7 @@ proc getShortTime*(tweet: Tweet): string =
result = "now"
proc getLink*(tweet: Tweet; focus=true): string =
if tweet.id == 0: return
if tweet.id.len == 0: return
var username = tweet.user.username
if username.len == 0:
username = "i"

View File

@ -6,14 +6,15 @@ from htmlgen import a
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 routes/[
preferences, timeline, status, media, search, rss, list, #debug,
unsupported, embed, resolver, router_utils, home, follow, twitter_api]
preferences, timeline, status, media, search, list, rss, #debug,
unsupported, embed, resolver, router_utils, home, follow, twitter_api,
activityspoof]
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")
@ -33,6 +34,7 @@ setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth)
setDisableTid(cfg.disableTid)
initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg)
@ -51,6 +53,7 @@ createEmbedRouter(cfg)
createRssRouter(cfg)
#createDebugRouter(cfg)
createTwitterApiRouter(cfg)
createActivityPubRouter(cfg)
settings:
port = Port(cfg.port)
@ -78,7 +81,7 @@ routes:
error InternalError:
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(
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
@ -102,6 +105,6 @@ routes:
extend preferences, ""
extend resolver, ""
extend embed, ""
#extend debug, ""
extend activityspoof, ""
extend api, ""
extend unsupported, ""

View File

@ -2,7 +2,7 @@
import strutils, options, times, math
import packedjson, packedjson/deserialiser
import types, parserutils, utils
import experimental/parser/unifiedcard
import experimental/parser/[unifiedcard, utils]
import std/tables
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
@ -24,17 +24,52 @@ proc parseUser(js: JsonNode; id=""): User =
media: js{"media_count"}.getInt,
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
protected: js{"protected"}.getBool,
joinDate: js{"created_at"}.getTime
joinDate: parseTwitterDate(js{"created_at"}.getStr)
)
result.expandUserEntities(js)
proc parseGraphUser(js: JsonNode): User =
var user = js{"user_result", "result"}
proc parseGraphUser*(js: JsonNode): User =
var user = js{"data", "user", "result"}
if user.isNull:
user = ? js{"user_results", "result"}
user = js{"user_results", "result"}
if user.isNull:
user = js{"user_result", "result"}
result = parseUser(user{"legacy"})
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):
result.verifiedType = blue
@ -205,9 +240,10 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
if js.isNull: return
result = Tweet(
id: js{"id_str"}.getId,
threadId: js{"conversation_id_str"}.getId,
replyId: js{"in_reply_to_status_id_str"}.getId,
id: js{"id_str"}.getStr,
threadId: js{"conversation_id_str"}.getStr,
replyId: js{"in_reply_to_status_id_str"}.getStr,
replyHandle: js{"in_reply_to_screen_name"}.getStr,
text: js{"full_text"}.getStr,
time: js{"created_at"}.getTime,
hasThread: js{"self_thread"}.notNull,
@ -217,22 +253,23 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
replies: js{"reply_count"}.getInt,
retweets: js{"retweet_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
if result.hasThread and result.threadId == 0:
result.threadId = js{"self_thread", "id_str"}.getId
if result.hasThread and result.threadId.len == 0:
result.threadId = js{"self_thread", "id_str"}.getStr
if "retweeted_status" in js:
result.retweet = some Tweet()
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
with rt, js{"retweeted_status_id_str"}:
result.retweet = some Tweet(id: rt.getId)
result.retweet = some Tweet(id: rt.getStr)
return
# graphql
@ -249,7 +286,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
let name = jsCard{"name"}.getStr
if "poll" 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)
elif name == "amplify":
@ -263,7 +302,10 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
for m in jsMedia:
case m{"type"}.getStr
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":
result.video = some(parseVideo(m))
with user, m{"additional_media_info", "source_user"}:
@ -318,11 +360,10 @@ proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
result.content.add @[parsed]
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 =
let intId = if id.len > 0: parseBiggestInt(id) else: 0
result = global.tweets.getOrDefault(id, Tweet(id: intId))
result = global.tweets.getOrDefault(id, Tweet(id: id))
if result.quote.isSome:
let quote = get(result.quote).id
@ -409,23 +450,6 @@ proc parseTimeline*(js: JsonNode; after=""): Profile =
else:
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 =
if js.kind == JNull:
return Tweet()
@ -442,7 +466,17 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
of "TweetPreviewDisplay":
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
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:
discard
@ -457,7 +491,7 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
jsCard["binding_values"] = values
result = parseTweet(js{"legacy"}, jsCard)
result.id = js{"rest_id"}.getId
result.id = js{"rest_id"}.getStr
result.user = parseGraphUser(js{"core"})
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
@ -467,13 +501,48 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
with communityNote, js{"birdwatch_pivot"}:
let title = communityNote{"title"}.getStr
let note = BirdwatchNote(
id: communityNote{"note", "rest_id"}.getId,
title: communityNote{"title"}.getStr,
id: communityNote{"note", "rest_id"}.getStr,
title: title,
)
note.expandBirdwatchEntities(communityNote{"subtitle"})
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] =
for t in js{"content", "items"}:
let entryId = t{"entryId"}.getStr
@ -481,7 +550,7 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
let cursor = t{"item", "content", "value"}
result.thread.cursor = cursor.getStr
result.thread.hasMore = true
elif "tweet" in entryId:
elif "tweet" in entryId and "promoted-" notin entryId:
let
isLegacy = t{"item"}.hasKey("itemContent")
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
@ -504,23 +573,25 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
if instructions.len == 0:
return
for e in instructions[0]{"entries"}:
for i in instructions:
if i{"type"}.getStr == "TimelineAddEntries":
for e in i{"entries"}:
let entryId = e{"entryId"}.getStr
if entryId.startsWith("tweet"):
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, true)
if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId())
tweet.id = entryId.getId()
if $tweet.id == tweetId:
if tweet.id == tweetId:
result.tweet = tweet
else:
result.before.content.add tweet
elif entryId.startsWith("tombstone"):
let id = entryId.getId()
let tweet = Tweet(
id: parseBiggestInt(id),
id: id,
available: false,
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
)
@ -529,42 +600,26 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
result.tweet = tweet
else:
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)
if self:
result.after = thread
else:
result.replies.content.add thread
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 =
result = Profile(tweets: Timeline(beginning: after.len == 0))
let 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"}
if instructions.len == 0:
return
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":
for e in i{"entries"}:
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"}:
let tweet = parseGraphTweet(tweetResult, false)
if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId())
tweet.id = 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
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
if after.len == 0 and i{"type"}.getStr == "TimelinePinEntry":
with tweetResult, i{"entry", "content", "itemContent", "tweet_results", "result"}:
let tweet = parseGraphTweet(tweetResult, false)
tweet.pinned = true
if not tweet.available and tweet.tombstone.len == 0:
let entryId = i{"entry", "entryId"}.getEntryId
if entryId.len > 0:
tweet.id = parseBiggestInt(entryId)
tweet.id = entryId
result.pinned = some tweet
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"}:
let tweet = parseGraphTweet(tweetRes)
if not tweet.available:
tweet.id = parseBiggestInt(entryId.getId())
tweet.id = entryId.getId()
result.content.add tweet
elif T is User:
if entryId.startsWith("user"):

View File

@ -48,7 +48,13 @@ template with*(ident; value: JsonNode; body): untyped =
if notNull(value): body
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 =
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)
if idx > -1 and name != replyTo:
tweet.reply.delete idx
elif idx == -1 and tweet.replyId != 0:
elif idx == -1 and tweet.replyId.len != 0:
tweet.reply.add name
replacements.deduplicate
@ -303,7 +309,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
var replyTo = ""
if tweet.replyId != 0:
if tweet.replyId.len != 0:
with reply, js{"in_reply_to_screen_name"}:
replyTo = reply.getStr
tweet.reply.add replyTo

View File

@ -59,6 +59,9 @@ genPrefs:
theme(select, "Nitter"):
"Theme"
eirResources(checkbox, true):
"Some extra silly js I added, like cursors :3"
infiniteScroll(checkbox, false):
"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 userKey(name: string): string = "p:" & name
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.} =
pool.withAcquire(r):
@ -86,7 +87,7 @@ proc cache*(data: List) {.async.} =
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
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.} =
if data.username.len == 0: return
@ -96,10 +97,15 @@ proc cache*(data: User) {.async.} =
dawait r.setEx(name.userKey, baseCacheTime, compress(toFlatty(data)))
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):
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.} =
let key = "rss:" & query
pool.withAcquire(r):
@ -114,7 +120,13 @@ template deserialize(data, T) =
except:
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)
pool.withAcquire(r):
result = await r.hGet(name.uidKey, name)
@ -133,13 +145,16 @@ proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
elif fetch:
result = await getGraphUser(username)
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.} =
let
key = "i:" & userId
username = await get(key)
if username != redisNil:
if username != redisNil and username.len > 0:
result = username
else:
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:
await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
# if id == 0: return
# 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 getCachedTweet*(id: string; after=""): Future[Conversation] {.async.} =
if id.len == 0: return
let tweet = await get(id.tweetKey)
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
if name.len == 0: return
let rail = await get("pr:" & toLower(name))
if tweet != redisNil:
result = deserializeConversation(tweet)
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:
rail.deserialize(PhotoRail)
else:
result = await getPhotoRail(name)
await cache(result, name)
result = await getPhotoRail(id)
await cache(result, id)
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
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 router_utils
import ".."/[types, redis_cache, api]
import ".."/[types, api, redis_cache]
import ../views/[general, timeline, list]
template respList*(list, timeline, title, vnode: typed) =
@ -20,6 +20,14 @@ template respList*(list, timeline, title, vnode: typed) =
proc title*(list: List): string =
&"@{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) =
router list:
get "/@name/lists/@slug/?":

View File

@ -141,3 +141,6 @@ proc createMediaRouter*(cfg: Config) =
content = proxifyVideo(vid, cookiePref(proxyVideos))
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 router_utils
import ".."/[types, api]
import ".."/[types, api, formatters]
import ../views/general
template respResolved*(url, kind: string): untyped =
template respResolved*(url, kind: string; prefs: Prefs): untyped =
let u = url
if u.len == 0:
resp showError("Invalid $1 link" % kind, cfg)
else:
redirect(u)
redirect(replaceUrls(u, prefs))
proc createResolverRouter*(cfg: Config) =
router resolver:
get "/cards/@card/@id":
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
respResolved(await resolve(url, cookiePrefs()), "card")
let
prefs = cookiePrefs()
url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
respResolved(await resolve(url, prefs), "card", prefs)
get "/t.co/@url":
let url = "https://t.co/" & @"url"
respResolved(await resolve(url, cookiePrefs()), "t.co")
let
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
import asyncdispatch, strutils, sequtils, uri, options, sugar
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
import jester, karax/vdom
import router_utils
import ".."/[types, formatters, api]
import ../views/[general, status, search]
import ".."/[types, formatters, api, redis_cache]
import ../views/[general, status, mastoapi]
export uri, sequtils, options, sugar
export json, uri, sequtils, options, sugar, times
export router_utils
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) =
router status:
@ -30,16 +43,86 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, ""
resp $renderReplies(replies, prefs, getPath())
if @"reactors" == "favoriters":
resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
request, cfg, prefs)
elif @"reactors" == "retweeters":
resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
request, cfg, prefs)
#if @"reactors" == "favoriters":
# resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
# request, cfg, prefs)
#elif @"reactors" == "retweeters":
# resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
# request, cfg, prefs)
get "/@name/status/@id/?":
get "/@name/status/@id/?@m?/?@i?/?":
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):
resp Http404, showError("Invalid tweet ID", cfg)
@ -53,56 +136,151 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, ""
resp $renderReplies(replies, prefs, getPath())
let conv = await getTweet(id, getCursor())
let conv = await getCachedTweet(id, getCursor())
if conv == nil:
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"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone
resp Http404, showError(error, cfg)
let
title = pageTitle(conv.tweet)
ogTitle = pageTitle(conv.tweet.user)
desc = conv.tweet.text
avatar = conv.tweet.user.userPic
time = some(conv.tweet.time)
tweet = conv.tweet
title = pageTitle(tweet)
ogTitle = pageTitle(tweet.user)
desc = tweet.text
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
images = conv.tweet.photos
images = tweet.photos
video = ""
context = ""
contextUrl = ""
if conv.tweet.video.isSome():
let videoObj = get(conv.tweet.video)
images = @[videoObj.thumb]
if tweet.quote.isSome():
let
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)
# idk why this wont sort when it sorts everywhere else
#video = vars.sortedByIt(it.bitrate)[^1].url
video = vars[^1].url
elif conv.tweet.gif.isSome():
let gif = get(conv.tweet.gif)
images = @[gif.thumb]
video = getPicUrl(gif.url)
#elif conv.tweet.card.isSome():
# let card = conv.tweet.card.get()
elif tweet.gif.isSome():
let gif = get(tweet.gif)
images.add(Image(url:gif.thumb))
video = getUrlPrefix(cfg) & getPicUrl(gif.url)
#elif tweet.card.isSome():
# let card = tweet.card.get()
# if card.image.len > 0:
# images = @[card.image]
# elif card.video.isSome():
# 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")
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?":
cond @"s" in ["status", "statuses"]
cond @"m" in ["video", "photo"]
redirect("/$1/status/$2" % [@"name", @"id"])
get "/@name/statuses/@id/?":
get "/@name/statuses/@id/?@m?/?@i?":
redirect("/$1/status/$2" % [@"name", @"id"])
get "/i/web/status/@id":

View File

@ -1,16 +1,16 @@
# 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 router_utils
import ".."/[types, redis_cache, formatters, query, api]
import ../views/[general, profile, timeline, status, search]
import ".."/[types, formatters, query, api, redis_cache]
import ../views/[general, profile, timeline, status, search, mastoapi]
export vdom
export uri, sequtils
export uri, sequtils, json
export router_utils
export redis_cache, formatters, query, api
export profile, timeline, status
export formatters, query, api, redis_cache
export profile, timeline, status, mastoapi
proc getQuery*(request: Request; tab, name: string): Query =
case tab
@ -32,7 +32,7 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
skipPinned=false): Future[Profile] {.async.} =
let
name = query.fromUser[0]
userId = await getUserId(name)
userId = await getCachedUserId(name)
if userId.len == 0:
return Profile(user: User(username: name))
@ -48,7 +48,7 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
let
rail =
skipIf(skipRail or query.kind == media, @[]):
getCachedPhotoRail(name)
getCachedPhotoRail(userId)
user = getCachedUser(name)
@ -83,7 +83,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
let pHtml = renderProfile(profile, cfg, prefs, getPath())
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)
template respTimeline*(timeline: typed) =
@ -111,6 +111,7 @@ proc createTimelineRouter*(cfg: Config) =
get "/@name/?@tab?/?":
cond '.' notin @"name"
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", ""]
let
prefs = cookiePrefs()
@ -120,10 +121,22 @@ proc createTimelineRouter*(cfg: Config) =
case tab:
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":
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:
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")
if names.len != 1:
query.fromUser = names

View File

@ -4,7 +4,7 @@ import json, asyncdispatch, options, uri
import times
import jester
import router_utils
import ".."/[types, api, apiutils, query, consts]
import ".."/[types, api, apiutils, redis_cache]
import httpclient, strutils
import sequtils
@ -52,7 +52,7 @@ proc tweetToJson*(t: Tweet): JsonNode =
result["photos"] = %t.photos
proc getUserProfileJson*(username: string): Future[JsonNode] {.async.} =
let user: User = await getGraphUser(username)
let user: User = await getCachedUser(username)
let response: JsonNode = %*{
"id": user.id,
"username": user.username
@ -81,52 +81,49 @@ proc getUserTweetsJson*(id: string): Future[JsonNode] {.async.} =
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.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = userTweetsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
result = await fetchRaw(graphUserTweets ? params, Api.userTweets)
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/$1""" % id)
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.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = userTweetsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
result = await fetchRaw(graphUserTweets ? params, Api.userTweetsAndReplies)
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/$1/with_replies""" % id)
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.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = userMediaVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
result = await fetchRaw(graphUserMedia ? params, Api.userMedia)
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/$1/media""" % id)
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.} =
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = tweetVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
result = await fetchRaw(graphTweet ? params, Api.tweetDetail)
if id.len == 0: return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/status/$1""" % id)
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) =
router api:
@ -135,20 +132,15 @@ proc createTwitterApiRouter*(cfg: Config) =
get "/api/user/@username":
let username = @"username"
let response = await getUserProfileJson(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)
let response = await getUser(username)
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":
let id = @"id"
let after = getCursor()

View File

@ -1,5 +1,5 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
.panel-container {
margin: auto;
@ -23,6 +23,7 @@
font-weight: bold;
width: 30px;
height: 30px;
padding: 0px 5px 1px 8px;
}
input {
@ -37,3 +38,13 @@
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,12 +1,12 @@
@import '_variables';
@import "_variables";
@import 'tweet/_base';
@import 'profile/_base';
@import 'general';
@import 'navbar';
@import 'inputs';
@import 'timeline';
@import 'search';
@import "tweet/_base";
@import "profile/_base";
@import "general";
@import "navbar";
@import "inputs";
@import "timeline";
@import "search";
body {
// colors
@ -50,7 +50,7 @@ body {
background-color: var(--bg_color);
color: var(--fg_color);
font-family: $font_0, $font_1, $font_2, $font_3;
font-family: $font_0, $font_1;
font-size: 14px;
line-height: 1.3;
margin: 0;
@ -66,7 +66,8 @@ h1 {
display: inline;
}
h2, h3 {
h2,
h3 {
font-weight: normal;
}
@ -90,7 +91,7 @@ fieldset {
legend {
width: 100%;
padding: .6em 0 .3em 0;
padding: 0.6em 0 0.3em 0;
border: 0;
font-size: 16px;
font-weight: 600;
@ -142,30 +143,50 @@ ul {
}
.verified-icon {
color: var(--icon_text);
border-radius: 50%;
flex-shrink: 0;
margin: 2px 0 3px 3px;
padding-top: 3px;
height: 11px;
width: 14px;
font-size: 8px;
display: inline-block;
text-align: center;
vertical-align: middle;
width: 14px;
height: 14px;
margin-left: 2px;
.verified-icon-circle {
position: absolute;
font-size: 15px;
}
.verified-icon-check {
position: absolute;
font-size: 9px;
margin: 5px 3px;
}
&.blue {
background-color: var(--verified_blue);
.verified-icon-circle {
color: var(--verified_blue);
}
.verified-icon-check {
color: var(--icon_text);
}
}
&.business {
.verified-icon-circle {
color: var(--verified_business);
}
.verified-icon-check {
color: var(--bg_panel);
background-color: var(--verified_business);
}
}
&.government {
.verified-icon-circle {
color: var(--verified_government);
}
.verified-icon-check {
color: var(--bg_panel);
background-color: var(--verified_government);
}
}
}
@ -174,7 +195,8 @@ ul {
max-width: 95vw;
}
.nav-item, .nav-item .icon-container {
.nav-item,
.nav-item .icon-container {
font-size: 16px;
}
}

View File

@ -1,4 +1,4 @@
@import '_variables';
@import "_variables";
nav {
display: flex;
@ -12,7 +12,8 @@ nav {
z-index: 1000;
font-size: 16px;
a, .icon-button button {
a,
.icon-button button {
color: var(--fg_nav);
}
}
@ -58,15 +59,11 @@ nav {
justify-content: flex-end;
}
&.right a {
padding-left: 4px;
&:hover {
&.right a:hover {
color: var(--accent_light);
text-decoration: unset;
}
}
}
.lp {
height: 14px;
@ -80,10 +77,11 @@ nav {
}
}
.icon-info:before {
.icon-info {
margin: 0 -3px;
}
.icon-cog {
font-size: 15px;
padding-left: 0 !important;
}

View File

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

View File

@ -1,5 +1,5 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
.search-title {
font-weight: bold;
@ -13,6 +13,7 @@
button {
margin: 0 2px 0 0;
padding: 0px 1px 1px 4px;
height: 23px;
display: flex;
align-items: center;
@ -34,7 +35,7 @@
background-color: var(--bg_elements);
color: var(--fg_color);
border: 1px solid var(--accent_border);
padding: 1px 6px 2px 6px;
padding: 1px 1px 2px 4px;
font-size: 14px;
cursor: pointer;
margin-bottom: 2px;
@ -62,7 +63,7 @@
.checkbox-container {
display: inline;
padding-right: unset;
margin-bottom: unset;
margin-bottom: 5px;
margin-left: 23px;
}

View File

@ -1,13 +1,14 @@
@import '_variables';
@import '_mixins';
@import 'thread';
@import 'media';
@import 'video';
@import 'embed';
@import 'card';
@import 'poll';
@import 'quote';
@import 'community_note';
@import "_variables";
@import "_mixins";
@import "thread";
@import "media";
@import "video";
@import "embed";
@import "card";
@import "poll";
@import "quote";
@import "community_note";
@import "limited_actions";
.tweet-body {
flex: 1;
@ -32,7 +33,7 @@
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: .2em;
margin-bottom: 0.2em;
a {
display: inline-block;
@ -47,6 +48,11 @@
display: flex;
justify-content: space-between;
}
.tweet-label-row {
padding: 0;
display: flex;
gap: 0.4em;
}
.fullname-and-username {
display: flex;
@ -65,17 +71,26 @@
.username {
@include ellipsis;
min-width: 1.6em;
margin-left: .4em;
margin-left: 0.4em;
word-wrap: normal;
}
.user-automated,
.user-pcf {
@include ellipsis;
min-width: 1px;
color: var(--fg_faded);
}
.tweet-date {
display: flex;
flex-shrink: 0;
margin-left: 4px;
}
.tweet-date a, .username, .show-more a {
.tweet-date a,
.username,
.show-more a {
color: var(--fg_dark);
}
@ -158,7 +173,8 @@
padding-right: 2px;
}
.media-tag, .icon-container {
.media-tag,
.icon-container {
color: var(--fg_faded);
}
}
@ -180,7 +196,9 @@
}
}
.retweet-header, .pinned, .tweet-stats {
.retweet-header,
.pinned,
.tweet-stats {
align-content: center;
color: var(--grey);
display: flex;

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;
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 {

View File

@ -1,8 +1,8 @@
@import '_variables';
@import '_mixins';
@import "_variables";
@import "_mixins";
video {
max-height: 100%;
height: 100%;
width: 100%;
}
@ -13,14 +13,12 @@ video {
.gallery-video.card-container {
flex-direction: column;
width: 100%;
}
.video-container {
max-height: 530px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
img {
max-height: 100%;

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

View File

@ -5,7 +5,7 @@ import karax/[karaxdsl, vdom]
const
date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"")
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}"
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 prefs = Prefs(hlsPlayback: true, mp4Playback: true)
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:
tdiv(class="embed-video"):
@ -23,11 +23,11 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
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,
"version": "1.0",
"provider_name": provider,
"provider_name": provider, #cfg.title,
"provider_url": getUrlPrefix(cfg),
"title": title,
"author_name": user,

View File

@ -3,7 +3,7 @@ import uri, strutils, strformat, times, options
import karax/[karaxdsl, vdom]
import renderutils
import ../utils, ../types, ../prefs, ../formatters
import ".."/[utils, types, prefs, formatters]
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))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""; avatar="";
time: Option[DateTime] = none(DateTime)): VNode =
video=""; images: seq[Image] = @[]; banner=""; ogTitle="";
rss=""; canonical=""; avatar=""; context=""; contextUrl="";
id=""; time: Option[DateTime] = none(DateTime); media="";
stats = ""): VNode =
var theme = prefs.theme.toTheme
if "theme" in req.params:
theme = req.params["theme"].toTheme
let ogType =
if video.len > 0: "video.other"
elif rss.len > 0: "object"
#elif rss.len > 0: "object"
elif images.len > 0: "photo"
else: "article"
@ -54,17 +55,19 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
buildHtml(head):
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")
script(src="/js/baguetteBox.min.js", `async`="")
script(src="/js/zoom.js")
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
if theme.len > 0:
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="/favicon-32x32.png")
link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png")
link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png"))
link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/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="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
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:
script(src="/js/infiniteScroll.js", `defer`="")
# Eir: load custom js
if prefs.eirResources:
script(src="/js/eirResources.js", `defer`="")
title:
if titleText.len > 0:
text titleText & " | " & cfg.title
@ -93,7 +100,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let finalizedDesc = stripHtml(desc)
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)
if video.len > 0 and len(finalizedDesc) <= 67:
meta(property="og:title", content=finalizedDesc)
@ -101,16 +108,22 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
meta(property="og:title", content=finalizedTitleText)
meta(property="og:description", content=finalizedDesc)
meta(property="og:locale", content="en_US")
meta(name="referrer", content="no-referrer")
var siteName = "Nitter"
var siteName = cfg.title
if time.isSome:
let timeObj = time.get
let timeStr = $timeObj
meta(property="og:article:published_time", content=timeStr)
if not isDiscord:
let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss")
siteName = &"Nitter • {formattedTime}"
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)
@ -119,19 +132,21 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
if images.len > 0:
for url in images:
let preloadUrl = if "400x400" in url: getPicUrl(url)
for imageObj in images:
let
url = imageObj.url
preloadUrl = if "400x400" in url: getPicUrl(url)
else: getSmallPic(url)
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
let image = getUrlPrefix(cfg) & getPicUrl(url)
meta(property="og:image", content=image)
meta(property="og:image:alt", content=imageObj.description)
if video.len == 0:
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")
else:
meta(property="twitter:card", content="summary")
elif avatar.len > 0:
let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar)
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:secure_url", content=video)
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:
title = author
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\" />"
#link(rel="alternate",
# href=&"{getUrlPrefix(cfg)}/oembed.json?type=video&title={encodeUrl(stripHtml(desc))}&user={encodeUrl(finalizedTitleText)}&url={encodeUrl(req.path)}",
# `type`="application/json+oembed")
if context != "":
author = encodeUrl(context & "\n") & author
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
# if this is done earlier, Chrome only preloads one image for some reason
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;
titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[string] = @[]; banner=""; avatar="";
time: Option[DateTime] = none(DateTime)): string =
images: seq[Image] = @[]; banner=""; avatar=""; context="";
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime);
media=""; stats=""): string =
let canonical = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical, avatar, time)
rss, canonical, avatar, context, contextUrl, id, time, media,
stats)
body:
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():
linkUser(user, class="profile-card-fullname")
linkUser(user, class="profile-card-username")
linkUser(user, class="profile-card-fullname", prefs)
linkUser(user, class="profile-card-username", prefs)
let following = isFollowing(user.username, prefs.following)
if not following:
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"
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:
tdiv(class="profile-bio"):
p(dir="auto"):

View File

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
import ".."/[types, utils]
import ".."/[types, utils, formatters]
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.} =
if user.verifiedType != VerifiedType.none:
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:
text ""
proc linkUser*(user: User, class=""): VNode =
proc linkUser*(user: User, class="", prefs: Prefs): VNode =
let
isName = "username" notin class
href = "/" & user.username
@ -44,6 +46,10 @@ proc linkUser*(user: User, class=""): VNode =
if user.protected:
text " "
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 =
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)
icon "calendar"
proc genImg*(url: string; class=""): VNode =
proc genImg*(url: string; alt=""; class=""): VNode =
buildHtml():
img(src=getPicUrl(url), class=class, alt="")
img(src=getPicUrl(url), class=class, alt=alt)
proc getTabClass*(query: Query; tab: QueryKind): string =
if query.kind == tab: "tab-item active"

View File

@ -2,6 +2,9 @@
## SPDX-License-Identifier: AGPL-3.0-only
#import strutils, xmltree, strformat, options, unicode
#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 =
#if tweet.pinned: result = "Pinned: "
@ -25,7 +28,25 @@
#end proc
#
#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
#
#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>
#if tweet.photos.len > 0:
# 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
#elif tweet.video.isSome:
<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:
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
@ -51,10 +75,18 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
# end if
#end if
#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/>
<p>Quoting: <a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
${renderRssTweet(get(tweet.quote), cfg)}
<blockquote>
<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 proc
#
@ -72,12 +104,17 @@ ${renderRssTweet(get(tweet.quote), cfg)}
# if link in links: continue
# end if
# links.add link
# let useGlobalGuid = parseBiggestInt(tweet.id) >= guidCutoff
<item>
<title>${getTitle(tweet, retweet)}</title>
<dc:creator>@${tweet.user.username}</dc:creator>
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
<pubDate>${getRfc822Time(tweet)}</pubDate>
#if useGlobalGuid:
<guid isPermaLink="false">${tweet.id}</guid>
#else:
<guid>${urlPrefix & link}</guid>
#end if
<link>${urlPrefix & link}</link>
</item>
# end for
@ -108,8 +145,9 @@ ${renderRssTweet(get(tweet.quote), cfg)}
<width>128</width>
<height>128</height>
</image>
#if profile.tweets.content.len > 0:
${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)}
#let tweetsList = getTweetsWithPinned(profile)
#if tweetsList.len > 0:
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
#end if
</channel>
</rss>

View File

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

View File

@ -28,13 +28,18 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore:
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")):
var hasReplies = false
var replyCount = 0
for thread in replies.content:
if thread.content.len == 0: continue
hasReplies = true
replyCount += thread.content.len
renderReplyThread(thread, prefs, path)
if replies.bottom.len > 0:
if hasReplies and replies.bottom.len > 0:
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 =
@ -45,7 +50,7 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if conv.before.content.len > 0:
tdiv(class="before-tweet thread-line"):
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)
for i, tweet in conv.before.content:
renderTweet(tweet, prefs, path, index=i)
@ -70,6 +75,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r")
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")

View File

@ -55,7 +55,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
renderTweet(tweet, prefs, path, class=(header & "thread"),
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]
if it.retweet.isSome or it.replyId in threads: return
for t in tweets:
@ -74,8 +74,8 @@ proc renderUser*(user: User; prefs: Prefs): VNode =
tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"):
linkUser(user, class="fullname")
linkUser(user, class="username")
linkUser(user, class="fullname", prefs)
linkUser(user, class="username", prefs)
tdiv(class="tweet-content media-body", dir="auto"):
verbatim replaceUrls(user.bio, prefs)
@ -112,20 +112,20 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else:
renderNoneFound()
else:
var retweets: seq[int64]
var retweets: seq[string]
for thread in results.content:
if thread.len == 1:
let
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
tweet.pinned and prefs.hidePins:
continue
var hasThread = tweet.hasThread
if retweetId != 0 and tweet.retweet.isSome:
if retweetId.len != 0 and tweet.retweet.isSome:
retweets &= retweetId
hasThread = get(tweet.retweet).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)
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
let user = tweet.user
buildHtml(tdiv):
if pinned:
tdiv(class="pinned"):
span: icon "pin", "Pinned Tweet"
span: icon "pin", "Pinned"
elif retweet.len > 0:
tdiv(class="retweet-header"):
span: icon "retweet", retweet & " retweeted"
tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.user.username)):
a(class="tweet-avatar", href=("/" & user.username)):
var size = "_bigger"
if not prefs.autoplayGifs and tweet.user.userPic.endsWith("gif"):
if not prefs.autoplayGifs and user.userPic.endsWith("gif"):
size = "_400x400"
genImg(tweet.user.getUserPic(size), class=prefs.getAvatarClass)
genImg(user.getUserPic(size), class=prefs.getAvatarClass)
tdiv(class="tweet-name-row"):
tdiv(class="fullname-and-username"):
linkUser(tweet.user, class="fullname")
linkUser(tweet.user, class="username")
linkUser(user, class="fullname", prefs)
linkUser(user, class="username", prefs)
span(class="tweet-date"):
a(href=getLink(tweet), title=tweet.getTime):
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 =
let
groups = if tweet.photos.len < 3: @[tweet.photos]
@ -49,12 +59,15 @@ proc renderAlbum(tweet: Tweet): VNode =
let margin = if i > 0: ".25em" else: ""
tdiv(class="gallery-row", style={marginTop: margin}):
for photo in photos:
tdiv(class="attachment image"):
tdiv(class="attachment image", title=photo.description):
let
named = "name=" in photo
small = if named: photo else: photo & smallWebp
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
genImg(small)
url = photo.url
named = "name=" in url
small = if named: url else: url & smallWebp
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 =
case playbackType
@ -180,19 +193,16 @@ func formatStat(stat: int): string =
if stat > 0: insertSep($stat, ',')
else: ""
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
proc renderStats(stats: TweetStats; tweet: Tweet): VNode =
buildHtml(tdiv(class="tweet-stats")):
a(href=getLink(tweet)):
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
a(href=getLink(tweet, false) & "/retweeters"):
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
a():
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
a(href=getLink(tweet)):
if views.len > 0:
span(class="tweet-stat"): icon "play", insertSep(views, ',')
span(class="tweet-stat", title="Replies", "aria-label"="Replies"): icon "comment", formatStat(stats.replies)
span(class="tweet-stat", title="Reposts", "aria-label"="Reposts"): icon "retweet", formatStat(stats.retweets)
span(class="tweet-stat"):
a(href="/search?q=quoted_tweet_id:" & $tweet.id, title="Quotes", "aria-label"="Quotes"): icon "quote", formatStat(stats.quotes)
span(class="tweet-stat", title="Likes", "aria-label"="Likes"): icon "heart", formatStat(stats.likes)
span(class="tweet-stat", title="Bookmarks", "aria-label"="Bookmarks"): icon "bookmark", formatStat(stats.bookmarks)
if stats.views > -1:
span(class="tweet-stat", title="Views", "aria-label"="Views"): icon "eye", formatStat(stats.views)
proc renderReply(tweet: Tweet): VNode =
buildHtml(tdiv(class="replying-to")):
@ -220,7 +230,8 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="quote-media-container")):
if quote.photos.len > 0:
renderAlbum(quote)
elif quote.video.isSome:
if quote.video.isSome:
renderVideo(quote.video.get(), prefs, path)
elif quote.gif.isSome:
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="fullname-and-username"):
renderMiniAvatar(quote.user, prefs)
linkUser(quote.user, class="fullname")
linkUser(quote.user, class="username")
linkUser(quote.user, class="fullname", prefs)
linkUser(quote.user, class="username", prefs)
span(class="tweet-date"):
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"):
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 =
let (place, url) = tweet.getLocation()
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))
tdiv(class="tweet-body"):
var views = ""
renderHeader(tweet, retweet, pinned, prefs)
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:
renderAlbum(tweet)
elif tweet.video.isSome:
if tweet.video.isSome:
renderVideo(tweet.video.get(), prefs, path)
views = tweet.video.get().views
elif tweet.gif.isSome:
renderGif(tweet.gif.get(), prefs)
views = "GIF"
if tweet.poll.isSome:
renderPoll(tweet.poll.get())
@ -351,7 +365,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
if tweet.quote.isSome:
renderQuote(tweet.quote.get(), prefs, path)
if mainTweet and tweet.birdwatch.isSome:
if tweet.birdwatch.isSome:
renderCommunityNote(tweet.birdwatch.get(), prefs)
if mainTweet:
@ -361,12 +375,15 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
renderMediaTags(tweet.mediaTags)
if not prefs.hideTweetStats:
renderStats(tweet.stats, views, tweet)
renderStats(tweet.stats, tweet)
if showThread:
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
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 =
let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req)