commit
						bce76ab8d1
					
				
					 30 changed files with 786 additions and 204 deletions
				
			
		| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
[Server]
 | 
			
		||||
address = "0.0.0.0"
 | 
			
		||||
port = 8080
 | 
			
		||||
https = true  # disable to enable cookies when not using https
 | 
			
		||||
title = "nitter"
 | 
			
		||||
staticDir = "./public"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,8 +11,8 @@ bin           = @["nitter"]
 | 
			
		|||
# Dependencies
 | 
			
		||||
 | 
			
		||||
requires "nim >= 0.19.9"
 | 
			
		||||
requires "norm >= 1.0.11"
 | 
			
		||||
requires "jester >= 0.4.1"
 | 
			
		||||
requires "norm >= 1.0.13"
 | 
			
		||||
requires "jester >= 0.4.3"
 | 
			
		||||
requires "regex >= 0.11.2"
 | 
			
		||||
requires "q >= 0.0.7"
 | 
			
		||||
requires "nimcrypto >= 0.3.9"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										53
									
								
								public/css/fontello.css
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								public/css/fontello.css
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
@font-face {
 | 
			
		||||
  font-family: 'fontello';
 | 
			
		||||
  src: url('/fonts/fontello.eot?39973630');
 | 
			
		||||
  src: url('/fonts/fontello.eot?39973630#iefix') format('embedded-opentype'),
 | 
			
		||||
       url('/fonts/fontello.woff2?39973630') format('woff2'),
 | 
			
		||||
       url('/fonts/fontello.woff?39973630') format('woff'),
 | 
			
		||||
       url('/fonts/fontello.ttf?39973630') format('truetype'),
 | 
			
		||||
       url('/fonts/fontello.svg?39973630#fontello') format('svg');
 | 
			
		||||
  font-weight: normal;
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 [class^="icon-"]:before, [class*=" icon-"]:before {
 | 
			
		||||
  font-family: "fontello";
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
  font-weight: normal;
 | 
			
		||||
  speak: none;
 | 
			
		||||
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  text-decoration: inherit;
 | 
			
		||||
  width: 1em;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
 | 
			
		||||
  /* For safety - reset parent styles, that can break glyph codes*/
 | 
			
		||||
  font-variant: normal;
 | 
			
		||||
  text-transform: none;
 | 
			
		||||
 | 
			
		||||
  /* fix buttons height, for twitter bootstrap */
 | 
			
		||||
  line-height: 1em;
 | 
			
		||||
 | 
			
		||||
  /* Font smoothing. That was taken from TWBS */
 | 
			
		||||
  -webkit-font-smoothing: antialiased;
 | 
			
		||||
  -moz-osx-font-smoothing: grayscale;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.icon-help-circled:before { content: '\e800'; } /* '' */
 | 
			
		||||
.icon-attention:before { content: '\e801'; } /* '' */
 | 
			
		||||
.icon-comment:before { content: '\e802'; } /* '' */
 | 
			
		||||
.icon-ok:before { content: '\e803'; } /* '' */
 | 
			
		||||
.icon-link:before { content: '\e805'; } /* '' */
 | 
			
		||||
.icon-calendar:before { content: '\e806'; } /* '' */
 | 
			
		||||
.icon-location:before { content: '\e807'; } /* '' */
 | 
			
		||||
.icon-down-open-1:before { content: '\e808'; } /* '' */
 | 
			
		||||
.icon-picture-1:before { content: '\e809'; } /* '' */
 | 
			
		||||
.icon-lock-circled:before { content: '\e80a'; } /* '' */
 | 
			
		||||
.icon-down-open:before { content: '\e80b'; } /* '' */
 | 
			
		||||
.icon-info-circled:before { content: '\e80c'; } /* '' */
 | 
			
		||||
.icon-retweet-1:before { content: '\e80d'; } /* '' */
 | 
			
		||||
.icon-search:before { content: '\e80e'; } /* '' */
 | 
			
		||||
.icon-pin:before { content: '\e80f'; } /* '' */
 | 
			
		||||
.icon-ok-circled:before { content: '\e810'; } /* '' */
 | 
			
		||||
.icon-cog-2:before { content: '\e812'; } /* '' */
 | 
			
		||||
.icon-thumbs-up-alt:before { content: '\f164'; } /* '' */
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,10 @@ body {
 | 
			
		|||
    line-height: 1.3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
* {
 | 
			
		||||
    outline: unset;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#posts {
 | 
			
		||||
    background-color: #161616;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -107,29 +111,19 @@ a:hover {
 | 
			
		|||
    text-overflow: ellipsis;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.icon {
 | 
			
		||||
.verified-icon {
 | 
			
		||||
    color: #fff;
 | 
			
		||||
    background-color: #1da1f2;
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
    flex-shrink: 0;
 | 
			
		||||
    margin: 2px 0 3px 3px;
 | 
			
		||||
    padding-top: 2px;
 | 
			
		||||
    height: 12px;
 | 
			
		||||
    width: 14px;
 | 
			
		||||
    font-size: 8px;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
    flex-shrink: 0;
 | 
			
		||||
    margin: 2px 0 3px 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.verified-icon {
 | 
			
		||||
    background-color: #1da1f2;
 | 
			
		||||
    height: 14px;
 | 
			
		||||
    width: 14px;
 | 
			
		||||
    font-size: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.protected-icon {
 | 
			
		||||
    background-color: #353535;
 | 
			
		||||
    height: 18px;
 | 
			
		||||
    width: 18px;
 | 
			
		||||
    font-size: 12px;
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tweet-date {
 | 
			
		||||
| 
						 | 
				
			
			@ -210,19 +204,28 @@ nav {
 | 
			
		|||
    justify-content: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.site-name {
 | 
			
		||||
    font-weight: 600;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.site-name:hover {
 | 
			
		||||
    color: #ffaca0;
 | 
			
		||||
    text-decoration: unset;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.site-logo {
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: 35px;
 | 
			
		||||
    height: 35px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.site-about {
 | 
			
		||||
    font-size: 17px;
 | 
			
		||||
    padding-right: 2px;
 | 
			
		||||
    margin-top: -0.75px;
 | 
			
		||||
.item.right a {
 | 
			
		||||
    padding-left: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.site-settings {
 | 
			
		||||
    font-size: 18px;
 | 
			
		||||
.item.right a:hover {
 | 
			
		||||
    color: #ffaca0;
 | 
			
		||||
    text-decoration: unset;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.attachments {
 | 
			
		||||
| 
						 | 
				
			
			@ -277,7 +280,7 @@ nav {
 | 
			
		|||
    overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
video {
 | 
			
		||||
video, .video-container img {
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -386,10 +389,15 @@ video {
 | 
			
		|||
    padding: 0 2em;
 | 
			
		||||
    line-height: 2em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.show-more a:hover {
 | 
			
		||||
    background-color: #282828;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.show-thread {
 | 
			
		||||
    display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.multi-header {
 | 
			
		||||
    background-color: #161616;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
| 
						 | 
				
			
			@ -437,7 +445,6 @@ video {
 | 
			
		|||
    text-align: left;
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
    max-width: 32%;
 | 
			
		||||
    position: sticky;
 | 
			
		||||
    top: 50px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -898,12 +905,8 @@ video {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
.quote-sensitive-icon {
 | 
			
		||||
    font-size: 25px;
 | 
			
		||||
    width: 37px;
 | 
			
		||||
    height: 32px;
 | 
			
		||||
    background-color: #4e4e4e;
 | 
			
		||||
    padding-bottom: 5px;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-size: 40px;
 | 
			
		||||
    color: #909090;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card {
 | 
			
		||||
| 
						 | 
				
			
			@ -1073,3 +1076,132 @@ video {
 | 
			
		|||
.poll-info {
 | 
			
		||||
    color: #868687;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.preferences-container {
 | 
			
		||||
    max-width: 600px;
 | 
			
		||||
    margin: 0 auto;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    margin-top: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.preferences {
 | 
			
		||||
    background-color: #1f1f1f;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding: 5px 15px 15px 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.preferences input[type="text"] {
 | 
			
		||||
    max-width: 120px;
 | 
			
		||||
    background-color: #121212;
 | 
			
		||||
    padding: 1px 4px;
 | 
			
		||||
    color: #f8f8f2;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    border: 1px solid #ff6c6091;
 | 
			
		||||
    border-radius: 0px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.preferences input[type="text"]:hover {
 | 
			
		||||
    border-color: #ff6c60;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fieldset {
 | 
			
		||||
    margin: .35em 0 .75em;
 | 
			
		||||
    border: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
legend {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding: .6em 0 .3em 0;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    border: 0;
 | 
			
		||||
    font-size: 16px;
 | 
			
		||||
    border-bottom: 1px solid #3e3e35;
 | 
			
		||||
    margin-bottom: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pref-input {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    margin-bottom: 6px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pref-submit, .pref-reset button {
 | 
			
		||||
    background-color: #121212;
 | 
			
		||||
    color: #f8f8f2;
 | 
			
		||||
    border: 1px solid #ff6c6091;
 | 
			
		||||
    padding: 3px 6px;
 | 
			
		||||
    margin-top: 6px;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    float: right;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pref-submit:hover, .pref-reset button:hover {
 | 
			
		||||
    border-color: #ff6c60;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pref-submit:active, .pref-reset button:active {
 | 
			
		||||
    border-color: #ff9f97;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pref-reset {
 | 
			
		||||
    float: left;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.icon-container {
 | 
			
		||||
    display: inline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container {
 | 
			
		||||
  display: block;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  margin-bottom: 5px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container input {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  height: 0;
 | 
			
		||||
  width: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 1px;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  height: 17px;
 | 
			
		||||
  width: 17px;
 | 
			
		||||
  background-color: #121212;
 | 
			
		||||
  border: 1px solid #ff6c6091;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container:hover input ~ .checkbox {
 | 
			
		||||
  border-color: #ff6c60;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container:active input ~ .checkbox {
 | 
			
		||||
    border-color: #ff9f97;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox:after {
 | 
			
		||||
  content: "";
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container input:checked ~ .checkbox:after {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container .checkbox:after {
 | 
			
		||||
  left: 2px;
 | 
			
		||||
  bottom: 0px;
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
  font-family: "fontello";
 | 
			
		||||
  content: '\e803';
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								public/fonts/LICENSE.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								public/fonts/LICENSE.txt
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
Font license info
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Entypo
 | 
			
		||||
 | 
			
		||||
   Copyright (C) 2012 by Daniel Bruce
 | 
			
		||||
 | 
			
		||||
   Author:    Daniel Bruce
 | 
			
		||||
   License:   SIL (http://scripts.sil.org/OFL)
 | 
			
		||||
   Homepage:  http://www.entypo.com
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## MFG Labs
 | 
			
		||||
 | 
			
		||||
   Copyright (C) 2012 by Daniel Bruce
 | 
			
		||||
 | 
			
		||||
   Author:    MFG Labs
 | 
			
		||||
   License:   SIL (http://scripts.sil.org/OFL)
 | 
			
		||||
   Homepage:  http://www.mfglabs.com/
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Font Awesome
 | 
			
		||||
 | 
			
		||||
   Copyright (C) 2016 by Dave Gandy
 | 
			
		||||
 | 
			
		||||
   Author:    Dave Gandy
 | 
			
		||||
   License:   SIL ()
 | 
			
		||||
   Homepage:  http://fortawesome.github.com/Font-Awesome/
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Elusive
 | 
			
		||||
 | 
			
		||||
   Copyright (C) 2013 by Aristeides Stathopoulos
 | 
			
		||||
 | 
			
		||||
   Author:    Aristeides Stathopoulos
 | 
			
		||||
   License:   SIL (http://scripts.sil.org/OFL)
 | 
			
		||||
   Homepage:  http://aristeides.com/
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								public/fonts/fontello.eot
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/fonts/fontello.eot
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										46
									
								
								public/fonts/fontello.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								public/fonts/fontello.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,46 @@
 | 
			
		|||
<?xml version="1.0" standalone="no"?>
 | 
			
		||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
<metadata>Copyright (C) 2019 by original authors @ fontello.com</metadata>
 | 
			
		||||
<defs>
 | 
			
		||||
<font id="fontello" horiz-adv-x="1000" >
 | 
			
		||||
<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
 | 
			
		||||
<missing-glyph horiz-adv-x="1000" />
 | 
			
		||||
<glyph glyph-name="help-circled" unicode="" d="M454 810q190 2 326-130t140-322q2-190-131-327t-323-141q-190-2-327 131t-139 323q-4 190 130 327t324 139z m-2-740q30 0 49 19t19 47q2 30-17 49t-49 19l-2 0q-28 0-47-18t-21-46q0-30 19-49t47-21l2 0z m166 328q26 34 26 78 0 78-54 116-52 38-134 38-64 0-104-26-68-42-72-146l0-4 110 0 0 4q0 26 16 54 16 24 54 24 40 0 52-20 16-20 16-44 0-18-16-40-8-12-20-20l-6-4q-6-4-16-11t-20-15-21-17-17-17q-14-20-18-78l0-8 108 0 0 4q0 12 4 28 6 20 28 36l28 18q46 34 56 50z" horiz-adv-x="920" />
 | 
			
		||||
 | 
			
		||||
<glyph glyph-name="attention" unicode="" d="M0 350q0 95 37 182t100 149 149 100 183 37q95 0 181-37t150-100 100-149 37-182q0-95-37-182t-100-150-150-100-181-37q-96 0-183 37t-149 100-100 150-37 182z m387 196l27-244q2-21 17-35t36-17q24-3 43 12t21 40l27 244v17q-4 35-31 58t-63 19-58-31-19-63z m12-411q0-30 22-52t51-21 52 21 22 52-22 52q-20 20-52 20t-51-20q-22-22-22-52z" horiz-adv-x="937.5" />
 | 
			
		||||
 | 
			
		||||
<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="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="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-open-1" unicode="" d="M564 422l-234-224q-18-18-40-18t-40 18l-234 224q-16 16-16 41t16 41q38 38 78 0l196-188 196 188q40 38 78 0 16-16 16-41t-16-41z" horiz-adv-x="580" />
 | 
			
		||||
 | 
			
		||||
<glyph glyph-name="picture-1" 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="lock-circled" 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="down-open" 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="info-circled" unicode="" d="M454 810q190 2 326-130t140-322q2-190-131-327t-323-141q-190-2-327 131t-139 323q-4 190 130 327t324 139z m52-152q-42 0-65-24t-23-50q-2-28 15-44t49-16q38 0 61 22t23 54q0 58-60 58z m-120-594q30 0 84 26t106 78l-18 24q-48-36-72-36-14 0-4 38l42 160q26 96-22 96-30 0-89-29t-115-75l16-26q52 34 74 34 12 0 0-34l-36-152q-26-104 34-104z" horiz-adv-x="920" />
 | 
			
		||||
 | 
			
		||||
<glyph glyph-name="retweet-1" 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="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="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="ok-circled" unicode="" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m182-57l105-105 104-104 103 104 324 324-103 104-324-325-106 106z" horiz-adv-x="1000" />
 | 
			
		||||
 | 
			
		||||
<glyph glyph-name="cog-2" unicode="" d="M0 272l0 156 150 16q14 45 38 88l-96 117 109 109 117-95q41 23 88 37l16 150 156 0 16-150q45-14 88-37l117 95 109-109-96-117q24-43 38-88l150-16 0-156-150-16q-14-47-38-88l96-117-109-109-117 96q-43-24-88-38l-16-150-156 0-16 150q-47 14-88 38l-117-96-109 109 96 117q-24 41-38 88z m355 78q0-60 42-102t103-42 103 42 42 102-42 103-103 42-103-42-42-103z" horiz-adv-x="1000" />
 | 
			
		||||
 | 
			
		||||
<glyph glyph-name="thumbs-up-alt" unicode="" d="M143 100q0 15-11 25t-25 11q-15 0-25-11t-11-25q0-15 11-25t25-11q15 0 25 11t11 25z m89 286v-357q0-15-10-25t-26-11h-160q-15 0-25 11t-11 25v357q0 14 11 25t25 10h160q15 0 26-10t10-25z m661 0q0-48-31-83 9-25 9-43 1-42-24-76 9-31 0-66-9-31-31-52 5-62-27-101-36-43-110-44h-72q-37 0-80 9t-68 16-67 22q-69 24-88 25-15 0-25 11t-11 25v357q0 14 10 25t24 11q13 1 42 33t57 67q38 49 56 67 10 10 17 27t10 27 8 34q4 22 7 34t11 29 19 28q10 11 25 11 25 0 46-6t33-15 22-22 14-25 7-28 2-25 1-22q0-21-6-43t-10-33-16-31q-1-4-5-10t-6-13-5-13h155q43 0 75-32t32-75z" horiz-adv-x="928.6" />
 | 
			
		||||
</font>
 | 
			
		||||
</defs>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 6.7 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								public/fonts/fontello.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/fonts/fontello.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/fonts/fontello.woff
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/fonts/fontello.woff
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/fonts/fontello.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/fonts/fontello.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										16
									
								
								src/api.nim
									
										
									
									
									
								
							
							
						
						
									
										16
									
								
								src/api.nim
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -52,10 +52,10 @@ macro genMediaGet(media: untyped; token=false) =
 | 
			
		|||
      var futs: seq[Future[void]]
 | 
			
		||||
      when `token`:
 | 
			
		||||
        var token = await getGuestToken(agent)
 | 
			
		||||
        futs.add `single`(convo.tweet, token, agent)
 | 
			
		||||
        futs.add `multi`(convo.before, token, agent)
 | 
			
		||||
        futs.add `multi`(convo.after, token, agent)
 | 
			
		||||
        futs.add convo.replies.mapIt(`multi`(it, token, agent))
 | 
			
		||||
        futs.add `single`(convo.tweet, agent, token)
 | 
			
		||||
        futs.add `multi`(convo.before, agent, token=token)
 | 
			
		||||
        futs.add `multi`(convo.after, agent, token=token)
 | 
			
		||||
        futs.add convo.replies.mapIt(`multi`(it, agent, token=token))
 | 
			
		||||
      else:
 | 
			
		||||
        futs.add `single`(convo.tweet, agent)
 | 
			
		||||
        futs.add `multi`(convo.before, agent)
 | 
			
		||||
| 
						 | 
				
			
			@ -117,7 +117,7 @@ proc getGuestToken(agent: string; force=false): Future[string] {.async.} =
 | 
			
		|||
  result = json["guest_token"].to(string)
 | 
			
		||||
  guestToken = result
 | 
			
		||||
 | 
			
		||||
proc getVideoFetch*(tweet: Tweet; token, agent: string) {.async.} =
 | 
			
		||||
proc getVideoFetch*(tweet: Tweet; agent, token: string) {.async.} =
 | 
			
		||||
  if tweet.video.isNone(): return
 | 
			
		||||
 | 
			
		||||
  let headers = newHttpHeaders({
 | 
			
		||||
| 
						 | 
				
			
			@ -135,7 +135,7 @@ proc getVideoFetch*(tweet: Tweet; token, agent: string) {.async.} =
 | 
			
		|||
    if getTime() - tokenUpdated > initDuration(seconds=1):
 | 
			
		||||
      tokenUpdated = getTime()
 | 
			
		||||
      discard await getGuestToken(agent, force=true)
 | 
			
		||||
    await getVideoFetch(tweet, guestToken, agent)
 | 
			
		||||
    await getVideoFetch(tweet, agent, guestToken)
 | 
			
		||||
    return
 | 
			
		||||
 | 
			
		||||
  if tweet.card.isNone:
 | 
			
		||||
| 
						 | 
				
			
			@ -151,12 +151,12 @@ proc getVideoVar*(tweet: Tweet): var Option[Video] =
 | 
			
		|||
  else:
 | 
			
		||||
    return tweet.video
 | 
			
		||||
 | 
			
		||||
proc getVideo*(tweet: Tweet; token, agent: string; force=false) {.async.} =
 | 
			
		||||
proc getVideo*(tweet: Tweet; agent, token: string; force=false) {.async.} =
 | 
			
		||||
  withDb:
 | 
			
		||||
    try:
 | 
			
		||||
      getVideoVar(tweet) = some(Video.getOne("videoId = ?", tweet.id))
 | 
			
		||||
    except KeyError:
 | 
			
		||||
      await getVideoFetch(tweet, token, agent)
 | 
			
		||||
      await getVideoFetch(tweet, agent, token)
 | 
			
		||||
      var video = getVideoVar(tweet)
 | 
			
		||||
      if video.isSome():
 | 
			
		||||
        get(video).insert()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import asyncdispatch, times
 | 
			
		||||
import types, api
 | 
			
		||||
 | 
			
		||||
withDb:
 | 
			
		||||
withCustomDb("cache.db", "", "", ""):
 | 
			
		||||
  try:
 | 
			
		||||
    createTables()
 | 
			
		||||
  except DbError:
 | 
			
		||||
| 
						 | 
				
			
			@ -13,7 +13,7 @@ proc isOutdated*(profile: Profile): bool =
 | 
			
		|||
  getTime() - profile.updated > profileCacheTime
 | 
			
		||||
 | 
			
		||||
proc cache*(profile: var Profile) =
 | 
			
		||||
  withDb:
 | 
			
		||||
  withCustomDb("cache.db", "", "", ""):
 | 
			
		||||
    try:
 | 
			
		||||
      let p = Profile.getOne("lower(username) = ?", toLower(profile.username))
 | 
			
		||||
      profile.id = p.id
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ proc cache*(profile: var Profile) =
 | 
			
		|||
        profile.insert()
 | 
			
		||||
 | 
			
		||||
proc hasCachedProfile*(username: string): Option[Profile] =
 | 
			
		||||
  withDb:
 | 
			
		||||
  withCustomDb("cache.db", "", "", ""):
 | 
			
		||||
    try:
 | 
			
		||||
      let p = Profile.getOne("lower(username) = ?", toLower(username))
 | 
			
		||||
      doAssert not p.isOutdated
 | 
			
		||||
| 
						 | 
				
			
			@ -32,7 +32,7 @@ proc hasCachedProfile*(username: string): Option[Profile] =
 | 
			
		|||
      result = none(Profile)
 | 
			
		||||
 | 
			
		||||
proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
 | 
			
		||||
  withDb:
 | 
			
		||||
  withCustomDb("cache.db", "", "", ""):
 | 
			
		||||
    try:
 | 
			
		||||
      result.getOne("lower(username) = ?", toLower(username))
 | 
			
		||||
      doAssert not result.isOutdated
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import parsecfg except Config
 | 
			
		||||
import os, net, types, strutils
 | 
			
		||||
import net, types, strutils
 | 
			
		||||
 | 
			
		||||
proc get[T](config: parseCfg.Config; s, v: string; default: T): T =
 | 
			
		||||
  let val = config.getSectionValue(s, v)
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +15,7 @@ proc getConfig*(path: string): Config =
 | 
			
		|||
  Config(
 | 
			
		||||
    address: cfg.get("Server", "address", "0.0.0.0"),
 | 
			
		||||
    port: cfg.get("Server", "port", 8080),
 | 
			
		||||
    useHttps: cfg.get("Server", "https", true),
 | 
			
		||||
    title: cfg.get("Server", "title", "Nitter"),
 | 
			
		||||
    staticDir: cfg.get("Server", "staticDir", "./public"),
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,8 @@ const
 | 
			
		|||
  usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
 | 
			
		||||
  picRegex = re"pic.twitter.com/[^ ]+"
 | 
			
		||||
  ellipsisRegex = re" ?…"
 | 
			
		||||
  ytRegex = re"(www.)?youtu(be.com|.be)"
 | 
			
		||||
  twRegex = re"(www.)?twitter.com"
 | 
			
		||||
  nbsp = $Rune(0x000A0)
 | 
			
		||||
 | 
			
		||||
proc stripText*(text: string): string =
 | 
			
		||||
| 
						 | 
				
			
			@ -46,7 +48,7 @@ proc reUsernameToLink*(m: RegexMatch; s: string): string =
 | 
			
		|||
 | 
			
		||||
  pretext & toLink("/" & username, "@" & username)
 | 
			
		||||
 | 
			
		||||
proc linkifyText*(text: string): string =
 | 
			
		||||
proc linkifyText*(text: string; prefs: Prefs): string =
 | 
			
		||||
  result = xmltree.escape(stripText(text))
 | 
			
		||||
  result = result.replace(ellipsisRegex, "")
 | 
			
		||||
  result = result.replace(emailRegex, reEmailToLink)
 | 
			
		||||
| 
						 | 
				
			
			@ -55,6 +57,17 @@ proc linkifyText*(text: string): string =
 | 
			
		|||
  result = result.replace(re"([^\s\(\n%])<a", "$1 <a")
 | 
			
		||||
  result = result.replace(re"</a>\s+([;.,!\)'%]|')", "</a>$1")
 | 
			
		||||
  result = result.replace(re"^\. <a", ".<a")
 | 
			
		||||
  if prefs.replaceYouTube.len > 0:
 | 
			
		||||
    result = result.replace(ytRegex, prefs.replaceYouTube)
 | 
			
		||||
  if prefs.replaceTwitter.len > 0:
 | 
			
		||||
    result = result.replace(twRegex, prefs.replaceTwitter)
 | 
			
		||||
 | 
			
		||||
proc replaceUrl*(url: string; prefs: Prefs): string =
 | 
			
		||||
  result = url
 | 
			
		||||
  if prefs.replaceYouTube.len > 0:
 | 
			
		||||
    result = result.replace(ytRegex, prefs.replaceYouTube)
 | 
			
		||||
  if prefs.replaceTwitter.len > 0:
 | 
			
		||||
    result = result.replace(twRegex, prefs.replaceTwitter)
 | 
			
		||||
 | 
			
		||||
proc stripTwitterUrls*(text: string): string =
 | 
			
		||||
  result = text
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,15 +1,17 @@
 | 
			
		|||
import asyncdispatch, asyncfile, httpclient, sequtils, strutils, strformat, uri, os
 | 
			
		||||
import asyncdispatch, asyncfile, httpclient, uri, os
 | 
			
		||||
import sequtils, strformat, strutils
 | 
			
		||||
from net import Port
 | 
			
		||||
 | 
			
		||||
import jester, regex
 | 
			
		||||
 | 
			
		||||
import api, utils, types, cache, formatters, search, config, agents
 | 
			
		||||
import views/[general, profile, status]
 | 
			
		||||
import api, utils, types, cache, formatters, search, config, prefs, agents
 | 
			
		||||
import views/[general, profile, status, preferences]
 | 
			
		||||
 | 
			
		||||
const configPath {.strdefine.} = "./nitter.conf"
 | 
			
		||||
let cfg = getConfig(configPath)
 | 
			
		||||
 | 
			
		||||
proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Future[string] {.async.} =
 | 
			
		||||
proc showSingleTimeline(name, after, agent: string; query: Option[Query];
 | 
			
		||||
                        prefs: Prefs): Future[string] {.async.} =
 | 
			
		||||
  let railFut = getPhotoRail(name, agent)
 | 
			
		||||
 | 
			
		||||
  var timeline: Timeline
 | 
			
		||||
| 
						 | 
				
			
			@ -34,33 +36,40 @@ proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Futur
 | 
			
		|||
  if profile.username.len == 0:
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
  let profileHtml = renderProfile(profile, timeline, await railFut)
 | 
			
		||||
  return renderMain(profileHtml, title=cfg.title, titleText=pageTitle(profile), desc=pageDesc(profile))
 | 
			
		||||
  let profileHtml = renderProfile(profile, timeline, await railFut, prefs)
 | 
			
		||||
  return renderMain(profileHtml, cfg.title, pageTitle(profile), pageDesc(profile))
 | 
			
		||||
 | 
			
		||||
proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query]): Future[string] {.async.} =
 | 
			
		||||
proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query];
 | 
			
		||||
                       prefs: Prefs): Future[string] {.async.} =
 | 
			
		||||
  var q = query
 | 
			
		||||
  if q.isSome:
 | 
			
		||||
    get(q).fromUser = names
 | 
			
		||||
  else:
 | 
			
		||||
    q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
 | 
			
		||||
 | 
			
		||||
  var timeline = renderMulti(await getTimelineSearch(get(q), after, agent), names.join(","))
 | 
			
		||||
  return renderMain(timeline, title=cfg.title, titleText="Multi")
 | 
			
		||||
  var timeline = renderMulti(await getTimelineSearch(get(q), after, agent),
 | 
			
		||||
                             names.join(","), prefs)
 | 
			
		||||
 | 
			
		||||
proc showTimeline(name, after: string; query: Option[Query]): Future[string] {.async.} =
 | 
			
		||||
  return renderMain(timeline, cfg.title, "Multi")
 | 
			
		||||
 | 
			
		||||
proc showTimeline(name, after: string; query: Option[Query];
 | 
			
		||||
                  prefs: Prefs): Future[string] {.async.} =
 | 
			
		||||
  let agent = getAgent()
 | 
			
		||||
  let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
 | 
			
		||||
 | 
			
		||||
  if names.len == 1:
 | 
			
		||||
    return await showSingleTimeline(names[0], after, agent, query)
 | 
			
		||||
    return await showSingleTimeline(names[0], after, agent, query, prefs)
 | 
			
		||||
  else:
 | 
			
		||||
    return await showMultiTimeline(names, after, agent, query)
 | 
			
		||||
    return await showMultiTimeline(names, after, agent, query, prefs)
 | 
			
		||||
 | 
			
		||||
template respTimeline(timeline: typed) =
 | 
			
		||||
  if timeline.len == 0:
 | 
			
		||||
    resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title)
 | 
			
		||||
  resp timeline
 | 
			
		||||
 | 
			
		||||
template cookiePrefs(): untyped {.dirty.} =
 | 
			
		||||
  getPrefs(request.cookies.getOrDefault("preferences"))
 | 
			
		||||
 | 
			
		||||
setProfileCacheTime(cfg.profileCacheTime)
 | 
			
		||||
 | 
			
		||||
settings:
 | 
			
		||||
| 
						 | 
				
			
			@ -70,32 +79,56 @@ settings:
 | 
			
		|||
 | 
			
		||||
routes:
 | 
			
		||||
  get "/":
 | 
			
		||||
    resp renderMain(renderSearch(), title=cfg.title)
 | 
			
		||||
    resp renderMain(renderSearch(), cfg.title)
 | 
			
		||||
 | 
			
		||||
  post "/search":
 | 
			
		||||
    if @"query".len == 0:
 | 
			
		||||
      resp Http404, showError("Please enter a username.", cfg.title)
 | 
			
		||||
    redirect("/" & @"query")
 | 
			
		||||
 | 
			
		||||
  post "/saveprefs":
 | 
			
		||||
    var prefs = cookiePrefs()
 | 
			
		||||
    genUpdatePrefs()
 | 
			
		||||
    setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps)
 | 
			
		||||
    redirect(decodeUrl(@"referer"))
 | 
			
		||||
 | 
			
		||||
  post "/resetprefs":
 | 
			
		||||
    var prefs = cookiePrefs()
 | 
			
		||||
    resetPrefs(prefs)
 | 
			
		||||
    setCookie("preferences", $prefs.id, daysForward(360), httpOnly=true, secure=cfg.useHttps)
 | 
			
		||||
    redirect("/settings")
 | 
			
		||||
 | 
			
		||||
  get "/settings":
 | 
			
		||||
    let refUri = request.headers.getOrDefault("Referer").parseUri()
 | 
			
		||||
    var path =
 | 
			
		||||
      if refUri.path.len > 0 and "/settings" notin refUri.path: refUri.path
 | 
			
		||||
      else: "/"
 | 
			
		||||
    if refUri.query.len > 0: path &= &"?{refUri.query}"
 | 
			
		||||
    resp renderMain(renderPreferences(cookiePrefs(), path), cfg.title, "Preferences")
 | 
			
		||||
 | 
			
		||||
  get "/@name/?":
 | 
			
		||||
    cond '.' notin @"name"
 | 
			
		||||
    respTimeline(await showTimeline(@"name", @"after", none(Query)))
 | 
			
		||||
    respTimeline(await showTimeline(@"name", @"after", none(Query), cookiePrefs()))
 | 
			
		||||
 | 
			
		||||
  get "/@name/search":
 | 
			
		||||
    cond '.' notin @"name"
 | 
			
		||||
    let prefs = cookiePrefs()
 | 
			
		||||
    let query = initQuery(@"filter", @"include", @"not", @"sep", @"name")
 | 
			
		||||
    respTimeline(await showTimeline(@"name", @"after", some(query)))
 | 
			
		||||
    respTimeline(await showTimeline(@"name", @"after", some(query), cookiePrefs()))
 | 
			
		||||
 | 
			
		||||
  get "/@name/replies":
 | 
			
		||||
    cond '.' notin @"name"
 | 
			
		||||
    respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name"))))
 | 
			
		||||
    let prefs = cookiePrefs()
 | 
			
		||||
    respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")), cookiePrefs()))
 | 
			
		||||
 | 
			
		||||
  get "/@name/media":
 | 
			
		||||
    cond '.' notin @"name"
 | 
			
		||||
    respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name"))))
 | 
			
		||||
    let prefs = cookiePrefs()
 | 
			
		||||
    respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")), cookiePrefs()))
 | 
			
		||||
 | 
			
		||||
  get "/@name/status/@id":
 | 
			
		||||
    cond '.' notin @"name"
 | 
			
		||||
    let prefs = cookiePrefs()
 | 
			
		||||
 | 
			
		||||
    let conversation = await getTweet(@"name", @"id", getAgent())
 | 
			
		||||
    if conversation == nil or conversation.tweet.id.len == 0:
 | 
			
		||||
| 
						 | 
				
			
			@ -103,26 +136,24 @@ routes:
 | 
			
		|||
 | 
			
		||||
    let title = pageTitle(conversation.tweet.profile)
 | 
			
		||||
    let desc = conversation.tweet.text
 | 
			
		||||
    let html = renderConversation(conversation)
 | 
			
		||||
    let html = renderConversation(conversation, prefs)
 | 
			
		||||
 | 
			
		||||
    if conversation.tweet.video.isSome():
 | 
			
		||||
      let thumb = get(conversation.tweet.video).thumb
 | 
			
		||||
      let vidUrl = getVideoEmbed(conversation.tweet.id)
 | 
			
		||||
      resp renderMain(html, title=cfg.title, titleText=title, desc=desc,
 | 
			
		||||
                      images = @[thumb], `type`="video", video=vidUrl)
 | 
			
		||||
      resp renderMain(html, cfg.title, title, desc, images = @[thumb],
 | 
			
		||||
                      `type`="video", video=vidUrl)
 | 
			
		||||
    elif conversation.tweet.gif.isSome():
 | 
			
		||||
      let thumb = get(conversation.tweet.gif).thumb
 | 
			
		||||
      let vidUrl = getVideoEmbed(conversation.tweet.id)
 | 
			
		||||
      resp renderMain(html, title=cfg.title, titleText=title, desc=desc,
 | 
			
		||||
                      images = @[thumb], `type`="video", video=vidUrl)
 | 
			
		||||
      resp renderMain(html, cfg.title, title, desc, images = @[thumb],
 | 
			
		||||
                      `type`="video", video=vidUrl)
 | 
			
		||||
    else:
 | 
			
		||||
      resp renderMain(html, title=cfg.title, titleText=title,
 | 
			
		||||
                      desc=desc, images=conversation.tweet.photos)
 | 
			
		||||
      resp renderMain(html, cfg.title, title, desc, images=conversation.tweet.photos)
 | 
			
		||||
 | 
			
		||||
  get "/pic/@sig/@url":
 | 
			
		||||
    cond "http" in @"url"
 | 
			
		||||
    cond "twimg" in @"url"
 | 
			
		||||
 | 
			
		||||
    let
 | 
			
		||||
      uri = parseUri(decodeUrl(@"url"))
 | 
			
		||||
      path = uri.path.split("/")[2 .. ^1].join("/")
 | 
			
		||||
| 
						 | 
				
			
			@ -156,11 +187,10 @@ routes:
 | 
			
		|||
    if getHmac(url) != @"sig":
 | 
			
		||||
      resp showError("Failed to verify signature", cfg.title)
 | 
			
		||||
 | 
			
		||||
    let
 | 
			
		||||
      client = newAsyncHttpClient()
 | 
			
		||||
      video = await client.getContent(url)
 | 
			
		||||
    let client = newAsyncHttpClient()
 | 
			
		||||
    let video = await client.getContent(url)
 | 
			
		||||
    client.close()
 | 
			
		||||
 | 
			
		||||
    defer: client.close()
 | 
			
		||||
    resp video, mimetype(url)
 | 
			
		||||
 | 
			
		||||
runForever()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										47
									
								
								src/prefs.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/prefs.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,47 @@
 | 
			
		|||
import sequtils, macros
 | 
			
		||||
import types
 | 
			
		||||
import prefs_impl
 | 
			
		||||
 | 
			
		||||
export genUpdatePrefs
 | 
			
		||||
 | 
			
		||||
static:
 | 
			
		||||
  var pFields: seq[string]
 | 
			
		||||
  for id in getTypeImpl(Prefs)[2]:
 | 
			
		||||
    if $id[0] == "id": continue
 | 
			
		||||
    pFields.add $id[0]
 | 
			
		||||
 | 
			
		||||
  let pDefs = toSeq(allPrefs()).mapIt(it.name)
 | 
			
		||||
  let missing = pDefs.filterIt(it notin pFields)
 | 
			
		||||
  if missing.len > 0:
 | 
			
		||||
    raiseAssert("{$1} missing from the Prefs type" % missing.join(", "))
 | 
			
		||||
 | 
			
		||||
withCustomDb("prefs.db", "", "", ""):
 | 
			
		||||
  try:
 | 
			
		||||
    createTables()
 | 
			
		||||
  except DbError:
 | 
			
		||||
    discard
 | 
			
		||||
 | 
			
		||||
proc cache*(prefs: var Prefs) =
 | 
			
		||||
  withCustomDb("prefs.db", "", "", ""):
 | 
			
		||||
    try:
 | 
			
		||||
      doAssert prefs.id != 0
 | 
			
		||||
      discard Prefs.getOne("id = ?", prefs.id)
 | 
			
		||||
      prefs.update()
 | 
			
		||||
    except AssertionError, KeyError:
 | 
			
		||||
      prefs.insert()
 | 
			
		||||
 | 
			
		||||
proc getPrefs*(id: string): Prefs =
 | 
			
		||||
  if id.len == 0: return genDefaultPrefs()
 | 
			
		||||
 | 
			
		||||
  withCustomDb("prefs.db", "", "", ""):
 | 
			
		||||
    try:
 | 
			
		||||
      result.getOne("id = ?", id)
 | 
			
		||||
    except KeyError:
 | 
			
		||||
      result = genDefaultPrefs()
 | 
			
		||||
      cache(result)
 | 
			
		||||
 | 
			
		||||
proc resetPrefs*(prefs: var Prefs) =
 | 
			
		||||
  var defPrefs = genDefaultPrefs()
 | 
			
		||||
  defPrefs.id = prefs.id
 | 
			
		||||
  cache(defPrefs)
 | 
			
		||||
  prefs = defPrefs
 | 
			
		||||
							
								
								
									
										103
									
								
								src/prefs_impl.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/prefs_impl.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,103 @@
 | 
			
		|||
import macros, tables, strutils, xmltree
 | 
			
		||||
 | 
			
		||||
const hostname {.strdefine.} = "nitter.net"
 | 
			
		||||
 | 
			
		||||
type
 | 
			
		||||
  PrefKind* = enum
 | 
			
		||||
    checkbox, select, input
 | 
			
		||||
 | 
			
		||||
  Pref* = object
 | 
			
		||||
    name*: string
 | 
			
		||||
    label*: string
 | 
			
		||||
    case kind*: PrefKind
 | 
			
		||||
    of checkbox:
 | 
			
		||||
      defaultState*: bool
 | 
			
		||||
    of select:
 | 
			
		||||
      defaultOption*: string
 | 
			
		||||
      options*: seq[string]
 | 
			
		||||
    of input:
 | 
			
		||||
      defaultInput*: string
 | 
			
		||||
      placeholder*: string
 | 
			
		||||
 | 
			
		||||
# TODO: write DSL to simplify this
 | 
			
		||||
const prefList*: Table[string, seq[Pref]] = {
 | 
			
		||||
  "Privacy": @[
 | 
			
		||||
    Pref(kind: input, name: "replaceTwitter",
 | 
			
		||||
         label: "Replace Twitter links with Nitter (blank to disable)",
 | 
			
		||||
         defaultInput: hostname, placeholder: "Nitter hostname"),
 | 
			
		||||
 | 
			
		||||
    Pref(kind: input, name: "replaceYouTube",
 | 
			
		||||
         label: "Replace YouTube links with Invidious (blank to disable)",
 | 
			
		||||
         defaultInput: "invidio.us", placeholder: "Invidious hostname")
 | 
			
		||||
  ],
 | 
			
		||||
 | 
			
		||||
  "Media": @[
 | 
			
		||||
    Pref(kind: checkbox, name: "mp4Playback",
 | 
			
		||||
        label: "Enable mp4 video playback",
 | 
			
		||||
        defaultState: true),
 | 
			
		||||
 | 
			
		||||
    Pref(kind: checkbox, name: "hlsPlayback",
 | 
			
		||||
         label: "Enable hls video streaming (requires JavaScript)",
 | 
			
		||||
         defaultState: false),
 | 
			
		||||
 | 
			
		||||
    Pref(kind: checkbox, name: "muteVideos",
 | 
			
		||||
         label: "Mute videos by default",
 | 
			
		||||
         defaultState: false),
 | 
			
		||||
 | 
			
		||||
    Pref(kind: checkbox, name: "autoplayGifs", label: "Autoplay gifs",
 | 
			
		||||
         defaultState: true)
 | 
			
		||||
  ],
 | 
			
		||||
 | 
			
		||||
  "Display": @[
 | 
			
		||||
    Pref(kind: checkbox, name: "hideTweetStats",
 | 
			
		||||
         label: "Hide tweet stats (replies, retweets, likes)",
 | 
			
		||||
         defaultState: false),
 | 
			
		||||
 | 
			
		||||
    Pref(kind: checkbox, name: "hideBanner", label: "Hide profile banner",
 | 
			
		||||
         defaultState: false),
 | 
			
		||||
 | 
			
		||||
    Pref(kind: checkbox, name: "stickyProfile",
 | 
			
		||||
         label: "Make profile sidebar stick to top",
 | 
			
		||||
         defaultState: true)
 | 
			
		||||
  ]
 | 
			
		||||
}.toTable
 | 
			
		||||
 | 
			
		||||
iterator allPrefs*(): Pref =
 | 
			
		||||
  for k, v in prefList:
 | 
			
		||||
    for pref in v:
 | 
			
		||||
      yield pref
 | 
			
		||||
 | 
			
		||||
macro genDefaultPrefs*(): untyped =
 | 
			
		||||
  result = nnkObjConstr.newTree(ident("Prefs"))
 | 
			
		||||
 | 
			
		||||
  for pref in allPrefs():
 | 
			
		||||
    let default =
 | 
			
		||||
      case pref.kind
 | 
			
		||||
      of checkbox: newLit(pref.defaultState)
 | 
			
		||||
      of select: newLit(pref.defaultOption)
 | 
			
		||||
      of input: newLit(pref.defaultInput)
 | 
			
		||||
 | 
			
		||||
    result.add nnkExprColonExpr.newTree(ident(pref.name), default)
 | 
			
		||||
 | 
			
		||||
macro genUpdatePrefs*(): untyped =
 | 
			
		||||
  result = nnkStmtList.newTree()
 | 
			
		||||
 | 
			
		||||
  for pref in allPrefs():
 | 
			
		||||
    let ident = ident(pref.name)
 | 
			
		||||
    let value = nnkPrefix.newTree(ident("@"), newLit(pref.name))
 | 
			
		||||
 | 
			
		||||
    case pref.kind
 | 
			
		||||
    of checkbox:
 | 
			
		||||
      result.add quote do: prefs.`ident` = `value` == "on"
 | 
			
		||||
    of input:
 | 
			
		||||
      result.add quote do: prefs.`ident` = xmltree.escape(strip(`value`))
 | 
			
		||||
    of select:
 | 
			
		||||
      let options = pref.options
 | 
			
		||||
      let default = pref.defaultOption
 | 
			
		||||
      result.add quote do:
 | 
			
		||||
        if `value` in `options`: prefs.`ident` = `value`
 | 
			
		||||
        else: prefs.`ident` = `default`
 | 
			
		||||
 | 
			
		||||
  result.add quote do:
 | 
			
		||||
    cache(prefs)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
import times, sequtils, options
 | 
			
		||||
import norm/sqlite
 | 
			
		||||
import prefs_impl
 | 
			
		||||
 | 
			
		||||
export sqlite, options
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -22,25 +23,17 @@ db("cache.db", "", "", ""):
 | 
			
		|||
      tweets*: string
 | 
			
		||||
      likes*: string
 | 
			
		||||
      media*: string
 | 
			
		||||
      verified* {.
 | 
			
		||||
          dbType: "STRING",
 | 
			
		||||
          parseIt: parseBool(it.s)
 | 
			
		||||
          formatIt: $it
 | 
			
		||||
        .}: bool
 | 
			
		||||
      protected* {.
 | 
			
		||||
          dbType: "STRING",
 | 
			
		||||
          parseIt: parseBool(it.s)
 | 
			
		||||
          formatIt: $it
 | 
			
		||||
        .}: bool
 | 
			
		||||
      verified*: bool
 | 
			
		||||
      protected*: bool
 | 
			
		||||
      joinDate* {.
 | 
			
		||||
        dbType: "INTEGER",
 | 
			
		||||
        parseIt: it.i.fromUnix(),
 | 
			
		||||
        formatIt: it.toUnix()
 | 
			
		||||
        dbType: "INTEGER"
 | 
			
		||||
        parseIt: it.i.fromUnix()
 | 
			
		||||
        formatIt: dbValue(it.toUnix())
 | 
			
		||||
        .}: Time
 | 
			
		||||
      updated* {.
 | 
			
		||||
          dbType: "INTEGER",
 | 
			
		||||
          parseIt: it.i.fromUnix(),
 | 
			
		||||
          formatIt: getTime().toUnix()
 | 
			
		||||
          dbType: "INTEGER"
 | 
			
		||||
          parseIt: it.i.fromUnix()
 | 
			
		||||
          formatIt: dbValue(getTime().toUnix())
 | 
			
		||||
        .}: Time
 | 
			
		||||
 | 
			
		||||
    Video* = object
 | 
			
		||||
| 
						 | 
				
			
			@ -50,16 +43,23 @@ db("cache.db", "", "", ""):
 | 
			
		|||
      url*: string
 | 
			
		||||
      thumb*: string
 | 
			
		||||
      views*: string
 | 
			
		||||
      available*: bool
 | 
			
		||||
      playbackType* {.
 | 
			
		||||
          dbType: "STRING",
 | 
			
		||||
          parseIt: parseEnum[VideoType](it.s),
 | 
			
		||||
          formatIt: $it,
 | 
			
		||||
          dbType: "STRING"
 | 
			
		||||
          parseIt: parseEnum[VideoType](it.s)
 | 
			
		||||
          formatIt: dbValue($it)
 | 
			
		||||
        .}: VideoType
 | 
			
		||||
      available* {.
 | 
			
		||||
          dbType: "STRING",
 | 
			
		||||
          parseIt: parseBool(it.s)
 | 
			
		||||
          formatIt: $it
 | 
			
		||||
        .}: bool
 | 
			
		||||
 | 
			
		||||
    Prefs* = object
 | 
			
		||||
      hlsPlayback*: bool
 | 
			
		||||
      mp4Playback*: bool
 | 
			
		||||
      muteVideos*: bool
 | 
			
		||||
      autoplayGifs*: bool
 | 
			
		||||
      hideTweetStats*: bool
 | 
			
		||||
      hideBanner*: bool
 | 
			
		||||
      stickyProfile*: bool
 | 
			
		||||
      replaceYouTube*: string
 | 
			
		||||
      replaceTwitter*: string
 | 
			
		||||
 | 
			
		||||
type
 | 
			
		||||
  QueryKind* = enum
 | 
			
		||||
| 
						 | 
				
			
			@ -169,6 +169,7 @@ type
 | 
			
		|||
  Config* = ref object
 | 
			
		||||
    address*: string
 | 
			
		||||
    port*: int
 | 
			
		||||
    useHttps*: bool
 | 
			
		||||
    title*: string
 | 
			
		||||
    staticDir*: string
 | 
			
		||||
    cacheDir*: string
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import karax/[karaxdsl, vdom]
 | 
			
		||||
 | 
			
		||||
import ../utils
 | 
			
		||||
import renderutils
 | 
			
		||||
import ../utils, ../types
 | 
			
		||||
 | 
			
		||||
const doctype = "<!DOCTYPE html>\n"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -13,14 +14,15 @@ proc renderNavbar*(title: string): VNode =
 | 
			
		|||
      a(href="/"): img(class="site-logo", src="/logo.png")
 | 
			
		||||
 | 
			
		||||
      tdiv(class="item right"):
 | 
			
		||||
        a(class="site-about", href="/about"): text "🛈"
 | 
			
		||||
        a(class="site-settings", href="/settings"): text "⚙"
 | 
			
		||||
        icon "info-circled", title="About", href="/about"
 | 
			
		||||
        icon "cog-2", title="Preferences", href="/settings"
 | 
			
		||||
 | 
			
		||||
proc renderMain*(body: VNode; title="Nitter"; titleText=""; desc="";
 | 
			
		||||
                 `type`="article"; video=""; images: seq[string] = @[]): string =
 | 
			
		||||
  let node = buildHtml(html(lang="en")):
 | 
			
		||||
    head:
 | 
			
		||||
      link(rel="stylesheet", `type`="text/css", href="/style.css")
 | 
			
		||||
      link(rel="stylesheet", `type`="text/css", href="/css/style.css")
 | 
			
		||||
      link(rel="stylesheet", `type`="text/css", href="/css/fontello.css")
 | 
			
		||||
 | 
			
		||||
      title:
 | 
			
		||||
        if titleText.len > 0:
 | 
			
		||||
| 
						 | 
				
			
			@ -53,12 +55,12 @@ proc renderSearch*(): VNode =
 | 
			
		|||
    tdiv(class="search-panel"):
 | 
			
		||||
      form(`method`="post", action="search"):
 | 
			
		||||
        input(`type`="text", name="query", autofocus="", placeholder="Enter usernames...")
 | 
			
		||||
        button(`type`="submit"): text "🔎"
 | 
			
		||||
        button(`type`="submit"): icon "search"
 | 
			
		||||
 | 
			
		||||
proc renderError*(error: string): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="panel")):
 | 
			
		||||
    tdiv(class="error-panel"):
 | 
			
		||||
      span: text error
 | 
			
		||||
 | 
			
		||||
proc showError*(error: string; title: string): string =
 | 
			
		||||
  renderMain(renderError(error), title=title, titleText="Error")
 | 
			
		||||
proc showError*(error, title: string): string =
 | 
			
		||||
  renderMain(renderError(error), title, "Error")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										67
									
								
								src/views/preferences.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/views/preferences.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,67 @@
 | 
			
		|||
import tables, macros, strformat, xmltree
 | 
			
		||||
import karax/[karaxdsl, vdom, vstyles]
 | 
			
		||||
 | 
			
		||||
import ../types, ../prefs_impl
 | 
			
		||||
 | 
			
		||||
proc genCheckbox(pref, label: string; state: bool): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="pref-group")):
 | 
			
		||||
    label(class="checkbox-container"):
 | 
			
		||||
      text label
 | 
			
		||||
      if state: input(name=pref, `type`="checkbox", checked="")
 | 
			
		||||
      else: input(name=pref, `type`="checkbox")
 | 
			
		||||
      span(class="checkbox")
 | 
			
		||||
 | 
			
		||||
proc genSelect(pref, label, state: string; options: seq[string]): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="pref-group")):
 | 
			
		||||
    label(`for`=pref): text label
 | 
			
		||||
    select(name=pref):
 | 
			
		||||
      for opt in options:
 | 
			
		||||
        if opt == state:
 | 
			
		||||
          option(value=opt, selected=""): text opt
 | 
			
		||||
        else:
 | 
			
		||||
          option(value=opt): text opt
 | 
			
		||||
 | 
			
		||||
proc genInput(pref, label, state, placeholder: string): VNode =
 | 
			
		||||
  let s = xmltree.escape(state)
 | 
			
		||||
  let p = xmltree.escape(placeholder)
 | 
			
		||||
  buildHtml(tdiv(class="pref-group pref-input")):
 | 
			
		||||
    label(`for`=pref): text label
 | 
			
		||||
    verbatim &"<input name={pref} type=\"text\" placeholder=\"{p}\" value=\"{s}\"/>"
 | 
			
		||||
 | 
			
		||||
macro renderPrefs*(): untyped =
 | 
			
		||||
  result = nnkCall.newTree(
 | 
			
		||||
    ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())
 | 
			
		||||
 | 
			
		||||
  for header, options in prefList:
 | 
			
		||||
    result[2].add nnkCall.newTree(
 | 
			
		||||
      ident("legend"),
 | 
			
		||||
      nnkStmtList.newTree(
 | 
			
		||||
        nnkCommand.newTree(ident("text"), newLit(header))))
 | 
			
		||||
 | 
			
		||||
    for pref in options:
 | 
			
		||||
      let procName = ident("gen" & capitalizeAscii($pref.kind))
 | 
			
		||||
      let state = nnkDotExpr.newTree(ident("prefs"), ident(pref.name))
 | 
			
		||||
      var stmt = nnkStmtList.newTree(
 | 
			
		||||
        nnkCall.newTree(procName, newLit(pref.name), newLit(pref.label), state))
 | 
			
		||||
 | 
			
		||||
      case pref.kind
 | 
			
		||||
      of checkbox: discard
 | 
			
		||||
      of select: stmt[0].add newLit(pref.options)
 | 
			
		||||
      of input: stmt[0].add newLit(pref.placeholder)
 | 
			
		||||
 | 
			
		||||
      result[2].add stmt
 | 
			
		||||
 | 
			
		||||
proc renderPreferences*(prefs: Prefs; path: string): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="preferences-container")):
 | 
			
		||||
    fieldset(class="preferences"):
 | 
			
		||||
      form(`method`="post", action="saveprefs"):
 | 
			
		||||
        verbatim "<input name=\"referer\" style=\"display: none\" value=\"$1\"/>" % path
 | 
			
		||||
 | 
			
		||||
        renderPrefs()
 | 
			
		||||
 | 
			
		||||
        button(`type`="submit", class="pref-submit"):
 | 
			
		||||
          text "Save preferences"
 | 
			
		||||
 | 
			
		||||
      form(`method`="post", action="resetprefs", class="pref-reset"):
 | 
			
		||||
        button(`type`="submit"):
 | 
			
		||||
          text "Reset preferences"
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +1,8 @@
 | 
			
		|||
import strutils, strformat
 | 
			
		||||
import karax/[karaxdsl, vdom, vstyles]
 | 
			
		||||
 | 
			
		||||
import ../types, ../utils, ../formatters
 | 
			
		||||
import tweet, timeline, renderutils
 | 
			
		||||
import ../types, ../utils, ../formatters
 | 
			
		||||
 | 
			
		||||
proc renderStat(num, class: string; text=""): VNode =
 | 
			
		||||
  let t = if text.len > 0: text else: class
 | 
			
		||||
| 
						 | 
				
			
			@ -11,7 +11,7 @@ proc renderStat(num, class: string; text=""): VNode =
 | 
			
		|||
    span(class="profile-stat-num"):
 | 
			
		||||
      text if num.len == 0: "?" else: num
 | 
			
		||||
 | 
			
		||||
proc renderProfileCard*(profile: Profile): VNode =
 | 
			
		||||
proc renderProfileCard*(profile: Profile; prefs: Prefs): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="profile-card")):
 | 
			
		||||
    a(class="profile-card-avatar", href=profile.getUserPic().getSigUrl("pic")):
 | 
			
		||||
      genImg(profile.getUserpic("_200x200"))
 | 
			
		||||
| 
						 | 
				
			
			@ -23,21 +23,21 @@ proc renderProfileCard*(profile: Profile): VNode =
 | 
			
		|||
    tdiv(class="profile-card-extra"):
 | 
			
		||||
      if profile.bio.len > 0:
 | 
			
		||||
        tdiv(class="profile-bio"):
 | 
			
		||||
          p: verbatim linkifyText(profile.bio)
 | 
			
		||||
          p: verbatim linkifyText(profile.bio, prefs)
 | 
			
		||||
 | 
			
		||||
      if profile.location.len > 0:
 | 
			
		||||
        tdiv(class="profile-location"):
 | 
			
		||||
          span: text "📍 " & profile.location
 | 
			
		||||
          span: icon "location", profile.location
 | 
			
		||||
 | 
			
		||||
      if profile.website.len > 0:
 | 
			
		||||
        tdiv(class="profile-website"):
 | 
			
		||||
          span:
 | 
			
		||||
            text "🔗 "
 | 
			
		||||
            icon "link"
 | 
			
		||||
            linkText(profile.website)
 | 
			
		||||
 | 
			
		||||
      tdiv(class="profile-joindate"):
 | 
			
		||||
        span(title=getJoinDateFull(profile)):
 | 
			
		||||
          text "📅 " & getJoinDate(profile)
 | 
			
		||||
          icon "calendar", getJoinDate(profile)
 | 
			
		||||
 | 
			
		||||
      tdiv(class="profile-card-extra-links"):
 | 
			
		||||
        ul(class="profile-statlist"):
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +50,7 @@ proc renderPhotoRail(profile: Profile; photoRail: seq[GalleryPhoto]): VNode =
 | 
			
		|||
  buildHtml(tdiv(class="photo-rail-card")):
 | 
			
		||||
    tdiv(class="photo-rail-header"):
 | 
			
		||||
      a(href=(&"/{profile.username}/media")):
 | 
			
		||||
        text &"🖼 {profile.media} Photos and videos"
 | 
			
		||||
        icon "picture-1", $profile.media & " Photos and videos"
 | 
			
		||||
 | 
			
		||||
    tdiv(class="photo-rail-grid"):
 | 
			
		||||
      for i, photo in photoRail:
 | 
			
		||||
| 
						 | 
				
			
			@ -68,20 +68,22 @@ proc renderBanner(profile: Profile): VNode =
 | 
			
		|||
        genImg(profile.banner)
 | 
			
		||||
 | 
			
		||||
proc renderProfile*(profile: Profile; timeline: Timeline;
 | 
			
		||||
                    photoRail: seq[GalleryPhoto]): VNode =
 | 
			
		||||
                    photoRail: seq[GalleryPhoto]; prefs: Prefs): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="profile-tabs")):
 | 
			
		||||
    tdiv(class="profile-banner"):
 | 
			
		||||
      renderBanner(profile)
 | 
			
		||||
    if not prefs.hideBanner:
 | 
			
		||||
      tdiv(class="profile-banner"):
 | 
			
		||||
        renderBanner(profile)
 | 
			
		||||
 | 
			
		||||
    tdiv(class="profile-tab"):
 | 
			
		||||
      renderProfileCard(profile)
 | 
			
		||||
    let sticky = if prefs.stickyProfile: "sticky" else: "unset"
 | 
			
		||||
    tdiv(class="profile-tab", style={position: sticky}):
 | 
			
		||||
      renderProfileCard(profile, prefs)
 | 
			
		||||
      if photoRail.len > 0:
 | 
			
		||||
        renderPhotoRail(profile, photoRail)
 | 
			
		||||
 | 
			
		||||
    tdiv(class="timeline-tab"):
 | 
			
		||||
      renderTimeline(timeline, profile.username, profile.protected)
 | 
			
		||||
      renderTimeline(timeline, profile.username, profile.protected, prefs)
 | 
			
		||||
 | 
			
		||||
proc renderMulti*(timeline: Timeline; usernames: string): VNode =
 | 
			
		||||
proc renderMulti*(timeline: Timeline; usernames: string; prefs: Prefs): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="multi-timeline")):
 | 
			
		||||
    tdiv(class="timeline-tab"):
 | 
			
		||||
      renderTimeline(timeline, usernames, false, multi=true)
 | 
			
		||||
      renderTimeline(timeline, usernames, false, prefs, multi=true)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,18 @@ import karax/[karaxdsl, vdom, vstyles]
 | 
			
		|||
 | 
			
		||||
import ../types, ../utils
 | 
			
		||||
 | 
			
		||||
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
 | 
			
		||||
  var c = "icon-" & icon
 | 
			
		||||
  if class.len > 0: c = c & " " & class
 | 
			
		||||
  buildHtml(tdiv(class="icon-container")):
 | 
			
		||||
    if href.len > 0:
 | 
			
		||||
      a(class=c, title=title, href=href)
 | 
			
		||||
    else:
 | 
			
		||||
      span(class=c, title=title)
 | 
			
		||||
 | 
			
		||||
    if text.len > 0:
 | 
			
		||||
      text " " & text
 | 
			
		||||
 | 
			
		||||
proc linkUser*(profile: Profile, class=""): VNode =
 | 
			
		||||
  let
 | 
			
		||||
    isName = "username" notin class
 | 
			
		||||
| 
						 | 
				
			
			@ -12,9 +24,10 @@ proc linkUser*(profile: Profile, class=""): VNode =
 | 
			
		|||
  buildHtml(a(href=href, class=class, title=nameText)):
 | 
			
		||||
    text nameText
 | 
			
		||||
    if isName and profile.verified:
 | 
			
		||||
      span(class="icon verified-icon", title="Verified account"): text "✔"
 | 
			
		||||
      icon "ok", class="verified-icon", title="Verified account"
 | 
			
		||||
    if isName and profile.protected:
 | 
			
		||||
      span(class="icon protected-icon", title="Protected account"): text "🔒"
 | 
			
		||||
      text " "
 | 
			
		||||
      icon "lock-circled", title="Protected account"
 | 
			
		||||
 | 
			
		||||
proc genImg*(url: string; class=""): VNode =
 | 
			
		||||
  buildHtml():
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,11 +4,11 @@ import karax/[karaxdsl, vdom]
 | 
			
		|||
import ../types
 | 
			
		||||
import tweet, renderutils
 | 
			
		||||
 | 
			
		||||
proc renderReplyThread(thread: Thread): VNode =
 | 
			
		||||
proc renderReplyThread(thread: Thread; prefs: Prefs): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="reply thread thread-line")):
 | 
			
		||||
    for i, tweet in thread.tweets:
 | 
			
		||||
      let last = (i == thread.tweets.high and thread.more == 0)
 | 
			
		||||
      renderTweet(tweet, index=i, last=last)
 | 
			
		||||
      renderTweet(tweet, prefs, index=i, last=last)
 | 
			
		||||
 | 
			
		||||
    if thread.more != 0:
 | 
			
		||||
      let num = if thread.more != -1: $thread.more & " " else: ""
 | 
			
		||||
| 
						 | 
				
			
			@ -17,26 +17,26 @@ proc renderReplyThread(thread: Thread): VNode =
 | 
			
		|||
        a(class="more-replies-text", title="Not implemented yet"):
 | 
			
		||||
          text $num & "more " & reply
 | 
			
		||||
 | 
			
		||||
proc renderConversation*(conversation: Conversation): VNode =
 | 
			
		||||
proc renderConversation*(conversation: Conversation; prefs: Prefs): VNode =
 | 
			
		||||
  let hasAfter = conversation.after != nil
 | 
			
		||||
  buildHtml(tdiv(class="conversation", id="posts")):
 | 
			
		||||
    tdiv(class="main-thread"):
 | 
			
		||||
      if conversation.before != nil:
 | 
			
		||||
        tdiv(class="before-tweet thread-line"):
 | 
			
		||||
          for i, tweet in conversation.before.tweets:
 | 
			
		||||
            renderTweet(tweet, index=i)
 | 
			
		||||
            renderTweet(tweet, prefs, index=i)
 | 
			
		||||
 | 
			
		||||
      tdiv(class="main-tweet"):
 | 
			
		||||
        let afterClass = if hasAfter: "thread thread-line" else: ""
 | 
			
		||||
        renderTweet(conversation.tweet, class=afterClass)
 | 
			
		||||
        renderTweet(conversation.tweet, prefs, class=afterClass)
 | 
			
		||||
 | 
			
		||||
      if hasAfter:
 | 
			
		||||
        tdiv(class="after-tweet thread-line"):
 | 
			
		||||
          let total = conversation.after.tweets.high
 | 
			
		||||
          for i, tweet in conversation.after.tweets:
 | 
			
		||||
            renderTweet(tweet, index=i, total=total)
 | 
			
		||||
            renderTweet(tweet, prefs, index=i, total=total)
 | 
			
		||||
 | 
			
		||||
    if conversation.replies.len > 0:
 | 
			
		||||
      tdiv(class="replies"):
 | 
			
		||||
        for thread in conversation.replies:
 | 
			
		||||
          renderReplyThread(thread)
 | 
			
		||||
          renderReplyThread(thread, prefs)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,28 +54,28 @@ proc renderProtected(username: string): VNode =
 | 
			
		|||
    h2: text "This account's tweets are protected."
 | 
			
		||||
    p: text &"Only confirmed followers have access to @{username}'s tweets."
 | 
			
		||||
 | 
			
		||||
proc renderThread(thread: seq[Tweet]): VNode =
 | 
			
		||||
proc renderThread(thread: seq[Tweet]; prefs: Prefs): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="timeline-tweet thread-line")):
 | 
			
		||||
    for i, threadTweet in thread.sortedByIt(it.time):
 | 
			
		||||
      renderTweet(threadTweet, "thread", index=i, total=thread.high)
 | 
			
		||||
      renderTweet(threadTweet, prefs, class="thread", index=i, total=thread.high)
 | 
			
		||||
 | 
			
		||||
proc threadFilter(it: Tweet; tweetThread: string): bool =
 | 
			
		||||
  it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
 | 
			
		||||
 | 
			
		||||
proc renderTweets(timeline: Timeline): VNode =
 | 
			
		||||
proc renderTweets(timeline: Timeline; prefs: Prefs): VNode =
 | 
			
		||||
  buildHtml(tdiv(id="posts")):
 | 
			
		||||
    var threads: seq[string]
 | 
			
		||||
    for tweet in timeline.tweets:
 | 
			
		||||
      if tweet.threadId in threads: continue
 | 
			
		||||
      let thread = timeline.tweets.filterIt(threadFilter(it, tweet.threadId))
 | 
			
		||||
      if thread.len < 2:
 | 
			
		||||
        renderTweet(tweet, "timeline-tweet")
 | 
			
		||||
        renderTweet(tweet, prefs, class="timeline-tweet")
 | 
			
		||||
      else:
 | 
			
		||||
        renderThread(thread)
 | 
			
		||||
        renderThread(thread, prefs)
 | 
			
		||||
        threads &= tweet.threadId
 | 
			
		||||
 | 
			
		||||
proc renderTimeline*(timeline: Timeline; username: string;
 | 
			
		||||
                     protected: bool; multi=false): VNode =
 | 
			
		||||
proc renderTimeline*(timeline: Timeline; username: string; protected: bool;
 | 
			
		||||
                     prefs: Prefs; multi=false): VNode =
 | 
			
		||||
  buildHtml(tdiv):
 | 
			
		||||
    if multi:
 | 
			
		||||
      tdiv(class="multi-header"):
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +91,7 @@ proc renderTimeline*(timeline: Timeline; username: string;
 | 
			
		|||
    elif timeline.tweets.len == 0:
 | 
			
		||||
      renderNoneFound()
 | 
			
		||||
    else:
 | 
			
		||||
      renderTweets(timeline)
 | 
			
		||||
      renderTweets(timeline, prefs)
 | 
			
		||||
      if timeline.hasMore or timeline.query.isSome:
 | 
			
		||||
        renderOlder(timeline, username)
 | 
			
		||||
      else:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +1,18 @@
 | 
			
		|||
import strutils, sequtils
 | 
			
		||||
import karax/[karaxdsl, vdom, vstyles]
 | 
			
		||||
 | 
			
		||||
import ../types, ../utils, ../formatters
 | 
			
		||||
import renderutils
 | 
			
		||||
import ../types, ../utils, ../formatters
 | 
			
		||||
 | 
			
		||||
proc renderHeader(tweet: Tweet): VNode =
 | 
			
		||||
  buildHtml(tdiv):
 | 
			
		||||
    if tweet.retweet.isSome:
 | 
			
		||||
      tdiv(class="retweet"):
 | 
			
		||||
        span: text "🔄 " & get(tweet.retweet).by & " retweeted"
 | 
			
		||||
        span: icon "retweet-1", get(tweet.retweet).by & " retweeted"
 | 
			
		||||
 | 
			
		||||
    if tweet.pinned:
 | 
			
		||||
      tdiv(class="pinned"):
 | 
			
		||||
        span: text "📌 Pinned Tweet"
 | 
			
		||||
        span: icon "pin", "Pinned Tweet"
 | 
			
		||||
 | 
			
		||||
    tdiv(class="tweet-header"):
 | 
			
		||||
      a(class="tweet-avatar", href=("/" & tweet.profile.username)):
 | 
			
		||||
| 
						 | 
				
			
			@ -44,26 +45,55 @@ proc renderAlbum(tweet: Tweet): VNode =
 | 
			
		|||
              target="_blank", style={display: flex}):
 | 
			
		||||
              genImg(photo)
 | 
			
		||||
 | 
			
		||||
proc renderVideo(video: Video): VNode =
 | 
			
		||||
proc isPlaybackEnabled(prefs: Prefs; video: Video): bool =
 | 
			
		||||
  case video.playbackType
 | 
			
		||||
  of mp4: prefs.mp4Playback
 | 
			
		||||
  of m3u8, vmap: prefs.hlsPlayback
 | 
			
		||||
 | 
			
		||||
proc renderVideoDisabled(video: Video): VNode =
 | 
			
		||||
  buildHtml(tdiv):
 | 
			
		||||
    img(src=video.thumb.getSigUrl("pic"))
 | 
			
		||||
    tdiv(class="video-overlay"):
 | 
			
		||||
      case video.playbackType
 | 
			
		||||
      of mp4:
 | 
			
		||||
        p: text "mp4 playback disabled in preferences"
 | 
			
		||||
      of m3u8, vmap:
 | 
			
		||||
        p: text "hls playback disabled in preferences"
 | 
			
		||||
 | 
			
		||||
proc renderVideo(video: Video; prefs: Prefs): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="attachments")):
 | 
			
		||||
    tdiv(class="gallery-video"):
 | 
			
		||||
      tdiv(class="attachment video-container"):
 | 
			
		||||
        case video.playbackType
 | 
			
		||||
        of mp4:
 | 
			
		||||
          video(poster=video.thumb.getSigUrl("pic"), controls=""):
 | 
			
		||||
            source(src=video.url.getSigUrl("video"), `type`="video/mp4")
 | 
			
		||||
        of m3u8, vmap:
 | 
			
		||||
          video(poster=video.thumb.getSigUrl("pic"))
 | 
			
		||||
          tdiv(class="video-overlay"):
 | 
			
		||||
            p: text "Video playback not supported"
 | 
			
		||||
        if prefs.isPlaybackEnabled(video):
 | 
			
		||||
          let thumb = video.thumb.getSigUrl("pic")
 | 
			
		||||
          let source = video.url.getSigUrl("video")
 | 
			
		||||
          case video.playbackType
 | 
			
		||||
          of mp4:
 | 
			
		||||
            if prefs.muteVideos:
 | 
			
		||||
              video(poster=thumb, controls="", muted=""):
 | 
			
		||||
                source(src=source, `type`="video/mp4")
 | 
			
		||||
            else:
 | 
			
		||||
              video(poster=thumb, controls=""):
 | 
			
		||||
                source(src=source, `type`="video/mp4")
 | 
			
		||||
          of m3u8, vmap:
 | 
			
		||||
            video(poster=thumb)
 | 
			
		||||
            tdiv(class="video-overlay"):
 | 
			
		||||
              p: text "Video playback not supported yet"
 | 
			
		||||
        else:
 | 
			
		||||
          renderVideoDisabled(video)
 | 
			
		||||
 | 
			
		||||
proc renderGif(gif: Gif): VNode =
 | 
			
		||||
proc renderGif(gif: Gif; prefs: Prefs): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="attachments media-gif")):
 | 
			
		||||
    tdiv(class="gallery-gif", style=style(maxHeight, "unset")):
 | 
			
		||||
      tdiv(class="attachment"):
 | 
			
		||||
        video(class="gif", poster=gif.thumb.getSigUrl("pic"),
 | 
			
		||||
              autoplay="", muted="", loop=""):
 | 
			
		||||
          source(src=gif.url.getSigUrl("video"), `type`="video/mp4")
 | 
			
		||||
        let thumb = gif.thumb.getSigUrl("pic")
 | 
			
		||||
        let url = gif.url.getSigUrl("video")
 | 
			
		||||
        if prefs.autoplayGifs:
 | 
			
		||||
          video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
 | 
			
		||||
            source(src=url, `type`="video/mp4")
 | 
			
		||||
        else:
 | 
			
		||||
          video(class="gif", poster=thumb, controls="", muted="", loop=""):
 | 
			
		||||
            source(src=url, `type`="video/mp4")
 | 
			
		||||
 | 
			
		||||
proc renderPoll(poll: Poll): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="poll")):
 | 
			
		||||
| 
						 | 
				
			
			@ -80,22 +110,22 @@ proc renderPoll(poll: Poll): VNode =
 | 
			
		|||
proc renderCardImage(card: Card): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="card-image-container")):
 | 
			
		||||
    tdiv(class="card-image"):
 | 
			
		||||
      img(src=get(card.image).getSigUrl("pic"))
 | 
			
		||||
      img(src=getSigUrl(get(card.image), "pic"))
 | 
			
		||||
      if card.kind == player:
 | 
			
		||||
        tdiv(class="card-overlay"):
 | 
			
		||||
          tdiv(class="card-overlay-circle"):
 | 
			
		||||
            span(class="card-overlay-triangle")
 | 
			
		||||
 | 
			
		||||
proc renderCard(card: Card): VNode =
 | 
			
		||||
proc renderCard(card: Card; prefs: Prefs): VNode =
 | 
			
		||||
  const largeCards = {summaryLarge, liveEvent, promoWebsite, promoVideo}
 | 
			
		||||
  let large = if card.kind in largeCards: " large" else: ""
 | 
			
		||||
 | 
			
		||||
  buildHtml(tdiv(class=("card" & large))):
 | 
			
		||||
    a(class="card-container", href=card.url):
 | 
			
		||||
    a(class="card-container", href=replaceUrl(card.url, prefs)):
 | 
			
		||||
      if card.image.isSome:
 | 
			
		||||
        renderCardImage(card)
 | 
			
		||||
      elif card.video.isSome:
 | 
			
		||||
        renderVideo(get(card.video))
 | 
			
		||||
        renderVideo(get(card.video), prefs)
 | 
			
		||||
 | 
			
		||||
      tdiv(class="card-content-container"):
 | 
			
		||||
        tdiv(class="card-content"):
 | 
			
		||||
| 
						 | 
				
			
			@ -105,9 +135,9 @@ proc renderCard(card: Card): VNode =
 | 
			
		|||
 | 
			
		||||
proc renderStats(stats: TweetStats): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="tweet-stats")):
 | 
			
		||||
    span(class="tweet-stat"): text "💬 " & $stats.replies
 | 
			
		||||
    span(class="tweet-stat"): text "🔄 " & $stats.retweets
 | 
			
		||||
    span(class="tweet-stat"): text "👍 " & $stats.likes
 | 
			
		||||
    span(class="tweet-stat"): icon "comment", $stats.replies
 | 
			
		||||
    span(class="tweet-stat"): icon "retweet-1", $stats.retweets
 | 
			
		||||
    span(class="tweet-stat"): icon "thumbs-up-alt", $stats.likes
 | 
			
		||||
 | 
			
		||||
proc renderReply(tweet: Tweet): VNode =
 | 
			
		||||
  buildHtml(tdiv(class="replying-to")):
 | 
			
		||||
| 
						 | 
				
			
			@ -133,9 +163,9 @@ proc renderQuoteMedia(quote: Quote): VNode =
 | 
			
		|||
            tdiv(class="quote-badge-text"): text quote.badge
 | 
			
		||||
    elif quote.sensitive:
 | 
			
		||||
      tdiv(class="quote-sensitive"):
 | 
			
		||||
        span(class="icon quote-sensitive-icon"): text "❗"
 | 
			
		||||
        icon "attention", class="quote-sensitive-icon"
 | 
			
		||||
 | 
			
		||||
proc renderQuote(quote: Quote): VNode =
 | 
			
		||||
proc renderQuote(quote: Quote; prefs: Prefs): VNode =
 | 
			
		||||
  if not quote.available:
 | 
			
		||||
    return buildHtml(tdiv(class="quote unavailable")):
 | 
			
		||||
      tdiv(class="unavailable-quote"):
 | 
			
		||||
| 
						 | 
				
			
			@ -155,13 +185,14 @@ proc renderQuote(quote: Quote): VNode =
 | 
			
		|||
      renderReply(quote)
 | 
			
		||||
 | 
			
		||||
    tdiv(class="quote-text"):
 | 
			
		||||
      verbatim linkifyText(quote.text)
 | 
			
		||||
      verbatim linkifyText(quote.text, prefs)
 | 
			
		||||
 | 
			
		||||
    if quote.hasThread:
 | 
			
		||||
      a(href=getLink(quote)):
 | 
			
		||||
      a(class="show-thread", href=getLink(quote)):
 | 
			
		||||
        text "Show this thread"
 | 
			
		||||
 | 
			
		||||
proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNode =
 | 
			
		||||
proc renderTweet*(tweet: Tweet; prefs: Prefs; class="";
 | 
			
		||||
                  index=0; total=(-1); last=false): VNode =
 | 
			
		||||
  var divClass = class
 | 
			
		||||
  if index == total or last:
 | 
			
		||||
    divClass = "thread-last " & class
 | 
			
		||||
| 
						 | 
				
			
			@ -181,24 +212,25 @@ proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNod
 | 
			
		|||
          renderReply(tweet)
 | 
			
		||||
 | 
			
		||||
        tdiv(class="status-content media-body"):
 | 
			
		||||
          verbatim linkifyText(tweet.text)
 | 
			
		||||
          verbatim linkifyText(tweet.text, prefs)
 | 
			
		||||
 | 
			
		||||
        if tweet.quote.isSome:
 | 
			
		||||
          renderQuote(tweet.quote.get())
 | 
			
		||||
          renderQuote(tweet.quote.get(), prefs)
 | 
			
		||||
 | 
			
		||||
        if tweet.card.isSome:
 | 
			
		||||
          renderCard(tweet.card.get())
 | 
			
		||||
          renderCard(tweet.card.get(), prefs)
 | 
			
		||||
        elif tweet.photos.len > 0:
 | 
			
		||||
          renderAlbum(tweet)
 | 
			
		||||
        elif tweet.video.isSome:
 | 
			
		||||
          renderVideo(tweet.video.get())
 | 
			
		||||
          renderVideo(tweet.video.get(), prefs)
 | 
			
		||||
        elif tweet.gif.isSome:
 | 
			
		||||
          renderGif(tweet.gif.get())
 | 
			
		||||
          renderGif(tweet.gif.get(), prefs)
 | 
			
		||||
        elif tweet.poll.isSome:
 | 
			
		||||
          renderPoll(tweet.poll.get())
 | 
			
		||||
 | 
			
		||||
        renderStats(tweet.stats)
 | 
			
		||||
        if not prefs.hideTweetStats:
 | 
			
		||||
          renderStats(tweet.stats)
 | 
			
		||||
 | 
			
		||||
        if tweet.hasThread and "timeline" in class:
 | 
			
		||||
          a(href=getLink(tweet)):
 | 
			
		||||
          a(class="show-thread", href=getLink(tweet)):
 | 
			
		||||
            text "Show this thread"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,7 +39,7 @@ class Tweet(object):
 | 
			
		|||
class Profile(object):
 | 
			
		||||
    fullname = '.profile-card-fullname'
 | 
			
		||||
    username = '.profile-card-username'
 | 
			
		||||
    protected = '.protected-icon'
 | 
			
		||||
    protected = '.icon-lock-circled'
 | 
			
		||||
    verified = '.verified-icon'
 | 
			
		||||
    banner = '.profile-banner'
 | 
			
		||||
    bio = '.profile-bio'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,68 +6,68 @@ card = [
 | 
			
		|||
    ['voidtarget/status/1133028231672582145',
 | 
			
		||||
     'sinkingsugar/nimqt-example',
 | 
			
		||||
     'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
 | 
			
		||||
     'github.com', '-tb6lD-A', False],
 | 
			
		||||
     'github.com', False],
 | 
			
		||||
 | 
			
		||||
    ['Bountysource/status/1141879700639215617',
 | 
			
		||||
     '$1,000 Bounty on kivy/plyer',
 | 
			
		||||
     'Automation and Screen Reader Support',
 | 
			
		||||
     'bountysource.com', 'TF5vo84K', False],
 | 
			
		||||
     'bountysource.com', False],
 | 
			
		||||
 | 
			
		||||
    ['lorenlugosch/status/1115440394148487168',
 | 
			
		||||
     'lorenlugosch/pretrain_speech_model',
 | 
			
		||||
     'Speech Model Pre-training for End-to-End Spoken Language Understanding - lorenlugosch/pretrain_speech_model',
 | 
			
		||||
     'github.com', 'VwMnYBVh', False],
 | 
			
		||||
     'github.com', False],
 | 
			
		||||
 | 
			
		||||
    ['PyTorch/status/1123379369672450051',
 | 
			
		||||
     'PyTorch',
 | 
			
		||||
     'An open source deep learning platform that provides a seamless path from research prototyping to production deployment.',
 | 
			
		||||
     'pytorch.org', 'lAc4aESh', False],
 | 
			
		||||
     'pytorch.org', False],
 | 
			
		||||
 | 
			
		||||
    ['Thom_Wolf/status/1122466524860702729',
 | 
			
		||||
     'pytorch/fairseq',
 | 
			
		||||
     'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - pytorch/fairseq',
 | 
			
		||||
     'github.com', '1SVn24P6', False],
 | 
			
		||||
     'github.com', False],
 | 
			
		||||
 | 
			
		||||
    ['TheTwoffice/status/558685306090946561',
 | 
			
		||||
     'Eternity: a moment standing still forever…',
 | 
			
		||||
     '- James Montgomery. | facebook | 500px | ferpectshotz | I dusted off this one from my old archives, it was taken while I was living in mighty new York city working at Wall St. I think this was the 11...',
 | 
			
		||||
     'flickr.com', '1LT6fSLU', True],
 | 
			
		||||
     'flickr.com', True],
 | 
			
		||||
 | 
			
		||||
    ['nim_lang/status/1136652293510717440',
 | 
			
		||||
     'Version 0.20.0 released',
 | 
			
		||||
     'We are very proud to announce Nim version 0.20. This is a massive release, both literally and figuratively. It contains more than 1,000 commits and it marks our release candidate for version 1.0!',
 | 
			
		||||
     'nim-lang.org', 'Q0aJrdMZ', True],
 | 
			
		||||
     'nim-lang.org', True],
 | 
			
		||||
 | 
			
		||||
    ['Tesla/status/1141041022035623936',
 | 
			
		||||
     'Experience the Tesla Arcade',
 | 
			
		||||
     '',
 | 
			
		||||
     'www.tesla.com', '40H36baw', True],
 | 
			
		||||
     'www.tesla.com', True],
 | 
			
		||||
 | 
			
		||||
    ['mobile_test/status/490378953744318464',
 | 
			
		||||
     'Nantasket Beach',
 | 
			
		||||
     'Rocks on the beach.',
 | 
			
		||||
     '500px.com', 'FVUU4YDwN', True],
 | 
			
		||||
     '500px.com', True],
 | 
			
		||||
 | 
			
		||||
    ['voidtarget/status/1094632512926605312',
 | 
			
		||||
     'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
 | 
			
		||||
     'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
 | 
			
		||||
     'gist.github.com', '37n4WuBF', True],
 | 
			
		||||
     'gist.github.com', True],
 | 
			
		||||
 | 
			
		||||
    ['AdsAPI/status/1110272721005367296',
 | 
			
		||||
     'Conversation Targeting',
 | 
			
		||||
     '',
 | 
			
		||||
     'view.highspot.com', 'FrVMLWJH', True],
 | 
			
		||||
     'view.highspot.com', True],
 | 
			
		||||
 | 
			
		||||
    ['FluentAI/status/1116417904831029248',
 | 
			
		||||
     'Amazon’s Alexa isn’t just AI — thousands of humans are listening',
 | 
			
		||||
     'One of the only ways to improve Alexa is to have human beings check it for errors',
 | 
			
		||||
     'theverge.com', 'HOW73fOB', True]
 | 
			
		||||
     'theverge.com', True]
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
no_thumb = [
 | 
			
		||||
    ['nim_lang/status/1082989146040340480',
 | 
			
		||||
     'Nim in 2018: A short recap',
 | 
			
		||||
     'Posted in r/programming by u/miran1 • 38 points and 46 comments',
 | 
			
		||||
     'Posted in r/programming by u/miran1 • 36 points and 46 comments',
 | 
			
		||||
     'reddit.com'],
 | 
			
		||||
 | 
			
		||||
    ['brent_p/status/1088857328680488961',
 | 
			
		||||
| 
						 | 
				
			
			@ -80,17 +80,17 @@ playable = [
 | 
			
		|||
    ['nim_lang/status/1118234460904919042',
 | 
			
		||||
     'Nim development blog 2019-03',
 | 
			
		||||
     'Arne (aka Krux02) * debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g * bugs: * forwarding of .pure. * sizeof union * fea...',
 | 
			
		||||
     'youtube.com', 'rJkABhGF'],
 | 
			
		||||
     'youtube.com'],
 | 
			
		||||
 | 
			
		||||
    ['nim_lang/status/1121090879823986688',
 | 
			
		||||
     'Nim - First natively compiled language w/ hot code-reloading at...',
 | 
			
		||||
     '#nim #c++ #ACCUConf Nim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming ca...',
 | 
			
		||||
     'youtube.com', 'FuFgnQ9PA'],
 | 
			
		||||
     'youtube.com'],
 | 
			
		||||
 | 
			
		||||
    ['lele/status/819930645145288704',
 | 
			
		||||
     'Eurocrash presents Open Decks - emerging dj #4: E-Musik',
 | 
			
		||||
     "OPEN DECKS is Eurocrash's new project about discovering new and emerging dj talents. Every selected dj will have the chance to perform the first dj-set in front of an actual audience. The best dj...",
 | 
			
		||||
     'mixcloud.com', 'FdM8jyi04']
 | 
			
		||||
     'mixcloud.com']
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
promo = [
 | 
			
		||||
| 
						 | 
				
			
			@ -106,12 +106,12 @@ promo = [
 | 
			
		|||
 | 
			
		||||
class CardTest(BaseTestCase):
 | 
			
		||||
    @parameterized.expand(card)
 | 
			
		||||
    def test_card(self, tweet, title, description, destination, image, large):
 | 
			
		||||
    def test_card(self, tweet, title, description, destination, large):
 | 
			
		||||
        self.open_nitter(tweet)
 | 
			
		||||
        card = Card(Conversation.main + " ")
 | 
			
		||||
        self.assert_text(title, card.title)
 | 
			
		||||
        self.assert_text(destination, card.destination)
 | 
			
		||||
        self.assertIn(image, self.get_image_url(card.image + ' img'))
 | 
			
		||||
        self.assertIn('_img', self.get_image_url(card.image + ' img'))
 | 
			
		||||
        if len(description) > 0:
 | 
			
		||||
            self.assert_text(description, card.description)
 | 
			
		||||
        if large:
 | 
			
		||||
| 
						 | 
				
			
			@ -129,12 +129,12 @@ class CardTest(BaseTestCase):
 | 
			
		|||
            self.assert_text(description, card.description)
 | 
			
		||||
 | 
			
		||||
    @parameterized.expand(playable)
 | 
			
		||||
    def test_card_playable(self, tweet, title, description, destination, image):
 | 
			
		||||
    def test_card_playable(self, tweet, title, description, destination):
 | 
			
		||||
        self.open_nitter(tweet)
 | 
			
		||||
        card = Card(Conversation.main + " ")
 | 
			
		||||
        self.assert_text(title, card.title)
 | 
			
		||||
        self.assert_text(destination, card.destination)
 | 
			
		||||
        self.assertIn(image, self.get_image_url(card.image + ' img'))
 | 
			
		||||
        self.assertIn('_img', self.get_image_url(card.image + ' img'))
 | 
			
		||||
        self.assert_element_visible('.card-overlay')
 | 
			
		||||
        if len(description) > 0:
 | 
			
		||||
            self.assert_text(description, card.description)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,15 +4,15 @@ from parameterized import parameterized
 | 
			
		|||
profiles = [
 | 
			
		||||
        ['mobile_test', 'Test account',
 | 
			
		||||
         'Test Account. test test Testing username with @mobile_test_2 and a #hashtag',
 | 
			
		||||
         '📍 San Francisco, CA', '🔗 example.com/foobar', '📅 Joined October 2009', '100'],
 | 
			
		||||
        ['mobile_test_2', 'mobile test 2', '', '', '', '📅 Joined January 2011', '13']
 | 
			
		||||
         'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '100'],
 | 
			
		||||
        ['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13']
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
verified = [['jack'], ['elonmusk']]
 | 
			
		||||
 | 
			
		||||
protected = [
 | 
			
		||||
    ['mobile_test_7', 'mobile test 7🔒', ''],
 | 
			
		||||
    ['Poop', 'Randy🔒', 'Social media fanatic.']
 | 
			
		||||
    ['mobile_test_7', 'mobile test 7', ''],
 | 
			
		||||
    ['Poop', 'Randy', 'Social media fanatic.']
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
invalid = [['thisprofiledoesntexist'], ['%']]
 | 
			
		||||
| 
						 | 
				
			
			@ -39,7 +39,7 @@ class ProfileTest(BaseTestCase):
 | 
			
		|||
            (location, Profile.location),
 | 
			
		||||
            (website, Profile.website),
 | 
			
		||||
            (joinDate, Profile.joinDate),
 | 
			
		||||
            (f"🖼 {mediaCount} Photos and videos", Profile.mediaCount)
 | 
			
		||||
            (mediaCount + " Photos and videos", Profile.mediaCount)
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for text, selector in tests:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,7 +16,7 @@ timeline = [
 | 
			
		|||
]
 | 
			
		||||
 | 
			
		||||
status = [
 | 
			
		||||
    [20, 'jack 🌍🌏🌎✔', 'jack', '21 Mar 2006', 'just setting up my twttr'],
 | 
			
		||||
    [20, 'jack 🌍🌏🌎', 'jack', '21 Mar 2006', 'just setting up my twttr'],
 | 
			
		||||
    [134849778302464000, 'The Twoffice', 'TheTwoffice', '10 Nov 2011', 'test'],
 | 
			
		||||
    [105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'],
 | 
			
		||||
    [572593440719912960, 'Test account', 'mobile_test', '2 Mar 2015', 'testing test']
 | 
			
		||||
| 
						 | 
				
			
			@ -77,7 +77,7 @@ emoji = [
 | 
			
		|||
 | 
			
		||||
retweet = [
 | 
			
		||||
    [7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'],
 | 
			
		||||
    [3, 'mobile_test_8', 'mobile test 8', 'jack 🌍🌏🌎✔', '@jack', 'twttr']
 | 
			
		||||
    [3, 'mobile_test_8', 'mobile test 8', 'jack 🌍🌏🌎', '@jack', 'twttr']
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
reply = [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -92,7 +92,7 @@ class MediaTest(BaseTestCase):
 | 
			
		|||
        self.assert_element_visible(Media.container)
 | 
			
		||||
        self.assert_element_visible(Media.video)
 | 
			
		||||
 | 
			
		||||
        video_thumb = self.get_attribute('video', 'poster')
 | 
			
		||||
        video_thumb = self.get_attribute(Media.video + ' img', 'src')
 | 
			
		||||
        self.assertIn(thumb, video_thumb)
 | 
			
		||||
 | 
			
		||||
    @parameterized.expand(gallery)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue