Selaa lähdekoodia

Merge pull request #24 from zedeus/user-prefs

User prefs
Zed 7 vuotta sitten
vanhempi
commit
bce76ab8d1

+ 1 - 0
nitter.conf

@@ -1,6 +1,7 @@
 [Server]
 [Server]
 address = "0.0.0.0"
 address = "0.0.0.0"
 port = 8080
 port = 8080
+https = true  # disable to enable cookies when not using https
 title = "nitter"
 title = "nitter"
 staticDir = "./public"
 staticDir = "./public"
 
 

+ 2 - 2
nitter.nimble

@@ -11,8 +11,8 @@ bin           = @["nitter"]
 # Dependencies
 # Dependencies
 
 
 requires "nim >= 0.19.9"
 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 "regex >= 0.11.2"
 requires "q >= 0.0.7"
 requires "q >= 0.0.7"
 requires "nimcrypto >= 0.3.9"
 requires "nimcrypto >= 0.3.9"

+ 53 - 0
public/css/fontello.css

@@ -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'; } /* '' */

+ 164 - 32
public/style.css → public/css/style.css

@@ -7,6 +7,10 @@ body {
     line-height: 1.3;
     line-height: 1.3;
 }
 }
 
 
+* {
+    outline: unset;
+}
+
 #posts {
 #posts {
     background-color: #161616;
     background-color: #161616;
 }
 }
@@ -107,29 +111,19 @@ a:hover {
     text-overflow: ellipsis;
     text-overflow: ellipsis;
 }
 }
 
 
-.icon {
+.verified-icon {
     color: #fff;
     color: #fff;
+    background-color: #1da1f2;
     border-radius: 50%;
     border-radius: 50%;
-    display: inline-block;
-    text-align: center;
-    vertical-align: middle;
     flex-shrink: 0;
     flex-shrink: 0;
     margin: 2px 0 3px 3px;
     margin: 2px 0 3px 3px;
-}
-
-.verified-icon {
-    background-color: #1da1f2;
-    height: 14px;
+    padding-top: 2px;
+    height: 12px;
     width: 14px;
     width: 14px;
-    font-size: 10px;
-}
-
-.protected-icon {
-    background-color: #353535;
-    height: 18px;
-    width: 18px;
-    font-size: 12px;
-    font-weight: bold;
+    font-size: 8px;
+    display: inline-block;
+    text-align: center;
+    vertical-align: middle;
 }
 }
 
 
 .tweet-date {
 .tweet-date {
@@ -210,19 +204,28 @@ nav {
     justify-content: flex-end;
     justify-content: flex-end;
 }
 }
 
 
+.site-name {
+    font-weight: 600;
+}
+
+.site-name:hover {
+    color: #ffaca0;
+    text-decoration: unset;
+}
+
 .site-logo {
 .site-logo {
+    display: block;
     width: 35px;
     width: 35px;
     height: 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 {
 .attachments {
@@ -277,7 +280,7 @@ nav {
     overflow: hidden;
     overflow: hidden;
 }
 }
 
 
-video {
+video, .video-container img {
     height: 100%;
     height: 100%;
     width: 100%;
     width: 100%;
 }
 }
@@ -386,10 +389,15 @@ video {
     padding: 0 2em;
     padding: 0 2em;
     line-height: 2em;
     line-height: 2em;
 }
 }
+
 .show-more a:hover {
 .show-more a:hover {
     background-color: #282828;
     background-color: #282828;
 }
 }
 
 
+.show-thread {
+    display: block;
+}
+
 .multi-header {
 .multi-header {
     background-color: #161616;
     background-color: #161616;
     text-align: center;
     text-align: center;
@@ -437,7 +445,6 @@ video {
     text-align: left;
     text-align: left;
     vertical-align: top;
     vertical-align: top;
     max-width: 32%;
     max-width: 32%;
-    position: sticky;
     top: 50px;
     top: 50px;
 }
 }
 
 
@@ -898,12 +905,8 @@ video {
 }
 }
 
 
 .quote-sensitive-icon {
 .quote-sensitive-icon {
-    font-size: 25px;
-    width: 37px;
-    height: 32px;
-    background-color: #4e4e4e;
-    padding-bottom: 5px;
-    margin: 0;
+    font-size: 40px;
+    color: #909090;
 }
 }
 
 
 .card {
 .card {
@@ -1073,3 +1076,132 @@ video {
 .poll-info {
 .poll-info {
     color: #868687;
     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 - 0
public/fonts/LICENSE.txt

@@ -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


+ 46 - 0
public/fonts/fontello.svg

@@ -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="&#xe800;" 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="&#xe801;" 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="&#xe802;" d="M1000 350q0-97-67-179t-182-130-251-48q-39 0-81 4-110-97-257-135-27-8-63-12-10-1-17 5t-10 16v1q-2 2 0 6t1 6 2 5l4 5t4 5 4 5q4 5 17 19t20 22 17 22 18 28 15 33 15 42q-88 50-138 123t-51 157q0 73 40 139t106 114 160 76 194 28q136 0 251-48t182-130 67-179z" horiz-adv-x="1000" />
+
+<glyph glyph-name="ok" unicode="&#xe803;" d="M0 260l162 162 166-164 508 510 164-164-510-510-162-162-162 164z" horiz-adv-x="1000" />
+
+<glyph glyph-name="link" unicode="&#xe805;" d="M294 116q14 14 34 14t36-14q32-34 0-70l-42-40q-56-56-132-56-78 0-134 56t-56 132q0 78 56 134l148 148q70 68 144 77t128-43q16-16 16-36t-16-36q-36-32-70 0-50 48-132-34l-148-146q-26-26-26-64t26-62q26-26 63-26t63 26z m450 574q56-56 56-132 0-78-56-134l-158-158q-74-72-150-72-62 0-112 50-14 14-14 34t14 36q14 14 35 14t35-14q50-48 122 24l158 156q28 28 28 64 0 38-28 62-24 26-56 31t-60-21l-50-50q-16-14-36-14t-34 14q-34 34 0 70l50 50q54 54 127 51t129-61z" horiz-adv-x="800" />
+
+<glyph glyph-name="calendar" unicode="&#xe806;" d="M800 700q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-700 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l46 0 0-100 160 0 0 100 290 0 0-100 160 0 0 100 44 0z m0-700l0 400-700 0 0-400 700 0z m-540 800l0-170-70 0 0 170 70 0z m450 0l0-170-70 0 0 170 70 0z" horiz-adv-x="900" />
+
+<glyph glyph-name="location" unicode="&#xe807;" d="M250 750q104 0 177-73t73-177q0-106-62-243t-126-223l-62-84q-10 12-27 35t-60 89-76 130-60 147-27 149q0 104 73 177t177 73z m0-388q56 0 96 40t40 96-40 95-96 39-95-39-39-95 39-96 95-40z" horiz-adv-x="500" />
+
+<glyph glyph-name="down-open-1" unicode="&#xe808;" 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="&#xe809;" d="M357 529q0-45-31-76t-76-32-76 32-31 76 31 76 76 31 76-31 31-76z m572-215v-250h-786v107l178 179 90-89 285 285z m53 393h-893q-7 0-12-5t-6-13v-678q0-7 6-13t12-5h893q7 0 13 5t5 13v678q0 8-5 13t-13 5z m89-18v-678q0-37-26-63t-63-27h-893q-36 0-63 27t-26 63v678q0 37 26 63t63 27h893q37 0 63-27t26-63z" horiz-adv-x="1071.4" />
+
+<glyph glyph-name="lock-circled" unicode="&#xe80a;" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m252-271l496 0 0 314-82 0 0 59q0 33-14 66-19 45-62 74t-94 30-92-29-62-75q-16-35-14-125l-76 0 0-314z m176 314l0 59q2 31 19 49t45 21l4 0q29-2 49-22t21-48l0-59-138 0z" horiz-adv-x="1000" />
+
+<glyph glyph-name="down-open" unicode="&#xe80b;" d="M939 399l-414-413q-10-11-25-11t-25 11l-414 413q-11 11-11 26t11 25l93 92q10 11 25 11t25-11l296-296 296 296q11 11 25 11t26-11l92-92q11-11 11-25t-11-26z" horiz-adv-x="1000" />
+
+<glyph glyph-name="info-circled" unicode="&#xe80c;" 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="&#xe80d;" d="M714 11q0-7-5-13t-13-5h-535q-5 0-8 1t-5 4-3 4-2 7 0 6v335h-107q-15 0-25 11t-11 25q0 13 8 23l179 214q11 12 27 12t28-12l178-214q9-10 9-23 0-15-11-25t-25-11h-107v-214h321q9 0 14-6l89-108q4-5 4-11z m357 232q0-13-8-23l-178-214q-12-13-28-13t-27 13l-179 214q-8 10-8 23 0 14 11 25t25 11h107v214h-322q-9 0-14 7l-89 107q-4 5-4 11 0 7 5 12t13 6h536q4 0 7-1t5-4 3-5 2-6 1-7v-334h107q14 0 25-11t10-25z" horiz-adv-x="1071.4" />
+
+<glyph glyph-name="search" unicode="&#xe80e;" d="M772 78q30-34 6-62l-46-46q-36-32-68 0l-190 190q-74-42-156-42-128 0-223 95t-95 223 90 219 218 91 224-95 96-223q0-88-46-162z m-678 358q0-88 68-156t156-68 151 63 63 153q0 88-68 155t-156 67-151-63-63-151z" horiz-adv-x="789" />
+
+<glyph glyph-name="pin" unicode="&#xe80f;" d="M268 368v250q0 8-5 13t-13 5-13-5-5-13v-250q0-8 5-13t13-5 13 5 5 13z m375-197q0-14-11-25t-25-10h-239l-29-270q-1-7-6-11t-11-5h-1q-15 0-17 15l-43 271h-225q-15 0-25 10t-11 25q0 69 44 124t99 55v286q-29 0-50 21t-22 50 22 50 50 22h357q29 0 50-22t21-50-21-50-50-21v-286q55 0 99-55t44-124z" horiz-adv-x="642.9" />
+
+<glyph glyph-name="ok-circled" unicode="&#xe810;" 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="&#xe812;" 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="&#xf164;" 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>

BIN
public/fonts/fontello.ttf


BIN
public/fonts/fontello.woff


BIN
public/fonts/fontello.woff2


+ 8 - 8
src/api.nim

@@ -52,10 +52,10 @@ macro genMediaGet(media: untyped; token=false) =
       var futs: seq[Future[void]]
       var futs: seq[Future[void]]
       when `token`:
       when `token`:
         var token = await getGuestToken(agent)
         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:
       else:
         futs.add `single`(convo.tweet, agent)
         futs.add `single`(convo.tweet, agent)
         futs.add `multi`(convo.before, 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)
   result = json["guest_token"].to(string)
   guestToken = result
   guestToken = result
 
 
-proc getVideoFetch*(tweet: Tweet; token, agent: string) {.async.} =
+proc getVideoFetch*(tweet: Tweet; agent, token: string) {.async.} =
   if tweet.video.isNone(): return
   if tweet.video.isNone(): return
 
 
   let headers = newHttpHeaders({
   let headers = newHttpHeaders({
@@ -135,7 +135,7 @@ proc getVideoFetch*(tweet: Tweet; token, agent: string) {.async.} =
     if getTime() - tokenUpdated > initDuration(seconds=1):
     if getTime() - tokenUpdated > initDuration(seconds=1):
       tokenUpdated = getTime()
       tokenUpdated = getTime()
       discard await getGuestToken(agent, force=true)
       discard await getGuestToken(agent, force=true)
-    await getVideoFetch(tweet, guestToken, agent)
+    await getVideoFetch(tweet, agent, guestToken)
     return
     return
 
 
   if tweet.card.isNone:
   if tweet.card.isNone:
@@ -151,12 +151,12 @@ proc getVideoVar*(tweet: Tweet): var Option[Video] =
   else:
   else:
     return tweet.video
     return tweet.video
 
 
-proc getVideo*(tweet: Tweet; token, agent: string; force=false) {.async.} =
+proc getVideo*(tweet: Tweet; agent, token: string; force=false) {.async.} =
   withDb:
   withDb:
     try:
     try:
       getVideoVar(tweet) = some(Video.getOne("videoId = ?", tweet.id))
       getVideoVar(tweet) = some(Video.getOne("videoId = ?", tweet.id))
     except KeyError:
     except KeyError:
-      await getVideoFetch(tweet, token, agent)
+      await getVideoFetch(tweet, agent, token)
       var video = getVideoVar(tweet)
       var video = getVideoVar(tweet)
       if video.isSome():
       if video.isSome():
         get(video).insert()
         get(video).insert()

+ 4 - 4
src/cache.nim

@@ -1,7 +1,7 @@
 import asyncdispatch, times
 import asyncdispatch, times
 import types, api
 import types, api
 
 
-withDb:
+withCustomDb("cache.db", "", "", ""):
   try:
   try:
     createTables()
     createTables()
   except DbError:
   except DbError:
@@ -13,7 +13,7 @@ proc isOutdated*(profile: Profile): bool =
   getTime() - profile.updated > profileCacheTime
   getTime() - profile.updated > profileCacheTime
 
 
 proc cache*(profile: var Profile) =
 proc cache*(profile: var Profile) =
-  withDb:
+  withCustomDb("cache.db", "", "", ""):
     try:
     try:
       let p = Profile.getOne("lower(username) = ?", toLower(profile.username))
       let p = Profile.getOne("lower(username) = ?", toLower(profile.username))
       profile.id = p.id
       profile.id = p.id
@@ -23,7 +23,7 @@ proc cache*(profile: var Profile) =
         profile.insert()
         profile.insert()
 
 
 proc hasCachedProfile*(username: string): Option[Profile] =
 proc hasCachedProfile*(username: string): Option[Profile] =
-  withDb:
+  withCustomDb("cache.db", "", "", ""):
     try:
     try:
       let p = Profile.getOne("lower(username) = ?", toLower(username))
       let p = Profile.getOne("lower(username) = ?", toLower(username))
       doAssert not p.isOutdated
       doAssert not p.isOutdated
@@ -32,7 +32,7 @@ proc hasCachedProfile*(username: string): Option[Profile] =
       result = none(Profile)
       result = none(Profile)
 
 
 proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
 proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
-  withDb:
+  withCustomDb("cache.db", "", "", ""):
     try:
     try:
       result.getOne("lower(username) = ?", toLower(username))
       result.getOne("lower(username) = ?", toLower(username))
       doAssert not result.isOutdated
       doAssert not result.isOutdated

+ 2 - 1
src/config.nim

@@ -1,5 +1,5 @@
 import parsecfg except Config
 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 =
 proc get[T](config: parseCfg.Config; s, v: string; default: T): T =
   let val = config.getSectionValue(s, v)
   let val = config.getSectionValue(s, v)
@@ -15,6 +15,7 @@ proc getConfig*(path: string): Config =
   Config(
   Config(
     address: cfg.get("Server", "address", "0.0.0.0"),
     address: cfg.get("Server", "address", "0.0.0.0"),
     port: cfg.get("Server", "port", 8080),
     port: cfg.get("Server", "port", 8080),
+    useHttps: cfg.get("Server", "https", true),
     title: cfg.get("Server", "title", "Nitter"),
     title: cfg.get("Server", "title", "Nitter"),
     staticDir: cfg.get("Server", "staticDir", "./public"),
     staticDir: cfg.get("Server", "staticDir", "./public"),
 
 

+ 14 - 1
src/formatters.nim

@@ -11,6 +11,8 @@ const
   usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
   usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
   picRegex = re"pic.twitter.com/[^ ]+"
   picRegex = re"pic.twitter.com/[^ ]+"
   ellipsisRegex = re" ?…"
   ellipsisRegex = re" ?…"
+  ytRegex = re"(www.)?youtu(be.com|.be)"
+  twRegex = re"(www.)?twitter.com"
   nbsp = $Rune(0x000A0)
   nbsp = $Rune(0x000A0)
 
 
 proc stripText*(text: string): string =
 proc stripText*(text: string): string =
@@ -46,7 +48,7 @@ proc reUsernameToLink*(m: RegexMatch; s: string): string =
 
 
   pretext & toLink("/" & username, "@" & username)
   pretext & toLink("/" & username, "@" & username)
 
 
-proc linkifyText*(text: string): string =
+proc linkifyText*(text: string; prefs: Prefs): string =
   result = xmltree.escape(stripText(text))
   result = xmltree.escape(stripText(text))
   result = result.replace(ellipsisRegex, "")
   result = result.replace(ellipsisRegex, "")
   result = result.replace(emailRegex, reEmailToLink)
   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"([^\s\(\n%])<a", "$1 <a")
   result = result.replace(re"</a>\s+([;.,!\)'%]|&apos;)", "</a>$1")
   result = result.replace(re"</a>\s+([;.,!\)'%]|&apos;)", "</a>$1")
   result = result.replace(re"^\. <a", ".<a")
   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 =
 proc stripTwitterUrls*(text: string): string =
   result = text
   result = text

+ 59 - 29
src/nitter.nim

@@ -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
 from net import Port
 
 
 import jester, regex
 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"
 const configPath {.strdefine.} = "./nitter.conf"
 let cfg = getConfig(configPath)
 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)
   let railFut = getPhotoRail(name, agent)
 
 
   var timeline: Timeline
   var timeline: Timeline
@@ -34,33 +36,40 @@ proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Futur
   if profile.username.len == 0:
   if profile.username.len == 0:
     return ""
     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
   var q = query
   if q.isSome:
   if q.isSome:
     get(q).fromUser = names
     get(q).fromUser = names
   else:
   else:
     q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
     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 agent = getAgent()
   let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
   let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
 
 
   if names.len == 1:
   if names.len == 1:
-    return await showSingleTimeline(names[0], after, agent, query)
+    return await showSingleTimeline(names[0], after, agent, query, prefs)
   else:
   else:
-    return await showMultiTimeline(names, after, agent, query)
+    return await showMultiTimeline(names, after, agent, query, prefs)
 
 
 template respTimeline(timeline: typed) =
 template respTimeline(timeline: typed) =
   if timeline.len == 0:
   if timeline.len == 0:
     resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title)
     resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title)
   resp timeline
   resp timeline
 
 
+template cookiePrefs(): untyped {.dirty.} =
+  getPrefs(request.cookies.getOrDefault("preferences"))
+
 setProfileCacheTime(cfg.profileCacheTime)
 setProfileCacheTime(cfg.profileCacheTime)
 
 
 settings:
 settings:
@@ -70,32 +79,56 @@ settings:
 
 
 routes:
 routes:
   get "/":
   get "/":
-    resp renderMain(renderSearch(), title=cfg.title)
+    resp renderMain(renderSearch(), cfg.title)
 
 
   post "/search":
   post "/search":
     if @"query".len == 0:
     if @"query".len == 0:
       resp Http404, showError("Please enter a username.", cfg.title)
       resp Http404, showError("Please enter a username.", cfg.title)
     redirect("/" & @"query")
     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/?":
   get "/@name/?":
     cond '.' notin @"name"
     cond '.' notin @"name"
-    respTimeline(await showTimeline(@"name", @"after", none(Query)))
+    respTimeline(await showTimeline(@"name", @"after", none(Query), cookiePrefs()))
 
 
   get "/@name/search":
   get "/@name/search":
     cond '.' notin @"name"
     cond '.' notin @"name"
+    let prefs = cookiePrefs()
     let query = initQuery(@"filter", @"include", @"not", @"sep", @"name")
     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":
   get "/@name/replies":
     cond '.' notin @"name"
     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":
   get "/@name/media":
     cond '.' notin @"name"
     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":
   get "/@name/status/@id":
     cond '.' notin @"name"
     cond '.' notin @"name"
+    let prefs = cookiePrefs()
 
 
     let conversation = await getTweet(@"name", @"id", getAgent())
     let conversation = await getTweet(@"name", @"id", getAgent())
     if conversation == nil or conversation.tweet.id.len == 0:
     if conversation == nil or conversation.tweet.id.len == 0:
@@ -103,26 +136,24 @@ routes:
 
 
     let title = pageTitle(conversation.tweet.profile)
     let title = pageTitle(conversation.tweet.profile)
     let desc = conversation.tweet.text
     let desc = conversation.tweet.text
-    let html = renderConversation(conversation)
+    let html = renderConversation(conversation, prefs)
 
 
     if conversation.tweet.video.isSome():
     if conversation.tweet.video.isSome():
       let thumb = get(conversation.tweet.video).thumb
       let thumb = get(conversation.tweet.video).thumb
       let vidUrl = getVideoEmbed(conversation.tweet.id)
       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():
     elif conversation.tweet.gif.isSome():
       let thumb = get(conversation.tweet.gif).thumb
       let thumb = get(conversation.tweet.gif).thumb
       let vidUrl = getVideoEmbed(conversation.tweet.id)
       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:
     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":
   get "/pic/@sig/@url":
     cond "http" in @"url"
     cond "http" in @"url"
     cond "twimg" in @"url"
     cond "twimg" in @"url"
-
     let
     let
       uri = parseUri(decodeUrl(@"url"))
       uri = parseUri(decodeUrl(@"url"))
       path = uri.path.split("/")[2 .. ^1].join("/")
       path = uri.path.split("/")[2 .. ^1].join("/")
@@ -156,11 +187,10 @@ routes:
     if getHmac(url) != @"sig":
     if getHmac(url) != @"sig":
       resp showError("Failed to verify signature", cfg.title)
       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)
     resp video, mimetype(url)
 
 
 runForever()
 runForever()

+ 47 - 0
src/prefs.nim

@@ -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 - 0
src/prefs_impl.nim

@@ -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)
+

+ 25 - 24
src/types.nim

@@ -1,5 +1,6 @@
 import times, sequtils, options
 import times, sequtils, options
 import norm/sqlite
 import norm/sqlite
+import prefs_impl
 
 
 export sqlite, options
 export sqlite, options
 
 
@@ -22,25 +23,17 @@ db("cache.db", "", "", ""):
       tweets*: string
       tweets*: string
       likes*: string
       likes*: string
       media*: 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* {.
       joinDate* {.
-        dbType: "INTEGER",
-        parseIt: it.i.fromUnix(),
-        formatIt: it.toUnix()
+        dbType: "INTEGER"
+        parseIt: it.i.fromUnix()
+        formatIt: dbValue(it.toUnix())
         .}: Time
         .}: Time
       updated* {.
       updated* {.
-          dbType: "INTEGER",
-          parseIt: it.i.fromUnix(),
-          formatIt: getTime().toUnix()
+          dbType: "INTEGER"
+          parseIt: it.i.fromUnix()
+          formatIt: dbValue(getTime().toUnix())
         .}: Time
         .}: Time
 
 
     Video* = object
     Video* = object
@@ -50,16 +43,23 @@ db("cache.db", "", "", ""):
       url*: string
       url*: string
       thumb*: string
       thumb*: string
       views*: string
       views*: string
+      available*: bool
       playbackType* {.
       playbackType* {.
-          dbType: "STRING",
-          parseIt: parseEnum[VideoType](it.s),
-          formatIt: $it,
+          dbType: "STRING"
+          parseIt: parseEnum[VideoType](it.s)
+          formatIt: dbValue($it)
         .}: VideoType
         .}: 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
 type
   QueryKind* = enum
   QueryKind* = enum
@@ -169,6 +169,7 @@ type
   Config* = ref object
   Config* = ref object
     address*: string
     address*: string
     port*: int
     port*: int
+    useHttps*: bool
     title*: string
     title*: string
     staticDir*: string
     staticDir*: string
     cacheDir*: string
     cacheDir*: string

+ 9 - 7
src/views/general.nim

@@ -1,6 +1,7 @@
 import karax/[karaxdsl, vdom]
 import karax/[karaxdsl, vdom]
 
 
-import ../utils
+import renderutils
+import ../utils, ../types
 
 
 const doctype = "<!DOCTYPE html>\n"
 const doctype = "<!DOCTYPE html>\n"
 
 
@@ -13,14 +14,15 @@ proc renderNavbar*(title: string): VNode =
       a(href="/"): img(class="site-logo", src="/logo.png")
       a(href="/"): img(class="site-logo", src="/logo.png")
 
 
       tdiv(class="item right"):
       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="";
 proc renderMain*(body: VNode; title="Nitter"; titleText=""; desc="";
                  `type`="article"; video=""; images: seq[string] = @[]): string =
                  `type`="article"; video=""; images: seq[string] = @[]): string =
   let node = buildHtml(html(lang="en")):
   let node = buildHtml(html(lang="en")):
     head:
     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:
       title:
         if titleText.len > 0:
         if titleText.len > 0:
@@ -53,12 +55,12 @@ proc renderSearch*(): VNode =
     tdiv(class="search-panel"):
     tdiv(class="search-panel"):
       form(`method`="post", action="search"):
       form(`method`="post", action="search"):
         input(`type`="text", name="query", autofocus="", placeholder="Enter usernames...")
         input(`type`="text", name="query", autofocus="", placeholder="Enter usernames...")
-        button(`type`="submit"): text "🔎"
+        button(`type`="submit"): icon "search"
 
 
 proc renderError*(error: string): VNode =
 proc renderError*(error: string): VNode =
   buildHtml(tdiv(class="panel")):
   buildHtml(tdiv(class="panel")):
     tdiv(class="error-panel"):
     tdiv(class="error-panel"):
       span: text error
       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 - 0
src/views/preferences.nim

@@ -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"

+ 17 - 15
src/views/profile.nim

@@ -1,8 +1,8 @@
 import strutils, strformat
 import strutils, strformat
 import karax/[karaxdsl, vdom, vstyles]
 import karax/[karaxdsl, vdom, vstyles]
 
 
-import ../types, ../utils, ../formatters
 import tweet, timeline, renderutils
 import tweet, timeline, renderutils
+import ../types, ../utils, ../formatters
 
 
 proc renderStat(num, class: string; text=""): VNode =
 proc renderStat(num, class: string; text=""): VNode =
   let t = if text.len > 0: text else: class
   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"):
     span(class="profile-stat-num"):
       text if num.len == 0: "?" else: 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")):
   buildHtml(tdiv(class="profile-card")):
     a(class="profile-card-avatar", href=profile.getUserPic().getSigUrl("pic")):
     a(class="profile-card-avatar", href=profile.getUserPic().getSigUrl("pic")):
       genImg(profile.getUserpic("_200x200"))
       genImg(profile.getUserpic("_200x200"))
@@ -23,21 +23,21 @@ proc renderProfileCard*(profile: Profile): VNode =
     tdiv(class="profile-card-extra"):
     tdiv(class="profile-card-extra"):
       if profile.bio.len > 0:
       if profile.bio.len > 0:
         tdiv(class="profile-bio"):
         tdiv(class="profile-bio"):
-          p: verbatim linkifyText(profile.bio)
+          p: verbatim linkifyText(profile.bio, prefs)
 
 
       if profile.location.len > 0:
       if profile.location.len > 0:
         tdiv(class="profile-location"):
         tdiv(class="profile-location"):
-          span: text "📍 " & profile.location
+          span: icon "location", profile.location
 
 
       if profile.website.len > 0:
       if profile.website.len > 0:
         tdiv(class="profile-website"):
         tdiv(class="profile-website"):
           span:
           span:
-            text "🔗 "
+            icon "link"
             linkText(profile.website)
             linkText(profile.website)
 
 
       tdiv(class="profile-joindate"):
       tdiv(class="profile-joindate"):
         span(title=getJoinDateFull(profile)):
         span(title=getJoinDateFull(profile)):
-          text "📅 " & getJoinDate(profile)
+          icon "calendar", getJoinDate(profile)
 
 
       tdiv(class="profile-card-extra-links"):
       tdiv(class="profile-card-extra-links"):
         ul(class="profile-statlist"):
         ul(class="profile-statlist"):
@@ -50,7 +50,7 @@ proc renderPhotoRail(profile: Profile; photoRail: seq[GalleryPhoto]): VNode =
   buildHtml(tdiv(class="photo-rail-card")):
   buildHtml(tdiv(class="photo-rail-card")):
     tdiv(class="photo-rail-header"):
     tdiv(class="photo-rail-header"):
       a(href=(&"/{profile.username}/media")):
       a(href=(&"/{profile.username}/media")):
-        text &"🖼 {profile.media} Photos and videos"
+        icon "picture-1", $profile.media & " Photos and videos"
 
 
     tdiv(class="photo-rail-grid"):
     tdiv(class="photo-rail-grid"):
       for i, photo in photoRail:
       for i, photo in photoRail:
@@ -68,20 +68,22 @@ proc renderBanner(profile: Profile): VNode =
         genImg(profile.banner)
         genImg(profile.banner)
 
 
 proc renderProfile*(profile: Profile; timeline: Timeline;
 proc renderProfile*(profile: Profile; timeline: Timeline;
-                    photoRail: seq[GalleryPhoto]): VNode =
+                    photoRail: seq[GalleryPhoto]; prefs: Prefs): VNode =
   buildHtml(tdiv(class="profile-tabs")):
   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:
       if photoRail.len > 0:
         renderPhotoRail(profile, photoRail)
         renderPhotoRail(profile, photoRail)
 
 
     tdiv(class="timeline-tab"):
     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")):
   buildHtml(tdiv(class="multi-timeline")):
     tdiv(class="timeline-tab"):
     tdiv(class="timeline-tab"):
-      renderTimeline(timeline, usernames, false, multi=true)
+      renderTimeline(timeline, usernames, false, prefs, multi=true)

+ 15 - 2
src/views/renderutils.nim

@@ -2,6 +2,18 @@ import karax/[karaxdsl, vdom, vstyles]
 
 
 import ../types, ../utils
 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 =
 proc linkUser*(profile: Profile, class=""): VNode =
   let
   let
     isName = "username" notin class
     isName = "username" notin class
@@ -12,9 +24,10 @@ proc linkUser*(profile: Profile, class=""): VNode =
   buildHtml(a(href=href, class=class, title=nameText)):
   buildHtml(a(href=href, class=class, title=nameText)):
     text nameText
     text nameText
     if isName and profile.verified:
     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:
     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 =
 proc genImg*(url: string; class=""): VNode =
   buildHtml():
   buildHtml():

+ 7 - 7
src/views/status.nim

@@ -4,11 +4,11 @@ import karax/[karaxdsl, vdom]
 import ../types
 import ../types
 import tweet, renderutils
 import tweet, renderutils
 
 
-proc renderReplyThread(thread: Thread): VNode =
+proc renderReplyThread(thread: Thread; prefs: Prefs): VNode =
   buildHtml(tdiv(class="reply thread thread-line")):
   buildHtml(tdiv(class="reply thread thread-line")):
     for i, tweet in thread.tweets:
     for i, tweet in thread.tweets:
       let last = (i == thread.tweets.high and thread.more == 0)
       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:
     if thread.more != 0:
       let num = if thread.more != -1: $thread.more & " " else: ""
       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"):
         a(class="more-replies-text", title="Not implemented yet"):
           text $num & "more " & reply
           text $num & "more " & reply
 
 
-proc renderConversation*(conversation: Conversation): VNode =
+proc renderConversation*(conversation: Conversation; prefs: Prefs): VNode =
   let hasAfter = conversation.after != nil
   let hasAfter = conversation.after != nil
   buildHtml(tdiv(class="conversation", id="posts")):
   buildHtml(tdiv(class="conversation", id="posts")):
     tdiv(class="main-thread"):
     tdiv(class="main-thread"):
       if conversation.before != nil:
       if conversation.before != nil:
         tdiv(class="before-tweet thread-line"):
         tdiv(class="before-tweet thread-line"):
           for i, tweet in conversation.before.tweets:
           for i, tweet in conversation.before.tweets:
-            renderTweet(tweet, index=i)
+            renderTweet(tweet, prefs, index=i)
 
 
       tdiv(class="main-tweet"):
       tdiv(class="main-tweet"):
         let afterClass = if hasAfter: "thread thread-line" else: ""
         let afterClass = if hasAfter: "thread thread-line" else: ""
-        renderTweet(conversation.tweet, class=afterClass)
+        renderTweet(conversation.tweet, prefs, class=afterClass)
 
 
       if hasAfter:
       if hasAfter:
         tdiv(class="after-tweet thread-line"):
         tdiv(class="after-tweet thread-line"):
           let total = conversation.after.tweets.high
           let total = conversation.after.tweets.high
           for i, tweet in conversation.after.tweets:
           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:
     if conversation.replies.len > 0:
       tdiv(class="replies"):
       tdiv(class="replies"):
         for thread in conversation.replies:
         for thread in conversation.replies:
-          renderReplyThread(thread)
+          renderReplyThread(thread, prefs)

+ 8 - 8
src/views/timeline.nim

@@ -54,28 +54,28 @@ proc renderProtected(username: string): VNode =
     h2: text "This account's tweets are protected."
     h2: text "This account's tweets are protected."
     p: text &"Only confirmed followers have access to @{username}'s tweets."
     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")):
   buildHtml(tdiv(class="timeline-tweet thread-line")):
     for i, threadTweet in thread.sortedByIt(it.time):
     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 =
 proc threadFilter(it: Tweet; tweetThread: string): bool =
   it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
   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")):
   buildHtml(tdiv(id="posts")):
     var threads: seq[string]
     var threads: seq[string]
     for tweet in timeline.tweets:
     for tweet in timeline.tweets:
       if tweet.threadId in threads: continue
       if tweet.threadId in threads: continue
       let thread = timeline.tweets.filterIt(threadFilter(it, tweet.threadId))
       let thread = timeline.tweets.filterIt(threadFilter(it, tweet.threadId))
       if thread.len < 2:
       if thread.len < 2:
-        renderTweet(tweet, "timeline-tweet")
+        renderTweet(tweet, prefs, class="timeline-tweet")
       else:
       else:
-        renderThread(thread)
+        renderThread(thread, prefs)
         threads &= tweet.threadId
         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):
   buildHtml(tdiv):
     if multi:
     if multi:
       tdiv(class="multi-header"):
       tdiv(class="multi-header"):
@@ -91,7 +91,7 @@ proc renderTimeline*(timeline: Timeline; username: string;
     elif timeline.tweets.len == 0:
     elif timeline.tweets.len == 0:
       renderNoneFound()
       renderNoneFound()
     else:
     else:
-      renderTweets(timeline)
+      renderTweets(timeline, prefs)
       if timeline.hasMore or timeline.query.isSome:
       if timeline.hasMore or timeline.query.isSome:
         renderOlder(timeline, username)
         renderOlder(timeline, username)
       else:
       else:

+ 68 - 36
src/views/tweet.nim

@@ -1,17 +1,18 @@
 import strutils, sequtils
 import strutils, sequtils
 import karax/[karaxdsl, vdom, vstyles]
 import karax/[karaxdsl, vdom, vstyles]
 
 
-import ../types, ../utils, ../formatters
 import renderutils
 import renderutils
+import ../types, ../utils, ../formatters
 
 
 proc renderHeader(tweet: Tweet): VNode =
 proc renderHeader(tweet: Tweet): VNode =
   buildHtml(tdiv):
   buildHtml(tdiv):
     if tweet.retweet.isSome:
     if tweet.retweet.isSome:
       tdiv(class="retweet"):
       tdiv(class="retweet"):
-        span: text "🔄 " & get(tweet.retweet).by & " retweeted"
+        span: icon "retweet-1", get(tweet.retweet).by & " retweeted"
+
     if tweet.pinned:
     if tweet.pinned:
       tdiv(class="pinned"):
       tdiv(class="pinned"):
-        span: text "📌 Pinned Tweet"
+        span: icon "pin", "Pinned Tweet"
 
 
     tdiv(class="tweet-header"):
     tdiv(class="tweet-header"):
       a(class="tweet-avatar", href=("/" & tweet.profile.username)):
       a(class="tweet-avatar", href=("/" & tweet.profile.username)):
@@ -44,26 +45,55 @@ proc renderAlbum(tweet: Tweet): VNode =
               target="_blank", style={display: flex}):
               target="_blank", style={display: flex}):
               genImg(photo)
               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")):
   buildHtml(tdiv(class="attachments")):
     tdiv(class="gallery-video"):
     tdiv(class="gallery-video"):
       tdiv(class="attachment video-container"):
       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"
-
-proc renderGif(gif: Gif): VNode =
+        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; prefs: Prefs): VNode =
   buildHtml(tdiv(class="attachments media-gif")):
   buildHtml(tdiv(class="attachments media-gif")):
     tdiv(class="gallery-gif", style=style(maxHeight, "unset")):
     tdiv(class="gallery-gif", style=style(maxHeight, "unset")):
       tdiv(class="attachment"):
       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 =
 proc renderPoll(poll: Poll): VNode =
   buildHtml(tdiv(class="poll")):
   buildHtml(tdiv(class="poll")):
@@ -80,22 +110,22 @@ proc renderPoll(poll: Poll): VNode =
 proc renderCardImage(card: Card): VNode =
 proc renderCardImage(card: Card): VNode =
   buildHtml(tdiv(class="card-image-container")):
   buildHtml(tdiv(class="card-image-container")):
     tdiv(class="card-image"):
     tdiv(class="card-image"):
-      img(src=get(card.image).getSigUrl("pic"))
+      img(src=getSigUrl(get(card.image), "pic"))
       if card.kind == player:
       if card.kind == player:
         tdiv(class="card-overlay"):
         tdiv(class="card-overlay"):
           tdiv(class="card-overlay-circle"):
           tdiv(class="card-overlay-circle"):
             span(class="card-overlay-triangle")
             span(class="card-overlay-triangle")
 
 
-proc renderCard(card: Card): VNode =
+proc renderCard(card: Card; prefs: Prefs): VNode =
   const largeCards = {summaryLarge, liveEvent, promoWebsite, promoVideo}
   const largeCards = {summaryLarge, liveEvent, promoWebsite, promoVideo}
   let large = if card.kind in largeCards: " large" else: ""
   let large = if card.kind in largeCards: " large" else: ""
 
 
   buildHtml(tdiv(class=("card" & large))):
   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:
       if card.image.isSome:
         renderCardImage(card)
         renderCardImage(card)
       elif card.video.isSome:
       elif card.video.isSome:
-        renderVideo(get(card.video))
+        renderVideo(get(card.video), prefs)
 
 
       tdiv(class="card-content-container"):
       tdiv(class="card-content-container"):
         tdiv(class="card-content"):
         tdiv(class="card-content"):
@@ -105,9 +135,9 @@ proc renderCard(card: Card): VNode =
 
 
 proc renderStats(stats: TweetStats): VNode =
 proc renderStats(stats: TweetStats): VNode =
   buildHtml(tdiv(class="tweet-stats")):
   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 =
 proc renderReply(tweet: Tweet): VNode =
   buildHtml(tdiv(class="replying-to")):
   buildHtml(tdiv(class="replying-to")):
@@ -133,9 +163,9 @@ proc renderQuoteMedia(quote: Quote): VNode =
             tdiv(class="quote-badge-text"): text quote.badge
             tdiv(class="quote-badge-text"): text quote.badge
     elif quote.sensitive:
     elif quote.sensitive:
       tdiv(class="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:
   if not quote.available:
     return buildHtml(tdiv(class="quote unavailable")):
     return buildHtml(tdiv(class="quote unavailable")):
       tdiv(class="unavailable-quote"):
       tdiv(class="unavailable-quote"):
@@ -155,13 +185,14 @@ proc renderQuote(quote: Quote): VNode =
       renderReply(quote)
       renderReply(quote)
 
 
     tdiv(class="quote-text"):
     tdiv(class="quote-text"):
-      verbatim linkifyText(quote.text)
+      verbatim linkifyText(quote.text, prefs)
 
 
     if quote.hasThread:
     if quote.hasThread:
-      a(href=getLink(quote)):
+      a(class="show-thread", href=getLink(quote)):
         text "Show this thread"
         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
   var divClass = class
   if index == total or last:
   if index == total or last:
     divClass = "thread-last " & class
     divClass = "thread-last " & class
@@ -181,24 +212,25 @@ proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNod
           renderReply(tweet)
           renderReply(tweet)
 
 
         tdiv(class="status-content media-body"):
         tdiv(class="status-content media-body"):
-          verbatim linkifyText(tweet.text)
+          verbatim linkifyText(tweet.text, prefs)
 
 
         if tweet.quote.isSome:
         if tweet.quote.isSome:
-          renderQuote(tweet.quote.get())
+          renderQuote(tweet.quote.get(), prefs)
 
 
         if tweet.card.isSome:
         if tweet.card.isSome:
-          renderCard(tweet.card.get())
+          renderCard(tweet.card.get(), prefs)
         elif tweet.photos.len > 0:
         elif tweet.photos.len > 0:
           renderAlbum(tweet)
           renderAlbum(tweet)
         elif tweet.video.isSome:
         elif tweet.video.isSome:
-          renderVideo(tweet.video.get())
+          renderVideo(tweet.video.get(), prefs)
         elif tweet.gif.isSome:
         elif tweet.gif.isSome:
-          renderGif(tweet.gif.get())
+          renderGif(tweet.gif.get(), prefs)
         elif tweet.poll.isSome:
         elif tweet.poll.isSome:
           renderPoll(tweet.poll.get())
           renderPoll(tweet.poll.get())
 
 
-        renderStats(tweet.stats)
+        if not prefs.hideTweetStats:
+          renderStats(tweet.stats)
 
 
         if tweet.hasThread and "timeline" in class:
         if tweet.hasThread and "timeline" in class:
-          a(href=getLink(tweet)):
+          a(class="show-thread", href=getLink(tweet)):
             text "Show this thread"
             text "Show this thread"

+ 1 - 1
tests/base.py

@@ -39,7 +39,7 @@ class Tweet(object):
 class Profile(object):
 class Profile(object):
     fullname = '.profile-card-fullname'
     fullname = '.profile-card-fullname'
     username = '.profile-card-username'
     username = '.profile-card-username'
-    protected = '.protected-icon'
+    protected = '.icon-lock-circled'
     verified = '.verified-icon'
     verified = '.verified-icon'
     banner = '.profile-banner'
     banner = '.profile-banner'
     bio = '.profile-bio'
     bio = '.profile-bio'

+ 20 - 20
tests/test_card.py

@@ -6,68 +6,68 @@ card = [
     ['voidtarget/status/1133028231672582145',
     ['voidtarget/status/1133028231672582145',
      'sinkingsugar/nimqt-example',
      '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.',
      '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',
     ['Bountysource/status/1141879700639215617',
      '$1,000 Bounty on kivy/plyer',
      '$1,000 Bounty on kivy/plyer',
      'Automation and Screen Reader Support',
      'Automation and Screen Reader Support',
-     'bountysource.com', 'TF5vo84K', False],
+     'bountysource.com', False],
 
 
     ['lorenlugosch/status/1115440394148487168',
     ['lorenlugosch/status/1115440394148487168',
      'lorenlugosch/pretrain_speech_model',
      'lorenlugosch/pretrain_speech_model',
      'Speech Model Pre-training for End-to-End Spoken Language Understanding - 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/status/1123379369672450051',
      'PyTorch',
      'PyTorch',
      'An open source deep learning platform that provides a seamless path from research prototyping to production deployment.',
      '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',
     ['Thom_Wolf/status/1122466524860702729',
      'pytorch/fairseq',
      'pytorch/fairseq',
      'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - pytorch/fairseq',
      'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - pytorch/fairseq',
-     'github.com', '1SVn24P6', False],
+     'github.com', False],
 
 
     ['TheTwoffice/status/558685306090946561',
     ['TheTwoffice/status/558685306090946561',
      'Eternity: a moment standing still forever…',
      '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...',
      '- 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',
     ['nim_lang/status/1136652293510717440',
      'Version 0.20.0 released',
      '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!',
      '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',
     ['Tesla/status/1141041022035623936',
      'Experience the Tesla Arcade',
      'Experience the Tesla Arcade',
      '',
      '',
-     'www.tesla.com', '40H36baw', True],
+     'www.tesla.com', True],
 
 
     ['mobile_test/status/490378953744318464',
     ['mobile_test/status/490378953744318464',
      'Nantasket Beach',
      'Nantasket Beach',
      'Rocks on the beach.',
      'Rocks on the beach.',
-     '500px.com', 'FVUU4YDwN', True],
+     '500px.com', True],
 
 
     ['voidtarget/status/1094632512926605312',
     ['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)',
      'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
      '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',
     ['AdsAPI/status/1110272721005367296',
      'Conversation Targeting',
      'Conversation Targeting',
      '',
      '',
-     'view.highspot.com', 'FrVMLWJH', True],
+     'view.highspot.com', True],
 
 
     ['FluentAI/status/1116417904831029248',
     ['FluentAI/status/1116417904831029248',
      'Amazon’s Alexa isn’t just AI — thousands of humans are listening',
      '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',
      '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 = [
 no_thumb = [
     ['nim_lang/status/1082989146040340480',
     ['nim_lang/status/1082989146040340480',
      'Nim in 2018: A short recap',
      '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'],
      'reddit.com'],
 
 
     ['brent_p/status/1088857328680488961',
     ['brent_p/status/1088857328680488961',
@@ -80,17 +80,17 @@ playable = [
     ['nim_lang/status/1118234460904919042',
     ['nim_lang/status/1118234460904919042',
      'Nim development blog 2019-03',
      '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...',
      '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_lang/status/1121090879823986688',
      'Nim - First natively compiled language w/ hot code-reloading at...',
      '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...',
      '#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',
     ['lele/status/819930645145288704',
      'Eurocrash presents Open Decks - emerging dj #4: E-Musik',
      '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...",
      "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 = [
 promo = [
@@ -106,12 +106,12 @@ promo = [
 
 
 class CardTest(BaseTestCase):
 class CardTest(BaseTestCase):
     @parameterized.expand(card)
     @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)
         self.open_nitter(tweet)
         card = Card(Conversation.main + " ")
         card = Card(Conversation.main + " ")
         self.assert_text(title, card.title)
         self.assert_text(title, card.title)
         self.assert_text(destination, card.destination)
         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:
         if len(description) > 0:
             self.assert_text(description, card.description)
             self.assert_text(description, card.description)
         if large:
         if large:
@@ -129,12 +129,12 @@ class CardTest(BaseTestCase):
             self.assert_text(description, card.description)
             self.assert_text(description, card.description)
 
 
     @parameterized.expand(playable)
     @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)
         self.open_nitter(tweet)
         card = Card(Conversation.main + " ")
         card = Card(Conversation.main + " ")
         self.assert_text(title, card.title)
         self.assert_text(title, card.title)
         self.assert_text(destination, card.destination)
         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')
         self.assert_element_visible('.card-overlay')
         if len(description) > 0:
         if len(description) > 0:
             self.assert_text(description, card.description)
             self.assert_text(description, card.description)

+ 5 - 5
tests/test_profile.py

@@ -4,15 +4,15 @@ from parameterized import parameterized
 profiles = [
 profiles = [
         ['mobile_test', 'Test account',
         ['mobile_test', 'Test account',
          'Test Account. test test Testing username with @mobile_test_2 and a #hashtag',
          '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']]
 verified = [['jack'], ['elonmusk']]
 
 
 protected = [
 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'], ['%']]
 invalid = [['thisprofiledoesntexist'], ['%']]
@@ -39,7 +39,7 @@ class ProfileTest(BaseTestCase):
             (location, Profile.location),
             (location, Profile.location),
             (website, Profile.website),
             (website, Profile.website),
             (joinDate, Profile.joinDate),
             (joinDate, Profile.joinDate),
-            (f"🖼 {mediaCount} Photos and videos", Profile.mediaCount)
+            (mediaCount + " Photos and videos", Profile.mediaCount)
         ]
         ]
 
 
         for text, selector in tests:
         for text, selector in tests:

+ 2 - 2
tests/test_tweet.py

@@ -16,7 +16,7 @@ timeline = [
 ]
 ]
 
 
 status = [
 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'],
     [134849778302464000, 'The Twoffice', 'TheTwoffice', '10 Nov 2011', 'test'],
     [105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'],
     [105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'],
     [572593440719912960, 'Test account', 'mobile_test', '2 Mar 2015', 'testing test']
     [572593440719912960, 'Test account', 'mobile_test', '2 Mar 2015', 'testing test']
@@ -77,7 +77,7 @@ emoji = [
 
 
 retweet = [
 retweet = [
     [7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'],
     [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 = [
 reply = [

+ 1 - 1
tests/test_tweet_media.py

@@ -92,7 +92,7 @@ class MediaTest(BaseTestCase):
         self.assert_element_visible(Media.container)
         self.assert_element_visible(Media.container)
         self.assert_element_visible(Media.video)
         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)
         self.assertIn(thumb, video_thumb)
 
 
     @parameterized.expand(gallery)
     @parameterized.expand(gallery)