diff --git a/.gitignore b/.gitignore index c1b3d413..21469fcb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,8 @@ dev/.DS_Store figwheel_server.log .rebel_readline_history resources/public/js -bin/set-env-variables.sh \ No newline at end of file +bin/set-env-variables.sh + +# IntelliJ files +.idea/* +*.iml diff --git a/Procfile b/Procfile index 2a92b316..a4de12ce 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: java $JVM_OPTS -jar target/smallworld.jar -m smallworld.web \ No newline at end of file +web: java -Xmx300M -Xss512k -XX:CICompilerCount=2 -Dfile.encoding=UTF-8 -jar target/smallworld.jar -m smallworld.web \ No newline at end of file diff --git a/README.md b/README.md index 59ed3da2..6c4b929a 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,16 @@ https://smallworld.kiwi - [update code running in the repl](#update-code-running-in-the-repl) - [deploy to Heroku](#deploy-to-heroku) - [initial designs](#initial-designs) -- [emails & scheduled jobs](#emails--scheduled-jobs) +- [emails](#emails) - [sql cheatsheet](#sql-cheatsheet) +- [misc commands cheatsheet](#misc-commands-cheatsheet) ## run, build, & deploy ### local setup 1. `lein install` 2. install postgres: https://postgresapp.com (database) -3. create local postgres db called `smallworld-local` +3. run `bin/setup` to create a local postgres db called `smallworld-local` ### local development 1. run `bin/start-dev.sh` - sets the environment variables @@ -84,11 +85,9 @@ bin/make-and-deploy.sh | - | - | - | | | | | -## emails & scheduled jobs +## emails - the server sends emails via SendGrid. view/edit the templates here: https://mc.sendgrid.com/dynamic-templates -- jobs are scheduled through the Heroku Scheduler: -https://dashboard.heroku.com/apps/small-world-friends/scheduler ## sql cheatsheet @@ -114,8 +113,7 @@ https://dashboard.heroku.com/apps/small-world-friends/scheduler - make a user go through welcome flow again: ```sql - update settings - set welcome_flow_complete = false + update settings set welcome_flow_complete = false where screen_name = 'devon_dos'; ``` @@ -142,3 +140,18 @@ https://dashboard.heroku.com/apps/small-world-friends/scheduler ALTER TABLE "settings" ADD COLUMN locations jsonb; ``` +## misc commands cheatsheet + +- run command line inside of Heroku: + ``` + heroku ps:exec --app=small-world-friends + ``` +- view environment variables for process with pid 4: + ``` + cat /proc/4/environ | tr '\0' '\n' + ``` +- run prod jar locally: + ```sh + lein uberjar # builds the jar + java -Xmx300m -Xss512k -XX:CICompilerCount=2 -Dfile.encoding=UTF-8 -jar target/smallworld.jar -m smallworld.web + ``` \ No newline at end of file diff --git a/bin/open-local-db.sh b/bin/open-local-db.sh new file mode 100755 index 00000000..3f7d955b --- /dev/null +++ b/bin/open-local-db.sh @@ -0,0 +1 @@ +/Applications/Postgres.app/Contents/Versions/14/bin/psql -p5432 "devonzuegel" \ No newline at end of file diff --git a/bin/open-prod-db.sh b/bin/open-prod-db.sh new file mode 100755 index 00000000..0e568051 --- /dev/null +++ b/bin/open-prod-db.sh @@ -0,0 +1 @@ +heroku pg:psql postgresql-fluffy-56995 --app small-world-friends \ No newline at end of file diff --git a/bin/set-env-variables TEMPLATE.sh b/bin/set-env-variables TEMPLATE.sh index 5b38f167..2f1a1fbd 100644 --- a/bin/set-env-variables TEMPLATE.sh +++ b/bin/set-env-variables TEMPLATE.sh @@ -6,4 +6,5 @@ export BING_MAPS_API_KEY=get from Bing Developer export COOKIE_STORE_SECRET_KEY=generate a 16-char random string export DATABASE_URL=set up in Heroku export LEIN_JVM_OPTS="-XX:TieredStopAtLevel=1" # suppresses OpenJDK 64-Bit Server VM warning: https://stackoverflow.com/a/67695691/2639250 +export JAVA_OPTS="-Xmx300m -Xss512k -XX:CICompilerCount=2 -Dfile.encoding=UTF-8" # match the max heap/stack size set by Heroku export SENDGRID_API_KEY=get from https://sendgrid.com \ No newline at end of file diff --git a/bin/setup b/bin/setup new file mode 100755 index 00000000..1ad368f2 --- /dev/null +++ b/bin/setup @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +psql -U postgres -c "CREATE DATABASE \"smallworld-local\"" diff --git a/dev/dev/repl.clj b/dev/dev/repl.clj index 1169968c..2972907d 100644 --- a/dev/dev/repl.clj +++ b/dev/dev/repl.clj @@ -9,13 +9,14 @@ #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn restart-server [] - (use 'smallworld.web :reload) (backend/stop!) - (backend/start! PORT)) + (use 'smallworld.web :reload) + (println "\nrestarting server......\n") + (backend/-main)) (defn initialize-repl [] (println (str "\n\n🌎 starting the small world server (backend): http://localhost:" PORT " 🌍\n")) - (backend/start! 3001) + (backend/-main) (println "\n\n🎨 starting the Figwheel server (frontend hot-reloading) 🎨\n") (repl-api/start-figwheel!) diff --git a/latest.dump b/latest.dump new file mode 100644 index 00000000..84a5bc5d Binary files /dev/null and b/latest.dump differ diff --git a/project.clj b/project.clj index 6fa8a303..58ddbec7 100644 --- a/project.clj +++ b/project.clj @@ -14,6 +14,7 @@ [twttr "3.2.3"] [cheshire "5.10.1"] [oauth-clj "0.1.16"] + [metosin/reitit "0.5.18"] [cljsjs/mapbox "0.46.0-0"] [ring/ring-jetty-adapter "1.9.5"] [ring/ring-ssl "0.3.0"] @@ -44,12 +45,10 @@ :source-paths ["src"] :figwheel true :compiler {:main "smallworld.frontend" - ;; :preloads [devtools.preload] :asset-path "js/out" :output-to "resources/public/js/main.js" :output-dir "resources/public/js/out" :optimizations :none - ;; :recompile-dependents true :source-map true}}]}} :uberjar {:env {:production true} :source-paths ["src"] diff --git a/resources/public/css/styles.css b/resources/public/css/styles.css index 6106b43f..8d3fb426 100644 --- a/resources/public/css/styles.css +++ b/resources/public/css/styles.css @@ -80,7 +80,7 @@ select { bottom: 0px; left: 0px; opacity: 0.26; - height: 46px; + height: 42px; } .nav .globe-loader { position: relative; @@ -202,6 +202,10 @@ select { font-size: 0.85em; opacity: 0.6; vertical-align: middle; + gap: 14px; +} +.friends-list .loading .loader { + margin: -6px; } /******************************************************************************/ @@ -209,7 +213,7 @@ select { /******************************************************************************/ .home-page { margin: auto; - padding: 24px 16px 12px 16px; + padding: 16px 16px 4px 16px; max-width: calc(758px + 20px); /* leave space for 2 columns + padding */ /* the following is so the map is centered, even when it overflows*/ @@ -329,11 +333,11 @@ tr:nth-child(even) { .category { border: 1px solid #d9d3cc22; border-radius: 10px; - margin-bottom: 12px; + margin-bottom: 16px; width: 100%; } -.category { - margin-top: 18px; +#track-new-location-field + .category { + margin-top: 16px !important; } .category .current-user-location { transform: translateY(calc(-50% - 6px)); @@ -349,11 +353,11 @@ tr:nth-child(even) { } .no-locations-info { color: #d9d3cccc; - padding: 24px; - border: 2px solid #ffffff22; + padding: 20px; + border: 1px solid #ffffff22; border-radius: 8px; line-height: 1.7; - margin-top: 16px; + margin-bottom: 16px; } .no-locations-info p, ul { @@ -362,9 +366,21 @@ ul { .friends-list { padding: 9px 6px; } +.friends-list svg.info { + fill: white; + opacity: 0.45; + margin-bottom: -3px; + margin-left: 8px; + cursor: pointer; + transition: all 50ms ease-out; + transform: scale(1.1); +} +.friends-list svg.info:hover { + opacity: 1; + transition: all 50ms ease-out; +} .friends-list .verb-gerund { - border-bottom: dashed 2px #ffffff55; - cursor: help; + border-bottom: solid 2px #ffffff33; } // @media screen and (max-width: 720px) { // .friends-list { @@ -388,7 +404,6 @@ ul { .friends-list.header { font-size: 0.85em; display: flex; - min-height: 59px; flex-direction: row; background-color: #ffffff0d; border-radius: 8px 8px 0 0; @@ -401,15 +416,10 @@ ul { font-weight: 600; } .friends-list .mapbox-container { - min-width: 80px; + width: 50px; + height: 50px; border-radius: 80px; } - -@media (max-width: 768px) /* mobile */ { - .friends-list .mapbox-container { - min-width: 60px; - } -} .friends-list .mapbox-container .mapboxgl-map { height: 100% !important; width: 100% !important; @@ -426,7 +436,8 @@ ul { display: flex; flex-wrap: wrap; gap: 12px; - margin-top: 12px; + margin-top: 8px; + transition: margin-top 100ms ease-out; } @media screen and (min-width: 720px) { .friends-list .friends { @@ -497,6 +508,32 @@ ul { opacity: 0.7; cursor: default; } +.friend-bubbles { + display: flex; + flex-wrap: wrap; + gap: 1px; + margin: 8px auto 6px 2px; + cursor: pointer; + transition: margin-top 200ms ease-out; +} +.friend-bubbles .friend { + width: auto; + display: inline; + margin: 1px; + opacity: 0.8; +} +.friend-bubbles .friend:hover { + opacity: 1; + transition: opacity 100ms ease-out; +} +.friend-bubbles .friend .twitter-pic { + border-width: 1px; +} +.friend-bubbles .friend .twitter-pic, +.friend-bubbles .friend .twitter-pic img { + height: 20px; + width: 20px; +} /******************************************************************************/ /**** friend: current-user ********************************************************************/ /******************************************************************************/ @@ -510,10 +547,9 @@ ul { margin: auto; } .twitter-data-explanation > .twitter-data { - background: #ffffff11; display: flex; padding: 8px 16px; - border-radius: 8px; + margin-top: 16px; column-gap: 12px; } .twitter-data-explanation > .twitter-data .right-side { @@ -531,8 +567,10 @@ ul { } .twitter-data-explanation > .twitter-data img { height: 50px; + width: 50px; border-radius: 100%; border: 2px solid #d9d3cc44; + background-color: #d9d3cc22; } .twitter-data-explanation { text-align: left; @@ -588,7 +626,6 @@ ul { align-items: center; } } - .current-user .friend { margin-bottom: 24px; } @@ -765,7 +802,8 @@ ul { .welcome-flow .you-signed-in-as, .welcome-flow .location-field, .welcome-flow .heads-up, -.welcome-flow .email-options { +.welcome-flow .email-options, +.welcome-flow .email-address { max-width: 340px; margin: auto; padding: 16px 12px; @@ -775,7 +813,6 @@ ul { .welcome-flow #track-new-location-field { width: fit-content; margin: auto; - margin-top: 12px; padding: 8px 14px; border-radius: 24px; fill: #d9d3cc; @@ -796,6 +833,9 @@ ul { margin-right: 2px; margin-bottom: -6px; } +.welcome-flow #track-new-location-field { + margin-top: 12px; +} .welcome-flow svg.cancel-icon, .home-page .delete-location-btn svg.cancel-icon { float: right; @@ -814,7 +854,7 @@ ul { } .welcome-flow svg.cancel-icon { margin-right: -18px; - margin-top: -28px; + margin-top: -20px; } .home-page .friends-list.header .right-side { margin-left: 12px; @@ -836,15 +876,16 @@ ul { margin-top: -14px; } .welcome-flow .location-field { - padding: 24px 12px; + padding: 16px 12px; padding-bottom: 6px; margin-top: 14px; } .welcome-flow .location-field label .small-info-text { padding: 0 10px; } +.welcome-flow .location-field, .welcome-flow .email-options, -.welcome-flow .location-field { +.welcome-flow .email-address { border: 1px solid #ffffff33; } .welcome-flow input { @@ -852,7 +893,9 @@ ul { } .welcome-flow .location-field:focus-within, .welcome-flow .email-options:focus-within, -.welcome-flow .email-options:focus /* the radio btns field is different */ { +.welcome-flow .email-options:focus /* the radio btns field is different */ +.welcome-flow .email-address:focus, +.welcome-flow .email-address:focus-within { border: 1px solid #ffffff55; } .welcome-flow .you-signed-in-as .friend { @@ -869,7 +912,11 @@ ul { position: absolute; opacity: 0.5; } -.welcome-flow input[type='text'] { +.welcome-flow .field { + font-size: 0.95em; +} +.welcome-flow input[type='text'], +.welcome-flow input[type='email'] { width: 254px; padding: 4px 32px 4px 5px; background: #ffffff11; @@ -901,6 +948,12 @@ ul { } .welcome-flow .email-options { margin-top: 14px; + padding-top: 6px; + padding-bottom: 0; +} +.welcome-flow .email-address { + margin-top: 14px; + padding-bottom: 12px; } .welcome-flow .email-options .radio-btns { text-align: left; @@ -956,21 +1009,22 @@ input[type='radio']:checked::before { } input[type='radio']:focus { - outline: 0.1em solidwhite; + outline: 0.1em solid white; outline-offset: max(2px, 0.15em); } :not(.error) .error-msg { display: none; } +.welcome-flow input::placeholder { + color: #d9d3cc55; +} .error .error-msg { display: block; - background: #ffdfe1; - border-radius: 4px; - color: #c2000d; + color: #ffa88d; + font-weight: 900; font-size: 0.8em; font-weight: 500; - margin: 2px auto 0; - padding: 4px; + margin: 0 auto; text-align: center; text-align: center; width: 289px; @@ -1047,19 +1101,18 @@ input[type='radio']:focus { box-shadow: 0px 0px 18px #ffffff22; border-radius: 24px; position: relative; /* required so that position:absolute works for the .expand-me btn */ + margin: 0 0 16px 0; } @media (max-width: 768px) /* mobile */ { #mapbox-container { width: calc(100vw - 32px); height: calc(60vh - 40px - 41px); /* 41px accounts for the margin */ - margin: 16px 0 20px 0; } } @media (min-width: 768px) /* desktop */ { #mapbox-container { - width: calc(100vw - 60px); - height: calc(60vh - 60px - 41px); /* 41px accounts for the margin */ - margin: 16px 0 20px 0; + width: calc(100vw - 40px); + height: calc(70vh - 60px - 41px); /* 41px accounts for the margin */ } } #mapbox-container.expanded { @@ -1076,17 +1129,35 @@ input[type='radio']:focus { z-index: 1; border-radius: 0; } +// #mapbox-container.loading { +// height: 20vh; +// transition: height 900ms ease-in-out; +// } +#mapbox-container .controls { + right: 0; + margin-top: 6px; + margin-right: 6px; + background: rgba(255, 255, 255, 0.9); + width: fit-content; + height: fit-content; + z-index: 1; + border-radius: 24px; + position: absolute; + display: flex; + justify-content: space-between; + gap: 12px; + padding: 0 11px 4px 13px; +} #mapbox-container .zoom-in, #mapbox-container .zoom-out, #mapbox-container .expand-me { - position: absolute; /* #mapbox-container needs position:relative */ pointer-events: auto; fill: #013917; cursor: pointer; filter: drop-shadow(0 0 2px rgb(255 255 255 / 0.5)); z-index: 300; margin-top: 12px; - margin-left: 12px; + transform: scale(1.3); } #mapbox-container .zoom-in:hover, #mapbox-container .zoom-out:hover, @@ -1094,11 +1165,8 @@ input[type='radio']:focus { fill: black; filter: drop-shadow(0 0 2px rgb(255 255 255 / 0.8)); } -#mapbox-container svg.zoom-in { - margin-left: 48px; -} -#mapbox-container svg.zoom-out { - margin-left: 80px; +#mapbox-container .expand-me { + margin-right: 4px; } #mapbox-container .mapboxgl-map { background: #9dc7d9; @@ -1408,3 +1476,180 @@ ul li { .info-footer a:hover { cursor: pointer; } + +/**************************************************************** + ** tooltips **************************************************** + ****************************************************************/ + +/* Base styles for the element that has a tooltip */ +[data-tooltip], +.tooltip { + position: relative; + cursor: pointer; +} +/* Base styles for the entire tooltip tooltip */ +[data-tooltip]:before, +[data-tooltip]:after, +.tooltip:before, +.tooltip:after { + position: absolute; + visibility: hidden; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; + filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=0); + opacity: 0; + -webkit-transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out, -webkit-transform 0.2s cubic-bezier(0.71, 1.7, 0.77, 1.24); + -moz-transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out, -moz-transform 0.2s cubic-bezier(0.71, 1.7, 0.77, 1.24); + transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out, transform 0.2s cubic-bezier(0.71, 1.7, 0.77, 1.24); + -webkit-transform: translate3d(0, 0, 0); + -moz-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + pointer-events: none; +} +/* Show the entire tooltip on hover and focus tooltip */ +[data-tooltip]:hover:before, +[data-tooltip]:hover:after, +[data-tooltip]:focus:before, +[data-tooltip]:focus:after, +.tooltip:hover:before, +.tooltip:hover:after, +.tooltip:focus:before, +.tooltip:focus:after { + visibility: visible; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=100)'; + filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=100); + opacity: 1; +} +/* Base styles for the tooltip's directional arrow tooltip */ +.tooltip:before, +[data-tooltip]:before { + z-index: 1001; + border: 6px solid transparent; + background: transparent; + content: ''; +} +/* Base styles for the tooltip's content area tooltip */ +.tooltip:after, +[data-tooltip]:after { + z-index: 1000; + padding: 8px; + width: 224px; + background-color: #022610; + color: #fff; + content: attr(data-tooltip); + font-size: 14px; + line-height: 1.2; +} +/* Directions tooltip */ +/* Top (default) */ +[data-tooltip]:before, +[data-tooltip]:after, +.tooltip:before, +.tooltip:after, +.tooltip-top:before, +.tooltip-top:after { + bottom: 100%; + left: 50%; +} +[data-tooltip]:before, +.tooltip:before, +.tooltip-top:before { + margin-left: -6px; + margin-bottom: -12px; + border-top-color: #022610; +} +/* Horizontally align top/bottom tooltips tooltip */ +[data-tooltip]:after, +.tooltip:after, +.tooltip-top:after { + margin-left: -80px; +} +[data-tooltip]:hover:before, +[data-tooltip]:hover:after, +[data-tooltip]:focus:before, +[data-tooltip]:focus:after, +.tooltip:hover:before, +.tooltip:hover:after, +.tooltip:focus:before, +.tooltip:focus:after, +.tooltip-top:hover:before, +.tooltip-top:hover:after, +.tooltip-top:focus:before, +.tooltip-top:focus:after { + -webkit-transform: translateY(-12px); + -moz-transform: translateY(-12px); + transform: translateY(-12px); +} +/* Left tooltip */ +.tooltip-left:before, +.tooltip-left:after { + right: 100%; + bottom: 50%; + left: auto; +} +.tooltip-left:before { + margin-left: 0; + margin-right: -12px; + margin-bottom: 0; + border-top-color: transparent; + border-left-color: #022610; +} +.tooltip-left:hover:before, +.tooltip-left:hover:after, +.tooltip-left:focus:before, +.tooltip-left:focus:after { + -webkit-transform: translateX(-12px); + -moz-transform: translateX(-12px); + transform: translateX(-12px); +} +/* Bottom tooltip */ +.tooltip-bottom:before, +.tooltip-bottom:after { + top: 100%; + bottom: auto; + left: 50%; +} +.tooltip-bottom:before { + margin-top: -12px; + margin-bottom: 0; + border-top-color: transparent; + border-bottom-color: #022610; +} +.tooltip-bottom:hover:before, +.tooltip-bottom:hover:after, +.tooltip-bottom:focus:before, +.tooltip-bottom:focus:after { + -webkit-transform: translateY(12px); + -moz-transform: translateY(12px); + transform: translateY(12px); +} +/* Right tooltip */ +.tooltip-right:before, +.tooltip-right:after { + bottom: 50%; + left: 100%; +} +.tooltip-right:before { + margin-bottom: 0; + margin-left: -12px; + border-top-color: transparent; + border-right-color: #022610; +} +.tooltip-right:hover:before, +.tooltip-right:hover:after, +.tooltip-right:focus:before, +.tooltip-right:focus:after { + -webkit-transform: translateX(12px); + -moz-transform: translateX(12px); + transform: translateX(12px); +} +/* Move directional arrows down a bit for left/right tooltips tooltip */ +.tooltip-left:before, +.tooltip-right:before { + top: 3px; +} +/* Vertically center tooltip content for left/right tooltips tooltip */ +.tooltip-left:after, +.tooltip-right:after { + margin-left: 0; + margin-bottom: -16px; +} diff --git a/resources/public/heroku-errors/application-error.html b/resources/public/heroku-errors/application-error.html new file mode 100644 index 00000000..be8da205 --- /dev/null +++ b/resources/public/heroku-errors/application-error.html @@ -0,0 +1,36 @@ + + +
+

Small World is overloaded

+

sorry, Small World is a little more popular than we anticipated!

+

if you refresh, the website should be back up in a few seconds

+
diff --git a/resources/public/index.html b/resources/public/index.html index b0aa9b60..c057c24e 100644 --- a/resources/public/index.html +++ b/resources/public/index.html @@ -14,9 +14,16 @@ gtag('config', 'G-PBL40J7H77') + + + - @@ -27,9 +34,12 @@ + + + - + - + - + @@ -58,6 +68,6 @@ - + diff --git a/resources/public/kiwi-favicon.png b/resources/public/kiwi-favicon.png index 4054b91f..91cc1ecb 100644 Binary files a/resources/public/kiwi-favicon.png and b/resources/public/kiwi-favicon.png differ diff --git a/resources/sql/friends.sql b/resources/sql/friends.sql new file mode 100644 index 00000000..fdb8e954 --- /dev/null +++ b/resources/sql/friends.sql @@ -0,0 +1,20 @@ +-- name: ysql-friends-by-screen-name +with friends_rows as ( + select json_array_elements(data->'friends') as f + from friends + where request_key = :screen_name +) +select + f->'id' as id, + f->'description' as description, + f->'screen-name' as "screen-name", + f->'profile-image-url-https' as "profile-image-url-https", + f->'email' as email, + f->'location' as location, + f->'name' as name +from friends_rows; + +-- name: ysql-friends-by-screen-name-debug +select json_array_elements(data->'friends') as f +from friends +where request_key = :screen_name \ No newline at end of file diff --git a/resources/sql/schema-access-tokens.sql b/resources/sql/schema-access-tokens.sql index 13b48638..6cca6ba1 100644 --- a/resources/sql/schema-access-tokens.sql +++ b/resources/sql/schema-access-tokens.sql @@ -8,6 +8,10 @@ create table if not exists access_tokens ( --- split here --- +-------------------------------------------------------------------------------- +---- auto-update updated_at ---------------------------------------------------- +-------------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger LANGUAGE plpgsql @@ -26,4 +30,13 @@ $function$; CREATE TRIGGER set_updated_at BEFORE UPDATE ON access_tokens FOR EACH ROW -EXECUTE FUNCTION set_current_timestamp_updated_at(); \ No newline at end of file +EXECUTE FUNCTION set_current_timestamp_updated_at(); + +-------------------------------------------------------------------------------- +---- add an index on request_key ----------------------------------------------- +-------------------------------------------------------------------------------- + +--- split here --- + +CREATE INDEX index__request_key__access_tokens +ON access_tokens (request_key); diff --git a/resources/sql/schema-coordinates.sql b/resources/sql/schema-coordinates.sql index d19bf30b..7585a28b 100644 --- a/resources/sql/schema-coordinates.sql +++ b/resources/sql/schema-coordinates.sql @@ -8,6 +8,10 @@ create table if not exists coordinates ( --- split here --- +-------------------------------------------------------------------------------- +---- auto-update updated_at ---------------------------------------------------- +-------------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger LANGUAGE plpgsql @@ -26,4 +30,13 @@ $function$; CREATE TRIGGER set_updated_at BEFORE UPDATE ON coordinates FOR EACH ROW -EXECUTE FUNCTION set_current_timestamp_updated_at(); \ No newline at end of file +EXECUTE FUNCTION set_current_timestamp_updated_at(); + +-------------------------------------------------------------------------------- +---- add an index on request_key ----------------------------------------------- +-------------------------------------------------------------------------------- + +--- split here --- + +CREATE INDEX index__request_key__coordinates +ON coordinates (request_key); diff --git a/resources/sql/schema-friends.sql b/resources/sql/schema-friends.sql index 38839d07..1105d801 100644 --- a/resources/sql/schema-friends.sql +++ b/resources/sql/schema-friends.sql @@ -8,6 +8,10 @@ create table if not exists friends ( --- split here --- +-------------------------------------------------------------------------------- +---- auto-update updated_at ---------------------------------------------------- +-------------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger LANGUAGE plpgsql @@ -26,4 +30,13 @@ $function$; CREATE TRIGGER set_updated_at BEFORE UPDATE ON friends FOR EACH ROW -EXECUTE FUNCTION set_current_timestamp_updated_at(); \ No newline at end of file +EXECUTE FUNCTION set_current_timestamp_updated_at(); + +-------------------------------------------------------------------------------- +---- add an index on request_key ----------------------------------------------- +-------------------------------------------------------------------------------- + +--- split here --- + +CREATE INDEX index__request_key__friends +ON friends (request_key); diff --git a/resources/sql/schema-impersonation.sql b/resources/sql/schema-impersonation.sql index 0e8f3a73..a979174d 100644 --- a/resources/sql/schema-impersonation.sql +++ b/resources/sql/schema-impersonation.sql @@ -6,6 +6,10 @@ create table if not exists impersonation ( --- split here --- +-------------------------------------------------------------------------------- +---- auto-update updated_at ---------------------------------------------------- +-------------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger LANGUAGE plpgsql @@ -24,4 +28,4 @@ $function$; CREATE TRIGGER set_updated_at BEFORE UPDATE ON impersonation FOR EACH ROW -EXECUTE FUNCTION set_current_timestamp_updated_at(); \ No newline at end of file +EXECUTE FUNCTION set_current_timestamp_updated_at(); diff --git a/resources/sql/schema-settings.sql b/resources/sql/schema-settings.sql index 9cbceaa6..7ffe088b 100644 --- a/resources/sql/schema-settings.sql +++ b/resources/sql/schema-settings.sql @@ -14,6 +14,10 @@ create table if not exists settings ( --- split here --- +-------------------------------------------------------------------------------- +---- auto-update updated_at ---------------------------------------------------- +-------------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger LANGUAGE plpgsql @@ -32,4 +36,13 @@ $function$; CREATE TRIGGER set_updated_at BEFORE UPDATE ON settings FOR EACH ROW -EXECUTE FUNCTION set_current_timestamp_updated_at(); \ No newline at end of file +EXECUTE FUNCTION set_current_timestamp_updated_at(); + +-------------------------------------------------------------------------------- +---- add an index on screen_name ----------------------------------------------- +-------------------------------------------------------------------------------- + +--- split here --- + +CREATE INDEX index__screen_name__settings +ON settings (screen_name); diff --git a/resources/sql/schema-twitter-profiles.sql b/resources/sql/schema-twitter-profiles.sql index 286db736..2a6dbada 100644 --- a/resources/sql/schema-twitter-profiles.sql +++ b/resources/sql/schema-twitter-profiles.sql @@ -8,6 +8,10 @@ create table if not exists twitter_profiles ( --- split here --- +-------------------------------------------------------------------------------- +---- auto-update updated_at ---------------------------------------------------- +-------------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION public.set_current_timestamp_updated_at() RETURNS trigger LANGUAGE plpgsql @@ -26,4 +30,13 @@ $function$; CREATE TRIGGER set_updated_at BEFORE UPDATE ON twitter_profiles FOR EACH ROW -EXECUTE FUNCTION set_current_timestamp_updated_at(); \ No newline at end of file +EXECUTE FUNCTION set_current_timestamp_updated_at(); + +-------------------------------------------------------------------------------- +---- add an index on request_key ----------------------------------------------- +-------------------------------------------------------------------------------- + +--- split here --- + +CREATE INDEX index__request_key__twitter_profiles +ON twitter_profiles (request_key); diff --git a/src/smallworld/admin.cljc b/src/smallworld/admin.cljc index 0582739b..990e613f 100644 --- a/src/smallworld/admin.cljc +++ b/src/smallworld/admin.cljc @@ -23,14 +23,14 @@ (do (defonce admin-summary* (r/atom :loading)) - (defn summary-screen [] ; TODO: fetch admin data on screen load – probably needs react effects to do it properly + (defn screen [] ; TODO: fetch admin data on screen load – probably needs react effects to do it properly [:div.admin-screen (if-not (= screen-name (:screen-name @session/*store)) (if (= :loading @session/*store) (decorations/loading-screen) [:p {:style {:margin "30vh auto 0 auto" :text-align "center" :font-size "2em"}} - "whoops, you don't have access to this page"]) + "not found"]) [:<> [:a.btn {:on-click #(util/fetch "/api/v1/admin/refresh_all_users_friends" (fn [result] @@ -76,14 +76,4 @@ (dissoc :data)) (db/select-all db/friends-table)) :coordinates (db/select-all db/coordinates-table)})] - (cheshire/generate-string result))) - - (defn friends-of-specific-user [get-current-session get-users-friends req] - (fn [{params :params}] - (println "params:") - (println params) - (let [curr-user-screen-name (:screen-name (get-current-session req)) - target-screen-name (:screen_name params)] - (if-not (= screen-name curr-user-screen-name) - (response/bad-request {:message "you don't have access to this page"}) - (get-users-friends req target-screen-name))))))) \ No newline at end of file + (cheshire/generate-string result))))) \ No newline at end of file diff --git a/src/smallworld/coordinates.clj b/src/smallworld/coordinates.clj index 05ba6f43..4344734d 100644 --- a/src/smallworld/coordinates.clj +++ b/src/smallworld/coordinates.clj @@ -35,8 +35,7 @@ slurp extract-coordinates)) (catch Throwable e - (println "\nBing Maps API - returning nil, because API call failed: ") - (println e) + (println "\nBing Maps API - returning nil, because API call failed to retrieve a valid result") nil)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -79,6 +78,6 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def table-memoized (m/my-memoize get-from-city db/coordinates-table)) +(def table-memoized (m/my-memoize get-from-city db/coordinates-table)) (def atom-memoized (m/my-memoize table-memoized (atom {}))) (def memoized atom-memoized) ; re-naming just for export diff --git a/src/smallworld/db.clj b/src/smallworld/db.clj index ec37b243..6d856973 100644 --- a/src/smallworld/db.clj +++ b/src/smallworld/db.clj @@ -4,8 +4,76 @@ [clojure.string :as str] [clojure.walk :as walk] [jdbc.pool.c3p0 :as pool] + [schema.core :as s] [smallworld.clj-postgresql.types] ; this enables the :json type - [smallworld.util :as util])) + [smallworld.util :as util] + [yesql.core :refer [defqueries]])) + +;; Note: don't fully trust these, they are useful guidance, not type checked + +(def CoordinatesRow + {:id s/Int + :request_key s/Str + :data s/Any + :created_at java.util.Date + :updated_at java.util.Date}) + +(def Coordinate + {:lat s/Num , + :lng s/Num}) + +(def Location + {:name s/Str , + :coords Coordinate, + :distances (s/maybe s/Any) + :special-status (s/enum "twitter-location" "from-display-name") + :name-initial-value s/Str}) + +(def ProfileRow + {:id s/Int + :request_key s/Str + :data s/Any + :created_at java.util.Date + :updated_at java.util.Date}) + +(def Friend + {:name (s/maybe s/Str) + :location (s/maybe s/Str) + :screen-name s/Str + :id s/Int + :profile-image-url-https (s/maybe s/Str) + :email (s/maybe s/Str)}) + +(def AbridgedFriend + (-> Friend + (select-keys [:name :screen-name :email]) + (assoc :profile_image_url_large (s/maybe s/Str) + :locations [(s/maybe Location)]))) + +(def FriendsRow + {:id s/Int + :request_key s/Str + :data {:friends [(assoc Friend s/Any s/Any)]} + :created_at java.util.Date + :updated_at java.util.Date}) + +(def Settings + {:id s/Int + :screen_name s/Str + :name s/Str + :twitter_avatar s/Str + :welcome_flow_complete s/Bool + :locations [Location] + :friends_refresh (s/maybe s/Any) ;; TODO, known to be nilable + :email_address s/Str + :email_notifications s/Str + :created_at java.util.Date + :updated_at java.util.Date}) + +(def Impersonation + {:screen_name (s/maybe s/Str) + :created_at java.util.Date + :updated_at java.util.Date}) (def debug? false) (def db-uri (java.net.URI. (util/get-env-var "DATABASE_URL"))) @@ -144,6 +212,38 @@ (insert! table-name data) (update! table-name col-name col-value new-data)))) +;; Settings + +(s/defn get-settings :- (s/maybe Settings) + [screen-name :- s/Str] + (first (select-by-col settings-table :screen_name screen-name))) + +(s/defn upsert-settings! + [settings :- Settings] + (insert-or-update! settings-table :screen_name settings)) + +;; Impersonation + +(s/defn get-current-impersonation :- (:screen_name Impersonation) + [] + (:screen_name (select-first impersonation-table))) + +;; Friends + +(defqueries "sql/friends.sql") + +(s/defn get-friends :- (s/maybe [Friend]) + [screen-name :- s/Str] + (some-> (ysql-friends-by-screen-name {:screen_name screen-name} + {:connection @pool}) + walk/keywordize-keys)) + +(s/defn upsert-friends! + [screen-name :- s/Str friends :- [Friend]] + (insert-or-update! friends-table :request_key + {:request_key screen-name + :data {:friends friends}})) + (comment (do (println "--------------------------------") diff --git a/src/smallworld/email.clj b/src/smallworld/email.clj index 017f9a83..05fcb8a7 100644 --- a/src/smallworld/email.clj +++ b/src/smallworld/email.clj @@ -1,6 +1,5 @@ (ns smallworld.email (:require [clj-http.client :as http] - [smallworld.util :as util] - [smallworld.db :as db])) + [smallworld.util :as util])) (def debug? true) (def FROM_EMAIL "hello@smallworld.kiwi") @@ -39,7 +38,8 @@ :form-params {:template_id template-id :personalizations [{:to [{:email to-email}] :dynamic_template_data dynamic-template-data}] - :from {:email FROM_EMAIL}}})) + :from {:email FROM_EMAIL + :name FROM_NAME}}})) (defn send-email [options] (log-event "send-email" options) @@ -55,4 +55,4 @@ (send-email {:to "devonzuegel@gmail.com" :subject "test from CLI" :body "test from CLI" - :type "text/plain"})) \ No newline at end of file + :type "text/plain"})) diff --git a/src/smallworld/frontend.cljs b/src/smallworld/frontend.cljs index 965ef7a5..69daa286 100644 --- a/src/smallworld/frontend.cljs +++ b/src/smallworld/frontend.cljs @@ -1,11 +1,20 @@ + (ns smallworld.frontend - (:require [reagent.core :as r] - [smallworld.session :as session] - [smallworld.decorations :as decorations] - [smallworld.screens.settings :as settings] - [smallworld.util :as util] - [smallworld.screens.home :as home] - [clojure.pprint :as pp] + (:require [reagent.core :as r] + [reitit.frontend :as rf] + [reitit.frontend.easy :as rfe] + [reitit.frontend.controllers :as rfc] + [reitit.ring :as ring] + [reitit.coercion.schema :as rsc] + [schema.core :as s] + [clojure.test :refer [deftest is]] + [fipp.edn :as fedn] + [smallworld.session :as session] + [smallworld.decorations :as decorations] + [smallworld.screens.settings :as settings] + [smallworld.util :as util] + [smallworld.screens.home :as home] + [clojure.pprint :as pp] [cljsjs.mapbox] [goog.dom] [smallworld.admin :as admin])) @@ -15,12 +24,6 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(util/fetch "/api/v1/session" (fn [result] - (when @*debug? - (println "/api/v1/session:") - (pp/pprint result)) - (session/update! result))) - (util/fetch "/api/v1/settings" (fn [result] (when @*debug? (println "/api/v1/settings:") @@ -31,7 +34,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn logged-out-screen [] +(defn signin-page [] [:div.welcome [:div.hero [:p.serif {:style {:font-size "1.5em" :margin-top "8px" :margin-bottom "4px"}} @@ -64,24 +67,70 @@ [:p "they may not have their location set on Twitter (either in their name or in the location field), or small world may not be able to parse the location yet."] [:p "if they have their location set but it's just not showing up in the app, please " [:a {:href "https://github.com/devonzuegel/smallworld"} "open a GitHub issue"] " and share more so I can improve the city parser."]]]]) -(defn not-found-404-screen [] +(defn home-page [] + (if (:welcome_flow_complete @settings/*settings) + [home/screen] + [settings/welcome-flow-screen])) + +(defn not-found-404-page [] [:p {:style {:margin "30vh auto 0 auto" :text-align "center" :font-size "2em"}} "404 not found"]) -(defn app-container [] - (condp = (.-pathname (.-location js/window)) - "/" (condp = @session/*store - :loading (decorations/loading-screen) - session/blank (logged-out-screen) - (if (= :loading @settings/*settings) - (decorations/loading-screen) - (if (:welcome_flow_complete @settings/*settings) - (home/screen) - (settings/welcome-flow-screen)))) - "/admin" (admin/summary-screen) - (not-found-404-screen))) - -(r/render-component - (fn [] [util/error-boundary [app-container]]) - (goog.dom/getElement "app")) +(defonce match (r/atom nil)) + +(defn redirect! [path] + (.replace (.-location js/window) path)) + +(defn current-page [] ; TODO: handle logged out state + (if (= :loading @session/*store) + (decorations/loading-screen) + (if (nil? @match) + not-found-404-page + (let [view (:view (:data @match))] + [view @match])))) + +(defn if-session-loading [next-steps-fn] + #(if (= :loading @session/*store) + (util/fetch "/api/v1/session" next-steps-fn) + (next-steps-fn @session/*store))) + +(def require-blank-session + [{:start (if-session-loading #(if (empty? %) + (session/update! %) + (redirect! "/")))}]) + +(def require-session + [{:start (if-session-loading #(if (empty? %) + (redirect! "/signin") + (session/update! %)))}]) + +(def require-admin + [{:start (if-session-loading #(when (not= admin/screen-name (:screen-name %)) + (redirect! "/not-found")))}]) + +(def routes + (rf/router + ["/" + ["signin" {:name ::signin :view signin-page :controllers require-blank-session}] + ["" {:name ::home :view home-page :controllers require-session}] + ["settings" {:name ::settings :view settings/screen :controllers require-session}] + ["admin" {:name ::admin :view admin/screen :controllers require-admin}]] + {:data {:coercion rsc/coercion}})) + +(deftest test-routes ; note – this will not get run at the same time as the clj tests + (is (= (rf/match-by-path routes "/no-match") nil)) + (is (not= (rf/match-by-path routes "/settings") nil)) + (is (not= (rf/match-by-path routes "/settings/") nil))) + +(defn init! [] + (rfe/start! + routes + (fn [new-match] + (swap! match (fn [old-match] + (when new-match + (assoc new-match :controllers (rfc/apply-controllers (:controllers old-match) new-match))))) + (util/fetch "/api/v1/session" session/update!)) + {:use-fragment false}) + (r/render [current-page] (.getElementById js/document "app"))) +(init!) \ No newline at end of file diff --git a/src/smallworld/mapbox.cljs b/src/smallworld/mapbox.cljs index b1bf7c5d..f80850f3 100644 --- a/src/smallworld/mapbox.cljs +++ b/src/smallworld/mapbox.cljs @@ -4,6 +4,7 @@ [clojure.string :as str] [clojure.walk :as walk] [goog.dom] + [goog.object :as obj] [reagent.core :as r] [reagent.dom.server] [smallworld.decorations :as decorations] @@ -14,37 +15,24 @@ (def *loading (r/atom true)) (def the-map (r/atom nil)) ; can't name it `map` since that's taken by the standard library (def *friends-computed (r/atom [])) +(def *popups (r/atom nil)) (defn coords-to-mapbox-array [coords] #js[(:lng coords) (:lat coords)]) -(defn assert-long-lat [-coordinates] - (let [[long lat] -coordinates] - (assert (not (nil? -coordinates)) - (str "expected coordinates to be a list of [long lat], but received nil")) - (assert (and (number? lat) - (number? long)) - (str "[lat long] must be numbers, but received [" lat " " long "]")) - (assert (and (>= lat -90) - (<= lat 90)) - (str "lat must be between -90 & 90, but received [" lat "]")) - (assert (and (>= long -180) - (<= long 180)) - (str "long must be between -180 & 180, but received [" lat "]")))) - -(defn random-offset [] (- (rand 0.6) 0.3)) - (defn round [num] (js/parseFloat (pp/cl-format nil "~,0f" num))) (defn Popup-Content [{location :location info :info - lng-lat :lng-lat user-name :user-name screen-name :screen-name}] [:<> [:div.top-row [:b.user-name user-name] - [:a.screen-name {:href (str "http://twitter.com/" screen-name)} "@" screen-name]] + [:a.screen-name {:href (str "http://twitter.com/" screen-name) + :target "_blank" + :rel "noopener"} + (str "@" screen-name)]] ; TODO: display latest Tweet too (this requires some backend work) [:div.bottom-row {:title info} (decorations/location-icon) @@ -62,19 +50,19 @@ (def Miami [-80.1947021484375 25.775083541870117]) +(def min-zoom 0) + (defn component-did-mount [current-user] ; this should be called just once when the component is mounted - ; create the map - (reset! the-map + (reset! the-map ; create the map (new js/mapboxgl.Map #js{:container "mapbox" :key (get-in config [style :access-token]) :style (get-in config [style :style]) :center (clj->js (or (:lng-lat current-user) Miami)) :attributionControl false ; removes the Mapbox copyright symbol - :zoom 2 + :zoom 0 ; this will almost immediately be updated to the true `min-zoom`; we're only starting at zero as a hacky way to force the images to load :maxZoom 9 :minZoom 1})) - ; minimize the map when the user hits ESCAPE (.addEventListener js/document "keyup" #(when (= (.-key %) "Escape") (reset! expanded false)) @@ -89,24 +77,72 @@ (def *groups (r/atom {})) +(defn toggle-expand-map [] + (swap! expanded not) + (doall (for [i (range 105)] + (js/setTimeout #(.resize @the-map) (* i 10))))) + (defn mapbox [current-user] [:div#mapbox-container {:data-tap-disabled "true" - :class (if @expanded "expanded" "not-expanded")} + :class (str/join " " [(if @expanded "expanded" "not-expanded") + (if @*loading "loading" "done-loading")])} [:div.loading {:class (when-not @*loading "hidden")} (decorations/simple-loading-animation) "fetching your Twitter friends..."] - [:a.expand-me - {:on-click (fn [] - (swap! expanded not) - (doall - (for [i (range 105)] - (js/setTimeout #(.resize @the-map) (* i 10)))))} - (if @expanded (decorations/minimize-icon) (decorations/fullscreen-icon))] - [:a {:on-click #(.zoomTo @the-map (+ (.getZoom @the-map) 2))} (decorations/zoom-in-icon)] - [:a {:on-click #(.zoomTo @the-map (- (.getZoom @the-map) 2))} (decorations/zoom-out-icon)] + [:div.controls + [:a.expand-me {:on-click toggle-expand-map} + (if @expanded (decorations/minimize-icon) (decorations/fullscreen-icon))] + [:a {:on-click #(.zoomTo @the-map (+ (.getZoom @the-map) 2))} (decorations/zoom-in-icon)] + [:a {:on-click #(.zoomTo @the-map (- (.getZoom @the-map) 2))} (decorations/zoom-out-icon)]] [mapbox-dom current-user]]) +(defn load-img [img] + (new js/Promise + (fn [resolve _reject] + (if (.hasImage @the-map (:id img)) + (resolve) + (.loadImage @the-map (:url img) + (fn [error result] + (when error (throw error)) + (.addImage @the-map (:id img) result) + (resolve))))))) + +(defn get-coords-from-curr-user [curr-user] + (let [coords (get-in curr-user [:locations 0 :coords]) + lat (:lat coords) + lng (:lng coords)] + (if (and lng lat) + [lng lat] + nil))) + +(defn set-map-cursor [cursor-style] + (set! (.. (.getCanvas @the-map) -style -cursor) cursor-style)) + +(defn add-popup-to-map [remove-on-mouseout?] + (fn [e] + (set-map-cursor "pointer") + + ; these `obj/get` calls are hacks – should really be using externs to enable .-features + (let [feature (first (obj/get e "features")) + properties (-> feature + (obj/get "properties") + js->clj + walk/keywordize-keys) + coordinates (-> feature + (obj/get "geometry") + (obj/get "coordinates"))] + (let [popup (doto (js/mapboxgl.Popup. #js{:offset 30 + :closeButton false + :anchor "left"}) + (.setLngLat coordinates) + (.setHTML (reagent.dom.server/render-to-string + (Popup-Content {:location (:location properties) + :user-name (:name properties) + :screen-name (:screen-name properties)}))) + (.addTo @the-map))] + (when remove-on-mouseout? (swap! *popups conj popup)))))) + (defn add-friends-to-map [friends curr-user] (reset! *groups {}) (reset! *friends-computed []) @@ -167,28 +203,31 @@ :id (:screen-name friend)}) @*friends-computed)] - (.on @the-map "sourcedata" #(when (and (.getSource @the-map source-name) - (.isSourceLoaded @the-map source-name)) - (js/setTimeout (reset! *loading false) 1000))) + (.on @the-map "sourcedata" (fn [] + (when (and (.getSource @the-map source-name) + @*loading ; only run this the first time that sourcedata is loaded + (.isSourceLoaded @the-map source-name)) + (reset! *loading false) + (js/setTimeout (fn [] + (.flyTo @the-map #js{:center (clj->js (or (get-coords-from-curr-user curr-user) + Miami)) + :zoom (+ (.getZoom @the-map) 1.5) + :speed .6 + :essential true}) + (js/setTimeout #(.setMinZoom @the-map min-zoom) 1000)) + 400)))) (.then (.all js/Promise - (map (fn [img] - (new js/Promise - (fn [resolve _reject] - (if (.hasImage @the-map (:id img)) - (resolve) - (.loadImage @the-map (:url img) - (fn [error result] - (when error (throw error)) - (.addImage @the-map (:id img) result) - (resolve))))))) - images)) + (clj->js + (mapv load-img images))) (let [features #js[]] (doseq [friend @*friends-computed] (.push features (clj->js {:type "Feature" - :geometry {:type "Point" :coordinates (:lng-lat friend)} + :geometry {:type "Point" ; round coords to 5 decimal places to improve performance: https://docs.mapbox.com/help/troubleshooting/working-with-large-geojson-data/#cleaning-up-your-data + :coordinates [(.toFixed (first (:lng-lat friend)) 5) + (.toFixed (second (:lng-lat friend)) 5)]} :properties {:icon (:screen-name friend) :name (:user-name friend) :location (:location friend) @@ -211,6 +250,7 @@ :source source-name :type "symbol" :layout #js{:icon-allow-overlap true + :icon-ignore-placement true :icon-size #js{:base .5 :stops #js[#js[(+ cluster-max-zoom 0) .13] #js[(+ cluster-max-zoom 1) .09] @@ -255,33 +295,28 @@ :text-font #js["DIN Offc Pro Medium" "Arial Unicode MS Bold"], :text-size 14}})) - ;; (.on @the-map "zoom" #(println (.getZoom @the-map))) ; for debugging + #_(.on @the-map "zoom" #(util/debounce (println (.getZoom @the-map)) 500)) ; for debugging (.on @the-map "click" "cluster-layer" (fn [event] - (.flyTo @the-map #js {:zoom (+ (.getZoom @the-map) 3) - :center (.-lngLat event)}))) - - (.on @the-map "click" "img-layer" - (fn [e] - (let [feature (first (.-features e)) - properties (-> feature - .-properties - js->clj - walk/keywordize-keys) + (let [feature (first (obj/get event "features")) coordinates (-> feature - .-geometry - .-coordinates)] - (doto (js/mapboxgl.Popup. #js{:offset 30 - :closeButton false - :anchor "left"}) - (.setLngLat coordinates) - (.setHTML (reagent.dom.server/render-to-string - (Popup-Content {:location (:location properties) - :user-name (:name properties) - :screen-name (:screen-name properties) - :lng-lat coordinates}))) - (.addTo @the-map))))) + (obj/get "geometry") + (obj/get "coordinates"))] + (.flyTo @the-map #js {:zoom (+ (.getZoom @the-map) 10) + :center coordinates + :speed 1.5})))) + + (.on @the-map "mouseover" "cluster-layer" #(set-map-cursor "pointer")) + (.on @the-map "mouseout" "cluster-layer" #(set-map-cursor "")) + + (.on @the-map "click" "img-layer" (add-popup-to-map false)) + (.on @the-map "mouseover" "img-layer" (add-popup-to-map true)) + (.on @the-map "mouseout" "img-layer" #(js/setTimeout (fn [] + (set-map-cursor "") + (doseq [popup @*popups] + (.remove popup))) + 0)) ; make sure the map is properly sized + the markers are placed (js/setTimeout #(.resize @the-map) 500))))) \ No newline at end of file diff --git a/src/smallworld/screens/home.cljs b/src/smallworld/screens/home.cljs index 5a427507..c5b8b184 100644 --- a/src/smallworld/screens/home.cljs +++ b/src/smallworld/screens/home.cljs @@ -1,45 +1,16 @@ (ns smallworld.screens.home (:require [clojure.string :as str] - [goog.dom :as dom] [reagent.core :as r] + [reitit.frontend.easy :as rfe] [smallworld.decorations :as decorations] [smallworld.mapbox :as mapbox] [smallworld.session :as session] - [smallworld.screens.settings :as settings] [smallworld.user-data :as user-data] - [smallworld.util :as util])) - -(def *debug? (r/atom false)) -(def *settings-open? (r/atom false)) -(defonce *minimaps (r/atom {})) - -; TODO: only do this on first load of logged-in-screen, not on every re-render -; and not for all the other pages – use component-did-mount -(defn refresh-atom [] - (when (and - (not= :loading @session/*store) - (not-empty @session/*store)) - (util/fetch "/api/v1/friends/refresh-atom" - (fn [result] - (when @*debug? (println "/api/v1/friends/refresh-atom: " (count result))) - (reset! user-data/*friends result) - ; TODO: only run this on the main page, otherwise you'll get errors - ; wait for the map to load – this is a hack & may be a source of errors ;) - (js/setTimeout (mapbox/add-friends-to-map @user-data/*friends @settings/*settings) 2000)) - :retry? true))) - -(doall (for [i (range 10)] - (js/setTimeout refresh-atom (* (util/exponent 2 i) 1000)))) - -(defn nav [] - [:div.nav {:class (when (:impersonation? @session/*store) "admin-impersonation")} - [:a#logo-animation.logo {:on-click #(reset! *settings-open? false)} - (decorations/animated-globe) - - [:div.logo-text "small world"]] - [:span.fill-nav-space] - [:a {:on-click #(reset! *settings-open? true) :style {:cursor "pointer"}} - [:b.screen-name " @" (:screen-name @session/*store)]]]) + [smallworld.util :as util] + [smallworld.screens.settings :as settings])) + +(def *debug? (r/atom false)) +(defonce *minimaps (r/atom {})) (defn minimap [minimap-id location-name coords] (r/create-class {:component-did-mount @@ -71,7 +42,7 @@ [:<> [:br] [:br] [:hr] [:div.refresh-friends {:style {:margin-top "64px" :text-align "center"}} [:a.btn {:href "#" - :on-click #(util/fetch "/api/v1/friends/refresh-atom" + :on-click #(util/fetch "/api/v1/friends" (fn [result] (reset! user-data/*friends result) (println settings/*settings) @@ -83,7 +54,7 @@ [:div.refresh-friends {:style {:margin-top "64px" :text-align "center"}} [:div {:style {:margin-bottom "12px" :font-size "0.9em"}} "does the data for your friends look out of date?"] - [:a.btn {:href "#" :on-click user-data/refresh-friends} + [:a.btn {:href "#" :on-click user-data/refetch-friends} "refresh friends"] [:div {:style {:margin-top "12px" :font-size "0.8em" :opacity "0.6" :font-family "Inria Serif, serif" :font-style "italic"}} "note: this takes several seconds to run"]] @@ -122,11 +93,11 @@ (or (= (:special-status location-data) "twitter-location") (= (:special-status location-data) "from-display-name"))) -(defn screen [] +(defn -screen [] [:<> - (nav) + (util/nav) (let [curr-user-locations (remove nil? (:locations @settings/*settings)) - update [:a {:href "https://twitter.com/settings/location" :target "_blank"} "update"] + update [:a {:href "https://twitter.com/settings/profile" :target "_blank"} "update"] track-new-location-btn [:div#track-new-location-field {:on-click (fn [] (let [updated-locations (vec (concat curr-user-locations ; using concat instead of conj so it adds to the end @@ -142,91 +113,91 @@ (.scrollIntoView #js{:behavior "smooth" :block "center" :inline "center"}))) 50)))} (decorations/plus-icon "scale(0.15)") "follow a new location"]] - [:<> - (if @*settings-open? - (settings/settings-screen) - - [:div.home-page - [:p.info "here are your friends' Twitter locations:"] - - (let [top-location (first (remove nil? (:locations @settings/*settings)))] - [util/error-boundary - [mapbox/mapbox - {:lng-lat (:coords top-location) - :location (:name top-location) - :user-img (:profile_image_url_large @session/*store) - :user-name (:name @session/*store) - :screen-name (:screen-name @session/*store)}]]) - - [:p.info "follow locations to see a list of who's nearby:"] - - - (if (= 0 (count curr-user-locations)) - [:div.no-locations-info - [:p "3 ways to start following your first location:"] - [:ul - [:li update " your Twitter profile location"] - [:li update " your Twitter display name (e.g. \"Devon in Miami Beach\")"] - [:li "add a location manually:"]] - track-new-location-btn] - track-new-location-btn) - - (doall (map-indexed - (fn [i location-data] - (let [minimap-id (str "minimap-location-" i)] - [:div.category {:key i} - [:div.friends-list.header - - [:div.left-side.mapbox-container - [minimap minimap-id (:name location-data) (:coords location-data)] - (when-not (str/blank? (:name location-data)) [:div.center-point])] - - [:div.right-side - [:div.based-on (condp = (:special-status location-data) - "twitter-location" "based on your Twitter location, you live in:" - "from-display-name" "based on your Twitter name, you're visiting:" - "you added this location manually:")] - [:input {:type "text" - :value (:name location-data) - :autoComplete "off" - :auto-complete "off" - :style {:cursor (when-not (from-twitter? location-data) "pointer")} - :readOnly (from-twitter? location-data) ; don't allow them to edit the Twitter locations - :placeholder "type a location to follow" - :on-change #(let [input-elem (.-target %) - new-value (.-value input-elem)] - (swap! settings/*settings assoc-in [:locations i :loading] true) - (fetch-coordinates-debounced! minimap-id new-value i) - (swap! settings/*settings assoc-in [:locations i :name] new-value) - (util/fetch-post "/api/v1/settings/update" {:locations (:locations @settings/*settings)}))}] - #_(when (from-twitter? location-data) ; no longer needed because they aren't editable - [:div.small-info-text "this won't update your Twitter profile :)"])] - - [:div.delete-location-btn {:title "delete this location" - :on-click #(when (js/confirm "are you sure that you want to delete this location? don't worry, you can add it back later any time") - (let [updated-locations (util/rm-from-list curr-user-locations i)] - (println "settings/*settings :locations (BEFORE)") - (println (:locations @settings/*settings)) - (swap! settings/*settings assoc :locations updated-locations) - (println "settings/*settings :locations (AFTER)") - (println (:locations @settings/*settings)) - (util/fetch-post "/api/v1/settings/update" {:locations updated-locations})))} - (decorations/cancel-icon)]] - - (if (or (get-in @settings/*settings [:locations i :loading]) (= [] @user-data/*friends)) - [:div.friends-list [:div.loading (decorations/simple-loading-animation) "fetching your Twitter friends..."]] - (when-not (nil? (:coords location-data)) - [:<> - (user-data/render-friends-list i "from-display-name" "visiting" (:name location-data)) - (user-data/render-friends-list i "twitter-location" "based near" (:name location-data))]))])) - curr-user-locations)) - - (when (not= 0 (count curr-user-locations)) - track-new-location-btn) - - [:br] - (debugger-btn) - - (util/info-footer (:screen-name @session/*store) - user-data/recompute-friends) ; TODO: replace with doseq, which is for side effects - (debugger-info)])])]) + [:div.home-page + (let [top-location (first (remove nil? (:locations @settings/*settings)))] + [util/error-boundary + [mapbox/mapbox + {:lng-lat (:coords top-location) + :location (:name top-location) + :user-img (:profile_image_url_large @session/*store) + :user-name (:name @session/*store) + :screen-name (:screen-name @session/*store)}]]) + + (when (not= 0 (count curr-user-locations)) + track-new-location-btn) + + (doall (map-indexed + (fn [i location-data] + (let [minimap-id (str "minimap-location-" i)] + [:div.category {:key i} + [:div.friends-list.header + + [:div.left-side.mapbox-container + [minimap minimap-id (:name location-data) (:coords location-data)] + (when-not (str/blank? (:name location-data)) [:div.center-point])] + + [:div.right-side + [:div.based-on (condp = (:special-status location-data) + "twitter-location" "based on your Twitter location, you live in:" + "from-display-name" "based on your Twitter name, you're visiting:" + nil)] + [:input {:type "text" + :value (:name location-data) + :autoComplete "off" + :auto-complete "off" + :style {:cursor (when-not (from-twitter? location-data) "pointer")} + :readOnly (from-twitter? location-data) ; don't allow them to edit the Twitter locations + :placeholder "type a location to follow" + :on-change #(let [input-elem (.-target %) + new-value (.-value input-elem)] + (swap! settings/*settings assoc-in [:locations i :loading] true) + (fetch-coordinates-debounced! minimap-id new-value i) + (swap! settings/*settings assoc-in [:locations i :name] new-value) + (util/fetch-post "/api/v1/settings/update" {:locations (:locations @settings/*settings)}))}] + #_(when (from-twitter? location-data) ; no longer needed because they aren't editable + [:div.small-info-text "this won't update your Twitter profile :)"])] + + [:div.delete-location-btn {:title "delete this location" + :on-click #(when (js/confirm "are you sure that you want to delete this location? don't worry, you can add it back later any time") + (let [updated-locations (util/rm-from-list curr-user-locations i)] + (println "settings/*settings :locations (BEFORE)") + (println (:locations @settings/*settings)) + (swap! settings/*settings assoc :locations updated-locations) + (println "settings/*settings :locations (AFTER)") + (println (:locations @settings/*settings)) + (util/fetch-post "/api/v1/settings/update" {:locations updated-locations})))} + (decorations/cancel-icon)]] + + (if (or (get-in @settings/*settings [:locations i :loading]) + (= [] @user-data/*friends) + (= :loading @user-data/*friends)) + [:div.friends-list [:div.loading (decorations/simple-loading-animation) "fetching your Twitter friends..."]] + (when-not (nil? (:coords location-data)) + [:<> ; TODO: refactor this so that data is passed in as a param, rather than depending on side effects + (user-data/render-friends-list i "twitter-location" "based near" (:name location-data)) + (user-data/render-friends-list i "from-display-name" "visiting" (:name location-data))]))])) + curr-user-locations)) + + [:div.no-locations-info + [:p "3 ways to start following a location:"] + [:ul + [:li update " your Twitter profile location"] + [:li update " your Twitter display name (e.g. \"Devon in NYC\")"] ; TODO: make this an `i` info hover + [:li "add a location manually:"]] + track-new-location-btn] + + [:br] [:br] + (debugger-btn) + + (util/info-footer (:screen-name @session/*store) + user-data/recompute-friends) ; TODO: replace with doseq, which is for side effects + (debugger-info)])]) + +(defn screen [] + (r/create-class + {:component-did-mount + (fn [] + (settings/refresh-friends) ; refresh immediately + (doall (for [i (range 2 5)] ; then refresh it again, with exponential backoff + (js/setTimeout settings/refresh-friends (* (util/exponent 2 i) 1000))))) + :reagent-render (fn [] [-screen])})) diff --git a/src/smallworld/screens/settings.cljs b/src/smallworld/screens/settings.cljs index 8bffb2ea..bcd83dfc 100644 --- a/src/smallworld/screens/settings.cljs +++ b/src/smallworld/screens/settings.cljs @@ -16,6 +16,21 @@ (defonce *form-errors (r/atom {})) (def *form-message (r/atom nil)) ; start with no message +; TODO: only do this on first load of logged-in-screen, not on every re-render +; and not for all the other pages – use component-did-mount +(defn refresh-friends [] + (when (and + (not= :loading @session/*store) + (not-empty @session/*store)) + (util/fetch "/api/v1/friends" + (fn [result] + (when debug? (println "/api/v1/friends: " (count result))) + (reset! user-data/*friends result) + ; TODO: only run this on the main page, otherwise you'll get errors + ; wait for the map to load – this is a hack & may be a source of errors ;) + (js/setTimeout (mapbox/add-friends-to-map @user-data/*friends @*settings) 2000)) + :retry? true))) + (defn fetch-coordinates! [minimap-id location-name-input index] (if (str/blank? location-name-input) (.flyTo (get @*minimaps minimap-id) #js{:essential true ; this animation is essential with respect to prefers-reduced-motion @@ -34,8 +49,6 @@ (def email_notifications_options [#_"instant" "daily" #_"weekly" "muted"]) (defn minimap [minimap-id location-name coords] - (println "coords: ") - (println coords) (r/create-class {:component-did-mount (fn [] ; this should be called just once when the component is mounted (swap! *minimaps assoc minimap-id @@ -76,7 +89,7 @@ [:<> [:div [:input.location-input {:type "text" - :tab-index (str index) + :tab-index "0" :auto-focus auto-focus :id (str id "-input") :key (str id "-input") @@ -111,7 +124,7 @@ (defn invalid-email? [email] (let [regex-pattern #"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])" - regex-result (re-matches regex-pattern email)] + regex-result (re-matches regex-pattern (str/lower-case email))] (nil? regex-result))) (defn valid-inputs!? [{email_address :email_address @@ -136,15 +149,13 @@ (= 0 (count (keys @*form-errors)))) - (defn submit-welcome-form [] (let [new-settings {:email_address (input-value-by-name "email-address-input") :email_notifications (.-id (input-by-name "email_notification" ":checked")) :locations @*locations-new :welcome_flow_complete true}] (when (valid-inputs!? new-settings) ; TODO: check that all of the locations are valid too (e.g. can't be blank) - ; TODO: send to backend to save it in db too – first, need a way to reflect this in backend, since right now we only have local storage as source of truth for the current user's session - (reset! *settings new-settings) + (swap! *settings merge new-settings) (util/fetch-post "/api/v1/settings/update" new-settings user-data/recompute-friends)))) @@ -171,30 +182,29 @@ [:<> "based on your display name, you’re in" [:br]]) - "added-manually" "add a location to follow")) + "added-manually" nil)) (defn location-placeholder [location] (condp = (:special-status location) "twitter-location" "what city do you live in?" "from-display-name" "any plans to travel?" - "share a location")) + "add a location")) (defn debug-info [] - (when debug? + (when (or debug? (= (.. js/window -location -hash) "#debug")) [:br] [:br] [:button.btn {:on-click #(reset! *form-errors {})} "clear errors"] [:div {:style {:text-align "left"}} [:br] - [:pre "@session/*store: \n" (util/preify @session/*store)] - [:pre "@*locations-new: \n" (util/preify @*locations-new)] - [:pre "@*form-errors: \n" (util/preify @*form-errors)]])) + [:pre "\n\n@*settings \n" (util/preify @*settings)] + [:pre "\n\n@session/*store: \n" (util/preify @session/*store)] + [:pre "\n\n@*locations-new: \n" (util/preify @*locations-new)] + [:pre "\n\n@*form-errors: \n" (util/preify @*form-errors)]])) (defn submit-settings-form [] - (let [new-settings {:email_address (input-value-by-name "email-address-input") :email_notifications (.-id (input-by-name "email_notification" ":checked"))}] (when (valid-inputs!? new-settings) ; TODO: check that all of the locations are valid too (e.g. can't be blank) - (reset! *settings (merge @*settings new-settings)) (reset! *form-message "saving...") (util/fetch-post "/api/v1/settings/update" (js->clj new-settings) @@ -202,7 +212,8 @@ (reset! *form-message "✓ settings saved!") (js/setTimeout #(reset! *form-message nil) 5000) ; clear the message after a few seconds (user-data/recompute-friends response)))))) -(defn welcome-flow-screen [] + +(defn -welcome-flow-screen [] (when (nil? @*email-address) (reset! *email-address (:email @*settings))) [:div.welcome-flow @@ -223,7 +234,8 @@ [:img {:src (:twitter_avatar @*settings)}] [:div.right-side [:div.name (:name @*settings)] - [:div.location (:main_location_corrected @*settings)]]]] + [:div.location (:name (first (filter #(= (:special-status %) "twitter-location") + (:locations @*settings))))]]]] (let [locations (remove nil? (:locations @*settings))] (when (= :loading @*locations-new) ; TODO: clean this up, it's kinda hacky @@ -233,8 +245,6 @@ [:div.location-fields ; TODO: add a way to delete locations from the list [:br] - (when debug? - [:pre {:style {:text-align "left"}} (util/preify @*locations-new)]) (map-indexed (fn [i location] (location-field {:index i @@ -265,72 +275,63 @@ [:div.small-info-text {:style {:margin-top "6px"}} "you can always add more locations later"]]) [:br] - [:div.email-options {:tab-index "3"} - [:p "would you like email notifications" [:br] "when your friends are nearby? *"] + [:div.field.email-address {:class (when (:email-address-input @*form-errors) "error")} + [:label "what's your email address? *"] [:br] + [:div.field + [:input {:type "email" + :tab-index "0" + :id "email-address-input" + :name "email-address-input" + :value @*email-address ; TODO: this is a hack - do it the same way as (location-input) instead, i.e. remove the atom + :autoComplete "off" + :auto-complete "off" + :on-change #(let [input-elem (.-target %) + new-value (.-value input-elem)] + (reset! *email-address new-value)) + :placeholder "email address"}] + (decorations/edit-icon)] + [:div.error-msg (:email-address-input @*form-errors)]] + + [:div.email-options {:tab-index "0"} + [:p {:style {:font-size ".95em"}} "should we email you when friends are nearby?"] [:div.radio-btns #_[:div.radio-btn [:input {:name "email_notification" :type "radio" :value "instant" :id "instant"}] [:label {:for "instant"} "yes, notify me immediately"]] [:div.radio-btn [:input {:name "email_notification" :type "radio" :value "daily" :id "daily" :default-checked true}] - [:label {:for "daily"} "yes, send me daily digests"]] + [:label {:for "daily"} "yes, let me know!"]] #_[:div.radio-btn [:input {:name "email_notification" :type "radio" :value "weekly" :id "weekly"}] [:label {:for "weekly"} "yes, send me weekly digests"]] [:div.radio-btn [:input {:name "email_notification" :type "radio" :value "muted" :id "muted"}] - [:label {:for "muted"} "no, don't notify me by email"]]] + [:label {:for "muted"} "no, please don't"]]] [:br]] [:br] - [:div.email-options {:class (when (:email-address-input @*form-errors) "error")} - [:div.email-address - [:label "what's your email address? *"] [:br] - [:div.field - [:input {:type "text" - :tab-index "4" - :id "email-address-input" - :name "email-address-input" - :value @*email-address ; TODO: this is a hack - do it the same way as (location-input) instead, i.e. remove the atom - :autoComplete "off" - :auto-complete "off" - :on-change #(let [input-elem (.-target %) - new-value (.-value input-elem)] - (reset! *email-address new-value)) - :placeholder "email address"}] - (decorations/edit-icon)] - [:div.error-msg (:email-address-input @*form-errors)]]] - [:br] [:button.btn {:on-click submit-welcome-form} "let's go!"] [debug-info] [:br] [:br] [:br] [:br] [:br] - util/info-footer]) + [util/info-footer (:screen-name @*settings) user-data/recompute-friends]]) -(defn settings-screen [] - [:div.welcome-flow - [:p.serif {:style {:font-size "1.3em" :padding-bottom "24px"}} "settings"] - [:div.email-options {:tab-index "3"} - [:p "would you like email notifications" [:br] "when your friends are nearby? *"] - [:div.radio-btns - #_[:div.radio-btn - [:input {:name "email_notification" :type "radio" :value "instant" :id "instant" :default-checked (= "instant" (:email_notifications @*settings))}] - [:label {:for "instant"} "yes, notify me immediately"]] - [:div.radio-btn - [:input {:name "email_notification" :type "radio" :value "daily" :id "daily" :default-checked (= "daily" (:email_notifications @*settings))}] - [:label {:for "daily"} "yes, send me daily digests"]] - #_[:div.radio-btn - [:input {:name "email_notification" :type "radio" :value "weekly" :id "weekly" :default-checked (= "weekly" (:email_notifications @*settings))}] - [:label {:for "weekly"} "yes, send me weekly digests"]] - [:div.radio-btn - [:input {:name "email_notification" :type "radio" :value "muted" :id "muted" :default-checked (= "muted" (:email_notifications @*settings))}] - [:label {:for "muted"} "no, don't notify me by email"]]] - [:br]] - [:br] - [:div.email-options {:class (when (:email-address-input @*form-errors) "error")} - [:div.email-address + +(defn welcome-flow-screen [] + (r/create-class + {:component-did-mount user-data/recompute-friends + :reagent-render (fn [] (if (str/blank? (:twitter_avatar @*settings)) + (decorations/loading-screen) + [-welcome-flow-screen]))})) + +(defn -screen [] + [:div + [util/nav] + [:div.welcome-flow + [:p.serif {:style {:font-size "1.3em" :padding-bottom "24px"}} "settings"] + [:div.email-address {:class (when (:email-address-input @*form-errors) "error")} [:label "what's your email address? *"] [:br] [:div.field - [:input {:type "text" - :tab-index "4" + [:input {:type "email" + :tab-index "0" :id "email-address-input" :name "email-address-input" :value @*email-address ; TODO: this is a hack - do it the same way as (location-input) instead, i.e. remove the atom @@ -341,8 +342,32 @@ (reset! *email-address new-value)) :placeholder "email address"}] (decorations/edit-icon)] - [:div.error-msg (:email-address-input @*form-errors)]]] - [:br] - [:button.btn {:on-click submit-settings-form} "save settings"] - [:p.small-info-text @*form-message] - [debug-info]]) + [:div.error-msg (:email-address-input @*form-errors)]] + + [:div.email-options {:tab-index "0"} + [:p {:style {:font-size ".95em"}} "should we email you when friends are nearby?"] + [:div.radio-btns + #_[:div.radio-btn + [:input {:name "email_notification" :type "radio" :value "instant" :id "instant" :default-checked (= "instant" (:email_notifications @*settings))}] + [:label {:for "instant"} "yes, notify me immediately"]] + [:div.radio-btn + [:input {:name "email_notification" :type "radio" :value "daily" :id "daily" :default-checked (= "daily" (:email_notifications @*settings))}] + [:label {:for "daily"} "yes, let me know!"]] + #_[:div.radio-btn + [:input {:name "email_notification" :type "radio" :value "weekly" :id "weekly" :default-checked (= "weekly" (:email_notifications @*settings))}] + [:label {:for "weekly"} "yes, send me weekly digests"]] + [:div.radio-btn + [:input {:name "email_notification" :type "radio" :value "muted" :id "muted" :default-checked (= "muted" (:email_notifications @*settings))}] + [:label {:for "muted"} "no, please don't"]]] + [:br]] + + [:br] + [:button.btn {:on-click submit-settings-form} "save settings"] + [:p.small-info-text (or @*form-message " ")] + [:br] + [:a {:href "/logout"} "log out"] + [debug-info]]]) + + +(defn screen [] + (r/create-class {:reagent-render (fn [] [-screen])})) \ No newline at end of file diff --git a/src/smallworld/tmp b/src/smallworld/tmp deleted file mode 100644 index dde182db..00000000 --- a/src/smallworld/tmp +++ /dev/null @@ -1,47 +0,0 @@ -
- - {{!-- - - - - --}} - {{#each friends}} - - - - {{#each this}} - - {{#equals 0 @index}}{{/equals}} - {{/each}} - - {{/each}} -
beforeupdated
- - {{#each this}} - {{#equals 0 @index}} - @{{this.screen_name}} - {{/equals}} - {{/each}} - - -
    -
  • - {{#equals 0 @index}}{{/equals}} - {{this.name}} - {{#equals 0 @index}}{{/equals}} -
  • -
  • - {{#equals 0 @index}}{{/equals}} - {{this.location}} - {{#equals 0 @index}}{{/equals}} -
  • -
-
- - -
diff --git a/src/smallworld/user_data.clj b/src/smallworld/user_data.clj index dd7349f1..03fb104e 100644 --- a/src/smallworld/user_data.clj +++ b/src/smallworld/user_data.clj @@ -1,83 +1,136 @@ (ns smallworld.user-data - (:require [smallworld.coordinates :as coordinates] - [clojure.string :as str])) + (:require [clojure.string :as str] + [clojure.test :refer [deftest is]] + [schema.core :as s] + [smallworld.db :as db] + [smallworld.coordinates :as coordinates]) + (:import (java.util.regex Pattern))) (def debug? false) -(defn normal-img-to-full-size [friend] - (let [original-url (:profile-image-url-https friend)] - (if (nil? original-url) - nil - (str/replace original-url "_normal" "")))) +(defn ^StringBuilder fast-replace [^StringBuilder sb ^Pattern pattern ^String replacement] + (let [matcher (.matcher pattern sb)] + (loop [start 0] + (when (.find matcher start) + (.replace sb (.start matcher) (.end matcher) replacement) + (recur (+ (.start matcher) (.length replacement)))))) + sb) + +(s/defn normal-img-to-full-size :- s/Str + [original-url :- s/Str] + (str (fast-replace (StringBuilder. original-url) #"_normal" ""))) (defn includes? [string substr] - (str/includes? (str/lower-case string) (str/lower-case substr))) + (str/includes? (str/lower-case string) substr)) (defn split-last [string splitter] - (last (str/split string splitter))) + (or (last (str/split string splitter)) "")) + +(defn remove-substr [^StringBuilder sb substr] + (fast-replace sb substr "")) -(defn remove-substr [string substr] - (str/replace string substr "")) +(defn normalize-location [^String name] ; case insensitive – used for coordinate lookup only, not for display + (let [_s (-> (str/lower-case (or name "")) + (StringBuilder.) + (remove-substr #"(?i)they/them") + (remove-substr #"(?i)she/her") + (remove-substr #"(?i)he/him") + (remove-substr #"(?i) soon") + (remove-substr #"(?i) mostly") + (remove-substr #"(?i) still") + (remove-substr #"(?i)Planet Earth") + (remove-substr #"(?i)Earth") + (str))] + (cond + (= _s "sf") "san francisco, california" + (= _s "home") "" + (includes? _s "at home") "" + (includes? _s "subscribe") "" + (includes? _s ".com") "" + (includes? _s ".net") "" + (includes? _s ".org") "" + (includes? _s ".eth") "" + (includes? _s "solana") "" + (includes? _s "blue/green sphere") "" + (includes? _s "pale blue dot") "" + (includes? _s "zoom") "" + (includes? _s "san francisco") "san francisco, california" + (includes? _s "sf, ") "san francisco, california" + (includes? _s "nyc") "new york city" + (includes? _s "new york") "new york city" + :else (let [_s (StringBuilder. _s) + _s (remove-substr _s #" \([^)]*\)") ; remove anything in parentheses (e.g. "sf (still!)" → "sf") + _s (str/replace _s #"(?i) area$" "") + _s (-> (str _s) + (split-last #"\/") + (split-last #" and ") + (split-last #"\|") + (split-last #"→") + (split-last #"·") + (split-last #"•") + (split-last #"✈️") + (split-last #"🔜")) + ^String _s (split-last _s #"➡️") + _s (StringBuilder. _s) + _s (remove-substr _s #",$") ; remove trailing comma + _s (fast-replace _s #"[^a-zA-Z]" " ") ; remove any remaining non-letter/non-comma strings with spaces (e.g. emoji) + _s (str _s)] + (cond + (> (count (str/split _s #" ")) 3) "" ; if there are more than 3 words, it's probably a sentence and not a place name + (= _s "new york") "new york city" + (= _s "california") "san francisco" + (= _s "british columbia") "vancouver, canada" ; approx. center of population in British Columbia + (= _s "canada") "whiteshell provincial park" ; approx. center of population in Canada + (= _s "québec") "québec, québec, canada" ; they probably meant Quebec city, not Québec province + :else (str/trim _s)))))) -(defn remove-emoji [string] - (remove-substr string #"[\uD83C-\uDBFF\uDC00-\uDFFF]+")) +(deftest test-normalize-location + (is (= (normalize-location "sf, foobar") "san francisco, california")) + (is (= (normalize-location "New York") "new york city")) + (is (= (normalize-location "Zoom") "")) + (is (= (normalize-location "Seattle area") "seattle")) + (is (= (normalize-location "CHQ → London") "london")) + (is (= (normalize-location "Planet Earth") ""))) -(defn normalize-location [name] ; case insensitive – used for coordinate lookup only, not for display - (let [_s (or name "") - _s (remove-substr _s #"(?i)they/them") - _s (remove-substr _s #"(?i)she/her") - _s (remove-substr _s #"(?i)he/him") - _s (remove-substr _s #"(?i) soon") - _s (remove-substr _s #"(?i) mostly") - _s (remove-substr _s #"(?i) still") - _s (remove-substr _s #"(?i)Planet Earth") - _s (remove-substr _s #"(?i)Earth") - _s (if (= _s "Canada") "whiteshell provincial park, canada" _s) ; approx. center of population in Canada - _s (if (= _s "Québec") "Québec, Québec, Canada" _s) ; they probably meant Quebec city, not Québec province - _s (if (includes? _s "subscribe") "" _s) - _s (if (includes? _s ".com") "" _s) - _s (if (includes? _s ".eth") "" _s) - _s (if (includes? _s "zoom") "" _s) - _s (if (includes? _s "san francisco") "San Francisco, CA" _s) - _s (if (includes? _s "sf, ") "San Francisco, CA" _s) - _s (if (includes? _s "nyc") "New York City" _s) - _s (if (includes? _s "new york, ny") "New York City" _s) - _s (remove-substr _s #" \([^)]*\)") ; remove anything in parentheses (e.g. "sf (still!)" → "sf") - _s (split-last _s #"→") - _s (split-last _s #"·") - _s (split-last _s #"•") - _s (split-last _s #"✈️") - _s (split-last _s #"🔜") - _s (split-last _s #"➡️") - _s (remove-emoji _s) ; this has to come after the splitting on specific emoji - _s (split-last _s #"\/") - _s (split-last _s #" and ") - _s (split-last _s #"\|") - _s (if (> (count (str/split _s #" ")) 3) "" _s) ; if there are more than 3 words, it's probably a sentence and not a place name - _s (str/trim _s) - _s (remove-substr _s #",$") ; remove trailing comma - ] - _s)) +(defn title-case [s] + (->> (str/split (str s) #"\b") + (map str/capitalize) + str/join)) (defn location-from-name [name] (let [_s (str/replace name #" in " "|") - _s (str/replace name #" | " "|") - _s (str/replace name #" visiting " "|") + _s (str/replace _s #" \| " "|") + _s (str/replace _s #"(?i) soon!?$" "") + _s (str/replace _s #" visiting " "|") + _s (str/replace _s #" at " "|") _s (str/split _s #"\|")] - (if (> 1 (count _s)) ; if there's only 1 element, assume they didn't put a location in their name - (normalize-location (last _s)) + (if (< 1 (count _s)) ; if there's only 1 element, assume they didn't put a location in their name + (title-case (normalize-location (last _s))) ""))) +(deftest test-location-from-name + (is (= (location-from-name "Devon ☀️ in Buenos Aires") "Buenos Aires")) + (is (= (location-from-name "Devon visiting SF") "San Francisco, California")) + (is (= (location-from-name "Devon visiting London soon") "London")) + (is (= (location-from-name "Devon visiting London soon!") "London")) + (is (= (location-from-name "Miami Beach Police") "")) + (is (= (location-from-name "Fairchild Garden") "")) + (is (= (location-from-name "Devon") ""))) + (defn distances-map [is-current-user? current-user friend-coords] (when (not is-current-user?) ; distances aren't relevant if the friend whose data we're abridging is the current user (zipmap - (map #(:name %) (:locations current-user)) + (map :name (:locations current-user)) (map #(coordinates/distance-btwn (:coords %) friend-coords) (:locations current-user))))) -;; "main" refers to the location set in the Twitter :location field -;; "name-location" refers to the location described in their Twitter :name (which may be nil) -(defn abridged [friend current-user] +;; "twitter-location" refers to the location set in the Twitter :location field +;; "from-display-name" refers to the location described in their Twitter :name (which may be nil) +(s/defn abridged :- db/AbridgedFriend + [friend :- db/Friend + current-user] + (assert (:name friend)) + (assert (:location friend)) (let [is-current-user? (= (:screen-name current-user) (:screen-name friend)) ; locations as strings friend-main-location (normalize-location (:location friend)) @@ -105,19 +158,20 @@ {:name (:name friend) :screen-name (:screen-name friend) - :profile_image_url_large (normal-img-to-full-size friend) + :profile_image_url_large (some-> friend + :profile-image-url-https + normal-img-to-full-size) ; note – email will only be available if the user has given us permission ; (i.e. if they are also the current-user) AND if they have set their email ; on Twitter, which is not required, so sometimes it'll be the empty string :email (:email friend) - :locations [(when (not (str/blank? friend-main-location)) + :locations [(when-not (str/blank? friend-main-location) {:special-status "twitter-location" ; formerly called "main location" :name (:location friend) :coords friend-main-coords :distances (distances-map is-current-user? current-user friend-main-coords)}) - - (when (not (str/blank? friend-name-location)) + (when-not (str/blank? friend-name-location) {:special-status "from-display-name" ; formerly called "name location" - :name (:name friend) + :name friend-name-location :coords friend-name-coords :distances (distances-map is-current-user? current-user friend-name-coords)})]})) diff --git a/src/smallworld/user_data.cljs b/src/smallworld/user_data.cljs index 393a6411..e9aebbb7 100644 --- a/src/smallworld/user_data.cljs +++ b/src/smallworld/user_data.cljs @@ -19,7 +19,7 @@ first-location (first (:locations user)) ; consider pulling from the "Twitter location" location or from the nearest location to the current user, instead of simply pulling the first location in the array lat (when first-location (:lat (:coords first-location))) lng (when first-location (:lng (:coords first-location)))] - [:div.friend {:key twitter-name} + [:div.friend {:key twitter-handle} [:a twitter-href [:div.twitter-pic [:img {:src twitter-pic :key k}]]] [:div.right-section @@ -32,6 +32,13 @@ :target "_blank"} [:span.location (:name first-location)]]]]])) +(defn render-user-bubble [k user] + (let [twitter-pic (:profile_image_url_large user) + twitter-handle (:screen-name user)] + [:div.friend {:key twitter-handle} + [:a ; TODO: on click center map on their face + [:div.twitter-pic [:img {:src twitter-pic :key k}]]]])) + ; TODO: the logic in this needs some serious cleanup; probably requires refactoring the data model too (defn get-close-friends [curr-user-location-name friend-location-key max-distance] (->> @*friends @@ -79,40 +86,50 @@ "when a friend includes a nearby location in their display name, they'll show up on this list" "when a friend's Twitter location is nearby, they'll show up on this list") key-pair [curr-user-location-i friend-location-key] - verb-gerund [:span.verb-gerund - {:title verb-gerund-info-text - :on-click #(js/alert verb-gerund-info-text)} - verb-gerund] + verb-gerund [:span.verb-gerund verb-gerund] friends-list (if (= :loading @*friends) [] (get-close-friends curr-user-location-name friend-location-key 100)) list-count (count friends-list) friend-pluralized (if (= list-count 1) "friend is" "friends are") - expanded? (boolean (get @*expanded? key-pair))] + expanded? (boolean (get @*expanded? key-pair))] [util/error-boundary [:div.friends-list - (if (= :loading @*friends) + (if (or (= :loading @*friends) (and @mapbox/*loading (= 0 (count @*friends)))) [:div.loading (decorations/simple-loading-animation) "fetching your Twitter friends..."] (if (> list-count 0) [:<> [:p.location-info - {:on-click ; toggle collapsed state - #(swap! *expanded? assoc key-pair (not expanded?))} - (decorations/triangle-icon (clojure.string/join " " ["caret" (if expanded? "right" "down")])) - [:<> - list-count " " - friend-pluralized " " - verb-gerund " " curr-user-location-name ":"]] - (when-not expanded? - [:div.friends (map-indexed render-user friends-list)])] + [:span {:title "expand for details" + :on-click #(swap! *expanded? assoc key-pair (not expanded?))} + (decorations/triangle-icon (clojure.string/join " " ["caret" (if expanded? "down" "right")])) + [:<> + list-count " " + friend-pluralized " " + verb-gerund " " curr-user-location-name]] + [:a {:data-tooltip verb-gerund-info-text + :class (if (< (.-innerWidth js/window) 500) + "tooltip-left" + "tooltip-right")} + (decorations/info-icon)]] + + [:div.friends {:style (when-not expanded? {:visibility "collapse" :height 0 :margin 0})} (map-indexed render-user friends-list)] + [:div.friend-bubbles {:style (when expanded? {:visibility "collapse" :height 0 :margin 0}) + :title "expand for details" + :on-click #(swap! *expanded? assoc key-pair (not expanded?))} + (map-indexed render-user-bubble (take 200 friends-list))]] [:div.no-friends-found (decorations/x-icon) - "0 friends are " verb-gerund " " curr-user-location-name]))]])) + "0 friends are " verb-gerund " " curr-user-location-name + [:a {:data-tooltip verb-gerund-info-text + :class (if (< (.-innerWidth js/window) 500) + "tooltip-left" + "tooltip-right")} + (decorations/info-icon)]]))]])) -; TODO: consider running every 10 mins... might create rate-limiting issues -(defn refresh-friends [] +(defn refetch-friends [] (util/fetch "/api/v1/friends/refetch-twitter" (fn [result] (reset! *friends result) @@ -121,6 +138,8 @@ (defn recompute-friends [& [callback]] (util/fetch "/api/v1/friends/recompute-locations" (fn [result] + (when (or debug? (= (.. js/window -location -hash) "#debug")) + (println "/api/v1/friends/recompute-locations: " (count result))) (when callback (callback)) (reset! *friends result) (mapbox/add-friends-to-map @*friends @session/*store)))) \ No newline at end of file diff --git a/src/smallworld/util.clj b/src/smallworld/util.clj index 2025e104..b094f6da 100644 --- a/src/smallworld/util.clj +++ b/src/smallworld/util.clj @@ -41,3 +41,6 @@ (defn log [string] (println (timestamp) "--" string)) + +(def ENVIRONMENTS {:prod "prod-heroku" + :local "dev-m1-macbook"}) \ No newline at end of file diff --git a/src/smallworld/util.cljs b/src/smallworld/util.cljs index af45e87d..66486047 100644 --- a/src/smallworld/util.cljs +++ b/src/smallworld/util.cljs @@ -1,6 +1,8 @@ (ns smallworld.util (:require [clojure.pprint :as pp] - [reagent.core :as r]) + [reagent.core :as r] + [smallworld.decorations :as decorations] + [smallworld.session :as session]) (:import [goog.async Debouncer])) (def debug? false) @@ -65,9 +67,7 @@ [:a {:href "https://github.com/devonzuegel/smallworld/issues" :target "_blank"} "report a bug"] (when (= screen-name "devonzuegel") [:<> [:span.dot-separator " · "] - [:a {:href "#" :on-click recompute-friends} "recompute locations"] - [:span.dot-separator " · "] - [:a "v5"]])]) + [:a {:href "#" :on-click recompute-friends} "recompute locations"]])]) (defn error-boundary [& children] (let [err-state (r/atom nil)] @@ -96,4 +96,24 @@ (defn exponent [base power] (.pow js/Math base power)) (defn query-dom [selector] - (array-seq (.querySelectorAll js/document selector))) \ No newline at end of file + (array-seq (.querySelectorAll js/document selector))) + +(defn nav [] + [:div.nav {:class (when (:impersonation? @session/*store) "admin-impersonation")} + [:a#logo-animation.logo {:href "/"} + (decorations/animated-globe) + + [:div.logo-text "small world"]] + [:span.fill-nav-space] + [:a {:href "/settings" #_(rfe/href ::settingks)} + [:b.screen-name " @" (:screen-name @session/*store)]]]) + +(defn device-type [] + (let [ua (.-userAgent js/navigator)] + (cond + (.test #"(?i)(tablet|ipad|playbook|silk)|(android(?!.*mobi))" ua) + "tablet" + (.test #"Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)" ua) + "mobile" + :else nil) + "desktop")) diff --git a/src/smallworld/web.clj b/src/smallworld/web.clj index 31062c01..6e83b79a 100644 --- a/src/smallworld/web.clj +++ b/src/smallworld/web.clj @@ -1,17 +1,21 @@ (ns smallworld.web (:gen-class) - (:require [cheshire.core :refer [generate-string]] + (:require [cheshire.core :refer [generate-string generate-stream]] [clojure.data.json :as json] [clojure.java.io :as io] [clojure.pprint :as pp] [clojure.set :as set] + [clojure.string :as str] [compojure.core :refer [ANY defroutes GET POST]] - [compojure.handler] + [compojure.handler :as compojure-handler] [compojure.route :as route] [oauth.twitter :as oauth] + [ring.util.io :as ring-io] [ring.adapter.jetty :as jetty] [ring.middleware.session.cookie :as cookie] - [ring.util.response :as response] + [ring.util.request :as ring-request] + [ring.util.response :as ring-response] + [schema.core :as s] [smallworld.admin :as admin] [smallworld.coordinates :as coordinates] [smallworld.db :as db] @@ -31,21 +35,25 @@ ;; server ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def Session + {(s/optional-key :screen-name) s/Str + (s/optional-key :impersonation) s/Bool + ;; we don't know what else is there + s/Any s/Any}) + (defn set-session [response-so-far new-session] (assoc response-so-far :session new-session)) -(defn get-session [req] +(s/defn get-session :- Session + [req] (let [session-data (get-in req [:session] session/blank) - screen-name (:screen-name session-data) - result (if (= screen-name admin/screen-name) - (let [new-screen-name (:screen_name (db/select-first db/impersonation-table))] - (if (nil? new-screen-name) - session-data - {:screen-name new-screen-name :impersonation? true})) - session-data)] - (log-event "get-session" result) - result)) + screen-name (:screen-name session-data)] + (if (= screen-name admin/screen-name) + (if-let [new-screen-name (db/get-current-impersonation)] + {:screen-name new-screen-name :impersonation? true} + session-data) + session-data))) ;; TODO: make it so this --with-access-token works with atom memoization too, to speed it up (defn fetch-current-user--with-access-token [access-token] @@ -67,7 +75,14 @@ (util/get-env-var "TWITTER_CONSUMER_SECRET")) redirect-url (oauth/oauth-authorization-url (:oauth-token request-token))] (log-event "start-oauth-flow" {:message "someone has started the oauth flow (we don't yet have the screen name)"}) - (response/redirect redirect-url))) + (ring-response/redirect redirect-url))) + +(defn location-sort-order [location] + (case (:special-status location) + "twitter-location" 1 + "from-display-name" 2 + "added-manually" 3 + 3)) ;; step 2 (defn store-fetched-access-token-then-redirect-home [req] @@ -78,23 +93,46 @@ current-user (user-data/abridged api-response {:screen-name (:screen-name api-response)}) screen-name (:screen-name api-response)] (when debug? - (pp/pprint "twitter verify_credentials.json:") + (pp/pprint "twitter verify_credentials.json =============================================") (pp/pprint api-response) - (println "screen-name: " screen-name)) - (db/memoized-insert-or-update! db/impersonation-table screen-name {:access_token access-token}) ; TODO: consider memoizing with an atom for speed + (println "current-user ==================================================================") + (pp/pprint current-user)) + (db/memoized-insert-or-update! db/access_tokens-table screen-name {:access_token access-token}) ; TODO: consider memoizing with an atom for speed (db/memoized-insert-or-update! db/twitter-profiles-table screen-name {:request_key screen-name :data api-response}) ; TODO: consider memoizing with an atom for speed - (db/insert-or-update! db/settings-table :screen_name {:screen_name screen-name - :name (:name api-response) - :locations (:locations current-user) - :twitter_avatar (user-data/normal-img-to-full-size api-response)}) + (let [sql-results (db/select-by-col db/settings-table :screen_name screen-name) + exists? (not= 0 (count sql-results)) + new-settings {:screen_name screen-name + :email_address (:email api-response) + :name (:name api-response) + :locations (:locations current-user) + :twitter_avatar (some-> api-response + :profile-background-image-url-https + user-data/normal-img-to-full-size)}] + (if-not exists? + ; if user doesn't exist, create a new row with the new settings + (db/insert! db/settings-table new-settings) + ; else, update their locations to include the new locations + (let [old-locations (set (:locations (first sql-results))) + new-locations (set (:locations new-settings)) + merged-locations (->> (set/union new-locations old-locations) + (sort-by location-sort-order))] + (db/update! db/settings-table :screen_name screen-name (assoc new-settings :locations merged-locations)) + (when debug? + (println "---- old-locations: ----------------------") + (pp/pprint old-locations) + (println "---- new-locations: ----------------------") + (pp/pprint new-locations) + (println "---- merged-locations: -------------------") + (pp/pprint merged-locations) + (println "------------------------------------------"))))) (log-event "new-authorization" {:screen-name screen-name :message (str "@" screen-name ") has successfully authorized small world to access their Twitter account")}) - (set-session (response/redirect "/") {:access-token access-token - :screen-name (:screen-name api-response)})) + (set-session (ring-response/redirect "/") {:access-token access-token + :screen-name (:screen-name api-response)})) (catch Throwable e (println "user failed to log in") (println e) - (response/redirect "/")))) + (ring-response/redirect "/")))) (defn logout [req] (let [screen-name (:screen-name (get-session req)) @@ -103,14 +141,14 @@ (str "@" screen-name " has logged out"))] (log-event "logout" {:screen-name screen-name :message logout-msg}) - (set-session (response/redirect "/") {}))) + (set-session (ring-response/redirect "/signin") {}))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; settings ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn get-settings [req screen-name] - (let [settings (first (db/select-by-col db/settings-table :screen_name screen-name)) ; TODO: set in the session for faster access + (let [settings (db/get-settings screen-name) ;; TODO: set in the session for faster access ip-address (or (get-in req [:headers "x-forwarded-for"]) (:remote-addr req))] (log-event "get-settings" (if (nil? screen-name) {} {:screen-name screen-name :settings settings @@ -140,17 +178,17 @@ :twitter_url (str "https://twitter.com/" screen-name)}})) ; TODO: add try-catch to handle failures - ; TODO: simplify/consolidate where the settings stuff is stored - (db/insert-or-update! db/settings-table :screen_name new-settings) - (response/response (generate-string new-settings)))) + ; TODO: simplify/consolidate where the settings stuff is stored + (db/upsert-settings! new-settings) + (ring-response/response (generate-string new-settings)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; twitter data fetching ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def abridged-friends-cache (atom {})) +(def max-results (* 15 200)) -(defn --fetch-friends [screen-name] ;; use the memoized version of this function! +(defn fetch-friends-from-twitter [screen-name] ;; use the memoized version of this function! (when debug? (println "================================================================================================= start") (println "fetching friends for " screen-name)) @@ -161,6 +199,8 @@ ; access token was deleted. it shouldn't be possible to get to this state, ; but if it does happen at some point, then we may need to add a way for ; the user to re-authenticate. + ; note – access tokens don't expire, though the user can revoke them: + ; https://developer.twitter.com/en/docs/authentication/faq#:~:text=How%20long%20does%20an%20access%20token%20last sql-result (db/select-by-col db/access_tokens-table :request_key screen-name) access-token (get-in (first sql-result) [:data :access_token]) ; TODO: memoize this with an atom for faster, non-db access, a la: (get @access-tokens screen-name) client (oauth/oauth-client (util/get-env-var "TWITTER_CONSUMER_KEY") @@ -188,143 +228,130 @@ (println "updating @" screen-name "'s friends... (partially!)") (println "-----------------------------------------------------------------------------------------\n")) - (db/insert-or-update! db/friends-table :request_key - {:request_key screen-name - :data {:friends (vec new-result)}}) - (if (= next-cursor 0) + (db/upsert-friends! screen-name (vec new-result)) + (if (or (= next-cursor 0) (= max-results (count new-result))) (do (log-event "fetch-twitter-friends--end" {:screen-name screen-name :cursor cursor :result-count (count new-result)}) + (when (= max-results (count new-result)) + (log-event "fetch-twitter-friends--max-results" {:screen-name screen-name + :details "stopped requesting friends before we were done because we'll get rate otherwise"})) new-result) ; return final result if Twitter returns a cursor of 0 (recur next-cursor new-result))))) (catch Throwable e (println "🔴 caught exception when getting friends for screen-name:" screen-name) (when (= 429 (get-in e [:data :status])) (println "you hit the Twitter rate limit!")) (println (pr-str e)) - :failed))) + nil #_failure))) (def memoized-friends (m/my-memoize (fn [screen-name] - (let [friends-result (--fetch-friends screen-name)] - (if (= :failed friends-result) + (let [friends-result (fetch-friends-from-twitter screen-name)] + (if (nil? friends-result) :failed {:friends friends-result}))) db/friends-table)) -(defn --fetch-abridged-friends [screen-name current-user] - (map #(user-data/abridged % current-user) - (:friends (memoized-friends screen-name)))) ; can add (take X) for debugging - -(defn --fetch-abridged-friends--not-memoized [screen-name current-user] - (let [friends (get-in (first (db/select-by-col db/friends-table :request_key screen-name)) - [:data :friends]) - result (map #(user-data/abridged % current-user) friends)] - (swap! abridged-friends-cache #(assoc % screen-name result)) - result)) - -(def memoized-abridged-friends - (m/my-memoize --fetch-abridged-friends abridged-friends-cache)) - -(defn get-users-friends [req & [screen-name]] - (let [session-screen-name (:screen-name (get-session req)) - logged-out? (nil? session-screen-name) - screen-name (or screen-name session-screen-name) - result (if logged-out? - [] - (memoized-abridged-friends screen-name (get-settings req session-screen-name)))] - (log-event "get-users-friends" {:count (count result) - :screen-name screen-name}) - (generate-string result))) - -; TODO: consolidate this with memoized-abridged-friends -(defn get-users-friends--not-memoized [req] +; TODO: consolidate this with memoized-friends +(s/defn get-users-friends--not-memoized :- [db/Friend] + [req + writer :- java.io.Writer] (let [screen-name (:screen-name (get-session req)) - logged-out? (nil? screen-name) - result (if logged-out? - [] - (--fetch-abridged-friends--not-memoized screen-name (get-settings req screen-name)))] - (generate-string result))) + logged-out? (nil? screen-name)] + (if logged-out? + (generate-stream [] writer) + (let [current-user (get-settings req screen-name) + friends (db/get-friends screen-name) + result (map #(user-data/abridged % current-user) friends)] + (generate-stream result writer))))) (defn select-location-fields [friend] - (merge (select-keys friend [:location :name :screen-name]))) + (select-keys friend [:location #_:name :screen-name])) ; TODO: rm this merge + +(defn highlight [highlighted-str] + (str "" + highlighted-str + "")) + +(defn html-line-result [screen-name _type before after] + (str "
  • @" screen-name ": " + (highlight before) " → " (highlight after) + "
  • ") + #_(str "
  • @" screen-name " updated their " type ": " + (highlight before) " → " (highlight after) + "
  • ")) (defn refresh-friends-from-twitter [settings] ; optionally pass in settings in case it's already computed so that we don't have to recompute (let [screen-name (:screen_name settings) - friends-result (--fetch-friends screen-name) - curr-user-info {:screen-name screen-name - :locations (:locations settings)} - friends-abridged (map #(user-data/abridged % curr-user-info) - friends-result) - old-friends (map + old-friends (map ; fetch the old friends before friends gets updated from the twitter fetch select-location-fields (-> db/friends-table (db/select-by-col :request_key screen-name) vec (get-in [0 :data :friends]))) + friends-result (fetch-friends-from-twitter screen-name) + curr-user-info {:screen-name screen-name + :locations (:locations settings)} + friends-abridged (map #(user-data/abridged % curr-user-info) + friends-result) new-friends (map select-location-fields (vec friends-result)) - diff (vec (vals (group-by :screen-name - (concat (set/difference (set old-friends) (set new-friends)) - (set/difference (set new-friends) (set old-friends)))))) + diff (->> old-friends + set + (set/difference (set new-friends)) + (concat (set/difference (set old-friends) (set new-friends))) + (group-by :screen-name) + vals + vec + (remove #(or (nil? (first %)) (nil? (second %))))) diff-html (if (= 0 (count diff)) ; this branch shouldn't be called, but defining the behavior just in case "none of your friends have updated their Twitter location or display name!" (str ""))] - (if (= :failed friends-result) + (if (nil? friends-result) (let [failure-message (str "could not refresh friends for @" screen-name)] (log-event "refresh-twitter-friends--failed" {:screen-name screen-name :failure-message failure-message}) - (generate-string (response/bad-request {:message failure-message}))) - (let [email-address (-> db/settings-table - (db/select-by-col :screen_name screen-name) - first - :email_address)] + (generate-string (ring-response/bad-request {:message failure-message}))) + (let [email-address (:email_address settings)] (log-event "refreshed-twitter-friends--success" {:screen-name screen-name :diff-count (count diff) :diff-html diff-html :email_notifications (:email_notifications settings) :send-email? (and (= "daily" (:email_notifications settings)) - (not-empty diff)) - ; - }) - + (not-empty diff))}) (println (str "\n\nhere are @" screen-name "'s friends that changed:")) (pp/pprint diff) (println (str "\n\nhere is the generated HTML:")) (pp/pprint diff-html) (println "\n\n") - (when (and (= "daily" (:email_notifications settings)) - (not-empty diff)) - (email/send-email {:to email-address - :template (:friends-on-the-move email/TEMPLATES) - :dynamic_template_data {:twitter_screen_name screen-name - :friends diff-html}})) + #_(when (and (= "daily" (:email_notifications settings)) + (not-empty diff)) + (email/send-email {:to email-address + :template (:friends-on-the-move email/TEMPLATES) + :dynamic_template_data {:twitter_screen_name screen-name + :friends diff-html}})) (db/update! db/friends-table :request_key screen-name {:data {:friends friends-result}}) - (swap! abridged-friends-cache - assoc screen-name friends-abridged) (when debug? (println (str "done refreshing friends for @" screen-name " (friends count: " (count friends-abridged) ")"))) (generate-string friends-abridged))))) @@ -343,7 +370,7 @@ (util/log (str "[user " i "/" total-count "] refresh friends for " (:screen_name user))) (let [result (refresh-friends-from-twitter user)] ; this is a hack :) it will be fragile if the error message ever changes - (when (clojure.string/starts-with? result "caught exception") + (when (str/starts-with? result "caught exception") (throw (Throwable. result)))) (catch Throwable e (println "\ncouldn't refresh friends for user" (:screen_name user)) @@ -351,33 +378,37 @@ (println e) nil)))) -(defn worker [] - (println) - (println "===============================================") - (util/log "starting worker.clj") +(defn email-update-worker [] + (println "\n===============================================") + (util/log "starting email-update worker") (println) - (let [all-users (db/select-all db/settings-table) ; (db/select-by-col db/settings-table :screen_name "antimatter15") + (let [all-users (db/select-all db/settings-table) ; (db/select-by-col db/settings-table :screen_name "devon_dos") n-users (count all-users) ;; n-failures (count @failures) curried-refresh-friends (try-to-refresh-friends n-users)] - (log-event "worker-start" {:count n-users - :message (str "preparing to refresh friends for " n-users " users\n")}) + (log-event "email-update-worker-start" {:count n-users + :message (str "preparing to refresh friends for " n-users " users\n")}) (doall (map-indexed curried-refresh-friends all-users)) - (log-event "worker-done" {:count n-users - :message (str "finished refreshing friends for " n-users " users")}) - - ;; TODO: put this back when we actually catch failures (currently, we don't) - ;; (util/log (str "finished refreshing friends for " n-users " users: " n-failures " failures\n")) - ;; (email/send-email {:to "avery.sara.james@gmail.com" - ;; :subject (str "[" (util/get-env-var "ENVIRONMENT") "] worker.clj finished: " n-failures " failures out of " n-users " users") - ;; :type "text/plain" - ;; :body (str "finished refreshing friends for " n-failures " users: " n-failures " failures" - ;; "\n\n" - ;; "users that failed:\n" (with-out-str (pp/pprint @failures)))})) - - (println) - (println "===============================================") - (println))) + (System/gc) + (log-event "garbage-collection" {:details "cleaning up after the email-update worker"}) + (log-event "email-update-worker-done" {:count n-users + :message (str "finished refreshing friends for " n-users " users")}) + + (when (= (:prod util/ENVIRONMENTS) (util/get-env-var "ENVIRONMENT")) + (email/send-email {:to "avery.sara.james@gmail.com" + :subject (str "[" (util/get-env-var "ENVIRONMENT") "] worker.clj finished for " n-users " users") #_n-failures #_" failures out of " + :type "text/plain" + :body (str "finished refreshing friends for " n-users " users:\n\n" + (str/join "\n" (map :screen_name all-users)) + ; ": " n-failures " failures" + #_"\n\n" + #_"users that failed:\n" #_(with-out-str (pp/pprint @failures)))}))) + (println "\n===============================================\n")) + +(defn worker-endpoint [req] + (if-not (= admin/screen-name (:screen-name (get-session req))) + (generate-string (ring-response/bad-request {:message "you don't have access to this page"})) + (email-update-worker))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; app core ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -390,21 +421,28 @@ (GET "/login" _ (start-oauth-flow)) (GET "/authorized" req (store-fetched-access-token-then-redirect-home req)) (GET "/logout" req (logout req)) - (GET "/api/v1/session" req (generate-string (select-keys (get-session req) [:screen-name :impersonation?]))) + (GET "/api/v1/session" req + (generate-string (-> (get-session req) + (select-keys [:screen-name :impersonation?])))) ;; admin endpoints (GET "/api/v1/admin/summary" req (admin/summary-data get-session req)) - (GET "/api/v1/admin/friends/:screen_name" req (admin/friends-of-specific-user get-session get-users-friends req)) - (GET "/api/v1/admin/refresh_all_users_friends" req (admin/refresh-all-users-friends (get-session req) log-event worker)) + (GET "/api/v1/admin/refresh_all_users_friends" req (admin/refresh-all-users-friends (get-session req) log-event email-update-worker)) ;; app data endpoints - (GET "/api/v1/settings" req (generate-string (get-settings req (:screen-name (get-session req))))) + (GET "/api/v1/settings" req + (generate-string (get-settings req (:screen-name (get-session req))))) (POST "/api/v1/settings/update" req (update-settings req)) (POST "/api/v1/coordinates" req (let [parsed-body (json/read-str (slurp (:body req)) :key-fn keyword) - location-name (:location-name parsed-body)] - (generate-string (coordinates/memoized location-name)))) - (GET "/api/v1/friends" req (get-users-friends req)) - (GET "/api/v1/friends/refresh-atom" req (get-users-friends--not-memoized req)) + raw-location-name (:location-name parsed-body) + normalized-location-name (user-data/normalize-location raw-location-name)] + (generate-string (coordinates/memoized normalized-location-name)))) + (GET "/api/v1/friends" req + (ring-response/response + (ring-io/piped-input-stream + (fn [input-stream] + (let [writer (io/make-writer input-stream {})] + (get-users-friends--not-memoized req writer)))))) ; recompute distances from new locations, without fetching data from Twitter (GET "/api/v1/friends/recompute-locations" req (let [screen-name (:screen-name (get-session req)) friends-full (:friends (memoized-friends screen-name)) @@ -413,13 +451,13 @@ {:locations (:locations settings)}) friends-abridged (map #(user-data/abridged % corrected-curr-user) friends-full)] (println (str "recomputed friends distances for @" screen-name " (count: " (count friends-abridged) ")")) - (swap! abridged-friends-cache - assoc screen-name friends-abridged) (generate-string friends-abridged))) ; re-fetch data from Twitter – TODO: this should be a POST not a GET (GET "/api/v1/friends/refetch-twitter" req (let [screen-name (:screen-name (get-session req)) - settings (first (db/select-by-col db/settings-table :screen_name screen-name))] + settings (db/get-settings screen-name)] (refresh-friends-from-twitter settings))) ; TODO: keep refactoring + (GET "/api/v1/worker" req (worker-endpoint req)) + ;; general resources (route/resources "/") (ANY "*" [] (io/resource "public/index.html"))) @@ -434,16 +472,16 @@ ; given a HTTP request, return a redirect response to the equivalent HTTPS url (defn ssl-redirect-response [request] - (-> (response/redirect (https-url (ring.util.request/request-url request))) + (-> (ring-response/redirect (https-url (ring-request/request-url request))) ; responding 301 to a POST changes it to a GET, because 301 is older & 307 is newer, so we need to respond to POST requests with a 307 - (response/status (if (get-request? request) 301 307)))) + (ring-response/status (if (get-request? request) 301 307)))) ; redirect any HTTP request to the equivalent HTTPS url (defn ssl-redirect [handler] ; note: we also have a setting in Cloudflare that forces SSL, so if you remove ; this, you'll probably still get an SSL redirect (fn [request] - (let [url (ring.util.request/request-url request) + (let [url (ring-request/request-url request) host (.getHost (java.net.URL. url)) headers (:headers request)] @@ -457,7 +495,7 @@ (println)) ; normally we'd use `(:scheme request)` to check for HTTPS instead of `x-forwarded-proto`, but for some reason `(:scheme request)` always says HTTP even when it's HTTPS, which results in infinite redirects - (if (or (clojure.string/includes? host "localhost") ; don't redirect localhost (it doesn't support SSL) + (if (or (str/includes? host "localhost") ; don't redirect localhost (it doesn't support SSL) (= "https" (headers "x-forwarded-proto")) ; don't redirect if already HTTPS (= :https (:scheme request))) (handler request) @@ -466,27 +504,49 @@ ; redirect any `www.smallworld.kiwi` request to the equivalent raw domain `smallworld.kiwi` url (defn www-redirect [handler & [port]] (fn [request] - (let [url (java.net.URL. (ring.util.request/request-url request)) + (let [url (java.net.URL. (ring-request/request-url request)) host (.getHost url)] (if (= host "www.smallworld.kiwi") - (response/redirect (str (java.net.URL. "https" "smallworld.kiwi" (or port -1) (.getFile url)))) + (ring-response/redirect (str (java.net.URL. "https" "smallworld.kiwi" (or port -1) (.getFile url)))) (handler request))))) +(defn trim-trailing-slash [handler] + (fn [request] + (let [uri (request :uri) + clean-uri (if (and (not= "/" uri) (.endsWith uri "/")) + (subs uri 0 (- (count uri) 1)) + uri)] + (if (= uri clean-uri) + (handler request) + (ring-response/redirect clean-uri))))) + + +(def one-year-in-seconds (* 60 #_seconds 60 #_minutes 24 #_hours 365 #_days)) + (def app-handler - (-> smallworld-routes - ssl-redirect - www-redirect - (compojure.handler/site {:session - {:cookie-name "small-world-session" - :store (cookie/cookie-store - {:key (util/get-env-var "COOKIE_STORE_SECRET_KEY")})}}))) + (-> smallworld-routes ; takes a request, returns response + ssl-redirect ; middleware: takes a handler, returns a handler + www-redirect ; middleware: takes a handler, returns a handler + trim-trailing-slash ; middleware: takes a handler, returns a handler + (compojure-handler/site ; middleware: takes a handler, returns a handler + {:session {:cookie-name "small-world-session" + :cookie-attrs {:max-age one-year-in-seconds} ; Safari requires max-age, not expiry: https://www.reddit.com/r/webdev/comments/jfk6t8/setting_cookie_expiry_date_always_defaults_to/g9kqnh5 + :store (cookie/cookie-store + {:key (util/get-env-var "COOKIE_STORE_SECRET_KEY")})}}))) + +(def email-update-worker-id (atom nil)) +(def garbage-collection-id (atom nil)) + +(defn end-schedule [] + (println "ending email update worker schedule:" @email-update-worker-id) + (timely/end-schedule @email-update-worker-id) + (reset! email-update-worker-id nil)) (defonce server* (atom nil)) (defn start! [port] (some-> @server* (.stop)) - ; create the tables if they don't already exists (db/create-table db/settings-table db/settings-schema) (db/create-table db/twitter-profiles-table db/twitter-profiles-schema) @@ -501,25 +561,59 @@ (reset! server* server))) (defn stop! [] + (if @email-update-worker-id + (end-schedule) + (println "@email-update-worker-id is nil – no schedule to end")) + (if @garbage-collection-id + (end-schedule) + (println "@garbage-collection-id is nil – no schedule to end")) (if @server* (.stop @server*) (println "@server* is nil – no server to stop"))) -(def scheduled-time (timely/at (timely/hour 2) (timely/minute 55))) ; in UTC +; this is a hack – there was a memory leak somewhere, so we run a GC to clean up. +; I *think* the source of the memory leak was an atom that I removed a while back, +; but I'm just putting this in here to be sure. if this project ever becomes +; "serious", I'll come back to it to find the memory leak and remove this worker. +(defn garbage-collection-worker [] + (System/gc) + (log-event "garbage-collection" {})) + +(def EMAIL-UPDATE-WORKER-TIME (timely/at (timely/hour 15) (timely/minute 52))) ; in UTC + +(defn start-scheduled-workers [] + (try (timely/start-scheduler) + (catch Exception e + (if (= (:cause (Throwable->map e)) "Scheduler already started") + (println "scheduler already started") ; it's fine, this isn't a real error, so just continue + (throw e)))) + (println "starting scheduler to run every day at" + (str (first (:hour EMAIL-UPDATE-WORKER-TIME)) ":" (first (:minute EMAIL-UPDATE-WORKER-TIME))) "UTC") + + ; start the email-update worker that refreshes users' twitter info/friends + (let [env (util/get-env-var "ENVIRONMENT")] + (if (= env (:prod util/ENVIRONMENTS)) + (let [id (timely/start-schedule + (timely/scheduled-item (timely/daily EMAIL-UPDATE-WORKER-TIME) email-update-worker))] + (reset! email-update-worker-id id) + (println "\nstarted email update worker with id:" @email-update-worker-id)) + (println "\nnot starting email update worker because ENVIRONMENT is" env "not" (:prod util/ENVIRONMENTS)))) + + ; start garbage collection worker + (let [id (timely/start-schedule + (timely/scheduled-item (timely/each-minute) garbage-collection-worker))] + (reset! garbage-collection-id id) + (println "\nstarted garbage collection worker with id:" @garbage-collection-id))) (defn -main [] - (println "starting scheduler to run every day at" - (str (first (:hour scheduled-time)) ":" (first (:minute scheduled-time))) "UTC") - (timely/start-scheduler) - (timely/start-schedule (timely/scheduled-item (timely/daily scheduled-time) - worker)) + (start-scheduled-workers) - (println "starting server...") - (let [default-port 8080 + (println "\nstarting server...") + (let [default-port 3001 port (System/getenv "PORT") port (if (nil? port) (do (println "PORT not defined. Defaulting to" default-port) default-port) (Integer/parseInt port))] (println "\nsmall world is running on" (str "http://localhost:" port) "\n") - (start! port))) \ No newline at end of file + (start! port)))