Compare commits
67 Commits
cb84ed219b
...
d973c6c2ee
| Author | SHA1 | Date | |
|---|---|---|---|
| d973c6c2ee | |||
| ec60cfc24d | |||
|
|
da09fbcaf5 | ||
|
|
7b15b8f0a2 | ||
|
|
413882f650 | ||
|
|
0bc6b33251 | ||
|
|
116652c2a5 | ||
|
|
a7d056a550 | ||
|
|
e96606eba3 | ||
|
|
1b8275d1b8 | ||
|
|
ec019eef72 | ||
|
|
0e74c1e9bd | ||
|
|
26853a83ca | ||
|
|
bed4014d4e | ||
|
|
1834742bb9 | ||
|
|
d3d6558913 | ||
|
|
cef5429cdc | ||
|
|
c3bcf30826 | ||
|
|
f6e9887ddb | ||
|
|
0f6afc2764 | ||
|
|
4fcbf7ba53 | ||
|
|
9751237316 | ||
|
|
b9d8ec6773 | ||
|
|
3445b183cd | ||
|
|
a943767f42 | ||
|
|
1fcf017359 | ||
|
|
71b19ae72b | ||
|
|
b2d71407ba | ||
|
|
80cca7b070 | ||
|
|
c09266cd17 | ||
|
|
9b3862de69 | ||
|
|
4064bd5c11 | ||
|
|
a6412968fe | ||
|
|
71c772d6c9 | ||
|
|
f76cfcc154 | ||
|
|
536f9d7fab | ||
|
|
ce4dc8a9e2 | ||
|
|
a5c9e78f5b | ||
|
|
4da3904b89 | ||
|
|
68e90344d6 | ||
|
|
91d3f4138c | ||
|
|
900054bab0 | ||
|
|
33db7b46cb | ||
|
|
8042c65ce3 | ||
|
|
f2cac5190a | ||
|
|
d985bbcf5c | ||
|
|
38f2ede5c0 | ||
|
|
7a6548cb2b | ||
|
|
24a267da50 | ||
|
|
42e8e7219e | ||
|
|
8304d99f15 | ||
|
|
0023af4311 | ||
|
|
5772e4089c | ||
|
|
d9aa2d1723 | ||
|
|
1a520f5792 | ||
|
|
0d9ffa6aa2 | ||
|
|
5240ccff2a | ||
|
|
b3e35dba12 | ||
|
|
c7d6b4291c | ||
|
|
6e86e086c3 | ||
|
|
271287e6f3 | ||
|
|
3208e7d25f | ||
|
|
5e2d126aea | ||
|
|
be4c83bfb0 | ||
|
|
c9b2e94ba9 | ||
|
|
1cf37e4e84 | ||
|
|
396322772f |
@ -26,12 +26,7 @@ enableRSS = true # set this to false to disable RSS feeds
|
|||||||
enableDebug = false # enable request logs and debug endpoints (/.accounts)
|
enableDebug = false # enable request logs and debug endpoints (/.accounts)
|
||||||
proxy = "" # http/https url, SOCKS proxies are not supported
|
proxy = "" # http/https url, SOCKS proxies are not supported
|
||||||
proxyAuth = ""
|
proxyAuth = ""
|
||||||
tokenCount = 10
|
disableTid = false # enable this if cookie-based auth is failing
|
||||||
# minimum amount of usable tokens. tokens are used to authorize API requests,
|
|
||||||
# but they expire after ~1 hour, and have a limit of 500 requests per endpoint.
|
|
||||||
# the limits reset every 15 minutes, and the pool is filled up so there's
|
|
||||||
# always at least `tokenCount` usable tokens. only increase this if you receive
|
|
||||||
# major bursts all the time and don't have a rate limiting setup via e.g. nginx
|
|
||||||
|
|
||||||
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
# Change default preferences here, see src/prefs_impl.nim for a complete list
|
||||||
[Preferences]
|
[Preferences]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.8 KiB |
122
public/css/fontello.css
vendored
122
public/css/fontello.css
vendored
@ -1,16 +1,27 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'fontello';
|
font-family: "fontello";
|
||||||
src: url('/fonts/fontello.eot?21002321');
|
src: url("/fonts/fontello.eot?76162212");
|
||||||
src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'),
|
src:
|
||||||
url('/fonts/fontello.woff2?21002321') format('woff2'),
|
url("/fonts/fontello.eot?76162212#iefix") format("embedded-opentype"),
|
||||||
url('/fonts/fontello.woff?21002321') format('woff'),
|
url("/fonts/fontello.woff2?76162212") format("woff2"),
|
||||||
url('/fonts/fontello.ttf?21002321') format('truetype'),
|
url("/fonts/fontello.woff?76162212") format("woff"),
|
||||||
url('/fonts/fontello.svg?21002321#fontello') format('svg');
|
url("/fonts/fontello.ttf?76162212") format("truetype"),
|
||||||
|
url("/fonts/fontello.svg?76162212#fontello") format("svg");
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
|
||||||
[class^="icon-"]:before, [class*=" icon-"]:before {
|
/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
|
||||||
|
/*
|
||||||
|
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||||
|
@font-face {
|
||||||
|
font-family: 'fontello';
|
||||||
|
src: url('../font/fontello.svg?76162212#fontello') format('svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
[class^="icon-"]:before,
|
||||||
|
[class*=" icon-"]:before {
|
||||||
font-family: "fontello";
|
font-family: "fontello";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@ -19,7 +30,9 @@
|
|||||||
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;
|
||||||
@ -28,26 +41,81 @@
|
|||||||
/* fix buttons height, for twitter bootstrap */
|
/* fix buttons height, for twitter bootstrap */
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
|
|
||||||
|
/* Animation center compensation - margins should be symmetric */
|
||||||
|
/* remove if not needed */
|
||||||
|
margin-left: 0.2em;
|
||||||
|
|
||||||
|
/* you can be more comfortable with increased icons size */
|
||||||
|
/* font-size: 120%; */
|
||||||
|
|
||||||
/* Font smoothing. That was taken from TWBS */
|
/* Font smoothing. That was taken from TWBS */
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
/* Uncomment for 3D effect */
|
||||||
|
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-heart:before { content: '\2665'; } /* '♥' */
|
.icon-heart:before {
|
||||||
.icon-quote:before { content: '\275e'; } /* '❞' */
|
content: "\2665";
|
||||||
.icon-comment:before { content: '\e802'; } /* '' */
|
} /* '♥' */
|
||||||
.icon-ok:before { content: '\e803'; } /* '' */
|
.icon-quote:before {
|
||||||
.icon-play:before { content: '\e804'; } /* '' */
|
content: "\275e";
|
||||||
.icon-link:before { content: '\e805'; } /* '' */
|
} /* '❞' */
|
||||||
.icon-calendar:before { content: '\e806'; } /* '' */
|
.icon-ok:before {
|
||||||
.icon-location:before { content: '\e807'; } /* '' */
|
content: "\e800";
|
||||||
.icon-picture:before { content: '\e809'; } /* '' */
|
} /* '' */
|
||||||
.icon-lock:before { content: '\e80a'; } /* '' */
|
.icon-play:before {
|
||||||
.icon-down:before { content: '\e80b'; } /* '' */
|
content: "\e801";
|
||||||
.icon-retweet:before { content: '\e80d'; } /* '' */
|
} /* '' */
|
||||||
.icon-search:before { content: '\e80e'; } /* '' */
|
.icon-comment:before {
|
||||||
.icon-pin:before { content: '\e80f'; } /* '' */
|
content: "\e802";
|
||||||
.icon-cog:before { content: '\e812'; } /* '' */
|
} /* '' */
|
||||||
.icon-rss-feed:before { content: '\e813'; } /* '' */
|
.icon-link:before {
|
||||||
.icon-info:before { content: '\f128'; } /* '' */
|
content: "\e803";
|
||||||
.icon-bird:before { content: '\f309'; } /* '' */
|
} /* '' */
|
||||||
|
.icon-calendar:before {
|
||||||
|
content: "\e804";
|
||||||
|
} /* '' */
|
||||||
|
.icon-picture:before {
|
||||||
|
content: "\e805";
|
||||||
|
} /* '' */
|
||||||
|
.icon-lock:before {
|
||||||
|
content: "\e806";
|
||||||
|
} /* '' */
|
||||||
|
.icon-down:before {
|
||||||
|
content: "\e807";
|
||||||
|
} /* '' */
|
||||||
|
.icon-retweet:before {
|
||||||
|
content: "\e808";
|
||||||
|
} /* '' */
|
||||||
|
.icon-search:before {
|
||||||
|
content: "\e809";
|
||||||
|
} /* '' */
|
||||||
|
.icon-pin:before {
|
||||||
|
content: "\e80a";
|
||||||
|
} /* '' */
|
||||||
|
.icon-cog:before {
|
||||||
|
content: "\e80b";
|
||||||
|
} /* '' */
|
||||||
|
.icon-info:before {
|
||||||
|
content: "\e80c";
|
||||||
|
} /* '' */
|
||||||
|
.icon-bookmark:before {
|
||||||
|
content: "\e80d";
|
||||||
|
} /* '' */
|
||||||
|
.icon-eye:before {
|
||||||
|
content: "\e80e";
|
||||||
|
} /* '' */
|
||||||
|
.icon-pcf:before {
|
||||||
|
content: "\e83a";
|
||||||
|
} /* '' */
|
||||||
|
.icon-location:before {
|
||||||
|
content: "\f031";
|
||||||
|
} /* '' */
|
||||||
|
.icon-bird:before {
|
||||||
|
content: "\f099";
|
||||||
|
} /* '' */
|
||||||
|
.icon-rss-feed:before {
|
||||||
|
content: "\f09e";
|
||||||
|
} /* '' */
|
||||||
|
|||||||
BIN
public/css/fonts/Hack-Bold.ttf
Executable file
BIN
public/css/fonts/Hack-Bold.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/Hack-BoldItalic.ttf
Executable file
BIN
public/css/fonts/Hack-BoldItalic.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/Hack-Italic.ttf
Executable file
BIN
public/css/fonts/Hack-Italic.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/Hack-Regular.ttf
Executable file
BIN
public/css/fonts/Hack-Regular.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/TerminessNerdFontMono-Bold.ttf
Executable file
BIN
public/css/fonts/TerminessNerdFontMono-Bold.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/TerminessNerdFontMono-BoldItalic.ttf
Executable file
BIN
public/css/fonts/TerminessNerdFontMono-BoldItalic.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/TerminessNerdFontMono-Italic.ttf
Executable file
BIN
public/css/fonts/TerminessNerdFontMono-Italic.ttf
Executable file
Binary file not shown.
BIN
public/css/fonts/TerminessNerdFontMono-Regular.ttf
Executable file
BIN
public/css/fonts/TerminessNerdFontMono-Regular.ttf
Executable file
Binary file not shown.
98
public/css/themes/eir.css
Executable file
98
public/css/themes/eir.css
Executable file
@ -0,0 +1,98 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: hack;
|
||||||
|
src: url("../fonts/Hack-Regular.ttf");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: hack;
|
||||||
|
src: url("../fonts/Hack-Bold.ttf");
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: hack;
|
||||||
|
src: url("../fonts/Hack-Italic.ttf");
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: hack;
|
||||||
|
src: url("../fonts/Hack-BoldItalic.ttf");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Terminess;
|
||||||
|
src: url("../fonts/TerminessNerdFontMono-Regular.ttf");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: Terminess;
|
||||||
|
src: url("../fonts/TerminessNerdFontMono-Bold.ttf");
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: Terminess;
|
||||||
|
src: url("../fonts/TerminessNerdFontMono-Italic.ttf");
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: Terminess;
|
||||||
|
src: url("../fonts/TerminessNerdFontMono-BoldItalic.ttf");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-image: url("https://eir-nya.gay/static/images/background/night_sky.png");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: 50% 0%;
|
||||||
|
background-attachment: fixed;
|
||||||
|
|
||||||
|
font-family: Terminess, hack, 'Courier New', courier, monospace;
|
||||||
|
}
|
||||||
|
.tweet-content {
|
||||||
|
font-family: hack, 'Courier New', courier, monospace;
|
||||||
|
}
|
||||||
|
.show-more {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
--bg_color: transparent; /*#282a36;*/
|
||||||
|
--fg_color: #f8f8f2;
|
||||||
|
--fg_faded: #818eb6;
|
||||||
|
--fg_dark: var(--fg_faded);
|
||||||
|
--fg_nav: var(--accent);
|
||||||
|
|
||||||
|
--bg_panel: rgba(0.9803921568627451, 0.6666666666666666, 0.6705882352941176, 0.875); /*#343746;*/
|
||||||
|
--bg_elements: #292b36;
|
||||||
|
--bg_overlays: #20202080; /*#44475a;*/
|
||||||
|
--bg_hover: #2f323f;
|
||||||
|
|
||||||
|
--grey: var(--fg_faded);
|
||||||
|
--dark_grey: #44475a;
|
||||||
|
--darker_grey: #3d4051;
|
||||||
|
--darkest_grey: #363948;
|
||||||
|
--border_grey: #44475a;
|
||||||
|
|
||||||
|
--accent: #faaaab;
|
||||||
|
--accent_light: #facdce;
|
||||||
|
--accent_dark: #ab7475;
|
||||||
|
--accent_border: #e36f7196;
|
||||||
|
|
||||||
|
--play_button: #ffb86c;
|
||||||
|
--play_button_hover: #ffc689;
|
||||||
|
|
||||||
|
--more_replies_dots: #bd93f9;
|
||||||
|
--error_red: #ff5555;
|
||||||
|
|
||||||
|
--verified_blue: var(--accent);
|
||||||
|
--icon_text: ##F8F8F2;
|
||||||
|
|
||||||
|
--tab: #6272a4;
|
||||||
|
--tab_selected: var(--accent);
|
||||||
|
|
||||||
|
--profile_stat: #919cbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar > form input::placeholder{
|
||||||
|
color: var(--fg_faded);
|
||||||
|
}
|
||||||
Binary file not shown.
@ -1,46 +1,52 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
<?xml version="1.0" standalone="no"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<metadata>Copyright (C) 2020 by original authors @ fontello.com</metadata>
|
<metadata>Copyright (C) 2025 by original authors @ fontello.com</metadata>
|
||||||
<defs>
|
<defs>
|
||||||
<font id="fontello" horiz-adv-x="1000" >
|
<font id="fontello" horiz-adv-x="1000" >
|
||||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
|
||||||
<missing-glyph horiz-adv-x="1000" />
|
<missing-glyph horiz-adv-x="1000" />
|
||||||
<glyph glyph-name="heart" unicode="♥" d="M790 644q70-64 70-156t-70-158l-360-330-360 330q-70 66-70 158t70 156q62 58 151 58t153-58l56-52 58 52q62 58 150 58t152-58z" horiz-adv-x="860" />
|
<glyph glyph-name="heart" unicode="♥" d="M500-79q-14 0-25 10l-348 336q-5 5-15 15t-31 37-38 54-30 67-13 77q0 123 71 192t196 70q34 0 70-12t67-33 54-38 42-38q20 20 42 38t54 38 67 33 70 12q125 0 196-70t71-192q0-123-128-251l-347-335q-10-10-25-10z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="quote" unicode="❞" 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="❞" 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="" 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="" 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="" 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="" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="ok" unicode="" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
|
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
||||||
|
|
||||||
<glyph glyph-name="play" unicode="" d="M772 333l-741-412q-13-7-22-2t-9 20v822q0 14 9 20t22-2l741-412q13-7 13-17t-13-17z" horiz-adv-x="785.7" />
|
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
||||||
|
|
||||||
<glyph glyph-name="link" unicode="" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
|
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
||||||
|
|
||||||
<glyph glyph-name="calendar" unicode="" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
|
<glyph glyph-name="lock" unicode="" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="location" unicode="" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
|
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="picture" unicode="" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
|
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
||||||
|
|
||||||
<glyph glyph-name="lock" unicode="" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" />
|
<glyph glyph-name="search" unicode="" d="M643 386q0 103-73 176t-177 74-177-74-73-176 73-177 177-73 177 73 73 177z m286-465q0-29-22-50t-50-21q-30 0-50 21l-191 191q-100-69-223-69-80 0-153 31t-125 84-84 125-31 153 31 152 84 126 125 84 153 31 153-31 125-84 84-126 31-152q0-123-69-223l191-191q21-21 21-51z" horiz-adv-x="928.6" />
|
||||||
|
|
||||||
<glyph glyph-name="down" unicode="" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
|
<glyph glyph-name="pin" unicode="" d="M573 37q0-23-15-38t-37-15q-21 0-37 16l-169 169-315-236 236 315-168 169q-24 23-12 56 14 32 48 32 157 0 270 57 90 45 151 171 9 24 36 32t50-13l208-209q21-23 14-50t-32-36q-127-63-172-152-56-110-56-268z" horiz-adv-x="834" />
|
||||||
|
|
||||||
<glyph glyph-name="retweet" unicode="" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
|
<glyph glyph-name="cog" unicode="" d="M571 350q0 59-41 101t-101 42-101-42-42-101 42-101 101-42 101 42 41 101z m286 61v-124q0-7-4-13t-11-7l-104-16q-10-30-21-51 19-27 59-77 6-6 6-13t-5-13q-15-21-55-61t-53-39q-7 0-14 5l-77 60q-25-13-51-21-9-76-16-104-4-16-20-16h-124q-8 0-14 5t-6 12l-16 103q-27 9-50 21l-79-60q-6-5-14-5-8 0-14 6-70 64-92 94-4 5-4 13 0 6 5 12 8 12 28 37t30 40q-15 28-23 55l-102 15q-7 1-11 7t-5 13v124q0 7 5 13t10 7l104 16q8 25 22 51-23 32-60 77-6 7-6 14 0 5 5 12 15 20 55 60t53 40q7 0 15-5l77-60q24 13 50 21 9 76 17 104 3 16 20 16h124q7 0 13-5t7-12l15-103q28-9 51-20l79 59q5 5 13 5 7 0 14-5 72-67 92-95 4-5 4-12 0-7-4-13-9-12-29-37t-30-40q15-28 23-54l102-16q7-1 12-7t4-13z" horiz-adv-x="857.1" />
|
||||||
|
|
||||||
<glyph glyph-name="search" unicode="" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
|
<glyph glyph-name="info" unicode="" d="M494 740q86-62 86-184 0-64-42-124-12-20-88-80l-46-30q-40-34-48-60-6-16-8-44 0-14-16-14l-128 0q-16 0-16 12 4 98 28 124 16 22 48 48t56 42l24 14q22 16 34 34 28 44 28 70 0 40-26 78-28 36-92 36-68 0-94-44-28-42-28-92l-166 0q6 162 114 232 70 42 166 42 130 0 214-60z m-216-636q44 0 73-30t27-74q-2-46-32-73t-74-25q-44 0-73 29t-27 75 32 73 74 25z" horiz-adv-x="580" />
|
||||||
|
|
||||||
<glyph glyph-name="pin" unicode="" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
|
<glyph glyph-name="bookmark" unicode="" d="M650 779q12 0 24-5 19-8 29-23t11-35v-719q0-19-11-35t-29-23q-10-4-24-4-27 0-47 18l-246 236-246-236q-20-19-46-19-13 0-25 5-18 7-29 23t-11 35v719q0 19 11 35t29 23q12 5 25 5h585z" horiz-adv-x="714.3" />
|
||||||
|
|
||||||
<glyph glyph-name="cog" unicode="" d="M911 295l-133-56q-8-22-12-31l55-133-79-79-135 53q-9-4-31-12l-55-134-112 0-56 133q-11 4-33 13l-132-55-78 79 53 134q-1 3-4 9t-6 12-4 11l-131 55 0 112 131 56 14 33-54 132 78 79 133-54q22 9 33 13l55 132 112 0 56-132q14-5 31-13l133 55 80-79-54-135q6-12 12-30l133-56 0-112z m-447-111q69 0 118 48t49 118-49 119-118 50-119-50-49-119 49-118 119-48z" horiz-adv-x="928" />
|
<glyph glyph-name="eye" unicode="" d="M0 350q6 49 64 110 79 80 176 129 129 60 260 60 137-2 260-60 103-53 176-129 64-73 64-110-6-49-64-109-79-80-176-129-129-61-260-61-137 2-260 61-103 53-176 129-64 72-64 109z m264 0q0-94 69-159t167-65 167 65 69 159-69 159-167 66-167-66-69-159z m86 1q0 60 44 102t106 42 106-42 44-102-44-102-106-43-106 43-44 102z" horiz-adv-x="1000" />
|
||||||
|
|
||||||
<glyph glyph-name="rss-feed" unicode="" d="M184 93c0-51-43-91-93-91s-91 40-91 91c0 50 41 91 91 91s93-41 93-91z m261-85l-125 0c0 174-140 323-315 323l0 118c231 0 440-163 440-441z m259 0l-136 0c0 300-262 561-563 561l0 129c370 0 699-281 699-690z" horiz-adv-x="704" />
|
<glyph glyph-name="pcf" unicode="" d="M50 800c-24 0-50-25-50-50l0-350c0-111 50-250 250-250 18 0 34 1 50 3l0 147-200 0s25 100 150 100c25 0 35-4 50-9l0 109c0 46 18 84 50 100 50 25 70 15 100 0 50-25 50-25 50-25l0 175c0 25-26 50-50 50-50 0-100-50-200-50-100 0-150 50-200 50z m88-175c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m262-75c-24 0-50-25-50-50l0-350c0-111 50-250 250-250 200 0 250 139 250 250l0 350c0 25-26 50-50 50-50 0-100-50-200-50-100 0-150 50-200 50z m88-175c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m225 0c34 0 62-28 62-62 0-35-28-63-62-63-35 0-63 28-63 63 0 34 28 62 63 62z m-263-275l300 0s-25-100-150-100-150 100-150 100z" horiz-adv-x="850" />
|
||||||
|
|
||||||
<glyph glyph-name="info" unicode="" d="M393 149v-134q0-9-7-15t-15-7h-134q-9 0-16 7t-7 15v134q0 9 7 16t16 6h134q9 0 15-6t7-16z m176 335q0-30-8-56t-20-43-31-33-32-25-34-19q-23-13-38-37t-15-37q0-10-7-18t-16-9h-134q-8 0-14 11t-6 20v26q0 46 37 87t79 60q33 16 47 32t14 42q0 24-26 41t-60 18q-36 0-60-16-20-14-60-64-7-9-17-9-7 0-14 4l-91 70q-8 6-9 14t3 16q89 148 259 148 45 0 90-17t81-46 59-72 23-88z" horiz-adv-x="571.4" />
|
<glyph glyph-name="location" unicode="" d="M0 473q0 70 27 134 25 61 74 111 47 47 110 73 63 27 134 27 72 0 135-27 62-26 110-73 48-49 74-111 27-64 27-134t-27-134q-18-40-36-65l-229-347q-22-35-53-35t-55 35l-228 347q-22 31-36 65-27 64-27 134z m217 0q0-53 37-91 38-38 91-38 54 0 91 38 38 37 38 91 0 53-38 91-37 38-91 38-53 0-91-38-37-38-37-91z" horiz-adv-x="691.4" />
|
||||||
|
|
||||||
<glyph glyph-name="bird" unicode="" d="M920 636q-36-54-94-98l0-24q0-130-60-250t-186-203-290-83q-160 0-290 84 14-2 46-2 132 0 234 80-62 2-110 38t-66 94q10-4 34-4 26 0 50 6-66 14-108 66t-42 120l0 2q36-20 84-24-84 58-84 158 0 48 26 94 154-188 390-196-6 18-6 42 0 78 55 133t135 55q82 0 136-58 60 12 120 44-20-66-82-104 56 8 108 30z" horiz-adv-x="920" />
|
<glyph glyph-name="bird" unicode="" d="M904 622q-37-54-90-93 0-8 0-23 0-73-21-145t-64-139-103-117-144-82-181-30q-151 0-276 81 19-2 43-2 126 0 224 77-59 1-105 36t-64 89q19-3 34-3 24 0 48 6-63 13-104 62t-41 115v2q38-21 82-23-37 25-59 64t-22 86q0 49 25 91 68-83 164-133t208-55q-5 21-5 41 0 75 53 127t127 53q79 0 132-57 61 12 115 44-21-64-80-100 52 6 104 28z" horiz-adv-x="928.6" />
|
||||||
|
|
||||||
|
<glyph glyph-name="rss-feed" unicode="" d="M214 100q0-45-31-76t-76-31-76 31-31 76 31 76 76 31 76-31 31-76z m286-69q1-15-9-26-10-12-27-12h-75q-14 0-24 9t-11 23q-12 128-103 219t-219 103q-14 1-23 11t-9 24v75q0 16 12 26 9 10 24 10h3q89-7 170-45t145-101q63-63 101-145t45-171z m286-1q1-15-10-26-10-11-26-11h-80q-14 0-25 10t-10 23q-7 120-57 228t-129 188-188 129-227 57q-14 1-24 11t-10 24v80q0 16 11 26 10 10 25 10h1q147-8 280-67t238-164q104-104 164-238t67-280z" horiz-adv-x="785.7" />
|
||||||
</font>
|
</font>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
7
public/js/eirResources.js
Executable file
7
public/js/eirResources.js
Executable file
@ -0,0 +1,7 @@
|
|||||||
|
let eirTheme = document.querySelector("link[href='/css/themes/eir.css']");
|
||||||
|
if (eirTheme != null) {
|
||||||
|
let cursorScr = document.createElement("script");
|
||||||
|
cursorScr.src = "/res/js/cursors.js";
|
||||||
|
cursorScr.defer = "";
|
||||||
|
document.getElementsByTagName("head")[0].appendChild(cursorScr);
|
||||||
|
}
|
||||||
0
public/js/hls.min.js → public/js/hls.light.min.js
vendored
Normal file → Executable file
0
public/js/hls.min.js → public/js/hls.light.min.js
vendored
Normal file → Executable file
@ -1,54 +1,89 @@
|
|||||||
# About
|
# My instance
|
||||||
|
|
||||||
Nitter is a free and open source alternative Twitter front-end focused on
|
**This instance is running a fork, whose source can be found at**
|
||||||
privacy and performance. The source is available on GitHub at
|
<https://git.eir-nya.gay/eir/nitter>.
|
||||||
<https://github.com/zedeus/nitter>
|
|
||||||
|
|
||||||
* No JavaScript or ads
|
My fork is based on [Cynthia Foxwell's fork](https://gitlab.com/Cynosphere/nitter).
|
||||||
* All requests go through the backend, client never talks to Twitter
|
Nitter is created by Zedeus, whose source can be found at <https://github.com/zedeus/nitter>.
|
||||||
* Prevents Twitter from tracking your IP or JavaScript fingerprint
|
|
||||||
* Uses Twitter's unofficial API (no rate limits or developer account required)
|
|
||||||
* Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
|
||||||
* RSS feeds
|
|
||||||
* Themes
|
|
||||||
* Mobile support (responsive design)
|
|
||||||
* AGPLv3 licensed, no proprietary instances permitted
|
|
||||||
|
|
||||||
Nitter's GitHub wiki contains
|
The rest of this page is copied from Cynthia's fork:
|
||||||
[instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
|
||||||
[browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
|
|
||||||
maintained by the community.
|
|
||||||
|
|
||||||
## Why use Nitter?
|
> # About
|
||||||
|
>
|
||||||
It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
> Nitter is a free and open source alternative Twitter front-end focused on
|
||||||
folks, preventing JavaScript analytics and IP-based tracking is important, but
|
> privacy and performance.
|
||||||
apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
>
|
||||||
a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
> * No JavaScript or ads
|
||||||
[browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
> * All requests go through the backend, client never talks to Twitter
|
||||||
[no JavaScript required](https://noscriptfingerprint.com/). This all became
|
> * Prevents Twitter from tracking your IP or JavaScript fingerprint
|
||||||
particularly important after Twitter [removed the
|
> * Uses Twitter's unofficial API (no rate limits or developer account required)
|
||||||
ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
> * Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com)
|
||||||
for users to control whether their data gets sent to advertisers.
|
> * RSS feeds
|
||||||
|
> * Themes
|
||||||
Using an instance of Nitter (hosted on a VPS for example), you can browse
|
> * Mobile support (responsive design)
|
||||||
Twitter without JavaScript while retaining your privacy. In addition to
|
> * AGPLv3 licensed, no proprietary instances permitted (source code below)
|
||||||
respecting your privacy, Nitter is on average around 15 times lighter than
|
>
|
||||||
Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
|
> Nitter's GitHub wiki contains
|
||||||
|
> [instances](https://github.com/zedeus/nitter/wiki/Instances) and
|
||||||
In the future a simple account system will be added that lets you follow Twitter
|
> [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions)
|
||||||
users, allowing you to have a clean chronological timeline without needing a
|
> maintained by the community.
|
||||||
Twitter account.
|
>
|
||||||
|
> ### Fork features by Cynthia Foxwell
|
||||||
## Donating
|
>
|
||||||
|
> * Localized following via cookies (list exportable and editable in preferences)
|
||||||
Liberapay: <https://liberapay.com/zedeus> \
|
> * Image zooming/carousel (requires JavaScript)
|
||||||
Patreon: <https://patreon.com/nitter> \
|
> * Up to date Twitter features, e.g. Community Notes
|
||||||
BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
> * Embeds for chat services on-par with services like [FxTwitter](https://github.com/FixTweet/FxTwitter) and [vxTwitter](https://github.com/dylanpdx/BetterTwitFix)
|
||||||
ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
>
|
||||||
LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
> ## Why use Nitter?
|
||||||
XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
>
|
||||||
|
> It's impossible to use Twitter without JavaScript enabled. For privacy-minded
|
||||||
## Contact
|
> folks, preventing JavaScript analytics and IP-based tracking is important, but
|
||||||
|
> apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind
|
||||||
Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org).
|
> a VPN and using heavy-duty adblockers, you can get accurately tracked with your
|
||||||
|
> [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/),
|
||||||
|
> [no JavaScript required](https://noscriptfingerprint.com/). This all became
|
||||||
|
> particularly important after Twitter [removed the
|
||||||
|
> ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws)
|
||||||
|
> for users to control whether their data gets sent to advertisers.
|
||||||
|
>
|
||||||
|
> Using an instance of Nitter (hosted on a VPS for example), you can browse
|
||||||
|
> Twitter without JavaScript while retaining your privacy. In addition to
|
||||||
|
> respecting your privacy, Nitter is on average around 15 times lighter than
|
||||||
|
> Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster).
|
||||||
|
>
|
||||||
|
> ## Donating
|
||||||
|
>
|
||||||
|
> Even though I could be selfish and point people to donate to me instead of
|
||||||
|
> Zedeus, it would be disrespectful.
|
||||||
|
>
|
||||||
|
> GitHub Sponsors: <https://github.com/sponsors/zedeus> \
|
||||||
|
> Donations go to zedeus, original creator of Nitter.
|
||||||
|
>
|
||||||
|
> Liberapay: <https://liberapay.com/zedeus> \
|
||||||
|
> Patreon: <https://patreon.com/nitter> \
|
||||||
|
> BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \
|
||||||
|
> ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \
|
||||||
|
> LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \
|
||||||
|
> XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL
|
||||||
|
>
|
||||||
|
> ## Credits
|
||||||
|
>
|
||||||
|
> * Zedeus for this project
|
||||||
|
> * PrivacyDevel, cmj, and taskylizard for keeping this project alive with forks after the main repo went inactive
|
||||||
|
> * Every other contributors who've committed to the main repo in the past
|
||||||
|
>
|
||||||
|
> ## To any law enforcement agencies and copyright holders
|
||||||
|
>
|
||||||
|
> **All illegal content should be reported to Twitter directly.** This service is
|
||||||
|
> merely a proxy of Twitter and no content is hosted on this server. Do not waste
|
||||||
|
> your time contacting internet service providers, hosting providers and/or domain
|
||||||
|
> registrars.
|
||||||
|
>
|
||||||
|
> If you would like more context, you can read about this exact issue happening to
|
||||||
|
> [PussTheCat.org's instance](https://pussthecat.org/nitter/).
|
||||||
|
>
|
||||||
|
> I emplore all Nitter instance hosts to not enable media proxying, even if it
|
||||||
|
> "phones home" to Twitter's CDN (which doesn't really pose a tracking risk and
|
||||||
|
> breaks videos anyways), as it [has been used as an attack vector to take down
|
||||||
|
> nitter.net](https://github.com/zedeus/nitter/issues/1150#issuecomment-1890855255).
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Nitter",
|
"name": "Kitter",
|
||||||
"short_name": "Nitter",
|
"short_name": "Kitter",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-192x192.png",
|
"src": "/android-chrome-192x192.png",
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"theme_color": "#333333",
|
"theme_color": "#faaaab",
|
||||||
"background_color": "#333333",
|
"background_color": "#faaaab",
|
||||||
"display": "standalone"
|
"display": "standalone"
|
||||||
}
|
}
|
||||||
|
|||||||
202
src/api.nim
202
src/api.nim
@ -1,59 +1,114 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar
|
import asyncdispatch, httpclient, strutils, sequtils, sugar
|
||||||
import packedjson
|
import packedjson
|
||||||
import types, query, formatters, consts, apiutils, parser
|
import types, query, formatters, consts, apiutils, parser
|
||||||
import experimental/parser as newParser
|
import experimental/parser as newParser
|
||||||
|
|
||||||
|
# Helper to generate params object for GraphQL requests
|
||||||
|
proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
|
||||||
|
result.add ("variables", variables)
|
||||||
|
result.add ("features", gqlFeatures)
|
||||||
|
if fieldToggles.len > 0:
|
||||||
|
result.add ("fieldToggles", fieldToggles)
|
||||||
|
|
||||||
|
proc apiUrl*(endpoint, variables: string; fieldToggles = ""): ApiUrl =
|
||||||
|
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
|
||||||
|
|
||||||
|
proc apiReq*(endpoint, variables: string; fieldToggles = ""): ApiReq =
|
||||||
|
let url = apiUrl(endpoint, variables, fieldToggles)
|
||||||
|
return ApiReq(cookie: url, oauth: url)
|
||||||
|
|
||||||
|
proc mediaUrl*(id: string; cursor: string): ApiReq =
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor], """{"withArticlePlainText":false}"""),
|
||||||
|
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
|
||||||
|
)
|
||||||
|
|
||||||
|
proc userTweetsUrl*(id: string; cursor: string): ApiReq =
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
|
||||||
|
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
|
||||||
|
)
|
||||||
|
|
||||||
|
proc userTweetsAndRepliesUrl*(id: string; cursor: string): ApiReq =
|
||||||
|
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
|
||||||
|
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
|
||||||
|
)
|
||||||
|
|
||||||
|
proc tweetDetailUrl*(id: string; cursor: string): ApiReq =
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphTweetDetail, tweetDetailVars % [id, cursor], tweetDetailFieldToggles),
|
||||||
|
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
|
||||||
|
)
|
||||||
|
|
||||||
|
proc userUrl*(username: string): ApiReq =
|
||||||
|
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
|
||||||
|
result = ApiReq(
|
||||||
|
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
|
||||||
|
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
|
||||||
|
)
|
||||||
|
|
||||||
proc getGraphUser*(username: string): Future[User] {.async.} =
|
proc getGraphUser*(username: string): Future[User] {.async.} =
|
||||||
if username.len == 0: return
|
if username.len == 0: return
|
||||||
let
|
|
||||||
variables = """{"screen_name": "$1"}""" % username
|
let headers = newHttpHeaders()
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
headers.add("Referer", """https://x.com/$1""" % username)
|
||||||
js = await fetchRaw(graphUser ? params, Api.userScreenName)
|
|
||||||
|
let js = await fetchRaw(userUrl(username), headers)
|
||||||
result = parseGraphUser(js)
|
result = parseGraphUser(js)
|
||||||
|
|
||||||
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
proc getGraphUserById*(id: string): Future[User] {.async.} =
|
||||||
if id.len == 0 or id.any(c => not c.isDigit): return
|
if id.len == 0 or id.any(c => not c.isDigit): return
|
||||||
|
let headers = newHttpHeaders()
|
||||||
|
headers.add("Referer", """https://x.com/i/user/$1""" % id)
|
||||||
|
|
||||||
let
|
let
|
||||||
variables = """{"rest_id": "$1"}""" % id
|
url = apiReq(graphUserById, """{"userId":"$1"}""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetchRaw(url, headers)
|
||||||
js = await fetchRaw(graphUserById ? params, Api.userRestId)
|
|
||||||
result = parseGraphUser(js)
|
result = parseGraphUser(js)
|
||||||
|
|
||||||
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
|
|
||||||
|
let
|
||||||
|
endpoint = case kind
|
||||||
|
of TimelineKind.tweets: ""
|
||||||
|
of TimelineKind.replies: "/with_replies"
|
||||||
|
of TimelineKind.media: "/media"
|
||||||
|
headers = newHttpHeaders()
|
||||||
|
headers.add("Referer", """https://x.com/$1$2""" % [id, endpoint])
|
||||||
|
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = if kind == TimelineKind.media: userMediaVariables % [id, cursor] else: userTweetsVariables % [id, cursor]
|
url = case kind
|
||||||
fieldToggles = """{"withArticlePlainText":true}"""
|
of TimelineKind.tweets: userTweetsUrl(id, cursor)
|
||||||
params = {"variables": variables, "features": gqlFeatures, "fieldToggles": fieldToggles}
|
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
|
||||||
(url, apiId) = case kind
|
of TimelineKind.media: mediaUrl(id, cursor)
|
||||||
of TimelineKind.tweets: (graphUserTweets, Api.userTweets)
|
js = await fetch(url, headers)
|
||||||
of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies)
|
|
||||||
of TimelineKind.media: (graphUserMedia, Api.userMedia)
|
|
||||||
js = await fetch(url ? params, apiId)
|
|
||||||
result = parseGraphTimeline(js, if kind == TimelineKind.media: "" else: "user", after)
|
result = parseGraphTimeline(js, if kind == TimelineKind.media: "" else: "user", after)
|
||||||
|
|
||||||
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = listTweetsVariables % [id, cursor]
|
url = apiReq(graphListTweets, restIdVars % [id, cursor])
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetch(url)
|
||||||
js = await fetch(graphListTweets ? params, Api.listTweets)
|
|
||||||
result = parseGraphTimeline(js, "list", after).tweets
|
result = parseGraphTimeline(js, "list", after).tweets
|
||||||
|
|
||||||
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
|
||||||
let
|
let
|
||||||
variables = %*{"screenName": name, "listSlug": list}
|
variables = %*{"screenName": name, "listSlug": list}
|
||||||
params = {"variables": $variables, "features": gqlFeatures}
|
url = apiReq(graphListBySlug, $variables)
|
||||||
result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug))
|
js = await fetch(url)
|
||||||
|
result = parseGraphList(js)
|
||||||
|
|
||||||
proc getGraphList*(id: string): Future[List] {.async.} =
|
proc getGraphList*(id: string): Future[List] {.async.} =
|
||||||
let
|
let
|
||||||
variables = """{"listId": "$1"}""" % id
|
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetch(url)
|
||||||
result = parseGraphList(await fetch(graphListById ? params, Api.list))
|
result = parseGraphList(js)
|
||||||
|
|
||||||
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
|
||||||
if list.id.len == 0: return
|
if list.id.len == 0: return
|
||||||
@ -67,77 +122,45 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
|
|||||||
}
|
}
|
||||||
if after.len > 0:
|
if after.len > 0:
|
||||||
variables["cursor"] = % after
|
variables["cursor"] = % after
|
||||||
let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures}
|
|
||||||
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 = consts.favorites ? {"variables": $variables, "features": gqlFeatures}
|
url = apiReq(graphListMembers, $variables)
|
||||||
result = parseGraphTimeline(await fetch(url, Api.favorites), after)
|
js = await fetchRaw(url)
|
||||||
|
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
|
||||||
variables = """{"rest_id": "$1"}""" % id
|
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
js = await fetch(url, headers)
|
||||||
js = await fetch(graphTweetResult ? params, Api.tweetResult)
|
|
||||||
result = parseGraphTweetResult(js)
|
result = parseGraphTweetResult(js)
|
||||||
|
|
||||||
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
|
proc getGraphTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
|
|
||||||
|
let headers = newHttpHeaders()
|
||||||
|
headers.add("Referer", """https://x.com/i/status/$1""" % id)
|
||||||
|
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = tweetVariables % [id, cursor]
|
js = await fetch(tweetDetailUrl(id, cursor), headers)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
js = await fetch(graphTweet ? params, Api.tweetDetail)
|
|
||||||
result = parseGraphConversation(js, id)
|
result = parseGraphConversation(js, id)
|
||||||
|
|
||||||
proc getGraphFavoriters*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
|
||||||
if id.len == 0: return
|
|
||||||
let
|
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
|
||||||
variables = reactorsVariables % [id, cursor]
|
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
js = await fetch(graphFavoriters ? params, Api.favoriters)
|
|
||||||
result = parseGraphFavoritersTimeline(js, id)
|
|
||||||
|
|
||||||
proc getGraphRetweeters*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
|
||||||
if id.len == 0: return
|
|
||||||
let
|
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
|
||||||
variables = reactorsVariables % [id, cursor]
|
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
js = await fetch(graphRetweeters ? params, Api.retweeters)
|
|
||||||
result = parseGraphRetweetersTimeline(js, id)
|
|
||||||
|
|
||||||
proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
proc getGraphFollowing*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = followVariables % [id, cursor]
|
js = await fetch(apiReq(graphFollowing, followVars % [id, cursor]))
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
js = await fetch(graphFollowing ? params, Api.following)
|
|
||||||
result = parseGraphFollowTimeline(js, id)
|
result = parseGraphFollowTimeline(js, id)
|
||||||
|
|
||||||
proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
proc getGraphFollowers*(id: string; after=""): Future[UsersTimeline] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
variables = followVariables % [id, cursor]
|
js = await fetch(apiReq(graphFollowers, followVars % [id, cursor]))
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
js = await fetch(graphFollowers ? params, Api.followers)
|
|
||||||
result = parseGraphFollowTimeline(js, id)
|
result = parseGraphFollowTimeline(js, id)
|
||||||
|
|
||||||
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
|
||||||
@ -158,15 +181,16 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
|
|||||||
variables = %*{
|
variables = %*{
|
||||||
"rawQuery": q,
|
"rawQuery": q,
|
||||||
"count": 20,
|
"count": 20,
|
||||||
|
"query_source": "typed_query",
|
||||||
"product": "Latest",
|
"product": "Latest",
|
||||||
"withDownvotePerspective": false,
|
"withGrokTranslatedBio": false
|
||||||
"withReactionsMetadata": false,
|
|
||||||
"withReactionsPerspective": false
|
|
||||||
}
|
}
|
||||||
if after.len > 0:
|
if after.len > 0:
|
||||||
variables["cursor"] = % after
|
variables["cursor"] = % after
|
||||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
let
|
||||||
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
|
url = apiReq(graphSearchTimeline, $variables)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphSearch[Tweets](js, after)
|
||||||
result.query = query
|
result.query = query
|
||||||
|
|
||||||
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
|
||||||
@ -177,26 +201,24 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
|
|||||||
variables = %*{
|
variables = %*{
|
||||||
"rawQuery": query.text,
|
"rawQuery": query.text,
|
||||||
"count": 20,
|
"count": 20,
|
||||||
|
"query_source": "typed_query",
|
||||||
"product": "People",
|
"product": "People",
|
||||||
"withDownvotePerspective": false,
|
"withGrokTranslatedBio": false
|
||||||
"withReactionsMetadata": false,
|
|
||||||
"withReactionsPerspective": false
|
|
||||||
}
|
}
|
||||||
if after.len > 0:
|
if after.len > 0:
|
||||||
variables["cursor"] = % after
|
variables["cursor"] = % after
|
||||||
result.beginning = false
|
result.beginning = false
|
||||||
|
|
||||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
let
|
||||||
result = parseGraphSearch[User](await fetch(url, Api.search), after)
|
url = apiReq(graphSearchTimeline, $variables)
|
||||||
|
js = await fetch(url)
|
||||||
|
result = parseGraphSearch[User](js, after)
|
||||||
result.query = query
|
result.query = query
|
||||||
|
|
||||||
proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||||
if name.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let js = await fetch(mediaUrl(id, ""))
|
||||||
ps = genParams({"screen_name": name, "trim_user": "true"},
|
result = parseGraphPhotoRail(js)
|
||||||
count="18", ext=false)
|
|
||||||
url = photoRail ? ps
|
|
||||||
result = parsePhotoRail(await fetch(url, Api.photoRail))
|
|
||||||
|
|
||||||
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =
|
||||||
let client = newAsyncHttpClient(maxRedirects=0)
|
let client = newAsyncHttpClient(maxRedirects=0)
|
||||||
|
|||||||
130
src/apiutils.nim
130
src/apiutils.nim
@ -1,7 +1,7 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import httpclient, asyncdispatch, options, strutils, uri, times, tables
|
import httpclient, asyncdispatch, options, strutils, uri, times, tables, math
|
||||||
import jsony, packedjson, zippy
|
import jsony, packedjson, zippy
|
||||||
import types, tokens, consts, parserutils, http_pool
|
import types, consts, parserutils, http_pool, tid
|
||||||
import experimental/types/common
|
import experimental/types/common
|
||||||
import config
|
import config
|
||||||
|
|
||||||
@ -9,66 +9,63 @@ const
|
|||||||
rlRemaining = "x-rate-limit-remaining"
|
rlRemaining = "x-rate-limit-remaining"
|
||||||
rlReset = "x-rate-limit-reset"
|
rlReset = "x-rate-limit-reset"
|
||||||
|
|
||||||
var pool: HttpPool
|
var
|
||||||
|
pool: HttpPool
|
||||||
|
disableTid: bool
|
||||||
|
|
||||||
proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
|
proc setDisableTid*(disable: bool) =
|
||||||
count="20"; ext=true): seq[(string, string)] =
|
disableTid = disable
|
||||||
result = timelineParams
|
|
||||||
for p in pars:
|
|
||||||
result &= p
|
|
||||||
if ext:
|
|
||||||
result &= ("include_ext_alt_text", "1")
|
|
||||||
result &= ("include_ext_media_stats", "1")
|
|
||||||
result &= ("include_ext_media_availability", "1")
|
|
||||||
if count.len > 0:
|
|
||||||
result &= ("count", count)
|
|
||||||
if cursor.len > 0:
|
|
||||||
# The raw cursor often has plus signs, which sometimes get turned into spaces,
|
|
||||||
# so we need to turn them back into a plus
|
|
||||||
if " " in cursor:
|
|
||||||
result &= ("cursor", cursor.replace(" ", "+"))
|
|
||||||
else:
|
|
||||||
result &= ("cursor", cursor)
|
|
||||||
|
|
||||||
proc genHeaders*(token: Token = nil): HttpHeaders =
|
proc toUrl(req: ApiReq): Uri =
|
||||||
|
let c = req.cookie
|
||||||
|
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
|
||||||
|
|
||||||
|
proc rateLimitError*(): ref RateLimitError =
|
||||||
|
newException(RateLimitError, "rate limited")
|
||||||
|
|
||||||
|
proc genHeaders*(): HttpHeaders =
|
||||||
|
let
|
||||||
|
t = getTime()
|
||||||
|
ffVersion = floor(124 + (t.toUnix() - 1710892800) / 2419200)
|
||||||
result = newHttpHeaders({
|
result = newHttpHeaders({
|
||||||
"connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"authorization": auth,
|
"Authorization": bearerToken,
|
||||||
"content-type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"x-guest-token": if token == nil: "" else: token.tok,
|
"Accept-Encoding": "gzip",
|
||||||
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
|
"Accept": "*/*",
|
||||||
|
"DNT": "1",
|
||||||
|
"Host": "x.com",
|
||||||
|
"Origin": "https://x.com",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"Sec-GPC": "1",
|
||||||
|
"TE": "trailers",
|
||||||
|
"User-Agent": """Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:$1.0) Gecko/20100101 Firefox/$1.0""" % $ffVersion,
|
||||||
"x-twitter-active-user": "yes",
|
"x-twitter-active-user": "yes",
|
||||||
"authority": "api.twitter.com",
|
"x-twitter-auth-type": "OAuth2Session",
|
||||||
"accept-encoding": "gzip",
|
"x-twitter-client-language": "en"
|
||||||
"accept-language": "en-US,en;q=0.9",
|
}, true)
|
||||||
"accept": "*/*",
|
|
||||||
"DNT": "1"
|
|
||||||
})
|
|
||||||
|
|
||||||
template updateToken() =
|
|
||||||
if resp.headers.hasKey(rlRemaining):
|
|
||||||
let
|
|
||||||
remaining = parseInt(resp.headers[rlRemaining])
|
|
||||||
reset = parseInt(resp.headers[rlReset])
|
|
||||||
token.setRateLimit(api, remaining, reset)
|
|
||||||
|
|
||||||
template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
||||||
once:
|
once:
|
||||||
pool = HttpPool()
|
pool = HttpPool()
|
||||||
|
|
||||||
var token = await getToken(api)
|
|
||||||
if token.tok.len == 0:
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|
||||||
if len(cfg.cookieHeader) != 0:
|
if len(cfg.cookieHeader) != 0:
|
||||||
additional_headers.add("Cookie", cfg.cookieHeader)
|
additional_headers.add("Cookie", cfg.cookieHeader)
|
||||||
|
if not disableTid:
|
||||||
|
additional_headers.add("x-client-transaction-id", await genTid(url.path))
|
||||||
|
|
||||||
if len(cfg.xCsrfToken) != 0:
|
if len(cfg.xCsrfToken) != 0:
|
||||||
additional_headers.add("x-csrf-token", cfg.xCsrfToken)
|
additional_headers.add("x-csrf-token", cfg.xCsrfToken)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
var resp: AsyncResponse
|
var resp: AsyncResponse
|
||||||
var headers = genHeaders(token)
|
var headers = genHeaders()
|
||||||
for key, value in additional_headers.pairs():
|
for key, value in additional_headers.pairs():
|
||||||
headers.add(key, value)
|
headers.add(key, value)
|
||||||
|
|
||||||
pool.use(headers):
|
pool.use(headers):
|
||||||
template getContent =
|
template getContent =
|
||||||
resp = await c.get($url)
|
resp = await c.get($url)
|
||||||
@ -87,7 +84,6 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
|||||||
let
|
let
|
||||||
remaining = parseInt(resp.headers[rlRemaining])
|
remaining = parseInt(resp.headers[rlRemaining])
|
||||||
reset = parseInt(resp.headers[rlReset])
|
reset = parseInt(resp.headers[rlReset])
|
||||||
token.setRateLimit(api, remaining, reset)
|
|
||||||
|
|
||||||
if result.len > 0:
|
if result.len > 0:
|
||||||
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
if resp.headers.getOrDefault("content-encoding") == "gzip":
|
||||||
@ -96,75 +92,55 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
|
|||||||
if result.startsWith("{\"errors"):
|
if result.startsWith("{\"errors"):
|
||||||
let errors = result.fromJson(Errors)
|
let errors = result.fromJson(Errors)
|
||||||
if errors in {expiredToken, badToken, authorizationError}:
|
if errors in {expiredToken, badToken, authorizationError}:
|
||||||
echo "fetch error: ", errors
|
|
||||||
release(token, invalid=true)
|
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
elif errors in {rateLimited}:
|
elif errors in {rateLimited}:
|
||||||
# rate limit hit, resets after 24 hours
|
|
||||||
#setLimited(account, api)
|
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
elif result.startsWith("429 Too Many Requests"):
|
elif result.startsWith("429 Too Many Requests"):
|
||||||
echo "[accounts] 429 error, API: ", api, ", token: ", token[]
|
|
||||||
#account.apis[api].remaining = 0
|
|
||||||
# rate limit hit, resets after the 15 minute window
|
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
|
|
||||||
fetchBody
|
fetchBody
|
||||||
|
|
||||||
release(token, used=true)
|
|
||||||
|
|
||||||
if resp.status == $Http400:
|
if resp.status == $Http400:
|
||||||
|
echo "ERROR 400, ", url.path, ": ", result
|
||||||
raise newException(InternalError, $url)
|
raise newException(InternalError, $url)
|
||||||
except InternalError as e:
|
except InternalError as e:
|
||||||
raise e
|
raise e
|
||||||
except BadClientError as e:
|
except BadClientError as e:
|
||||||
release(token, used=true)
|
|
||||||
raise e
|
raise e
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise e
|
raise e
|
||||||
|
except ProtocolError as e:
|
||||||
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
|
echo "error: ", e.name, ", msg: ", e.msg, ", url: ", url.path
|
||||||
if "length" notin e.msg and "descriptor" notin e.msg:
|
|
||||||
release(token, invalid=true)
|
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
|
|
||||||
template retry(bod) =
|
template retry(bod) =
|
||||||
try:
|
try:
|
||||||
bod
|
bod
|
||||||
except RateLimitError:
|
except ProtocolError:
|
||||||
echo "[accounts] Rate limited, retrying ", api, " request..."
|
|
||||||
bod
|
bod
|
||||||
|
|
||||||
proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
|
proc fetch*(req: ApiReq; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
|
||||||
retry:
|
retry:
|
||||||
var body: string
|
var body: string
|
||||||
|
let url = req.toUrl()
|
||||||
fetchImpl(body, additional_headers):
|
fetchImpl(body, additional_headers):
|
||||||
if body.startsWith('{') or body.startsWith('['):
|
if body.startsWith('{') or body.startsWith('['):
|
||||||
result = parseJson(body)
|
result = parseJson(body)
|
||||||
else:
|
else:
|
||||||
echo resp.status, ": ", body, " --- url: ", url
|
echo resp.status, " - non-json for: ", url, ", body: ", result
|
||||||
result = newJNull()
|
result = newJNull()
|
||||||
|
|
||||||
updateToken()
|
|
||||||
|
|
||||||
let error = result.getError
|
let error = result.getError
|
||||||
if error in {expiredToken, badToken}:
|
if error in {expiredToken, badToken}:
|
||||||
echo "fetch error: ", result.getError
|
echo "Fetch error, API: ", url.path, ", error: ", result.getError
|
||||||
release(token, invalid=true)
|
|
||||||
raise rateLimitError()
|
raise rateLimitError()
|
||||||
|
|
||||||
proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
|
proc fetchRaw*(req: ApiReq; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
|
||||||
retry:
|
retry:
|
||||||
|
let url = req.toUrl()
|
||||||
fetchImpl(result, additional_headers):
|
fetchImpl(result, additional_headers):
|
||||||
if not (result.startsWith('{') or result.startsWith('[')):
|
if not (result.startsWith('{') or result.startsWith('[')):
|
||||||
echo resp.status, ": ", result, " --- url: ", url
|
echo resp.status, " - non-json for: ", url, ", body: ", result
|
||||||
result.setLen(0)
|
result.setLen(0)
|
||||||
|
|
||||||
updateToken()
|
|
||||||
|
|
||||||
if result.startsWith("{\"errors"):
|
|
||||||
let errors = result.fromJson(Errors)
|
|
||||||
if errors in {expiredToken, badToken}:
|
|
||||||
echo "fetch error: ", errors
|
|
||||||
release(token, invalid=true)
|
|
||||||
raise rateLimitError()
|
|
||||||
@ -126,7 +126,7 @@ proc getAccountPoolDebug*(): JsonNode =
|
|||||||
proc rateLimitError*(): ref RateLimitError =
|
proc rateLimitError*(): ref RateLimitError =
|
||||||
newException(RateLimitError, "rate limited")
|
newException(RateLimitError, "rate limited")
|
||||||
|
|
||||||
proc isLimited(account: GuestAccount; api: Api): bool =
|
proc isLimited(account: GuestAccount; req: ApiReq): bool =
|
||||||
if account.isNil:
|
if account.isNil:
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -157,9 +157,9 @@ proc release*(account: GuestAccount) =
|
|||||||
if account.isNil: return
|
if account.isNil: return
|
||||||
dec account.pending
|
dec account.pending
|
||||||
|
|
||||||
proc getGuestAccount*(api: Api): Future[GuestAccount] {.async.} =
|
proc getGuestAccount*(req: ApiReq): Future[GuestAccount] {.async.} =
|
||||||
for i in 0 ..< accountPool.len:
|
for i in 0 ..< accountPool.len:
|
||||||
if result.isReady(api): break
|
if result.isReady(req): break
|
||||||
result = accountPool.sample()
|
result = accountPool.sample()
|
||||||
|
|
||||||
if not result.isNil and result.isReady(api):
|
if not result.isNil and result.isReady(api):
|
||||||
|
|||||||
@ -22,6 +22,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||||||
httpMaxConns: cfg.get("Server", "httpMaxConnections", 100),
|
httpMaxConns: cfg.get("Server", "httpMaxConnections", 100),
|
||||||
staticDir: cfg.get("Server", "staticDir", "./public"),
|
staticDir: cfg.get("Server", "staticDir", "./public"),
|
||||||
title: cfg.get("Server", "title", "Nitter"),
|
title: cfg.get("Server", "title", "Nitter"),
|
||||||
|
oembedColor: cfg.get("Server", "oembedColor", "#1F1F1F"),
|
||||||
hostname: cfg.get("Server", "hostname", "nitter.net"),
|
hostname: cfg.get("Server", "hostname", "nitter.net"),
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
@ -42,6 +43,7 @@ proc getConfig*(path: string): (Config, parseCfg.Config) =
|
|||||||
enableDebug: cfg.get("Config", "enableDebug", false),
|
enableDebug: cfg.get("Config", "enableDebug", false),
|
||||||
proxy: cfg.get("Config", "proxy", ""),
|
proxy: cfg.get("Config", "proxy", ""),
|
||||||
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
proxyAuth: cfg.get("Config", "proxyAuth", ""),
|
||||||
|
disableTid: cfg.get("Config", "disableTid", false),
|
||||||
cookieHeader: cfg.get("Config", "cookieHeader", ""),
|
cookieHeader: cfg.get("Config", "cookieHeader", ""),
|
||||||
xCsrfToken: cfg.get("Config", "xCsrfToken", "")
|
xCsrfToken: cfg.get("Config", "xCsrfToken", "")
|
||||||
)
|
)
|
||||||
|
|||||||
224
src/consts.nim
224
src/consts.nim
@ -1,105 +1,77 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import uri, sequtils, strutils
|
import strutils
|
||||||
|
|
||||||
const
|
const
|
||||||
auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"
|
|
||||||
|
|
||||||
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
consumerKey* = "3nVuSoBZnx6U4vzUxf5w"
|
||||||
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
consumerSecret* = "Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys"
|
||||||
|
bearerToken* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
||||||
|
|
||||||
api = parseUri("https://api.twitter.com")
|
graphUser* = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"
|
||||||
activate* = $(api / "1.1/guest/activate.json")
|
graphUserV2* = "WEoGnYB0EG1yGwamDCF6zg/UserResultByScreenNameQuery"
|
||||||
|
graphUserById* = "Bbaot8ySMtJD7K2t01gW7A/UserByRestId"
|
||||||
photoRail* = api / "1.1/statuses/media_timeline.json"
|
graphUserTweetsV2* = "6QdSuZ5feXxOadEdXa4XZg/UserWithProfileTweetsQueryV2"
|
||||||
|
graphUserTweetsAndRepliesV2* = "BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2"
|
||||||
timelineApi = api / "2/timeline"
|
graphUserTweets* = "lZRf8IC-GTuGxDwcsHW8aw/UserTweets"
|
||||||
|
graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies"
|
||||||
graphql = api / "graphql"
|
graphUserMedia* = "1D04dx9H2pseMQAbMjXTvQ/UserMedia"
|
||||||
graphUser* = graphql / "u7wQyGi6oExe8_TRWGMq4Q/UserResultByScreenNameQuery"
|
graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"
|
||||||
graphUserById* = graphql / "oPppcargziU1uDQHAUmH-A/UserResultByIdQuery"
|
graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline"
|
||||||
graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2"
|
graphTweetDetail* = "6QzqakNMdh_YzBAR9SYPkQ/TweetDetail"
|
||||||
graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2"
|
graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery"
|
||||||
graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia"
|
graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"
|
||||||
graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail"
|
graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"
|
||||||
graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery"
|
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||||
graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline"
|
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||||
graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId"
|
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||||
graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug"
|
graphFollowers* = "Efm7xwLreAw77q2Fq7rX-Q/Followers"
|
||||||
graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers"
|
graphFollowing* = "e0UtTAwQqgLKBllQxMgVxQ/Following"
|
||||||
graphListTweets* = graphql / "BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline"
|
|
||||||
graphFavoriters* = graphql / "mDc_nU8xGv0cLRWtTaIEug/Favoriters"
|
|
||||||
graphRetweeters* = graphql / "RCR9gqwYD1NEgi9FWzA50A/Retweeters"
|
|
||||||
graphFollowers* = graphql / "EAqBhgcGr_qPOzhS4Q3scQ/Followers"
|
|
||||||
graphFollowing* = graphql / "JPZiqKjET7_M1r5Tlr8pyA/Following"
|
|
||||||
favorites* = graphql / "eSSNbhECHHWWALkkQq-YTA/Likes"
|
|
||||||
|
|
||||||
timelineParams* = {
|
|
||||||
"include_can_media_tag": "1",
|
|
||||||
"include_cards": "1",
|
|
||||||
"include_entities": "1",
|
|
||||||
"include_profile_interstitial_type": "0",
|
|
||||||
"include_quote_count": "0",
|
|
||||||
"include_reply_count": "0",
|
|
||||||
"include_user_entities": "0",
|
|
||||||
"include_ext_reply_count": "0",
|
|
||||||
"include_ext_media_color": "0",
|
|
||||||
"cards_platform": "Web-13",
|
|
||||||
"tweet_mode": "extended",
|
|
||||||
"send_error_codes": "1",
|
|
||||||
"simple_quoted_tweet": "1"
|
|
||||||
}.toSeq
|
|
||||||
|
|
||||||
gqlFeatures* = """{
|
gqlFeatures* = """{
|
||||||
"android_graphql_skip_api_media_color_palette": false,
|
"rweb_video_screen_enabled": false,
|
||||||
"articles_preview_enabled": false,
|
"profile_label_improvements_pcf_label_in_post_enabled": true,
|
||||||
"blue_business_profile_image_shape_enabled": false,
|
"responsive_web_profile_redirect_enabled": false,
|
||||||
"c9s_tweet_anatomy_moderator_badge_enabled": false,
|
"rweb_tipjar_consumption_enabled": true,
|
||||||
"communities_web_enable_tweet_community_results_fetch": false,
|
|
||||||
"creator_subscriptions_quote_tweet_preview_enabled": false,
|
|
||||||
"creator_subscriptions_subscription_count_enabled": false,
|
|
||||||
"creator_subscriptions_tweet_preview_api_enabled": true,
|
|
||||||
"freedom_of_speech_not_reach_fetch_enabled": false,
|
|
||||||
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
|
|
||||||
"hidden_profile_likes_enabled": false,
|
|
||||||
"highlights_tweets_tab_ui_enabled": false,
|
|
||||||
"interactive_text_enabled": false,
|
|
||||||
"longform_notetweets_consumption_enabled": true,
|
|
||||||
"longform_notetweets_inline_media_enabled": false,
|
|
||||||
"longform_notetweets_richtext_consumption_enabled": true,
|
|
||||||
"longform_notetweets_rich_text_read_enabled": false,
|
|
||||||
"responsive_web_edit_tweet_api_enabled": false,
|
|
||||||
"responsive_web_enhance_cards_enabled": false,
|
|
||||||
"responsive_web_graphql_exclude_directive_enabled": true,
|
|
||||||
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
|
||||||
"responsive_web_graphql_timeline_navigation_enabled": false,
|
|
||||||
"responsive_web_media_download_video_enabled": false,
|
|
||||||
"responsive_web_text_conversations_enabled": false,
|
|
||||||
"responsive_web_twitter_article_tweet_consumption_enabled": false,
|
|
||||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true,
|
|
||||||
"rweb_lists_timeline_redesign_enabled": true,
|
|
||||||
"rweb_tipjar_consumption_enabled": false,
|
|
||||||
"rweb_video_timestamps_enabled": true,
|
|
||||||
"spaces_2022_h2_clipping": true,
|
|
||||||
"spaces_2022_h2_spaces_communities": true,
|
|
||||||
"standardized_nudges_misinfo": false,
|
|
||||||
"subscriptions_verification_info_enabled": true,
|
|
||||||
"subscriptions_verification_info_reason_enabled": true,
|
|
||||||
"subscriptions_verification_info_verified_since_enabled": true,
|
|
||||||
"super_follow_badge_privacy_enabled": false,
|
|
||||||
"super_follow_exclusive_tweet_notifications_enabled": false,
|
|
||||||
"super_follow_tweet_api_enabled": false,
|
|
||||||
"super_follow_user_api_enabled": false,
|
|
||||||
"tweet_awards_web_tipping_enabled": false,
|
|
||||||
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
|
|
||||||
"tweetypie_unmention_optimization_enabled": false,
|
|
||||||
"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
|
|
||||||
"verified_phone_label_enabled": false,
|
"verified_phone_label_enabled": false,
|
||||||
"vibe_api_enabled": false,
|
"creator_subscriptions_tweet_preview_api_enabled": true,
|
||||||
"view_counts_everywhere_api_enabled": false
|
"responsive_web_graphql_timeline_navigation_enabled": true,
|
||||||
|
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
|
||||||
|
"premium_content_api_read_enabled": false,
|
||||||
|
"communities_web_enable_tweet_community_results_fetch": true,
|
||||||
|
"c9s_tweet_anatomy_moderator_badge_enabled": true,
|
||||||
|
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
|
||||||
|
"responsive_web_grok_analyze_post_followups_enabled": true,
|
||||||
|
"responsive_web_jetfuel_frame": true,
|
||||||
|
"responsive_web_grok_share_attachment_enabled": true,
|
||||||
|
"articles_preview_enabled": true,
|
||||||
|
"responsive_web_edit_tweet_api_enabled": true,
|
||||||
|
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
|
||||||
|
"view_counts_everywhere_api_enabled": true,
|
||||||
|
"longform_notetweets_consumption_enabled": true,
|
||||||
|
"responsive_web_twitter_article_tweet_consumption_enabled": true,
|
||||||
|
"tweet_awards_web_tipping_enabled": false,
|
||||||
|
"responsive_web_grok_show_grok_translated_post": true,
|
||||||
|
"responsive_web_grok_analysis_button_from_backend": true,
|
||||||
|
"creator_subscriptions_quote_tweet_preview_enabled": false,
|
||||||
|
"freedom_of_speech_not_reach_fetch_enabled": true,
|
||||||
|
"standardized_nudges_misinfo": true,
|
||||||
|
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
|
||||||
|
"longform_notetweets_rich_text_read_enabled": true,
|
||||||
|
"longform_notetweets_inline_media_enabled": true,
|
||||||
|
"responsive_web_grok_image_annotation_enabled": true,
|
||||||
|
"responsive_web_grok_imagine_annotation_enabled": true,
|
||||||
|
"responsive_web_grok_community_note_auto_translation_is_enabled": false,
|
||||||
|
"responsive_web_enhance_cards_enabled": false,
|
||||||
|
"payments_enabled": false,
|
||||||
|
"responsive_web_twitter_article_notes_tab_enabled": false,
|
||||||
|
"hidden_profile_subscriptions_enabled": false,
|
||||||
|
"subscriptions_verification_info_verified_since_enabled": false,
|
||||||
|
"subscriptions_verification_info_is_identity_verified_enabled": false,
|
||||||
|
"highlights_tweets_tab_ui_enabled": false,
|
||||||
|
"subscriptions_feature_can_gift_premium": false
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
tweetVariables* = """{
|
tweetVars* = """{
|
||||||
"focalTweetId": "$1",
|
"postId": "$1",
|
||||||
$2
|
$2
|
||||||
"includeHasBirdwatchNotes": false,
|
"includeHasBirdwatchNotes": false,
|
||||||
"includePromotedContent": false,
|
"includePromotedContent": false,
|
||||||
@ -108,47 +80,57 @@ const
|
|||||||
"withV2Timeline": true
|
"withV2Timeline": true
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
# oldUserTweetsVariables* = """{
|
tweetDetailVars* = """{
|
||||||
# "userId": "$1", $2
|
"focalTweetId": "$1",
|
||||||
# "count": 20,
|
|
||||||
# "includePromotedContent": false,
|
|
||||||
# "withDownvotePerspective": false,
|
|
||||||
# "withReactionsMetadata": false,
|
|
||||||
# "withReactionsPerspective": false,
|
|
||||||
# "withVoice": false,
|
|
||||||
# "withV2Timeline": true
|
|
||||||
# }
|
|
||||||
# """.replace(" ", "").replace("\n", "")
|
|
||||||
|
|
||||||
userTweetsVariables* = """{
|
|
||||||
"rest_id": "$1",
|
|
||||||
$2
|
$2
|
||||||
"count": 20
|
"referrer": "profile",
|
||||||
|
"with_rux_injections": false,
|
||||||
|
"rankingMode": "Relevance",
|
||||||
|
"includePromotedContent": true,
|
||||||
|
"withCommunity": true,
|
||||||
|
"withQuickPromoteEligibilityTweetFields": true,
|
||||||
|
"withBirdwatchNotes": true,
|
||||||
|
"withVoice": true
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
listTweetsVariables* = """{
|
restIdVars* = """{
|
||||||
"rest_id": "$1",
|
"rest_id": "$1", $2
|
||||||
$2
|
|
||||||
"count": 20
|
"count": 20
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}"""
|
||||||
|
|
||||||
reactorsVariables* = """{
|
userMediaVars* = """{
|
||||||
"tweetId": "$1",
|
"userId": "$1", $2
|
||||||
$2
|
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"includePromotedContent": false
|
"includePromotedContent": false,
|
||||||
|
"withClientEventToken": false,
|
||||||
|
"withBirdwatchNotes": false,
|
||||||
|
"withVoice": true
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
followVariables* = """{
|
userTweetsVars* = """{
|
||||||
|
"userId": "$1", $2
|
||||||
|
"count": 20,
|
||||||
|
"includePromotedContent": false,
|
||||||
|
"withQuickPromoteEligibilityTweetFields": true,
|
||||||
|
"withVoice": true
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
|
userTweetsAndRepliesVars* = """{
|
||||||
|
"userId": "$1", $2
|
||||||
|
"count": 20,
|
||||||
|
"includePromotedContent": false,
|
||||||
|
"withCommunity": true,
|
||||||
|
"withVoice": true
|
||||||
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
|
followVars* = """{
|
||||||
"userId": "$1",
|
"userId": "$1",
|
||||||
$2
|
$2
|
||||||
"count": 20,
|
"count": 20,
|
||||||
"includePromotedContent": false
|
"includePromotedContent": false
|
||||||
}""".replace(" ", "").replace("\n", "")
|
}""".replace(" ", "").replace("\n", "")
|
||||||
|
|
||||||
userMediaVariables* = """{
|
|
||||||
"userId": "$1",
|
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
|
||||||
$2
|
userTweetsFieldToggles* = """{"withArticlePlainText":false}"""
|
||||||
"count": 20,
|
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""
|
||||||
"includePromotedContent": false
|
|
||||||
}""".replace(" ", "").replace("\n", "")
|
|
||||||
|
|||||||
@ -1,21 +1,53 @@
|
|||||||
import options
|
import options, strutils
|
||||||
import jsony
|
import jsony
|
||||||
import user, ../types/[graphuser, graphlistmembers]
|
import user, utils, ../types/[graphuser, graphlistmembers]
|
||||||
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
from ../../types import User, VerifiedType, Result, Query, QueryKind
|
||||||
|
|
||||||
|
proc parseUserResult*(userResult: UserResult): User =
|
||||||
|
result = userResult.legacy
|
||||||
|
|
||||||
|
if result.verifiedType == none and userResult.isBlueVerified:
|
||||||
|
result.verifiedType = blue
|
||||||
|
|
||||||
|
if result.username.len == 0 and userResult.core.screenName.len > 0:
|
||||||
|
result.id = userResult.restId
|
||||||
|
result.username = userResult.core.screenName
|
||||||
|
result.fullname = userResult.core.name
|
||||||
|
result.userPic = userResult.avatar.imageUrl.replace("_normal", "")
|
||||||
|
|
||||||
|
if userResult.privacy.isSome:
|
||||||
|
result.protected = userResult.privacy.get.protected
|
||||||
|
|
||||||
|
if userResult.location.isSome:
|
||||||
|
result.location = userResult.location.get.location
|
||||||
|
|
||||||
|
if userResult.core.createdAt.len > 0:
|
||||||
|
result.joinDate = parseTwitterDate(userResult.core.createdAt)
|
||||||
|
|
||||||
|
if userResult.verification.isSome:
|
||||||
|
let v = userResult.verification.get
|
||||||
|
if v.verifiedType != VerifiedType.none:
|
||||||
|
result.verifiedType = v.verifiedType
|
||||||
|
|
||||||
|
if userResult.profileBio.isSome and result.bio.len == 0:
|
||||||
|
result.bio = userResult.profileBio.get.description
|
||||||
|
|
||||||
proc parseGraphUser*(json: string): User =
|
proc parseGraphUser*(json: string): User =
|
||||||
if json.len == 0 or json[0] != '{':
|
if json.len == 0 or json[0] != '{':
|
||||||
return
|
return
|
||||||
|
|
||||||
let raw = json.fromJson(GraphUser)
|
let
|
||||||
|
raw = json.fromJson(GraphUser)
|
||||||
|
userResult =
|
||||||
|
if raw.data.userResult.isSome: raw.data.userResult.get.result
|
||||||
|
elif raw.data.user.isSome: raw.data.user.get.result
|
||||||
|
else: UserResult()
|
||||||
|
|
||||||
if raw.data.userResult.result.unavailableReason.get("") == "Suspended":
|
if userResult.unavailableReason.get("") == "Suspended" or
|
||||||
|
userResult.reason.get("") == "Suspended":
|
||||||
return User(suspended: true)
|
return User(suspended: true)
|
||||||
|
|
||||||
result = raw.data.userResult.result.legacy
|
result = parseUserResult(userResult)
|
||||||
result.id = raw.data.userResult.result.restId
|
|
||||||
if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified:
|
|
||||||
result.verifiedType = blue
|
|
||||||
|
|
||||||
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
||||||
result = Result[User](
|
result = Result[User](
|
||||||
@ -31,7 +63,7 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
|
|||||||
of TimelineTimelineItem:
|
of TimelineTimelineItem:
|
||||||
let userResult = entry.content.itemContent.userResults.result
|
let userResult = entry.content.itemContent.userResults.result
|
||||||
if userResult.restId.len > 0:
|
if userResult.restId.len > 0:
|
||||||
result.content.add userResult.legacy
|
result.content.add parseUserResult(userResult)
|
||||||
of TimelineTimelineCursor:
|
of TimelineTimelineCursor:
|
||||||
if entry.content.cursorType == "Bottom":
|
if entry.content.cursorType == "Bottom":
|
||||||
result.bottom = entry.content.value
|
result.bottom = entry.content.value
|
||||||
|
|||||||
8
src/experimental/parser/tid.nim
Normal file
8
src/experimental/parser/tid.nim
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import jsony
|
||||||
|
import ../types/tid
|
||||||
|
export TidPair
|
||||||
|
|
||||||
|
proc parseTidPairs*(raw: string): seq[TidPair] =
|
||||||
|
result = raw.fromJson(seq[TidPair])
|
||||||
|
if result.len == 0:
|
||||||
|
raise newException(ValueError, "Parsing pairs failed: " & raw)
|
||||||
@ -64,7 +64,7 @@ proc toUser*(raw: RawUser): User =
|
|||||||
)
|
)
|
||||||
|
|
||||||
if raw.pinnedTweetIdsStr.len > 0:
|
if raw.pinnedTweetIdsStr.len > 0:
|
||||||
result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0])
|
result.pinnedTweet = raw.pinnedTweetIdsStr[0]
|
||||||
|
|
||||||
result.expandUserEntities(raw)
|
result.expandUserEntities(raw)
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,48 @@
|
|||||||
import options
|
import options, strutils
|
||||||
from ../../types import User
|
from ../../types import User, VerifiedType
|
||||||
|
|
||||||
type
|
type
|
||||||
GraphUser* = object
|
GraphUser* = object
|
||||||
data*: tuple[userResult: UserData]
|
data*: tuple[userResult: Option[UserData], user: Option[UserData]]
|
||||||
|
|
||||||
UserData* = object
|
UserData* = object
|
||||||
result*: UserResult
|
result*: UserResult
|
||||||
|
|
||||||
UserResult = object
|
UserCore* = object
|
||||||
|
name*: string
|
||||||
|
screenName*: string
|
||||||
|
createdAt*: string
|
||||||
|
|
||||||
|
UserBio* = object
|
||||||
|
description*: string
|
||||||
|
|
||||||
|
UserAvatar* = object
|
||||||
|
imageUrl*: string
|
||||||
|
|
||||||
|
Verification* = object
|
||||||
|
verifiedType*: VerifiedType
|
||||||
|
|
||||||
|
Location* = object
|
||||||
|
location*: string
|
||||||
|
|
||||||
|
Privacy* = object
|
||||||
|
protected*: bool
|
||||||
|
|
||||||
|
UserResult* = object
|
||||||
legacy*: User
|
legacy*: User
|
||||||
restId*: string
|
restId*: string
|
||||||
isBlueVerified*: bool
|
isBlueVerified*: bool
|
||||||
|
core*: UserCore
|
||||||
|
avatar*: UserAvatar
|
||||||
unavailableReason*: Option[string]
|
unavailableReason*: Option[string]
|
||||||
|
reason*: Option[string]
|
||||||
|
privacy*: Option[Privacy]
|
||||||
|
profileBio*: Option[UserBio]
|
||||||
|
verification*: Option[Verification]
|
||||||
|
location*: Option[Location]
|
||||||
|
|
||||||
|
proc enumHook*(s: string; v: var VerifiedType) =
|
||||||
|
v = try:
|
||||||
|
parseEnum[VerifiedType](s)
|
||||||
|
except:
|
||||||
|
VerifiedType.none
|
||||||
|
|||||||
4
src/experimental/types/tid.nim
Normal file
4
src/experimental/types/tid.nim
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
type
|
||||||
|
TidPair* = object
|
||||||
|
animationKey*: string
|
||||||
|
verification*: string
|
||||||
@ -7,10 +7,11 @@ const
|
|||||||
cards = "cards.twitter.com/cards"
|
cards = "cards.twitter.com/cards"
|
||||||
tco = "https://t.co"
|
tco = "https://t.co"
|
||||||
twitter = parseUri("https://twitter.com")
|
twitter = parseUri("https://twitter.com")
|
||||||
|
sameProto = "//"
|
||||||
|
|
||||||
let
|
let
|
||||||
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?twitter\.com"
|
twRegex = re"(?<=(?<!\S)https:\/\/|(?<=\s))(www\.|mobile\.)?(twitter|x)\.com"
|
||||||
twLinkRegex = re"""<a href="https:\/\/twitter.com([^"]+)">twitter\.com(\S+)</a>"""
|
twLinkRegex = re"""<a href="https:\/\/(twitter|x).com([^"]+)">(twitter|x)\.com(\S+)</a>"""
|
||||||
|
|
||||||
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
|
ytRegex = re(r"([A-z.]+\.)?youtu(be\.com|\.be)", {reStudy, reIgnoreCase})
|
||||||
|
|
||||||
@ -31,8 +32,10 @@ let
|
|||||||
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
|
illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]"
|
||||||
|
|
||||||
proc getUrlPrefix*(cfg: Config): string =
|
proc getUrlPrefix*(cfg: Config): string =
|
||||||
if cfg.useHttps: https & cfg.hostname
|
var proto = "http"
|
||||||
else: "http://" & cfg.hostname
|
if cfg.useHttps: proto &= "s"
|
||||||
|
proto &= "://"
|
||||||
|
result = proto & cfg.hostname
|
||||||
|
|
||||||
proc shortLink*(text: string; length=28): string =
|
proc shortLink*(text: string; length=28): string =
|
||||||
result = text.replace(wwwRegex, "")
|
result = text.replace(wwwRegex, "")
|
||||||
@ -59,12 +62,12 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
|||||||
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
if prefs.replaceYouTube.len > 0 and "youtu" in result:
|
||||||
result = result.replace(ytRegex, prefs.replaceYouTube)
|
result = result.replace(ytRegex, prefs.replaceYouTube)
|
||||||
|
|
||||||
if prefs.replaceTwitter.len > 0 and ("twitter.com" in body or tco in body):
|
if prefs.replaceTwitter.len > 0 and ("twitter.com" in result or "/x.com" in result or tco in result):
|
||||||
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
result = result.replace(tco, https & prefs.replaceTwitter & "/t.co")
|
||||||
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
result = result.replace(cards, prefs.replaceTwitter & "/cards")
|
||||||
result = result.replace(twRegex, prefs.replaceTwitter)
|
result = result.replace(twRegex, prefs.replaceTwitter)
|
||||||
result = result.replacef(twLinkRegex, a(
|
result = result.replacef(twLinkRegex, a(
|
||||||
prefs.replaceTwitter & "$2", href = https & prefs.replaceTwitter & "$1"))
|
prefs.replaceTwitter & "$4", href = prefs.replaceTwitter & "$2"))
|
||||||
|
|
||||||
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
if prefs.replaceReddit.len > 0 and ("reddit.com" in result or "redd.it" in result):
|
||||||
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
|
result = result.replace(rdShortRegex, prefs.replaceReddit & "/comments/")
|
||||||
@ -150,7 +153,7 @@ proc getShortTime*(tweet: Tweet): string =
|
|||||||
result = "now"
|
result = "now"
|
||||||
|
|
||||||
proc getLink*(tweet: Tweet; focus=true): string =
|
proc getLink*(tweet: Tweet; focus=true): string =
|
||||||
if tweet.id == 0: return
|
if tweet.id.len == 0: return
|
||||||
var username = tweet.user.username
|
var username = tweet.user.username
|
||||||
if username.len == 0:
|
if username.len == 0:
|
||||||
username = "i"
|
username = "i"
|
||||||
|
|||||||
@ -6,14 +6,15 @@ from htmlgen import a
|
|||||||
|
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
import types, config, prefs, formatters, redis_cache, http_pool
|
import types, config, prefs, formatters, redis_cache, http_pool, apiutils
|
||||||
import views/[general, about]
|
import views/[general, about]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, rss, list, #debug,
|
preferences, timeline, status, media, search, list, rss, #debug,
|
||||||
unsupported, embed, resolver, router_utils, home, follow, twitter_api]
|
unsupported, embed, resolver, router_utils, home, follow, twitter_api,
|
||||||
|
activityspoof]
|
||||||
|
|
||||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
const issuesUrl = "https://git.eir-nya.gay/eir/nitter/issues"
|
||||||
|
|
||||||
#let accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
|
#let accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json")
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ setHmacKey(cfg.hmacKey)
|
|||||||
setProxyEncoding(cfg.base64Media)
|
setProxyEncoding(cfg.base64Media)
|
||||||
setMaxHttpConns(cfg.httpMaxConns)
|
setMaxHttpConns(cfg.httpMaxConns)
|
||||||
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
setHttpProxy(cfg.proxy, cfg.proxyAuth)
|
||||||
|
setDisableTid(cfg.disableTid)
|
||||||
initAboutPage(cfg.staticDir)
|
initAboutPage(cfg.staticDir)
|
||||||
|
|
||||||
waitFor initRedisPool(cfg)
|
waitFor initRedisPool(cfg)
|
||||||
@ -51,6 +53,7 @@ createEmbedRouter(cfg)
|
|||||||
createRssRouter(cfg)
|
createRssRouter(cfg)
|
||||||
#createDebugRouter(cfg)
|
#createDebugRouter(cfg)
|
||||||
createTwitterApiRouter(cfg)
|
createTwitterApiRouter(cfg)
|
||||||
|
createActivityPubRouter(cfg)
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
port = Port(cfg.port)
|
port = Port(cfg.port)
|
||||||
@ -78,7 +81,7 @@ routes:
|
|||||||
|
|
||||||
error InternalError:
|
error InternalError:
|
||||||
echo error.exc.name, ": ", error.exc.msg
|
echo error.exc.name, ": ", error.exc.msg
|
||||||
const link = a("open a GitHub issue", href = issuesUrl)
|
const link = a("open an issue", href = issuesUrl)
|
||||||
resp Http500, showError(
|
resp Http500, showError(
|
||||||
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
|
&"An error occurred, please {link} with the URL you tried to visit.", cfg)
|
||||||
|
|
||||||
@ -102,6 +105,6 @@ routes:
|
|||||||
extend preferences, ""
|
extend preferences, ""
|
||||||
extend resolver, ""
|
extend resolver, ""
|
||||||
extend embed, ""
|
extend embed, ""
|
||||||
#extend debug, ""
|
extend activityspoof, ""
|
||||||
extend api, ""
|
extend api, ""
|
||||||
extend unsupported, ""
|
extend unsupported, ""
|
||||||
|
|||||||
241
src/parser.nim
241
src/parser.nim
@ -2,7 +2,7 @@
|
|||||||
import strutils, options, times, math
|
import strutils, options, times, math
|
||||||
import packedjson, packedjson/deserialiser
|
import packedjson, packedjson/deserialiser
|
||||||
import types, parserutils, utils
|
import types, parserutils, utils
|
||||||
import experimental/parser/unifiedcard
|
import experimental/parser/[unifiedcard, utils]
|
||||||
import std/tables
|
import std/tables
|
||||||
|
|
||||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet
|
||||||
@ -24,17 +24,52 @@ proc parseUser(js: JsonNode; id=""): User =
|
|||||||
media: js{"media_count"}.getInt,
|
media: js{"media_count"}.getInt,
|
||||||
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
|
verifiedType: parseEnum[VerifiedType](js{"verified_type"}.getStr("None")),
|
||||||
protected: js{"protected"}.getBool,
|
protected: js{"protected"}.getBool,
|
||||||
joinDate: js{"created_at"}.getTime
|
joinDate: parseTwitterDate(js{"created_at"}.getStr)
|
||||||
)
|
)
|
||||||
|
|
||||||
result.expandUserEntities(js)
|
result.expandUserEntities(js)
|
||||||
|
|
||||||
proc parseGraphUser(js: JsonNode): User =
|
proc parseGraphUser*(js: JsonNode): User =
|
||||||
var user = js{"user_result", "result"}
|
var user = js{"data", "user", "result"}
|
||||||
if user.isNull:
|
if user.isNull:
|
||||||
user = ? js{"user_results", "result"}
|
user = js{"user_results", "result"}
|
||||||
|
if user.isNull:
|
||||||
|
user = js{"user_result", "result"}
|
||||||
|
|
||||||
result = parseUser(user{"legacy"})
|
if user{"__typename"}.getStr == "UserUnavailable" and user{"reason"}.getStr == "Suspended":
|
||||||
|
return User(suspended: true)
|
||||||
|
|
||||||
|
result = parseUser(user{"legacy"}, user{"rest_id"}.getStr)
|
||||||
|
|
||||||
|
result.username = user{"core", "screen_name"}.getStr
|
||||||
|
result.fullname = user{"core", "name"}.getStr
|
||||||
|
result.joinDate = parseTwitterDate(user{"core", "created_at"}.getStr)
|
||||||
|
result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "")
|
||||||
|
result.protected = user{"privacy", "protected"}.getBool
|
||||||
|
|
||||||
|
let label = user{"affiliates_highlighted_label", "label"}
|
||||||
|
if not label.isNull:
|
||||||
|
let labelType = label{"userLabelType"}.getStr
|
||||||
|
if labelType == "AutomatedLabel":
|
||||||
|
result.bot = true
|
||||||
|
let entities = label{"longDescription", "entities"}
|
||||||
|
if not entities.isNull:
|
||||||
|
for ent in entities:
|
||||||
|
if ent{"ref", "type"}.getStr != "TimelineRichTextMention": continue
|
||||||
|
result.botOwner = ent{"ref", "screen_name"}.getStr
|
||||||
|
break
|
||||||
|
# TODO: are there other types than the two
|
||||||
|
# TODO: find profile with "userLabelDisplayType" not equal to "Badge"
|
||||||
|
elif labelType == "BusinessLabel":
|
||||||
|
result.badge = Badge(
|
||||||
|
name: label{"description"}.getStr,
|
||||||
|
icon: label{"badge", "url"}.getStr,
|
||||||
|
url: label{"url", "url"}.getStr
|
||||||
|
)
|
||||||
|
|
||||||
|
let pcf = user{"parody_commentary_fan_label"}.getStr
|
||||||
|
if pcf.len > 0 and pcf != "None":
|
||||||
|
result.pcf = pcf
|
||||||
|
|
||||||
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
|
if result.verifiedType == VerifiedType.none and user{"is_blue_verified"}.getBool(false):
|
||||||
result.verifiedType = blue
|
result.verifiedType = blue
|
||||||
@ -205,9 +240,10 @@ proc parseCard(js: JsonNode; urls: JsonNode): Card =
|
|||||||
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
result = Tweet(
|
result = Tweet(
|
||||||
id: js{"id_str"}.getId,
|
id: js{"id_str"}.getStr,
|
||||||
threadId: js{"conversation_id_str"}.getId,
|
threadId: js{"conversation_id_str"}.getStr,
|
||||||
replyId: js{"in_reply_to_status_id_str"}.getId,
|
replyId: js{"in_reply_to_status_id_str"}.getStr,
|
||||||
|
replyHandle: js{"in_reply_to_screen_name"}.getStr,
|
||||||
text: js{"full_text"}.getStr,
|
text: js{"full_text"}.getStr,
|
||||||
time: js{"created_at"}.getTime,
|
time: js{"created_at"}.getTime,
|
||||||
hasThread: js{"self_thread"}.notNull,
|
hasThread: js{"self_thread"}.notNull,
|
||||||
@ -217,22 +253,23 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
replies: js{"reply_count"}.getInt,
|
replies: js{"reply_count"}.getInt,
|
||||||
retweets: js{"retweet_count"}.getInt,
|
retweets: js{"retweet_count"}.getInt,
|
||||||
likes: js{"favorite_count"}.getInt,
|
likes: js{"favorite_count"}.getInt,
|
||||||
quotes: js{"quote_count"}.getInt
|
quotes: js{"quote_count"}.getInt,
|
||||||
|
bookmarks: js{"bookmark_count"}.getInt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# fix for pinned threads
|
# fix for pinned threads
|
||||||
if result.hasThread and result.threadId == 0:
|
if result.hasThread and result.threadId.len == 0:
|
||||||
result.threadId = js{"self_thread", "id_str"}.getId
|
result.threadId = js{"self_thread", "id_str"}.getStr
|
||||||
|
|
||||||
if "retweeted_status" in js:
|
if "retweeted_status" in js:
|
||||||
result.retweet = some Tweet()
|
result.retweet = some Tweet()
|
||||||
elif js{"is_quote_status"}.getBool:
|
elif js{"is_quote_status"}.getBool:
|
||||||
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId)
|
result.quote = some Tweet(id: js{"quoted_status_id_str"}.getStr)
|
||||||
|
|
||||||
# legacy
|
# legacy
|
||||||
with rt, js{"retweeted_status_id_str"}:
|
with rt, js{"retweeted_status_id_str"}:
|
||||||
result.retweet = some Tweet(id: rt.getId)
|
result.retweet = some Tweet(id: rt.getStr)
|
||||||
return
|
return
|
||||||
|
|
||||||
# graphql
|
# graphql
|
||||||
@ -249,7 +286,9 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
let name = jsCard{"name"}.getStr
|
let name = jsCard{"name"}.getStr
|
||||||
if "poll" in name:
|
if "poll" in name:
|
||||||
if "image" in name:
|
if "image" in name:
|
||||||
result.photos.add jsCard{"binding_values", "image_large"}.getImageVal
|
result.photos.add Image(
|
||||||
|
url: jsCard{"binding_values", "image_large"}.getImageVal
|
||||||
|
)
|
||||||
|
|
||||||
result.poll = some parsePoll(jsCard)
|
result.poll = some parsePoll(jsCard)
|
||||||
elif name == "amplify":
|
elif name == "amplify":
|
||||||
@ -263,7 +302,10 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet =
|
|||||||
for m in jsMedia:
|
for m in jsMedia:
|
||||||
case m{"type"}.getStr
|
case m{"type"}.getStr
|
||||||
of "photo":
|
of "photo":
|
||||||
result.photos.add m{"media_url_https"}.getImageStr
|
result.photos.add Image(
|
||||||
|
url: m{"media_url_https"}.getImageStr,
|
||||||
|
description: m{"ext_alt_text"}.getStr,
|
||||||
|
)
|
||||||
of "video":
|
of "video":
|
||||||
result.video = some(parseVideo(m))
|
result.video = some(parseVideo(m))
|
||||||
with user, m{"additional_media_info", "source_user"}:
|
with user, m{"additional_media_info", "source_user"}:
|
||||||
@ -318,11 +360,10 @@ proc parseTweetSearch*(js: JsonNode; after=""): Timeline =
|
|||||||
result.content.add @[parsed]
|
result.content.add @[parsed]
|
||||||
|
|
||||||
if result.content.len > 0:
|
if result.content.len > 0:
|
||||||
result.bottom = $(result.content[^1][0].id - 1)
|
result.bottom = $(result.content[^1][0].id.parseBiggestInt() - 1)
|
||||||
|
|
||||||
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
proc finalizeTweet(global: GlobalObjects; id: string): Tweet =
|
||||||
let intId = if id.len > 0: parseBiggestInt(id) else: 0
|
result = global.tweets.getOrDefault(id, Tweet(id: id))
|
||||||
result = global.tweets.getOrDefault(id, Tweet(id: intId))
|
|
||||||
|
|
||||||
if result.quote.isSome:
|
if result.quote.isSome:
|
||||||
let quote = get(result.quote).id
|
let quote = get(result.quote).id
|
||||||
@ -409,23 +450,6 @@ proc parseTimeline*(js: JsonNode; after=""): Profile =
|
|||||||
else:
|
else:
|
||||||
result.tweets.top = cursor{"value"}.getStr
|
result.tweets.top = cursor{"value"}.getStr
|
||||||
|
|
||||||
proc parsePhotoRail*(js: JsonNode): PhotoRail =
|
|
||||||
with error, js{"error"}:
|
|
||||||
if error.getStr == "Not authorized.":
|
|
||||||
return
|
|
||||||
|
|
||||||
for tweet in js:
|
|
||||||
let
|
|
||||||
t = parseTweet(tweet, js{"tweet_card"})
|
|
||||||
url = if t.photos.len > 0: t.photos[0]
|
|
||||||
elif t.video.isSome: get(t.video).thumb
|
|
||||||
elif t.gif.isSome: get(t.gif).thumb
|
|
||||||
elif t.card.isSome: get(t.card).image
|
|
||||||
else: ""
|
|
||||||
|
|
||||||
if url.len == 0: continue
|
|
||||||
result.add GalleryPhoto(url: url, tweetId: $t.id)
|
|
||||||
|
|
||||||
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
||||||
if js.kind == JNull:
|
if js.kind == JNull:
|
||||||
return Tweet()
|
return Tweet()
|
||||||
@ -442,7 +466,17 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
|||||||
of "TweetPreviewDisplay":
|
of "TweetPreviewDisplay":
|
||||||
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
return Tweet(text: "You're unable to view this Tweet because it's only available to the Subscribers of the account owner.")
|
||||||
of "TweetWithVisibilityResults":
|
of "TweetWithVisibilityResults":
|
||||||
return parseGraphTweet(js{"tweet"}, isLegacy)
|
result = parseGraphTweet(js{"tweet"}, isLegacy)
|
||||||
|
if js.hasKey("limitedActionResults"):
|
||||||
|
for actionRes in js{"limitedActionResults", "limited_actions"}:
|
||||||
|
let action = LimitedActions(
|
||||||
|
title: actionRes{"prompt" , "headline", "text"}.getStr,
|
||||||
|
text: actionRes{"prompt", "subtext", "text"}.getStr
|
||||||
|
)
|
||||||
|
result.limitedActions = some(action)
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
discard
|
discard
|
||||||
|
|
||||||
@ -457,7 +491,7 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
|||||||
jsCard["binding_values"] = values
|
jsCard["binding_values"] = values
|
||||||
|
|
||||||
result = parseTweet(js{"legacy"}, jsCard)
|
result = parseTweet(js{"legacy"}, jsCard)
|
||||||
result.id = js{"rest_id"}.getId
|
result.id = js{"rest_id"}.getStr
|
||||||
result.user = parseGraphUser(js{"core"})
|
result.user = parseGraphUser(js{"core"})
|
||||||
|
|
||||||
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
with noteTweet, js{"note_tweet", "note_tweet_results", "result"}:
|
||||||
@ -467,12 +501,47 @@ proc parseGraphTweet(js: JsonNode; isLegacy=false): Tweet =
|
|||||||
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
|
result.quote = some(parseGraphTweet(js{"quoted_status_result", "result"}, isLegacy))
|
||||||
|
|
||||||
with communityNote, js{"birdwatch_pivot"}:
|
with communityNote, js{"birdwatch_pivot"}:
|
||||||
|
let title = communityNote{"title"}.getStr
|
||||||
let note = BirdwatchNote(
|
let note = BirdwatchNote(
|
||||||
id: communityNote{"note", "rest_id"}.getId,
|
id: communityNote{"note", "rest_id"}.getStr,
|
||||||
title: communityNote{"title"}.getStr,
|
title: title,
|
||||||
)
|
)
|
||||||
note.expandBirdwatchEntities(communityNote{"subtitle"})
|
note.expandBirdwatchEntities(communityNote{"subtitle"})
|
||||||
result.birdwatch = some(note)
|
if title != "Rate proposed Community Notes":
|
||||||
|
result.birdwatch = some(note)
|
||||||
|
|
||||||
|
if not js{"views", "count"}.isNull:
|
||||||
|
result.stats.views = parseInt(js{"views", "count"}.getStr)
|
||||||
|
else:
|
||||||
|
result.stats.views = -1
|
||||||
|
|
||||||
|
proc parseGraphPhotoRail*(js: JsonNode): PhotoRail =
|
||||||
|
result = @[]
|
||||||
|
|
||||||
|
let instructions =
|
||||||
|
? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
||||||
|
|
||||||
|
for i in instructions:
|
||||||
|
if i{"__typename"}.getStr == "TimelineAddEntries":
|
||||||
|
for e in i{"entries"}:
|
||||||
|
let entryId = e{"entryId"}.getStr
|
||||||
|
if entryId.startsWith("tweet"):
|
||||||
|
with tweetResult, e{"content", "content", "tweetResult", "result"}:
|
||||||
|
let t = parseGraphTweet(tweetResult, false)
|
||||||
|
if not t.available:
|
||||||
|
t.id = $parseBiggestInt(entryId.getId())
|
||||||
|
|
||||||
|
let url =
|
||||||
|
if t.photos.len > 0: t.photos[0].url
|
||||||
|
elif t.video.isSome: get(t.video).thumb
|
||||||
|
elif t.gif.isSome: get(t.gif).thumb
|
||||||
|
elif t.card.isSome: get(t.card).image
|
||||||
|
else: ""
|
||||||
|
|
||||||
|
result.add GalleryPhoto(url: url, tweetId: t.id)
|
||||||
|
|
||||||
|
if result.len == 16:
|
||||||
|
break
|
||||||
|
|
||||||
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
||||||
for t in js{"content", "items"}:
|
for t in js{"content", "items"}:
|
||||||
@ -481,7 +550,7 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] =
|
|||||||
let cursor = t{"item", "content", "value"}
|
let cursor = t{"item", "content", "value"}
|
||||||
result.thread.cursor = cursor.getStr
|
result.thread.cursor = cursor.getStr
|
||||||
result.thread.hasMore = true
|
result.thread.hasMore = true
|
||||||
elif "tweet" in entryId:
|
elif "tweet" in entryId and "promoted-" notin entryId:
|
||||||
let
|
let
|
||||||
isLegacy = t{"item"}.hasKey("itemContent")
|
isLegacy = t{"item"}.hasKey("itemContent")
|
||||||
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
|
(contentKey, resultKey) = if isLegacy: ("itemContent", "tweet_results")
|
||||||
@ -504,67 +573,53 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation =
|
|||||||
if instructions.len == 0:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for e in instructions[0]{"entries"}:
|
for i in instructions:
|
||||||
let entryId = e{"entryId"}.getStr
|
if i{"type"}.getStr == "TimelineAddEntries":
|
||||||
if entryId.startsWith("tweet"):
|
for e in i{"entries"}:
|
||||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
let entryId = e{"entryId"}.getStr
|
||||||
let tweet = parseGraphTweet(tweetResult, true)
|
if entryId.startsWith("tweet"):
|
||||||
|
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||||
|
let tweet = parseGraphTweet(tweetResult, true)
|
||||||
|
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
tweet.id = entryId.getId()
|
||||||
|
|
||||||
if $tweet.id == tweetId:
|
if tweet.id == tweetId:
|
||||||
result.tweet = tweet
|
result.tweet = tweet
|
||||||
else:
|
else:
|
||||||
result.before.content.add tweet
|
result.before.content.add tweet
|
||||||
elif entryId.startsWith("tombstone"):
|
elif entryId.startsWith("tombstone"):
|
||||||
let id = entryId.getId()
|
let id = entryId.getId()
|
||||||
let tweet = Tweet(
|
let tweet = Tweet(
|
||||||
id: parseBiggestInt(id),
|
id: id,
|
||||||
available: false,
|
available: false,
|
||||||
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone
|
||||||
)
|
)
|
||||||
|
|
||||||
if id == tweetId:
|
if id == tweetId:
|
||||||
result.tweet = tweet
|
result.tweet = tweet
|
||||||
else:
|
else:
|
||||||
result.before.content.add tweet
|
result.before.content.add tweet
|
||||||
elif entryId.startsWith("conversationthread"):
|
elif (entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation")) and "promoted-tweet" notin entryId:
|
||||||
let (thread, self) = parseGraphThread(e)
|
let (thread, self) = parseGraphThread(e)
|
||||||
if self:
|
if self:
|
||||||
result.after = thread
|
result.after = thread
|
||||||
else:
|
else:
|
||||||
result.replies.content.add thread
|
result.replies.content.add thread
|
||||||
elif entryId.startsWith("cursor-bottom"):
|
elif entryId.startsWith("cursor-bottom"):
|
||||||
result.replies.bottom = e{"content", "itemContent", "value"}.getStr
|
result.replies.bottom = e.getCursor
|
||||||
|
|
||||||
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
||||||
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
result = Profile(tweets: Timeline(beginning: after.len == 0))
|
||||||
|
|
||||||
let instructions =
|
let instructions =
|
||||||
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
|
if root == "list": ? js{"data", "list", "timeline_response", "timeline", "instructions"}
|
||||||
elif root == "user": ? js{"data", "user_result", "result", "timeline_response", "timeline", "instructions"}
|
|
||||||
else: ? js{"data", "user", "result", "timeline", "timeline", "instructions"}
|
else: ? js{"data", "user", "result", "timeline", "timeline", "instructions"}
|
||||||
|
|
||||||
if instructions.len == 0:
|
if instructions.len == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
for i in instructions:
|
for i in instructions:
|
||||||
if i{"__typename"}.getStr == "TimelineAddEntries":
|
|
||||||
for e in i{"entries"}:
|
|
||||||
let entryId = e{"entryId"}.getStr
|
|
||||||
if entryId.startsWith("tweet"):
|
|
||||||
with tweetResult, e{"content", "content", "tweetResult", "result"}:
|
|
||||||
let tweet = parseGraphTweet(tweetResult, false)
|
|
||||||
if not tweet.available:
|
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
|
||||||
result.tweets.content.add tweet
|
|
||||||
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
|
||||||
let (thread, self) = parseGraphThread(e)
|
|
||||||
result.tweets.content.add thread.content
|
|
||||||
elif entryId.startsWith("cursor-bottom"):
|
|
||||||
result.tweets.bottom = e{"content", "value"}.getStr
|
|
||||||
# TODO cleanup
|
|
||||||
if i{"type"}.getStr == "TimelineAddEntries":
|
if i{"type"}.getStr == "TimelineAddEntries":
|
||||||
for e in i{"entries"}:
|
for e in i{"entries"}:
|
||||||
let entryId = e{"entryId"}.getStr
|
let entryId = e{"entryId"}.getStr
|
||||||
@ -572,21 +627,21 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile =
|
|||||||
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
with tweetResult, e{"content", "itemContent", "tweet_results", "result"}:
|
||||||
let tweet = parseGraphTweet(tweetResult, false)
|
let tweet = parseGraphTweet(tweetResult, false)
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
tweet.id = entryId.getId()
|
||||||
result.tweets.content.add tweet
|
result.tweets.content.add tweet
|
||||||
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
elif "-conversation-" in entryId or entryId.startsWith("homeConversation"):
|
||||||
let (thread, self) = parseGraphThread(e)
|
let (thread, self) = parseGraphThread(e)
|
||||||
result.tweets.content.add thread.content
|
result.tweets.content.add thread.content
|
||||||
elif entryId.startsWith("cursor-bottom"):
|
elif entryId.startsWith("cursor-bottom"):
|
||||||
result.tweets.bottom = e{"content", "value"}.getStr
|
result.tweets.bottom = e{"content", "value"}.getStr
|
||||||
if after.len == 0 and i{"__typename"}.getStr == "TimelinePinEntry":
|
if after.len == 0 and i{"type"}.getStr == "TimelinePinEntry":
|
||||||
with tweetResult, i{"entry", "content", "content", "tweetResult", "result"}:
|
with tweetResult, i{"entry", "content", "itemContent", "tweet_results", "result"}:
|
||||||
let tweet = parseGraphTweet(tweetResult, false)
|
let tweet = parseGraphTweet(tweetResult, false)
|
||||||
tweet.pinned = true
|
tweet.pinned = true
|
||||||
if not tweet.available and tweet.tombstone.len == 0:
|
if not tweet.available and tweet.tombstone.len == 0:
|
||||||
let entryId = i{"entry", "entryId"}.getEntryId
|
let entryId = i{"entry", "entryId"}.getEntryId
|
||||||
if entryId.len > 0:
|
if entryId.len > 0:
|
||||||
tweet.id = parseBiggestInt(entryId)
|
tweet.id = entryId
|
||||||
result.pinned = some tweet
|
result.pinned = some tweet
|
||||||
|
|
||||||
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
|
proc parseGraphUsersTimeline(timeline: JsonNode; after=""): UsersTimeline =
|
||||||
@ -636,7 +691,7 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
|
|||||||
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}:
|
with tweetRes, e{"content", "itemContent", "tweet_results", "result"}:
|
||||||
let tweet = parseGraphTweet(tweetRes)
|
let tweet = parseGraphTweet(tweetRes)
|
||||||
if not tweet.available:
|
if not tweet.available:
|
||||||
tweet.id = parseBiggestInt(entryId.getId())
|
tweet.id = entryId.getId()
|
||||||
result.content.add tweet
|
result.content.add tweet
|
||||||
elif T is User:
|
elif T is User:
|
||||||
if entryId.startsWith("user"):
|
if entryId.startsWith("user"):
|
||||||
|
|||||||
@ -48,7 +48,13 @@ template with*(ident; value: JsonNode; body): untyped =
|
|||||||
if notNull(value): body
|
if notNull(value): body
|
||||||
|
|
||||||
template getCursor*(js: JsonNode): string =
|
template getCursor*(js: JsonNode): string =
|
||||||
js{"content", "operation", "cursor", "value"}.getStr
|
var cursor = js{"content", "operation", "cursor", "value"}
|
||||||
|
if cursor.isNull:
|
||||||
|
cursor = js{"content", "value"}
|
||||||
|
if cursor.isNull:
|
||||||
|
cursor = js{"content", "itemContent", "value"}
|
||||||
|
|
||||||
|
cursor.getStr
|
||||||
|
|
||||||
template getError*(js: JsonNode): Error =
|
template getError*(js: JsonNode): Error =
|
||||||
if js.kind != JArray or js.len == 0: null
|
if js.kind != JArray or js.len == 0: null
|
||||||
@ -286,7 +292,7 @@ proc expandTextEntities(tweet: Tweet; entities: JsonNode; text: string; textSlic
|
|||||||
url: "/" & name, display: mention["name"].getStr)
|
url: "/" & name, display: mention["name"].getStr)
|
||||||
if idx > -1 and name != replyTo:
|
if idx > -1 and name != replyTo:
|
||||||
tweet.reply.delete idx
|
tweet.reply.delete idx
|
||||||
elif idx == -1 and tweet.replyId != 0:
|
elif idx == -1 and tweet.replyId.len != 0:
|
||||||
tweet.reply.add name
|
tweet.reply.add name
|
||||||
|
|
||||||
replacements.deduplicate
|
replacements.deduplicate
|
||||||
@ -303,7 +309,7 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
|
|||||||
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
|
||||||
|
|
||||||
var replyTo = ""
|
var replyTo = ""
|
||||||
if tweet.replyId != 0:
|
if tweet.replyId.len != 0:
|
||||||
with reply, js{"in_reply_to_screen_name"}:
|
with reply, js{"in_reply_to_screen_name"}:
|
||||||
replyTo = reply.getStr
|
replyTo = reply.getStr
|
||||||
tweet.reply.add replyTo
|
tweet.reply.add replyTo
|
||||||
|
|||||||
@ -59,6 +59,9 @@ genPrefs:
|
|||||||
theme(select, "Nitter"):
|
theme(select, "Nitter"):
|
||||||
"Theme"
|
"Theme"
|
||||||
|
|
||||||
|
eirResources(checkbox, true):
|
||||||
|
"Some extra silly js I added, like cursors :3"
|
||||||
|
|
||||||
infiniteScroll(checkbox, false):
|
infiniteScroll(checkbox, false):
|
||||||
"Infinite scrolling (experimental, requires JavaScript)"
|
"Infinite scrolling (experimental, requires JavaScript)"
|
||||||
|
|
||||||
|
|||||||
@ -66,7 +66,8 @@ proc initRedisPool*(cfg: Config) {.async.} =
|
|||||||
template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
|
template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000)
|
||||||
template userKey(name: string): string = "p:" & name
|
template userKey(name: string): string = "p:" & name
|
||||||
template listKey(l: List): string = "l:" & l.id
|
template listKey(l: List): string = "l:" & l.id
|
||||||
template tweetKey(id: int64): string = "t:" & $id
|
template tweetKey(id: string): string = "t:" & id
|
||||||
|
template convKey(id: string): string = "c:" & id
|
||||||
|
|
||||||
proc get(query: string): Future[string] {.async.} =
|
proc get(query: string): Future[string] {.async.} =
|
||||||
pool.withAcquire(r):
|
pool.withAcquire(r):
|
||||||
@ -86,7 +87,7 @@ proc cache*(data: List) {.async.} =
|
|||||||
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
await setEx(data.listKey, listCacheTime, compress(toFlatty(data)))
|
||||||
|
|
||||||
proc cache*(data: PhotoRail; name: string) {.async.} =
|
proc cache*(data: PhotoRail; name: string) {.async.} =
|
||||||
await setEx("pr:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
|
await setEx("pr2:" & toLower(name), baseCacheTime * 2, compress(toFlatty(data)))
|
||||||
|
|
||||||
proc cache*(data: User) {.async.} =
|
proc cache*(data: User) {.async.} =
|
||||||
if data.username.len == 0: return
|
if data.username.len == 0: return
|
||||||
@ -96,10 +97,15 @@ proc cache*(data: User) {.async.} =
|
|||||||
dawait r.setEx(name.userKey, baseCacheTime, compress(toFlatty(data)))
|
dawait r.setEx(name.userKey, baseCacheTime, compress(toFlatty(data)))
|
||||||
|
|
||||||
proc cache*(data: Tweet) {.async.} =
|
proc cache*(data: Tweet) {.async.} =
|
||||||
if data.isNil or data.id == 0: return
|
if data.isNil or data.id.len == 0: return
|
||||||
pool.withAcquire(r):
|
pool.withAcquire(r):
|
||||||
dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data)))
|
dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data)))
|
||||||
|
|
||||||
|
proc cache*(data: Conversation) {.async.} =
|
||||||
|
if data.isNil or data.tweet.isNil or data.tweet.id.len == 0: return
|
||||||
|
pool.withAcquire(r):
|
||||||
|
dawait r.setEx(data.tweet.id.convKey, baseCacheTime, compress(toFlatty(data)))
|
||||||
|
|
||||||
proc cacheRss*(query: string; rss: Rss) {.async.} =
|
proc cacheRss*(query: string; rss: Rss) {.async.} =
|
||||||
let key = "rss:" & query
|
let key = "rss:" & query
|
||||||
pool.withAcquire(r):
|
pool.withAcquire(r):
|
||||||
@ -114,7 +120,13 @@ template deserialize(data, T) =
|
|||||||
except:
|
except:
|
||||||
echo "Decompression failed($#): '$#'" % [astToStr(T), data]
|
echo "Decompression failed($#): '$#'" % [astToStr(T), data]
|
||||||
|
|
||||||
proc getUserId*(username: string): Future[string] {.async.} =
|
proc deserializeConversation(data: string): Conversation =
|
||||||
|
try:
|
||||||
|
result = fromFlatty(uncompress(data), Conversation)
|
||||||
|
except:
|
||||||
|
echo "Decompression failed(Conversation): '$#'" % [data]
|
||||||
|
|
||||||
|
proc getCachedUserId*(username: string): Future[string] {.async.} =
|
||||||
let name = toLower(username)
|
let name = toLower(username)
|
||||||
pool.withAcquire(r):
|
pool.withAcquire(r):
|
||||||
result = await r.hGet(name.uidKey, name)
|
result = await r.hGet(name.uidKey, name)
|
||||||
@ -133,13 +145,16 @@ proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} =
|
|||||||
elif fetch:
|
elif fetch:
|
||||||
result = await getGraphUser(username)
|
result = await getGraphUser(username)
|
||||||
await cache(result)
|
await cache(result)
|
||||||
|
if result.id.len > 0:
|
||||||
|
await setEx("i:" & result.id, baseCacheTime, result.username)
|
||||||
|
await cacheUserId(result.username, result.id)
|
||||||
|
|
||||||
proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
||||||
let
|
let
|
||||||
key = "i:" & userId
|
key = "i:" & userId
|
||||||
username = await get(key)
|
username = await get(key)
|
||||||
|
|
||||||
if username != redisNil:
|
if username != redisNil and username.len > 0:
|
||||||
result = username
|
result = username
|
||||||
else:
|
else:
|
||||||
let user = await getGraphUserById(userId)
|
let user = await getGraphUserById(userId)
|
||||||
@ -148,24 +163,28 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
|||||||
if result.len > 0 and user.id.len > 0:
|
if result.len > 0 and user.id.len > 0:
|
||||||
await all(cacheUserId(result, user.id), cache(user))
|
await all(cacheUserId(result, user.id), cache(user))
|
||||||
|
|
||||||
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
|
proc getCachedTweet*(id: string; after=""): Future[Conversation] {.async.} =
|
||||||
# if id == 0: return
|
if id.len == 0: return
|
||||||
# let tweet = await get(id.tweetKey)
|
let tweet = await get(id.tweetKey)
|
||||||
# if tweet != redisNil:
|
|
||||||
# tweet.deserialize(Tweet)
|
|
||||||
# else:
|
|
||||||
# result = await getGraphTweetResult($id)
|
|
||||||
# if not result.isNil:
|
|
||||||
# await cache(result)
|
|
||||||
|
|
||||||
proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} =
|
if tweet != redisNil:
|
||||||
if name.len == 0: return
|
result = deserializeConversation(tweet)
|
||||||
let rail = await get("pr:" & toLower(name))
|
else:
|
||||||
|
result = await getGraphTweet(id)
|
||||||
|
if not result.isNil:
|
||||||
|
await cache(result)
|
||||||
|
|
||||||
|
if not result.isNil and after.len > 0:
|
||||||
|
result.replies = await getReplies(id, after)
|
||||||
|
|
||||||
|
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||||
|
if id.len == 0: return
|
||||||
|
let rail = await get("pr2:" & toLower(id))
|
||||||
if rail != redisNil:
|
if rail != redisNil:
|
||||||
rail.deserialize(PhotoRail)
|
rail.deserialize(PhotoRail)
|
||||||
else:
|
else:
|
||||||
result = await getPhotoRail(name)
|
result = await getPhotoRail(id)
|
||||||
await cache(result, name)
|
await cache(result, id)
|
||||||
|
|
||||||
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||||
let list = if id.len == 0: redisNil
|
let list = if id.len == 0: redisNil
|
||||||
|
|||||||
350
src/routes/activityspoof.nim
Normal file
350
src/routes/activityspoof.nim
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
|
||||||
|
|
||||||
|
import jester
|
||||||
|
|
||||||
|
import router_utils
|
||||||
|
import ".."/[types, formatters, api, redis_cache]
|
||||||
|
import ../views/[mastoapi]
|
||||||
|
|
||||||
|
export json, uri, sequtils, options, sugar, times
|
||||||
|
export router_utils
|
||||||
|
export api, formatters
|
||||||
|
export mastoapi
|
||||||
|
|
||||||
|
proc createActivityPubRouter*(cfg: Config) =
|
||||||
|
router activityspoof:
|
||||||
|
get "/api/v1/accounts/?":
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, """[]"""
|
||||||
|
|
||||||
|
get "/api/v1/accounts/@id":
|
||||||
|
let id = @"id"
|
||||||
|
#if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
|
if not id.allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'}):
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid account ID"}"""
|
||||||
|
|
||||||
|
#var username = await getCachedUsername(id)
|
||||||
|
#if username.len == 0:
|
||||||
|
#resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||||
|
|
||||||
|
let user = await getCachedUser(id)
|
||||||
|
if user.suspended or user.id.len == 0:
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $getMastoAPIUser(user, cfg)
|
||||||
|
|
||||||
|
get "/api/v1/statuses/@id":
|
||||||
|
var
|
||||||
|
id = @"id"
|
||||||
|
query = ""
|
||||||
|
|
||||||
|
# stupid hack to trick discord lmao
|
||||||
|
if id.startsWith("422209040515"):
|
||||||
|
query = "video"
|
||||||
|
id.removePrefix("422209040515")
|
||||||
|
elif id.startsWith("421608152015"):
|
||||||
|
query = "photo:"
|
||||||
|
id.removePrefix("421608152015")
|
||||||
|
query &= id[0]
|
||||||
|
id = id[1..^1]
|
||||||
|
|
||||||
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||||
|
|
||||||
|
let prefs = cookiePrefs()
|
||||||
|
|
||||||
|
let conv = await getCachedTweet(id)
|
||||||
|
if conv == nil:
|
||||||
|
echo "nil conv"
|
||||||
|
|
||||||
|
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
|
||||||
|
var error = "Record not found"
|
||||||
|
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||||
|
error = conv.tweet.tombstone
|
||||||
|
|
||||||
|
var errJson = newJObject()
|
||||||
|
errJson["error"] = %error
|
||||||
|
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||||
|
|
||||||
|
var
|
||||||
|
mediaType = ""
|
||||||
|
mediaIndex = ""
|
||||||
|
if query.len > 0:
|
||||||
|
let parts = query.split(":")
|
||||||
|
mediaType = parts[0]
|
||||||
|
if parts.len == 2:
|
||||||
|
mediaIndex = parts[1]
|
||||||
|
|
||||||
|
let
|
||||||
|
tweet = conv.tweet
|
||||||
|
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}"
|
||||||
|
var media: seq[JsonNode] = @[]
|
||||||
|
|
||||||
|
if mediaType.len > 0:
|
||||||
|
if mediaType == "video" and tweet.video.isSome or tweet.gif.isSome:
|
||||||
|
tweet.photos = @[]
|
||||||
|
elif mediaType == "photo" and tweet.photos.len > 0:
|
||||||
|
if mediaIndex.len > 0:
|
||||||
|
var index = parseInt(mediaIndex)
|
||||||
|
var useVideo = false
|
||||||
|
if index > tweet.photos.len:
|
||||||
|
if tweet.video.isSome or tweet.gif.isSome:
|
||||||
|
useVideo = true
|
||||||
|
else:
|
||||||
|
index = tweet.photos.len
|
||||||
|
elif index < 1:
|
||||||
|
index = 1
|
||||||
|
|
||||||
|
if useVideo:
|
||||||
|
tweet.photos = @[]
|
||||||
|
else:
|
||||||
|
index -= 1
|
||||||
|
tweet.video = none(Video)
|
||||||
|
let image = tweet.photos[index]
|
||||||
|
tweet.photos = @[]
|
||||||
|
tweet.photos.add(image)
|
||||||
|
|
||||||
|
if tweet.photos.len > 0:
|
||||||
|
for imageObj in tweet.photos:
|
||||||
|
let image = getUrlPrefix(cfg) & getPicUrl(imageObj.url)
|
||||||
|
var mediaObj = newJObject()
|
||||||
|
|
||||||
|
mediaObj["id"] = %"138733266285887488" # idk if discord even parses this snowflake, but its my user id why not
|
||||||
|
mediaObj["type"] = %"image"
|
||||||
|
mediaObj["url"] = %image
|
||||||
|
mediaObj["preview_url"] = %image
|
||||||
|
mediaObj["remote_url"] = %image
|
||||||
|
mediaObj["preview_remote_url"] = %image
|
||||||
|
mediaObj["text_url"] = newJNull()
|
||||||
|
mediaObj["description"] = %imageObj.description
|
||||||
|
# FIXME but this probably isnt used by discord
|
||||||
|
mediaObj["meta"] = newJObject()
|
||||||
|
|
||||||
|
media.add(mediaObj)
|
||||||
|
|
||||||
|
if tweet.video.isSome():
|
||||||
|
let
|
||||||
|
videoObj = get(tweet.video)
|
||||||
|
vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||||
|
videoUrl = vars[^1].url.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", "")
|
||||||
|
videoPreview = getUrlPrefix(cfg) & getPicUrl(videoObj.thumb)
|
||||||
|
var mediaObj = newJObject()
|
||||||
|
var description = videoObj.title
|
||||||
|
if videoObj.description.len > 0:
|
||||||
|
description = videoObj.description
|
||||||
|
|
||||||
|
mediaObj["id"] = %"138733266285887488"
|
||||||
|
mediaObj["type"] = %"video"
|
||||||
|
mediaObj["url"] = %videoUrl
|
||||||
|
mediaObj["preview_url"] = %videoPreview
|
||||||
|
mediaObj["remote_url"] = %videoUrl
|
||||||
|
mediaObj["preview_remote_url"] = %videoPreview
|
||||||
|
mediaObj["text_url"] = newJNull()
|
||||||
|
mediaObj["description"] = %description
|
||||||
|
# FIXME but this probably isnt used by discord
|
||||||
|
mediaObj["meta"] = newJObject()
|
||||||
|
|
||||||
|
media.add(mediaObj)
|
||||||
|
elif tweet.gif.isSome():
|
||||||
|
let
|
||||||
|
gif = get(tweet.gif)
|
||||||
|
gifUrl = (https & gif.url).replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", "")
|
||||||
|
gifPreview = getUrlPrefix(cfg) & getPicUrl(gif.thumb)
|
||||||
|
var mediaObj = newJObject()
|
||||||
|
|
||||||
|
mediaObj["id"] = %"138733266285887488"
|
||||||
|
mediaObj["type"] = %"video"
|
||||||
|
mediaObj["url"] = %gifUrl
|
||||||
|
mediaObj["preview_url"] = %gifPreview
|
||||||
|
mediaObj["remote_url"] = %gifUrl
|
||||||
|
mediaObj["preview_remote_url"] = %gifPreview
|
||||||
|
mediaObj["text_url"] = newJNull()
|
||||||
|
mediaObj["description"] = newJNull() # FIXME this requires refactoring gifs
|
||||||
|
# FIXME but this probably isnt used by discord
|
||||||
|
mediaObj["meta"] = newJObject()
|
||||||
|
|
||||||
|
media.add(mediaObj)
|
||||||
|
|
||||||
|
var fields: seq[JsonNode] = @[]
|
||||||
|
|
||||||
|
if tweet.user.location.len > 0:
|
||||||
|
var location = newJObject()
|
||||||
|
location["name"] = %"Location"
|
||||||
|
location["value"] = %tweet.user.location
|
||||||
|
location["verified_at"] = newJNull()
|
||||||
|
fields.add(location)
|
||||||
|
|
||||||
|
if tweet.user.website.len > 0:
|
||||||
|
var website = newJObject()
|
||||||
|
website["name"] = %"Website"
|
||||||
|
website["value"] = %(&"<a href=\"{tweet.user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{tweet.user.website}</a>")
|
||||||
|
website["verified_at"] = newJNull()
|
||||||
|
fields.add(website)
|
||||||
|
|
||||||
|
var postJson = newJObject()
|
||||||
|
postJson["id"] = %tweet.id
|
||||||
|
postJson["url"] = %tweetUrl
|
||||||
|
postJson["uri"] = %tweetUrl
|
||||||
|
postJson["created_at"] = %($tweet.time)
|
||||||
|
postJson["edited_at"] = newJNull()
|
||||||
|
postJson["reblog"] = newJNull()
|
||||||
|
if tweet.replyId.len != 0:
|
||||||
|
postJson["in_reply_to_id"] = %(&"{tweet.replyId}")
|
||||||
|
postJson["in_reply_to_account_id"] = %tweet.replyHandle
|
||||||
|
else:
|
||||||
|
postJson["in_reply_to_id"] = newJNull()
|
||||||
|
postJson["in_reply_to_account_id"] = newJNull()
|
||||||
|
postJson["language"] = %"en" # FIXME
|
||||||
|
postJson["content"] = %formatTweetForMastoAPI(tweet, cfg, prefs)
|
||||||
|
postJson["spoiler_text"] = %""
|
||||||
|
postJson["visibility"] = %"public"
|
||||||
|
postJson["application"] = %*{
|
||||||
|
"name": "Nitter",
|
||||||
|
"website": getUrlPrefix(cfg)
|
||||||
|
}
|
||||||
|
postJson["media_attachments"] = %media
|
||||||
|
postJson["account"] = getMastoAPIUser(tweet.user, cfg)
|
||||||
|
postJson["mentions"] = newJArray() # TODO: parse?
|
||||||
|
postJson["tags"] = newJArray() # TODO: parse?
|
||||||
|
postJson["emojis"] = newJArray()
|
||||||
|
postJson["card"] = newJNull()
|
||||||
|
postJson["poll"] = newJNull() # TODO: parse?
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||||
|
|
||||||
|
get "/users/@name/statuses/@id":
|
||||||
|
var
|
||||||
|
id = @"id"
|
||||||
|
query = ""
|
||||||
|
|
||||||
|
# stupid hack to trick discord lmao
|
||||||
|
if id.startsWith("422209040515"):
|
||||||
|
query = "video"
|
||||||
|
id.removePrefix("422209040515")
|
||||||
|
elif id.startsWith("421608152015"):
|
||||||
|
query = "photo:"
|
||||||
|
id.removePrefix("421608152015")
|
||||||
|
query &= id[0]
|
||||||
|
id = id[1..^1]
|
||||||
|
|
||||||
|
var
|
||||||
|
mediaType = ""
|
||||||
|
mediaIndex = ""
|
||||||
|
if query.len > 0:
|
||||||
|
let parts = query.split(":")
|
||||||
|
mediaType = parts[0]
|
||||||
|
if parts.len == 2:
|
||||||
|
mediaIndex = parts[1]
|
||||||
|
|
||||||
|
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||||
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||||
|
|
||||||
|
let prefs = cookiePrefs()
|
||||||
|
|
||||||
|
let conv = await getCachedTweet(id)
|
||||||
|
if conv == nil:
|
||||||
|
echo "nil conv"
|
||||||
|
|
||||||
|
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
|
||||||
|
var error = "Record not found"
|
||||||
|
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||||
|
error = conv.tweet.tombstone
|
||||||
|
|
||||||
|
var errJson = newJObject()
|
||||||
|
errJson["error"] = %error
|
||||||
|
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||||
|
|
||||||
|
let tweet = conv.tweet
|
||||||
|
|
||||||
|
if mediaType.len > 0:
|
||||||
|
if mediaType == "video" and tweet.video.isSome or tweet.gif.isSome:
|
||||||
|
tweet.photos = @[]
|
||||||
|
elif mediaType == "photo" and tweet.photos.len > 0:
|
||||||
|
if mediaIndex.len > 0:
|
||||||
|
var index = parseInt(mediaIndex)
|
||||||
|
var useVideo = false
|
||||||
|
if index > tweet.photos.len:
|
||||||
|
if tweet.video.isSome or tweet.gif.isSome:
|
||||||
|
useVideo = true
|
||||||
|
else:
|
||||||
|
index = tweet.photos.len
|
||||||
|
elif index < 1:
|
||||||
|
index = 1
|
||||||
|
|
||||||
|
if useVideo:
|
||||||
|
tweet.photos = @[]
|
||||||
|
else:
|
||||||
|
index -= 1
|
||||||
|
tweet.video = none(Video)
|
||||||
|
let image = tweet.photos[index]
|
||||||
|
tweet.photos = @[]
|
||||||
|
tweet.photos.add(image)
|
||||||
|
|
||||||
|
let postJson = getActivityStream(tweet, cfg, prefs)
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||||
|
|
||||||
|
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||||
|
|
||||||
|
get "/users/@name":
|
||||||
|
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||||
|
let user = await getCachedUser(@"name")
|
||||||
|
if user.suspended or user.id.len == 0:
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||||
|
|
||||||
|
let prefs = cookiePrefs()
|
||||||
|
|
||||||
|
let userJson = getActivityStream(user, cfg, prefs)
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $userJson
|
||||||
|
|
||||||
|
redirect("/" & @"name")
|
||||||
|
|
||||||
|
# might as well
|
||||||
|
get "/.well-known/nodeinfo":
|
||||||
|
var nodeinfo = newJObject()
|
||||||
|
let link: JsonNode = %*{
|
||||||
|
"href": &"{getUrlPrefix(cfg)}/nodeinfo/2.1.json",
|
||||||
|
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1"
|
||||||
|
}
|
||||||
|
var links: seq[JsonNode] = @[]
|
||||||
|
links.add(link)
|
||||||
|
|
||||||
|
nodeinfo["links"] = %links
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
|
||||||
|
|
||||||
|
get "/nodeinfo/2.1.json":
|
||||||
|
var nodeinfo = newJObject()
|
||||||
|
nodeinfo["version"] = %"2.1"
|
||||||
|
nodeinfo["software"] = %*{
|
||||||
|
"name": cfg.title,
|
||||||
|
"repository": "https://git.eir-nya.gay/eir/nitter"
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = newJObject()
|
||||||
|
metadata["features"] = newJArray()
|
||||||
|
metadata["federation"] = newJObject()
|
||||||
|
metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)"
|
||||||
|
metadata["nodeName"] = %cfg.title
|
||||||
|
metadata["private"] = %true
|
||||||
|
metadata["maintainer"] = %*{
|
||||||
|
"name": "Eir",
|
||||||
|
"email": "eir@eir-nya.gay"
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeinfo["metadata"] = metadata
|
||||||
|
nodeinfo["openRegistrations"] = %false
|
||||||
|
nodeinfo["protocols"] = newJArray()
|
||||||
|
|
||||||
|
var services = newJObject()
|
||||||
|
services["inbound"] = newJArray()
|
||||||
|
services["outbound"] = newJArray()
|
||||||
|
|
||||||
|
nodeinfo["services"] = services
|
||||||
|
nodeinfo["usage"] = newJObject()
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $nodeinfo
|
||||||
@ -1,17 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
import jester
|
|
||||||
import router_utils
|
|
||||||
import ".."/[tokens, types]
|
|
||||||
|
|
||||||
proc createDebugRouter*(cfg: Config) =
|
|
||||||
router debug:
|
|
||||||
get "/.tokens":
|
|
||||||
cond cfg.enableDebug
|
|
||||||
respJson getPoolJson()
|
|
||||||
|
|
||||||
#get "/.health":
|
|
||||||
#respJson getAccountPoolHealth()
|
|
||||||
|
|
||||||
#get "/.accounts":
|
|
||||||
#cond cfg.enableDebug
|
|
||||||
#respJson getAccountPoolDebug()
|
|
||||||
@ -4,7 +4,7 @@ import strutils, strformat, uri
|
|||||||
import jester
|
import jester
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, redis_cache, api]
|
import ".."/[types, api, redis_cache]
|
||||||
import ../views/[general, timeline, list]
|
import ../views/[general, timeline, list]
|
||||||
|
|
||||||
template respList*(list, timeline, title, vnode: typed) =
|
template respList*(list, timeline, title, vnode: typed) =
|
||||||
@ -20,6 +20,14 @@ template respList*(list, timeline, title, vnode: typed) =
|
|||||||
proc title*(list: List): string =
|
proc title*(list: List): string =
|
||||||
&"@{list.username}/{list.name}"
|
&"@{list.username}/{list.name}"
|
||||||
|
|
||||||
|
|
||||||
|
proc getList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||||
|
if id.len > 0:
|
||||||
|
result = await getGraphList(id)
|
||||||
|
else:
|
||||||
|
result = await getGraphListBySlug(username, slug)
|
||||||
|
|
||||||
|
|
||||||
proc createListRouter*(cfg: Config) =
|
proc createListRouter*(cfg: Config) =
|
||||||
router list:
|
router list:
|
||||||
get "/@name/lists/@slug/?":
|
get "/@name/lists/@slug/?":
|
||||||
|
|||||||
@ -141,3 +141,6 @@ proc createMediaRouter*(cfg: Config) =
|
|||||||
content = proxifyVideo(vid, cookiePref(proxyVideos))
|
content = proxifyVideo(vid, cookiePref(proxyVideos))
|
||||||
|
|
||||||
resp content, m3u8Mime
|
resp content, m3u8Mime
|
||||||
|
|
||||||
|
get re"^\/tvid\/(.+)$":
|
||||||
|
redirect("https://video.twimg.com/" & request.matches[0] & ".mp4")
|
||||||
|
|||||||
@ -4,22 +4,26 @@ import strutils
|
|||||||
import jester
|
import jester
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, api]
|
import ".."/[types, api, formatters]
|
||||||
import ../views/general
|
import ../views/general
|
||||||
|
|
||||||
template respResolved*(url, kind: string): untyped =
|
template respResolved*(url, kind: string; prefs: Prefs): untyped =
|
||||||
let u = url
|
let u = url
|
||||||
if u.len == 0:
|
if u.len == 0:
|
||||||
resp showError("Invalid $1 link" % kind, cfg)
|
resp showError("Invalid $1 link" % kind, cfg)
|
||||||
else:
|
else:
|
||||||
redirect(u)
|
redirect(replaceUrls(u, prefs))
|
||||||
|
|
||||||
proc createResolverRouter*(cfg: Config) =
|
proc createResolverRouter*(cfg: Config) =
|
||||||
router resolver:
|
router resolver:
|
||||||
get "/cards/@card/@id":
|
get "/cards/@card/@id":
|
||||||
let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
let
|
||||||
respResolved(await resolve(url, cookiePrefs()), "card")
|
prefs = cookiePrefs()
|
||||||
|
url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"]
|
||||||
|
respResolved(await resolve(url, prefs), "card", prefs)
|
||||||
|
|
||||||
get "/t.co/@url":
|
get "/t.co/@url":
|
||||||
let url = "https://t.co/" & @"url"
|
let
|
||||||
respResolved(await resolve(url, cookiePrefs()), "t.co")
|
prefs = cookiePrefs()
|
||||||
|
url = "https://t.co/" & @"url"
|
||||||
|
respResolved(await resolve(url, prefs), "t.co", prefs)
|
||||||
|
|||||||
@ -1,16 +1,29 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, strutils, sequtils, uri, options, sugar
|
import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times
|
||||||
|
|
||||||
import jester, karax/vdom
|
import jester, karax/vdom
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, formatters, api]
|
import ".."/[types, formatters, api, redis_cache]
|
||||||
import ../views/[general, status, search]
|
import ../views/[general, status, mastoapi]
|
||||||
|
|
||||||
export uri, sequtils, options, sugar
|
export json, uri, sequtils, options, sugar, times
|
||||||
export router_utils
|
export router_utils
|
||||||
export api, formatters
|
export api, formatters
|
||||||
export status
|
export status, mastoapi
|
||||||
|
|
||||||
|
proc formatStat*(stat: int): string =
|
||||||
|
#if stat > 1000000000000:
|
||||||
|
# result = formatBiggestFloat(stat / 1000000000000, ffDecimal, precision = 1) & "T"
|
||||||
|
#el
|
||||||
|
if stat > 1000000000:
|
||||||
|
result = formatBiggestFloat(stat / 1000000000, ffDecimal, precision = 1) & "B"
|
||||||
|
elif stat > 1000000:
|
||||||
|
result = formatBiggestFloat(stat / 1000000, ffDecimal, precision = 1) & "M"
|
||||||
|
elif stat > 1000:
|
||||||
|
result = formatBiggestFloat(stat / 1000, ffDecimal, precision = 1) & "K"
|
||||||
|
else:
|
||||||
|
result = $stat
|
||||||
|
|
||||||
proc createStatusRouter*(cfg: Config) =
|
proc createStatusRouter*(cfg: Config) =
|
||||||
router status:
|
router status:
|
||||||
@ -30,16 +43,86 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
resp Http404, ""
|
resp Http404, ""
|
||||||
resp $renderReplies(replies, prefs, getPath())
|
resp $renderReplies(replies, prefs, getPath())
|
||||||
|
|
||||||
if @"reactors" == "favoriters":
|
#if @"reactors" == "favoriters":
|
||||||
resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
|
# resp renderMain(renderUserList(await getGraphFavoriters(id, getCursor()), prefs),
|
||||||
request, cfg, prefs)
|
# request, cfg, prefs)
|
||||||
elif @"reactors" == "retweeters":
|
#elif @"reactors" == "retweeters":
|
||||||
resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
|
# resp renderMain(renderUserList(await getGraphRetweeters(id, getCursor()), prefs),
|
||||||
request, cfg, prefs)
|
# request, cfg, prefs)
|
||||||
|
|
||||||
get "/@name/status/@id/?":
|
get "/@name/status/@id/?@m?/?@i?/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
let id = @"id"
|
var
|
||||||
|
id = @"id"
|
||||||
|
media = @"m"
|
||||||
|
mediaIndex = @"i"
|
||||||
|
|
||||||
|
let url = $request.getNativeReq().url
|
||||||
|
var
|
||||||
|
rawVideo = false
|
||||||
|
rawImage = false
|
||||||
|
if url.endsWith(".mp4") or url.endsWith(".gif"):
|
||||||
|
rawVideo = true
|
||||||
|
elif url.endsWith(".png") or url.endsWith(".jpg"):
|
||||||
|
rawImage = true
|
||||||
|
|
||||||
|
for ext in @[".mp4", ".gif", ".png", ".jpg"]:
|
||||||
|
if id.endsWith(ext):
|
||||||
|
id.removeSuffix(ext)
|
||||||
|
if media.endsWith(ext):
|
||||||
|
media.removeSuffix(ext)
|
||||||
|
if mediaIndex.endsWith(ext):
|
||||||
|
mediaIndex.removeSuffix(ext)
|
||||||
|
|
||||||
|
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||||
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}"""
|
||||||
|
|
||||||
|
let prefs = cookiePrefs()
|
||||||
|
|
||||||
|
let conv = await getCachedTweet(id)
|
||||||
|
if conv == nil:
|
||||||
|
echo "nil conv"
|
||||||
|
|
||||||
|
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
|
||||||
|
var error = "Record not found"
|
||||||
|
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||||
|
error = conv.tweet.tombstone
|
||||||
|
|
||||||
|
var errJson = newJObject()
|
||||||
|
errJson["error"] = %error
|
||||||
|
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, $errJson
|
||||||
|
|
||||||
|
let tweet = conv.tweet
|
||||||
|
|
||||||
|
if media.len > 0:
|
||||||
|
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
|
||||||
|
tweet.photos = @[]
|
||||||
|
elif media == "photo" and tweet.photos.len > 0:
|
||||||
|
if mediaIndex.len > 0:
|
||||||
|
var index = parseInt(mediaIndex)
|
||||||
|
var useVideo = false
|
||||||
|
if index > tweet.photos.len:
|
||||||
|
if tweet.video.isSome or tweet.gif.isSome:
|
||||||
|
useVideo = true
|
||||||
|
else:
|
||||||
|
index = tweet.photos.len
|
||||||
|
elif index < 1:
|
||||||
|
index = 1
|
||||||
|
|
||||||
|
if useVideo:
|
||||||
|
tweet.photos = @[]
|
||||||
|
else:
|
||||||
|
index -= 1
|
||||||
|
tweet.video = none(Video)
|
||||||
|
let image = tweet.photos[index]
|
||||||
|
tweet.photos = @[]
|
||||||
|
tweet.photos.add(image)
|
||||||
|
|
||||||
|
let postJson = getActivityStream(tweet, cfg, prefs)
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $postJson
|
||||||
|
|
||||||
if id.len > 19 or id.any(c => not c.isDigit):
|
if id.len > 19 or id.any(c => not c.isDigit):
|
||||||
resp Http404, showError("Invalid tweet ID", cfg)
|
resp Http404, showError("Invalid tweet ID", cfg)
|
||||||
@ -53,56 +136,151 @@ proc createStatusRouter*(cfg: Config) =
|
|||||||
resp Http404, ""
|
resp Http404, ""
|
||||||
resp $renderReplies(replies, prefs, getPath())
|
resp $renderReplies(replies, prefs, getPath())
|
||||||
|
|
||||||
let conv = await getTweet(id, getCursor())
|
let conv = await getCachedTweet(id, getCursor())
|
||||||
if conv == nil:
|
if conv == nil:
|
||||||
echo "nil conv"
|
echo "nil conv"
|
||||||
|
|
||||||
if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
|
if conv == nil or conv.tweet == nil or conv.tweet.id.len == 0:
|
||||||
var error = "Tweet not found"
|
var error = "Tweet not found"
|
||||||
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
|
||||||
error = conv.tweet.tombstone
|
error = conv.tweet.tombstone
|
||||||
resp Http404, showError(error, cfg)
|
resp Http404, showError(error, cfg)
|
||||||
|
|
||||||
let
|
let
|
||||||
title = pageTitle(conv.tweet)
|
tweet = conv.tweet
|
||||||
ogTitle = pageTitle(conv.tweet.user)
|
title = pageTitle(tweet)
|
||||||
desc = conv.tweet.text
|
ogTitle = pageTitle(tweet.user)
|
||||||
avatar = conv.tweet.user.userPic
|
desc = tweet.text
|
||||||
time = some(conv.tweet.time)
|
avatar = tweet.user.userPic
|
||||||
|
time = some(tweet.time)
|
||||||
|
|
||||||
|
let
|
||||||
|
ua = request.headers.getOrDefault("User-Agent").toString()
|
||||||
|
isChatEmbedder = ua.contains("Discordbot") or ua.contains("TelegramBot")
|
||||||
|
var
|
||||||
|
realMediaIndex = mediaIndex
|
||||||
|
realUseVideo = false
|
||||||
|
if isChatEmbedder and media.len > 0:
|
||||||
|
if media == "video" and tweet.video.isSome or tweet.gif.isSome:
|
||||||
|
tweet.photos = @[]
|
||||||
|
elif media == "photo" and tweet.photos.len > 0:
|
||||||
|
if mediaIndex.len > 0:
|
||||||
|
var index = parseInt(mediaIndex)
|
||||||
|
var useVideo = false
|
||||||
|
if index > tweet.photos.len:
|
||||||
|
if tweet.video.isSome or tweet.gif.isSome:
|
||||||
|
useVideo = true
|
||||||
|
realUseVideo = true
|
||||||
|
else:
|
||||||
|
index = tweet.photos.len
|
||||||
|
elif index < 1:
|
||||||
|
index = 1
|
||||||
|
|
||||||
|
if useVideo:
|
||||||
|
tweet.photos = @[]
|
||||||
|
else:
|
||||||
|
realMediaIndex = $index
|
||||||
|
index -= 1
|
||||||
|
tweet.video = none(Video)
|
||||||
|
let image = tweet.photos[index]
|
||||||
|
tweet.photos = @[]
|
||||||
|
tweet.photos.add(image)
|
||||||
|
|
||||||
var
|
var
|
||||||
images = conv.tweet.photos
|
images = tweet.photos
|
||||||
video = ""
|
video = ""
|
||||||
|
context = ""
|
||||||
|
contextUrl = ""
|
||||||
|
|
||||||
if conv.tweet.video.isSome():
|
if tweet.quote.isSome():
|
||||||
let videoObj = get(conv.tweet.video)
|
let
|
||||||
images = @[videoObj.thumb]
|
quote = get(tweet.quote)
|
||||||
|
quoteUser = quote.user
|
||||||
|
if tweet.replyId.len != 0:
|
||||||
|
let replyUser = await getCachedUser(tweet.replyHandle)
|
||||||
|
context = &"↩ {replyUser.fullname} (@{tweet.replyHandle})\n↘ {quoteUser.fullname} (@{quoteUser.username})"
|
||||||
|
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
|
||||||
|
else:
|
||||||
|
context = &"↘ {quoteUser.fullname} (@{quoteUser.username})"
|
||||||
|
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
|
||||||
|
elif tweet.replyId.len != 0:
|
||||||
|
let replyUser = await getCachedUser(tweet.replyHandle)
|
||||||
|
context = &"↩ {replyUser.fullname} (@{tweet.replyHandle})"
|
||||||
|
contextUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
|
||||||
|
|
||||||
|
if tweet.video.isSome():
|
||||||
|
let videoObj = get(tweet.video)
|
||||||
|
images.add(Image(url:videoObj.thumb))
|
||||||
|
|
||||||
let vars = videoObj.variants.filterIt(it.contentType == mp4)
|
let vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||||
# idk why this wont sort when it sorts everywhere else
|
# idk why this wont sort when it sorts everywhere else
|
||||||
#video = vars.sortedByIt(it.bitrate)[^1].url
|
#video = vars.sortedByIt(it.bitrate)[^1].url
|
||||||
video = vars[^1].url
|
video = vars[^1].url
|
||||||
elif conv.tweet.gif.isSome():
|
elif tweet.gif.isSome():
|
||||||
let gif = get(conv.tweet.gif)
|
let gif = get(tweet.gif)
|
||||||
images = @[gif.thumb]
|
images.add(Image(url:gif.thumb))
|
||||||
video = getPicUrl(gif.url)
|
video = getUrlPrefix(cfg) & getPicUrl(gif.url)
|
||||||
#elif conv.tweet.card.isSome():
|
#elif tweet.card.isSome():
|
||||||
# let card = conv.tweet.card.get()
|
# let card = tweet.card.get()
|
||||||
# if card.image.len > 0:
|
# if card.image.len > 0:
|
||||||
# images = @[card.image]
|
# images = @[card.image]
|
||||||
# elif card.video.isSome():
|
# elif card.video.isSome():
|
||||||
# images = @[card.video.get().thumb]
|
# images = @[card.video.get().thumb]
|
||||||
|
|
||||||
|
if rawVideo and video != "":
|
||||||
|
redirect(video)
|
||||||
|
elif rawImage and images.len > 0:
|
||||||
|
if media == "photo" and mediaIndex.len > 0:
|
||||||
|
var index = parseInt(mediaIndex)
|
||||||
|
var useVideo = false
|
||||||
|
if index > tweet.photos.len:
|
||||||
|
if video != "":
|
||||||
|
useVideo = true
|
||||||
|
else:
|
||||||
|
index = tweet.photos.len
|
||||||
|
elif index < 1:
|
||||||
|
index = 1
|
||||||
|
|
||||||
|
if useVideo:
|
||||||
|
redirect(video)
|
||||||
|
else:
|
||||||
|
index -= 1
|
||||||
|
redirect(getPicUrl(images[index].url))
|
||||||
|
else:
|
||||||
|
redirect(getPicUrl(images[0].url))
|
||||||
|
|
||||||
|
var query = ""
|
||||||
|
if media == "video":
|
||||||
|
query = "video"
|
||||||
|
elif media == "photo" and mediaIndex.len > 0:
|
||||||
|
if realUseVideo and video != "":
|
||||||
|
query = "video"
|
||||||
|
else:
|
||||||
|
query = &"photo:{realMediaIndex}"
|
||||||
|
|
||||||
|
var stats: seq[string] = @[]
|
||||||
|
if tweet.stats.replies > 0:
|
||||||
|
stats.add("↩ " & formatStat(tweet.stats.replies))
|
||||||
|
if tweet.stats.retweets > 0:
|
||||||
|
stats.add("🔁 " & formatStat(tweet.stats.retweets))
|
||||||
|
if tweet.stats.quotes > 0:
|
||||||
|
stats.add("↘ " & formatStat(tweet.stats.quotes))
|
||||||
|
if tweet.stats.likes > 0:
|
||||||
|
stats.add("♥ " & formatStat(tweet.stats.likes))
|
||||||
|
if tweet.stats.bookmarks > 0:
|
||||||
|
stats.add("🔖 " & formatStat(tweet.stats.bookmarks))
|
||||||
|
if tweet.stats.views > 0:
|
||||||
|
stats.add("👁️ " & formatStat(tweet.stats.views))
|
||||||
|
|
||||||
|
let statsStr = stats.join(" ")
|
||||||
|
|
||||||
let html = renderConversation(conv, prefs, getPath() & "#m")
|
let html = renderConversation(conv, prefs, getPath() & "#m")
|
||||||
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
|
||||||
images=images, video=video, avatar=avatar, time=time)
|
images=images, video=video, avatar=avatar, time=time,
|
||||||
|
context=context, contextUrl=contextUrl, id=id,
|
||||||
|
media=query, stats=statsStr)
|
||||||
|
|
||||||
get "/@name/@s/@id/@m/?@i?":
|
get "/@name/statuses/@id/?@m?/?@i?":
|
||||||
cond @"s" in ["status", "statuses"]
|
|
||||||
cond @"m" in ["video", "photo"]
|
|
||||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
|
||||||
|
|
||||||
get "/@name/statuses/@id/?":
|
|
||||||
redirect("/$1/status/$2" % [@"name", @"id"])
|
redirect("/$1/status/$2" % [@"name", @"id"])
|
||||||
|
|
||||||
get "/i/web/status/@id":
|
get "/i/web/status/@id":
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import asyncdispatch, strutils, sequtils, uri, options, times
|
import asyncdispatch, strutils, sequtils, uri, options, times, json
|
||||||
import jester, karax/vdom
|
import jester, karax/vdom
|
||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, redis_cache, formatters, query, api]
|
import ".."/[types, formatters, query, api, redis_cache]
|
||||||
import ../views/[general, profile, timeline, status, search]
|
import ../views/[general, profile, timeline, status, search, mastoapi]
|
||||||
|
|
||||||
export vdom
|
export vdom
|
||||||
export uri, sequtils
|
export uri, sequtils, json
|
||||||
export router_utils
|
export router_utils
|
||||||
export redis_cache, formatters, query, api
|
export formatters, query, api, redis_cache
|
||||||
export profile, timeline, status
|
export profile, timeline, status, mastoapi
|
||||||
|
|
||||||
proc getQuery*(request: Request; tab, name: string): Query =
|
proc getQuery*(request: Request; tab, name: string): Query =
|
||||||
case tab
|
case tab
|
||||||
@ -32,7 +32,7 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
|
|||||||
skipPinned=false): Future[Profile] {.async.} =
|
skipPinned=false): Future[Profile] {.async.} =
|
||||||
let
|
let
|
||||||
name = query.fromUser[0]
|
name = query.fromUser[0]
|
||||||
userId = await getUserId(name)
|
userId = await getCachedUserId(name)
|
||||||
|
|
||||||
if userId.len == 0:
|
if userId.len == 0:
|
||||||
return Profile(user: User(username: name))
|
return Profile(user: User(username: name))
|
||||||
@ -48,7 +48,7 @@ proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
|
|||||||
let
|
let
|
||||||
rail =
|
rail =
|
||||||
skipIf(skipRail or query.kind == media, @[]):
|
skipIf(skipRail or query.kind == media, @[]):
|
||||||
getCachedPhotoRail(name)
|
getCachedPhotoRail(userId)
|
||||||
|
|
||||||
user = getCachedUser(name)
|
user = getCachedUser(name)
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
|||||||
|
|
||||||
let pHtml = renderProfile(profile, cfg, prefs, getPath())
|
let pHtml = renderProfile(profile, cfg, prefs, getPath())
|
||||||
result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
|
result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
|
||||||
rss=rss, images = @[u.getUserPic("_400x400")],
|
rss=rss, images = @[Image(url: u.getUserPic("_400x400"))],
|
||||||
banner=u.banner)
|
banner=u.banner)
|
||||||
|
|
||||||
template respTimeline*(timeline: typed) =
|
template respTimeline*(timeline: typed) =
|
||||||
@ -111,6 +111,7 @@ proc createTimelineRouter*(cfg: Config) =
|
|||||||
get "/@name/?@tab?/?":
|
get "/@name/?@tab?/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||||
|
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_', ','})
|
||||||
cond @"tab" in ["with_replies", "media", "search", "following", "followers", ""]
|
cond @"tab" in ["with_replies", "media", "search", "following", "followers", ""]
|
||||||
let
|
let
|
||||||
prefs = cookiePrefs()
|
prefs = cookiePrefs()
|
||||||
@ -120,10 +121,22 @@ proc createTimelineRouter*(cfg: Config) =
|
|||||||
|
|
||||||
case tab:
|
case tab:
|
||||||
of "followers":
|
of "followers":
|
||||||
resp renderMain(renderUserList(await getGraphFollowers(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
resp renderMain(renderUserList(await getGraphFollowers(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
||||||
of "following":
|
of "following":
|
||||||
resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
resp renderMain(renderUserList(await getGraphFollowing(await getCachedUserId(@"name"), getCursor()), prefs), request, cfg, prefs)
|
||||||
else:
|
else:
|
||||||
|
if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json":
|
||||||
|
let userId = await getCachedUserId(@"name")
|
||||||
|
|
||||||
|
if userId == "suspended" or userId.len == 0:
|
||||||
|
resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}"""
|
||||||
|
|
||||||
|
let user = await getCachedUser(@"name")
|
||||||
|
|
||||||
|
let userJson = getActivityStream(user, cfg, prefs)
|
||||||
|
|
||||||
|
resp Http200, {"Content-Type": "application/json"}, $userJson
|
||||||
|
|
||||||
var query = request.getQuery(@"tab", @"name")
|
var query = request.getQuery(@"tab", @"name")
|
||||||
if names.len != 1:
|
if names.len != 1:
|
||||||
query.fromUser = names
|
query.fromUser = names
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import json, asyncdispatch, options, uri
|
|||||||
import times
|
import times
|
||||||
import jester
|
import jester
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, api, apiutils, query, consts]
|
import ".."/[types, api, apiutils, redis_cache]
|
||||||
import httpclient, strutils
|
import httpclient, strutils
|
||||||
import sequtils
|
import sequtils
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ proc tweetToJson*(t: Tweet): JsonNode =
|
|||||||
result["photos"] = %t.photos
|
result["photos"] = %t.photos
|
||||||
|
|
||||||
proc getUserProfileJson*(username: string): Future[JsonNode] {.async.} =
|
proc getUserProfileJson*(username: string): Future[JsonNode] {.async.} =
|
||||||
let user: User = await getGraphUser(username)
|
let user: User = await getCachedUser(username)
|
||||||
let response: JsonNode = %*{
|
let response: JsonNode = %*{
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"username": user.username
|
"username": user.username
|
||||||
@ -81,52 +81,49 @@ proc getUserTweetsJson*(id: string): Future[JsonNode] {.async.} =
|
|||||||
|
|
||||||
result = response
|
result = response
|
||||||
|
|
||||||
proc searchTimeline*(query: Query; after=""): Future[string] {.async.} =
|
|
||||||
let q = genQueryParam(query)
|
|
||||||
var
|
|
||||||
variables = %*{
|
|
||||||
"rawQuery": q,
|
|
||||||
"count": 20,
|
|
||||||
"product": "Latest",
|
|
||||||
"withDownvotePerspective": false,
|
|
||||||
"withReactionsMetadata": false,
|
|
||||||
"withReactionsPerspective": false
|
|
||||||
}
|
|
||||||
if after.len > 0:
|
|
||||||
variables["cursor"] = % after
|
|
||||||
let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures}
|
|
||||||
result = await fetchRaw(url, Api.search)
|
|
||||||
|
|
||||||
proc getUserTweets*(id: string; after=""): Future[string] {.async.} =
|
proc getUserTweets*(id: string; after=""): Future[string] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
let headers = newHttpHeaders()
|
||||||
variables = userTweetsVariables % [id, cursor]
|
headers.add("Referer", """https://x.com/$1""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
result = await fetchRaw(graphUserTweets ? params, Api.userTweets)
|
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
|
result = await fetchRaw(userTweetsUrl(id, cursor), headers)
|
||||||
|
|
||||||
proc getUserReplies*(id: string; after=""): Future[string] {.async.} =
|
proc getUserReplies*(id: string; after=""): Future[string] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
let headers = newHttpHeaders()
|
||||||
variables = userTweetsVariables % [id, cursor]
|
headers.add("Referer", """https://x.com/$1/with_replies""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
result = await fetchRaw(graphUserTweets ? params, Api.userTweetsAndReplies)
|
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
|
result = await fetchRaw(userTweetsAndRepliesUrl(id, cursor), headers)
|
||||||
|
|
||||||
proc getUserMedia*(id: string; after=""): Future[string] {.async.} =
|
proc getUserMedia*(id: string; after=""): Future[string] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
let headers = newHttpHeaders()
|
||||||
variables = userMediaVariables % [id, cursor]
|
headers.add("Referer", """https://x.com/$1/media""" % id)
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
|
||||||
result = await fetchRaw(graphUserMedia ? params, Api.userMedia)
|
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
|
result = await fetchRaw(mediaUrl(id, cursor), headers)
|
||||||
|
|
||||||
proc getTweetById*(id: string; after=""): Future[string] {.async.} =
|
proc getTweetById*(id: string; after=""): Future[string] {.async.} =
|
||||||
let
|
if id.len == 0: return
|
||||||
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
|
||||||
variables = tweetVariables % [id, cursor]
|
let headers = newHttpHeaders()
|
||||||
params = {"variables": variables, "features": gqlFeatures}
|
headers.add("Referer", """https://x.com/i/status/$1""" % id)
|
||||||
result = await fetchRaw(graphTweet ? params, Api.tweetDetail)
|
|
||||||
|
let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
|
||||||
|
result = await fetchRaw(tweetDetailUrl(id, cursor), headers)
|
||||||
|
|
||||||
|
proc getUser*(username: string): Future[string] {.async.} =
|
||||||
|
if username.len == 0: return
|
||||||
|
|
||||||
|
let headers = newHttpHeaders()
|
||||||
|
headers.add("Referer", """https://x.com/$1""" % username)
|
||||||
|
|
||||||
|
result = await fetchRaw(userUrl(username), headers)
|
||||||
|
|
||||||
proc createTwitterApiRouter*(cfg: Config) =
|
proc createTwitterApiRouter*(cfg: Config) =
|
||||||
router api:
|
router api:
|
||||||
@ -135,20 +132,15 @@ proc createTwitterApiRouter*(cfg: Config) =
|
|||||||
|
|
||||||
get "/api/user/@username":
|
get "/api/user/@username":
|
||||||
let username = @"username"
|
let username = @"username"
|
||||||
let response = await getUserProfileJson(username)
|
let response = await getUser(username)
|
||||||
respJson response
|
|
||||||
|
|
||||||
# get "/api/user/@id/tweets":
|
|
||||||
# let id = @"id"
|
|
||||||
# let response = await getUserTweetsJson(id)
|
|
||||||
# respJson response
|
|
||||||
|
|
||||||
get "/api/user/@username/timeline":
|
|
||||||
let username = @"username"
|
|
||||||
let query = Query(fromUser: @[username])
|
|
||||||
let response = await searchTimeline(query)
|
|
||||||
resp Http200, { "Content-Type": "application/json" }, response
|
resp Http200, { "Content-Type": "application/json" }, response
|
||||||
|
|
||||||
|
#get "/api/user/@username/timeline":
|
||||||
|
# let username = @"username"
|
||||||
|
# let query = Query(fromUser: @[username])
|
||||||
|
# let response = await searchTimeline(query)
|
||||||
|
# resp Http200, { "Content-Type": "application/json" }, response
|
||||||
|
|
||||||
get "/api/user/@id/tweets":
|
get "/api/user/@id/tweets":
|
||||||
let id = @"id"
|
let id = @"id"
|
||||||
let after = getCursor()
|
let after = getCursor()
|
||||||
|
|||||||
@ -1,39 +1,50 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.panel-container {
|
.panel-container {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
font-size: 130%;
|
font-size: 130%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-panel {
|
.error-panel {
|
||||||
@include center-panel(var(--error_red));
|
@include center-panel(var(--error_red));
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar > form {
|
.search-bar > form {
|
||||||
@include center-panel(var(--darkest_grey));
|
@include center-panel(var(--darkest_grey));
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: var(--bg_elements);
|
background: var(--bg_elements);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
padding: 0px 5px 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--bg_elements);
|
background: var(--bg_elements);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
height: unset;
|
height: unset;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-badge {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.brand-badge-image {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid var(--accent_border);
|
||||||
|
margin-bottom: -4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,180 +1,202 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
@import 'tweet/_base';
|
@import "tweet/_base";
|
||||||
@import 'profile/_base';
|
@import "profile/_base";
|
||||||
@import 'general';
|
@import "general";
|
||||||
@import 'navbar';
|
@import "navbar";
|
||||||
@import 'inputs';
|
@import "inputs";
|
||||||
@import 'timeline';
|
@import "timeline";
|
||||||
@import 'search';
|
@import "search";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
// colors
|
// colors
|
||||||
--bg_color: #{$bg_color};
|
--bg_color: #{$bg_color};
|
||||||
--fg_color: #{$fg_color};
|
--fg_color: #{$fg_color};
|
||||||
--fg_faded: #{$fg_faded};
|
--fg_faded: #{$fg_faded};
|
||||||
--fg_dark: #{$fg_dark};
|
--fg_dark: #{$fg_dark};
|
||||||
--fg_nav: #{$fg_nav};
|
--fg_nav: #{$fg_nav};
|
||||||
|
|
||||||
--bg_panel: #{$bg_panel};
|
--bg_panel: #{$bg_panel};
|
||||||
--bg_elements: #{$bg_elements};
|
--bg_elements: #{$bg_elements};
|
||||||
--bg_overlays: #{$bg_overlays};
|
--bg_overlays: #{$bg_overlays};
|
||||||
--bg_hover: #{$bg_hover};
|
--bg_hover: #{$bg_hover};
|
||||||
|
|
||||||
--grey: #{$grey};
|
--grey: #{$grey};
|
||||||
--dark_grey: #{$dark_grey};
|
--dark_grey: #{$dark_grey};
|
||||||
--darker_grey: #{$darker_grey};
|
--darker_grey: #{$darker_grey};
|
||||||
--darkest_grey: #{$darkest_grey};
|
--darkest_grey: #{$darkest_grey};
|
||||||
--border_grey: #{$border_grey};
|
--border_grey: #{$border_grey};
|
||||||
|
|
||||||
--accent: #{$accent};
|
--accent: #{$accent};
|
||||||
--accent_light: #{$accent_light};
|
--accent_light: #{$accent_light};
|
||||||
--accent_dark: #{$accent_dark};
|
--accent_dark: #{$accent_dark};
|
||||||
--accent_border: #{$accent_border};
|
--accent_border: #{$accent_border};
|
||||||
|
|
||||||
--play_button: #{$play_button};
|
--play_button: #{$play_button};
|
||||||
--play_button_hover: #{$play_button_hover};
|
--play_button_hover: #{$play_button_hover};
|
||||||
|
|
||||||
--more_replies_dots: #{$more_replies_dots};
|
--more_replies_dots: #{$more_replies_dots};
|
||||||
--error_red: #{$error_red};
|
--error_red: #{$error_red};
|
||||||
|
|
||||||
--verified_blue: #{$verified_blue};
|
--verified_blue: #{$verified_blue};
|
||||||
--verified_business: #{$verified_business};
|
--verified_business: #{$verified_business};
|
||||||
--verified_government: #{$verified_government};
|
--verified_government: #{$verified_government};
|
||||||
--icon_text: #{$icon_text};
|
--icon_text: #{$icon_text};
|
||||||
|
|
||||||
--tab: #{$fg_color};
|
--tab: #{$fg_color};
|
||||||
--tab_selected: #{$accent};
|
--tab_selected: #{$accent};
|
||||||
|
|
||||||
--profile_stat: #{$fg_color};
|
--profile_stat: #{$fg_color};
|
||||||
|
|
||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
font-family: $font_0, $font_1, $font_2, $font_3;
|
font-family: $font_0, $font_1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
outline: unset;
|
outline: unset;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2, h3 {
|
h2,
|
||||||
font-weight: normal;
|
h3 {
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 14px 0;
|
margin: 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-top: -0.6em;
|
margin-top: -0.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
legend {
|
legend {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: .6em 0 .3em 0;
|
padding: 0.6em 0 0.3em 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 1px solid var(--border_grey);
|
border-bottom: 1px solid var(--border_grey);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preferences .note {
|
.preferences .note {
|
||||||
border-top: 1px solid var(--border_grey);
|
border-top: 1px solid var(--border_grey);
|
||||||
border-bottom: 1px solid var(--border_grey);
|
border-bottom: 1px solid var(--border_grey);
|
||||||
padding: 6px 0 8px 0;
|
padding: 6px 0 8px 0;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
padding-left: 1.3em;
|
padding-left: 1.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-top: 50px;
|
padding-top: 50px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-container {
|
.icon-container {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-panel {
|
.overlay-panel {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
background-color: var(--bg_overlays);
|
background-color: var(--bg_overlays);
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.verified-icon {
|
.verified-icon {
|
||||||
color: var(--icon_text);
|
display: inline-block;
|
||||||
border-radius: 50%;
|
width: 14px;
|
||||||
flex-shrink: 0;
|
height: 14px;
|
||||||
margin: 2px 0 3px 3px;
|
margin-left: 2px;
|
||||||
padding-top: 3px;
|
|
||||||
height: 11px;
|
|
||||||
width: 14px;
|
|
||||||
font-size: 8px;
|
|
||||||
display: inline-block;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
|
|
||||||
&.blue {
|
.verified-icon-circle {
|
||||||
background-color: var(--verified_blue);
|
position: absolute;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-icon-check {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 9px;
|
||||||
|
margin: 5px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.blue {
|
||||||
|
.verified-icon-circle {
|
||||||
|
color: var(--verified_blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.business {
|
.verified-icon-check {
|
||||||
color: var(--bg_panel);
|
color: var(--icon_text);
|
||||||
background-color: var(--verified_business);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.business {
|
||||||
|
.verified-icon-circle {
|
||||||
|
color: var(--verified_business);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.government {
|
.verified-icon-check {
|
||||||
color: var(--bg_panel);
|
color: var(--bg_panel);
|
||||||
background-color: var(--verified_government);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.government {
|
||||||
|
.verified-icon-circle {
|
||||||
|
color: var(--verified_government);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-icon-check {
|
||||||
|
color: var(--bg_panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.preferences-container {
|
.preferences-container {
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item, .nav-item .icon-container {
|
.nav-item,
|
||||||
font-size: 16px;
|
.nav-item .icon-container {
|
||||||
}
|
font-size: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,89 +1,87 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--bg_overlays);
|
background-color: var(--bg_overlays);
|
||||||
box-shadow: 0 0 4px $shadow;
|
box-shadow: 0 0 4px $shadow;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
||||||
a, .icon-button button {
|
a,
|
||||||
color: var(--fg_nav);
|
.icon-button button {
|
||||||
}
|
color: var(--fg_nav);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner-nav {
|
.inner-nav {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-basis: 920px;
|
flex-basis: 920px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-name {
|
.site-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--accent_light);
|
color: var(--accent_light);
|
||||||
text-decoration: unset;
|
text-decoration: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-logo {
|
.site-logo {
|
||||||
display: block;
|
display: block;
|
||||||
width: 35px;
|
width: 35px;
|
||||||
height: 35px;
|
height: 35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&.right {
|
&.right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.right a {
|
&.right a:hover {
|
||||||
padding-left: 4px;
|
color: var(--accent_light);
|
||||||
|
text-decoration: unset;
|
||||||
&:hover {
|
}
|
||||||
color: var(--accent_light);
|
|
||||||
text-decoration: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lp {
|
.lp {
|
||||||
height: 14px;
|
height: 14px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
fill: var(--fg_nav);
|
fill: var(--fg_nav);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
fill: var(--accent_light);
|
fill: var(--accent_light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-info:before {
|
.icon-info {
|
||||||
margin: 0 -3px;
|
margin: 0 -3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-cog {
|
.icon-cog {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
padding-left: 0 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,7 +82,9 @@
|
|||||||
|
|
||||||
.profile-joindate,
|
.profile-joindate,
|
||||||
.profile-location,
|
.profile-location,
|
||||||
.profile-website {
|
.profile-website,
|
||||||
|
.profile-automated,
|
||||||
|
.profile-pcf {
|
||||||
color: var(--fg_faded);
|
color: var(--fg_faded);
|
||||||
margin: 1px 0;
|
margin: 1px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -1,120 +1,121 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.search-title {
|
.search-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-field {
|
.search-field {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0 2px 0 0;
|
||||||
|
padding: 0px 1px 1px 4px;
|
||||||
|
height: 23px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
.pref-input {
|
||||||
margin: 0 2px 0 0;
|
margin: 0 4px 0 0;
|
||||||
height: 23px;
|
flex-grow: 1;
|
||||||
display: flex;
|
height: 23px;
|
||||||
align-items: center;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.pref-input {
|
input[type="text"] {
|
||||||
margin: 0 4px 0 0;
|
height: calc(100% - 4px);
|
||||||
flex-grow: 1;
|
width: calc(100% - 8px);
|
||||||
height: 23px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"] {
|
> label {
|
||||||
height: calc(100% - 4px);
|
display: inline;
|
||||||
width: calc(100% - 8px);
|
background-color: var(--bg_elements);
|
||||||
}
|
color: var(--fg_color);
|
||||||
|
border: 1px solid var(--accent_border);
|
||||||
|
padding: 1px 1px 2px 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
> label {
|
@include input-colors;
|
||||||
display: inline;
|
}
|
||||||
background-color: var(--bg_elements);
|
|
||||||
color: var(--fg_color);
|
|
||||||
border: 1px solid var(--accent_border);
|
|
||||||
padding: 1px 6px 2px 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
|
|
||||||
@include input-colors;
|
@include create-toggle(search-panel, 200px);
|
||||||
}
|
|
||||||
|
|
||||||
@include create-toggle(search-panel, 200px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-panel {
|
.search-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.4s;
|
transition: max-height 0.4s;
|
||||||
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-weight: initial;
|
font-weight: initial;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
line-height: 1.7em;
|
line-height: 1.7em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container {
|
.checkbox-container {
|
||||||
display: inline;
|
display: inline;
|
||||||
padding-right: unset;
|
padding-right: unset;
|
||||||
margin-bottom: unset;
|
margin-bottom: 5px;
|
||||||
margin-left: 23px;
|
margin-left: 23px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
right: unset;
|
right: unset;
|
||||||
left: -22px;
|
left: -22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-container .checkbox:after {
|
.checkbox-container .checkbox:after {
|
||||||
top: -4px;
|
top: -4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
line-height: unset;
|
line-height: unset;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-input {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
height: 21px;
|
height: 21px;
|
||||||
}
|
margin-top: 1px;
|
||||||
|
|
||||||
.pref-input {
|
|
||||||
display: block;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
|
|
||||||
input {
|
|
||||||
height: 21px;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-toggles {
|
.search-toggles {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, auto);
|
grid-template-columns: repeat(6, auto);
|
||||||
grid-column-gap: 10px;
|
grid-column-gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
@include search-resize(820px, 5);
|
@include search-resize(820px, 5);
|
||||||
@include search-resize(725px, 4);
|
@include search-resize(725px, 4);
|
||||||
@include search-resize(600px, 6);
|
@include search-resize(600px, 6);
|
||||||
@include search-resize(560px, 5);
|
@include search-resize(560px, 5);
|
||||||
@include search-resize(480px, 4);
|
@include search-resize(480px, 4);
|
||||||
@include search-resize(410px, 3);
|
@include search-resize(410px, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include search-resize(560px, 5);
|
@include search-resize(560px, 5);
|
||||||
|
|||||||
@ -1,242 +1,260 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
@import 'thread';
|
@import "thread";
|
||||||
@import 'media';
|
@import "media";
|
||||||
@import 'video';
|
@import "video";
|
||||||
@import 'embed';
|
@import "embed";
|
||||||
@import 'card';
|
@import "card";
|
||||||
@import 'poll';
|
@import "poll";
|
||||||
@import 'quote';
|
@import "quote";
|
||||||
@import 'community_note';
|
@import "community_note";
|
||||||
|
@import "limited_actions";
|
||||||
|
|
||||||
.tweet-body {
|
.tweet-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: 58px;
|
margin-left: 58px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-content {
|
.tweet-content {
|
||||||
font-family: $font_3;
|
font-family: $font_3;
|
||||||
line-height: 1.3em;
|
line-height: 1.3em;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-bidi {
|
.tweet-bidi {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-header {
|
.tweet-header {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
margin-bottom: .2em;
|
margin-bottom: 0.2em;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-name-row {
|
.tweet-name-row {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.tweet-label-row {
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullname-and-username {
|
.fullname-and-username {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullname {
|
.fullname {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
flex-shrink: 2;
|
flex-shrink: 2;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
min-width: 1.6em;
|
min-width: 1.6em;
|
||||||
margin-left: .4em;
|
margin-left: 0.4em;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-automated,
|
||||||
|
.user-pcf {
|
||||||
|
@include ellipsis;
|
||||||
|
min-width: 1px;
|
||||||
|
color: var(--fg_faded);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-date {
|
.tweet-date {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-date a, .username, .show-more a {
|
.tweet-date a,
|
||||||
color: var(--fg_dark);
|
.username,
|
||||||
|
.show-more a {
|
||||||
|
color: var(--fg_dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-published {
|
.tweet-published {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
color: var(--grey);
|
color: var(--grey);
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-avatar {
|
.tweet-avatar {
|
||||||
display: contents !important;
|
display: contents !important;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
float: left;
|
float: left;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
margin-left: -58px;
|
margin-left: -58px;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
&.round {
|
&.round {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mini {
|
&.mini {
|
||||||
position: unset;
|
position: unset;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-embed {
|
.tweet-embed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--bg_panel);
|
||||||
|
|
||||||
|
.tweet-content {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
max-height: calc(100vh - 0.75em * 2);
|
||||||
height: 100%;
|
}
|
||||||
background-color: var(--bg_panel);
|
|
||||||
|
|
||||||
.tweet-content {
|
.card-image img {
|
||||||
font-size: 18px;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-body {
|
.avatar {
|
||||||
display: flex;
|
position: absolute;
|
||||||
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, .pinned, .tweet-stats {
|
.retweet-header,
|
||||||
align-content: center;
|
.pinned,
|
||||||
color: var(--grey);
|
.tweet-stats {
|
||||||
display: flex;
|
align-content: center;
|
||||||
flex-shrink: 0;
|
color: var(--grey);
|
||||||
flex-wrap: wrap;
|
display: flex;
|
||||||
font-size: 14px;
|
flex-shrink: 0;
|
||||||
font-weight: 600;
|
flex-wrap: wrap;
|
||||||
line-height: 22px;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
@include ellipsis;
|
@include ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.retweet-header {
|
.retweet-header {
|
||||||
margin-top: -5px !important;
|
margin-top: -5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-stats {
|
.tweet-stats {
|
||||||
margin-bottom: -3px;
|
margin-bottom: -3px;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-stat {
|
.tweet-stat {
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
margin-right: 0.8em;
|
margin-right: 0.8em;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-thread {
|
.show-thread {
|
||||||
display: block;
|
display: block;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unavailable-box {
|
.unavailable-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: solid 1px var(--dark_grey);
|
border: solid 1px var(--dark_grey);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: var(--bg_color);
|
background-color: var(--bg_color);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-link {
|
.tweet-link {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--bg_hover);
|
background-color: var(--bg_hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/sass/tweet/limited_actions.scss
Normal file
32
src/sass/tweet/limited_actions.scss
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
@import "_variables";
|
||||||
|
|
||||||
|
.limited-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
border: solid 1px var(--dark_grey);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: var(--bg_overlays);
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: all;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.limited-actions-title {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 6px 8px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limited-actions-text {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
padding: 8px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,6 +48,19 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
max-height: 530px;
|
max-height: 530px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alt {
|
||||||
|
position: relative;
|
||||||
|
bottom: 15px;
|
||||||
|
left: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #101010;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-gif video {
|
.gallery-gif video {
|
||||||
|
|||||||
@ -1,66 +1,64 @@
|
|||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
video {
|
video {
|
||||||
max-height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-video {
|
.gallery-video {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-video.card-container {
|
.gallery-video.card-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
max-height: 530px;
|
max-height: 530px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-overlay {
|
.video-overlay {
|
||||||
@include play-button;
|
@include play-button;
|
||||||
background-color: $shadow;
|
background-color: $shadow;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
top: calc(50% - 20px);
|
top: calc(50% - 20px);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
div {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
top: calc(50% - 20px);
|
top: calc(50% - 20px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/tid.nim
Normal file
62
src/tid.nim
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import std/[asyncdispatch, base64, httpclient, random, strutils, sequtils, times]
|
||||||
|
import nimcrypto
|
||||||
|
import experimental/parser/tid
|
||||||
|
|
||||||
|
randomize()
|
||||||
|
|
||||||
|
const defaultKeyword = "obfiowerehiring";
|
||||||
|
const pairsUrl =
|
||||||
|
"https://raw.githubusercontent.com/fa0311/x-client-transaction-id-pair-dict/refs/heads/main/pair.json";
|
||||||
|
|
||||||
|
var
|
||||||
|
cachedPairs: seq[TidPair] = @[]
|
||||||
|
lastCached = 0
|
||||||
|
# refresh every hour
|
||||||
|
ttlSec = 60 * 60
|
||||||
|
|
||||||
|
proc getPair(): Future[TidPair] {.async.} =
|
||||||
|
if cachedPairs.len == 0 or int(epochTime()) - lastCached > ttlSec:
|
||||||
|
lastCached = int(epochTime())
|
||||||
|
|
||||||
|
let client = newAsyncHttpClient()
|
||||||
|
defer: client.close()
|
||||||
|
|
||||||
|
let resp = await client.get(pairsUrl)
|
||||||
|
if resp.status == $Http200:
|
||||||
|
cachedPairs = parseTidPairs(await resp.body)
|
||||||
|
|
||||||
|
return sample(cachedPairs)
|
||||||
|
|
||||||
|
proc encodeSha256(text: string): array[32, byte] =
|
||||||
|
let
|
||||||
|
data = cast[ptr byte](addr text[0])
|
||||||
|
dataLen = uint(len(text))
|
||||||
|
digest = sha256.digest(data, dataLen)
|
||||||
|
return digest.data
|
||||||
|
|
||||||
|
proc encodeBase64[T](data: T): string =
|
||||||
|
return encode(data).replace("=", "")
|
||||||
|
|
||||||
|
proc decodeBase64(data: string): seq[byte] =
|
||||||
|
return cast[seq[byte]](decode(data))
|
||||||
|
|
||||||
|
proc genTid*(path: string): Future[string] {.async.} =
|
||||||
|
let
|
||||||
|
pair = await getPair()
|
||||||
|
|
||||||
|
timeNow = int(epochTime() - 1682924400)
|
||||||
|
timeNowBytes = @[
|
||||||
|
byte(timeNow and 0xff),
|
||||||
|
byte((timeNow shr 8) and 0xff),
|
||||||
|
byte((timeNow shr 16) and 0xff),
|
||||||
|
byte((timeNow shr 24) and 0xff)
|
||||||
|
]
|
||||||
|
|
||||||
|
data = "GET!" & path & "!" & $timeNow & defaultKeyword & pair.animationKey
|
||||||
|
hashBytes = encodeSha256(data)
|
||||||
|
keyBytes = decodeBase64(pair.verification)
|
||||||
|
bytesArr = keyBytes & timeNowBytes & hashBytes[0 ..< 16] & @[3'u8]
|
||||||
|
randomNum = byte(rand(256))
|
||||||
|
tid = @[randomNum] & bytesArr.mapIt(it xor randomNum)
|
||||||
|
|
||||||
|
return encodeBase64(tid)
|
||||||
168
src/tokens.nim
168
src/tokens.nim
@ -1,168 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
import asyncdispatch, httpclient, times, sequtils, json, random
|
|
||||||
import strutils, tables
|
|
||||||
import types, consts
|
|
||||||
|
|
||||||
const
|
|
||||||
maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions
|
|
||||||
maxLastUse = 1.hours # if a token is unused for 60 minutes, it expires
|
|
||||||
maxAge = 2.hours + 55.minutes # tokens expire after 3 hours
|
|
||||||
failDelay = initDuration(minutes=30)
|
|
||||||
|
|
||||||
var
|
|
||||||
tokenPool: seq[Token]
|
|
||||||
lastFailed: Time
|
|
||||||
enableLogging = false
|
|
||||||
|
|
||||||
let headers = newHttpHeaders({"authorization": auth})
|
|
||||||
|
|
||||||
template log(str) =
|
|
||||||
if enableLogging: echo "[tokens] ", str
|
|
||||||
|
|
||||||
proc getPoolJson*(): JsonNode =
|
|
||||||
var
|
|
||||||
list = newJObject()
|
|
||||||
totalReqs = 0
|
|
||||||
totalPending = 0
|
|
||||||
reqsPerApi: Table[string, int]
|
|
||||||
|
|
||||||
for token in tokenPool:
|
|
||||||
totalPending.inc(token.pending)
|
|
||||||
list[token.tok] = %*{
|
|
||||||
"apis": newJObject(),
|
|
||||||
"pending": token.pending,
|
|
||||||
"init": $token.init,
|
|
||||||
"lastUse": $token.lastUse
|
|
||||||
}
|
|
||||||
|
|
||||||
for api in token.apis.keys:
|
|
||||||
list[token.tok]["apis"][$api] = %token.apis[api]
|
|
||||||
|
|
||||||
let
|
|
||||||
maxReqs =
|
|
||||||
case api
|
|
||||||
of Api.photoRail: 180
|
|
||||||
#of Api.timeline: 187
|
|
||||||
#of Api.userTweets, Api.userTimeline: 300
|
|
||||||
of Api.userTweets: 300
|
|
||||||
of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets,
|
|
||||||
Api.userTweetsAndReplies, Api.userMedia,
|
|
||||||
Api.userRestId, Api.userScreenName, Api.tweetDetail,
|
|
||||||
Api.tweetResult, Api.search, Api.favorites,
|
|
||||||
Api.retweeters, Api.favoriters, Api.following, Api.followers: 500
|
|
||||||
#of Api.userSearch: 900
|
|
||||||
reqs = maxReqs - token.apis[api].remaining
|
|
||||||
|
|
||||||
reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs
|
|
||||||
totalReqs.inc(reqs)
|
|
||||||
|
|
||||||
return %*{
|
|
||||||
"amount": tokenPool.len,
|
|
||||||
"requests": totalReqs,
|
|
||||||
"pending": totalPending,
|
|
||||||
"apis": reqsPerApi,
|
|
||||||
"tokens": list
|
|
||||||
}
|
|
||||||
|
|
||||||
proc rateLimitError*(): ref RateLimitError =
|
|
||||||
newException(RateLimitError, "rate limited")
|
|
||||||
|
|
||||||
proc fetchToken(): Future[Token] {.async.} =
|
|
||||||
if getTime() - lastFailed < failDelay:
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|
||||||
let client = newAsyncHttpClient(headers=headers)
|
|
||||||
|
|
||||||
try:
|
|
||||||
let
|
|
||||||
resp = await client.postContent(activate)
|
|
||||||
tokNode = parseJson(resp)["guest_token"]
|
|
||||||
tok = tokNode.getStr($(tokNode.getInt))
|
|
||||||
time = getTime()
|
|
||||||
|
|
||||||
return Token(tok: tok, init: time, lastUse: time)
|
|
||||||
except Exception as e:
|
|
||||||
echo "[tokens] fetching token failed: ", e.msg
|
|
||||||
if "Try again" notin e.msg:
|
|
||||||
echo "[tokens] fetching tokens paused, resuming in 30 minutes"
|
|
||||||
lastFailed = getTime()
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
proc expired(token: Token): bool =
|
|
||||||
let time = getTime()
|
|
||||||
token.init < time - maxAge or token.lastUse < time - maxLastUse
|
|
||||||
|
|
||||||
proc isLimited(token: Token; api: Api): bool =
|
|
||||||
if token.isNil or token.expired:
|
|
||||||
return true
|
|
||||||
|
|
||||||
if api in token.apis:
|
|
||||||
let limit = token.apis[api]
|
|
||||||
return (limit.remaining <= 10 and limit.reset > epochTime().int)
|
|
||||||
else:
|
|
||||||
return false
|
|
||||||
|
|
||||||
proc isReady(token: Token; api: Api): bool =
|
|
||||||
not (token.isNil or token.pending > maxConcurrentReqs or token.isLimited(api))
|
|
||||||
|
|
||||||
proc release*(token: Token; used=false; invalid=false) =
|
|
||||||
if token.isNil: return
|
|
||||||
if invalid or token.expired:
|
|
||||||
if invalid: log "discarding invalid token"
|
|
||||||
elif token.expired: log "discarding expired token"
|
|
||||||
|
|
||||||
let idx = tokenPool.find(token)
|
|
||||||
if idx > -1: tokenPool.delete(idx)
|
|
||||||
elif used:
|
|
||||||
dec token.pending
|
|
||||||
token.lastUse = getTime()
|
|
||||||
|
|
||||||
proc getToken*(api: Api): Future[Token] {.async.} =
|
|
||||||
for i in 0 ..< tokenPool.len:
|
|
||||||
if result.isReady(api): break
|
|
||||||
release(result)
|
|
||||||
result = tokenPool.sample()
|
|
||||||
|
|
||||||
if not result.isReady(api):
|
|
||||||
release(result)
|
|
||||||
result = await fetchToken()
|
|
||||||
log "added new token to pool"
|
|
||||||
tokenPool.add result
|
|
||||||
|
|
||||||
if not result.isNil:
|
|
||||||
inc result.pending
|
|
||||||
else:
|
|
||||||
raise rateLimitError()
|
|
||||||
|
|
||||||
proc setRateLimit*(token: Token; api: Api; remaining, reset: int) =
|
|
||||||
# avoid undefined behavior in race conditions
|
|
||||||
if api in token.apis:
|
|
||||||
let limit = token.apis[api]
|
|
||||||
if limit.reset >= reset and limit.remaining < remaining:
|
|
||||||
return
|
|
||||||
|
|
||||||
token.apis[api] = RateLimit(remaining: remaining, reset: reset)
|
|
||||||
|
|
||||||
proc poolTokens*(amount: int) {.async.} =
|
|
||||||
var futs: seq[Future[Token]]
|
|
||||||
for i in 0 ..< amount:
|
|
||||||
futs.add fetchToken()
|
|
||||||
|
|
||||||
for token in futs:
|
|
||||||
var newToken: Token
|
|
||||||
|
|
||||||
try: newToken = await token
|
|
||||||
except: discard
|
|
||||||
|
|
||||||
if not newToken.isNil:
|
|
||||||
log "added new token to pool"
|
|
||||||
tokenPool.add newToken
|
|
||||||
|
|
||||||
proc initTokenPool*(cfg: Config) {.async.} =
|
|
||||||
enableLogging = cfg.enableDebug
|
|
||||||
|
|
||||||
while true:
|
|
||||||
if tokenPool.countIt(not it.isLimited(Api.userTweets)) < cfg.minTokens:
|
|
||||||
await poolTokens(min(4, cfg.minTokens - tokenPool.len))
|
|
||||||
await sleepAsync(2000)
|
|
||||||
@ -12,25 +12,13 @@ type
|
|||||||
TimelineKind* {.pure.} = enum
|
TimelineKind* {.pure.} = enum
|
||||||
tweets, replies, media
|
tweets, replies, media
|
||||||
|
|
||||||
Api* {.pure.} = enum
|
ApiUrl* = object
|
||||||
tweetDetail
|
endpoint*: string
|
||||||
tweetResult
|
params*: seq[(string, string)]
|
||||||
photoRail
|
|
||||||
search
|
ApiReq* = object
|
||||||
list
|
oauth*: ApiUrl
|
||||||
listBySlug
|
cookie*: ApiUrl
|
||||||
listMembers
|
|
||||||
listTweets
|
|
||||||
userRestId
|
|
||||||
userScreenName
|
|
||||||
favorites
|
|
||||||
userTweets
|
|
||||||
userTweetsAndReplies
|
|
||||||
userMedia
|
|
||||||
favoriters
|
|
||||||
retweeters
|
|
||||||
following
|
|
||||||
followers
|
|
||||||
|
|
||||||
RateLimit* = object
|
RateLimit* = object
|
||||||
remaining*: int
|
remaining*: int
|
||||||
@ -43,14 +31,14 @@ type
|
|||||||
init*: Time
|
init*: Time
|
||||||
lastUse*: Time
|
lastUse*: Time
|
||||||
pending*: int
|
pending*: int
|
||||||
apis*: Table[Api, RateLimit]
|
apis*: Table[string, RateLimit]
|
||||||
|
|
||||||
GuestAccount* = ref object
|
GuestAccount* = ref object
|
||||||
id*: int64
|
id*: int64
|
||||||
oauthToken*: string
|
oauthToken*: string
|
||||||
oauthSecret*: string
|
oauthSecret*: string
|
||||||
pending*: int
|
pending*: int
|
||||||
apis*: Table[Api, RateLimit]
|
apis*: Table[string, RateLimit]
|
||||||
|
|
||||||
Error* = enum
|
Error* = enum
|
||||||
null = 0
|
null = 0
|
||||||
@ -80,6 +68,11 @@ type
|
|||||||
business = "Business"
|
business = "Business"
|
||||||
government = "Government"
|
government = "Government"
|
||||||
|
|
||||||
|
Badge* = object
|
||||||
|
name*: string
|
||||||
|
icon*: string
|
||||||
|
url*: string
|
||||||
|
|
||||||
User* = object
|
User* = object
|
||||||
id*: string
|
id*: string
|
||||||
username*: string
|
username*: string
|
||||||
@ -89,7 +82,7 @@ type
|
|||||||
bio*: string
|
bio*: string
|
||||||
userPic*: string
|
userPic*: string
|
||||||
banner*: string
|
banner*: string
|
||||||
pinnedTweet*: int64
|
pinnedTweet*: string
|
||||||
following*: int
|
following*: int
|
||||||
followers*: int
|
followers*: int
|
||||||
tweets*: int
|
tweets*: int
|
||||||
@ -99,6 +92,10 @@ type
|
|||||||
protected*: bool
|
protected*: bool
|
||||||
suspended*: bool
|
suspended*: bool
|
||||||
joinDate*: DateTime
|
joinDate*: DateTime
|
||||||
|
bot*: bool
|
||||||
|
botOwner*: string
|
||||||
|
pcf*: string
|
||||||
|
badge*: Badge
|
||||||
|
|
||||||
VideoType* = enum
|
VideoType* = enum
|
||||||
m3u8 = "application/x-mpegURL"
|
m3u8 = "application/x-mpegURL"
|
||||||
@ -123,6 +120,10 @@ type
|
|||||||
playbackType*: VideoType
|
playbackType*: VideoType
|
||||||
variants*: seq[VideoVariant]
|
variants*: seq[VideoVariant]
|
||||||
|
|
||||||
|
Image* = object
|
||||||
|
url*: string
|
||||||
|
description*: string
|
||||||
|
|
||||||
QueryKind* = enum
|
QueryKind* = enum
|
||||||
posts, replies, media, users, tweets, userList, favorites
|
posts, replies, media, users, tweets, userList, favorites
|
||||||
|
|
||||||
@ -197,20 +198,27 @@ type
|
|||||||
retweets*: int
|
retweets*: int
|
||||||
likes*: int
|
likes*: int
|
||||||
quotes*: int
|
quotes*: int
|
||||||
|
bookmarks*: int
|
||||||
|
views*: int
|
||||||
|
|
||||||
BirdwatchNote* = ref object
|
BirdwatchNote* = ref object
|
||||||
id*: int64
|
id*: string
|
||||||
|
title*: string
|
||||||
|
text*: string
|
||||||
|
|
||||||
|
LimitedActions* = ref object
|
||||||
title*: string
|
title*: string
|
||||||
text*: string
|
text*: string
|
||||||
|
|
||||||
Tweet* = ref object
|
Tweet* = ref object
|
||||||
id*: int64
|
id*: string
|
||||||
threadId*: int64
|
threadId*: string
|
||||||
replyId*: int64
|
replyId*: string
|
||||||
user*: User
|
user*: User
|
||||||
text*: string
|
text*: string
|
||||||
time*: DateTime
|
time*: DateTime
|
||||||
reply*: seq[string]
|
reply*: seq[string]
|
||||||
|
replyHandle*: string
|
||||||
pinned*: bool
|
pinned*: bool
|
||||||
hasThread*: bool
|
hasThread*: bool
|
||||||
available*: bool
|
available*: bool
|
||||||
@ -227,8 +235,9 @@ type
|
|||||||
poll*: Option[Poll]
|
poll*: Option[Poll]
|
||||||
gif*: Option[Gif]
|
gif*: Option[Gif]
|
||||||
video*: Option[Video]
|
video*: Option[Video]
|
||||||
photos*: seq[string]
|
photos*: seq[Image]
|
||||||
birdwatch*: Option[BirdwatchNote]
|
birdwatch*: Option[BirdwatchNote]
|
||||||
|
limitedActions*: Option[LimitedActions]
|
||||||
|
|
||||||
Tweets* = seq[Tweet]
|
Tweets* = seq[Tweet]
|
||||||
|
|
||||||
@ -277,6 +286,7 @@ type
|
|||||||
useHttps*: bool
|
useHttps*: bool
|
||||||
httpMaxConns*: int
|
httpMaxConns*: int
|
||||||
title*: string
|
title*: string
|
||||||
|
oembedColor*: string
|
||||||
hostname*: string
|
hostname*: string
|
||||||
staticDir*: string
|
staticDir*: string
|
||||||
|
|
||||||
@ -287,6 +297,7 @@ type
|
|||||||
enableDebug*: bool
|
enableDebug*: bool
|
||||||
proxy*: string
|
proxy*: string
|
||||||
proxyAuth*: string
|
proxyAuth*: string
|
||||||
|
disableTid*: bool
|
||||||
cookieHeader*: string
|
cookieHeader*: string
|
||||||
xCsrfToken*: string
|
xCsrfToken*: string
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import karax/[karaxdsl, vdom]
|
|||||||
const
|
const
|
||||||
date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"")
|
date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"")
|
||||||
hash = staticExec("git show -s --format=\"%h\"")
|
hash = staticExec("git show -s --format=\"%h\"")
|
||||||
link = "https://github.com/zedeus/nitter/commit/" & hash
|
link = "https://git.eir-nya.gay/eir/nitter/commit/" & hash
|
||||||
version = &"{date}-{hash}"
|
version = &"{date}-{hash}"
|
||||||
|
|
||||||
var aboutHtml: string
|
var aboutHtml: string
|
||||||
|
|||||||
@ -15,7 +15,7 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
|||||||
let vidUrl = vars.sortedByIt(it.bitrate)[^1].url
|
let vidUrl = vars.sortedByIt(it.bitrate)[^1].url
|
||||||
let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
let prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
renderHead(prefs, cfg, req, video=vidUrl, images=(@[Image(url:thumb)]))
|
||||||
|
|
||||||
body:
|
body:
|
||||||
tdiv(class="embed-video"):
|
tdiv(class="embed-video"):
|
||||||
@ -23,11 +23,11 @@ proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
|||||||
|
|
||||||
result = doctype & $node
|
result = doctype & $node
|
||||||
|
|
||||||
proc generateOembed*(cfg: Config; typ, title, user, url, provider: string): JsonNode =
|
proc generateOembed*(cfg: Config; typ, title, user, url: string, provider: string): JsonNode =
|
||||||
%*{
|
%*{
|
||||||
"type": typ,
|
"type": typ,
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"provider_name": provider,
|
"provider_name": provider, #cfg.title,
|
||||||
"provider_url": getUrlPrefix(cfg),
|
"provider_url": getUrlPrefix(cfg),
|
||||||
"title": title,
|
"title": title,
|
||||||
"author_name": user,
|
"author_name": user,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import uri, strutils, strformat, times, options
|
|||||||
import karax/[karaxdsl, vdom]
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
import renderutils
|
import renderutils
|
||||||
import ../utils, ../types, ../prefs, ../formatters
|
import ".."/[utils, types, prefs, formatters]
|
||||||
|
|
||||||
import jester
|
import jester
|
||||||
|
|
||||||
@ -37,16 +37,17 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
|||||||
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
|
||||||
|
|
||||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
video=""; images: seq[Image] = @[]; banner=""; ogTitle="";
|
||||||
rss=""; canonical=""; avatar="";
|
rss=""; canonical=""; avatar=""; context=""; contextUrl="";
|
||||||
time: Option[DateTime] = none(DateTime)): VNode =
|
id=""; time: Option[DateTime] = none(DateTime); media="";
|
||||||
|
stats = ""): VNode =
|
||||||
var theme = prefs.theme.toTheme
|
var theme = prefs.theme.toTheme
|
||||||
if "theme" in req.params:
|
if "theme" in req.params:
|
||||||
theme = req.params["theme"].toTheme
|
theme = req.params["theme"].toTheme
|
||||||
|
|
||||||
let ogType =
|
let ogType =
|
||||||
if video.len > 0: "video.other"
|
if video.len > 0: "video.other"
|
||||||
elif rss.len > 0: "object"
|
#elif rss.len > 0: "object"
|
||||||
elif images.len > 0: "photo"
|
elif images.len > 0: "photo"
|
||||||
else: "article"
|
else: "article"
|
||||||
|
|
||||||
@ -54,17 +55,19 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
|
|
||||||
buildHtml(head):
|
buildHtml(head):
|
||||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=20")
|
link(rel="stylesheet", type="text/css", href="/css/style.css?v=20")
|
||||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=3")
|
||||||
link(rel="stylesheet", href="/css/baguetteBox.min.css")
|
link(rel="stylesheet", href="/css/baguetteBox.min.css")
|
||||||
script(src="/js/baguetteBox.min.js", `async`="")
|
script(src="/js/baguetteBox.min.js", `async`="")
|
||||||
script(src="/js/zoom.js")
|
script(src="/js/zoom.js")
|
||||||
|
|
||||||
|
let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot")
|
||||||
|
|
||||||
if theme.len > 0:
|
if theme.len > 0:
|
||||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||||
|
|
||||||
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
|
link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png"))
|
||||||
link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png")
|
link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png"))
|
||||||
link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png")
|
link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png?v=2")
|
||||||
link(rel="manifest", href="/site.webmanifest")
|
link(rel="manifest", href="/site.webmanifest")
|
||||||
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
|
link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
|
||||||
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
|
||||||
@ -83,6 +86,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
if prefs.infiniteScroll:
|
if prefs.infiniteScroll:
|
||||||
script(src="/js/infiniteScroll.js", `defer`="")
|
script(src="/js/infiniteScroll.js", `defer`="")
|
||||||
|
|
||||||
|
# Eir: load custom js
|
||||||
|
if prefs.eirResources:
|
||||||
|
script(src="/js/eirResources.js", `defer`="")
|
||||||
|
|
||||||
title:
|
title:
|
||||||
if titleText.len > 0:
|
if titleText.len > 0:
|
||||||
text titleText & " | " & cfg.title
|
text titleText & " | " & cfg.title
|
||||||
@ -93,7 +100,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
let finalizedDesc = stripHtml(desc)
|
let finalizedDesc = stripHtml(desc)
|
||||||
|
|
||||||
meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
meta(name="viewport", content="width=device-width, initial-scale=1.0")
|
||||||
meta(name="theme-color", content="#1F1F1F")
|
meta(name="theme-color", content=cfg.oembedColor)
|
||||||
meta(property="og:type", content=ogType)
|
meta(property="og:type", content=ogType)
|
||||||
if video.len > 0 and len(finalizedDesc) <= 67:
|
if video.len > 0 and len(finalizedDesc) <= 67:
|
||||||
meta(property="og:title", content=finalizedDesc)
|
meta(property="og:title", content=finalizedDesc)
|
||||||
@ -101,16 +108,22 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
meta(property="og:title", content=finalizedTitleText)
|
meta(property="og:title", content=finalizedTitleText)
|
||||||
meta(property="og:description", content=finalizedDesc)
|
meta(property="og:description", content=finalizedDesc)
|
||||||
meta(property="og:locale", content="en_US")
|
meta(property="og:locale", content="en_US")
|
||||||
|
meta(name="referrer", content="no-referrer")
|
||||||
|
|
||||||
var siteName = "Nitter"
|
var siteName = cfg.title
|
||||||
|
|
||||||
if time.isSome:
|
if time.isSome:
|
||||||
let timeObj = time.get
|
let timeObj = time.get
|
||||||
let timeStr = $timeObj
|
let timeStr = $timeObj
|
||||||
meta(property="og:article:published_time", content=timeStr)
|
meta(property="og:article:published_time", content=timeStr)
|
||||||
|
|
||||||
let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss")
|
if not isDiscord:
|
||||||
siteName = &"Nitter • {formattedTime}"
|
let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss")
|
||||||
|
siteName = &"{siteName} • {formattedTime}"
|
||||||
|
if stats.len > 0:
|
||||||
|
siteName &= "\n" & stats
|
||||||
|
|
||||||
|
if isDiscord and stats.len > 0:
|
||||||
|
siteName &= " • " & stats
|
||||||
|
|
||||||
meta(property="og:site_name", content=siteName)
|
meta(property="og:site_name", content=siteName)
|
||||||
|
|
||||||
@ -119,19 +132,21 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
|
link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
|
||||||
|
|
||||||
if images.len > 0:
|
if images.len > 0:
|
||||||
for url in images:
|
for imageObj in images:
|
||||||
let preloadUrl = if "400x400" in url: getPicUrl(url)
|
let
|
||||||
|
url = imageObj.url
|
||||||
|
preloadUrl = if "400x400" in url: getPicUrl(url)
|
||||||
else: getSmallPic(url)
|
else: getSmallPic(url)
|
||||||
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
|
link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
|
||||||
|
|
||||||
let image = getUrlPrefix(cfg) & getPicUrl(url)
|
let image = getUrlPrefix(cfg) & getPicUrl(url)
|
||||||
meta(property="og:image", content=image)
|
meta(property="og:image", content=image)
|
||||||
|
meta(property="og:image:alt", content=imageObj.description)
|
||||||
if video.len == 0:
|
if video.len == 0:
|
||||||
meta(property="twitter:image:src", content=image)
|
meta(property="twitter:image:src", content=image)
|
||||||
if rss.len > 0:
|
|
||||||
meta(property="twitter:card", content="summary")
|
|
||||||
elif video.len == 0:
|
|
||||||
meta(property="twitter:card", content="summary_large_image")
|
meta(property="twitter:card", content="summary_large_image")
|
||||||
|
else:
|
||||||
|
meta(property="twitter:card", content="summary")
|
||||||
elif avatar.len > 0:
|
elif avatar.len > 0:
|
||||||
let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar)
|
let avatarUrl = getUrlPrefix(cfg) & getPicUrl(avatar)
|
||||||
meta(property="og:image", content=avatarUrl)
|
meta(property="og:image", content=avatarUrl)
|
||||||
@ -140,32 +155,59 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||||||
meta(property="og:video:url", content=video)
|
meta(property="og:video:url", content=video)
|
||||||
meta(property="og:video:secure_url", content=video)
|
meta(property="og:video:secure_url", content=video)
|
||||||
meta(property="og:video:type", content="video/mp4")
|
meta(property="og:video:type", content="video/mp4")
|
||||||
var title = encodeUrl(finalizedDesc)
|
|
||||||
var author = encodeUrl(finalizedTitleText)
|
var
|
||||||
|
title = encodeUrl(finalizedDesc)
|
||||||
|
author = encodeUrl(finalizedTitleText)
|
||||||
|
url = req.path
|
||||||
|
|
||||||
if len(finalizedDesc) > 67:
|
if len(finalizedDesc) > 67:
|
||||||
title = author
|
title = author
|
||||||
author = encodeUrl(finalizedDesc)
|
author = encodeUrl(finalizedDesc)
|
||||||
|
|
||||||
verbatim &"<link rel=\"alternate\" href=\"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(req.path)}\" type=\"application/json+oembed\" />"
|
if context != "":
|
||||||
#link(rel="alternate",
|
author = encodeUrl(context & "\n") & author
|
||||||
# href=&"{getUrlPrefix(cfg)}/oembed.json?type=video&title={encodeUrl(stripHtml(desc))}&user={encodeUrl(finalizedTitleText)}&url={encodeUrl(req.path)}",
|
|
||||||
# `type`="application/json+oembed")
|
if contextUrl != "":
|
||||||
|
url = contextUrl
|
||||||
|
|
||||||
|
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed")
|
||||||
|
elif context != "" and contextUrl != "":
|
||||||
|
var
|
||||||
|
title = encodeUrl(finalizedTitleText)
|
||||||
|
author = encodeUrl(context)
|
||||||
|
link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed")
|
||||||
|
|
||||||
|
var fediUrl = &"{getUrlPrefix(cfg)}/users/i/statuses/"
|
||||||
|
if media.len > 0:
|
||||||
|
if media == "video":
|
||||||
|
fediUrl &= "422209040515" # 42 + "video"
|
||||||
|
else:
|
||||||
|
let parts = media.split(":")
|
||||||
|
fediUrl &= "421608152015" # 42 + "photo"
|
||||||
|
if parts.len == 2:
|
||||||
|
fediUrl &= parts[1] # + index
|
||||||
|
|
||||||
|
fediUrl &= id
|
||||||
|
link(rel="alternate", href=fediUrl, type="application/activity+json")
|
||||||
|
|
||||||
# this is last so images are also preloaded
|
# this is last so images are also preloaded
|
||||||
# if this is done earlier, Chrome only preloads one image for some reason
|
# if this is done earlier, Chrome only preloads one image for some reason
|
||||||
link(rel="preload", type="font/woff2", `as`="font",
|
link(rel="preload", type="font/woff2", `as`="font",
|
||||||
href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
|
href="/fonts/fontello.woff2?76162212", crossorigin="anonymous")
|
||||||
|
|
||||||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||||
images: seq[string] = @[]; banner=""; avatar="";
|
images: seq[Image] = @[]; banner=""; avatar=""; context="";
|
||||||
time: Option[DateTime] = none(DateTime)): string =
|
contextUrl=""; id=""; time: Option[DateTime] = none(DateTime);
|
||||||
|
media=""; stats=""): string =
|
||||||
|
|
||||||
let canonical = getTwitterLink(req.path, req.params)
|
let canonical = getTwitterLink(req.path, req.params)
|
||||||
|
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||||
rss, canonical, avatar, time)
|
rss, canonical, avatar, context, contextUrl, id, time, media,
|
||||||
|
stats)
|
||||||
|
|
||||||
body:
|
body:
|
||||||
renderNavbar(cfg, req, rss, canonical)
|
renderNavbar(cfg, req, rss, canonical)
|
||||||
|
|||||||
341
src/views/mastoapi.nim
Normal file
341
src/views/mastoapi.nim
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import strutils, strformat, options, json, sequtils, times, math
|
||||||
|
import ".."/[types, formatters, utils]
|
||||||
|
|
||||||
|
proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string =
|
||||||
|
var content = replaceUrls(tweet.text, prefs, absolute=getUrlPrefix(cfg))
|
||||||
|
|
||||||
|
if tweet.poll.isSome():
|
||||||
|
let poll = get(tweet.poll)
|
||||||
|
content &= "\n<blockquote>"
|
||||||
|
for i in 0 ..< poll.options.len:
|
||||||
|
let
|
||||||
|
leader = if poll.leader == i: " leader" else: ""
|
||||||
|
val = poll.values[i]
|
||||||
|
perc = if val > 0: val / poll.votes * 100 else: 0
|
||||||
|
percStr = (&"{perc:>3.0f}").strip(chars={'.'}) & '%'
|
||||||
|
barLen = round((perc / 100) * 32).int
|
||||||
|
bar = repeat("█", barLen)
|
||||||
|
notBar = repeat(" ", 32 - barLen)
|
||||||
|
content &= &"<b>{poll.options[i]}</b> ({insertSep($val, ',')}, {percStr})\n<code>{bar}{notBar}</code>\n"
|
||||||
|
|
||||||
|
content &= &"\n{insertSep($poll.votes, ',')} votes • {poll.status}</blockquote>"
|
||||||
|
|
||||||
|
if tweet.quote.isSome():
|
||||||
|
let
|
||||||
|
quote = get(tweet.quote)
|
||||||
|
quoteContent = replaceUrls(quote.text, prefs, absolute=getUrlPrefix(cfg))
|
||||||
|
quoteUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}"
|
||||||
|
content &= &"\n\n<blockquote><b>↘ <a href=\"{quoteUrl}\">{quote.user.fullName} (@{quote.user.username})</a></b>\n{quoteContent}"
|
||||||
|
|
||||||
|
if quote.video.isSome() or quote.gif.isSome():
|
||||||
|
content &= "\n📹"
|
||||||
|
if quote.gif.isSome():
|
||||||
|
content &= " (GIF)"
|
||||||
|
elif quote.photos.len > 0:
|
||||||
|
content &= "\n🖼️"
|
||||||
|
if quote.photos.len > 1:
|
||||||
|
content &= &" ({quote.photos.len})"
|
||||||
|
|
||||||
|
content &= "</blockquote>"
|
||||||
|
|
||||||
|
if tweet.birdwatch.isSome():
|
||||||
|
let
|
||||||
|
note = get(tweet.birdwatch)
|
||||||
|
noteContent = replaceUrls(note.text, prefs, absolute=getUrlPrefix(cfg))
|
||||||
|
content &= &"\n\n<blockquote><b>ⓘ {note.title}</b>\n{noteContent}</blockquote>"
|
||||||
|
|
||||||
|
result = content.replace("\n", "<br>")
|
||||||
|
|
||||||
|
proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode =
|
||||||
|
let
|
||||||
|
tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.id}"
|
||||||
|
tweetContent = formatTweetForMastoAPI(tweet, cfg, prefs)
|
||||||
|
var media: seq[JsonNode] = @[]
|
||||||
|
|
||||||
|
if tweet.photos.len > 0:
|
||||||
|
for imageObj in tweet.photos:
|
||||||
|
let
|
||||||
|
image = getUrlPrefix(cfg) & getPicUrl(imageObj.url)
|
||||||
|
splitUrl = imageObj.url.split('.')
|
||||||
|
var filetype = splitUrl[^1]
|
||||||
|
if filetype == "jpg":
|
||||||
|
filetype = "jpeg"
|
||||||
|
|
||||||
|
var mediaObj = newJObject()
|
||||||
|
mediaObj["type"] = %"Image"
|
||||||
|
mediaObj["mediaType"] = %("image/" & filetype)
|
||||||
|
mediaObj["url"] = %image
|
||||||
|
mediaObj["name"] = %imageObj.description
|
||||||
|
media.add(mediaObj)
|
||||||
|
|
||||||
|
if tweet.video.isSome():
|
||||||
|
let
|
||||||
|
videoObj = get(tweet.video)
|
||||||
|
vars = videoObj.variants.filterIt(it.contentType == mp4)
|
||||||
|
var description = videoObj.title
|
||||||
|
if videoObj.description.len > 0:
|
||||||
|
description = videoObj.description
|
||||||
|
|
||||||
|
let splitUrl = videoObj.thumb.split('.')
|
||||||
|
var filetype = splitUrl[^1]
|
||||||
|
if filetype == "jpg":
|
||||||
|
filetype = "jpeg"
|
||||||
|
|
||||||
|
var url: seq[JsonNode] = @[]
|
||||||
|
|
||||||
|
var thumb = newJObject()
|
||||||
|
thumb["type"] = %"Link"
|
||||||
|
thumb["mediaType"] = %("image/" & filetype)
|
||||||
|
thumb["href"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb))
|
||||||
|
url.add(thumb)
|
||||||
|
|
||||||
|
var mediaObj = newJObject()
|
||||||
|
mediaObj["type"] = %"Link"
|
||||||
|
mediaObj["mediaType"] = %"video/mp4"
|
||||||
|
mediaObj["href"] = %(vars[^1].url.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", ""))
|
||||||
|
url.add(mediaObj)
|
||||||
|
|
||||||
|
var wrapper = newJObject()
|
||||||
|
wrapper["type"] = %"Video"
|
||||||
|
wrapper["name"] = %description
|
||||||
|
wrapper["url"] = %url
|
||||||
|
media.add(wrapper)
|
||||||
|
elif tweet.gif.isSome():
|
||||||
|
let
|
||||||
|
gif = get(tweet.gif)
|
||||||
|
gifUrl = https & gif.url
|
||||||
|
|
||||||
|
let splitUrl = gif.thumb.split('.')
|
||||||
|
var filetype = splitUrl[^1]
|
||||||
|
if filetype == "jpg":
|
||||||
|
filetype = "jpeg"
|
||||||
|
|
||||||
|
var url: seq[JsonNode] = @[]
|
||||||
|
|
||||||
|
var thumb = newJObject()
|
||||||
|
thumb["type"] = %"Link"
|
||||||
|
thumb["mediaType"] = %("image/" & filetype)
|
||||||
|
thumb["href"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb))
|
||||||
|
url.add(thumb)
|
||||||
|
|
||||||
|
var mediaObj = newJObject()
|
||||||
|
mediaObj["type"] = %"Link"
|
||||||
|
mediaObj["mediaType"] = %"video/mp4"
|
||||||
|
mediaObj["href"] = %(gifUrl.replace("https://video.twimg.com", getUrlPrefix(cfg) & "/tvid").replace(".mp4", ""))
|
||||||
|
url.add(mediaObj)
|
||||||
|
|
||||||
|
var wrapper = newJObject()
|
||||||
|
wrapper["type"] = %"Video"
|
||||||
|
wrapper["name"] = newJNull()
|
||||||
|
wrapper["url"] = %url
|
||||||
|
media.add(wrapper)
|
||||||
|
|
||||||
|
var context: seq[JsonNode] = @[]
|
||||||
|
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
|
||||||
|
context.add(contextUrl)
|
||||||
|
let asProps: JsonNode = %*{
|
||||||
|
"ostatus": "http://ostatus.org#",
|
||||||
|
"atomUri": "ostatus:atomUri",
|
||||||
|
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
|
||||||
|
"conversation": "ostatus:conversation",
|
||||||
|
"sensitive": "as:sensitive",
|
||||||
|
}
|
||||||
|
context.add(asProps)
|
||||||
|
|
||||||
|
var postJson = newJObject()
|
||||||
|
postJson["@context"] = %context
|
||||||
|
postJson["id"] = %tweetUrl
|
||||||
|
postJson["type"] = %"Note"
|
||||||
|
postJson["summary"] = newJNull()
|
||||||
|
if tweet.replyId.len != 0:
|
||||||
|
let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}"
|
||||||
|
postJson["inReplyTo"] = %replyUrl
|
||||||
|
postJson["inReplyToAtomUri"] = %replyUrl
|
||||||
|
else:
|
||||||
|
postJson["inReplyTo"] = newJNull()
|
||||||
|
postJson["inReplyToAtomUri"] = newJNull()
|
||||||
|
postJson["published"] = %($tweet.time)
|
||||||
|
postJson["url"] = %tweetUrl
|
||||||
|
postJson["attributedTo"] = %(&"{getUrlPrefix(cfg)}/users/{tweet.user.username}")
|
||||||
|
postJson["to"] = newJArray()
|
||||||
|
postJson["cc"] = %(@["https://www.w3.org/ns/activitystreams#Public"])
|
||||||
|
postJson["sensitive"] = %false # FIXME
|
||||||
|
postJson["atomUri"] = %tweetUrl
|
||||||
|
postJson["conversation"] = %""
|
||||||
|
postJson["content"] = %tweetContent
|
||||||
|
postJson["contentMap"] = %*{
|
||||||
|
"en": tweetContent
|
||||||
|
}
|
||||||
|
postJson["attachment"] = %media
|
||||||
|
postJson["tag"] = newJArray() # TODO: parse?
|
||||||
|
postJson["replies"] = newJObject()
|
||||||
|
|
||||||
|
result = postJson
|
||||||
|
|
||||||
|
proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode =
|
||||||
|
let userUrl = &"{getUrlPrefix(cfg)}/{user.username}"
|
||||||
|
|
||||||
|
var context: seq[JsonNode] = @[]
|
||||||
|
let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams"
|
||||||
|
context.add(contextUrl)
|
||||||
|
let contextUrl2: JsonNode = %"https://w3id.org/security/v1"
|
||||||
|
context.add(contextUrl2)
|
||||||
|
|
||||||
|
let contextAka: JsonNode = %*{
|
||||||
|
"@id": "as:alsoKnownAs",
|
||||||
|
"@type": "@id"
|
||||||
|
}
|
||||||
|
let contextMovedTo = %*{
|
||||||
|
"@id": "as:movedTo",
|
||||||
|
"@type": "@id"
|
||||||
|
}
|
||||||
|
var asProps: JsonNode = %*{
|
||||||
|
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||||
|
"schema": "http://schema.org#",
|
||||||
|
"PropertyValue": "schema:PropertyValue",
|
||||||
|
"value": "schema:value",
|
||||||
|
}
|
||||||
|
asProps["alsoKnownAs"] = contextAka
|
||||||
|
asProps["movedTo"] = contextMovedTo
|
||||||
|
context.add(asProps)
|
||||||
|
|
||||||
|
var fields: seq[JsonNode] = @[]
|
||||||
|
|
||||||
|
if user.location.len > 0:
|
||||||
|
var location = newJObject()
|
||||||
|
location["type"] = %"PropertyValue"
|
||||||
|
location["name"] = %"Location"
|
||||||
|
location["value"] = %user.location
|
||||||
|
fields.add(location)
|
||||||
|
|
||||||
|
if user.website.len > 0:
|
||||||
|
var website = newJObject()
|
||||||
|
website["type"] = %"PropertyValue"
|
||||||
|
website["name"] = %"Website"
|
||||||
|
website["value"] = %(&"<a href=\"{user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{user.website}</a>")
|
||||||
|
fields.add(website)
|
||||||
|
|
||||||
|
if user.botOwner.len > 0:
|
||||||
|
var botOwner = newJObject()
|
||||||
|
botOwner["type"] = %"PropertyValue"
|
||||||
|
botOwner["name"] = %"Automated by"
|
||||||
|
botOwner["value"] = %(&"<a href=\"{getUrlPrefix(cfg)}/{user.botOwner}\" translate=\"no\">{user.botOwner}</a>")
|
||||||
|
fields.add(botOwner)
|
||||||
|
|
||||||
|
if user.pcf != "" and user.pcf != "None":
|
||||||
|
var pcf = newJObject()
|
||||||
|
pcf["type"] = %"PropertyValue"
|
||||||
|
pcf["name"] = %"PCF Label"
|
||||||
|
pcf["value"] = %user.pcf
|
||||||
|
fields.add(pcf)
|
||||||
|
|
||||||
|
if user.verifiedType != none:
|
||||||
|
var verified = newJObject()
|
||||||
|
verified["type"] = %"PropertyValue"
|
||||||
|
verified["name"] = %"Verified Type"
|
||||||
|
verified["value"] = %user.verifiedType
|
||||||
|
fields.add(verified)
|
||||||
|
|
||||||
|
var userJson = newJObject()
|
||||||
|
userJson["@context"] = %context
|
||||||
|
userJson["id"] = %userUrl
|
||||||
|
userJson["type"] = %"Person"
|
||||||
|
userJson["following"] = %(userUrl & "/following")
|
||||||
|
userJson["followers"] = %(userUrl & "/followers")
|
||||||
|
userJson["inbox"] = newJNull()
|
||||||
|
userJson["outbox"] = newJNull()
|
||||||
|
userJson["featured"] = newJNull()
|
||||||
|
userJson["featuredTags"] = newJNull()
|
||||||
|
userJson["preferredUsername"] = %user.username
|
||||||
|
userJson["name"] = %user.fullname
|
||||||
|
userJson["summary"] = %user.bio
|
||||||
|
userJson["url"] = %userUrl
|
||||||
|
userJson["manuallyApprovesFollowers"] = %user.protected
|
||||||
|
userJson["discoverable"] = %true
|
||||||
|
userJson["indexable"] = %false
|
||||||
|
userJson["published"] = %($user.joinDate)
|
||||||
|
userJson["memorial"] = %false
|
||||||
|
userJson["publicKey"] = newJNull()
|
||||||
|
userJson["tag"] = newJArray()
|
||||||
|
userJson["attachment"] = %fields
|
||||||
|
userJson["endpoints"] = newJObject()
|
||||||
|
userJson["icon"] = %*{
|
||||||
|
"type": "Image",
|
||||||
|
"mediaType": "image/jpeg",
|
||||||
|
"url": getUrlPrefix(cfg) & getPicUrl(user.userPic)
|
||||||
|
}
|
||||||
|
userJson["image"] = %*{
|
||||||
|
"type": "Image",
|
||||||
|
"mediaType": "image/jpeg",
|
||||||
|
"url": getUrlPrefix(cfg) & getPicUrl(user.banner)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = userJson
|
||||||
|
|
||||||
|
proc getMastoAPIUser*(user: User, cfg: Config): JsonNode =
|
||||||
|
var fields: seq[JsonNode] = @[]
|
||||||
|
|
||||||
|
if user.location.len > 0:
|
||||||
|
var location = newJObject()
|
||||||
|
location["name"] = %"Location"
|
||||||
|
location["value"] = %user.location
|
||||||
|
location["verified_at"] = newJNull()
|
||||||
|
fields.add(location)
|
||||||
|
|
||||||
|
if user.website.len > 0:
|
||||||
|
var website = newJObject()
|
||||||
|
website["name"] = %"Website"
|
||||||
|
website["value"] = %(&"<a href=\"{user.website}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{user.website}</a>")
|
||||||
|
website["verified_at"] = newJNull()
|
||||||
|
fields.add(website)
|
||||||
|
|
||||||
|
if user.botOwner.len > 0:
|
||||||
|
var botOwner = newJObject()
|
||||||
|
botOwner["name"] = %"Automated by"
|
||||||
|
botOwner["value"] = %(&"<a href=\"{getUrlPrefix(cfg)}/{user.botOwner}\" translate=\"no\">{user.botOwner}</a>")
|
||||||
|
botOwner["verified_at"] = newJNull()
|
||||||
|
fields.add(botOwner)
|
||||||
|
|
||||||
|
if user.pcf != "" and user.pcf != "None":
|
||||||
|
var pcf = newJObject()
|
||||||
|
pcf["name"] = %"PCF Label"
|
||||||
|
pcf["value"] = %user.pcf
|
||||||
|
pcf["verified_at"] = newJNull()
|
||||||
|
fields.add(pcf)
|
||||||
|
|
||||||
|
if user.verifiedType != none:
|
||||||
|
var verified = newJObject()
|
||||||
|
verified["name"] = %"Verified Type"
|
||||||
|
verified["value"] = %user.verifiedType
|
||||||
|
verified["verified_at"] = newJNull()
|
||||||
|
fields.add(verified)
|
||||||
|
|
||||||
|
var userJson = newJObject()
|
||||||
|
userJson["id"] = %user.id
|
||||||
|
userJson["username"] = %user.username
|
||||||
|
userJson["acct"] = %user.username
|
||||||
|
userJson["display_name"] = %user.fullname
|
||||||
|
userJson["locked"] = %user.protected
|
||||||
|
userJson["bot"] = %user.bot
|
||||||
|
userJson["discoverable"] = %true
|
||||||
|
userJson["indexable"] = %false
|
||||||
|
userJson["group"] = %false
|
||||||
|
userJson["created_at"] = %($user.joinDate)
|
||||||
|
userJson["note"] = %user.bio
|
||||||
|
userJson["url"] = %(&"{getUrlPrefix(cfg)}/{user.username}")
|
||||||
|
userJson["uri"] = %(&"{getUrlPrefix(cfg)}/{user.username}")
|
||||||
|
userJson["avatar"] = %(getUrlPrefix(cfg) & getPicUrl(user.userPic))
|
||||||
|
userJson["avatar_static"] = %(getUrlPrefix(cfg) & getPicUrl(user.userPic))
|
||||||
|
userJson["header"] = %(getUrlPrefix(cfg) & getPicUrl(user.banner))
|
||||||
|
userJson["header_static"] = %(getUrlPrefix(cfg) & getPicUrl(user.banner))
|
||||||
|
userJson["followers_count"] = %user.followers
|
||||||
|
userJson["following_count"] = %user.following
|
||||||
|
userJson["statuses_count"] = %user.tweets
|
||||||
|
userJson["hide_collections"] = %false
|
||||||
|
userJson["noindex"] = %false
|
||||||
|
userJson["emojis"] = %(@[])
|
||||||
|
userJson["roles"] = %(@[])
|
||||||
|
userJson["fields"] = %fields
|
||||||
|
|
||||||
|
result = userJson
|
||||||
@ -26,8 +26,8 @@ proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
|
|||||||
|
|
||||||
tdiv(class="profile-card-tabs-name-and-follow"):
|
tdiv(class="profile-card-tabs-name-and-follow"):
|
||||||
tdiv():
|
tdiv():
|
||||||
linkUser(user, class="profile-card-fullname")
|
linkUser(user, class="profile-card-fullname", prefs)
|
||||||
linkUser(user, class="profile-card-username")
|
linkUser(user, class="profile-card-username", prefs)
|
||||||
let following = isFollowing(user.username, prefs.following)
|
let following = isFollowing(user.username, prefs.following)
|
||||||
if not following:
|
if not following:
|
||||||
buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button"
|
buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button"
|
||||||
@ -35,6 +35,20 @@ proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
|
|||||||
buttonReferer "/unfollow/" & user.username, "Unfollow", path, "profile-card-follow-button"
|
buttonReferer "/unfollow/" & user.username, "Unfollow", path, "profile-card-follow-button"
|
||||||
|
|
||||||
tdiv(class="profile-card-extra"):
|
tdiv(class="profile-card-extra"):
|
||||||
|
if user.bot:
|
||||||
|
tdiv(class="profile-automated"):
|
||||||
|
span:
|
||||||
|
if user.botOwner.len > 0:
|
||||||
|
icon "cog", "Automated by "
|
||||||
|
a(href=(&"/{user.botOwner}")): text &"@{user.botOwner}"
|
||||||
|
else:
|
||||||
|
icon "cog", "Automated"
|
||||||
|
|
||||||
|
if user.pcf.len > 0:
|
||||||
|
tdiv(class="profile-pcf"):
|
||||||
|
span:
|
||||||
|
icon "pcf", &"{user.pcf} account"
|
||||||
|
|
||||||
if user.bio.len > 0:
|
if user.bio.len > 0:
|
||||||
tdiv(class="profile-bio"):
|
tdiv(class="profile-bio"):
|
||||||
p(dir="auto"):
|
p(dir="auto"):
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import strutils, strformat
|
import strutils, strformat
|
||||||
import karax/[karaxdsl, vdom, vstyles]
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
import ".."/[types, utils]
|
import ".."/[types, utils, formatters]
|
||||||
|
|
||||||
const smallWebp* = "?name=small&format=webp"
|
const smallWebp* = "?name=small&format=webp"
|
||||||
|
|
||||||
@ -26,11 +26,13 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
|
|||||||
template verifiedIcon*(user: User): untyped {.dirty.} =
|
template verifiedIcon*(user: User): untyped {.dirty.} =
|
||||||
if user.verifiedType != VerifiedType.none:
|
if user.verifiedType != VerifiedType.none:
|
||||||
let lower = ($user.verifiedType).toLowerAscii()
|
let lower = ($user.verifiedType).toLowerAscii()
|
||||||
icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account")
|
buildHtml(tdiv(class=(&"verified-icon {lower}"))):
|
||||||
|
icon "circle", class="verified-icon-circle", title=(&"Verified {lower} account")
|
||||||
|
icon "ok", class="verified-icon-check", title=(&"Verified {lower} account")
|
||||||
else:
|
else:
|
||||||
text ""
|
text ""
|
||||||
|
|
||||||
proc linkUser*(user: User, class=""): VNode =
|
proc linkUser*(user: User, class="", prefs: Prefs): VNode =
|
||||||
let
|
let
|
||||||
isName = "username" notin class
|
isName = "username" notin class
|
||||||
href = "/" & user.username
|
href = "/" & user.username
|
||||||
@ -44,6 +46,10 @@ proc linkUser*(user: User, class=""): VNode =
|
|||||||
if user.protected:
|
if user.protected:
|
||||||
text " "
|
text " "
|
||||||
icon "lock", title="Protected account"
|
icon "lock", title="Protected account"
|
||||||
|
if user.badge.name.len > 0:
|
||||||
|
span(class="brand-badge"):
|
||||||
|
a(href=replaceUrls(user.badge.url, prefs), title=user.badge.name):
|
||||||
|
img(class="brand-badge-image", src=getPicUrl(user.badge.icon), alt=user.badge.name)
|
||||||
|
|
||||||
proc linkText*(text: string; class=""): VNode =
|
proc linkText*(text: string; class=""): VNode =
|
||||||
let url = if "http" notin text: https & text else: text
|
let url = if "http" notin text: https & text else: text
|
||||||
@ -89,9 +95,9 @@ proc genDate*(pref, state: string): VNode =
|
|||||||
input(name=pref, `type`="date", value=state)
|
input(name=pref, `type`="date", value=state)
|
||||||
icon "calendar"
|
icon "calendar"
|
||||||
|
|
||||||
proc genImg*(url: string; class=""): VNode =
|
proc genImg*(url: string; alt=""; class=""): VNode =
|
||||||
buildHtml():
|
buildHtml():
|
||||||
img(src=getPicUrl(url), class=class, alt="")
|
img(src=getPicUrl(url), class=class, alt=alt)
|
||||||
|
|
||||||
proc getTabClass*(query: Query; tab: QueryKind): string =
|
proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||||
if query.kind == tab: "tab-item active"
|
if query.kind == tab: "tab-item active"
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
## SPDX-License-Identifier: AGPL-3.0-only
|
## SPDX-License-Identifier: AGPL-3.0-only
|
||||||
#import strutils, xmltree, strformat, options, unicode
|
#import strutils, xmltree, strformat, options, unicode
|
||||||
#import ../types, ../utils, ../formatters, ../prefs
|
#import ../types, ../utils, ../formatters, ../prefs
|
||||||
|
## Snowflake ID cutoff for RSS GUID format transition
|
||||||
|
## Corresponds to approximately December 14, 2025 UTC
|
||||||
|
#const guidCutoff = 2000000000000000000'i64
|
||||||
#
|
#
|
||||||
#proc getTitle(tweet: Tweet; retweet: string): string =
|
#proc getTitle(tweet: Tweet; retweet: string): string =
|
||||||
#if tweet.pinned: result = "Pinned: "
|
#if tweet.pinned: result = "Pinned: "
|
||||||
@ -25,7 +28,25 @@
|
|||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc getDescription(desc: string; cfg: Config): string =
|
#proc getDescription(desc: string; cfg: Config): string =
|
||||||
Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}
|
||||||
|
#end proc
|
||||||
|
#
|
||||||
|
#proc getTweetsWithPinned(profile: Profile): seq[Tweets] =
|
||||||
|
#result = profile.tweets.content
|
||||||
|
#if profile.pinned.isSome and result.len > 0:
|
||||||
|
# let pinnedTweet = profile.pinned.get
|
||||||
|
# var inserted = false
|
||||||
|
# for threadIdx in 0 ..< result.len:
|
||||||
|
# if not inserted:
|
||||||
|
# for tweetIdx in 0 ..< result[threadIdx].len:
|
||||||
|
# if result[threadIdx][tweetIdx].id < pinnedTweet.id:
|
||||||
|
# result[threadIdx].insert(pinnedTweet, tweetIdx)
|
||||||
|
# inserted = true
|
||||||
|
# end if
|
||||||
|
# end for
|
||||||
|
# end if
|
||||||
|
# end for
|
||||||
|
#end if
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
#proc renderRssTweet(tweet: Tweet; cfg: Config): string =
|
||||||
@ -35,10 +56,13 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||||||
<p>${text.replace("\n", "<br>\n")}</p>
|
<p>${text.replace("\n", "<br>\n")}</p>
|
||||||
#if tweet.photos.len > 0:
|
#if tweet.photos.len > 0:
|
||||||
# for photo in tweet.photos:
|
# for photo in tweet.photos:
|
||||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
<img src="${urlPrefix}${getPicUrl(photo.url)}" style="max-width:250px;" />
|
||||||
# end for
|
# end for
|
||||||
#elif tweet.video.isSome:
|
#elif tweet.video.isSome:
|
||||||
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
<a href="${urlPrefix}${tweet.getLink}">
|
||||||
|
<br>Video<br>
|
||||||
|
<img src="${urlPrefix}${getPicUrl(get(tweet.video).thumb)}" style="max-width:250px;" />
|
||||||
|
</a>
|
||||||
#elif tweet.gif.isSome:
|
#elif tweet.gif.isSome:
|
||||||
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
# let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
|
||||||
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
# let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
|
||||||
@ -51,10 +75,18 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
|||||||
# end if
|
# end if
|
||||||
#end if
|
#end if
|
||||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||||
# let quoteLink = getLink(get(tweet.quote))
|
# let quoteTweet = get(tweet.quote)
|
||||||
|
# let quoteLink = urlPrefix & getLink(quoteTweet)
|
||||||
<hr/>
|
<hr/>
|
||||||
<p>Quoting: <a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
<blockquote>
|
||||||
${renderRssTweet(get(tweet.quote), cfg)}
|
<b>${quoteTweet.user.fullname} (@${quoteTweet.user.username})</b>
|
||||||
|
<p>
|
||||||
|
${renderRssTweet(quoteTweet, cfg)}
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
— <cite><a href="${quoteLink}">${quoteLink}</a>
|
||||||
|
</footer>
|
||||||
|
</blockquote>
|
||||||
#end if
|
#end if
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
@ -72,12 +104,17 @@ ${renderRssTweet(get(tweet.quote), cfg)}
|
|||||||
# if link in links: continue
|
# if link in links: continue
|
||||||
# end if
|
# end if
|
||||||
# links.add link
|
# links.add link
|
||||||
|
# let useGlobalGuid = parseBiggestInt(tweet.id) >= guidCutoff
|
||||||
<item>
|
<item>
|
||||||
<title>${getTitle(tweet, retweet)}</title>
|
<title>${getTitle(tweet, retweet)}</title>
|
||||||
<dc:creator>@${tweet.user.username}</dc:creator>
|
<dc:creator>@${tweet.user.username}</dc:creator>
|
||||||
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
|
||||||
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
<pubDate>${getRfc822Time(tweet)}</pubDate>
|
||||||
|
#if useGlobalGuid:
|
||||||
|
<guid isPermaLink="false">${tweet.id}</guid>
|
||||||
|
#else:
|
||||||
<guid>${urlPrefix & link}</guid>
|
<guid>${urlPrefix & link}</guid>
|
||||||
|
#end if
|
||||||
<link>${urlPrefix & link}</link>
|
<link>${urlPrefix & link}</link>
|
||||||
</item>
|
</item>
|
||||||
# end for
|
# end for
|
||||||
@ -108,8 +145,9 @@ ${renderRssTweet(get(tweet.quote), cfg)}
|
|||||||
<width>128</width>
|
<width>128</width>
|
||||||
<height>128</height>
|
<height>128</height>
|
||||||
</image>
|
</image>
|
||||||
#if profile.tweets.content.len > 0:
|
#let tweetsList = getTweetsWithPinned(profile)
|
||||||
${renderRssTweets(profile.tweets.content, cfg, userId=profile.user.id)}
|
#if tweetsList.len > 0:
|
||||||
|
${renderRssTweets(tweetsList, cfg, userId=profile.user.id)}
|
||||||
#end if
|
#end if
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
|
|||||||
@ -24,9 +24,9 @@ proc renderSearch*(): VNode =
|
|||||||
buildHtml(tdiv(class="panel-container")):
|
buildHtml(tdiv(class="panel-container")):
|
||||||
tdiv(class="search-bar"):
|
tdiv(class="search-bar"):
|
||||||
form(`method`="get", action="/search", autocomplete="off"):
|
form(`method`="get", action="/search", autocomplete="off"):
|
||||||
hiddenField("f", "users")
|
hiddenField("f", "tweets")
|
||||||
input(`type`="text", name="q", autofocus="",
|
input(`type`="text", name="q", autofocus="",
|
||||||
placeholder="Enter username...", dir="auto")
|
placeholder="Search...", dir="auto")
|
||||||
button(`type`="submit"): icon "search"
|
button(`type`="submit"): icon "search"
|
||||||
|
|
||||||
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
|
proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode =
|
||||||
|
|||||||
@ -28,14 +28,19 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
|
|||||||
if thread.hasMore:
|
if thread.hasMore:
|
||||||
renderMoreReplies(thread)
|
renderMoreReplies(thread)
|
||||||
|
|
||||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
|
||||||
buildHtml(tdiv(class="replies", id="r")):
|
buildHtml(tdiv(class="replies", id="r")):
|
||||||
|
var hasReplies = false
|
||||||
|
var replyCount = 0
|
||||||
for thread in replies.content:
|
for thread in replies.content:
|
||||||
if thread.content.len == 0: continue
|
if thread.content.len == 0: continue
|
||||||
|
hasReplies = true
|
||||||
|
replyCount += thread.content.len
|
||||||
renderReplyThread(thread, prefs, path)
|
renderReplyThread(thread, prefs, path)
|
||||||
|
|
||||||
if replies.bottom.len > 0:
|
if hasReplies and replies.bottom.len > 0:
|
||||||
renderMore(Query(), replies.bottom, focus="#r")
|
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
|
||||||
|
renderMore(Query(), replies.bottom, focus="#r")
|
||||||
|
|
||||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||||
let hasAfter = conv.after.content.len > 0
|
let hasAfter = conv.after.content.len > 0
|
||||||
@ -45,7 +50,7 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
|||||||
if conv.before.content.len > 0:
|
if conv.before.content.len > 0:
|
||||||
tdiv(class="before-tweet thread-line"):
|
tdiv(class="before-tweet thread-line"):
|
||||||
let first = conv.before.content[0]
|
let first = conv.before.content[0]
|
||||||
if threadId != first.id and (first.replyId > 0 or not first.available):
|
if threadId != first.id and (first.replyId.len > 0 or not first.available):
|
||||||
renderEarlier(conv.before)
|
renderEarlier(conv.before)
|
||||||
for i, tweet in conv.before.content:
|
for i, tweet in conv.before.content:
|
||||||
renderTweet(tweet, prefs, path, index=i)
|
renderTweet(tweet, prefs, path, index=i)
|
||||||
@ -70,6 +75,6 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
|||||||
if not conv.replies.beginning:
|
if not conv.replies.beginning:
|
||||||
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
||||||
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
||||||
renderReplies(conv.replies, prefs, path)
|
renderReplies(conv.replies, prefs, path, conv.tweet)
|
||||||
|
|
||||||
renderToTop(focus="#m")
|
renderToTop(focus="#m")
|
||||||
|
|||||||
@ -55,7 +55,7 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
|||||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||||
index=i, last=(i == thread.high), showThread=show)
|
index=i, last=(i == thread.high), showThread=show)
|
||||||
|
|
||||||
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
|
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[string]; it: Tweet): seq[Tweet] =
|
||||||
result = @[it]
|
result = @[it]
|
||||||
if it.retweet.isSome or it.replyId in threads: return
|
if it.retweet.isSome or it.replyId in threads: return
|
||||||
for t in tweets:
|
for t in tweets:
|
||||||
@ -74,8 +74,8 @@ proc renderUser*(user: User; prefs: Prefs): VNode =
|
|||||||
|
|
||||||
tdiv(class="tweet-name-row"):
|
tdiv(class="tweet-name-row"):
|
||||||
tdiv(class="fullname-and-username"):
|
tdiv(class="fullname-and-username"):
|
||||||
linkUser(user, class="fullname")
|
linkUser(user, class="fullname", prefs)
|
||||||
linkUser(user, class="username")
|
linkUser(user, class="username", prefs)
|
||||||
|
|
||||||
tdiv(class="tweet-content media-body", dir="auto"):
|
tdiv(class="tweet-content media-body", dir="auto"):
|
||||||
verbatim replaceUrls(user.bio, prefs)
|
verbatim replaceUrls(user.bio, prefs)
|
||||||
@ -112,20 +112,20 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
|
|||||||
else:
|
else:
|
||||||
renderNoneFound()
|
renderNoneFound()
|
||||||
else:
|
else:
|
||||||
var retweets: seq[int64]
|
var retweets: seq[string]
|
||||||
|
|
||||||
for thread in results.content:
|
for thread in results.content:
|
||||||
if thread.len == 1:
|
if thread.len == 1:
|
||||||
let
|
let
|
||||||
tweet = thread[0]
|
tweet = thread[0]
|
||||||
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
|
retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: ""
|
||||||
|
|
||||||
if retweetId in retweets or tweet.id in retweets or
|
if retweetId in retweets or tweet.id in retweets or
|
||||||
tweet.pinned and prefs.hidePins:
|
tweet.pinned and prefs.hidePins:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
var hasThread = tweet.hasThread
|
var hasThread = tweet.hasThread
|
||||||
if retweetId != 0 and tweet.retweet.isSome:
|
if retweetId.len != 0 and tweet.retweet.isSome:
|
||||||
retweets &= retweetId
|
retweets &= retweetId
|
||||||
hasThread = get(tweet.retweet).hasThread
|
hasThread = get(tweet.retweet).hasThread
|
||||||
renderTweet(tweet, prefs, path, showThread=hasThread)
|
renderTweet(tweet, prefs, path, showThread=hasThread)
|
||||||
|
|||||||
@ -15,30 +15,40 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
|
|||||||
img(class=(prefs.getAvatarClass & " mini"), src=url)
|
img(class=(prefs.getAvatarClass & " mini"), src=url)
|
||||||
|
|
||||||
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
||||||
|
let user = tweet.user
|
||||||
|
|
||||||
buildHtml(tdiv):
|
buildHtml(tdiv):
|
||||||
if pinned:
|
if pinned:
|
||||||
tdiv(class="pinned"):
|
tdiv(class="pinned"):
|
||||||
span: icon "pin", "Pinned Tweet"
|
span: icon "pin", "Pinned"
|
||||||
elif retweet.len > 0:
|
elif retweet.len > 0:
|
||||||
tdiv(class="retweet-header"):
|
tdiv(class="retweet-header"):
|
||||||
span: icon "retweet", retweet & " retweeted"
|
span: icon "retweet", retweet & " retweeted"
|
||||||
|
|
||||||
tdiv(class="tweet-header"):
|
tdiv(class="tweet-header"):
|
||||||
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
a(class="tweet-avatar", href=("/" & user.username)):
|
||||||
var size = "_bigger"
|
var size = "_bigger"
|
||||||
if not prefs.autoplayGifs and tweet.user.userPic.endsWith("gif"):
|
if not prefs.autoplayGifs and user.userPic.endsWith("gif"):
|
||||||
size = "_400x400"
|
size = "_400x400"
|
||||||
genImg(tweet.user.getUserPic(size), class=prefs.getAvatarClass)
|
genImg(user.getUserPic(size), class=prefs.getAvatarClass)
|
||||||
|
|
||||||
tdiv(class="tweet-name-row"):
|
tdiv(class="tweet-name-row"):
|
||||||
tdiv(class="fullname-and-username"):
|
tdiv(class="fullname-and-username"):
|
||||||
linkUser(tweet.user, class="fullname")
|
linkUser(user, class="fullname", prefs)
|
||||||
linkUser(tweet.user, class="username")
|
linkUser(user, class="username", prefs)
|
||||||
|
|
||||||
span(class="tweet-date"):
|
span(class="tweet-date"):
|
||||||
a(href=getLink(tweet), title=tweet.getTime):
|
a(href=getLink(tweet), title=tweet.getTime):
|
||||||
text tweet.getShortTime
|
text tweet.getShortTime
|
||||||
|
|
||||||
|
if user.pcf.len > 0 or user.bot:
|
||||||
|
tdiv(class="tweet-label-row"):
|
||||||
|
if user.bot:
|
||||||
|
tdiv(class="user-automated"): icon "cog", "Automated"
|
||||||
|
|
||||||
|
if user.pcf.len > 0:
|
||||||
|
tdiv(class="user-pcf"): icon "pcf", &"{user.pcf} account"
|
||||||
|
|
||||||
proc renderAlbum(tweet: Tweet): VNode =
|
proc renderAlbum(tweet: Tweet): VNode =
|
||||||
let
|
let
|
||||||
groups = if tweet.photos.len < 3: @[tweet.photos]
|
groups = if tweet.photos.len < 3: @[tweet.photos]
|
||||||
@ -49,12 +59,15 @@ proc renderAlbum(tweet: Tweet): VNode =
|
|||||||
let margin = if i > 0: ".25em" else: ""
|
let margin = if i > 0: ".25em" else: ""
|
||||||
tdiv(class="gallery-row", style={marginTop: margin}):
|
tdiv(class="gallery-row", style={marginTop: margin}):
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
tdiv(class="attachment image"):
|
tdiv(class="attachment image", title=photo.description):
|
||||||
let
|
let
|
||||||
named = "name=" in photo
|
url = photo.url
|
||||||
small = if named: photo else: photo & smallWebp
|
named = "name=" in url
|
||||||
a(href=getOrigPicUrl(photo), class="still-image", target="_blank"):
|
small = if named: url else: url & smallWebp
|
||||||
genImg(small)
|
a(href=getOrigPicUrl(url), class="still-image", target="_blank", data-caption=photo.description):
|
||||||
|
genImg(small, alt=photo.description)
|
||||||
|
if photo.description.len > 0:
|
||||||
|
span(class="alt"): text "ALT"
|
||||||
|
|
||||||
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool =
|
||||||
case playbackType
|
case playbackType
|
||||||
@ -180,19 +193,16 @@ func formatStat(stat: int): string =
|
|||||||
if stat > 0: insertSep($stat, ',')
|
if stat > 0: insertSep($stat, ',')
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
|
proc renderStats(stats: TweetStats; tweet: Tweet): VNode =
|
||||||
buildHtml(tdiv(class="tweet-stats")):
|
buildHtml(tdiv(class="tweet-stats")):
|
||||||
a(href=getLink(tweet)):
|
span(class="tweet-stat", title="Replies", "aria-label"="Replies"): icon "comment", formatStat(stats.replies)
|
||||||
span(class="tweet-stat"): icon "comment", formatStat(stats.replies)
|
span(class="tweet-stat", title="Reposts", "aria-label"="Reposts"): icon "retweet", formatStat(stats.retweets)
|
||||||
a(href=getLink(tweet, false) & "/retweeters"):
|
span(class="tweet-stat"):
|
||||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
a(href="/search?q=quoted_tweet_id:" & $tweet.id, title="Quotes", "aria-label"="Quotes"): icon "quote", formatStat(stats.quotes)
|
||||||
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
|
span(class="tweet-stat", title="Likes", "aria-label"="Likes"): icon "heart", formatStat(stats.likes)
|
||||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
span(class="tweet-stat", title="Bookmarks", "aria-label"="Bookmarks"): icon "bookmark", formatStat(stats.bookmarks)
|
||||||
a():
|
if stats.views > -1:
|
||||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
span(class="tweet-stat", title="Views", "aria-label"="Views"): icon "eye", formatStat(stats.views)
|
||||||
a(href=getLink(tweet)):
|
|
||||||
if views.len > 0:
|
|
||||||
span(class="tweet-stat"): icon "play", insertSep(views, ',')
|
|
||||||
|
|
||||||
proc renderReply(tweet: Tweet): VNode =
|
proc renderReply(tweet: Tweet): VNode =
|
||||||
buildHtml(tdiv(class="replying-to")):
|
buildHtml(tdiv(class="replying-to")):
|
||||||
@ -220,7 +230,8 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode =
|
|||||||
buildHtml(tdiv(class="quote-media-container")):
|
buildHtml(tdiv(class="quote-media-container")):
|
||||||
if quote.photos.len > 0:
|
if quote.photos.len > 0:
|
||||||
renderAlbum(quote)
|
renderAlbum(quote)
|
||||||
elif quote.video.isSome:
|
|
||||||
|
if quote.video.isSome:
|
||||||
renderVideo(quote.video.get(), prefs, path)
|
renderVideo(quote.video.get(), prefs, path)
|
||||||
elif quote.gif.isSome:
|
elif quote.gif.isSome:
|
||||||
renderGif(quote.gif.get(), prefs)
|
renderGif(quote.gif.get(), prefs)
|
||||||
@ -242,8 +253,8 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
|
|||||||
tdiv(class="tweet-name-row"):
|
tdiv(class="tweet-name-row"):
|
||||||
tdiv(class="fullname-and-username"):
|
tdiv(class="fullname-and-username"):
|
||||||
renderMiniAvatar(quote.user, prefs)
|
renderMiniAvatar(quote.user, prefs)
|
||||||
linkUser(quote.user, class="fullname")
|
linkUser(quote.user, class="fullname", prefs)
|
||||||
linkUser(quote.user, class="username")
|
linkUser(quote.user, class="username", prefs)
|
||||||
|
|
||||||
span(class="tweet-date"):
|
span(class="tweet-date"):
|
||||||
a(href=getLink(quote), title=quote.getTime):
|
a(href=getLink(quote), title=quote.getTime):
|
||||||
@ -271,6 +282,11 @@ proc renderCommunityNote(note: BirdwatchNote; prefs: Prefs): VNode =
|
|||||||
tdiv(class="community-note-text", dir="auto"):
|
tdiv(class="community-note-text", dir="auto"):
|
||||||
verbatim replaceUrls(note.text, prefs)
|
verbatim replaceUrls(note.text, prefs)
|
||||||
|
|
||||||
|
proc renderLimitedActions(action: LimitedActions): VNode =
|
||||||
|
buildHtml(tdiv(class="limited-actions")):
|
||||||
|
tdiv(class="limited-actions-title"): text action.title
|
||||||
|
tdiv(class="limited-actions-text"): text action.text
|
||||||
|
|
||||||
proc renderLocation*(tweet: Tweet): string =
|
proc renderLocation*(tweet: Tweet): string =
|
||||||
let (place, url) = tweet.getLocation()
|
let (place, url) = tweet.getLocation()
|
||||||
if place.len == 0: return
|
if place.len == 0: return
|
||||||
@ -316,7 +332,6 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
a(class="tweet-link", href=getLink(tweet))
|
a(class="tweet-link", href=getLink(tweet))
|
||||||
|
|
||||||
tdiv(class="tweet-body"):
|
tdiv(class="tweet-body"):
|
||||||
var views = ""
|
|
||||||
renderHeader(tweet, retweet, pinned, prefs)
|
renderHeader(tweet, retweet, pinned, prefs)
|
||||||
|
|
||||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||||
@ -338,12 +353,11 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
|
|
||||||
if tweet.photos.len > 0:
|
if tweet.photos.len > 0:
|
||||||
renderAlbum(tweet)
|
renderAlbum(tweet)
|
||||||
elif tweet.video.isSome:
|
|
||||||
|
if tweet.video.isSome:
|
||||||
renderVideo(tweet.video.get(), prefs, path)
|
renderVideo(tweet.video.get(), prefs, path)
|
||||||
views = tweet.video.get().views
|
|
||||||
elif tweet.gif.isSome:
|
elif tweet.gif.isSome:
|
||||||
renderGif(tweet.gif.get(), prefs)
|
renderGif(tweet.gif.get(), prefs)
|
||||||
views = "GIF"
|
|
||||||
|
|
||||||
if tweet.poll.isSome:
|
if tweet.poll.isSome:
|
||||||
renderPoll(tweet.poll.get())
|
renderPoll(tweet.poll.get())
|
||||||
@ -351,7 +365,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
if tweet.quote.isSome:
|
if tweet.quote.isSome:
|
||||||
renderQuote(tweet.quote.get(), prefs, path)
|
renderQuote(tweet.quote.get(), prefs, path)
|
||||||
|
|
||||||
if mainTweet and tweet.birdwatch.isSome:
|
if tweet.birdwatch.isSome:
|
||||||
renderCommunityNote(tweet.birdwatch.get(), prefs)
|
renderCommunityNote(tweet.birdwatch.get(), prefs)
|
||||||
|
|
||||||
if mainTweet:
|
if mainTweet:
|
||||||
@ -361,12 +375,15 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||||||
renderMediaTags(tweet.mediaTags)
|
renderMediaTags(tweet.mediaTags)
|
||||||
|
|
||||||
if not prefs.hideTweetStats:
|
if not prefs.hideTweetStats:
|
||||||
renderStats(tweet.stats, views, tweet)
|
renderStats(tweet.stats, tweet)
|
||||||
|
|
||||||
if showThread:
|
if showThread:
|
||||||
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
a(class="show-thread", href=("/i/status/" & $tweet.threadId)):
|
||||||
text "Show this thread"
|
text "Show this thread"
|
||||||
|
|
||||||
|
if mainTweet and tweet.limitedActions.isSome:
|
||||||
|
renderLimitedActions(tweet.limitedActions.get())
|
||||||
|
|
||||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
renderHead(prefs, cfg, req)
|
renderHead(prefs, cfg, req)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user