Compare commits

..

3 Commits

Author SHA1 Message Date
cb84ed219b pull updates from cynthia foxwell's fork
Some checks failed
Build and Publish Docker / build (push) Has been cancelled
2025-08-07 20:25:37 -05:00
775f04eeaa update eir theme - bg
Some checks failed
Build and Publish Docker / build (push) Has been cancelled
2025-02-17 17:38:20 -06:00
2a9c98fd0c Initial commit, my theme + changes
Some checks failed
Build and Publish Docker / build (push) Has been cancelled
2024-12-17 21:21:34 -06:00
57 changed files with 1555 additions and 2237 deletions

View File

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

View File

@ -26,7 +26,12 @@ enableRSS = true # set this to false to disable RSS feeds
enableDebug = false # enable request logs and debug endpoints (/.accounts) enableDebug = false # enable request logs and debug endpoints (/.accounts)
proxy = "" # http/https url, SOCKS proxies are not supported proxy = "" # http/https url, SOCKS proxies are not supported
proxyAuth = "" proxyAuth = ""
disableTid = false # enable this if cookie-based auth is failing 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
# Change default preferences here, see src/prefs_impl.nim for a complete list # Change default preferences here, see src/prefs_impl.nim for a complete list
[Preferences] [Preferences]

View File

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

Binary file not shown.

View File

@ -1,52 +1,46 @@
<?xml version="1.0" standalone="no"?> <?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata> <metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
<defs> <defs>
<font id="fontello" horiz-adv-x="1000" > <font id="fontello" horiz-adv-x="1000" >
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" /> <font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" /> <missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="heart" unicode="&#x2665;" d="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="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="quote" unicode="&#x275e;" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" /> <glyph glyph-name="quote" unicode="&#x275e;" d="M18 685l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z m558 0l335 0 0-334q0-140-98-238t-237-97l0 111q92 0 158 65t65 159l-223 0 0 334z" horiz-adv-x="928" />
<glyph glyph-name="ok" unicode="&#xe800;" d="M249 0q-34 0-56 28l-180 236q-16 24-12 52t26 46 51 14 47-28l118-154 296 474q16 24 43 30t53-8q24-16 30-43t-8-53l-350-560q-20-32-56-32z" horiz-adv-x="667" />
<glyph glyph-name="play" unicode="&#xe801;" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
<glyph glyph-name="comment" unicode="&#xe802;" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" /> <glyph glyph-name="comment" unicode="&#xe802;" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
<glyph glyph-name="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="rss-feed" unicode="&#xf09e;" d="M214 100q0-45-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m286-69q1-15-9-26-10-12-27-12h-75q-14 0-24 9t-11 23q-12 128-103 219t-219 103q-14 1-23 11t-9 24v75q0 16 12 26 9 10 24 10h3q89-7 170-45t145-101q63-63 101-145t45-171z m286-1q1-15-10-26-10-11-26-11h-80q-14 0-25 10t-10 23q-7 120-57 228t-129 188-188 129-227 57q-14 1-24 11t-10 24v80q0 16 11 26 10 10 25 10h1q147-8 280-67t238-164q104-104 164-238t67-280z" horiz-adv-x="785.7" />
</font> </font>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,89 +1,82 @@
# My instance # About
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** **This instance is running a fork, whose source can be found at**
<https://git.eir-nya.gay/eir/nitter>. <https://git.eir-nya.gay/eir/nitter>
Additionally, I am copying changes from <https://gitlab.com/Cynosphere/nitter> ([her instance](https://tw.counter-strike.gay))
My fork is based on [Cynthia Foxwell's fork](https://gitlab.com/Cynosphere/nitter). * No JavaScript or ads
Nitter is created by Zedeus, whose source can be found at <https://github.com/zedeus/nitter>. * 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)
The rest of this page is copied from Cynthia's fork: 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.
> # About ### Fork features (from [Cynthia Foxwell's fork](<https://gitlab.com/Cynosphere/nitter>))
>
> Nitter is a free and open source alternative Twitter front-end focused on * Localized following via cookies (list exportable and editable in preferences)
> privacy and performance. * Image zooming/carousel (requires JavaScript)
> * Up to date Twitter features, e.g. Community Notes
> * No JavaScript or ads * Embeds for chat services on-par with services like [FxTwitter](https://github.com/FixTweet/FxTwitter) and [vxTwitter](https://github.com/dylanpdx/BetterTwitFix)
> * All requests go through the backend, client never talks to Twitter * No dependency on Redis, as it has caused ratelimiting issues, but also forcably disables RSS
> * Prevents Twitter from tracking your IP or JavaScript fingerprint
> * Uses Twitter's unofficial API (no rate limits or developer account required) ## Why use Nitter?
> * Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
> * RSS feeds It's impossible to use Twitter without JavaScript enabled. For privacy-minded
> * Themes folks, preventing JavaScript analytics and IP-based tracking is important, but
> * Mobile support (responsive design) apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
> * AGPLv3 licensed, no proprietary instances permitted (source code below) a VPN and using heavy-duty adblockers, you can get accurately tracked with your
> [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
> Nitter's GitHub wiki contains [no JavaScript required](https://noscriptfingerprint.com/). This all became
> [instances](https://github.com/zedeus/nitter/wiki/Instances) and particularly important after Twitter [removed the
> [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions) ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
> maintained by the community. for users to control whether their data gets sent to advertisers.
>
> ### Fork features by Cynthia Foxwell Using an instance of Nitter (hosted on a VPS for example), you can browse
> Twitter without JavaScript while retaining your privacy. In addition to
> * Localized following via cookies (list exportable and editable in preferences) respecting your privacy, Nitter is on average around 15 times lighter than
> * Image zooming/carousel (requires JavaScript) Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
> * 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) ## Donating
>
> ## Why use Nitter? Donations go to zedeus, original creator of Nitter.
>
> It's impossible to use Twitter without JavaScript enabled. For privacy-minded Liberapay: <https://liberapay.com/zedeus> \
> folks, preventing JavaScript analytics and IP-based tracking is important, but Patreon: <https://patreon.com/nitter> \
> apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
> a VPN and using heavy-duty adblockers, you can get accurately tracked with your ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
> [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/), LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
> [no JavaScript required](https://noscriptfingerprint.com/). This all became XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
> 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) ## Credits
> for users to control whether their data gets sent to advertisers.
> * Cynthia Foxwell for her fork of this project
> Using an instance of Nitter (hosted on a VPS for example), you can browse * Zedeus for this project
> Twitter without JavaScript while retaining your privacy. In addition to * PrivacyDevel, cmj, and taskylizard for keeping this project alive with forks after the main repo went inactive
> respecting your privacy, Nitter is on average around 15 times lighter than * Every other contributors who've committed to the main repo in the past
> Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
> ## To any law enforcement agencies and copyright holders (from [Cynthia Foxwell's fork](<https://gitlab.com/Cynosphere/nitter>))
> ## Donating
> **All illegal content should be reported to Twitter directly.** This service is
> Even though I could be selfish and point people to donate to me instead of merely a proxy of Twitter and no content is hosted on this server. Do not waste
> Zedeus, it would be disrespectful. your time contacting internet service providers, hosting providers and/or domain
> registrars.
> GitHub Sponsors: <https://github.com/sponsors/zedeus> \
> Donations go to zedeus, original creator of Nitter. If you would like more context, you can read about this exact issue happening to
> [PussTheCat.org's instance](https://pussthecat.org/nitter/).
> Liberapay: <https://liberapay.com/zedeus> \
> Patreon: <https://patreon.com/nitter> \ I emplore all Nitter instance hosts to not enable media proxying, even if it
> BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \ "phones home" to Twitter's CDN (which doesn't really pose a tracking risk and
> ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \ breaks videos anyways), as it [has been used as an attack vector to take down
> LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \ nitter.net](https://github.com/zedeus/nitter/issues/1150#issuecomment-1890855255).
> 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,114 +1,59 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import asyncdispatch, httpclient, strutils, sequtils, sugar import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
import packedjson import packedjson
import types, query, formatters, consts, apiutils, parser import types, query, formatters, consts, apiutils, parser
import experimental/parser as newParser import experimental/parser as newParser
# Helper to generate params object for GraphQL requests
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
result.add ("variables", variables)
result.add ("features", gqlFeatures)
if fieldToggles.len > 0:
result.add ("fieldToggles", fieldToggles)
proc apiUrl*(endpoint, variables: string; fieldToggles = ""): ApiUrl =
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
proc apiReq*(endpoint, variables: string; fieldToggles = ""): ApiReq =
let url = apiUrl(endpoint, variables, fieldToggles)
return ApiReq(cookie: url, oauth: url)
proc mediaUrl*(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor], """{"withArticlePlainText":false}"""),
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
)
proc userTweetsUrl*(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
)
proc userTweetsAndRepliesUrl*(id: string; cursor: string): ApiReq =
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
result = ApiReq(
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
)
proc tweetDetailUrl*(id: string; cursor: string): ApiReq =
result = ApiReq(
cookie: apiUrl(graphTweetDetail, tweetDetailVars % [id, cursor], tweetDetailFieldToggles),
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
)
proc userUrl*(username: string): ApiReq =
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
result = ApiReq(
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
)
proc getGraphUser*(username: string): Future[User] {.async.} = proc getGraphUser*(username: string): Future[User] {.async.} =
if username.len == 0: return if username.len == 0: return
let
let headers = newHttpHeaders() variables = """{"screen_name": "$1"}""" % username
headers.add("Referer", """https://x.com/$1""" % username) params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUser ? params, Api.userScreenName)
let js = await fetchRaw(userUrl(username), headers)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserById*(id: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} =
if id.len == 0 or id.any(c => not c.isDigit): return if id.len == 0 or id.any(c => not c.isDigit): return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/user/$1""" % id)
let let
url = apiReq(graphUserById, """{"userId":"$1"}""" % id) variables = """{"rest_id": "$1"}""" % id
js = await fetchRaw(url, headers) params = {"variables": variables, "features": gqlFeatures}
js = await fetchRaw(graphUserById ? params, Api.userRestId)
result = parseGraphUser(js) result = parseGraphUser(js)
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} = proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
if id.len == 0: return if id.len == 0: return
let
endpoint = case kind
of TimelineKind.tweets: ""
of TimelineKind.replies: "/with_replies"
of TimelineKind.media: "/media"
headers = newHttpHeaders()
headers.add("Referer", """https://x.com/$1$2""" % [id, endpoint])
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = case kind variables = if kind == TimelineKind.media: userMediaVariables % [id, cursor] else: userTweetsVariables % [id, cursor]
of TimelineKind.tweets: userTweetsUrl(id, cursor) fieldToggles = """{"withArticlePlainText":true}"""
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor) params = {"variables": variables, "features": gqlFeatures, "fieldToggles": fieldToggles}
of TimelineKind.media: mediaUrl(id, cursor) (url, apiId) = case kind
js = await fetch(url, headers) of TimelineKind.tweets: (graphUserTweets, Api.userTweets)
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
of TimelineKind.media: (graphUserMedia, Api.userMedia)
js = await fetch(url ? params, apiId)
result = parseGraphTimeline(js, if kind == TimelineKind.media: "" else: "user", after) result = parseGraphTimeline(js, if kind == TimelineKind.media: "" else: "user", after)
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = apiReq(graphListTweets, restIdVars % [id, cursor]) variables = listTweetsVariables % [id, cursor]
js = await fetch(url) params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphListTweets ? params, Api.listTweets)
result = parseGraphTimeline(js, "list", after).tweets result = parseGraphTimeline(js, "list", after).tweets
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
let let
variables = %*{"screenName": name, "listSlug": list} variables = %*{"screenName": name, "listSlug": list}
url = apiReq(graphListBySlug, $variables) params = {"variables": $variables, "features": gqlFeatures}
js = await fetch(url) result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
result = parseGraphList(js)
proc getGraphList*(id: string): Future[List] {.async.} = proc getGraphList*(id: string): Future[List] {.async.} =
let let
url = apiReq(graphListById, """{"listId": "$1"}""" % id) variables = """{"listId": "$1"}""" % id
js = await fetch(url) params = {"variables": variables, "features": gqlFeatures}
result = parseGraphList(js) result = parseGraphList(await fetch(graphListById ? params, Api.list))
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
if list.id.len == 0: return if list.id.len == 0: return
@ -122,45 +67,77 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
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 let
url = apiReq(graphListMembers, $variables) url = consts.favorites ? {"variables": $variables, "features": gqlFeatures}
js = await fetchRaw(url) result = parseGraphTimeline(await fetch(url, Api.favorites), after)
result = parseGraphListMembers(js, after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return if id.len == 0: return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/status/$1""" % id)
let let
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id) variables = """{"rest_id": "$1"}""" % id
js = await fetch(url, headers) params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphTweetResult ? params, Api.tweetResult)
result = parseGraphTweetResult(js) result = parseGraphTweetResult(js)
proc getGraphTweet*(id: string; after=""): Future[Conversation] {.async.} = proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
if id.len == 0: return if id.len == 0: return
let headers = newHttpHeaders()
headers.add("Referer", """https://x.com/i/status/$1""" % id)
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = await fetch(tweetDetailUrl(id, cursor), headers) variables = tweetVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures, "fieldToggles": tweetFieldToggles}
js = await fetch(graphTweet ? params, Api.tweetDetail)
result = parseGraphConversation(js, id) result = parseGraphConversation(js, id)
proc getGraphFavoriters*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = reactorsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFavoriters ? params, Api.favoriters)
result = parseGraphFavoritersTimeline(js, id)
proc getGraphRetweeters*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
variables = reactorsVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphRetweeters ? params, Api.retweeters)
result = parseGraphRetweetersTimeline(js, id)
proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} = proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = await fetch(apiReq(graphFollowing, followVars % [id, cursor])) variables = followVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowing ? params, Api.following)
result = parseGraphFollowTimeline(js, id) result = parseGraphFollowTimeline(js, id)
proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} = proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
js = await fetch(apiReq(graphFollowers, followVars % [id, cursor])) variables = followVariables % [id, cursor]
params = {"variables": variables, "features": gqlFeatures}
js = await fetch(graphFollowers ? params, Api.followers)
result = parseGraphFollowTimeline(js, id) result = parseGraphFollowTimeline(js, id)
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
@ -181,16 +158,15 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
variables = %*{ variables = %*{
"rawQuery": q, "rawQuery": q,
"count": 20, "count": 20,
"query_source": "typed_query",
"product": "Latest", "product": "Latest",
"withGrokTranslatedBio": false "withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
let let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
url = apiReq(graphSearchTimeline, $variables) result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
js = await fetch(url)
result = parseGraphSearch[Tweets](js, after)
result.query = query result.query = query
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} = proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
@ -201,24 +177,26 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
variables = %*{ variables = %*{
"rawQuery": query.text, "rawQuery": query.text,
"count": 20, "count": 20,
"query_source": "typed_query",
"product": "People", "product": "People",
"withGrokTranslatedBio": false "withDownvotePerspective": false,
"withReactionsMetadata": false,
"withReactionsPerspective": false
} }
if after.len > 0: if after.len > 0:
variables["cursor"] = % after variables["cursor"] = % after
result.beginning = false result.beginning = false
let let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
url = apiReq(graphSearchTimeline, $variables) result = parseGraphSearch[User](await fetch(url, Api.search), after)
js = await fetch(url)
result = parseGraphSearch[User](js, after)
result.query = query result.query = query
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} = proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} =
if id.len == 0: return if name.len == 0: return
let js = await fetch(mediaUrl(id, "")) let
result = parseGraphPhotoRail(js) ps = genParams({"screen_name": name, "trim_user": "true"},
count="18", ext=false)
url = photoRail ? ps
result = parsePhotoRail(await fetch(url, Api.photoRail))
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
let client = newAsyncHttpClient(maxRedirects=0) let client = newAsyncHttpClient(maxRedirects=0)

View File

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

View File

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

View File

@ -43,7 +43,6 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
enableDebug: cfg.get("Config", "enableDebug", false), enableDebug: cfg.get("Config", "enableDebug", false),
proxy: cfg.get("Config", "proxy", ""), proxy: cfg.get("Config", "proxy", ""),
proxyAuth: cfg.get("Config", "proxyAuth", ""), proxyAuth: cfg.get("Config", "proxyAuth", ""),
disableTid: cfg.get("Config", "disableTid", false),
cookieHeader: cfg.get("Config", "cookieHeader", ""), cookieHeader: cfg.get("Config", "cookieHeader", ""),
xCsrfToken: cfg.get("Config", "xCsrfToken", "") xCsrfToken: cfg.get("Config", "xCsrfToken", "")
) )

View File

@ -1,136 +1,160 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils import uri, sequtils, strutils
const const
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
consumerKey* = "3nVuSoBZnx6U4vzUxf5w" consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys" consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName" api = parseUri("https://api.twitter.com")
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery" activate* = $(api / "1.1/guest/activate.json")
graphUserById* = "Bbaot8ySMtJD7K2t01gW7A/UserByRestId"
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2" photoRail* = api / "1.1/statuses/media_timeline.json"
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
graphUserTweets* = "lZRf8IC-GTuGxDwcsHW8aw/UserTweets" timelineApi = api / "2/timeline"
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
graphUserMedia* = "1D04dx9H2pseMQAbMjXTvQ/UserMedia" graphql = api / "graphql"
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
graphTweetDetail* = "6QzqakNMdh_YzBAR9SYPkQ/TweetDetail" graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia"
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" graphTweet* = graphql / "y90SwUGBZ3yz0yNUmCHgTw/TweetDetail"
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers" graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline" graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
graphFollowers* = "Efm7xwLreAw77q2Fq7rX-Q/Followers" graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
graphFollowing* = "e0UtTAwQqgLKBllQxMgVxQ/Following" 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
gqlFeatures* = """{ gqlFeatures* = """{
"rweb_video_screen_enabled": false, "android_graphql_skip_api_media_color_palette": false,
"profile_label_improvements_pcf_label_in_post_enabled": true, "articles_preview_enabled": false,
"responsive_web_profile_redirect_enabled": false, "blue_business_profile_image_shape_enabled": false,
"rweb_tipjar_consumption_enabled": true, "c9s_tweet_anatomy_moderator_badge_enabled": false,
"verified_phone_label_enabled": false, "communities_web_enable_tweet_community_results_fetch": 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, "creator_subscriptions_quote_tweet_preview_enabled": false,
"freedom_of_speech_not_reach_fetch_enabled": true, "creator_subscriptions_subscription_count_enabled": false,
"standardized_nudges_misinfo": true, "creator_subscriptions_tweet_preview_api_enabled": true,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": false,
"longform_notetweets_rich_text_read_enabled": true, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
"longform_notetweets_inline_media_enabled": true, "hidden_profile_likes_enabled": false,
"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, "highlights_tweets_tab_ui_enabled": false,
"subscriptions_feature_can_gift_premium": false "interactive_text_enabled": false,
"longform_notetweets_consumption_enabled": true,
"longform_notetweets_inline_media_enabled": false,
"longform_notetweets_richtext_consumption_enabled": true,
"longform_notetweets_rich_text_read_enabled": false,
"responsive_web_edit_tweet_api_enabled": false,
"responsive_web_enhance_cards_enabled": false,
"responsive_web_graphql_exclude_directive_enabled": true,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
"responsive_web_graphql_timeline_navigation_enabled": false,
"responsive_web_media_download_video_enabled": false,
"responsive_web_text_conversations_enabled": false,
"responsive_web_twitter_article_tweet_consumption_enabled": false,
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
"rweb_lists_timeline_redesign_enabled": true,
"rweb_tipjar_consumption_enabled": false,
"rweb_video_timestamps_enabled": true,
"spaces_2022_h2_clipping": true,
"spaces_2022_h2_spaces_communities": true,
"standardized_nudges_misinfo": false,
"subscriptions_verification_info_enabled": true,
"subscriptions_verification_info_reason_enabled": true,
"subscriptions_verification_info_verified_since_enabled": true,
"super_follow_badge_privacy_enabled": false,
"super_follow_exclusive_tweet_notifications_enabled": false,
"super_follow_tweet_api_enabled": false,
"super_follow_user_api_enabled": false,
"tweet_awards_web_tipping_enabled": false,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
"tweetypie_unmention_optimization_enabled": false,
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
"verified_phone_label_enabled": false,
"vibe_api_enabled": false,
"view_counts_everywhere_api_enabled": false,
"premium_content_api_read_enabled": false,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analysis_button_from_backend": false,
"responsive_web_grok_analyze_post_followups_enabled": false,
"responsive_web_jetfuel_frame": false,
"profile_label_improvements_pcf_label_in_post_enabled": true,
"responsive_web_grok_image_annotation_enabled": false,
"responsive_web_grok_share_attachment_enabled": false,
"rweb_video_screen_enabled": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
tweetVars* = """{ tweetVariables* = """{
"postId": "$1",
$2
"includeHasBirdwatchNotes": false,
"includePromotedContent": false,
"withBirdwatchNotes": false,
"withVoice": false,
"withV2Timeline": true
}""".replace(" ", "").replace("\n", "")
tweetDetailVars* = """{
"focalTweetId": "$1", "focalTweetId": "$1",
$2 $2
"referrer": "profile",
"with_rux_injections": false, "with_rux_injections": false,
"rankingMode": "Relevance", "rankingMode": "Relevance",
"includePromotedContent": true, "includePromotedContent": false,
"withCommunity": true, "withCommunity": true,
"withQuickPromoteEligibilityTweetFields": true, "withQuickPromoteEligibilityTweetFields": false,
"withBirdwatchNotes": true, "withBirdwatchNotes": true,
"withVoice": true "withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
restIdVars* = """{ tweetFieldToggles* = """{
"rest_id": "$1", $2 "withArticleRichContentState": false,
"withArticlePlainText": true,
"withGrokAnalyze": false,
"withDisallowedReplyControls": false
}""".replace(" ", "").replace("\n", "")
userTweetsVariables* = """{
"rest_id": "$1",
$2
"count": 20 "count": 20
}"""
userMediaVars* = """{
"userId": "$1", $2
"count": 20,
"includePromotedContent": false,
"withClientEventToken": false,
"withBirdwatchNotes": false,
"withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsVars* = """{ listTweetsVariables* = """{
"userId": "$1", $2 "rest_id": "$1",
"count": 20, $2
"includePromotedContent": false, "count": 20
"withQuickPromoteEligibilityTweetFields": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userTweetsAndRepliesVars* = """{ reactorsVariables* = """{
"userId": "$1", $2 "tweetId": "$1",
$2
"count": 20, "count": 20,
"includePromotedContent": false, "includePromotedContent": false
"withCommunity": true,
"withVoice": true
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
followVars* = """{ followVariables* = """{
"userId": "$1", "userId": "$1",
$2 $2
"count": 20, "count": 20,
"includePromotedContent": false "includePromotedContent": false
}""".replace(" ", "").replace("\n", "") }""".replace(" ", "").replace("\n", "")
userMediaVariables* = """{
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}""" "userId": "$1",
userTweetsFieldToggles* = """{"withArticlePlainText":false}""" $2
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}""" "count": 20,
"includePromotedContent": false
}""".replace(" ", "").replace("\n", "")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,15 +6,15 @@ from htmlgen import a
import jester import jester
import types, config, prefs, formatters, redis_cache, http_pool, apiutils import types, config, prefs, formatters, redis_cache, http_pool
import views/[general, about] import views/[general, about]
import routes/[ import routes/[
preferences, timeline, status, media, search, list, rss, #debug, preferences, timeline, status, media, search, list, #rss, debug,
unsupported, embed, resolver, router_utils, home, follow, twitter_api, unsupported, embed, resolver, router_utils, home, follow, twitter_api,
activityspoof] activityspoof]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://git.eir-nya.gay/eir/nitter/issues" const issuesUrl = "https://github.com/zedeus/nitter/issues"
#let accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json") #let accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
@ -34,12 +34,11 @@ setHmacKey(cfg.hmacKey)
setProxyEncoding(cfg.base64Media) setProxyEncoding(cfg.base64Media)
setMaxHttpConns(cfg.httpMaxConns) setMaxHttpConns(cfg.httpMaxConns)
setHttpProxy(cfg.proxy, cfg.proxyAuth) setHttpProxy(cfg.proxy, cfg.proxyAuth)
setDisableTid(cfg.disableTid)
initAboutPage(cfg.staticDir) initAboutPage(cfg.staticDir)
waitFor initRedisPool(cfg) #waitFor initRedisPool(cfg)
stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" #stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
stdout.flushFile #stdout.flushFile
createUnsupportedRouter(cfg) createUnsupportedRouter(cfg)
createResolverRouter(cfg) createResolverRouter(cfg)
@ -50,7 +49,7 @@ createStatusRouter(cfg)
createSearchRouter(cfg) createSearchRouter(cfg)
createMediaRouter(cfg) createMediaRouter(cfg)
createEmbedRouter(cfg) createEmbedRouter(cfg)
createRssRouter(cfg) #createRssRouter(cfg)
#createDebugRouter(cfg) #createDebugRouter(cfg)
createTwitterApiRouter(cfg) createTwitterApiRouter(cfg)
createActivityPubRouter(cfg) createActivityPubRouter(cfg)
@ -81,7 +80,7 @@ routes:
error InternalError: error InternalError:
echo error.exc.name, ": ", error.exc.msg echo error.exc.name, ": ", error.exc.msg
const link = a("open an issue", href = issuesUrl) const link = a("open a GitHub issue", href = issuesUrl)
resp Http500, showError( resp Http500, showError(
&"An error occurred, please {link} with the URL you tried to visit.", cfg) &"An error occurred, please {link} with the URL you tried to visit.", cfg)
@ -96,7 +95,7 @@ routes:
extend home, "" extend home, ""
extend follow, "" extend follow, ""
extend rss, "" #extend rss, ""
extend status, "" extend status, ""
extend search, "" extend search, ""
extend timeline, "" extend timeline, ""
@ -105,6 +104,7 @@ routes:
extend preferences, "" extend preferences, ""
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""
#extend debug, ""
extend activityspoof, "" extend activityspoof, ""
extend api, "" extend api, ""
extend unsupported, "" extend unsupported, ""

View File

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

View File

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

View File

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

View File

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

232
src/routes/activityspoof.nim Normal file → Executable file
View File

@ -4,7 +4,7 @@ import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat,
import jester import jester
import router_utils import router_utils
import ".."/[types, formatters, api, redis_cache] import ".."/[types, formatters, api]
import ../views/[mastoapi] import ../views/[mastoapi]
export json, uri, sequtils, options, sugar, times export json, uri, sequtils, options, sugar, times
@ -17,47 +17,19 @@ proc createActivityPubRouter*(cfg: Config) =
get "/api/v1/accounts/?": get "/api/v1/accounts/?":
resp Http200, {"Content-Type": "application/json"}, """[]""" 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": get "/api/v1/statuses/@id":
var let id = @"id"
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): if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}""" resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
let prefs = cookiePrefs() let prefs = cookiePrefs()
let conv = await getCachedTweet(id) let conv = await getTweet(id)
if conv == nil: if conv == nil:
echo "nil conv" echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0: if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found" var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone error = conv.tweet.tombstone
@ -67,57 +39,24 @@ proc createActivityPubRouter*(cfg: Config) =
resp Http404, {"Content-Type": "application/json"}, $errJson 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 let
tweet = conv.tweet tweet = conv.tweet
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}" tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}"
var media: seq[JsonNode] = @[] 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: if tweet.photos.len > 0:
for imageObj in tweet.photos: for url in tweet.photos:
let image = getUrlPrefix(cfg) & getPicUrl(imageObj.url) let image = getUrlPrefix(cfg) & getPicUrl(url)
var mediaObj = newJObject() var mediaObj = newJObject()
mediaObj["id"] = %"138733266285887488" # idk if discord even parses this snowflake, but its my user id why not mediaObj["id"] = %"150745989836308480" # idk if discord even parses this snowflake, but its my user id why not
mediaObj["type"] = %"image" mediaObj["type"] = %"image"
mediaObj["url"] = %image mediaObj["url"] = %image
mediaObj["preview_url"] = %image mediaObj["preview_url"] = %image
mediaObj["remote_url"] = %image mediaObj["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = %image mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull() mediaObj["text_url"] = newJNull()
mediaObj["description"] = %imageObj.description mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# FIXME but this probably isnt used by discord # FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject() mediaObj["meta"] = newJObject()
@ -127,71 +66,47 @@ proc createActivityPubRouter*(cfg: Config) =
let let
videoObj = get(tweet.video) videoObj = get(tweet.video)
vars = videoObj.variants.filterIt(it.contentType == mp4) 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 mediaObj = newJObject()
var description = videoObj.title
if videoObj.description.len > 0:
description = videoObj.description
mediaObj["id"] = %"138733266285887488" mediaObj["id"] = %"150745989836308480"
mediaObj["type"] = %"video" mediaObj["type"] = %"video"
mediaObj["url"] = %videoUrl mediaObj["url"] = %vars[^1].url
mediaObj["preview_url"] = %videoPreview mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb))
mediaObj["remote_url"] = %videoUrl mediaObj["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = %videoPreview mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull() mediaObj["text_url"] = newJNull()
mediaObj["description"] = %description mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# FIXME but this probably isnt used by discord # FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject() mediaObj["meta"] = newJObject()
media.add(mediaObj) media.add(mediaObj)
elif tweet.gif.isSome(): elif tweet.gif.isSome():
let let gif = get(tweet.gif)
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() var mediaObj = newJObject()
mediaObj["id"] = %"138733266285887488" mediaObj["id"] = %"150745989836308480"
mediaObj["type"] = %"video" mediaObj["type"] = %"video"
mediaObj["url"] = %gifUrl mediaObj["url"] = %(&"https://{gif.url}")
mediaObj["preview_url"] = %gifPreview mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
mediaObj["remote_url"] = %gifUrl mediaObj["remote_url"] = newJNull()
mediaObj["preview_remote_url"] = %gifPreview mediaObj["preview_remote_url"] = newJNull()
mediaObj["text_url"] = newJNull() mediaObj["text_url"] = newJNull()
mediaObj["description"] = newJNull() # FIXME this requires refactoring gifs mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y)
# FIXME but this probably isnt used by discord # FIXME but this probably isnt used by discord
mediaObj["meta"] = newJObject() mediaObj["meta"] = newJObject()
media.add(mediaObj) 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() var postJson = newJObject()
postJson["id"] = %tweet.id postJson["id"] = %(&"{tweet.id}")
postJson["url"] = %tweetUrl postJson["url"] = %tweetUrl
postJson["uri"] = %tweetUrl postJson["uri"] = %tweetUrl
postJson["created_at"] = %($tweet.time) postJson["created_at"] = %($tweet.time)
postJson["edited_at"] = newJNull() postJson["edited_at"] = newJNull()
postJson["reblog"] = newJNull() postJson["reblog"] = newJNull()
if tweet.replyId.len != 0: if tweet.replyId != 0:
postJson["in_reply_to_id"] = %(&"{tweet.replyId}") postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
postJson["in_reply_to_account_id"] = %tweet.replyHandle postJson["in_reply_to_account_id"] = %""
else: else:
postJson["in_reply_to_id"] = newJNull() postJson["in_reply_to_id"] = newJNull()
postJson["in_reply_to_account_id"] = newJNull() postJson["in_reply_to_account_id"] = newJNull()
@ -204,7 +119,32 @@ proc createActivityPubRouter*(cfg: Config) =
"website": getUrlPrefix(cfg) "website": getUrlPrefix(cfg)
} }
postJson["media_attachments"] = %media postJson["media_attachments"] = %media
postJson["account"] = getMastoAPIUser(tweet.user, cfg) postJson["account"] = %*{
"id": &"{tweet.user.id}",
"display_name": tweet.user.fullname,
"username": tweet.user.username,
"acct": tweet.user.username,
"url": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
"uri": &"{getUrlPrefix(cfg)}/{tweet.user.username}",
"created_at": $tweet.user.joinDate,
"locked": tweet.user.protected,
"bot": false, # TODO?
"discoverable": true,
"indexable": false,
"group": false,
"avatar": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
"avatar_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic),
"header": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
"header_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner),
"followers_count": tweet.user.followers,
"following_count": tweet.user.following,
"statuses_count": tweet.user.tweets,
"hide_collections": false,
"noindex": false,
"emojis": @[],
"roles": @[],
"fields": @[],
}
postJson["mentions"] = newJArray() # TODO: parse? postJson["mentions"] = newJArray() # TODO: parse?
postJson["tags"] = newJArray() # TODO: parse? postJson["tags"] = newJArray() # TODO: parse?
postJson["emojis"] = newJArray() postJson["emojis"] = newJArray()
@ -214,40 +154,18 @@ proc createActivityPubRouter*(cfg: Config) =
resp Http200, {"Content-Type": "application/json"}, $postJson resp Http200, {"Content-Type": "application/json"}, $postJson
get "/users/@name/statuses/@id": get "/users/@name/statuses/@id":
var let id = @"id"
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 request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit): if id.len > 19 or id.any(c => not c.isDigit):
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}""" resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
let prefs = cookiePrefs() let prefs = cookiePrefs()
let conv = await getCachedTweet(id) let conv = await getTweet(id)
if conv == nil: if conv == nil:
echo "nil conv" echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0: if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found" var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone error = conv.tweet.tombstone
@ -257,33 +175,7 @@ proc createActivityPubRouter*(cfg: Config) =
resp Http404, {"Content-Type": "application/json"}, $errJson resp Http404, {"Content-Type": "application/json"}, $errJson
let tweet = conv.tweet let postJson = getActivityStream(conv.tweet, cfg, prefs)
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 resp Http200, {"Content-Type": "application/json"}, $postJson
@ -291,7 +183,7 @@ proc createActivityPubRouter*(cfg: Config) =
get "/users/@name": get "/users/@name":
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
let user = await getCachedUser(@"name") let user = await getGraphUser(@"name")
if user.suspended or user.id.len == 0: if user.suspended or user.id.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}""" resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
@ -321,19 +213,19 @@ proc createActivityPubRouter*(cfg: Config) =
var nodeinfo = newJObject() var nodeinfo = newJObject()
nodeinfo["version"] = %"2.1" nodeinfo["version"] = %"2.1"
nodeinfo["software"] = %*{ nodeinfo["software"] = %*{
"name": cfg.title, "name": "Nitter",
"repository": "https://git.eir-nya.gay/eir/nitter" "repository": "https://gitlab.com/Cynosphere/nitter"
} }
var metadata = newJObject() var metadata = newJObject()
metadata["features"] = newJArray() metadata["features"] = newJArray()
metadata["federation"] = newJObject() metadata["federation"] = newJObject()
metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)" metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)"
metadata["nodeName"] = %cfg.title metadata["nodeName"] = %"Nitter"
metadata["private"] = %true metadata["private"] = %true
metadata["maintainer"] = %*{ metadata["maintainer"] = %*{
"name": "Eir", "name": "Cynthia",
"email": "eir@eir-nya.gay" "email": "gamers@riseup.net"
} }
nodeinfo["metadata"] = metadata nodeinfo["metadata"] = metadata

17
src/routes/debug.nim Normal file
View File

@ -0,0 +1,17 @@
# 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

@ -36,4 +36,4 @@ proc createEmbedRouter*(cfg: Config) =
resp Http404 resp Http404
get "/oembed.json": get "/oembed.json":
respJson generateOembed(cfg, @"type", @"title", @"user", @"url", @"provider") respJson generateOembed(cfg, @"type", @"title", @"user", @"url")

View File

@ -1,6 +1,6 @@
import jester import jester
import asyncdispatch, strutils, options, router_utils, timeline import asyncdispatch, strutils, options, router_utils, timeline
import ".."/[prefs, types, utils, redis_cache] import ".."/[prefs, types, utils]
import ../views/[general, home, search] import ../views/[general, home, search]
export home export home
@ -43,7 +43,7 @@ proc createHomeRouter*(cfg: Config) =
query.kind = userList query.kind = userList
for name in names: for name in names:
let prof = await getCachedUser(name) let prof = await getGraphUser(name)
profs &= @[prof] profs &= @[prof]
resp renderMain(renderFollowing(query, profs, prefs), request, cfg, prefs) resp renderMain(renderFollowing(query, profs, prefs), request, cfg, prefs)

View File

@ -4,7 +4,7 @@ import strutils, strformat, uri
import jester import jester
import router_utils import router_utils
import ".."/[types, api, redis_cache] import ".."/[types, api]
import ../views/[general, timeline, list] import ../views/[general, timeline, list]
template respList*(list, timeline, title, vnode: typed) = template respList*(list, timeline, title, vnode: typed) =
@ -36,7 +36,7 @@ proc createListRouter*(cfg: Config) =
cond @"slug" != "memberships" cond @"slug" != "memberships"
let let
slug = decodeUrl(@"slug") slug = decodeUrl(@"slug")
list = await getCachedList(@"name", slug) list = await getList(@"name", slug)
if list.id.len == 0: if list.id.len == 0:
resp Http404, showError(&"""List "{@"slug"}" not found""", cfg) resp Http404, showError(&"""List "{@"slug"}" not found""", cfg)
redirect(&"/i/lists/{list.id}") redirect(&"/i/lists/{list.id}")
@ -45,7 +45,7 @@ proc createListRouter*(cfg: Config) =
cond '.' notin @"id" cond '.' notin @"id"
let let
prefs = cookiePrefs() prefs = cookiePrefs()
list = await getCachedList(id=(@"id")) list = await getList(id=(@"id"))
timeline = await getGraphListTweets(list.id, getCursor()) timeline = await getGraphListTweets(list.id, getCursor())
vnode = renderTimelineTweets(timeline, prefs, request.path) vnode = renderTimelineTweets(timeline, prefs, request.path)
respList(list, timeline, list.title, vnode) respList(list, timeline, list.title, vnode)
@ -54,6 +54,6 @@ proc createListRouter*(cfg: Config) =
cond '.' notin @"id" cond '.' notin @"id"
let let
prefs = cookiePrefs() prefs = cookiePrefs()
list = await getCachedList(id=(@"id")) list = await getList(id=(@"id"))
members = await getGraphListMembers(list, getCursor()) members = await getGraphListMembers(list, getCursor())
respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))

View File

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

View File

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

View File

@ -4,27 +4,14 @@ import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat,
import jester, karax/vdom import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, formatters, api, redis_cache] import ".."/[types, formatters, api]
import ../views/[general, status, mastoapi] import ../views/[general, status, search, mastoapi]
export json, uri, sequtils, options, sugar, times export json, uri, sequtils, options, sugar, times
export router_utils export router_utils
export api, formatters export api, formatters
export status, mastoapi export status, mastoapi
proc formatStat*(stat: int): string =
#if stat > 1000000000000:
# result = formatBiggestFloat(stat / 1000000000000, ffDecimal, precision = 1) & "T"
#el
if stat > 1000000000:
result = formatBiggestFloat(stat / 1000000000, ffDecimal, precision = 1) & "B"
elif stat > 1000000:
result = formatBiggestFloat(stat / 1000000, ffDecimal, precision = 1) & "M"
elif stat > 1000:
result = formatBiggestFloat(stat / 1000, ffDecimal, precision = 1) & "K"
else:
result = $stat
proc createStatusRouter*(cfg: Config) = proc createStatusRouter*(cfg: Config) =
router status: router status:
get "/@name/status/@id/@reactors": get "/@name/status/@id/@reactors":
@ -43,36 +30,20 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, "" resp Http404, ""
resp $renderReplies(replies, prefs, getPath()) resp $renderReplies(replies, prefs, getPath())
#if @"reactors" == "favoriters": if @"reactors" == "favoriters":
# resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs), resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
# request, cfg, prefs) request, cfg, prefs)
#elif @"reactors" == "retweeters": elif @"reactors" == "retweeters":
# resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs), resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
# request, cfg, prefs) request, cfg, prefs)
get "/@name/status/@id/?@m?/?@i?/?": get "/@name/status/@id/?":
cond '.' notin @"name" cond '.' notin @"name"
var var id = @"id"
id = @"id" var rawFile = false
media = @"m" if id.endsWith(".mp4"):
mediaIndex = @"i" rawFile = true
id.removeSuffix(".mp4")
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 request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
if id.len > 19 or id.any(c => not c.isDigit): if id.len > 19 or id.any(c => not c.isDigit):
@ -80,11 +51,11 @@ proc createStatusRouter*(cfg: Config) =
let prefs = cookiePrefs() let prefs = cookiePrefs()
let conv = await getCachedTweet(id) let conv = await getTweet(id)
if conv == nil: if conv == nil:
echo "nil conv" echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0: if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Record not found" var error = "Record not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone error = conv.tweet.tombstone
@ -94,33 +65,7 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, {"Content-Type": "application/json"}, $errJson resp Http404, {"Content-Type": "application/json"}, $errJson
let tweet = conv.tweet let postJson = getActivityStream(conv.tweet, cfg, prefs)
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 resp Http200, {"Content-Type": "application/json"}, $postJson
@ -136,11 +81,11 @@ proc createStatusRouter*(cfg: Config) =
resp Http404, "" resp Http404, ""
resp $renderReplies(replies, prefs, getPath()) resp $renderReplies(replies, prefs, getPath())
let conv = await getCachedTweet(id, getCursor()) let conv = await getTweet(id, getCursor())
if conv == nil: if conv == nil:
echo "nil conv" echo "nil conv"
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0: if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
var error = "Tweet not found" var error = "Tweet not found"
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
error = conv.tweet.tombstone error = conv.tweet.tombstone
@ -154,38 +99,6 @@ proc createStatusRouter*(cfg: Config) =
avatar = tweet.user.userPic avatar = tweet.user.userPic
time = some(tweet.time) time = some(tweet.time)
let
ua = request.headers.getOrDefault("User-Agent").toString()
isChatEmbedder = ua.contains("Discordbot") or ua.contains("TelegramBot")
var
realMediaIndex = mediaIndex
realUseVideo = false
if isChatEmbedder and media.len > 0:
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
tweet.photos = @[]
elif media == "photo" and tweet.photos.len > 0:
if mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if tweet.video.isSome or tweet.gif.isSome:
useVideo = true
realUseVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
tweet.photos = @[]
else:
realMediaIndex = $index
index -= 1
tweet.video = none(Video)
let image = tweet.photos[index]
tweet.photos = @[]
tweet.photos.add(image)
var var
images = tweet.photos images = tweet.photos
video = "" video = ""
@ -196,21 +109,19 @@ proc createStatusRouter*(cfg: Config) =
let let
quote = get(tweet.quote) quote = get(tweet.quote)
quoteUser = quote.user quoteUser = quote.user
if tweet.replyId.len != 0: if tweet.replyId != 0:
let replyUser = await getCachedUser(tweet.replyHandle) context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})"
context = &"↩ {replyUser.fullname} (@{tweet.replyHandle})\n↘ {quoteUser.fullname} (@{quoteUser.username})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}" contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
else: else:
context = &"{quoteUser.fullname} (@{quoteUser.username})" context = &"Quoting: {quoteUser.fullname} (@{quoteUser.username})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}" contextUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
elif tweet.replyId.len != 0: elif tweet.replyId != 0:
let replyUser = await getCachedUser(tweet.replyHandle) context = &"↩ Replying to: @{tweet.replyHandle}"
context = &"↩ {replyUser.fullname} (@{tweet.replyHandle})"
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}" contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
if tweet.video.isSome(): if tweet.video.isSome():
let videoObj = get(tweet.video) let videoObj = get(tweet.video)
images.add(Image(url:videoObj.thumb)) images = @[videoObj.thumb]
let vars = videoObj.variants.filterIt(it.contentType == mp4) let vars = videoObj.variants.filterIt(it.contentType == mp4)
# idk why this wont sort when it sorts everywhere else # idk why this wont sort when it sorts everywhere else
@ -218,7 +129,7 @@ proc createStatusRouter*(cfg: Config) =
video = vars[^1].url video = vars[^1].url
elif tweet.gif.isSome(): elif tweet.gif.isSome():
let gif = get(tweet.gif) let gif = get(tweet.gif)
images.add(Image(url:gif.thumb)) images = @[gif.thumb]
video = getUrlPrefix(cfg) & getPicUrl(gif.url) video = getUrlPrefix(cfg) & getPicUrl(gif.url)
#elif tweet.card.isSome(): #elif tweet.card.isSome():
# let card = tweet.card.get() # let card = tweet.card.get()
@ -227,60 +138,20 @@ proc createStatusRouter*(cfg: Config) =
# elif card.video.isSome(): # elif card.video.isSome():
# images = @[card.video.get().thumb] # images = @[card.video.get().thumb]
if rawVideo and video != "": if rawFile and video != "":
redirect(video) redirect(video)
elif rawImage and images.len > 0:
if media == "photo" and mediaIndex.len > 0:
var index = parseInt(mediaIndex)
var useVideo = false
if index > tweet.photos.len:
if video != "":
useVideo = true
else:
index = tweet.photos.len
elif index < 1:
index = 1
if useVideo:
redirect(video)
else:
index -= 1
redirect(getPicUrl(images[index].url))
else:
redirect(getPicUrl(images[0].url))
var query = ""
if media == "video":
query = "video"
elif media == "photo" and mediaIndex.len > 0:
if realUseVideo and video != "":
query = "video"
else:
query = &"photo:{realMediaIndex}"
var stats: seq[string] = @[]
if tweet.stats.replies > 0:
stats.add("" & formatStat(tweet.stats.replies))
if tweet.stats.retweets > 0:
stats.add("🔁 " & formatStat(tweet.stats.retweets))
if tweet.stats.quotes > 0:
stats.add("" & formatStat(tweet.stats.quotes))
if tweet.stats.likes > 0:
stats.add("" & formatStat(tweet.stats.likes))
if tweet.stats.bookmarks > 0:
stats.add("🔖 " & formatStat(tweet.stats.bookmarks))
if tweet.stats.views > 0:
stats.add("👁️ " & formatStat(tweet.stats.views))
let statsStr = stats.join(" ")
let html = renderConversation(conv, prefs, getPath() & "#m") let html = renderConversation(conv, prefs, getPath() & "#m")
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
images=images, video=video, avatar=avatar, time=time, images=images, video=video, avatar=avatar, time=time,
context=context, contextUrl=contextUrl, id=id, context=context, contextUrl=contextUrl, id=id)
media=query, stats=statsStr)
get "/@name/statuses/@id/?@m?/?@i?": 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/?":
redirect("/$1/status/$2" % [@"name", @"id"]) redirect("/$1/status/$2" % [@"name", @"id"])
get "/i/web/status/@id": get "/i/web/status/@id":

View File

@ -3,13 +3,13 @@ import asyncdispatch, strutils, sequtils, uri, options, times, json
import jester, karax/vdom import jester, karax/vdom
import router_utils import router_utils
import ".."/[types, formatters, query, api, redis_cache] import ".."/[types, formatters, query, api]
import ../views/[general, profile, timeline, status, search, mastoapi] import ../views/[general, profile, timeline, status, search, mastoapi]
export vdom export vdom
export uri, sequtils, json export uri, sequtils, json
export router_utils export router_utils
export formatters, query, api, redis_cache export formatters, query, api
export profile, timeline, status, mastoapi export profile, timeline, status, mastoapi
proc getQuery*(request: Request; tab, name: string): Query = proc getQuery*(request: Request; tab, name: string): Query =
@ -28,11 +28,24 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
else: else:
body body
proc getUserId(username: string): Future[string] {.async.} =
let user = await getGraphUser(username)
if user.suspended:
return "suspended"
else:
return user.id
proc getUsername*(userId: string): Future[string] {.async.} =
let user = await getGraphUserById(userId)
result = user.username
proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false; proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
skipPinned=false): Future[Profile] {.async.} = skipPinned=false): Future[Profile] {.async.} =
let let
name = query.fromUser[0] name = query.fromUser[0]
userId = await getCachedUserId(name) userId = await getUserId(name)
if userId.len == 0: if userId.len == 0:
return Profile(user: User(username: name)) return Profile(user: User(username: name))
@ -48,9 +61,9 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
let let
rail = rail =
skipIf(skipRail or query.kind == media, @[]): skipIf(skipRail or query.kind == media, @[]):
getCachedPhotoRail(userId) getPhotoRail(name)
user = getCachedUser(name) user = getGraphUser(name)
result = result =
case query.kind case query.kind
@ -83,7 +96,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
let pHtml = renderProfile(profile, cfg, prefs, getPath()) let pHtml = renderProfile(profile, cfg, prefs, getPath())
result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u), result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
rss=rss, images = @[Image(url: u.getUserPic("_400x400"))], rss=rss, images = @[u.getUserPic("_400x400")],
banner=u.banner) banner=u.banner)
template respTimeline*(timeline: typed) = template respTimeline*(timeline: typed) =
@ -94,7 +107,7 @@ template respTimeline*(timeline: typed) =
template respUserId*() = template respUserId*() =
cond @"user_id".len > 0 cond @"user_id".len > 0
let username = await getCachedUsername(@"user_id") let username = await getUsername(@"user_id")
if username.len > 0: if username.len > 0:
redirect("/" & username) redirect("/" & username)
else: else:
@ -111,7 +124,6 @@ proc createTimelineRouter*(cfg: Config) =
get "/@name/?@tab?/?": get "/@name/?@tab?/?":
cond '.' notin @"name" cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
cond @"tab" in ["with_replies", "media", "search", "following", "followers", ""] cond @"tab" in ["with_replies", "media", "search", "following", "followers", ""]
let let
prefs = cookiePrefs() prefs = cookiePrefs()
@ -121,17 +133,17 @@ proc createTimelineRouter*(cfg: Config) =
case tab: case tab:
of "followers": of "followers":
resp renderMain(renderUserList(await getGraphFollowers(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs) resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
of "following": of "following":
resp renderMain(renderUserList(await getGraphFollowing(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs) resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
else: else:
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
let userId = await getCachedUserId(@"name") let userId = await getUserId(@"name")
if userId == "suspended" or userId.len == 0: if userId == "suspended" or userId.len == 0:
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}""" resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
let user = await getCachedUser(@"name") let user = await getGraphUser(@"name")
let userJson = getActivityStream(user, cfg, prefs) let userJson = getActivityStream(user, cfg, prefs)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,62 +0,0 @@
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)

168
src/tokens.nim Normal file
View File

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

View File

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

View File

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

View File

@ -29,18 +29,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
tdiv(class="nav-item right"): tdiv(class="nav-item right"):
icon "search", title="Search", href="/search" icon "search", title="Search", href="/search"
if cfg.enableRss and rss.len > 0: #if cfg.enableRss and rss.len > 0:
icon "rss-feed", title="RSS Feed", href=rss #icon "rss-feed", title="RSS Feed", href=rss
icon "bird", title="Open in Twitter", href=canonical icon "bird", title="Open in Twitter", href=canonical
a(href="https://liberapay.com/zedeus"): verbatim lp a(href="https://liberapay.com/zedeus"): verbatim lp
icon "info", title="About", href="/about" icon "info", title="About", href="/about"
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
video=""; images: seq[Image] = @[]; banner=""; ogTitle=""; video=""; images: seq[string] = @[]; banner=""; ogTitle="";
rss=""; canonical=""; avatar=""; context=""; contextUrl=""; rss=""; canonical=""; avatar=""; context=""; contextUrl="";
id=""; time: Option[DateTime] = none(DateTime); media=""; id=""; time: Option[DateTime] = none(DateTime)): VNode =
stats = ""): VNode =
var theme = prefs.theme.toTheme var theme = prefs.theme.toTheme
if "theme" in req.params: if "theme" in req.params:
theme = req.params["theme"].toTheme theme = req.params["theme"].toTheme
@ -55,19 +54,17 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=20") link(rel="stylesheet", type="text/css", href="/css/style.css?v=20")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
link(rel="stylesheet", href="/css/baguetteBox.min.css") link(rel="stylesheet", href="/css/baguetteBox.min.css")
script(src="/js/baguetteBox.min.js", `async`="") script(src="/js/baguetteBox.min.js", `async`="")
script(src="/js/zoom.js") script(src="/js/zoom.js")
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
if theme.len > 0: if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.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="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="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
link(rel="manifest", href="/site.webmanifest") link(rel="manifest", href="/site.webmanifest")
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60") link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
@ -76,8 +73,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if canonical.len > 0: if canonical.len > 0:
link(rel="canonical", href=canonical) link(rel="canonical", href=canonical)
if cfg.enableRss and rss.len > 0: #if cfg.enableRss and rss.len > 0:
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") #link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
if prefs.hlsPlayback: if prefs.hlsPlayback:
script(src="/js/hls.light.min.js", `defer`="") script(src="/js/hls.light.min.js", `defer`="")
@ -86,9 +83,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
if prefs.infiniteScroll: if prefs.infiniteScroll:
script(src="/js/infiniteScroll.js", `defer`="") script(src="/js/infiniteScroll.js", `defer`="")
# Eir: load custom js # load custom js
if prefs.eirResources: script(src="/js/eirResources.js", `defer`="")
script(src="/js/eirResources.js", `defer`="")
title: title:
if titleText.len > 0: if titleText.len > 0:
@ -108,22 +104,18 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
meta(property="og:title", content=finalizedTitleText) meta(property="og:title", content=finalizedTitleText)
meta(property="og:description", content=finalizedDesc) meta(property="og:description", content=finalizedDesc)
meta(property="og:locale", content="en_US") meta(property="og:locale", content="en_US")
meta(name="referrer", content="no-referrer")
var siteName = cfg.title var siteName = cfg.title
if time.isSome:
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
if time.isSome and not isDiscord:
let timeObj = time.get let timeObj = time.get
let timeStr = $timeObj let timeStr = $timeObj
meta(property="og:article:published_time", content=timeStr) meta(property="og:article:published_time", content=timeStr)
if not isDiscord: let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss")
let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss") siteName = &"{cfg.title} • {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) meta(property="og:site_name", content=siteName)
@ -132,16 +124,13 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
link(rel="preload", type="image/png", href=bannerUrl, `as`="image") link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
if images.len > 0: if images.len > 0:
for imageObj in images: for url in images:
let let preloadUrl = if "400x400" in url: getPicUrl(url)
url = imageObj.url
preloadUrl = if "400x400" in url: getPicUrl(url)
else: getSmallPic(url) else: getSmallPic(url)
link(rel="preload", type="image/png", href=preloadUrl, `as`="image") link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
let image = getUrlPrefix(cfg) & getPicUrl(url) let image = getUrlPrefix(cfg) & getPicUrl(url)
meta(property="og:image", content=image) meta(property="og:image", content=image)
meta(property="og:image:alt", content=imageObj.description)
if video.len == 0: if video.len == 0:
meta(property="twitter:image:src", content=image) meta(property="twitter:image:src", content=image)
meta(property="twitter:card", content="summary_large_image") meta(property="twitter:card", content="summary_large_image")
@ -178,36 +167,24 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
author = encodeUrl(context) 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") 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/" link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/users/i/statuses/{id}"), type="application/activity+json")
if media.len > 0:
if media == "video":
fediUrl &= "422209040515" # 42 + "video"
else:
let parts = media.split(":")
fediUrl &= "421608152015" # 42 + "photo"
if parts.len == 2:
fediUrl &= parts[1] # + index
fediUrl &= id
link(rel="alternate", href=fediUrl, type="application/activity+json")
# this is last so images are also preloaded # this is last so images are also preloaded
# if this is done earlier, Chrome only preloads one image for some reason # if this is done earlier, Chrome only preloads one image for some reason
link(rel="preload", type="font/woff2", `as`="font", link(rel="preload", type="font/woff2", `as`="font",
href="/fonts/fontello.woff2?76162212", crossorigin="anonymous") href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
titleText=""; desc=""; ogTitle=""; rss=""; video=""; titleText=""; desc=""; ogTitle=""; rss=""; video="";
images: seq[Image] = @[]; banner=""; avatar=""; context=""; images: seq[string] = @[]; banner=""; avatar=""; context="";
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime); contextUrl=""; id=""; time: Option[DateTime] = none(DateTime)
media=""; stats=""): string = ): string =
let canonical = getTwitterLink(req.path, req.params) let canonical = getTwitterLink(req.path, req.params)
let node = buildHtml(html(lang="en")): let node = buildHtml(html(lang="en")):
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
rss, canonical, avatar, context, contextUrl, id, time, media, rss, canonical, avatar, context, contextUrl, id, time)
stats)
body: body:
renderNavbar(cfg, req, rss, canonical) renderNavbar(cfg, req, rss, canonical)

217
src/views/mastoapi.nim Normal file → Executable file
View File

@ -1,30 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, options, json, sequtils, times, math import strutils, strformat, options, json, sequtils, times
import ".."/[types, formatters, utils] import ".."/[types, formatters, utils]
proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string = proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string =
var content = replaceUrls(tweet.text, prefs, absolute=getUrlPrefix(cfg)) var content = replaceUrls(tweet.text, prefs)
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(): if tweet.quote.isSome():
let let
quote = get(tweet.quote) quote = get(tweet.quote)
quoteContent = replaceUrls(quote.text, prefs, absolute=getUrlPrefix(cfg)) quoteContent = replaceUrls(quote.text, prefs)
quoteUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}" 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}" content &= &"\n\n<blockquote><b>↘ <a href=\"{quoteUrl}\">{quote.user.fullName} (@{quote.user.username})</a></b>\n{quoteContent}"
@ -42,8 +26,8 @@ proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string =
if tweet.birdwatch.isSome(): if tweet.birdwatch.isSome():
let let
note = get(tweet.birdwatch) note = get(tweet.birdwatch)
noteContent = replaceUrls(note.text, prefs, absolute=getUrlPrefix(cfg)) noteContent = replaceUrls(note.text, prefs)
content &= &"\n\n<blockquote><b>ⓘ {note.title}</b>\n{noteContent}</blockquote>" content &= &"\n<blockquote><b>ⓘ {note.title}</b>\n{noteContent}</blockquote>"
result = content.replace("\n", "<br>") result = content.replace("\n", "<br>")
@ -54,82 +38,39 @@ proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
var media: seq[JsonNode] = @[] var media: seq[JsonNode] = @[]
if tweet.photos.len > 0: if tweet.photos.len > 0:
for imageObj in tweet.photos: for url in tweet.photos:
let let image = getUrlPrefix(cfg) & getPicUrl(url)
image = getUrlPrefix(cfg) & getPicUrl(imageObj.url)
splitUrl = imageObj.url.split('.')
var filetype = splitUrl[^1]
if filetype == "jpg":
filetype = "jpeg"
var mediaObj = newJObject() var mediaObj = newJObject()
mediaObj["type"] = %"Image"
mediaObj["mediaType"] = %("image/" & filetype) mediaObj["type"] = %"Document"
mediaObj["mediaType"] = %"image/png"
mediaObj["url"] = %image mediaObj["url"] = %image
mediaObj["name"] = %imageObj.description mediaObj["name"] = newJNull() # FIXME a11y
media.add(mediaObj) media.add(mediaObj)
if tweet.video.isSome(): if tweet.video.isSome():
let let
videoObj = get(tweet.video) videoObj = get(tweet.video)
vars = videoObj.variants.filterIt(it.contentType == mp4) 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() 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() mediaObj["type"] = %"Document"
wrapper["type"] = %"Video" mediaObj["mediaType"] = %"video/mp4"
wrapper["name"] = %description mediaObj["url"] = %vars[^1].url
wrapper["url"] = %url mediaObj["name"] = newJNull() # FIXME a11y
media.add(wrapper)
media.add(mediaObj)
elif tweet.gif.isSome(): elif tweet.gif.isSome():
let let gif = get(tweet.gif)
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() 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() mediaObj["type"] = %"Document"
wrapper["type"] = %"Video" mediaObj["mediaType"] = %"video/mp4"
wrapper["name"] = newJNull() mediaObj["url"] = %(&"https://{gif.url}")
wrapper["url"] = %url mediaObj["name"] = newJNull() # FIXME a11y
media.add(wrapper)
media.add(mediaObj)
var context: seq[JsonNode] = @[] var context: seq[JsonNode] = @[]
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams" let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
@ -148,7 +89,7 @@ proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
postJson["id"] = %tweetUrl postJson["id"] = %tweetUrl
postJson["type"] = %"Note" postJson["type"] = %"Note"
postJson["summary"] = newJNull() postJson["summary"] = newJNull()
if tweet.replyId.len != 0: if tweet.replyId != 0:
let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}" let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
postJson["inReplyTo"] = %replyUrl postJson["inReplyTo"] = %replyUrl
postJson["inReplyToAtomUri"] = %replyUrl postJson["inReplyToAtomUri"] = %replyUrl
@ -200,43 +141,6 @@ proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
asProps["movedTo"] = contextMovedTo asProps["movedTo"] = contextMovedTo
context.add(asProps) 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() var userJson = newJObject()
userJson["@context"] = %context userJson["@context"] = %context
userJson["id"] = %userUrl userJson["id"] = %userUrl
@ -258,7 +162,7 @@ proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
userJson["memorial"] = %false userJson["memorial"] = %false
userJson["publicKey"] = newJNull() userJson["publicKey"] = newJNull()
userJson["tag"] = newJArray() userJson["tag"] = newJArray()
userJson["attachment"] = %fields userJson["attachment"] = newJArray()
userJson["endpoints"] = newJObject() userJson["endpoints"] = newJObject()
userJson["icon"] = %*{ userJson["icon"] = %*{
"type": "Image", "type": "Image",
@ -272,70 +176,3 @@ proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
} }
result = userJson result = userJson
proc getMastoAPIUser*(user: User, cfg: Config): JsonNode =
var fields: seq[JsonNode] = @[]
if user.location.len > 0:
var location = newJObject()
location["name"] = %"Location"
location["value"] = %user.location
location["verified_at"] = newJNull()
fields.add(location)
if user.website.len > 0:
var website = newJObject()
website["name"] = %"Website"
website["value"] = %(&"<a href=\"{user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{user.website}</a>")
website["verified_at"] = newJNull()
fields.add(website)
if user.botOwner.len > 0:
var botOwner = newJObject()
botOwner["name"] = %"Automated by"
botOwner["value"] = %(&"<a href=\"{getUrlPrefix(cfg)}/{user.botOwner}\" translate=\"no\">{user.botOwner}</a>")
botOwner["verified_at"] = newJNull()
fields.add(botOwner)
if user.pcf != "" and user.pcf != "None":
var pcf = newJObject()
pcf["name"] = %"PCF Label"
pcf["value"] = %user.pcf
pcf["verified_at"] = newJNull()
fields.add(pcf)
if user.verifiedType != none:
var verified = newJObject()
verified["name"] = %"Verified Type"
verified["value"] = %user.verifiedType
verified["verified_at"] = newJNull()
fields.add(verified)
var userJson = newJObject()
userJson["id"] = %user.id
userJson["username"] = %user.username
userJson["acct"] = %user.username
userJson["display_name"] = %user.fullname
userJson["locked"] = %user.protected
userJson["bot"] = %user.bot
userJson["discoverable"] = %true
userJson["indexable"] = %false
userJson["group"] = %false
userJson["created_at"] = %($user.joinDate)
userJson["note"] = %user.bio
userJson["url"] = %(&"{getUrlPrefix(cfg)}/{user.username}")
userJson["uri"] = %(&"{getUrlPrefix(cfg)}/{user.username}")
userJson["avatar"] = %(getUrlPrefix(cfg) & getPicUrl(user.userPic))
userJson["avatar_static"] = %(getUrlPrefix(cfg) & getPicUrl(user.userPic))
userJson["header"] = %(getUrlPrefix(cfg) & getPicUrl(user.banner))
userJson["header_static"] = %(getUrlPrefix(cfg) & getPicUrl(user.banner))
userJson["followers_count"] = %user.followers
userJson["following_count"] = %user.following
userJson["statuses_count"] = %user.tweets
userJson["hide_collections"] = %false
userJson["noindex"] = %false
userJson["emojis"] = %(@[])
userJson["roles"] = %(@[])
userJson["fields"] = %fields
result = userJson

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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