this repo has no description
atproto bluesky typescript express

init

+28
.dockerignore
···
··· 1 + **/.classpath 2 + **/.dockerignore 3 + **/.env 4 + **/.git 5 + **/.gitignore 6 + **/.project 7 + **/.settings 8 + **/.toolstarget 9 + **/.vs 10 + **/.vscode 11 + **/.next 12 + **/.cache 13 + **/*.*proj.user 14 + **/*.dbmdl 15 + **/*.jfm 16 + **/charts 17 + **/docker-compose* 18 + **/compose.y*ml 19 + **/Dockerfile* 20 + **/node_modules 21 + **/npm-debug.log 22 + **/obj 23 + **/secrets.dev.yaml 24 + **/values.dev.yaml 25 + **/build 26 + **/dist 27 + LICENSE 28 + README.md
+7
.gitignore
···
··· 1 + node_modules/* 2 + dist/* 3 + *.env 4 + *.db 5 + *.lock 6 + package-lock.json 7 + docker-compose.yml
+18
.tangled/workflows/test.yml
···
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: ["main", "ci"] 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - nodejs_24 10 + - gcc 11 + - gnumake 12 + 13 + steps: 14 + - name: install dependecies 15 + command: npm install 16 + 17 + - name: test resolver 18 + command: npm run test:resolver
+12
Dockerfile
···
··· 1 + # syntax=docker/dockerfile:1 2 + ARG NODE_VERSION=22.21.0 3 + FROM node:${NODE_VERSION}-alpine 4 + ENV NODE_ENV production 5 + WORKDIR /usr/src/app 6 + RUN --mount=type=bind,source=/package.json,target=/usr/src/app/package.json \ 7 + --mount=type=cache,target=/root/.yarn \ 8 + yarn install 9 + USER node 10 + COPY . . 11 + EXPOSE 3000 12 + CMD yarn start
+19
LICENSE
···
··· 1 + MIT License 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy 4 + of this software and associated documentation files (the "Software"), to deal 5 + in the Software without restriction, including without limitation the rights 6 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 + copies of the Software, and to permit persons to whom the Software is 8 + furnished to do so, subject to the following conditions: 9 + 10 + The above copyright notice and this permission notice shall be included in all 11 + copies or substantial portions of the Software. 12 + 13 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 + SOFTWARE.
+2
README.md
···
··· 1 + # Skittr 2 + A lightweight Bluesky client + AppView service.
+12
docker-compose-example.yml
···
··· 1 + services: 2 + server: 3 + build: 4 + context: . 5 + dockerfile: Dockerfile 6 + environment: 7 + NODE_ENV: production 8 + SESSION_SECRET: 9 + PUBLIC_URL: 10 + PORT: 11 + ports: 12 + - 3000:3000
+106
lexicons/app/bsky/feed/post.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record containing a Bluesky post.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["text", "createdAt"], 12 + "properties": { 13 + "text": { 14 + "type": "string", 15 + "maxLength": 3000, 16 + "maxGraphemes": 300, 17 + "description": "The primary post content. May be an empty string, if there are embeds." 18 + }, 19 + "entities": { 20 + "type": "array", 21 + "description": "DEPRECATED: replaced by app.bsky.richtext.facet.", 22 + "items": { "type": "ref", "ref": "#entity" } 23 + }, 24 + "facets": { 25 + "type": "array", 26 + "description": "Annotations of text (mentions, URLs, hashtags, etc)", 27 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 28 + }, 29 + "reply": { "type": "ref", "ref": "#replyRef" }, 30 + "embed": { 31 + "type": "union", 32 + "refs": [ 33 + "app.bsky.embed.images", 34 + "app.bsky.embed.video", 35 + "app.bsky.embed.external", 36 + "app.bsky.embed.record", 37 + "app.bsky.embed.recordWithMedia" 38 + ] 39 + }, 40 + "langs": { 41 + "type": "array", 42 + "description": "Indicates human language of post primary text content.", 43 + "maxLength": 3, 44 + "items": { "type": "string", "format": "language" } 45 + }, 46 + "labels": { 47 + "type": "union", 48 + "description": "Self-label values for this post. Effectively content warnings.", 49 + "refs": ["com.atproto.label.defs#selfLabels"] 50 + }, 51 + "tags": { 52 + "type": "array", 53 + "description": "Additional hashtags, in addition to any included in post text and facets.", 54 + "maxLength": 8, 55 + "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } 56 + }, 57 + "createdAt": { 58 + "type": "string", 59 + "format": "datetime", 60 + "description": "Client-declared timestamp when this post was originally created." 61 + }, 62 + "via": { 63 + "type": "object", 64 + "required": ["name", "version"], 65 + "properties": { 66 + "name": { "type": "string", "maxLength": 64 }, 67 + "version": { "type": "string", "maxLength": 64 }, 68 + "url": { "type": "string", "maxLength": 64 } 69 + }, 70 + "description": "Client metadata for this post." 71 + } 72 + } 73 + } 74 + }, 75 + "replyRef": { 76 + "type": "object", 77 + "required": ["root", "parent"], 78 + "properties": { 79 + "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, 80 + "parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" } 81 + } 82 + }, 83 + "entity": { 84 + "type": "object", 85 + "description": "Deprecated: use facets instead.", 86 + "required": ["index", "type", "value"], 87 + "properties": { 88 + "index": { "type": "ref", "ref": "#textSlice" }, 89 + "type": { 90 + "type": "string", 91 + "description": "Expected values are 'mention' and 'link'." 92 + }, 93 + "value": { "type": "string" } 94 + } 95 + }, 96 + "textSlice": { 97 + "type": "object", 98 + "description": "Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings.", 99 + "required": ["start", "end"], 100 + "properties": { 101 + "start": { "type": "integer", "minimum": 0 }, 102 + "end": { "type": "integer", "minimum": 0 } 103 + } 104 + } 105 + } 106 + }
+28
lexicons/lol/skittr/actor/getTheme.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "lol.skittr.actor.getTheme", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get actor's theme", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "Handle or DID of the actor" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "ref", 23 + "ref": "lol.skittr.actor.theme#main" 24 + } 25 + } 26 + } 27 + } 28 + }
+52
lexicons/lol/skittr/actor/theme.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "lol.skittr.actor.theme", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Profile customization values", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["bg_color", "text_color", "link_color", "side_color", "side_border"], 12 + "properties": { 13 + "bg_color": { 14 + "type": "string", 15 + "maxGraphemes": 64, 16 + "maxLength": 640 17 + }, 18 + "text_color": { 19 + "type": "string", 20 + "maxGraphemes": 64, 21 + "maxLength": 640 22 + }, 23 + "link_color": { 24 + "type": "string", 25 + "maxGraphemes": 64, 26 + "maxLength": 640 27 + }, 28 + "side_color": { 29 + "type": "string", 30 + "maxGraphemes": 64, 31 + "maxLength": 640 32 + }, 33 + "side_border": { 34 + "type": "string", 35 + "maxGraphemes": 64, 36 + "maxLength": 640 37 + }, 38 + "background": { 39 + "type": "blob", 40 + "description": "A background image, must be either PNG or JPEG", 41 + "accept": ["image/png", "image/jpeg"], 42 + "maxSize": 1000000 43 + }, 44 + "repeat": { 45 + "type": "boolean", 46 + "default": false 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+37
lexicons/lol/skittr/server/getMeta.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "lol.skittr.server.getMeta", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get information about the AppView", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["software", "version", "supported_lexicons"], 13 + "properties": { 14 + "software": { 15 + "type": "string" 16 + }, 17 + "version": { 18 + "type": "string" 19 + }, 20 + "supported_lexicons": { 21 + "type": "array", 22 + "items": { 23 + "type": "string" 24 + } 25 + }, 26 + "supported_extensions": { 27 + "type": "array", 28 + "items": { 29 + "type": "string" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+32
package.json
···
··· 1 + { 2 + "name": "skittr", 3 + "version": "0.0.1", 4 + "license": "MIT", 5 + "type": "module", 6 + "dependencies": { 7 + "@atproto/api": "^0.13.14", 8 + "@atproto/common": "^0.4.12", 9 + "@types/better-sqlite3": "^7.6.13", 10 + "@types/cookie-parser": "^1.4.7", 11 + "@types/express": "^5.0.0", 12 + "@types/express-session": "^1.18.0", 13 + "@types/multer": "^1.4.12", 14 + "@types/node": "^24.0.3", 15 + "better-sqlite3": "^12.4.1", 16 + "body-parser": "^1.20.3", 17 + "cookie-parser": "^1.4.7", 18 + "dotenv": "^16.4.5", 19 + "express": "^4.21.1", 20 + "express-handlebars": "^8.0.1", 21 + "express-session": "^1.18.1", 22 + "moment": "^2.30.1", 23 + "multer": "^1.4.5-lts.2", 24 + "tsx": "^4.19.2", 25 + "typescript": "^5.6.3", 26 + "undici": "^7.16.0" 27 + }, 28 + "scripts": { 29 + "start": "tsx src/index.ts", 30 + "test:resolver": "tsx src/tests/resolver.test.ts" 31 + } 32 + }
public/favicon.ico

This is a binary file and will not be displayed.

public/img/arr.gif

This is a binary file and will not be displayed.

public/img/arr2.gif

This is a binary file and will not be displayed.

public/img/bg.gif

This is a binary file and will not be displayed.

public/img/bsky.png

This is a binary file and will not be displayed.

public/img/btn-bg.gif

This is a binary file and will not be displayed.

public/img/feed.png

This is a binary file and will not be displayed.

public/img/girl.gif

This is a binary file and will not be displayed.

public/img/icon_star_empty.gif

This is a binary file and will not be displayed.

public/img/icon_star_full.gif

This is a binary file and will not be displayed.

public/img/icon_trash.gif

This is a binary file and will not be displayed.

public/img/loader.gif

This is a binary file and will not be displayed.

public/img/mobile.gif

This is a binary file and will not be displayed.

public/img/toggle_closed.gif

This is a binary file and will not be displayed.

public/img/toggle_opened.gif

This is a binary file and will not be displayed.

public/img/tour_1.gif

This is a binary file and will not be displayed.

public/img/tour_2.gif

This is a binary file and will not be displayed.

public/img/twitter_57.png

This is a binary file and will not be displayed.

public/img/verified.png

This is a binary file and will not be displayed.

+71
public/robots.txt
···
··· 1 + User-agent: * 2 + Disallow: 3 + 4 + User-agent: AdsBot-Google 5 + Disallow: / 6 + 7 + User-agent: Amazonbot 8 + Disallow: / 9 + 10 + User-agent: anthropic-ai 11 + Disallow: / 12 + 13 + User-agent: AwarioRssBot 14 + Disallow: / 15 + 16 + User-agent: AwarioSmartBot 17 + Disallow: / 18 + 19 + User-agent: Bytespider 20 + Disallow: / 21 + 22 + User-agent: CCBot 23 + Disallow: / 24 + 25 + User-agent: ChatGPT-User 26 + Disallow: / 27 + 28 + User-agent: ClaudeBot 29 + Disallow: / 30 + 31 + User-agent: Claude-Web 32 + Disallow: / 33 + 34 + User-agent: cohere-ai 35 + Disallow: / 36 + 37 + User-agent: DataForSeoBot 38 + Disallow: / 39 + 40 + User-agent: FacebookBot 41 + Disallow: / 42 + 43 + User-agent: Google-Extended 44 + Disallow: / 45 + 46 + User-agent: GPTBot 47 + Disallow: / 48 + 49 + User-agent: ImagesiftBot 50 + Disallow: / 51 + 52 + User-agent: magpie-crawler 53 + Disallow: / 54 + 55 + User-agent: omgili 56 + Disallow: / 57 + 58 + User-agent: omgilibot 59 + Disallow: / 60 + 61 + User-agent: peer39_crawler 62 + Disallow: / 63 + 64 + User-agent: peer39_crawler/1.0 65 + Disallow: / 66 + 67 + User-agent: PerplexityBot 68 + Disallow: / 69 + 70 + User-agent: YouBot 71 + Disallow: /
+346
public/screen.css
···
··· 1 + * { margin: 0; padding: 0; } 2 + a { text-decoration: none; color: blue; } 3 + a:hover { text-decoration: underline; } 4 + ul { list-style: none; } 5 + 6 + /* bulleted list with » */ 7 + ul.bullets { list-style-type: square; } 8 + ul.bullets li { margin-left: 1em; } 9 + ul.bullets { padding: 1em; } 10 + 11 + a img, form, fieldset { border: 0; } 12 + hr { display: none; } 13 + abbr { text-decoration: none; border-bottom: none; } 14 + 15 + body { text-align: center; font: 0.75em/1.5 'Lucida Grande', sans-serif; color: #333; } 16 + 17 + #dim-screen { position: absolute; background: #000000; z-index: 99; width: 100%; height: 100%; top: 0px; left: 0px; opacity: .90; filter: alpha(opacity=90); display: none; margin: 0 auto; } 18 + #dev_header { padding: 5px; height: 25px; background: #eee; font-weight: bold; font-size: 20px; text-align: right; border-bottom: 1px solid #000; } 19 + #announcement { border: 5px solid #87BC44; background: white; font-weight: bold; padding: 10px; margin: 10px; font-size: 1.1em; } 20 + #container { width: 755px; margin: 0 auto; padding: 15px 0; text-align: left; position: relative; } 21 + #accessibility, #navigation h3, #footer h3 {position: absolute; left: -9999px; overflow: hidden;} 22 + 23 + #loader { position:absolute; top:5px; right:0px; padding:0px; background:#FFFFFF;border: 1px solid #CCCCCC; line-height:0px; } 24 + #logout_form { display: inline; padding: 0; margin: 0; } 25 + #logout_form div { display: none; } 26 + #chars_left_notice { color: #ccc; font-size: 22pt !important; } 27 + 28 + #container #flash .desc { padding-top: 11px; background: url(img/arr2.gif) no-repeat 27px 0px; margin-bottom: 9px; } 29 + #container #flash .desc p { display: block; background: #fff; font-size: 2.12em; line-height: 1.2em; padding: 7px; font-weight: bold; } 30 + #container #flash .thumb { padding-left: 26px; } 31 + 32 + #navigation, #footer { background: #fff; } 33 + 34 + #content { width: 555px; margin-top: 0px; float: left; padding-bottom: 15px; } 35 + #content #doingForm .bar { line-height: 1.9em; position: relative; padding: 0 10px; } 36 + #content #doingForm .bar h3 { font-size: 1.5em;} 37 + #content #doingForm .bar h3 label { font-weight: bold; color: #000; padding-right: 170px; letter-spacing: -1px; } 38 + #content #doingForm .bar span { font-size: 0.92em; display: block; position: absolute; top: 0; right: 10px; } 39 + #content #doingForm .bar span#submit_loading { padding-top:.3em; } 40 + #content #doingForm .info { background: #fff; padding-top: 3px; text-align: center; } 41 + #content #doingForm textarea { 42 + height: 2.5em; 43 + width: 500px; 44 + padding: 5px; 45 + font: 1.15em/1.1 'Lucida Grande', sans-serif; 46 + overflow: auto; 47 + } 48 + #content #submit { display: block; padding: 5px 10px; margin: 7px auto; font: normal 1.12em/1.5 'Lucida Grande', sans-serif;} 49 + #content h2.thumb { font-size: 2.8em; } 50 + #content h2.thumb img { float: left; margin: 0 10px 0 0; border: 1px solid #999; } 51 + #content h2.thumb small { font-size: .4em; } 52 + #content div.desc { background: url(img/arr2.gif) no-repeat 14px 0px; margin: 6px 0 12px 0; } 53 + #content div.desc p { display: block; background: #fff; font-size: 2.12em; line-height: 1.2em; padding: 0; } 54 + #content div.desc .meta { font-size: .98em; padding: 0; font-weight: normal; text-indent: 0; } 55 + #content div.desc .meta img { vertical-align: top; } 56 + #content .tabMenu { text-align: center; margin: 25px 0 0;} 57 + #content .tabMenu li { display: inline; margin-left: -5px;} 58 + #content .tabMenu li a { display: inline; padding: 3px 20px 1px 20px; background: #e6e6e6; text-decoration: none; color: #4c4c4c; border-top: 1px solid #cecece;border-right: 1px solid #cecece; border-left: 1px solid #cecece; } 59 + #content .tabMenu li a:hover { text-decoration: none; color: black; background: #999;} 60 + #content .tabMenu li.active a { background: #fff; color: #000; border-bottom: 1px solid #fff; } 61 + #content .tab { background: #fff; padding: 3px; border-top: 1px solid #cecece;} 62 + #content .tab #ad { text-align: center; } 63 + #content .tab p { text-indent: 1em; } 64 + #content .section_links { border: 1px solid #cecece; padding: 4px 15px; margin: 1px; } 65 + #content .doing { font-size: 1.2em; line-height: 1.1; width: 100%; } 66 + #content .doing td { border-bottom: 1px solid #bbb; vertical-align: middle; } 67 + #content .doing .thumb { padding: 6px 5px 4px 4px; width: 50px; vertical-align:top; } 68 + #content .doing .meta { font-size: 0.80em; } 69 + #content .doing .meta img { vertical-align: top; } 70 + #content .doing .user_actions { vertical-align: top; width: 16px; } 71 + #content .doing .status_activity { margin: 4px 0 0 0; padding: 10px 0 0 20px; } 72 + #content .doing .status_activity .activity { margin: 0 0 5px; } 73 + #content .doing .status_activity .activity .content { vertical-align:top; margin: 0 0 0 5px; font-size:.8em; } 74 + #content .green_button { background-color: #D4FF84; border: 4px solid #669933; padding: 5px; cursor:pointer; } 75 + #content .green_button a { color: black; text-decoration:none; font-weight: bold; } 76 + #content #permalink { padding-top: 153px; } 77 + #content #permalink div.desc { background: 0; } 78 + body.status #content #permalink div.desc { margin: 0; } 79 + #content #permalink h2 { background: url(img/arr.gif) no-repeat 335px 0; padding: 16px 0 5px 321px; font-size: 2em; } 80 + #content #permalink #ad { text-align: right; } 81 + #content .desc .status_actions { float: right; padding: 5px 5px; } 82 + #content .desc .status_actions li { display: inline; } 83 + #content .desc .status_actions li img { vertical-align: middle; } 84 + 85 + .bottom_nav { padding: 0; margin-top: 20px;} 86 + .pagination { float: right; } 87 + .pagination ul { padding: 0; margin: 0; } 88 + .pagination li { float: left; padding: 0 7px; } 89 + 90 + .subpage #content .wrapper { background: #fff; padding: 5px 10px 15px; } 91 + .subpage #content h1, .subpage #content h2, .subpage #content h3, .subpage #content h4, .subpage #content h5 { margin: 13px 0 4px 0; } 92 + .subpage #content p { line-height: 1.2; margin: 5px 0; } 93 + .subpage #content ul { padding-left: 30px; } 94 + .subpage #content ol, #side ol { padding-left: 30px; } 95 + .subpage #content code { font-size: 1.2em; } 96 + 97 + #side { float: right; width: 166px; padding: 12px 10px; margin-top: 10px; margin-bottom: 10px; border: 1px solid #87bc44; background: #DDFFCC; line-height: 1.2; } 98 + .subpage #side { margin-top: 15px; } 99 + #side div.user_icon img { float: left; padding-right: 0.5em; } 100 + #side div.section { margin-bottom: 2em; } 101 + #side div.section-header { border-bottom: 1px solid #98D231; margin-bottom: 10px; } 102 + #side div.section-header h1 { font-weight: bold; color: #333; font-size: 1em; } 103 + #side .section-links { float: right; font-size: 0.9em; text-align:right } 104 + #side div.msg strong { display: block; font-size: 1.4em; } 105 + #side div.msg h3 { font-size: 1.25em; } 106 + #side ul { margin: 0; } 107 + #side ul.stats { margin: 0; padding: 0; } 108 + #side ul.stats li { text-align: right; line-height: 1.4em; } 109 + #side ul.stats li .numeric { font-size: 1.2em; } 110 + #side ul.stats .label { float: left; } 111 + #side .notify { border: 1px solid #87bc44; padding: 2px 5px; margin: 10px -3px; font-size: .9em; } 112 + #side .notify a { text-decoration: none; } 113 + #side .actions { border: 1px solid #87bc44; padding: 2px 5px; margin: 10px -3px; } 114 + #side .actions small { font-size: .9em; } 115 + #side .actions a { padding-left: 7px; } 116 + #side .featured { border: 1px solid #87bc44; padding: 2px 5px; margin: 10px -3px; } 117 + #side .featured img { vertical-align: middle; padding: 1px 0 1px 7px; } 118 + #side .promo { border: 1px solid #87bc44; background: #fff; padding: 10px 0px 10px 5px; margin-top: 8px; font-size: .9em; } 119 + #side .promo a { text-decoration: none; } 120 + 121 + #side p.complete { font-size: .9em; margin-top: 1em; } 122 + #side .notify { text-align: center; line-height: 1.5; padding: 5px 0; } 123 + div.join { text-align: center; } 124 + div.join input { background-color: #417596; color: white; font-size: 11pt; padding: .3em 2.5em; font-weight: bold; border: 1px solid black; } 125 + div.join input:hover { background-color: #294B60; } 126 + #side ul.todo { font-style: italic; } 127 + #side #submit { display: block; padding: 3px 10px; margin: 5px auto; font: bold 1.12em/1.5 'Lucida Grande', sans-serif; } 128 + #side #friends img { padding: 0px; } 129 + #side #friends .non-friend { opacity: .7; filter: alpha(opacity=70); } 130 + #side .note { background: #fff; font-size: .95em; padding: 3px; border: 1px dashed #aaa; } 131 + #side .note strong { color:red; } 132 + #side .note li+li { border-top: 1px solid #ccc; padding-top: 4px; } 133 + #side .note a { text-decoration: underline; } 134 + #side .about li {padding-bottom: 3px;} 135 + #side .about .label { font-weight: bold } 136 + 137 + #navigation { position: absolute; top: 32px; right: 0; padding: 6px 5px 6px 5px; line-height: 1.5em; text-align: center; } 138 + #navigation li { display: inline; padding: 0 0 0 5px; } 139 + #navigation li:before { content: ' '; padding-right: 0; } 140 + #navigation li.first:before { content: ''; padding-right: 0; } 141 + 142 + #footer { clear: left; width: 555px; text-align: center; padding: 8px 0; line-height: 1; } 143 + #footer li { display: inline; padding: 0 0 0 5px; } 144 + #footer li.first:before { content: ''; padding-right: 0; } 145 + 146 + /* front */ 147 + 148 + .h { position: absolute; left: -9999px; } 149 + #front #content { width: 755px; padding-bottom: 0; background: transparent url(img/arr2.gif) no-repeat scroll 25px 0px; padding-top: 11px; margin-top: 6px; float: left; } 150 + #front .wrapper { background: #fff none repeat scroll 0%; float: left; width: 715px; padding: 17px 20px;} 151 + 152 + #front .intro { width: 510px; float: left; } 153 + #front h2 { font-size: 2em; color: #000; line-height: 1.0; float: left; } 154 + #front #menu { float: right; } 155 + #front #menu li { float: left; display: inline; margin: .2em 0 0 8px; } 156 + #front #menu li a { border: 1px dashed #a4a0a1; color: #a4a0a1; padding: 3px 12px; text-decoration: none; } 157 + #front #menu li a:hover, #front #menu li a:visited { border: 1px solid #999; color: #999; } 158 + #front #menu li.act a { border: 1px solid #000; color: #000; } 159 + #front img.tour { margin: 9px 0; border: 1px solid #000; } 160 + #front .intro p, #front .intro ul { font-size: 1.2em; line-height: 1.3; color: #000; margin: 0 0 1em; } 161 + #front .intro ul { list-style: disc; margin-left: 1.2em; } 162 + #front p.teaser { font-size: 1.5em; padding: 0 4px; } 163 + #front p a.join { display: block; width: 9.5em; text-align: center; margin: 1em auto .5em; background: #97cd39; color: #fff; font-size: 1.5em; padding: 4px; border: 1px solid #000; } 164 + #front p a.join:hover { text-decoration: none; } 165 + #front #footer { width: 755px; } 166 + 167 + #signin { float: right; width: 179px; margin: 1.55em 0;} 168 + #signin legend { font-size: 1.2em; font-weight: bold; } 169 + #signin p { margin: 5px 0; } 170 + 171 + #signin input { width: 173px; } 172 + #signin input[type="submit"] { cursor: pointer; } 173 + #signin .remember { float: left; font-size: .85em; padding: .6em 0 0; } 174 + #signin .submit { float: right; } 175 + #signin .remember input, #signin .submit input { width: auto; color: #333; } 176 + #signin .forgot { clear: both; padding: .5em 0; font-size: .85em; } 177 + #signin .forgot a { color: #333; text-decoration: underline; } 178 + #signin .forgot a:hover { text-decoration: none; } 179 + #signin p.complete { font-size: .85em; text-align: center; background: #e8fecd; border: 1px solid #a9bf74; padding: 4px 20px; line-height: 1.2; } 180 + 181 + #whatistwitter { float: left; background: #afeff1; padding: 30px 0; margin: 0 auto; width: 755px; } 182 + #whatistwitter ul { width: 576px; float: left; display: inline; margin: 0 0 0 100px; color: #000; } 183 + #whatistwitter ul li { width: 169px; float: left; display: inline; margin: 0 23px 0 0; } 184 + #whatistwitter ul li blockquote { background: url(img/arr.gif) no-repeat 8% 100%; padding: 0 0 11px 0; margin: 0 0 1px 0; } 185 + #whatistwitter ul li blockquote p { background: #fff; font-size: .92em; line-height: 1.2; padding: 6px 5px; } 186 + #whatistwitter ul li cite { font-style: normal; font-size: .85em; } 187 + #whatistwitter ul li cite span strong { padding: 0 8px; } 188 + 189 + /* end front */ 190 + 191 + #settingsNav { margin: 0 0 20px 0; font-weight: normal; } 192 + 193 + fieldset { margin: 10px 0; } 194 + fieldset p { margin: 0 0 5px 0; } 195 + fieldset th,td { padding: 7px 3px; vertical-align: top; } 196 + fieldset th { text-align: right; width: 11em; padding-top: 10px; font-weight: normal; } 197 + fieldset small { color: #777; font-size: .97em; } 198 + fieldset input[type="text"], input[type="password"], select, checkbox { margin-right: 3px; border: 1px solid #aaa; padding: 4px 2px; } 199 + fieldset input[type="text"], input[type="password"] { width: 12em; } 200 + fieldset td[colspan="2"] { text-align: right; } 201 + fieldset label { white-space: nowrap; font-weight: normal; } 202 + 203 + fieldset ul li { padding: 5px 0; } 204 + fieldset ul li label { display: block; font-weight: bold; } 205 + fieldset ul li label sup { color: #888; } 206 + 207 + #user_search_form { margin-bottom: 3px; } 208 + #user_search_form img { margin-left: 2px; vertical-align: middle; } 209 + #user_search_q { font-size: 1em; color: #999; width: 90px; padding: 3px; } 210 + #user_search_form input[type="submit"] { padding:3px 0px; font-size: 1em; } 211 + 212 + #bio-pics { text-align: center; padding: 10px 0; } 213 + #bio-pics div { text-align: left; margin: auto; width: 158px; } 214 + #bio-pics img { padding-right: 2px; } 215 + 216 + #invite_preview { background-color: #eef; padding: 10px; } 217 + #invite_message { white-space: normal; } 218 + 219 + .direct_messages .bar h3 { padding: 4px 4px; } 220 + .direct_messages .bar h3 select { width: 13em; } 221 + 222 + .devices { width: 100%; } 223 + .devices small { font-size: 95%; } 224 + .devices .thumb img { border: 1px solid gray; } 225 + #create_device form { padding: 5px; } 226 + #create_device input[type="text"] { width: 12em; } 227 + #create_device select { width: 5em; } 228 + #create_device input[type="text"], #create_device input[type="submit"], #create_device select {font-size:1.5em; vertical-align: middle; padding: 4px 2px; } 229 + .not_verified { background-color: #ffc; } 230 + .subpage #content tr.not_verified code { display:block; font-size: 1.9em; color: green; font-weight: bold; text-align: center; } 231 + 232 + body#profile #content h2.thumb { font-size: 2.8em; line-height: 45px; } 233 + body#profile #content h2.thumb img#profile-image { margin: 10px 10px 0 0; } 234 + body#profile #content h2.thumb div#follow-details img#x { float: right; margin: 3px 0 0 0; cursor: pointer; border: none; } 235 + div#follow-control { margin-top: 3px; } 236 + div#follow-details { background: #F9FDAB; margin: 5px 0 10px 0; padding: 5px 10px 10px 10px; border: solid 1px #FDCC68; color: #000; line-height: 1.7em; display: none; font-size: 0.9em; } 237 + div#follow-flash { background: #F9FDAB; border: solid 1px #FDCC68; font-size: 0.9em; color: #000; line-height: 1.75em; margin: 5px 0; font-weight: bold; padding: 5px; } 238 + div#follow-details p { margin-top: 10px; } 239 + div#follow-actions #onoff { margin-left: 10px;} 240 + div#follow-details strong { display: inline; font-size: 120%; } 241 + div#follow-details div#notifications { margin-top: 10px;} 242 + div#follow-toggle { background-repeat: no-repeat; cursor:pointer; background-position: 2% 50%; padding:5px 5px 5px 20px; border: 1px solid #CCCCCC; } 243 + div#follow-toggle.closed { background-image: url('img/toggle_closed.gif'); } 244 + div#follow-toggle.opened { background-image: url('img/toggle_opened.gif'); } 245 + div#followed { background-color: #e6e6e6; border: 1px solid #D1D1D1; } 246 + .follow-button button,.follow-button input[type=submit], input[type=button].follow-button {background-color:#808080;color:#FFF;font-size:1em;font-weight:bold;border: 1px solid black;height:30px; width:75px;cursor:pointer;} 247 + .remove-button button,.remove-button input[type=submit], input[type=button].remove-button {background-color:#E6E6E6;color:#000;font-size:1em; width:75px;cursor:pointer;margin-left:3px;} 248 + .update-button button,.update-button input[type=submit], input[type=button].update-button {background-color:#808080;color:#FFF;font-size:1em;font-weight:bold;border: 1px solid black;cursor:pointer;margin-top:5px;} 249 + span#p { color:#999 } 250 + img.follow-icon { border: 0; margin: 1px 5px 3px 0; vertical-align: middle; } 251 + #content h2.thumb img.follow-icon { border: 0; margin: 1px 7px 3px 0; vertical-align: middle; } 252 + button.small { background: #e6e6e6; width: 44px; height: 16px; padding: 0; font-size: 9px; text-align: center; margin: 2px 2px 1px 2px; border: none; line-height: 9px; cursor: pointer; } 253 + button.med { background: #e6e6e6; width: 75px; height: 16px; padding: 0px; font-size: 9px; text-align: center; margin: 2px 2px 1px 2px; border: none; } 254 + div.big-btn { background: #e6e6e6; width: 75pt; height: 19pt; padding: 8px 3px 4px 3px; text-align: center; font-weight: bold; text-decoration: none; font-size: 95%; vertical-align: middle; cursor: pointer; } 255 + div.long-btn { background: #e6e6e6; width: 200px; padding: 3px 2px 2px 2px; font-size: 11px; vertical-align: middle; color: #000; cursor: pointer; } 256 + div.med-btn { background: #e6e6e6; width: 75px; height: 18px; padding: 1px 3px 1px 21px; font-size: 11px; vertical-align: middle; color: #000; cursor: pointer; } 257 + div.short-btn { background: #e6e6e6; width: 60px; height: 14px; padding: 2px 2px 1px 21px; font-weight: bold; font-size: 11px; line-height: 14px; vertical-align: middle; color: #000; cursor: pointer; } 258 + #content h2.thumb img.ticon { border: none; float: left; margin: 2px 2px 3px 3px; vertical-align: middle; } 259 + body#profile #content h2.thumb { margin-bottom: 10px; } 260 + input.big-btn { background: url(img/btn-bg.gif) no-repeat top left; border: none; display: block; width: 88px; height: 31px; 261 + text-align: center; font-weight: bold; text-decoration: none; font-size: 95%; vertical-align: middle; } 262 + #notifications-sub .desc { margin-left:3px;font-style:italic; } 263 + 264 + form.device_control { display: inline; } 265 + form.device_control select { font-size: 85%; } 266 + 267 + img.lock { vertical-align: middle; margin-bottom: 2px; } 268 + 269 + #downtime-announce { background: #fff; border: 1px solid grey; padding: 7px; color: #333; font-size: 1.1em; } 270 + .note { background: #fff; border: 1px solid grey; padding: 7px; color: #333; font-size: 1.1em; } 271 + 272 + .person-actions { padding:7px 0 0 0; font-size: 90%; } 273 + 274 + #admin_side { background: #f9f6ba; font-size: .9em; float: right; position: absolute; left:547px; width: 185px; padding: 0px; margin: 20px; margin-bottom: 10px; line-height: 1.4; } 275 + #admin_side .admin { margin: 10px 10px; } 276 + 277 + a#back-link { margin-left: 20px; font-size:120% } 278 + 279 + div#buffer { padding: 17px; } 280 + 281 + #username_url { color: green; font-weight: bold; } 282 + .username_taken { color: red; } 283 + 284 + .fieldWithErrors { display: inline; } 285 + .fieldWithErrors input, .fieldWithErrors select { background-color: #ffdfdf; } 286 + 287 + .error { color: red; } 288 + .highlight{ background-color: #f9f6ba; } 289 + .beta { font-size: .9em; background-color: #f9f6ba; } 290 + .midgrey { font-size: 1.2em; color: #999; padding-bottom: 4px; } 291 + 292 + .user_search { border: 1px solid #bbbbbb; clear: both; padding: 10px; margin-bottom: 10px; } 293 + .user_search .screen_name { font-weight: normal; font-size: 2em; vertical-align: bottom; text-decoration: none; } 294 + .user_search .follow { float: right; font-size: 1em; } 295 + .user_search .details{ clear: both; padding-top: 4px; } 296 + .user_search .profile_img { float: left; margin-right: 10px; } 297 + 298 + .search_following { background-color: #D8F4F5; border: 1px solid #84C2D2; } 299 + .search_following button { background-color: #fff; border: 1px solid #84C2D2; } 300 + 301 + /* trying to phase out the "flatbutton" style and just have all buttons default to the same style */ 302 + input.submit, button, input[type=submit], input[type=button], input[type="file"] > input[type="button"] { background-color: #E6E6E6; border: 1px solid #ccc; padding-top: 1px solid #fff; font-size: 1em; padding: 4px 8px 4px 8px; } 303 + input.submit:hover, button:hover, input[type=submit]:hover, input[type="file"] > input[type="button"]:hover { background: #999; } 304 + 305 + .flatbutton button,.flatbutton input[type=submit], input[type=button].flatbutton { background-color: #E6E6E6; border: 1px solid #ccc; padding-top: 1px solid #fff; font-size: 1em; padding: 4px 8px 4px 8px; } 306 + .flatbutton input[type=submit]:hover { background: #999; } 307 + 308 + .clear { clear: both; } 309 + div.clear { height: 1px; } 310 + .numeric { font-family: 'Georgia', 'Serif'; } 311 + input.labeled_field { color: #999; } 312 + 313 + 314 + 315 + #snaz_container {background:#d8d8ee; width:600px; margin:15px; padding:20px;} 316 + .xsnazzy h1, .xsnazzy h2, .xsnazzy p {margin:0 10px; letter-spacing:1px;} 317 + .xsnazzy h1 {font-size:2.5em; color:#fc0;} 318 + .xsnazzy h2 {font-size:2em; color:#234; border:0;} 319 + .xsnazzy p {padding-bottom:0.5em; color:#eee;} 320 + .xsnazzy h2 {padding-top:0.5em; padding-left:10px;} 321 + .xsnazzy {background: transparent; margin:1em; width:300px; position:absolute; top:61px; left:300px; z-index:99;display:none;} 322 + .xsnazzy em {display:block; width:0; height:0; color:#d8d8ee; overflow:hidden; border-top:12px solid #fff; border-left:12px dotted transparent; border-right:12px dotted transparent; margin-left:50px;} 323 + /* hack for IE5.5 */ 324 + * html .xsnazzy em {width:24px; height:12px; width:0; height:0;} 325 + .xsnazzy span {display:block; width:0; height:0; color:#fff; overflow:hidden; border-top:10px solid #7f7f9c; border-left:10px dotted transparent; border-right:10px dotted transparent; margin-left:52px; margin-top:-15px;} 326 + * html .xsnazzy span {width:20px; height:10px; width:0; height:0;} 327 + .xb1, .xb2, .xb3, .xb4, .xb5, .xb6, .xb7 {display:block; overflow:hidden; font-size:0;} 328 + .xb1, .xb2, .xb3, .xb4, .xb5, .xb6 {height:1px;} 329 + .xb4, .xb5, .xb6, .xb7 {background:#ccc; border-left:1px solid #fff; border-right:1px solid #fff;} 330 + .xb1 {margin:0 8px; background:#fff;} 331 + .xb2 {margin:0 6px; background:#fff;} 332 + .xb3 {margin:0 4px; background:#fff;} 333 + .xb4 {margin:0 3px; background:#7f7f9c; border-width:0 5px;} 334 + .xb5 {margin:0 2px; background:#7f7f9c; border-width:0 4px;} 335 + .xb6 {margin:0 2px; background:#7f7f9c; border-width:0 3px;} 336 + .xb7 {margin:0 1px; background:#7f7f9c; border-width:0 3px; height:2px;} 337 + .xboxcontent {display:block; background:#7f7f9c; border:3px solid #fff; border-width:0 3px;} 338 + 339 + .user_search { border: 1px solid #bbbbbb; clear: both; padding: 10px; margin-bottom: 10px; } 340 + .user_search .screen_name { font-weight: normal; font-size: 2em; vertical-align: bottom; text-decoration: none; } 341 + .user_search .follow { float: right; font-size: 1em; } 342 + .user_search .details{ clear: both; padding-top: 4px; } 343 + .user_search .profile_img { float: left; margin-right: 10px; } 344 + 345 + .search_following { background-color: #D8F4F5; border: 1px solid #84C2D2; } 346 + .search_following button { background-color: #fff; border: 1px solid #84C2D2; }
+9
src/agent.ts
···
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + 3 + export const agent = new AtpAgent({ 4 + service: "https://bsky.social", 5 + }); 6 + 7 + export const pubagent = new AtpAgent({ 8 + service: "https://public.api.bsky.app", 9 + });
+8
src/env.ts
···
··· 1 + import dotenv from "dotenv"; 2 + 3 + dotenv.config(); 4 + 5 + export const NODE_ENV = process.env.NODE_ENV || "development"; 6 + export const SESSION_SECRET = process.env.SESSION_SECRET; 7 + export const PUBLIC_URL = process.env.PUBLIC_URL || "http://localhost:3000"; 8 + export const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
+10
src/global.d.ts
···
··· 1 + import "express-session"; 2 + 3 + declare module "express-session" { 4 + interface SessionData { 5 + handle?: string; 6 + accessJwt?: string; 7 + refreshJwt?: string; 8 + pds?: string; 9 + } 10 + }
+12
src/helpers/registerHelpers.ts
···
··· 1 + import Handlebars from 'handlebars'; 2 + import { renderTextWithFacets } from './renderTextWithFacets.js'; 3 + 4 + export function registerHelpers(): void { 5 + Handlebars.registerHelper( 6 + 'renderTextWithFacets', 7 + (text: string, facets: any[]) => { 8 + const result = renderTextWithFacets(text, facets); 9 + return new Handlebars.SafeString(result); 10 + } 11 + ); 12 + }
+52
src/helpers/renderTextWithFacets.ts
···
··· 1 + interface Facet { 2 + index: { byteStart: number; byteEnd: number }; 3 + features: Array< 4 + | { $type: 'app.bsky.richtext.facet#mention' } 5 + | { $type: 'app.bsky.richtext.facet#link'; uri: string } 6 + | { $type: 'app.bsky.richtext.facet#tag' } 7 + >; 8 + } 9 + 10 + export function renderTextWithFacets(text: string, facets: Facet[]): string { 11 + if (!facets || facets.length === 0) return text; 12 + 13 + // Convert string to a Uint8Array (UTF-8 encoded bytes) 14 + const encoder = new TextEncoder(); 15 + const decoder = new TextDecoder(); 16 + const textBytes = encoder.encode(text); 17 + 18 + let result = ''; 19 + let lastIndex = 0; 20 + 21 + facets.forEach((facet) => { 22 + const { byteStart, byteEnd } = facet.index; 23 + 24 + // Extract parts of the text using byte offsets 25 + const preFacetText = decoder.decode(textBytes.slice(lastIndex, byteStart)); 26 + const facetText = decoder.decode(textBytes.slice(byteStart, byteEnd)); 27 + 28 + let replacedFacetText = facetText; 29 + 30 + facet.features.forEach((feature) => { 31 + if (feature.$type === 'app.bsky.richtext.facet#mention') { 32 + replacedFacetText = `<a href="../profile/${facetText.replace('@', '')}" class="mention">${facetText}</a>`; 33 + } 34 + 35 + if (feature.$type === 'app.bsky.richtext.facet#link') { 36 + replacedFacetText = `<a href="${feature.uri}" target="_blank" rel="nofollow">${facetText}</a>`; 37 + } 38 + 39 + if (feature.$type === 'app.bsky.richtext.facet#tag') { 40 + replacedFacetText = `<a href="https://bsky.app/hashtag/${facetText.replace('#', '')}" class="hashtag">${facetText}</a>`; //TODO: Replace with search endpoint when added 41 + } 42 + }); 43 + 44 + result += preFacetText + replacedFacetText; 45 + lastIndex = byteEnd; 46 + }); 47 + 48 + // Append any remaining text after the last facet 49 + result += decoder.decode(textBytes.slice(lastIndex)); 50 + 51 + return result; 52 + }
+90
src/index.ts
···
··· 1 + import path from "path"; 2 + import { fileURLToPath } from "url"; 3 + import express, { Express } from "express"; 4 + import { engine } from "express-handlebars"; 5 + import router from "./routes/main.js"; 6 + import mobile from "./routes/mobile.js"; 7 + import account from "./routes/account.js"; 8 + import api from "./routes/appview.js"; 9 + import checkUserAgent from "./lib/useragent.js"; 10 + import { NODE_ENV, SESSION_SECRET, PORT, PUBLIC_URL } from "./env.js"; 11 + import expressSession from "express-session"; 12 + import cookieParser from "cookie-parser"; 13 + import bodyParser from "body-parser"; 14 + import crypto from "crypto"; 15 + import moment from "moment"; 16 + 17 + import { registerHelpers } from "./helpers/registerHelpers.js"; 18 + 19 + const app: Express = express(); 20 + const randomSecret = crypto.randomBytes(32).toString("hex"); 21 + 22 + registerHelpers(); 23 + 24 + app.engine( 25 + "hbs", 26 + engine({ 27 + extname: ".hbs", 28 + helpers: { 29 + elapsed: function (date: any) { 30 + const parsedDate = moment(date); 31 + const now = moment(); 32 + const duration = moment.duration(now.diff(parsedDate)); 33 + if (duration.days() > 0) { 34 + return `${duration.days()} days`; 35 + } else if (duration.hours() > 0) { 36 + return `${duration.hours()} hours`; 37 + } else if (duration.minutes() > 0) { 38 + return `${duration.minutes()} minutes`; 39 + } else { 40 + return `${duration.seconds()} seconds`; 41 + } 42 + }, 43 + rssDate: function (date: any) { 44 + return moment(date).format("ddd, DD MMM YYYY HH:mm:ss ZZ"); 45 + }, 46 + }, 47 + }), 48 + ); 49 + 50 + app.set("view engine", "hbs"); 51 + app.set( 52 + "views", 53 + path.join(path.dirname(fileURLToPath(import.meta.url)), "../views"), 54 + ); 55 + 56 + app.use(cookieParser()); 57 + app.use( 58 + expressSession({ 59 + name: "sid", 60 + secret: SESSION_SECRET || randomSecret, 61 + resave: false, 62 + saveUninitialized: false, 63 + cookie: { 64 + secure: NODE_ENV === "production", 65 + httpOnly: true, 66 + sameSite: "lax", 67 + maxAge: 1000 * 60 * 60 * 24 * 7, 68 + }, 69 + genid: () => crypto.randomUUID(), 70 + }), 71 + ); 72 + 73 + app.use(express.static("public")); 74 + app.use(express.json()); 75 + app.use(express.urlencoded({ extended: true })); 76 + app.use(bodyParser.urlencoded({ extended: true })); 77 + app.use(checkUserAgent); 78 + app.use("/", router); 79 + app.use("/m", mobile); 80 + app.use("/account", account); 81 + app.use("/xrpc", api); 82 + 83 + app.listen(PORT, () => { 84 + console.log(`[server] Server is running at ${PUBLIC_URL}`); 85 + if (!SESSION_SECRET) { 86 + console.warn( 87 + "[warning] SESSION_SECRET is not set. Sessions might not work properly,", 88 + ); 89 + } 90 + });
+41
src/lib/actor.ts
···
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + 3 + export async function getActor(agent: AtpAgent, handle: string) { 4 + const response = await agent.getProfile({ 5 + actor: handle, 6 + }); 7 + return response.data; 8 + } 9 + 10 + export async function getActorDid(agent: AtpAgent, handle: string) { 11 + if (handle.startsWith("did:")) { 12 + return handle; 13 + } 14 + 15 + const response = await agent.resolveHandle({ 16 + handle: handle, 17 + }); 18 + return response.data.did; 19 + } 20 + 21 + export async function getActorFeed(agent: AtpAgent, handle: string, cursor?: string) { 22 + const response = await agent.getAuthorFeed({ 23 + actor: handle, 24 + cursor: cursor 25 + }); 26 + return response.data; 27 + } 28 + 29 + export async function getActorFollows(agent: AtpAgent, handle: string) { 30 + const response = await agent.getFollows({ 31 + actor: handle, 32 + }); 33 + return response.data.follows; 34 + } 35 + 36 + export async function getActorFollowers(agent: AtpAgent, handle: string) { 37 + const response = await agent.getFollowers({ 38 + actor: handle, 39 + }); 40 + return response.data.followers; 41 + }
+13
src/lib/auth.ts
···
··· 1 + import { Request, Response, NextFunction } from "express"; 2 + 3 + export default async function auth(req: Request, res: Response, next: NextFunction) { 4 + const { handle, accessJwt, refreshJwt } = req.cookies; 5 + 6 + if (!handle || !accessJwt || !refreshJwt) return res.redirect("/login"); 7 + 8 + req.session.handle = handle; 9 + req.session.accessJwt = accessJwt; 10 + req.session.refreshJwt = refreshJwt; 11 + 12 + next(); 13 + }
+16
src/lib/feed.ts
···
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + 3 + export async function getFeed(agent: AtpAgent, did: string, record: string, cursor?: string) { 4 + const response = await agent.app.bsky.feed.getFeed({ 5 + feed: "at://" + did + "/app.bsky.feed.generator/" + record, 6 + cursor: cursor, 7 + }); 8 + return response.data; 9 + } 10 + 11 + export async function getFeedData(agent: AtpAgent, did: string, record: string) { 12 + const response = await agent.app.bsky.feed.getFeedGenerators({ 13 + feeds: ["at://" + did + "/app.bsky.feed.generator/" + record], 14 + }); 15 + return response.data.feeds; 16 + }
+22
src/lib/misc.ts
···
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + 3 + export async function getTimeline(agent: AtpAgent, cursor?: string) { 4 + const response = await agent.getTimeline({ 5 + cursor: cursor, 6 + }); 7 + return response.data; 8 + } 9 + 10 + export async function searchActors(agent: AtpAgent, q: string) { 11 + const response = await agent.searchActors({ 12 + q: q, 13 + }); 14 + return response.data.actors; 15 + } 16 + 17 + export async function getPostThread(agent: AtpAgent, uri: string) { 18 + const response = await agent.getPostThread({ 19 + uri: uri 20 + }); 21 + return response.data.thread.post; 22 + }
+75
src/lib/resolver.ts
···
··· 1 + import { fetch, Agent, setGlobalDispatcher } from "undici"; 2 + import dns from "dns/promises"; 3 + 4 + export async function byDNS(domain: string): Promise<string | any> { 5 + const txt = `_atproto.${domain}`; 6 + let records: string[][]; 7 + 8 + try { 9 + records = await dns.resolveTxt(txt); 10 + } catch (e) { 11 + return null; 12 + } 13 + 14 + for (const chunks of records) { 15 + const record = chunks.join(""); 16 + const trimmed = record.trim(); 17 + 18 + if (trimmed.toLowerCase().startsWith("did=")) { 19 + const candidate = trimmed.slice(4).trim(); 20 + 21 + if (candidate) return candidate; 22 + } 23 + } 24 + 25 + return null; 26 + } 27 + 28 + export async function byHTTP(domain: string, secure: boolean = true): Promise<string | any> { 29 + const agent = new Agent({ keepAliveTimeout: 10000 }); 30 + setGlobalDispatcher(agent); 31 + 32 + let url; 33 + 34 + if (secure) { 35 + url = `https://${domain}/.well-known/atproto-did`; 36 + } else { 37 + url = `http://${domain}/.well-known/atproto-did`; 38 + } 39 + 40 + try { 41 + const response = await fetch(url); 42 + const data = await response.text(); 43 + 44 + if (!data) { 45 + return null; 46 + } else { 47 + return data.trim(); 48 + } 49 + } catch (e) { 50 + return null; 51 + } 52 + } 53 + 54 + export async function byPLC(did: string, endpoint: string = "https://plc.directory"): Promise<any> { 55 + const agent = new Agent({ keepAliveTimeout: 10000 }); 56 + setGlobalDispatcher(agent); 57 + 58 + try { 59 + const response = await fetch(`${endpoint}/${did}`); 60 + const data = await response.json(); 61 + 62 + if (!data) { 63 + return null; 64 + } else { 65 + return data; 66 + } 67 + } catch (e) { 68 + return null; 69 + } 70 + } 71 + 72 + export async function getPDS(did: string) { 73 + const didDoc = await byPLC(did); 74 + return didDoc.service[0].serviceEndpoint; 75 + }
+120
src/lib/theme.ts
···
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + 3 + export async function getTheme(agent: AtpAgent, did: string) { 4 + try { 5 + const response = await agent.com.atproto.repo.getRecord({ 6 + repo: did, 7 + collection: "lol.skittr.actor.theme", 8 + rkey: "self", 9 + }); 10 + return response.data; 11 + } catch (error: any) { 12 + if (error.error === "RecordNotFound" || error.error === "InvalidRequest") { 13 + // dummy record 14 + return { 15 + value: { 16 + $type: "lol.skittr.actor.theme", 17 + bg_color: "#9ae4e8", 18 + text_color: "#000000", 19 + link_color: "#0000ff", 20 + side_color: "#e0ff92", 21 + side_border: "#87bc44", 22 + }, 23 + }; 24 + } 25 + throw error; 26 + } 27 + } 28 + 29 + export async function getBackground(agent: AtpAgent, did: string, cid: string) { 30 + try { 31 + const response = await agent.com.atproto.sync.getBlob({ 32 + did: did, 33 + cid: cid, 34 + }); 35 + return response; 36 + } catch (error: any) { 37 + throw error; 38 + } 39 + } 40 + 41 + export async function getBackgroundBase64( 42 + agent: AtpAgent, 43 + did: string, 44 + cid: string, 45 + ) { 46 + const blob = await getBackground(agent, did, cid); 47 + const mimetype = blob.headers["content-type"] ?? "application/octet-stream"; 48 + const base64 = Buffer.from(blob.data).toString("base64"); 49 + return `data:${mimetype};base64,${base64}`; 50 + } 51 + 52 + // this code is incomplete 53 + export async function loadTheme(agent: AtpAgent, did: string) { 54 + const theme = await getTheme(agent, did); 55 + const cid = (theme as any).value?.background?.ref?.$link; 56 + let bg = null; 57 + 58 + if (cid) { 59 + bg = await getBackgroundBase64(agent, did, cid); 60 + } else { 61 + bg = "../../../img/bg.gif"; 62 + } 63 + 64 + return { theme, bg }; 65 + } 66 + 67 + export async function putTheme( 68 + agent: AtpAgent, 69 + did: string, 70 + theme: any, 71 + bg?: any, 72 + ) { 73 + const { 74 + bg_color, 75 + text_color, 76 + link_color, 77 + side_color, 78 + side_border, 79 + repeat = false, 80 + } = theme; 81 + const bgBuffer = Buffer.from(bg, "base64"); 82 + let output = {}; 83 + 84 + if (bg) { 85 + const blob = await agent.com.atproto.repo.uploadBlob(bgBuffer); 86 + 87 + output = { 88 + $type: "lol.skittr.actor.theme", 89 + bg_color: bg_color, 90 + text_color: text_color, 91 + link_color: link_color, 92 + side_color: side_color, 93 + side_border: side_border, 94 + background: { 95 + $type: "blob", 96 + ref: blob.data.blob.ref, 97 + mimeType: blob.data.blob.mimeType, 98 + size: blob.data.blob.size, 99 + }, 100 + repeat: repeat, 101 + }; 102 + } else { 103 + output = { 104 + $type: "lol.skittr.actor.theme", 105 + bg_color: bg_color, 106 + text_color: text_color, 107 + link_color: link_color, 108 + side_color: side_color, 109 + side_border: side_border, 110 + }; 111 + } 112 + 113 + const response = await agent.com.atproto.repo.putRecord({ 114 + repo: did, 115 + collection: "lol.skittr.actor.theme", 116 + rkey: "self", 117 + record: output, 118 + }); 119 + return response.data; 120 + }
+23
src/lib/useragent.ts
···
··· 1 + import { Request, Response, NextFunction } from "express"; 2 + 3 + const mobileRegex = 4 + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i; 5 + const shortMobileRegex = 6 + /^1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i; 7 + 8 + export default function checkUserAgent( 9 + req: Request, 10 + res: Response, 11 + next: NextFunction, 12 + ) { 13 + const userAgent = req.headers["user-agent"]?.toLowerCase(); 14 + const isMobile = 15 + mobileRegex.test(userAgent as string) || 16 + shortMobileRegex.test((userAgent as string).substr(0, 4)); 17 + 18 + if (isMobile && !req.originalUrl.startsWith("/m")) { 19 + res.redirect("/m" + req.originalUrl); 20 + } 21 + 22 + next(); 23 + }
+35
src/reviews.ts
···
··· 1 + const reviews = [ 2 + { 3 + name: "Matt Mullenweg", 4 + title: "Automattic CEO", 5 + review: "Bluesky proves that it is computationally possible to solve the problems of Twitter-like social networking in a distributed fashion, with a clean user experience. That is really exciting to me.", 6 + }, 7 + { 8 + name: "mondyspartan", 9 + title: "Graphic Designer", 10 + review: "We're now getting the YT2009 equivalent of Bsky. In a way that it simulates the look and feel of older Twitter.", 11 + }, 12 + { 13 + name: "massacre", 14 + title: "aka depresssd", 15 + review: "Android users?", 16 + }, 17 + { 18 + name: "Allison", 19 + title: "aka ArchiverXP", 20 + review: 'read bleating heart on <a href="https://bleatingheart.webcomic.ws/comics/1/">https://bleatingheart.webcomic.ws/comics/1/</a>', 21 + }, 22 + { 23 + name: "amyot2008", 24 + title: "aka amyoteus", 25 + review: "bsky is the new way of communication, now we don't need to constantly send e-mails or SMS messages to our friends just to notify them what are we doing currently!", 26 + }, 27 + ]; 28 + 29 + export function getReviews() { 30 + const indicies: Set<number> = new Set(); 31 + while (indicies.size < Math.min(3, reviews.length)) { 32 + indicies.add(Math.floor(Math.random() * reviews.length)); 33 + } 34 + return Array.from(indicies).map((index) => reviews[index]); 35 + }
+59
src/routes/account.ts
···
··· 1 + import { Router, Request, Response } from "express"; 2 + import { agent } from "../agent.js"; 3 + import { getActor, getActorDid } from "../lib/actor.js"; 4 + import { getTheme, putTheme } from "../lib/theme.js"; 5 + import multer from "multer"; 6 + import auth from "../lib/auth.js"; 7 + 8 + const account = Router(); 9 + const upload = multer({ storage: multer.memoryStorage() }); 10 + 11 + account.get("/", (req: Request, res: Response) => { 12 + res.redirect("/settings"); 13 + }); 14 + 15 + account.get("/settings", (req: Request, res: Response) => { 16 + res.send("wip"); 17 + }); 18 + 19 + account.get("/design", async (req: Request, res: Response) => { 20 + const curuser = await getActorDid(agent, req.cookies.handle); 21 + const existingusertheme = await getTheme(agent, curuser); 22 + res.render("account/design", { 23 + layout: null, 24 + usertheme: existingusertheme, 25 + }); 26 + }); 27 + 28 + account.post( 29 + "/design", 30 + auth, 31 + upload.single("background"), 32 + async (req: Request, res: Response) => { 33 + const { bg_color, text_color, link_color, side_color, side_border } = 34 + req.body; 35 + const did = await getActorDid(agent, req.cookies.handle); 36 + const theme = { 37 + bg_color: bg_color, 38 + text_color: text_color, 39 + link_color: link_color, 40 + side_color: side_color, 41 + side_border: side_border, 42 + }; 43 + let bg: string | undefined; 44 + 45 + if (req.file?.buffer) { 46 + bg = req.file.buffer.toString("base64"); 47 + } 48 + 49 + try { 50 + await putTheme(agent, did, theme, bg); 51 + res.redirect("./design"); 52 + } catch (error) { 53 + console.log(error); 54 + res.status(400).send("Authorization error"); 55 + } 56 + }, 57 + ); 58 + 59 + export default account;
+69
src/routes/appview.ts
···
··· 1 + import { Router, Request, Response } from "express"; 2 + import { byDNS, byHTTP, byPLC } from "../lib/resolver.js"; 3 + 4 + const api = Router(); 5 + 6 + api.get("/lol.skittr.server.getMeta", (req: Request, res: Response) => { 7 + res.json({ 8 + software: "skittr", 9 + version: "0.0.1", 10 + supported_lexicons: ["com.atproto.*", "app.bsky.*", "lol.skittr.*"], 11 + supported_extensions: [], 12 + }); 13 + }); 14 + 15 + api.get( 16 + "/com.atproto.identity.resolveHandle", 17 + async (req: Request, res: Response) => { 18 + const handle = req.query.handle as string; 19 + 20 + if (!handle) { 21 + return res.status(400).json({ 22 + error: "InvalidRequest", 23 + message: "handle not found", 24 + }); 25 + } 26 + 27 + const did = await byDNS(handle); 28 + 29 + if (!did) { 30 + return res.status(400).json({ 31 + error: "HandleNotFound", 32 + message: "the provided handle does not have an associated DID value", 33 + }); 34 + } 35 + 36 + res.json({ 37 + did: did, 38 + }); 39 + }, 40 + ); 41 + 42 + api.get( 43 + "/com.atproto.identity.resolveDid", 44 + async (req: Request, res: Response) => { 45 + const did = req.query.did as string; 46 + 47 + if (!did) { 48 + return res.status(400).json({ 49 + error: "InvalidRequest", 50 + message: "DID not found", 51 + }); 52 + } 53 + 54 + const didDoc = await byPLC(did); 55 + 56 + if (!didDoc) { 57 + return res.status(400).json({ 58 + error: "DidNotFound", 59 + message: "DID document not found", 60 + }); 61 + } 62 + 63 + res.json({ 64 + didDoc: didDoc, 65 + }); 66 + }, 67 + ); 68 + 69 + export default api;
+327
src/routes/main.ts
···
··· 1 + import { Router, Request, Response } from "express"; 2 + import { RichText } from "@atproto/api"; 3 + 4 + import { agent, pubagent } from "../agent.js"; 5 + 6 + import { 7 + getActor, 8 + getActorDid, 9 + getActorFeed, 10 + getActorFollowers, 11 + getActorFollows, 12 + } from "../lib/actor.js"; 13 + import { getFeed, getFeedData } from "../lib/feed.js"; 14 + import { getTheme, loadTheme } from "../lib/theme.js"; 15 + import { getTimeline, searchActors, getPostThread } from "../lib/misc.js"; 16 + 17 + import { getReviews } from "../reviews.js"; 18 + import { PUBLIC_URL } from "../env.js"; 19 + import auth from "../lib/auth.js"; 20 + 21 + const router = Router(); 22 + 23 + router.get("/", (req: Request, res: Response) => { 24 + const pageReviews = getReviews(); 25 + 26 + res.render("index", { 27 + layout: "main", 28 + title: "Bluesky: What are you doing?", 29 + year: new Date().getFullYear(), 30 + reviews: pageReviews, 31 + }); 32 + }); 33 + 34 + router.get("/login", (req: Request, res: Response) => { 35 + if (!req.cookies.handle) { 36 + res.render("login", { 37 + layout: "main", 38 + title: "Bluesky", 39 + year: new Date().getFullYear(), 40 + }); 41 + } else { 42 + res.redirect("/home"); 43 + } 44 + }); 45 + 46 + router.get("/home", auth, async (req: Request, res: Response) => { 47 + const cursor = (req.query.cursor as string) || ""; 48 + const handle = req.cookies.handle; 49 + const did = await getActorDid(agent, handle); 50 + const actor = await getActor(agent, did); 51 + const feed = await getTimeline(agent, cursor); 52 + const follows = await getActorFollows(agent, did); 53 + const { theme, bg } = await loadTheme(agent, did); 54 + 55 + res.render("home", { 56 + layout: "main", 57 + title: "Bluesky / Home", 58 + actor: actor, 59 + feed: feed.feed, 60 + cursor: feed.cursor, 61 + follows: follows, 62 + usertheme: theme, 63 + bg: bg, 64 + curuser: handle, 65 + year: new Date().getFullYear(), 66 + }); 67 + }); 68 + 69 + router.get("/profile/:handle", async (req: Request, res: Response) => { 70 + const cursor = (req.query.cursor as string) || ""; 71 + const did = await getActorDid(pubagent, req.params.handle); 72 + const actor = await getActor(pubagent, did); 73 + const feed = await getActorFeed(pubagent, did, cursor); 74 + const follows = await getActorFollows(pubagent, did); 75 + const { theme, bg } = await loadTheme(agent, did); 76 + 77 + res.render("profile", { 78 + layout: "main", 79 + title: "Bluesky / " + actor.handle, 80 + actor: actor, 81 + feed: feed.feed, 82 + cursor: feed.cursor, 83 + follows: follows, 84 + usertheme: theme, 85 + bg: bg, 86 + curuser: req.cookies.handle, 87 + year: new Date().getFullYear(), 88 + }); 89 + }); 90 + 91 + router.get("/profile/:handle/following", async (req: Request, res: Response) => { 92 + const did = await getActorDid(pubagent, req.params.handle); 93 + const actor = await getActor(pubagent, did); 94 + const follows = await getActorFollows(pubagent, did); 95 + const { theme, bg } = await loadTheme(agent, did); 96 + 97 + res.render("following", { 98 + layout: "main", 99 + title: "Bluesky / " + actor.handle, 100 + actor: actor, 101 + follows: follows, 102 + usertheme: theme, 103 + bg: bg, 104 + curuser: req.cookies.handle, 105 + year: new Date().getFullYear(), 106 + }); 107 + }, 108 + ); 109 + 110 + router.get("/profile/:handle/followers", async (req: Request, res: Response) => { 111 + const did = await getActorDid(pubagent, req.params.handle); 112 + const actor = await getActor(pubagent, did); 113 + const follows = await getActorFollows(pubagent, did); 114 + const followers = await getActorFollowers(pubagent, did); 115 + const { theme, bg } = await loadTheme(agent, did); 116 + 117 + res.render("followers", { 118 + layout: "main", 119 + title: "Bluesky / " + actor.handle, 120 + actor: actor, 121 + follows: follows, 122 + followers: followers, 123 + usertheme: theme, 124 + bg: bg, 125 + curuser: req.cookies.handle, 126 + year: new Date().getFullYear(), 127 + }); 128 + }, 129 + ); 130 + 131 + router.get("/profile/:handle/post/:tid", async (req: Request, res: Response) => { 132 + const did = await getActorDid(pubagent, req.params.handle); 133 + const actor = await getActor(pubagent, did); 134 + const post = await getPostThread(pubagent, `at://${did}/app.bsky.feed.post/${req.params.tid}`); 135 + const { theme, bg } = await loadTheme(agent, did); 136 + 137 + res.render("status", { 138 + layout: "main", 139 + title: "Bluesky / " + actor.handle, 140 + actor: actor, 141 + post: post, 142 + usertheme: theme, 143 + bg: bg, 144 + curuser: req.cookies.handle, 145 + year: new Date().getFullYear(), 146 + }); 147 + }); 148 + 149 + router.get("/tw/search", async (req: Request, res: Response) => { 150 + const did = await getActorDid(agent, req.cookies.handle); 151 + const { theme, bg } = await loadTheme(agent, did); 152 + 153 + res.render("search_users", { 154 + layout: "main", 155 + title: "Bluesky / people search", 156 + usertheme: theme, 157 + bg: bg, 158 + curuser: req.cookies.handle, 159 + year: new Date().getFullYear(), 160 + }); 161 + }); 162 + 163 + router.get("/tw/search/users", async (req: Request, res: Response) => { 164 + const { q } = req.query; 165 + const actors = await searchActors(pubagent, q as string); 166 + const did = await getActorDid(agent, req.cookies.handle); 167 + const { theme, bg } = await loadTheme(agent, did); 168 + 169 + res.render("results_users", { 170 + layout: "main", 171 + title: "Bluesky / people search", 172 + query: q, 173 + actor: actors, 174 + usertheme: theme, 175 + bg: bg, 176 + curuser: req.cookies.handle, 177 + year: new Date().getFullYear(), 178 + }); 179 + }); 180 + 181 + router.get("/profile/:handle/feed/:record", async (req: Request, res: Response) => { 182 + const cursor = (req.query.cursor as string) || ""; 183 + const did = await getActorDid(pubagent, req.params.handle); 184 + const feed = await getFeed(pubagent, did, req.params.record, cursor); 185 + const feedData = await getFeedData(pubagent, did, req.params.record); 186 + 187 + res.render("feed", { 188 + layout: "main", 189 + title: "Bluesky / " + feedData[0].displayName, 190 + feed: feed.feed, 191 + cursor: feed.cursor, 192 + feedData: feedData, 193 + record: req.params.record, 194 + curuser: req.cookies.handle, 195 + year: new Date().getFullYear(), 196 + }); 197 + }, 198 + ); 199 + 200 + router.get("/profile/:handle/rss", async (req: Request, res: Response) => { 201 + const did = await getActorDid(pubagent, req.params.handle); 202 + const actor = await getActor(pubagent, did); 203 + const feed = await getActorFeed(pubagent, did); 204 + 205 + res.set("Content-Type", "application/rss+xml"); 206 + res.render("rss/user", { 207 + layout: null, 208 + actor: actor, 209 + feed: feed, 210 + instanceURL: PUBLIC_URL, 211 + year: new Date().getFullYear(), 212 + }); 213 + }); 214 + 215 + router.get("/profile/:handle/feed/:record/rss", async (req: Request, res: Response) => { 216 + const did = await getActorDid(pubagent, req.params.handle); 217 + const feed = await getFeed(pubagent, did, req.params.record); 218 + const feedData = await getFeedData(pubagent, did, req.params.record); 219 + 220 + res.set("Content-Type", "application/rss+xml"); 221 + res.render("rss/feed", { 222 + layout: null, 223 + record: req.params.record, 224 + feed: feed, 225 + instanceURL: PUBLIC_URL, 226 + year: new Date().getFullYear(), 227 + }); 228 + }, 229 + ); 230 + 231 + router.post("/status", auth, async (req: Request, res: Response) => { 232 + const { mobile_text_content, layout } = req.body; 233 + 234 + const rt = new RichText({ 235 + text: mobile_text_content, 236 + }); 237 + 238 + await rt.detectFacets(agent); 239 + 240 + const postRecord = { 241 + //$type: 'app.bsky.feed.post', 242 + text: rt.text, 243 + facets: rt.facets, 244 + createdAt: new Date().toISOString(), 245 + via: { 246 + name: "Skittr", 247 + version: "0.0.1", 248 + }, 249 + }; 250 + 251 + try { 252 + await agent.post(postRecord); 253 + 254 + if (layout === "mobile") { 255 + res.redirect("/m/home"); 256 + } else { 257 + res.redirect("/home"); 258 + } 259 + } catch (error) { 260 + console.log(error); 261 + res.redirect("/login"); 262 + } 263 + }); 264 + 265 + router.get("/like", auth, async (req: Request, res: Response) => { 266 + const { postUri, cid } = req.params; 267 + 268 + try { 269 + await agent.like(postUri, cid); 270 + res.redirect("/home"); 271 + } catch (error) { 272 + console.log(error); 273 + res.redirect("/login"); 274 + } 275 + }); 276 + 277 + router.post("/follow", auth, async (req: Request, res: Response) => { 278 + const { user } = req.body; 279 + const targetDid = await getActorDid(agent, user); 280 + 281 + try { 282 + await agent.follow(targetDid); 283 + res.redirect(`/profile/${user}`); 284 + } catch (error) { 285 + console.log(error); 286 + res.redirect("/login"); 287 + } 288 + }); 289 + 290 + router.post("/unfollow", auth, async (req: Request, res: Response) => { 291 + const { user } = req.body; 292 + const targetDid = await getActorDid(agent, user); 293 + 294 + try { 295 + await agent.deleteFollow(targetDid); 296 + res.redirect(`/profile/${user}`); 297 + } catch (error) { 298 + console.log(error); 299 + res.redirect("/login"); 300 + } 301 + }); 302 + 303 + router.post("/sessions", async (req: Request, res: Response) => { 304 + const { username_or_email, password, layout, pds_url } = req.body; 305 + 306 + try { 307 + await agent.login({ 308 + identifier: username_or_email, 309 + password: password, 310 + }); 311 + 312 + res.cookie("handle", agent.session?.handle, { httpOnly: true }); 313 + res.cookie("accessJwt", agent.session?.accessJwt, { httpOnly: true }); 314 + res.cookie("refreshJwt", agent.session?.refreshJwt, { httpOnly: true }); 315 + 316 + if (layout === "mobile") { 317 + res.redirect("/m/home"); 318 + } else { 319 + res.redirect("/home"); 320 + } 321 + } catch (error) { 322 + console.log(error); 323 + res.redirect("/login"); 324 + } 325 + }); 326 + 327 + export default router;
+119
src/routes/mobile.ts
···
··· 1 + import { Router, Request, Response } from "express"; 2 + import { agent, pubagent } from "../agent.js"; 3 + import { 4 + getActor, 5 + getActorDid, 6 + getActorFeed, 7 + getActorFollowers, 8 + getActorFollows, 9 + } from "../lib/actor.js"; 10 + import { getFeed, getFeedData } from "../lib/feed.js"; 11 + import { getTimeline } from "../lib/misc.js"; 12 + import auth from "../lib/auth.js"; 13 + 14 + const mobile = Router(); 15 + 16 + mobile.get("/", (req: Request, res: Response) => { 17 + res.redirect("/login"); 18 + }); 19 + 20 + mobile.get("/login", (req: Request, res: Response) => { 21 + if (!req.cookies.handle) { 22 + res.render("mobile/login", { 23 + layout: "mobile", 24 + title: "Bluesky", 25 + year: new Date().getFullYear(), 26 + }); 27 + } else { 28 + res.redirect("/m/home"); 29 + } 30 + }); 31 + 32 + mobile.get("/home", auth, async (req: Request, res: Response) => { 33 + const cursor = (req.query.cursor as string) || ""; 34 + const feed = await getTimeline(agent, cursor); 35 + 36 + res.render("mobile/home", { 37 + layout: "mobile", 38 + title: "Bluesky", 39 + feed: feed.feed, 40 + cursor: feed.cursor, 41 + curuser: req.cookies.handle, 42 + year: new Date().getFullYear(), 43 + }); 44 + }); 45 + 46 + mobile.get("/profile/:handle", async (req: Request, res: Response) => { 47 + const cursor = (req.query.cursor as string) || ""; 48 + const did = await getActorDid(pubagent, req.params.handle); 49 + const actor = await getActor(pubagent, did); 50 + const feed = await getActorFeed(pubagent, did, cursor); 51 + 52 + res.render("mobile/profile", { 53 + layout: "mobile", 54 + title: "Bluesky / " + actor.handle, 55 + actor: actor, 56 + feed: feed.feed, 57 + cursor: feed.cursor, 58 + curuser: req.cookies.handle, 59 + year: new Date().getFullYear(), 60 + }); 61 + }); 62 + 63 + mobile.get( 64 + "/profile/:handle/following", 65 + async (req: Request, res: Response) => { 66 + const did = await getActorDid(pubagent, req.params.handle); 67 + const actor = await getActor(pubagent, did); 68 + const follows = await getActorFollows(pubagent, did); 69 + 70 + res.render("mobile/following", { 71 + layout: "mobile", 72 + title: "Bluesky / " + actor.handle, 73 + actor: actor, 74 + follows: follows, 75 + curuser: req.cookies.handle, 76 + year: new Date().getFullYear(), 77 + }); 78 + }, 79 + ); 80 + 81 + mobile.get( 82 + "/profile/:handle/followers", 83 + async (req: Request, res: Response) => { 84 + const did = await getActorDid(pubagent, req.params.handle); 85 + const actor = await getActor(pubagent, did); 86 + const followers = await getActorFollowers(pubagent, did); 87 + 88 + res.render("mobile/followers", { 89 + layout: "mobile", 90 + title: "Bluesky / " + actor.handle, 91 + actor: actor, 92 + followers: followers, 93 + curuser: req.cookies.handle, 94 + year: new Date().getFullYear(), 95 + }); 96 + }, 97 + ); 98 + 99 + mobile.get( 100 + "/profile/:handle/feed/:record", 101 + async (req: Request, res: Response) => { 102 + const cursor = (req.query.cursor as string) || ""; 103 + const did = await getActorDid(pubagent, req.params.handle); 104 + const feed = await getFeed(pubagent, did, req.params.record, cursor); 105 + const feedData = await getFeedData(pubagent, did, req.params.record); 106 + 107 + res.render("mobile/feed", { 108 + layout: "mobile", 109 + title: "Bluesky / " + feedData[0].displayName, 110 + feed: feed.feed, 111 + cursor: feed.cursor, 112 + feedData: feedData, 113 + curuser: req.cookies.handle, 114 + year: new Date().getFullYear(), 115 + }); 116 + }, 117 + ); 118 + 119 + export default mobile;
+10
src/tests/resolver.test.ts
···
··· 1 + import { byDNS, byHTTP, byPLC, getPDS } from "../lib/resolver.js"; 2 + 3 + byDNS("yoyle.city").then((result) => console.log("DNS: " + result)); 4 + byHTTP("yoyle.city").then((result) => console.log("HTTP: " + result)); 5 + byPLC("did:plc:vro3sykit2gjemuza2pwvxwy").then((result) => 6 + console.log("PLC: " + JSON.stringify(result)), 7 + ); 8 + getPDS("did:plc:vro3sykit2gjemuza2pwvxwy").then((result) => 9 + console.log("PDS: " + result), 10 + );
+14
tsconfig.json
···
··· 1 + { 2 + "compilerOptions": { 3 + "target": "es2017", 4 + "module": "NodeNext", 5 + "rootDir": "./src", 6 + "esModuleInterop": true, 7 + "forceConsistentCasingInFileNames": true, 8 + "strict": true, 9 + "skipLibCheck": true, 10 + "outDir": "./dist", 11 + "moduleResolution": "NodeNext", 12 + "typeRoots": ["./node_modules/@types", "./src/types"] 13 + } 14 + }
+25
views/account/design.hbs
···
··· 1 + <!-- this is a placeholder --> 2 + <h1>User theme settings</h1> 3 + Colors must be in hex codes (<code>#FFFFFF</code>) 4 + <br /> 5 + <form method="post" action="./design" enctype="multipart/form-data"> 6 + <p>Background color:</p> 7 + <input type="color" name="bg_color" value="{{usertheme.value.bg_color}}" required /> 8 + <br /> 9 + <p>Text color:</p> 10 + <input type="color" name="text_color" value="{{usertheme.value.text_color}}" required /> 11 + <br /> 12 + <p>Link color:</p> 13 + <input type="color" name="link_color" value="{{usertheme.value.link_color}}" required /> 14 + <br /> 15 + <p>Sidebar color:</p> 16 + <input type="color" name="side_color" value="{{usertheme.value.side_color}}" required /> 17 + <br /> 18 + <p>Sidebar border color:</p> 19 + <input type="color" name="side_border" value="{{usertheme.value.side_border}}" required /> 20 + <br /> 21 + <p>Background (optional):</p> 22 + <input type="file" name="background" accept="image/png, image/jpeg" /> 23 + <br /> 24 + <input type="submit" value="Update" /> 25 + </form>
+70
views/feed.hbs
···
··· 1 + <body id="profile" class="account"> 2 + {{>accessbility}} 3 + <div id="container" class="subpage"> 4 + <span id="loader" style="display:none"><img alt="Loader" src="img/loader.gif" /></span> 5 + {{>logo}} 6 + 7 + <div id="flash" style="display: none;"></div> 8 + 9 + <div id="side"> 10 + <div class="section"> 11 + <div class="section-header"> 12 + <span class="section-links"> </span> 13 + <h1>About</h1> 14 + </div> 15 + <!-- /section-header --> 16 + 17 + <address> 18 + <ul class="about vcard entry-author"> 19 + <li><span class="label">Description</span> <span class="fn">{{feedData.[0].description}}</span></li> 20 + <li><span class="label">Author</span> @<span class="fn"><a class="url" href="../../{{feedData.[0].creator.handle}}">{{feedData.[0].creator.handle}}</a></span></li> 21 + </ul> 22 + </address> 23 + </div> 24 + <!-- /section --> 25 + </div> 26 + <!-- /side --> 27 + <hr /> 28 + 29 + <div id="content"> 30 + <div class="wrapper"> 31 + <h2 class="thumb" style="margin-bottom:25px"> 32 + {{#if feedData.[0].avatar}} 33 + <img alt="Default_profile_bigger" id="profile-image" src="{{feedData.[0].avatar}}" width="73" height="73" /> 34 + {{else}} 35 + <img alt="Default_profile_bigger" id="profile-image" src="../../../img/feed.png" width="73" height="73" /> 36 + {{/if}} 37 + 38 + {{feedData.[0].displayName}} 39 + </h2> 40 + 41 + <div class="clear"></div> 42 + 43 + <br /> 44 + 45 + <div class="hfeed"> 46 + <div class="tab"> 47 + {{>timeline}} 48 + 49 + <div class="bottom_nav"> 50 + <div class="pagination"> 51 + <a href="?cursor={{cursor}}" class="section_links" rel="prev">Older »</a> 52 + </div> 53 + 54 + <span class="statuses_options"> 55 + <a href="{{record}}/rss" class="section_links">RSS</a> 56 + </span> 57 + </div> 58 + </div> 59 + </div> 60 + </div> 61 + <!-- /wrapper --> 62 + </div> 63 + <!-- /content --> 64 + {{>footer}} 65 + <hr /> 66 + {{>navigation}} 67 + <hr /> 68 + </div> 69 + <!-- /container --> 70 + </body>
+94
views/followers.hbs
···
··· 1 + <body id="profile" class="account"> 2 + {{>accessbility}} 3 + <div id="container" class="subpage"> 4 + <span id="loader" style="display:none"><img alt="Loader" src="img/loader.gif" /></span> 5 + {{>logo}} 6 + 7 + <div id="flash" style="display: none;"></div> 8 + 9 + <div id="side"> 10 + <div class="section"> 11 + <div class="section-header"> 12 + <span class="section-links"> </span> 13 + <h1>About</h1> 14 + </div> 15 + <!-- /section-header --> 16 + 17 + <address> 18 + <ul class="about vcard entry-author"> 19 + <li><span class="label">Name</span> <span class="fn">{{actor.displayName}}</span></li> 20 + <li><span class="label">Web</span> <span class="fn"><a class="url" href="https://{{actor.handle}}">https://{{actor.handle}}</a></span></li> 21 + <li><span class="label">Bio</span> <span class="fn">{{actor.description}}</span></li> 22 + </ul> 23 + </address> 24 + </div> 25 + <!-- /section --> 26 + 27 + <div class="section"> 28 + <div class="section-header"> 29 + <h1>Stats</h1> 30 + </div> 31 + <!-- section-header --> 32 + 33 + <ul class="stats"> 34 + <li><span class="label"><a href="../{{actor.handle}}/following" class="label">Following</a></span> <span class="numeric stats_count">{{actor.followsCount}}</span></li> 35 + <li><span class="label"><a href="../{{actor.handle}}/followers" class="label">Followers</a></span> <span class="stats_count numeric">{{actor.followersCount}}</span></li> 36 + <li><a href="../{{actor.handle}}" class="label">Updates</a> <span class="stats_count numeric">{{actor.postsCount}}</span></li> 37 + </ul> 38 + </div> 39 + <!-- /section --> 40 + 41 + <div class="section"> 42 + <div class="section-header"> 43 + <h1>Following</h1> 44 + </div> 45 + <!-- /section-header --> 46 + {{>friends}} 47 + </div> 48 + <!-- /section --> 49 + </div> 50 + <!-- /side --> 51 + <hr /> 52 + 53 + <div id="content"> 54 + <div class="wrapper"> 55 + <h2 class="thumb" style="margin-bottom:25px"> 56 + {{actor.displayName}}'s Followers 57 + </h2> 58 + <div class="clear"></div> 59 + 60 + <div class="hfeed"> 61 + <div class="tab"> 62 + <table class="doing" id="timeline" cellspacing="0"> 63 + <tbody> 64 + {{#each followers}} 65 + <tr class="hentry"> 66 + <td class="thumb"> 67 + <a href="../{{this.handle}}"> 68 + <img src="{{this.avatar}}" alt="{{this.displayName}}" width="50" /> 69 + </a> 70 + </td> 71 + <td class="content"> 72 + <span class="entry-title entry-content"> 73 + <a href="../{{this.handle}}"> 74 + {{this.displayName}} ({{this.handle}}) 75 + </a> 76 + </span> 77 + </td> 78 + </tr> 79 + {{/each}} 80 + </tbody> 81 + </table> 82 + </div> 83 + </div> 84 + </div> 85 + <!-- /wrapper --> 86 + </div> 87 + <!-- /content --> 88 + {{>footer}} 89 + <hr /> 90 + {{>navigation}} 91 + <hr /> 92 + </div> 93 + <!-- /container --> 94 + </body>
+94
views/following.hbs
···
··· 1 + <body id="profile" class="account"> 2 + {{>accessbility}} 3 + <div id="container" class="subpage"> 4 + <span id="loader" style="display:none"><img alt="Loader" src="img/loader.gif" /></span> 5 + {{>logo}} 6 + 7 + <div id="flash" style="display: none;"></div> 8 + 9 + <div id="side"> 10 + <div class="section"> 11 + <div class="section-header"> 12 + <span class="section-links"> </span> 13 + <h1>About</h1> 14 + </div> 15 + <!-- /section-header --> 16 + 17 + <address> 18 + <ul class="about vcard entry-author"> 19 + <li><span class="label">Name</span> <span class="fn">{{actor.displayName}}</span></li> 20 + <li><span class="label">Web</span> <span class="fn"><a class="url" href="https://{{actor.handle}}">https://{{actor.handle}}</a></span></li> 21 + <li><span class="label">Bio</span> <span class="fn">{{actor.description}}</span></li> 22 + </ul> 23 + </address> 24 + </div> 25 + <!-- /section --> 26 + 27 + <div class="section"> 28 + <div class="section-header"> 29 + <h1>Stats</h1> 30 + </div> 31 + <!-- section-header --> 32 + 33 + <ul class="stats"> 34 + <li><span class="label"><a href="../{{actor.handle}}/following" class="label">Following</a></span> <span class="numeric stats_count">{{actor.followsCount}}</span></li> 35 + <li><span class="label"><a href="../{{actor.handle}}/followers" class="label">Followers</a></span> <span class="stats_count numeric">{{actor.followersCount}}</span></li> 36 + <li><a href="../{{actor.handle}}" class="label">Updates</a> <span class="stats_count numeric">{{actor.postsCount}}</span></li> 37 + </ul> 38 + </div> 39 + <!-- /section --> 40 + 41 + <div class="section"> 42 + <div class="section-header"> 43 + <h1>Following</h1> 44 + </div> 45 + <!-- /section-header --> 46 + {{>friends}} 47 + </div> 48 + <!-- /section --> 49 + </div> 50 + <!-- /side --> 51 + <hr /> 52 + 53 + <div id="content"> 54 + <div class="wrapper"> 55 + <h2 class="thumb" style="margin-bottom:25px"> 56 + {{actor.displayName}}'s Following 57 + </h2> 58 + <div class="clear"></div> 59 + 60 + <div class="hfeed"> 61 + <div class="tab"> 62 + <table class="doing" id="timeline" cellspacing="0"> 63 + <tbody> 64 + {{#each follows}} 65 + <tr class="hentry"> 66 + <td class="thumb"> 67 + <a href="../{{this.handle}}"> 68 + <img src="{{this.avatar}}" alt="{{this.displayName}}" width="50" /> 69 + </a> 70 + </td> 71 + <td class="content"> 72 + <span class="entry-title entry-content"> 73 + <a href="../{{this.handle}}"> 74 + {{this.displayName}} ({{this.handle}}) 75 + </a> 76 + </span> 77 + </td> 78 + </tr> 79 + {{/each}} 80 + </tbody> 81 + </table> 82 + </div> 83 + </div> 84 + </div> 85 + <!-- /wrapper --> 86 + </div> 87 + <!-- /content --> 88 + {{>footer}} 89 + <hr /> 90 + {{>navigation}} 91 + <hr /> 92 + </div> 93 + <!-- /container --> 94 + </body>
+98
views/home.hbs
···
··· 1 + <body id="profile" class="account"> 2 + {{>accessbility}} 3 + <div id="container" class="subpage"> 4 + <span id="loader" style="display: none"><img alt="Loader" src="img/loader.gif" /></span> 5 + {{>logo}} 6 + 7 + <div id="flash" style="display: none"></div> 8 + 9 + <div id="side"> 10 + <div class="section"> 11 + <div class="section-header"> 12 + <h3 style="margin: 0; padding: 0">Welcome,</h3> 13 + <strong> 14 + <a href="../profile/{{actor.handle}}">{{actor.handle}}</a> 15 + </strong> 16 + </div> 17 + </div> 18 + 19 + <div class="section"> 20 + <div class="section-header"> 21 + <h1>Stats</h1> 22 + </div> 23 + <ul class="stats"> 24 + <li> 25 + <span class="label" 26 + ><a href="../profile/{{actor.handle}}/following" class="label">Following</a></span 27 + > 28 + <span class="numeric stats_count">{{actor.followsCount}}</span> 29 + </li> 30 + <li> 31 + <span class="label" 32 + ><a href="../profile/{{actor.handle}}/followers" class="label">Followers</a></span 33 + > 34 + <span class="stats_count numeric">{{actor.followersCount}}</span> 35 + </li> 36 + <li> 37 + <a href="../profile/{{actor.handle}}" class="label">Updates</a> 38 + <span class="stats_count numeric">{{actor.postsCount}}</span> 39 + </li> 40 + </ul> 41 + </div> 42 + 43 + <div class="section"> 44 + <div class="section-header"> 45 + <h1>Following</h1> 46 + </div> 47 + {{>friends}} 48 + </div> 49 + </div> 50 + 51 + <hr /> 52 + 53 + <div id="content"> 54 + <div class="wrapper"> 55 + <form action="../status" method="POST" id="doingForm"> 56 + <div class="info"> 57 + <div class="bar"> 58 + <h3 style="margin: 0">What are you doing?</h3> 59 + <span> 60 + Characters available: 61 + <b id="length">300</b> 62 + </span> 63 + </div> 64 + <script> 65 + function u(e) { 66 + if (e.value.length > e.maxLength) { 67 + e.value = e.value.substr(0, e.maxLength); 68 + } 69 + document.getElementById("length").innerText = e.maxLength - e.value.length; 70 + } 71 + </script> 72 + <textarea name="mobile_text_content" maxlength="300" id="" cols="30" rows="4" oninput="u(this);"></textarea> 73 + </div> 74 + 75 + <div class="submit"> 76 + <input type="hidden" name="via" value="web" /> 77 + <input type="submit" id="submit" value="Update" /> 78 + </div> 79 + </form> 80 + <h2>Home</h2> 81 + <div class="hfeed"> 82 + <div class="tab"> 83 + {{>timeline}} 84 + 85 + <div class="bottom_nav"> 86 + <div class="pagination"> 87 + <a href="?cursor={{cursor}}" class="section_links" rel="prev">Older »</a> 88 + </div> 89 + </div> 90 + </div> 91 + </div> 92 + </div> 93 + </div> 94 + {{>footer}} 95 + <hr /> 96 + {{>navigation}} 97 + </div> 98 + </body>
+76
views/index.hbs
···
··· 1 + <body id="front"> 2 + {{>accessbility}} 3 + <div id="container"> 4 + {{>logo}} 5 + <div id="content"> 6 + <div class="wrapper"> 7 + <div class="intro"> 8 + <h2>What is Bluesky?</h2> 9 + 10 + <img alt="What is Twitter?" class="tour" height="154" src="img/tour_1.gif" width="508" /> 11 + 12 + <p class="teaser"> 13 + Bluesky is a service for friends, family, and co–workers to communicate and stay connected through the exchange of quick, frequent answers to one simple question: 14 + <strong>What are you doing?</strong> 15 + </p> 16 + </div> 17 + <hr /> 18 + <form method="post" id="signin" action="/sessions"> 19 + <fieldset> 20 + <legend>Please Sign In</legend> 21 + 22 + <input type="hidden" name="layout" value="desktop" /> 23 + 24 + <p> 25 + <label class="h" tabindex="1" for="username">Username</label> 26 + <input type="text" id="username" name="username_or_email" placeholder="username" title="username" class="populate" style="color: rgb(148, 153, 157);" /> 27 + </p> 28 + 29 + <p> 30 + <label class="h" tabindex="2" for="password">Password</label> 31 + <input type="password" id="password" name="password" placeholder="password" title="password" class="populate" style="color: rgb(148, 153, 157);" /> 32 + </p> 33 + 34 + <p class="remember"> 35 + <input type="checkbox" id="remember" /> 36 + <label tabindex="3" for="remember">Remember me</label> 37 + </p> 38 + 39 + <p class="submit"> 40 + <input type="submit" tabindex="4" value="Sign In »" /> 41 + </p> 42 + 43 + <p class="forgot"> 44 + Forgot password? <a href="#">Click here</a>. 45 + </p> 46 + 47 + <p class="complete"> 48 + Already using Bluesky by SMS or IM? <a href="#">Click here.</a> 49 + </p> 50 + </fieldset> 51 + </form> 52 + </div> 53 + <!-- /wrapper --> 54 + </div> 55 + <!-- /content --> 56 + <hr /> 57 + 58 + <div id="whatistwitter"> 59 + <ul> 60 + {{#each reviews}} 61 + <li> 62 + <blockquote> 63 + <p>{{{this.review}}}</p> 64 + </blockquote> 65 + 66 + <cite class="vcard"><strong class="fn">{{this.name}}</strong>, <span class="title">{{this.title}}</span></cite> 67 + </li> 68 + {{/each}} 69 + </ul> 70 + </div> 71 + <!-- /whatistwitter --> 72 + <hr /> 73 + {{>footer}} 74 + </div> 75 + <!-- /container --> 76 + </body>
+37
views/layouts/main.hbs
···
··· 1 + <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> 2 + <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> 3 + <head> 4 + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> 5 + <meta http-equiv="Content-Language" content="en-us" /> 6 + <meta name="viewport" content="width=780" /> 7 + <title>{{title}}</title> 8 + <link href="../../../../screen.css" media="screen, projection" rel="stylesheet" type="text/css" /> 9 + <link rel="shortcut icon" href="../../../../favicon.ico" type="image/x-icon" /> 10 + {{>usertheme}} 11 + <style type="text/css"> 12 + #side .notify { border: 1px solid #87bc44; } 13 + #side .actions { border: 1px solid #87bc44; } 14 + #side div.section-header { border-bottom: 1px solid #87bc44; margin-bottom: 10px; } 15 + #side div.section-header h1 { color: #000000; } 16 + h2.thumb, h2.thumb a {color: #000000;} 17 + .subpage #content { padding-top: 11px; background: url(../../../img/arr2.gif) no-repeat 25px 0px; margin-top: 6px;} 18 + 19 + .embed { 20 + max-width: 100%; 21 + margin: 10px 0; 22 + display: flex; 23 + justify-content: center; 24 + overflow: hidden; 25 + border-radius: 0%; 26 + } 27 + 28 + .embed img { 29 + max-width: 100%; 30 + height: auto; 31 + object-fit: cover; 32 + } 33 + </style> 34 + </head> 35 + 36 + {{{body}}} 37 + </html>
+51
views/layouts/mobile.hbs
···
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.0//EN" "http://www.wapforum.org/DTD/xhtml-mobile10.dtd"> 3 + <html xmlns="http://www.w3.org/1999/xhtml"> 4 + <head> 5 + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> 6 + <link rel="shortcut-icon" href="../../../../favicon.ico" /> 7 + <link rel="apple-touch-icon" href="../../../../../img/twitter_57.png" /> 8 + <title>{{title}}</title> 9 + <style type="text/css"> 10 + body {background-color:#ffffff;color:#333333;font-family: Helvetica,sans-serif;} 11 + body,ul,li,table,tr,td {margin:0;padding:0;} 12 + img {border:0;} 13 + table,tr,td {border:0;border-collapse:collapse;border-spacing:0;vertical-align:top;} 14 + div,ul,form,p{padding-left:2px;} 15 + table {margin-left:2px;} 16 + ul li {list-style:none;} 17 + small {color:#aaaaaa;} 18 + a, .f small {color:#13819f;text-decoration:none;} 19 + a:active, a:hover, .b:active 20 + {background-color:#13819f;color:#aaaaaa;} 21 + .i, .b {background-color:#ffffff;border: 1px solid #aaaaaa;color:#333333;} 22 + .i {margin-bottom:2px;} 23 + .f {text-align:center;} 24 + .a, .p {text-align:right;} 25 + ul li, .s {margin:2px 0;} 26 + .s {border-bottom:2px solid #dddddd;} 27 + .n {margin-top:1em;padding-top:2px;} 28 + .h, .f {background-color:#84DEFF;padding:3px;} 29 + .h {margin-bottom:3px;} 30 + .f {margin-top:3px;} 31 + ul a {font-weight:bold;} 32 + ul li, .m {border-bottom:1px solid #dddddd;padding:2px;} 33 + .m img {vertical-align:middle;} 34 + .g {width:50px;} 35 + .y {border:1px solid #87BC44;margin:2px;} 36 + </style> 37 + </head> 38 + <body> 39 + <div class="h"> 40 + <a href="/m"> 41 + <img src="../../../../img/mobile.gif" alt="Bluesky" /> 42 + </a> 43 + </div> 44 + 45 + {{{body}}} 46 + 47 + <div class="f"> 48 + <small>© {{year}} Skittr</small> 49 + </div> 50 + </body> 51 + </html>
+58
views/login.hbs
···
··· 1 + <body class="sessions" id="new"> 2 + <style type="text/css"> 3 + body { background: #9ae4e8 url(img/bg.gif) fixed no-repeat top left; } 4 + .subpage #content { padding-top: 11px; background: url(img/arr2.gif) no-repeat 25px 0px; margin-top: 6px; } 5 + </style> 6 + 7 + {{>accessbility}} 8 + 9 + <div id="container" class="subpage"> 10 + <span id="loader" style="display:none"><img alt="Loader" src="img/loader.gif" /></span> 11 + {{>logo}} 12 + 13 + <div id="flash" style="display: none;"></div> 14 + 15 + <div id="content"> 16 + <div class="wrapper"> 17 + <h2>Sign in to Bluesky</h2> 18 + 19 + <form method="post" class="signin" action="/sessions"> 20 + <input type="hidden" name="layout" value="desktop" /> 21 + <fieldset> 22 + <table cellspacing="0"> 23 + <tbody> 24 + <tr> 25 + <th><label for="username_or_email">Username</label></th> 26 + <td><input id="username_or_email" name="username_or_email" type="text" /></td> 27 + </tr> 28 + <tr> 29 + <th><label for="password">Password</label></th> 30 + <td><input id="password" name="password" type="password"></td> 31 + </tr> 32 + <tr> 33 + <th><label for="pds_url">Custom PDS URL (optional)</label></th> 34 + <td><input id="pds_url" name="pds_url" type="text" /></td> 35 + </tr> 36 + <tr> 37 + <th></th> 38 + <td><input id="remember_me" name="remember_me" type="checkbox" value="1" /> <label for="remember_me" class="inline">Remember me</label></td> 39 + </tr> 40 + <tr> 41 + <th></th> 42 + <td><input name="commit" type="submit" value="Sign In" /></td> 43 + </tr> 44 + </tbody> 45 + </table> 46 + </fieldset> 47 + </form> 48 + </div> 49 + <!-- /wrapper --> 50 + </div> 51 + <!-- /content --> 52 + {{>footer}} 53 + <hr /> 54 + {{>navigation}} 55 + <hr /> 56 + </div> 57 + <!-- /container --> 58 + </body>
+32
views/mobile/feed.hbs
···
··· 1 + <table> 2 + <tr> 3 + <td class="g"><img alt="{{feedData.[0].displayName}}" height="48" src="{{feedData.[0].avatar}}" width="48" /></td> 4 + <td> 5 + <b>{{feedData.[0].displayName}}</b> 6 + <small>by @<a href="../../{{feedData.[0].creator.handle}}">{{feedData.[0].creator.handle}}</a></small> 7 + </td> 8 + </tr> 9 + </table> 10 + 11 + <div class="s"><b>Recent Public Updates</b></div> 12 + <ul> 13 + {{#each feed}} 14 + <li> 15 + {{#if this.reason}} 16 + <a href="/profile/{{this.reason.by.handle}}">{{this.reason.by.handle}}</a> 17 + RT @<a class="tweet-url username" href="{{this.post.author.handle}}" rel="nofollow">{{this.post.author.handle}}</a>: 18 + {{this.post.record.text}} 19 + {{else}} 20 + <a href="/profile/{{this.post.author.handle}}">{{this.post.author.handle}}</a> 21 + {{this.post.record.text}} 22 + {{/if}} 23 + <small>{{elapsed this.post.record.createdAt}} ago</small> 24 + </li> 25 + {{/each}} 26 + </ul> 27 + 28 + <div style="text-align:right;font-size:small"> 29 + <div><a href="?cursor={{cursor}}" accesskey="6">Older</a> 6</div> 30 + </div> 31 + 32 + {{>mobilefooter}}
+7
views/mobile/followers.hbs
···
··· 1 + <div class="s"><b>{{actor.displayName}}'s Followers</b></div> 2 + <ul> 3 + {{#each followers}} 4 + <li><a href="../{{this.handle}}">{{this.displayName}} ({{this.handle}})</a></li> 5 + {{/each}} 6 + </ul> 7 + {{>mobilefooter}}
+7
views/mobile/following.hbs
···
··· 1 + <div class="s"><b>{{actor.displayName}}'s Following</b></div> 2 + <ul> 3 + {{#each follows}} 4 + <li><a href="../{{this.handle}}">{{this.displayName}} ({{this.handle}})</a></li> 5 + {{/each}} 6 + </ul> 7 + {{>mobilefooter}}
+37
views/mobile/home.hbs
···
··· 1 + <b>What are you doing?</b> 2 + <form action="../status" method="post"> 3 + <div style="margin:0;padding:0"> 4 + <input name="layout" type="hidden" value="mobile" /> 5 + <input type="hidden" name="via" value="mobile" /> 6 + </div> 7 + <input class="i" id="status" maxlength="300" name="mobile_text_content" type="text" /><br /> 8 + <input class="b" id="submit" name="submit" type="submit" value="Update" /> 9 + </form> 10 + <br /> 11 + <div class="s"><b>you + friends</b></div> 12 + <ul> 13 + {{#each feed}} 14 + <li> 15 + {{#if this.reason}} 16 + <a href="/m/profile/{{this.reason.by.handle}}">{{this.reason.by.handle}}</a> 17 + RT @<a class="tweet-url username" href="/m/profile/{{this.post.author.handle}}" rel="nofollow">{{this.post.author.handle}}</a>: 18 + {{else}} 19 + <a href="/m/profile/{{this.post.author.handle}}">{{this.post.author.handle}}</a> 20 + {{/if}} 21 + 22 + {{#if this.post.record.facets}} 23 + {{renderTextWithFacets this.post.record.text this.post.record.facets}} 24 + {{else}} 25 + {{this.post.record.text}} 26 + {{/if}} 27 + 28 + <small>{{elapsed this.post.record.createdAt}} ago</small> 29 + </li> 30 + {{/each}} 31 + </ul> 32 + <div class="p"> 33 + <small> 34 + <div>&nbsp;<a href="?page=2" accesskey="6">Older</a></div> 35 + </small> 36 + </div> 37 + {{>mobilefooter}}
+12
views/mobile/login.hbs
···
··· 1 + <form action="../sessions" method="post"> 2 + <div style="margin:0;padding:0"> 3 + <input name="layout" type="hidden" value="mobile" /> 4 + </div> 5 + <b>Username: </b><input class="i" id="username_or_email" name="username_or_email" type="text" /><br /> 6 + <b>Password: </b><input class="i" id="password" name="password" type="password" /><br /> 7 + <input class="b" name="commit" type="submit" value="Sign In" /> 8 + </form> 9 + <p> 10 + Bluesky is a global community of friends and strangers answering one simple question <b>What are you doing?</b> Answer on your phone, IM, or right here on the web! 11 + Register for an account from your computer at <a href="https://bsky.app">bsky.app</a>. 12 + </p>
+51
views/mobile/profile.hbs
···
··· 1 + <table> 2 + <tr> 3 + <td class="g"><img alt="{{actor.displayName}}" height="48" src="{{actor.avatar}}" width="48" /></td> 4 + <td> 5 + <b>{{actor.displayName}}</b> 6 + {{#if feed.[0].reason}} 7 + RT @<a class="tweet-url username" href="{{feed.[0].post.author.handle}}" rel="nofollow">{{feed.[0].post.author.handle}}</a>: 8 + {{{renderTextWithFacets feed.[0].post.record.text feed.[0].post.record.facets}}} 9 + {{else}} 10 + {{{renderTextWithFacets feed.[0].post.record.text feed.[0].post.record.facets}}} 11 + {{/if}} 12 + <small>{{elapsed feed.[0].post.record.createdAt}} ago</small> 13 + </td> 14 + </tr> 15 + </table> 16 + 17 + {{#if curuser}} 18 + <form method="post" action="/follow" class="a"> 19 + <input type="hidden" value="{{actor.handle}}" name="user"> 20 + <input type="submit" class="b" value="Follow"> 21 + </form> 22 + {{/if}} 23 + 24 + <div class="s"><b>Previous Tweets</b></div> 25 + <ul> 26 + {{#each feed}} 27 + <li> 28 + {{#if this.reason}} 29 + RT @<a class="tweet-url username" href="{{this.post.author.handle}}" rel="nofollow">{{this.post.author.handle}}</a>: 30 + {{{renderTextWithFacets this.post.record.text this.post.record.facets}}} 31 + {{else}} 32 + {{{renderTextWithFacets this.post.record.text this.post.record.facets}}} 33 + {{/if}} 34 + <small>{{elapsed this.post.record.createdAt}} ago</small> 35 + </li> 36 + {{/each}} 37 + </ul> 38 + 39 + <div style="text-align:right;font-size:small"> 40 + <div><a href="?cursor={{cursor}}" accesskey="6">Older</a> 6</div> 41 + </div> 42 + 43 + <div class="s"><b>About {{actor.displayName}}</b></div> 44 + <div>{{actor.displayName}}</div> 45 + <div>{{actor.description}} <a href="https://{{actor.handle}}">web</a></div> 46 + <div> 47 + <a href="{{actor.handle}}/following">{{actor.followsCount}} Following</a> 48 + <a href="{{actor.handle}}/followers">{{actor.followersCount}} Followers</a> 49 + </div> 50 + 51 + {{>mobilefooter}}
+4
views/partials/accessbility.hbs
···
··· 1 + <ul id="accessibility"> 2 + <li>On a mobile phone? Check out <a href="m/">the mobile version</a>!</li> 3 + <li><a href="#footer" accesskey="2">Skip to navigation</a></li> 4 + </ul>
+9
views/partials/footer.hbs
···
··· 1 + <div id="footer"> 2 + <h3>Footer</h3> 3 + <ul> 4 + <li class="first">© {{year}} Skittr</li> 5 + <li><a href="#">API</a></li> 6 + <li><a href="https://bsky.social/about/support/tos">TOS</a></li> 7 + <li><a href="https://bsky.social/about/support/privacy-policy">Privacy</a></li> 8 + </ul> 9 + </div>
+9
views/partials/friends.hbs
···
··· 1 + <div id="friends"> 2 + {{#each follows}} 3 + <span class="vcard"> 4 + <a href="../profile/{{this.handle}}" class="url" rel="contact" title="{{this.displayName}}"> 5 + <img alt="{{this.displayName}}" class="photo fn" height="24" id="profile-image" src="{{this.avatar}}" width="24" /> 6 + </a> 7 + </span> 8 + {{/each}} 9 + </div>
+5
views/partials/logo.hbs
···
··· 1 + <h1 id="header"> 2 + <a href="/" title="Bluesky: home" accesskey="1"> 3 + <img alt="Bluesky" width="112" height="49" src="../../../img/bsky.png" /> 4 + </a> 5 + </h1>
+10
views/partials/mobilefooter.hbs
···
··· 1 + {{#if curuser}} 2 + <br /> 3 + <div><a href="/m/home">Home</a></div> 4 + <div><a href="/m/replies">@{{curuser}}</a></div> 5 + <div><a href="/m/profile/{{curuser}}">Your Profile</a></div> 6 + <div><a href="/m/profile/{{curuser}}/following">People You Follow</a></div> 7 + <form method="post" action="../signout" class="a"> 8 + <input class="b" type="submit" value="Sign out" /> 9 + </form> 10 + {{/if}}
+17
views/partials/navigation.hbs
···
··· 1 + <div id="navigation"> 2 + <h3>Navigation</h3> 3 + <ul> 4 + <li> 5 + <form name="search_form" class="flatbutton" id="search_form" action="/tw/search/users" style="display:inline;"> 6 + <input type="text" name="q" value="Find folks to follow!" id="query_input" style="font-size:0.9em;padding:2px;width:130px;line-height:1em;" onfocus="this.value = ''" /> &nbsp; 7 + <input type="submit" value="search" id="query_submit" style="font-size: 0.9em;" /> 8 + </form> 9 + </li> 10 + {{#if curuser}} 11 + <li><a href="/home">Home</a></li> 12 + <li><a href="/account/design">Edit User Theme</a></li> 13 + {{else}} 14 + <li><a href="/login">Login</a></li> 15 + {{/if}} 16 + </ul> 17 + </div>
+55
views/partials/timeline.hbs
···
··· 1 + <table class="doing" id="timeline" cellspacing="0"> 2 + <tbody> 3 + {{#each feed}} 4 + <tr class="hentry" id="status_{{this.post.cid}}"> 5 + <td class="thumb"> 6 + {{#if this.reason}} 7 + <a href="/profile/{{this.reason.by.handle}}"> 8 + <img src="{{this.reason.by.avatar}}" alt="{{this.reason.by.displayName}}" width="50" /> 9 + </a> 10 + {{else}} 11 + <a href="/profile/{{this.post.author.handle}}"> 12 + <img src="{{this.post.author.avatar}}" alt="{{this.post.author.displayName}}" width="50" /> 13 + </a> 14 + {{/if}} 15 + </td> 16 + <td class="content"> 17 + <span class="entry-title entry-content"> 18 + {{#if this.reason}} @<a href="../profile/{{this.reason.by.handle}}">{{this.reason.by.handle}}</a> 19 + <br /> 20 + RT @<a href="../profile/{{this.post.author.handle}}" rel="nofollow">{{this.post.author.handle}}</a>: {{{renderTextWithFacets this.post.record.text this.post.record.facets}}} {{#if this.post.embed}} 21 + <br /> 22 + {{#each this.post.embed.images}} 23 + <div style="display: flex;justify-content: center;"> 24 + <div class="embed" style="width: 420px; height:297px;"> 25 + <a href="{{this.fullsize}}"> 26 + <img src="{{this.thumb}}" alt="{{this.alt}}" height="100%" width="100%" /> 27 + </a> 28 + </div> 29 + </div> 30 + {{/each}} {{/if}} {{else}} @<a href="/profile/{{this.post.author.handle}}">{{this.post.author.handle}}</a> 31 + <br /> 32 + {{{renderTextWithFacets this.post.record.text this.post.record.facets}}} {{#if this.post.embed}} 33 + <br /> 34 + {{#each this.post.embed.images}} 35 + <div style="display: flex;justify-content: center;"> 36 + <div class="embed" style="width: 420px; height:297px;"> 37 + <a href="{{this.fullsize}}"> 38 + <img src="{{this.thumb}}" alt="{{this.alt}}" height="100%" width="100%" /> 39 + </a> 40 + </div> 41 + </div> 42 + {{/each}} {{/if}} {{/if}} 43 + </span> 44 + <br /> 45 + <span class="meta entry-meta"> 46 + <a href="#" class="entry-date" rel="bookmark"> <abbr class="published" title="{{this.post.record.createdAt}}">{{elapsed this.post.record.createdAt}}</abbr> ago </a> 47 + from {{#if this.post.via}}{{this.post.via}}{{else}}web{{/if}} 48 + 49 + <span id="status_actions_{{this.post.cid}}"> </span> 50 + </span> 51 + </td> 52 + </tr> 53 + {{/each}} 54 + </tbody> 55 + </table>
+31
views/partials/usertheme.hbs
···
··· 1 + {{#if usertheme}} 2 + <style type="text/css"> 3 + a {color: {{usertheme.value.link_color}};} 4 + 5 + body { 6 + color: {{usertheme.value.text_color}}; 7 + background-color: {{usertheme.value.bg_color}}; 8 + background: {{usertheme.value.bg_color}} url({{bg}}) fixed no-repeat top left; 9 + } 10 + 11 + #side { 12 + background-color: {{usertheme.value.side_color}}; 13 + border: 1px solid {{usertheme.value.side_border}}; 14 + } 15 + </style> 16 + {{else}} 17 + <style type="text/css"> 18 + a {color: #0000ff;} 19 + 20 + body { 21 + color: #000000; 22 + background-color: #9ae4e8; 23 + background: #9ae4e8 url(../../../img/bg.gif) fixed no-repeat top left; 24 + } 25 + 26 + #side { 27 + background-color: #e0ff92; 28 + border: 1px solid #87bc44; 29 + } 30 + </style> 31 + {{/if}}
+89
views/profile.hbs
···
··· 1 + <body id="profile" class="account"> 2 + {{>accessbility}} 3 + <div id="container" class="subpage"> 4 + <span id="loader" style="display:none"><img alt="Loader" src="img/loader.gif" /></span> 5 + {{>logo}} 6 + 7 + <div id="flash" style="display: none;"></div> 8 + 9 + <div id="side"> 10 + <div class="section"> 11 + <div class="section-header"> 12 + <span class="section-links"> </span> 13 + <h1>About</h1> 14 + </div> 15 + <!-- /section-header --> 16 + 17 + <address> 18 + <ul class="about vcard entry-author"> 19 + <li><span class="label">Name</span> <span class="fn">{{actor.displayName}}</span></li> 20 + <li><span class="label">Web</span> <span class="fn"><a class="url" href="https://{{actor.handle}}">https://{{actor.handle}}</a></span></li> 21 + <li><span class="label">Bio</span> <span class="fn">{{actor.description}}</span></li> 22 + </ul> 23 + </address> 24 + </div> 25 + <!-- /section --> 26 + 27 + <div class="section"> 28 + <div class="section-header"> 29 + <h1>Stats</h1> 30 + </div> 31 + <!-- section-header --> 32 + 33 + <ul class="stats"> 34 + <li><span class="label"><a href="../{{actor.handle}}/following" class="label">Following</a></span> <span class="numeric stats_count">{{actor.followsCount}}</span></li> 35 + <li><span class="label"><a href="../{{actor.handle}}/followers" class="label">Followers</a></span> <span class="stats_count numeric">{{actor.followersCount}}</span></li> 36 + <li><a href="../{{actor.handle}}" class="label">Updates</a> <span class="stats_count numeric">{{actor.postsCount}}</span></li> 37 + </ul> 38 + </div> 39 + <!-- /section --> 40 + 41 + <div class="section"> 42 + <div class="section-header"> 43 + <h1>Following</h1> 44 + </div> 45 + <!-- /section-header --> 46 + {{>friends}} 47 + </div> 48 + <!-- /section --> 49 + </div> 50 + <!-- /side --> 51 + 52 + <div id="content"> 53 + <div class="wrapper"> 54 + <h2 class="thumb" style="margin-bottom:25px"> 55 + <img alt="Default_profile_bigger" id="profile-image" src="{{actor.avatar}}" width="73" height="73" /> 56 + 57 + {{actor.displayName}} 58 + </h2> 59 + 60 + <div class="clear"></div> 61 + 62 + <br /> 63 + 64 + <div class="hfeed"> 65 + <div class="tab"> 66 + {{>timeline}} 67 + 68 + <div class="bottom_nav"> 69 + <div class="pagination"> 70 + <a href="?cursor={{cursor}}" class="section_links" rel="prev">Older »</a> 71 + </div> 72 + 73 + <span class="statuses_options"> 74 + <a href="{{actor.handle}}/rss" class="section_links">RSS</a> 75 + </span> 76 + </div> 77 + </div> 78 + </div> 79 + </div> 80 + <!-- /wrapper --> 81 + </div> 82 + <!-- /content --> 83 + {{>footer}} 84 + <hr /> 85 + {{>navigation}} 86 + <hr /> 87 + </div> 88 + <!-- /container --> 89 + </body>
+63
views/results_users.hbs
···
··· 1 + <body id="users" class="search"> 2 + {{>accessbility}} 3 + <div id="container" class="subpage"> 4 + <span id="loader" style="display:none"><img alt="Loader" src="img/loader.gif" /></span> 5 + {{>logo}} 6 + 7 + <div id="flash" style="display: none;"></div> 8 + 9 + <div id="content"> 10 + <div class="wrapper"> 11 + <div id="resultsheader" style="float:left;width:440px;margin:0px;padding:0px;"> 12 + <h1>Results 1 - {{actor.length}} of {{actor.length}} for "{{query}}"</h1> 13 + <form name="search_form" class="flatbutton" id="search_form" action="/tw/search/users"> 14 + <input type="text" name="q" value="{{query}}" id="query_input" style="font-size:1.1em;line-height:1.5em;padding:2px;width:250px;"> 15 + &nbsp; 16 + <input type="submit" value="search" id="query_submit" style="font-size: 1.1em;"> 17 + </form> 18 + </div> 19 + <br> 20 + <p style="clear:both"></p> 21 + <br> 22 + <div id="results"> 23 + {{#each actor}} 24 + <div id="user_{{this.did}}" class="user_search"> 25 + <a href="../../../profile/{{this.handle}}"> 26 + <img alt="{{this.displayName}}" class="profile_img" id="profile-image" src="{{this.avatar}}" width="50"> 27 + </a> 28 + <div style="float: left; margin-top: 6px;"> 29 + <a href="../../../profile/{{this.handle}}" class="screen_name"> 30 + <span class="detail">{{this.handle}}</span> 31 + </a> 32 + </div> 33 + <div class="details"> 34 + <strong>Name</strong> <span class="detail">{{this.displayName}}</span> 35 + <br> 36 + <strong>Web</strong> <span class="detail"><a href="https://{{this.handle}}">http://{{this.handle}}</a></span> 37 + <br> 38 + <strong>Bio</strong> <span class="detail">{{this.description}}</span> 39 + <br> 40 + <div style="width: 65%"> 41 + <strong>Recently</strong> 42 + y5rg87uirehnwugijr 43 + <span style="color: #666;">1 day ago</span> 44 + </div> 45 + </div> 46 + </div> 47 + {{/each}} 48 + </div> 49 + <p style="clear:both"></p> 50 + <p style="text-align: center;" id="pagination"> 51 + &nbsp;<strong>page 1 of 1</strong>&nbsp; 52 + </p> 53 + </div> 54 + <!-- /wrapper --> 55 + </div> 56 + <!-- /content --> 57 + {{>footer}} 58 + <hr /> 59 + {{>navigation}} 60 + <hr /> 61 + </div> 62 + <!-- /container --> 63 + </body>
+30
views/rss/feed.hbs
···
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <rss version="2.0"> 3 + <channel> 4 + <title>Bluesky / {{feedData.[0].displayName}}</title> 5 + <link>{{instanceURL}}/profile/{{feedData.[0].creator.handle}}/feed/{{record}}</link> 6 + <description>{{feedData.[0].description}}</description> 7 + <language>en-us</language> 8 + <ttl>40</ttl> 9 + {{#each feed}} 10 + <item> 11 + <title>{{#if this.reason}}RT @{{this.post.author.handle}} ({{elapsed this.post.record.createdAt}} ago){{else}}@{{this.post.author.handle}} ({{elapsed this.post.record.createdAt}} ago){{/if}}</title> 12 + <description> 13 + <![CDATA[ 14 + <p>{{{renderTextWithFacets this.post.record.text this.post.record.facets}}}</p> 15 + {{#if this.post.embed}} 16 + {{#each this.post.embed.images}} 17 + <a href="{{this.fullsize}}"> 18 + <img src="{{this.thumb}}" alt="{{this.alt}}" height="100%" width="100%" /> 19 + </a> 20 + {{/each}} 21 + {{/if}} 22 + ]]> 23 + </description> 24 + <pubDate>{{rssDate this.post.record.createdAt}}</pubDate> 25 + <guid>{{this.post.uri}}</guid> 26 + <link>{{this.post.uri}}</link> 27 + </item> 28 + {{/each}} 29 + </channel> 30 + </rss>
+30
views/rss/user.hbs
···
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <rss version="2.0"> 3 + <channel> 4 + <title>Bluesky / {{actor.displayName}}</title> 5 + <link>{{instanceURL}}/profile/{{actor.handle}}</link> 6 + <description>Bluesky updates from {{actor.displayName}}.</description> 7 + <language>en-us</language> 8 + <ttl>40</ttl> 9 + {{#each feed}} 10 + <item> 11 + <title>{{#if this.reason}}RT @{{this.post.author.handle}} ({{elapsed this.post.record.createdAt}} ago){{else}}@{{this.post.author.handle}} ({{elapsed this.post.record.createdAt}} ago){{/if}}</title> 12 + <description> 13 + <![CDATA[ 14 + <p>{{{renderTextWithFacets this.post.record.text this.post.record.facets}}}</p> 15 + {{#if this.post.embed}} 16 + {{#each this.post.embed.images}} 17 + <a href="{{this.fullsize}}"> 18 + <img src="{{this.thumb}}" alt="{{this.alt}}" height="100%" width="100%" /> 19 + </a> 20 + {{/each}} 21 + {{/if}} 22 + ]]> 23 + </description> 24 + <pubDate>{{rssDate this.post.record.createdAt}}</pubDate> 25 + <guid>{{this.post.uri}}</guid> 26 + <link>{{this.post.uri}}</link> 27 + </item> 28 + {{/each}} 29 + </channel> 30 + </rss>
+27
views/search_users.hbs
···
··· 1 + <body id="users" class="search"> 2 + {{>accessbility}} 3 + <div id="container" class="subpage"> 4 + <span id="loader" style="display:none"><img alt="Loader" src="img/loader.gif" /></span> 5 + {{>logo}} 6 + 7 + <div id="flash" style="display: none;"></div> 8 + 9 + <div id="content"> 10 + <div class="wrapper"> 11 + <h1>Who are you looking for?</h1> 12 + <form name="search_form" class="flatbutton" id="search_form" action="/tw/search/users"> 13 + <input type="text" name="q" value="" id="query_input" style="font-size:1.1em;line-height:1.5em;padding:2px;width:250px;"> &nbsp; 14 + <input type="submit" value="search" id="query_submit" style="font-size: 1.1em;"> 15 + &nbsp; <span class="midgrey">Examples: John, bob@test.com, San Francisco, user123.</span> 16 + </form> 17 + </div> 18 + <!-- /wrapper --> 19 + </div> 20 + <!-- /content --> 21 + {{>footer}} 22 + <hr /> 23 + {{>navigation}} 24 + <hr /> 25 + </div> 26 + <!-- /container --> 27 + </body>
+37
views/status.hbs
···
··· 1 + <body id="show" class="status"> 2 + {{>accessbility}} 3 + <div id="container"> 4 + <div id="content"> 5 + <div class="wrapper"> 6 + <div id="permalink"> 7 + <div class="desc"> 8 + <p>{{post.record.text}}</p> 9 + {{#each this.post.embed.images}} 10 + <div style="display: flex;justify-content: center;"> 11 + <div class="embed" style="width: 420px; height:297px;"> 12 + <a href="{{this.fullsize}}"> 13 + <img src="{{this.thumb}}" alt="{{this.alt}}" height="100%" width="100%" /> 14 + </a> 15 + </div> 16 + </div> 17 + {{/each}} 18 + <p class="meta"> 19 + <span class="meta"> 20 + {{elapsed post.record.createdAt}} 21 + <span id="status_actions_{{post.cid}}"> </span> 22 + </span> 23 + </p> 24 + </div> 25 + 26 + <h2 class="thumb"> 27 + <a href="../../{{post.author.handle}}"> 28 + <img alt="Picture_2" src="{{post.author.avatar}}" width="73" height="73" /> 29 + </a> 30 + <a href="../../{{post.author.handle}}">{{post.author.displayName}}</a> 31 + </h2> 32 + </div> 33 + </div> 34 + </div> 35 + <hr /> 36 + </div> 37 + </body>