Initial commit
This commit is contained in:
		
						commit
						cea5cc0523
					
				
					 14 changed files with 1370 additions and 0 deletions
				
			
		
							
								
								
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
nitter
 | 
			
		||||
*.html
 | 
			
		||||
							
								
								
									
										14
									
								
								nitter.nimble
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								nitter.nimble
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
# Package
 | 
			
		||||
 | 
			
		||||
version       = "0.1.0"
 | 
			
		||||
author        = "zedeus"
 | 
			
		||||
description   = "An alternative front-end for Twitter"
 | 
			
		||||
license       = "AGPL-3.0"
 | 
			
		||||
srcDir        = "src"
 | 
			
		||||
bin           = @["nitter"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Dependencies
 | 
			
		||||
 | 
			
		||||
requires "nim >= 0.19.9"
 | 
			
		||||
requires "regex", "nimquery", "nimcrypto", "norm", "jester"
 | 
			
		||||
							
								
								
									
										561
									
								
								public/style.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										561
									
								
								public/style.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,561 @@
 | 
			
		|||
body {
 | 
			
		||||
    background-color: #121212;
 | 
			
		||||
    color: #f8f8f2;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#tweets {
 | 
			
		||||
    background-color: #161616;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#user {
 | 
			
		||||
    background-color: #242424;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding: 5pt;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#user > h1 {
 | 
			
		||||
    color: #ffffff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h1 {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    display: inline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h2 {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-weight: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h3 {
 | 
			
		||||
    margin-bottom: 0;
 | 
			
		||||
    font-weight: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h4 {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a {
 | 
			
		||||
    color: #ff6c60;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a:hover {
 | 
			
		||||
    text-decoration: underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el {
 | 
			
		||||
    overflow-wrap: break-word;
 | 
			
		||||
    word-wrap: break-word;
 | 
			
		||||
    word-break: break-word;
 | 
			
		||||
    border-left-width: 0;
 | 
			
		||||
    min-width: 0;
 | 
			
		||||
    padding: .75em;
 | 
			
		||||
    display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.timeline-tweet {
 | 
			
		||||
    border-bottom: 1px solid #3e3e35;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-body {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    min-width: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.media-heading a {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    word-break: break-all;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .media-heading {
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    vertical-align: bottom;
 | 
			
		||||
    flex-basis: 100%;
 | 
			
		||||
    margin-bottom: .2em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .media-heading .heading-name-row {
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
    line-height: 18px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .media-heading .heading-name-row .name-and-account-name {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    min-width: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .media-heading .heading-name-row .username {
 | 
			
		||||
    flex-shrink: 1;
 | 
			
		||||
    margin-right: .4em;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .username {
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    flex-shrink: 0;
 | 
			
		||||
    font-weight: 700;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.username {
 | 
			
		||||
    color: #f8f8f2;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .media-heading .heading-right {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-shrink: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .media-heading .heading-name-row .account-name {
 | 
			
		||||
    min-width: 1.6em;
 | 
			
		||||
    margin-right: .4em;
 | 
			
		||||
    flex: 1 1 0;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
    word-wrap: normal;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .media-heading a {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    word-break: break-all;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .status-content {
 | 
			
		||||
    font-family: sans-serif;
 | 
			
		||||
    line-height: 1.4em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-el .media-body {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.container, .item {
 | 
			
		||||
    display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.container {
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#content {
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    padding-top: 50px;
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    min-height: 100vh;
 | 
			
		||||
    background-color: rgba(0,0,0,.15);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
nav {
 | 
			
		||||
    z-index: 1000;
 | 
			
		||||
    background-color: #1f1f1f;
 | 
			
		||||
    color: hsla(240,1%,73%,.5);
 | 
			
		||||
    box-shadow: 0 0 4px rgba(0,0,0,.6);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav-bar {
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    height: 50px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav-bar .inner-nav {
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    padding-left: 10px;
 | 
			
		||||
    padding-right: 10px;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    height: 50px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.item {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    line-height: 50px;
 | 
			
		||||
    height: 50px;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.attachments {
 | 
			
		||||
    margin-top: .5em;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    max-height: 600px;
 | 
			
		||||
    border-radius: 7px;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    flex-flow: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gallery-row {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
    flex-wrap: nowrap;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
    max-height: 379.5px;
 | 
			
		||||
    max-width: 506px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gallery-row .attachment, .gallery-row .attachments {
 | 
			
		||||
    margin: 0 .25em 0 0;
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    min-width: 2em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gallery-row .attachment:last-child, .gallery-row .attachments:last-child {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.attachments .attachment {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    line-height: 0;
 | 
			
		||||
    border-color: #222;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gallery-row .image-attachment {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.attachments .image-attachment {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.still-image {
 | 
			
		||||
    max-height: 379.5px;
 | 
			
		||||
    max-width: 506px;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.still-image img {
 | 
			
		||||
    object-fit: cover;
 | 
			
		||||
    max-width: 506px;
 | 
			
		||||
    max-height: 379.5px;
 | 
			
		||||
    border-color: #222;
 | 
			
		||||
    flex-basis: 300px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-body {
 | 
			
		||||
    margin-left: 58px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar {
 | 
			
		||||
    float: left;
 | 
			
		||||
    margin-top: 3px;
 | 
			
		||||
    margin-left: -58px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    width: 48px;
 | 
			
		||||
    height: 48px;
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.retweet, .pinned, .tweet-stats {
 | 
			
		||||
    align-content: center;
 | 
			
		||||
    color: hsla(240,1%,73%,.7);
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-shrink: 0;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
    font-weight: 600;
 | 
			
		||||
    line-height: 22px;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    max-width: 85%;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tweet-stat {
 | 
			
		||||
    padding-top: 5px;
 | 
			
		||||
    padding-right: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.show-more {
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    padding: .75em 0;
 | 
			
		||||
    display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.show-more.status-el {
 | 
			
		||||
    border-bottom: 1px solid #3e3e35;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.show-more a {
 | 
			
		||||
    background-color: #242424;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    height: 2em;
 | 
			
		||||
    padding: 0 2em;
 | 
			
		||||
    line-height: 2em;
 | 
			
		||||
}
 | 
			
		||||
.show-more a:hover {
 | 
			
		||||
    background-color: #282828;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-tabs {
 | 
			
		||||
    max-width: 846px;
 | 
			
		||||
    margin: 0 auto;
 | 
			
		||||
    float: none;
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    width: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.timeline-tab {
 | 
			
		||||
    float: right;
 | 
			
		||||
    padding: 0 4px;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    text-align: left;
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
    width: 70% !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-tab {
 | 
			
		||||
    padding: 0 4px;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    text-align: left;
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
    width: 30% !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-banner {
 | 
			
		||||
    padding: 0 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-banner img {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding-bottom: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-banner-color {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding-bottom: 25%;
 | 
			
		||||
    margin-bottom: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card {
 | 
			
		||||
    float: left;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    margin-top: 0;
 | 
			
		||||
    background: #161616;
 | 
			
		||||
    border-radius: 0 0 4px 4px;
 | 
			
		||||
    padding: 12px;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: flex-start;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-tabs {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    flex: 1 1 auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-tabs-name {
 | 
			
		||||
    padding-top: 0;
 | 
			
		||||
    padding-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-name, .profile-card-username {
 | 
			
		||||
    color: #f8f8f2;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-name {
 | 
			
		||||
    font-size: 16px;
 | 
			
		||||
    word-wrap: break-word;
 | 
			
		||||
    text-shadow: none;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-username {
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-avatar {
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding-bottom: 16px;
 | 
			
		||||
    margin-right: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-avatar img {
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: calc(100% - 4px);
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    border: 4px solid #282828;
 | 
			
		||||
    background: #040404;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-extra {
 | 
			
		||||
    display: block;
 | 
			
		||||
    flex: 100%;
 | 
			
		||||
    margin-top: 4px;
 | 
			
		||||
 }
 | 
			
		||||
 | 
			
		||||
.profile-bio {
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
    background: transparent;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    margin-right: -5px
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-description {
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
    font-weight: 400;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    word-break: normal;
 | 
			
		||||
    word-wrap: break-word;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.conversation {
 | 
			
		||||
    max-width: 580px;
 | 
			
		||||
    margin: 0 auto;
 | 
			
		||||
    float: none;
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    width: auto;
 | 
			
		||||
    background-color: #0f0f0f !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.main-thread {
 | 
			
		||||
    margin-bottom: 20px;
 | 
			
		||||
    background-color: #161616;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.main-tweet .status-content {
 | 
			
		||||
    font-size: 22px;
 | 
			
		||||
    line-height: 30px;
 | 
			
		||||
    letter-spacing: .01em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.thread {
 | 
			
		||||
    background-color: #161616;
 | 
			
		||||
    margin-bottom: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.panel {
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    font-size: 130%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error-panel {
 | 
			
		||||
    background-color: #420a05 !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error-panel, .search-panel > form {
 | 
			
		||||
    padding: 12px;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    background: #222222;
 | 
			
		||||
    box-shadow: 0 0 15px rgba(0,0,0,.2);
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    margin-top: -50px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.search-panel > form > button {
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
    line-height: 24px;
 | 
			
		||||
    display: block;
 | 
			
		||||
    border: 0;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    background: #2f2f2f;
 | 
			
		||||
    color: #f8f8f2;
 | 
			
		||||
    outline: 0;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
    width: 37px;
 | 
			
		||||
    height: 32px;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.search-panel > form > input {
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    font-size: 16px;
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    outline: 0;
 | 
			
		||||
    font-family: inherit;
 | 
			
		||||
    background: #131419;
 | 
			
		||||
    color: #f8f8f2;
 | 
			
		||||
    border: 1px solid #0a0b0e;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    padding: 4px;
 | 
			
		||||
    margin-right: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-card-extra-links {
 | 
			
		||||
    margin-top: 8px;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-statlist {
 | 
			
		||||
    vertical-align: bottom;
 | 
			
		||||
    table-layout: fixed;
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-statlist > li {
 | 
			
		||||
    display: table-cell;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-statlist .tweets {
 | 
			
		||||
    flex-shrink: 2;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-statlist .followers {
 | 
			
		||||
    flex-grow: 2;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-statlist .following {
 | 
			
		||||
    flex-shrink: 1.5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.profile-stat-header {
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.timeline-protected {
 | 
			
		||||
    max-width: 550px;
 | 
			
		||||
    margin: 0 auto;
 | 
			
		||||
    padding: 6px 0px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.timeline-protected-header {
 | 
			
		||||
    color: #d0564c;
 | 
			
		||||
    font-size: 21px;
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										105
									
								
								src/api.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/api.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,105 @@
 | 
			
		|||
import httpclient, asyncdispatch, htmlparser, times
 | 
			
		||||
import sequtils, strutils, strformat, json, xmltree, uri
 | 
			
		||||
import nimquery, regex
 | 
			
		||||
 | 
			
		||||
import ./types, ./parser
 | 
			
		||||
 | 
			
		||||
const base = parseUri("https://twitter.com/")
 | 
			
		||||
const agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
 | 
			
		||||
 | 
			
		||||
const timelineUrl = "i/profiles/show/$1/timeline/tweets?include_available_features=1&include_entities=1&include_new_items_bar=true"
 | 
			
		||||
const profileUrl = "i/profiles/popup"
 | 
			
		||||
const tweetUrl = "i/status/"
 | 
			
		||||
 | 
			
		||||
proc getProfile*(username: string): Future[Profile] {.async.} =
 | 
			
		||||
  let client = newAsyncHttpClient()
 | 
			
		||||
  defer: client.close()
 | 
			
		||||
 | 
			
		||||
  client.headers = newHttpHeaders({
 | 
			
		||||
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9",
 | 
			
		||||
    "Referer": $(base / username),
 | 
			
		||||
    "User-Agent": agent,
 | 
			
		||||
    "X-Twitter-Active-User": "yes",
 | 
			
		||||
    "X-Requested-With": "XMLHttpRequest",
 | 
			
		||||
    "Accept-Language": "en-US,en;q=0.9"
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  let params = {
 | 
			
		||||
    "screen_name": username,
 | 
			
		||||
    "wants_hovercard": "true",
 | 
			
		||||
    "_": $(epochTime().int)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let url = base / profileUrl ? params
 | 
			
		||||
  var resp = ""
 | 
			
		||||
 | 
			
		||||
  try:
 | 
			
		||||
    resp = await client.getContent($url)
 | 
			
		||||
  except:
 | 
			
		||||
    return Profile()
 | 
			
		||||
 | 
			
		||||
  let
 | 
			
		||||
    json = parseJson(resp)["html"].str
 | 
			
		||||
    html = parseHtml(json)
 | 
			
		||||
 | 
			
		||||
  result = parseProfile(html)
 | 
			
		||||
 | 
			
		||||
proc getTimeline*(username: string; after=""): Future[Tweets] {.async.} =
 | 
			
		||||
  let client = newAsyncHttpClient()
 | 
			
		||||
  defer: client.close()
 | 
			
		||||
 | 
			
		||||
  client.headers = newHttpHeaders({
 | 
			
		||||
    "Accept": "application/json, text/javascript, */*; q=0.01",
 | 
			
		||||
    "Referer": $(base / username),
 | 
			
		||||
    "User-Agent": agent,
 | 
			
		||||
    "X-Twitter-Active-User": "yes",
 | 
			
		||||
    "X-Requested-With": "XMLHttpRequest",
 | 
			
		||||
    "Accept-Language": "en-US,en;q=0.9"
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  var url = timelineUrl % username
 | 
			
		||||
  if after != "":
 | 
			
		||||
    url &= "&max_position=" & after
 | 
			
		||||
 | 
			
		||||
  var resp = ""
 | 
			
		||||
  try:
 | 
			
		||||
    resp = await client.getContent($(base / url))
 | 
			
		||||
  except:
 | 
			
		||||
    return
 | 
			
		||||
 | 
			
		||||
  var json: string = ""
 | 
			
		||||
  var html: XmlNode
 | 
			
		||||
  json = parseJson(resp)["items_html"].str
 | 
			
		||||
  html = parseHtml(json)
 | 
			
		||||
 | 
			
		||||
  writeFile("epic.html", $html)
 | 
			
		||||
 | 
			
		||||
  result = parseTweets(html)
 | 
			
		||||
 | 
			
		||||
proc getTweet*(id: string): Future[Conversation] {.async.} =
 | 
			
		||||
  let client = newAsyncHttpClient()
 | 
			
		||||
  defer: client.close()
 | 
			
		||||
 | 
			
		||||
  client.headers = newHttpHeaders({
 | 
			
		||||
    "Accept": "application/json, text/javascript, */*; q=0.01",
 | 
			
		||||
    "Referer": $base,
 | 
			
		||||
    "User-Agent": agent,
 | 
			
		||||
    "X-Twitter-Active-User": "yes",
 | 
			
		||||
    "X-Requested-With": "XMLHttpRequest",
 | 
			
		||||
    "Accept-Language": "en-US,en;q=0.9",
 | 
			
		||||
    "pragma": "no-cache",
 | 
			
		||||
    "x-previous-page-name": "profile"
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  let url = base / tweetUrl / id
 | 
			
		||||
 | 
			
		||||
  var resp: string = ""
 | 
			
		||||
  try:
 | 
			
		||||
    resp = await client.getContent($url)
 | 
			
		||||
  except:
 | 
			
		||||
    return Conversation()
 | 
			
		||||
 | 
			
		||||
  var html: XmlNode
 | 
			
		||||
  html = parseHtml(resp)
 | 
			
		||||
 | 
			
		||||
  result = parseConversation(html)
 | 
			
		||||
							
								
								
									
										74
									
								
								src/cache.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src/cache.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,74 @@
 | 
			
		|||
import sharedtables, times, hashes
 | 
			
		||||
import types, api
 | 
			
		||||
 | 
			
		||||
# var
 | 
			
		||||
#   profileCache: SharedTable[int, Profile]
 | 
			
		||||
#   profileCacheTime = initDuration(seconds=10)
 | 
			
		||||
 | 
			
		||||
# profileCache.init()
 | 
			
		||||
 | 
			
		||||
proc getCachedProfile*(username: string; force=false): Profile =
 | 
			
		||||
  return getProfile(username)
 | 
			
		||||
  # let index = username.hash
 | 
			
		||||
 | 
			
		||||
  # try:
 | 
			
		||||
  #   result = profileCache.mget(index)
 | 
			
		||||
  #   # if force or getTime() - result.lastUpdated > profileCacheTime:
 | 
			
		||||
  #   #   result = getProfile(username)
 | 
			
		||||
  #   #   profileCache[username.hash] = deepCopy(result)
 | 
			
		||||
  #   #   return
 | 
			
		||||
  # except KeyError:
 | 
			
		||||
  #   # result = getProfile(username)
 | 
			
		||||
  #   # profileCache.add(username.hash, deepCopy(result))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  #   var profile: Profile
 | 
			
		||||
  #   profileCache.withKey(index) do (k: int, v: var Profile, pairExists: var bool):
 | 
			
		||||
  #     v = getProfile(username)
 | 
			
		||||
  #     profile = v
 | 
			
		||||
  #     echo v
 | 
			
		||||
  #     pairExists = true
 | 
			
		||||
  #   echo profile.username
 | 
			
		||||
  #   return profile
 | 
			
		||||
 | 
			
		||||
  # profileCache.withValue(hash(username), value) do:
 | 
			
		||||
  #   if getTime() - value.lastUpdated > profileCacheTime or force:
 | 
			
		||||
  #     result = getProfile(username)
 | 
			
		||||
  #     value = result
 | 
			
		||||
  #   else:
 | 
			
		||||
  #     result = value
 | 
			
		||||
  # do:
 | 
			
		||||
  #   result = getProfile(username)
 | 
			
		||||
  #   value = result
 | 
			
		||||
 | 
			
		||||
  # var profile: Profile
 | 
			
		||||
 | 
			
		||||
  # profileCache.withKey(username.hash) do (k: int, v: var Profile, pairExists: var bool):
 | 
			
		||||
  #   if pairExists and getTime() - v.lastUpdated < profileCacheTime and not force:
 | 
			
		||||
  #     profile = deepCopy(v)
 | 
			
		||||
  #     echo "cached"
 | 
			
		||||
  #   else:
 | 
			
		||||
  #     profile = getProfile(username)
 | 
			
		||||
  #     v = deepCopy(profile)
 | 
			
		||||
  #     pairExists = true
 | 
			
		||||
  #     echo "fetched"
 | 
			
		||||
 | 
			
		||||
  # return profile
 | 
			
		||||
 | 
			
		||||
  # try:
 | 
			
		||||
  #   result = profileCache.mget(username.hash)
 | 
			
		||||
  #   if force or getTime() - result.lastUpdated > profileCacheTime:
 | 
			
		||||
  #     result = getProfile(username)
 | 
			
		||||
  #     profileCache[username.hash] = deepCopy(result)
 | 
			
		||||
  #     return
 | 
			
		||||
  # except KeyError:
 | 
			
		||||
  #   result = getProfile(username)
 | 
			
		||||
  #   profileCache.add(username.hash, deepCopy(result))
 | 
			
		||||
 | 
			
		||||
  # if not result.isNil or force or
 | 
			
		||||
  #   getTime() - result.lastUpdated > profileCacheTime:
 | 
			
		||||
  #   result = getProfile(username)
 | 
			
		||||
  #   profileCache[username] = result
 | 
			
		||||
    # return
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										89
									
								
								src/formatters.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/formatters.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
import strutils, strformat, htmlgen, xmltree
 | 
			
		||||
import regex
 | 
			
		||||
 | 
			
		||||
import ./types, ./utils
 | 
			
		||||
 | 
			
		||||
const
 | 
			
		||||
  urlRegex = re"((https?|ftp)://(-\.)?([^\s/?\.#]+\.?)+(/[^\s]*)?)"
 | 
			
		||||
  emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
 | 
			
		||||
  usernameRegex = re"(^|[^\S\n]|\.)@([A-z0-9_]+)"
 | 
			
		||||
  picRegex = re"pic.twitter.com/[^ ]+"
 | 
			
		||||
  cardRegex = re"(https?://)?cards.twitter.com/[^ ]+"
 | 
			
		||||
  ellipsisRegex = re" ?…"
 | 
			
		||||
 | 
			
		||||
proc shortLink*(text: string; length=28): string =
 | 
			
		||||
  result = text.replace(re"https?://(www.)?", "")
 | 
			
		||||
  if result.len > length:
 | 
			
		||||
    result = result[0 ..< length] & "…"
 | 
			
		||||
 | 
			
		||||
proc toLink*(url, text: string; class="timeline-link"): string =
 | 
			
		||||
  htmlgen.a(text, class=class, href=url)
 | 
			
		||||
 | 
			
		||||
proc reUrlToLink*(m: RegexMatch; s: string): string =
 | 
			
		||||
  let url = s[m.group(0)[0]]
 | 
			
		||||
  toLink(url, " " & shortLink(url))
 | 
			
		||||
 | 
			
		||||
proc reEmailToLink*(m: RegexMatch; s: string): string =
 | 
			
		||||
  let url = s[m.group(0)[0]]
 | 
			
		||||
  toLink("mailto://" & url, url)
 | 
			
		||||
 | 
			
		||||
proc reUsernameToLink*(m: RegexMatch; s: string): string =
 | 
			
		||||
  var
 | 
			
		||||
    username = ""
 | 
			
		||||
    pretext = ""
 | 
			
		||||
 | 
			
		||||
  let
 | 
			
		||||
    pre = m.group(0)
 | 
			
		||||
    match = m.group(1)
 | 
			
		||||
 | 
			
		||||
  username = s[match[0]]
 | 
			
		||||
 | 
			
		||||
  if pre.len > 0:
 | 
			
		||||
    pretext = s[pre[0]]
 | 
			
		||||
 | 
			
		||||
  pretext & toLink("/" & username, "@" & username)
 | 
			
		||||
 | 
			
		||||
proc linkifyText*(text: string): string =
 | 
			
		||||
  result = text.replace("\n", "<br>")
 | 
			
		||||
  result = result.replace(ellipsisRegex, "")
 | 
			
		||||
  result = result.replace(usernameRegex, reUsernameToLink)
 | 
			
		||||
  result = result.replace(emailRegex, reEmailToLink)
 | 
			
		||||
  result = result.replace(urlRegex, reUrlToLink)
 | 
			
		||||
 | 
			
		||||
proc stripTwitterUrls*(text: string): string =
 | 
			
		||||
  result = text
 | 
			
		||||
  result = result.replace(picRegex, "")
 | 
			
		||||
  result = result.replace(cardRegex, "")
 | 
			
		||||
  result = result.replace(ellipsisRegex, "")
 | 
			
		||||
 | 
			
		||||
proc getUserpic*(userpic: string; style=""): string =
 | 
			
		||||
  let pic = userpic.replace(re"_(normal|bigger|mini|200x200)(\.[A-z]+)$", "$2")
 | 
			
		||||
  pic.replace(re"(\.[A-z]+)$", style & "$1")
 | 
			
		||||
 | 
			
		||||
proc getUserpic*(profile: Profile; style=""): string =
 | 
			
		||||
  getUserPic(profile.userpic, style)
 | 
			
		||||
 | 
			
		||||
proc getGifSrc*(tweet: Tweet): string =
 | 
			
		||||
  fmt"https://video.twimg.com/tweet_video/{tweet.gif}.mp4"
 | 
			
		||||
 | 
			
		||||
proc getGifThumb*(tweet: Tweet): string =
 | 
			
		||||
  fmt"https://pbs.twimg.com/tweet_video_thumb/{tweet.gif}.jpg"
 | 
			
		||||
 | 
			
		||||
proc formatName(profile: Profile): string =
 | 
			
		||||
  result = profile.fullname
 | 
			
		||||
  if profile.verified:
 | 
			
		||||
    result &= " 🔹"
 | 
			
		||||
  elif profile.protected:
 | 
			
		||||
    result &= " 🔒"
 | 
			
		||||
  result = xmltree.escape(result)
 | 
			
		||||
 | 
			
		||||
proc linkUser*(profile: Profile; h: string; username=true; class=""): string =
 | 
			
		||||
  let text =
 | 
			
		||||
    if username: "@" & profile.username
 | 
			
		||||
    else: formatName(profile)
 | 
			
		||||
 | 
			
		||||
  if h == "":
 | 
			
		||||
    return htmlgen.a(text, href = &"/{profile.username}", class=class)
 | 
			
		||||
 | 
			
		||||
  let element = &"<{h} class=\"{class}\">{text}</{h}>"
 | 
			
		||||
  htmlgen.a(element, href = &"/{profile.username}")
 | 
			
		||||
							
								
								
									
										75
									
								
								src/nitter.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/nitter.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,75 @@
 | 
			
		|||
import asyncdispatch, httpclient, times, strutils, hashes, random, uri
 | 
			
		||||
import jester, regex
 | 
			
		||||
 | 
			
		||||
import api, utils, types
 | 
			
		||||
import views/[user, general, conversation]
 | 
			
		||||
 | 
			
		||||
proc showTimeline(name: string; num=""): Future[string] {.async.} =
 | 
			
		||||
  let
 | 
			
		||||
    username = name.strip(chars={'/'})
 | 
			
		||||
    profileFut = getProfile(username)
 | 
			
		||||
    tweetsFut = getTimeline(username, after=num)
 | 
			
		||||
 | 
			
		||||
  let profile = await profileFut
 | 
			
		||||
  if profile.username == "":
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
  return renderMain(renderProfile(profile, await tweetsFut, num == ""))
 | 
			
		||||
 | 
			
		||||
routes:
 | 
			
		||||
  get "/":
 | 
			
		||||
    resp renderMain(renderSearchPanel())
 | 
			
		||||
 | 
			
		||||
  post "/search":
 | 
			
		||||
    if @"query".len == 0:
 | 
			
		||||
      resp Http404, showError("Please enter a username.")
 | 
			
		||||
 | 
			
		||||
    redirect("/" & @"query")
 | 
			
		||||
 | 
			
		||||
  get "/@name/?":
 | 
			
		||||
    cond '.' notin @"name"
 | 
			
		||||
    let timeline = await showTimeline(@"name", @"after")
 | 
			
		||||
    if timeline == "":
 | 
			
		||||
      resp Http404, showError("User \"" & @"name" & "\" not found")
 | 
			
		||||
 | 
			
		||||
    resp timeline
 | 
			
		||||
 | 
			
		||||
  get "/@name/status/@id":
 | 
			
		||||
    cond '.' notin @"name"
 | 
			
		||||
    let conversation = await getTweet(@"id")
 | 
			
		||||
    if conversation.tweet.id == "":
 | 
			
		||||
      resp Http404, showError("Tweet not found")
 | 
			
		||||
 | 
			
		||||
    resp renderMain(renderConversation(conversation))
 | 
			
		||||
 | 
			
		||||
  get "/pic/@sig/@url":
 | 
			
		||||
    cond "http" in @"url"
 | 
			
		||||
    cond "twimg" in @"url"
 | 
			
		||||
    let url = decodeUrl(@"url")
 | 
			
		||||
 | 
			
		||||
    if getHmac(url) != @"sig":
 | 
			
		||||
      resp showError("Failed to verify signature")
 | 
			
		||||
 | 
			
		||||
    let
 | 
			
		||||
      client = newAsyncHttpClient()
 | 
			
		||||
      pic = await client.getContent(url)
 | 
			
		||||
 | 
			
		||||
    defer: client.close()
 | 
			
		||||
    resp pic, mimetype(url)
 | 
			
		||||
 | 
			
		||||
  get "/video/@sig/@url":
 | 
			
		||||
    cond "http" in @"url"
 | 
			
		||||
    cond "video.twimg" in @"url"
 | 
			
		||||
    let url = decodeUrl(@"url")
 | 
			
		||||
 | 
			
		||||
    if getHmac(url) != @"sig":
 | 
			
		||||
      resp showError("Failed to verify signature")
 | 
			
		||||
 | 
			
		||||
    let
 | 
			
		||||
      client = newAsyncHttpClient()
 | 
			
		||||
      pic = await client.getContent(url)
 | 
			
		||||
 | 
			
		||||
    defer: client.close()
 | 
			
		||||
    resp pic, mimetype(url)
 | 
			
		||||
 | 
			
		||||
runForever()
 | 
			
		||||
							
								
								
									
										100
									
								
								src/parser.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/parser.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,100 @@
 | 
			
		|||
import xmltree, sequtils, strtabs, strutils, strformat, json, times
 | 
			
		||||
import nimquery, regex
 | 
			
		||||
 | 
			
		||||
import ./types, ./formatters
 | 
			
		||||
 | 
			
		||||
proc getAttr(node: XmlNode; attr: string; default=""): string =
 | 
			
		||||
  if node.isNIl or node.attrs.isNil: return default
 | 
			
		||||
  return node.attrs.getOrDefault(attr)
 | 
			
		||||
 | 
			
		||||
proc selectAttr(node: XmlNode; selector: string; attr: string; default=""): string =
 | 
			
		||||
  let res = node.querySelector(selector)
 | 
			
		||||
  return res.getAttr(attr, default)
 | 
			
		||||
 | 
			
		||||
proc selectText(node: XmlNode; selector: string): string =
 | 
			
		||||
  let res = node.querySelector(selector)
 | 
			
		||||
  result = if res == nil: "" else: res.innerText()
 | 
			
		||||
 | 
			
		||||
proc parseProfile*(node: XmlNode): Profile =
 | 
			
		||||
  let profile = node.querySelector(".profile-card")
 | 
			
		||||
  result.fullname = profile.selectText(".fullname")
 | 
			
		||||
  result.username = profile.selectText(".username").strip(chars={'@', ' '})
 | 
			
		||||
  result.description = profile.selectText(".bio")
 | 
			
		||||
  result.verified = profile.selectText("li.verified").len > 0
 | 
			
		||||
  result.protected = profile.selectText(".Icon.Icon--protected").len > 0
 | 
			
		||||
  result.userpic = profile.selectAttr(".ProfileCard-avatarImage", "src").getUserpic()
 | 
			
		||||
  result.banner = profile.selectAttr("svg > image", "xlink:href").replace("600x200", "1500x500")
 | 
			
		||||
  if result.banner == "":
 | 
			
		||||
      result.banner = profile.selectAttr(".ProfileCard-bg", "style")
 | 
			
		||||
 | 
			
		||||
  let stats = profile.querySelectorAll(".ProfileCardStats-statLink")
 | 
			
		||||
  for s in stats:
 | 
			
		||||
    let text = s.getAttr("title").split(" ")[0]
 | 
			
		||||
    case s.getAttr("href").split("/")[^1]
 | 
			
		||||
    of "followers": result.followers = text
 | 
			
		||||
    of "following": result.following = text
 | 
			
		||||
    else: result.tweets = text
 | 
			
		||||
 | 
			
		||||
proc parseTweetProfile*(tweet: XmlNode): Profile =
 | 
			
		||||
  result = Profile(
 | 
			
		||||
    fullname: tweet.getAttr("data-name"),
 | 
			
		||||
    username: tweet.getAttr("data-screen-name"),
 | 
			
		||||
    userpic: tweet.selectAttr(".avatar", "src").getUserpic(),
 | 
			
		||||
    verified: tweet.selectText(".Icon.Icon--verified").len > 0
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
proc parseTweet*(tweet: XmlNode): Tweet =
 | 
			
		||||
  result.id = tweet.getAttr("data-item-id")
 | 
			
		||||
  result.link = tweet.getAttr("data-permalink-path")
 | 
			
		||||
  result.text = tweet.selectText(".tweet-text").stripTwitterUrls()
 | 
			
		||||
  result.retweetBy = tweet.selectText(".js-retweet-text > a > b")
 | 
			
		||||
  result.pinned = "pinned" in tweet.getAttr("class")
 | 
			
		||||
  result.profile = parseTweetProfile(tweet)
 | 
			
		||||
 | 
			
		||||
  let time = tweet.querySelector(".js-short-timestamp")
 | 
			
		||||
  result.time = fromUnix(parseInt(time.getAttr("data-time", "0")))
 | 
			
		||||
  result.shortTime = time.innerText()
 | 
			
		||||
 | 
			
		||||
  result.replies = "0"
 | 
			
		||||
  result.likes = "0"
 | 
			
		||||
  result.retweets = "0"
 | 
			
		||||
 | 
			
		||||
  for action in tweet.querySelectorAll(".ProfileTweet-actionCountForAria"):
 | 
			
		||||
    let
 | 
			
		||||
      text = action.innerText.split()
 | 
			
		||||
      num = text[0]
 | 
			
		||||
      act = text[1]
 | 
			
		||||
 | 
			
		||||
    case act
 | 
			
		||||
    of "replies": result.replies = num
 | 
			
		||||
    of "likes": result.likes = num
 | 
			
		||||
    of "retweets": result.retweets = num
 | 
			
		||||
    else: discard
 | 
			
		||||
 | 
			
		||||
  for photo in tweet.querySelectorAll(".AdaptiveMedia-photoContainer"):
 | 
			
		||||
    result.photos.add photo.attrs["data-image-url"]
 | 
			
		||||
 | 
			
		||||
  let gif = tweet.selectAttr(".PlayableMedia-player", "style")
 | 
			
		||||
  if gif != "":
 | 
			
		||||
    result.gif = gif.replace(re".+thumb/([^\.']+)\.jpg.+", "$1")
 | 
			
		||||
 | 
			
		||||
proc parseTweets*(node: XmlNode): Tweets =
 | 
			
		||||
  if node.isNil: return
 | 
			
		||||
  node.querySelectorAll(".tweet").map(parseTweet)
 | 
			
		||||
 | 
			
		||||
template selectTweets*(node: XmlNode; class: string): untyped =
 | 
			
		||||
  parseTweets(node.querySelector(class))
 | 
			
		||||
 | 
			
		||||
proc parseConversation*(node: XmlNode): Conversation =
 | 
			
		||||
  result.tweet = parseTweet(node.querySelector(".permalink-tweet-container > .tweet"))
 | 
			
		||||
  result.before = node.selectTweets(".in-reply-to")
 | 
			
		||||
 | 
			
		||||
  let replies = node.querySelector(".replies-to")
 | 
			
		||||
  if replies.isNil: return
 | 
			
		||||
 | 
			
		||||
  result.after = replies.selectTweets(".ThreadedConversation--selfThread")
 | 
			
		||||
 | 
			
		||||
  for reply in replies.querySelectorAll("li > .stream-items"):
 | 
			
		||||
    let thread = parseTweets(reply)
 | 
			
		||||
    if not thread.anyIt(it in result.after):
 | 
			
		||||
      result.replies.add thread
 | 
			
		||||
							
								
								
									
										40
									
								
								src/types.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/types.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
import times, sequtils
 | 
			
		||||
 | 
			
		||||
type
 | 
			
		||||
  Profile* = object
 | 
			
		||||
    username*: string
 | 
			
		||||
    fullname*: string
 | 
			
		||||
    description*: string
 | 
			
		||||
    userpic*: string
 | 
			
		||||
    banner*: string
 | 
			
		||||
    following*: string
 | 
			
		||||
    followers*: string
 | 
			
		||||
    tweets*: string
 | 
			
		||||
    verified*: bool
 | 
			
		||||
    protected*: bool
 | 
			
		||||
 | 
			
		||||
  Tweet* = object
 | 
			
		||||
    id*: string
 | 
			
		||||
    profile*: Profile
 | 
			
		||||
    link*: string
 | 
			
		||||
    text*: string
 | 
			
		||||
    time*: Time
 | 
			
		||||
    shortTime*: string
 | 
			
		||||
    replies*: string
 | 
			
		||||
    retweets*: string
 | 
			
		||||
    likes*: string
 | 
			
		||||
    retweetBy*: string
 | 
			
		||||
    pinned*: bool
 | 
			
		||||
    photos*: seq[string]
 | 
			
		||||
    gif*: string
 | 
			
		||||
 | 
			
		||||
  Tweets* = seq[Tweet]
 | 
			
		||||
 | 
			
		||||
  Conversation* = object
 | 
			
		||||
    tweet*: Tweet
 | 
			
		||||
    before*: Tweets
 | 
			
		||||
    after*: Tweets
 | 
			
		||||
    replies*: seq[Tweets]
 | 
			
		||||
 | 
			
		||||
proc contains*(thread: Tweets; tweet: Tweet): bool =
 | 
			
		||||
  thread.anyIt(it.id == tweet.id)
 | 
			
		||||
							
								
								
									
										23
									
								
								src/utils.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/utils.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import strutils, strformat, uri
 | 
			
		||||
import nimcrypto
 | 
			
		||||
 | 
			
		||||
const key = "supersecretkey"
 | 
			
		||||
 | 
			
		||||
proc mimetype*(filename: string): string =
 | 
			
		||||
  if ".png" in filename:
 | 
			
		||||
    return "image/" & "png"
 | 
			
		||||
  elif ".jpg" in filename or ".jpeg" in filename:
 | 
			
		||||
    return "image/" & "jpg"
 | 
			
		||||
  elif ".mp4" in filename:
 | 
			
		||||
    return "video/" & "mp4"
 | 
			
		||||
  else:
 | 
			
		||||
    return "text/plain"
 | 
			
		||||
 | 
			
		||||
proc getHmac*(data: string): string =
 | 
			
		||||
  ($hmac(sha256, key, data))[0 .. 12]
 | 
			
		||||
 | 
			
		||||
proc getSigUrl*(link: string; path: string): string =
 | 
			
		||||
  let
 | 
			
		||||
    sig = getHmac(link)
 | 
			
		||||
    url = encodeUrl(link)
 | 
			
		||||
  &"/{path}/{sig}/{url}"
 | 
			
		||||
							
								
								
									
										38
									
								
								src/views/conversation.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/views/conversation.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
#? stdtmpl(subsChar = '$', metaChar = '#')
 | 
			
		||||
#import xmltree, strutils, uri
 | 
			
		||||
#import ../types, ../formatters, ./tweet
 | 
			
		||||
#
 | 
			
		||||
#proc renderConversation*(conversation: Conversation): string =
 | 
			
		||||
<div class="conversation" id="tweets">
 | 
			
		||||
  <div class="main-thread">
 | 
			
		||||
    #if conversation.before.len > 0:
 | 
			
		||||
    <div class="before-tweet">
 | 
			
		||||
      #for tweet in conversation.before:
 | 
			
		||||
      ${renderTweet(tweet)}
 | 
			
		||||
      #end for
 | 
			
		||||
    </div>
 | 
			
		||||
    #end if
 | 
			
		||||
    <div class="main-tweet">
 | 
			
		||||
      ${renderTweet(conversation.tweet)}
 | 
			
		||||
    </div>
 | 
			
		||||
    #if conversation.after.len > 0:
 | 
			
		||||
    <div class="after-tweet">
 | 
			
		||||
      #for tweet in conversation.after:
 | 
			
		||||
      ${renderTweet(tweet)}
 | 
			
		||||
      #end for
 | 
			
		||||
    </div>
 | 
			
		||||
    #end if
 | 
			
		||||
  </div>
 | 
			
		||||
  #if conversation.replies.len > 0:
 | 
			
		||||
  <div class="replies">
 | 
			
		||||
    #for thread in conversation.replies:
 | 
			
		||||
    <div class="thread">
 | 
			
		||||
      #for tweet in thread:
 | 
			
		||||
      ${renderTweet(tweet)}
 | 
			
		||||
      #end for
 | 
			
		||||
    </div>
 | 
			
		||||
    #end for
 | 
			
		||||
  </div>
 | 
			
		||||
  #end if
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
							
								
								
									
										50
									
								
								src/views/general.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/views/general.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,50 @@
 | 
			
		|||
#? stdtmpl(subsChar = '$', metaChar = '#')
 | 
			
		||||
#import user
 | 
			
		||||
#import xmltree
 | 
			
		||||
#
 | 
			
		||||
#proc renderMain*(body: string): string =
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
  <head>
 | 
			
		||||
    <title>Nitter</title>
 | 
			
		||||
    <link rel="stylesheet" type="text/css" href="/style.css">
 | 
			
		||||
  </head>
 | 
			
		||||
 | 
			
		||||
  <body>
 | 
			
		||||
    <nav id="nav" class="nav-bar container">
 | 
			
		||||
      <div class="inner-nav">
 | 
			
		||||
        <div class="item">
 | 
			
		||||
          <a href="/" class="site-name">twatter</a>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </nav>
 | 
			
		||||
 | 
			
		||||
    <div id="content" class="container">
 | 
			
		||||
      ${body}
 | 
			
		||||
    </div>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderSearchPanel*(): string =
 | 
			
		||||
<div class="panel">
 | 
			
		||||
  <div class="search-panel">
 | 
			
		||||
    <form action="search" method="post">
 | 
			
		||||
      <input type="text" name="query" placeholder="Enter username...">
 | 
			
		||||
      <button type="submit" name="button">🔎</button>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderError*(error: string): string =
 | 
			
		||||
<div class="panel">
 | 
			
		||||
  <div class="error-panel">
 | 
			
		||||
    <span>${error}</span>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc showError*(error: string): string =
 | 
			
		||||
${renderMain(renderError(error))}
 | 
			
		||||
#end proc
 | 
			
		||||
							
								
								
									
										99
									
								
								src/views/tweet.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/views/tweet.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,99 @@
 | 
			
		|||
#? stdtmpl(subsChar = '$', metaChar = '#')
 | 
			
		||||
#import xmltree, strutils, times, sequtils, uri
 | 
			
		||||
#import ../types, ../formatters, ../utils
 | 
			
		||||
#
 | 
			
		||||
#proc renderHeading(tweet: Tweet): string =
 | 
			
		||||
#if tweet.retweetBy != "":
 | 
			
		||||
  <div class="retweet">
 | 
			
		||||
    <span>🔄 ${tweet.retweetBy} retweeted</span>
 | 
			
		||||
  </div>
 | 
			
		||||
#end if
 | 
			
		||||
#if tweet.pinned:
 | 
			
		||||
  <div class="pinned">
 | 
			
		||||
    <span>📌 Pinned Tweet</span>
 | 
			
		||||
  </div>
 | 
			
		||||
#end if
 | 
			
		||||
<div class="media-heading">
 | 
			
		||||
  <div class="heading-name-row">
 | 
			
		||||
    <img class="avatar" src=${tweet.profile.getUserpic("_bigger").getSigUrl("pic")}>
 | 
			
		||||
    <div class="name-and-account-name">
 | 
			
		||||
      ${linkUser(tweet.profile, "h4", class="username", username=false)}
 | 
			
		||||
      ${linkUser(tweet.profile, "", class="account-name")}
 | 
			
		||||
    </div>
 | 
			
		||||
    <span class="heading-right">
 | 
			
		||||
      <a href="${tweet.link}" class="timeago faint-link">
 | 
			
		||||
        <time title="${tweet.time.format("d/M/yyyy', ' HH:mm:ss")}">${tweet.shortTime}</time>
 | 
			
		||||
      </a>
 | 
			
		||||
    </span>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderMediaGroup(tweet: Tweet): string =
 | 
			
		||||
#let groups = if tweet.photos.len > 2: tweet.photos.distribute(2) else: @[tweet.photos]
 | 
			
		||||
#let groupStyle = if groups.len == 1 and groups[0].len < 2: "" else: "background-color: #0f0f0f;"
 | 
			
		||||
#var first = true
 | 
			
		||||
<div class="attachments media-body" style="${groupStyle}">
 | 
			
		||||
#for photos in groups:
 | 
			
		||||
  #let style = if first: "" else: "margin-top: .25em;"
 | 
			
		||||
  <div class="gallery-row cover-fit" style="${style}">
 | 
			
		||||
    #for photo in photos:
 | 
			
		||||
    <div class="attachment image">
 | 
			
		||||
      ##TODO: why doesn't this work?
 | 
			
		||||
      <a href=${getSigUrl(photo & ":large", "pic")} target="_blank" class="image-attachment">
 | 
			
		||||
        #let style = if photos.len > 1 or groups.len > 1: "display: flex;" else: ""
 | 
			
		||||
        #let istyle = if photos.len > 1 or groups.len > 1: "" else: "border-radius: 7px;"
 | 
			
		||||
        <div class="still-image" style="${style}">
 | 
			
		||||
          <img src=${getSigUrl(photo, "pic")} referrerpolicy="" style="${istyle}">
 | 
			
		||||
        </div>
 | 
			
		||||
      </a>
 | 
			
		||||
    </div>
 | 
			
		||||
    #end for
 | 
			
		||||
  </div>
 | 
			
		||||
  #first = false
 | 
			
		||||
#end for
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderGif(tweet: Tweet): string =
 | 
			
		||||
#let thumbUrl = getGifThumb(tweet).getSigUrl("pic")
 | 
			
		||||
#let videoUrl = getGifSrc(tweet).getSigUrl("video")
 | 
			
		||||
<div class="attachments media-body">
 | 
			
		||||
  <div class="gallery-row" style="max-height: unset;">
 | 
			
		||||
    <div class="attachment image">
 | 
			
		||||
      <video poster=${thumbUrl} style="width: 100%; height: 100%;" autoplay muted loop>
 | 
			
		||||
        <source src=${videoUrl} type="video/mp4">
 | 
			
		||||
      </video>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderStats(tweet: Tweet): string =
 | 
			
		||||
<div class="tweet-stats">
 | 
			
		||||
  <span class="tweet-stat">💬 ${$tweet.replies}</span>
 | 
			
		||||
  <span class="tweet-stat">🔄 ${$tweet.retweets}</span>
 | 
			
		||||
  <span class="tweet-stat">👍 ${$tweet.likes}</span>
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderTweet*(tweet: Tweet; class=""): string =
 | 
			
		||||
<div class="${class}">
 | 
			
		||||
  <div class="status-el">
 | 
			
		||||
    <div class="status-body">
 | 
			
		||||
      ${renderHeading(tweet)}
 | 
			
		||||
      <div class="status-content-wrapper">
 | 
			
		||||
        <div class="status-content media-body">
 | 
			
		||||
          ${linkifyText(tweet.text)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      #if tweet.photos.len > 0:
 | 
			
		||||
        ${renderMediaGroup(tweet)}
 | 
			
		||||
      #elif tweet.gif.len > 0:
 | 
			
		||||
        ${renderGif(tweet)}
 | 
			
		||||
      #end if
 | 
			
		||||
      ${renderStats(tweet)}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
							
								
								
									
										100
									
								
								src/views/user.nim
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/views/user.nim
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,100 @@
 | 
			
		|||
#? stdtmpl(subsChar = '$', metaChar = '#')
 | 
			
		||||
#import xmltree, strutils, uri, htmlgen
 | 
			
		||||
#import ../types, ../formatters, ../utils
 | 
			
		||||
#import ./tweet
 | 
			
		||||
#
 | 
			
		||||
#proc renderProfileCard*(profile: Profile): string =
 | 
			
		||||
#let pic = profile.getUserpic().getSigUrl("pic")
 | 
			
		||||
#let smallPic = profile.getUserpic("_200x200").getSigUrl("pic")
 | 
			
		||||
<div class="profile-card">
 | 
			
		||||
  <a class="profile-card-avatar" href="${pic}">
 | 
			
		||||
    <img src="${smallPic}">
 | 
			
		||||
  </a>
 | 
			
		||||
  <div class="profile-card-tabs">
 | 
			
		||||
    <div class="profile-card-tabs-name">
 | 
			
		||||
      ${linkUser(profile, "h1", class="profile-card-name", username=false)}
 | 
			
		||||
      ${linkUser(profile, "h2", class="profile-card-username")}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="profile-card-extra">
 | 
			
		||||
    <div class="profile-bio">
 | 
			
		||||
      #if profile.description.len > 0:
 | 
			
		||||
      <div class="profile-description">
 | 
			
		||||
        <p>${linkifyText(xmltree.escape(profile.description))}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
      #end if
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="profile-card-extra-links">
 | 
			
		||||
      <ul class="profile-statlist">
 | 
			
		||||
        <li class="tweets">
 | 
			
		||||
          <span class="profile-stat-header">Tweets</span>
 | 
			
		||||
          <span>${$profile.tweets}</span>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li class="followers">
 | 
			
		||||
          <span class="profile-stat-header">Followers</span>
 | 
			
		||||
          <span>${$profile.followers}</span>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li class="following">
 | 
			
		||||
          <span class="profile-stat-header">Following</span>
 | 
			
		||||
          <span>${$profile.following}</span>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderBanner(profile: Profile): string =
 | 
			
		||||
#if "#" in profile.banner:
 | 
			
		||||
<div style="${profile.banner}" class="profile-banner-color"></div>
 | 
			
		||||
#else:
 | 
			
		||||
#let url = getSigUrl(profile.banner, "pic")
 | 
			
		||||
<a href="${url}">
 | 
			
		||||
  <img src="${url}">
 | 
			
		||||
</a>
 | 
			
		||||
#end if
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderTimeline*(tweets: Tweets; profile: Profile; beginning: bool): string =
 | 
			
		||||
<div id="tweets">
 | 
			
		||||
  #if profile.protected:
 | 
			
		||||
  <div class="timeline-protected">
 | 
			
		||||
    <h2 class="timeline-protected-header">This account's Tweets are protected.</h2>
 | 
			
		||||
    <p class="timeline-protected-explanation">Only confirmed followers have access to @${profile.username}'s Tweets.
 | 
			
		||||
  </div>
 | 
			
		||||
  #end if
 | 
			
		||||
  #if not beginning:
 | 
			
		||||
  <div class="show-more status-el">
 | 
			
		||||
    <a href="/${profile.username}">Load newest tweets</a>
 | 
			
		||||
  </div>
 | 
			
		||||
  #end if
 | 
			
		||||
  #var retweets: Tweets
 | 
			
		||||
  #for tweet in tweets:
 | 
			
		||||
    #if tweet in retweets: continue
 | 
			
		||||
    #end if
 | 
			
		||||
    #if tweet.retweetBy.len > 0: retweets.add tweet
 | 
			
		||||
    #end if
 | 
			
		||||
    ${renderTweet(tweet, "timeline-tweet")}
 | 
			
		||||
  #end for
 | 
			
		||||
  #if tweets.len > 0:
 | 
			
		||||
  <div class="show-more">
 | 
			
		||||
    <a href="/${profile.username}?after=${$tweets[^1].id}">Load older tweets</a>
 | 
			
		||||
  </div>
 | 
			
		||||
  #end if
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
#
 | 
			
		||||
#proc renderProfile*(profile: Profile; tweets: Tweets; beginning: bool): string =
 | 
			
		||||
<div class="profile-tabs">
 | 
			
		||||
  <div class="profile-banner">
 | 
			
		||||
    ${renderBanner(profile)}
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="profile-tab">
 | 
			
		||||
    ${renderProfileCard(profile)}
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="timeline-tab">
 | 
			
		||||
    ${renderTimeline(tweets, profile, beginning)}
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
#end proc
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue