├── .gitignore ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── default_profile.png ├── favicon.ico ├── img │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── android-chrome-maskable-192x192.png │ │ ├── android-chrome-maskable-512x512.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-60x60.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── msapplication-icon-144x144.png │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.vue ├── assets │ ├── logo.png │ └── tailwind.css ├── components │ ├── DefaultRightBar.vue │ ├── EditProfileOverlay.vue │ ├── Loader.vue │ ├── NewMessageOverlay.vue │ ├── ReplyOverlay.vue │ ├── Results.vue │ ├── ResultsNewMessage.vue │ ├── Retweet.vue │ ├── SearchBar.vue │ ├── SetUpProfileOverlay.vue │ ├── SideNav.vue │ ├── TrendingForYou.vue │ ├── Tweet.vue │ ├── Tweets.vue │ ├── User.vue │ ├── UserNewMessage.vue │ ├── Users.vue │ └── WhoToFollow.vue ├── directives │ ├── escape.directive.js │ ├── index.js │ ├── linkify.directive.js │ └── scroll.directive.js ├── filters │ ├── index.js │ ├── time.js │ └── timeago.js ├── lib │ └── backend.js ├── main.js ├── registerServiceWorker.js ├── router │ ├── auth.guard.js │ └── index.js ├── service-worker.js ├── store │ ├── index.js │ └── modules │ │ ├── authentication │ │ ├── actions.js │ │ ├── getters.js │ │ ├── index.js │ │ └── mutations.js │ │ ├── signup │ │ ├── actions.js │ │ ├── getters.js │ │ ├── index.js │ │ └── mutations.js │ │ └── twitter │ │ ├── actions.js │ │ ├── getters.js │ │ ├── index.js │ │ └── mutations.js └── views │ ├── Followers.vue │ ├── Following.vue │ ├── Hashtag.vue │ ├── Home.vue │ ├── LogIn.vue │ ├── Messages.vue │ ├── Notifications.vue │ ├── Profile.vue │ ├── Root.vue │ └── Search.vue ├── tailwind.config.js ├── vue.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # appsyncmasterclass-frontend 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | yarn lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appsyncmasterclass-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "@tailwindcss/postcss7-compat": "^2.0.2", 12 | "autoprefixer": "^9", 13 | "aws-sdk": "^2.1195.0", 14 | "axios": "^0.21.4", 15 | "core-js": "^3.6.5", 16 | "graphql": "^16.0.0", 17 | "linkify-html": "^3.0.4", 18 | "linkify-plugin-hashtag": "^3.0.4", 19 | "linkify-plugin-mention": "^3.0.4", 20 | "linkifyjs": "^3.0.4", 21 | "moment": "^2.29.1", 22 | "postcss": "^7", 23 | "register-service-worker": "^1.7.1", 24 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2", 25 | "vue": "^2.6.11", 26 | "vue-router": "^3.2.0" 27 | }, 28 | "devDependencies": { 29 | "@aws-amplify/ui-vue": "^0.2.33", 30 | "@vue/cli-plugin-babel": "~4.5.0", 31 | "@vue/cli-plugin-eslint": "~4.5.0", 32 | "@vue/cli-plugin-pwa": "~4.5.10", 33 | "@vue/cli-plugin-router": "~4.5.0", 34 | "@vue/cli-service": "~4.5.0", 35 | "aws-amplify": "^3.3.14", 36 | "babel-eslint": "^10.1.0", 37 | "eslint": "^6.7.2", 38 | "eslint-plugin-vue": "^6.2.2", 39 | "graphql-tag": "^2.11.0", 40 | "vue-cli-plugin-tailwind": "~2.0.5", 41 | "vue-template-compiler": "^2.6.11", 42 | "vuex": "^3.6.0" 43 | }, 44 | "eslintConfig": { 45 | "root": true, 46 | "env": { 47 | "node": true 48 | }, 49 | "extends": [ 50 | "plugin:vue/essential", 51 | "eslint:recommended" 52 | ], 53 | "parserOptions": { 54 | "parser": "babel-eslint" 55 | }, 56 | "rules": {} 57 | }, 58 | "browserslist": [ 59 | "> 1%", 60 | "last 2 versions", 61 | "not dead" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/default_profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/default_profile.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/favicon.ico -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/android-chrome-maskable-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/android-chrome-maskable-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appsyncmasterclass-frontend", 3 | "short_name": "appsyncmasterclass-frontend", 4 | "theme_color": "#4DBA87", 5 | "icons": [ 6 | { 7 | "src": "./img/icons/android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "./img/icons/android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "./img/icons/android-chrome-maskable-192x192.png", 18 | "sizes": "192x192", 19 | "type": "image/png", 20 | "purpose": "maskable" 21 | }, 22 | { 23 | "src": "./img/icons/android-chrome-maskable-512x512.png", 24 | "sizes": "512x512", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | } 28 | ], 29 | "start_url": ".", 30 | "display": "standalone", 31 | "background_color": "#000000" 32 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-frontend/949c7ed18d6c05695a6e5e681576dbf1f2e7c219/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | html, body {height: 100%;} 7 | 8 | button:focus, input:focus { 9 | outline: none; 10 | } 11 | 12 | .modal-main { 13 | height: 40rem; 14 | } 15 | 16 | textarea { 17 | resize: none; 18 | } 19 | 20 | input[type="search"]::-webkit-search-cancel-button { 21 | -webkit-appearance: none; 22 | height: 1.6em; 23 | width: 1.6em; 24 | border-radius: 50em; 25 | background: url(https://pro.fontawesome.com/releases/v5.10.0/svgs/solid/times-circle.svg) no-repeat 50% 50%; 26 | background-size: contain; 27 | opacity: 0; 28 | pointer-events: none; 29 | } 30 | 31 | input[type="search"]:focus::-webkit-search-cancel-button { 32 | opacity: .2; 33 | pointer-events: all; 34 | } 35 | 36 | a:link, a { 37 | color: #1b95e0; 38 | } 39 | 40 | a:hover { 41 | text-decoration: underline; 42 | } -------------------------------------------------------------------------------- /src/components/DefaultRightBar.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/EditProfileOverlay.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | -------------------------------------------------------------------------------- /src/components/Results.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/ResultsNewMessage.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/Retweet.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/SearchBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/SetUpProfileOverlay.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | -------------------------------------------------------------------------------- /src/components/SideNav.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | -------------------------------------------------------------------------------- /src/components/TrendingForYou.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/Tweet.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | -------------------------------------------------------------------------------- /src/components/Tweets.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/User.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | -------------------------------------------------------------------------------- /src/components/UserNewMessage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/Users.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/WhoToFollow.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | -------------------------------------------------------------------------------- /src/directives/escape.directive.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bind: function(el, binding) { 3 | if (typeof binding.value !== 'function') return; 4 | el.__callback__ = (event) => { 5 | if (event.keyCode === 27) { 6 | binding.value(event, el); 7 | } 8 | } 9 | el.addEventListener('keyup', el.__callback__); 10 | }, 11 | unbind: function(el) { 12 | el.removeEventListener('keyup', el.__callback__); 13 | el.__callback__ = null; 14 | } 15 | } -------------------------------------------------------------------------------- /src/directives/index.js: -------------------------------------------------------------------------------- 1 | import escape from './escape.directive'; 2 | import scroll from './scroll.directive'; 3 | import linkify from './linkify.directive'; 4 | 5 | export default { 6 | install(Vue) { 7 | Vue.directive('escape', escape); 8 | Vue.directive('scroll', scroll); 9 | Vue.directive('linkify', linkify); 10 | } 11 | } -------------------------------------------------------------------------------- /src/directives/linkify.directive.js: -------------------------------------------------------------------------------- 1 | import linkifyHtml from 'linkify-html'; 2 | import 'linkify-plugin-hashtag'; 3 | import 'linkify-plugin-mention'; 4 | 5 | const options = { 6 | formatHref: { 7 | hashtag: (href) => `#/hashtag?m=Latest&hash=${Date.now()}&q=${escape('#' + href.substr(1))}`, 8 | mention: (href) => `#/${href.substr(1)}` 9 | }, 10 | target: (href, type) => (type == 'mention' || type == 'hashtag') ? undefined : '_blank', 11 | truncate: 25, 12 | ignoreTags: ['script', 'style'] 13 | } 14 | 15 | export default { 16 | bind: function (el) { 17 | let text = el.innerHTML; 18 | el.innerHTML = linkifyHtml(text, options); 19 | }, 20 | } -------------------------------------------------------------------------------- /src/directives/scroll.directive.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bind: function (el, binding) { 3 | if (typeof binding.value !== 'function') return; 4 | el.__callback__ = (event) => { 5 | if (binding.arg == 'top') { 6 | if (!el) return; 7 | const isTop = el.scrollTop==0; 8 | if (!isTop) return; 9 | binding.value(event, el); 10 | } else if (binding.arg == 'bottom') { 11 | if (!el) return; 12 | const isBottom = Math.ceil(el.offsetHeight + el.scrollTop) >= el.scrollHeight; 13 | if (!isBottom) return; 14 | binding.value(event, el); 15 | } else { 16 | binding.value(event, el); 17 | } 18 | } 19 | el.addEventListener('scroll', el.__callback__); 20 | }, 21 | unbind: function (el) { 22 | el.removeEventListener('scroll', el.__callback__); 23 | el.__callback__ = null; 24 | } 25 | } -------------------------------------------------------------------------------- /src/filters/index.js: -------------------------------------------------------------------------------- 1 | import timeago from './timeago'; 2 | import time from './time'; 3 | 4 | export default { 5 | install(Vue) { 6 | Vue.filter('timeago', timeago); 7 | Vue.filter('time', time); 8 | } 9 | } -------------------------------------------------------------------------------- /src/filters/time.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | moment.updateLocale('en', { 4 | monthsShort: [ 5 | "Jan", "Feb", "Mar", "Apr", "May", "Jun", 6 | "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" 7 | ], 8 | }); 9 | 10 | export default function time(_date) { 11 | const date = moment(_date); 12 | const today = moment().startOf('day'); 13 | const yesterday = moment().subtract(1, 'day').startOf('day'); 14 | 15 | if (date <= yesterday) { // Jan 31, 6:30 PM 16 | return date.format("MMM D, h:mm A"); 17 | } else if (date <= today) { // Yesterday, 6:30 PM 18 | return `Yesterday, ${date.format("h:mm A")}`; 19 | } else { // 6:30 PM 20 | return date.format("h:mm A"); 21 | } 22 | } -------------------------------------------------------------------------------- /src/filters/timeago.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | moment.updateLocale('en', { 4 | monthsShort: [ 5 | "Jan", "Feb", "Mar", "Apr", "May", "Jun", 6 | "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" 7 | ], 8 | relativeTime: { 9 | past: '%ss', 10 | s: 'Now', 11 | ss: '%ss', 12 | m: '%dm', 13 | mm: '%dm', 14 | h: '%dh', 15 | hh: '%dh', 16 | } 17 | }); 18 | 19 | export default function timeago(date) { 20 | if (moment().diff(date, "days") == 0) { 21 | return moment(date).fromNow(true); // Now, 15s, 5m, 3h 22 | } else { 23 | return moment(date).format("MMM D"); // Jan 31 24 | } 25 | } -------------------------------------------------------------------------------- /src/lib/backend.js: -------------------------------------------------------------------------------- 1 | import { API } from 'aws-amplify' 2 | import gql from 'graphql-tag' 3 | 4 | const getMyProfile = async () => { 5 | const result = await API.graphql({ 6 | query: gql` 7 | query getMyProfile { 8 | getMyProfile { 9 | id 10 | name 11 | screenName 12 | imageUrl 13 | backgroundImageUrl 14 | bio 15 | location 16 | website 17 | birthdate 18 | createdAt 19 | followersCount 20 | followingCount 21 | tweetsCount 22 | likesCounts 23 | } 24 | } 25 | `, 26 | authMode: "AMAZON_COGNITO_USER_POOLS" 27 | }) 28 | const profile = result.data.getMyProfile 29 | 30 | profile.imageUrl = profile.imageUrl || 'default_profile.png' 31 | return profile 32 | } 33 | 34 | const getProfileByScreenName = async (screenName) => { 35 | const result = await API.graphql({ 36 | query: gql` 37 | query getProfile($screenName: String!) { 38 | getProfile(screenName: $screenName) { 39 | id 40 | name 41 | screenName 42 | imageUrl 43 | backgroundImageUrl 44 | bio 45 | location 46 | website 47 | birthdate 48 | createdAt 49 | followersCount 50 | followingCount 51 | tweetsCount 52 | likesCounts 53 | following 54 | followedBy 55 | } 56 | } 57 | `, 58 | variables: { 59 | screenName 60 | }, 61 | authMode: "AMAZON_COGNITO_USER_POOLS" 62 | }) 63 | const profile = result.data.getProfile || {}; 64 | 65 | profile.imageUrl = profile.imageUrl || 'default_profile.png' 66 | return profile 67 | } 68 | 69 | const getMyTimeline = async (limit, nextToken) => { 70 | const result = await API.graphql({ 71 | query: gql` 72 | query getMyTimeline($limit:Int!, $nextToken:String) { 73 | getMyTimeline(limit: $limit, nextToken: $nextToken) { 74 | nextToken 75 | tweets { 76 | __typename 77 | id 78 | profile { 79 | id 80 | name 81 | screenName 82 | imageUrl 83 | } 84 | createdAt 85 | ... on Tweet { 86 | text 87 | liked 88 | likes 89 | retweeted 90 | retweets 91 | replies 92 | } 93 | ... on Retweet { 94 | retweetOf { 95 | id 96 | profile { 97 | id 98 | name 99 | screenName 100 | imageUrl 101 | } 102 | createdAt 103 | ... on Tweet { 104 | text 105 | liked 106 | likes 107 | retweeted 108 | retweets 109 | replies 110 | } 111 | ... on Reply { 112 | text 113 | liked 114 | likes 115 | retweeted 116 | retweets 117 | replies 118 | } 119 | } 120 | } 121 | ... on Reply { 122 | text 123 | liked 124 | likes 125 | retweeted 126 | retweets 127 | replies 128 | inReplyToTweet { 129 | id 130 | profile { 131 | id 132 | name 133 | screenName 134 | imageUrl 135 | } 136 | createdAt 137 | ... on Tweet { 138 | text 139 | liked 140 | likes 141 | retweeted 142 | retweets 143 | replies 144 | } 145 | ... on Reply { 146 | text 147 | liked 148 | likes 149 | retweeted 150 | retweets 151 | replies 152 | } 153 | ... on Retweet { 154 | retweetOf { 155 | id 156 | } 157 | } 158 | } 159 | inReplyToUsers { 160 | id 161 | name 162 | screenName 163 | imageUrl 164 | } 165 | } 166 | } 167 | } 168 | } 169 | `, 170 | variables: { 171 | limit, 172 | nextToken 173 | }, 174 | authMode: "AMAZON_COGNITO_USER_POOLS" 175 | }) 176 | 177 | return result.data.getMyTimeline 178 | } 179 | 180 | const getTweets = async (userId, limit, nextToken) => { 181 | const result = await API.graphql({ 182 | query: gql` 183 | query getTweets($userId:ID!, $limit:Int!, $nextToken:String) { 184 | getTweets(userId:$userId, limit:$limit, nextToken: $nextToken) { 185 | nextToken 186 | tweets { 187 | ... on Tweet { 188 | id 189 | createdAt 190 | text 191 | liked 192 | likes 193 | retweeted 194 | retweets 195 | replies 196 | profile { 197 | id 198 | name 199 | screenName 200 | imageUrl 201 | } 202 | } 203 | ... on Reply { 204 | text 205 | liked 206 | likes 207 | retweeted 208 | retweets 209 | replies 210 | inReplyToTweet { 211 | id 212 | profile { 213 | id 214 | name 215 | screenName 216 | imageUrl 217 | } 218 | createdAt 219 | ... on Tweet { 220 | text 221 | liked 222 | likes 223 | retweeted 224 | retweets 225 | replies 226 | } 227 | ... on Reply { 228 | text 229 | liked 230 | likes 231 | retweeted 232 | retweets 233 | replies 234 | } 235 | } 236 | inReplyToUsers { 237 | id 238 | name 239 | screenName 240 | imageUrl 241 | } 242 | } 243 | } 244 | } 245 | } 246 | `, 247 | variables: { 248 | userId, 249 | limit, 250 | nextToken 251 | }, 252 | authMode: "AMAZON_COGNITO_USER_POOLS" 253 | }) 254 | 255 | return result.data.getTweets 256 | } 257 | 258 | const getImageUploadUrl = async (extension, contentType) => { 259 | const result = await API.graphql({ 260 | query: gql` 261 | query getImageUploadUrl($extension:String, $contentType:String) { 262 | getImageUploadUrl(extension:$extension, contentType:$contentType) 263 | } 264 | `, 265 | variables: { 266 | extension, 267 | contentType 268 | }, 269 | authMode: "AMAZON_COGNITO_USER_POOLS" 270 | }) 271 | 272 | return result.data.getImageUploadUrl 273 | } 274 | 275 | const editMyProfile = async (newProfile) => { 276 | const result = await API.graphql({ 277 | query: gql` 278 | mutation editMyProfile($newProfile:ProfileInput!) { 279 | editMyProfile(newProfile: $newProfile) { 280 | backgroundImageUrl 281 | bio 282 | createdAt 283 | birthdate 284 | followersCount 285 | followingCount 286 | id 287 | imageUrl 288 | likesCounts 289 | location 290 | name 291 | screenName 292 | tweetsCount 293 | website 294 | } 295 | } 296 | `, 297 | variables: { 298 | newProfile 299 | }, 300 | authMode: "AMAZON_COGNITO_USER_POOLS" 301 | }) 302 | return result.data.editMyProfile 303 | } 304 | 305 | const tweet = async (text) => { 306 | const result = await API.graphql({ 307 | query: gql` 308 | mutation tweet($text:String!) { 309 | tweet(text: $text) { 310 | createdAt 311 | id 312 | liked 313 | likes 314 | profile { 315 | imageUrl 316 | name 317 | screenName 318 | } 319 | replies 320 | retweeted 321 | retweets 322 | text 323 | } 324 | } 325 | `, 326 | variables: { 327 | text 328 | }, 329 | authMode: "AMAZON_COGNITO_USER_POOLS" 330 | }) 331 | return result.data.tweet 332 | } 333 | 334 | const retweet = async (tweetId) => { 335 | await API.graphql({ 336 | query: gql` 337 | mutation retweet($tweetId:ID!) { 338 | retweet(tweetId: $tweetId) { 339 | id 340 | createdAt 341 | } 342 | } 343 | `, 344 | variables: { 345 | tweetId 346 | }, 347 | authMode: "AMAZON_COGNITO_USER_POOLS" 348 | }) 349 | } 350 | 351 | const unretweet = async (tweetId) => { 352 | await API.graphql({ 353 | query: gql` 354 | mutation unretweet($tweetId:ID!) { 355 | unretweet(tweetId: $tweetId) 356 | } 357 | `, 358 | variables: { 359 | tweetId 360 | }, 361 | authMode: "AMAZON_COGNITO_USER_POOLS" 362 | }) 363 | } 364 | 365 | const like = async (tweetId) => { 366 | await API.graphql({ 367 | query: gql` 368 | mutation like($tweetId:ID!) { 369 | like(tweetId: $tweetId) 370 | } 371 | `, 372 | variables: { 373 | tweetId 374 | }, 375 | authMode: "AMAZON_COGNITO_USER_POOLS" 376 | }) 377 | } 378 | 379 | const unlike = async (tweetId) => { 380 | await API.graphql({ 381 | query: gql` 382 | mutation unlike($tweetId:ID!) { 383 | unlike(tweetId: $tweetId) 384 | } 385 | `, 386 | variables: { 387 | tweetId 388 | }, 389 | authMode: "AMAZON_COGNITO_USER_POOLS" 390 | }) 391 | } 392 | 393 | const reply = async (tweetId, text) => { 394 | const result = await API.graphql({ 395 | query: gql` 396 | mutation reply($tweetId:ID!, $text:String!) { 397 | reply(tweetId: $tweetId, text: $text) { 398 | id 399 | createdAt 400 | liked 401 | likes 402 | profile { 403 | imageUrl 404 | name 405 | screenName 406 | } 407 | replies 408 | retweeted 409 | retweets 410 | text 411 | } 412 | } 413 | `, 414 | variables: { 415 | tweetId, 416 | text 417 | }, 418 | authMode: "AMAZON_COGNITO_USER_POOLS" 419 | }) 420 | 421 | return result.data.reply 422 | } 423 | 424 | const follow = async (userId) => { 425 | await API.graphql({ 426 | query: gql` 427 | mutation follow($userId:ID!) { 428 | follow(userId: $userId) 429 | } 430 | `, 431 | variables: { 432 | userId 433 | }, 434 | authMode: "AMAZON_COGNITO_USER_POOLS" 435 | }) 436 | } 437 | 438 | const unfollow = async (userId) => { 439 | await API.graphql({ 440 | query: gql` 441 | mutation unfollow($userId:ID!) { 442 | unfollow(userId: $userId) 443 | } 444 | `, 445 | variables: { 446 | userId 447 | }, 448 | authMode: "AMAZON_COGNITO_USER_POOLS" 449 | }) 450 | } 451 | 452 | const getFollowers = async (userId, limit = 10) => { 453 | const result = await API.graphql({ 454 | query: gql` 455 | query getFollowers($userId:ID!, $limit:Int!) { 456 | getFollowers(userId: $userId, limit: $limit) { 457 | profiles { 458 | id 459 | name 460 | screenName 461 | imageUrl 462 | bio 463 | ... on OtherProfile { 464 | following 465 | followedBy 466 | } 467 | }, 468 | nextToken 469 | } 470 | } 471 | `, 472 | variables: { 473 | userId, 474 | limit 475 | }, 476 | authMode: "AMAZON_COGNITO_USER_POOLS" 477 | }) 478 | 479 | return result.data.getFollowers 480 | } 481 | 482 | const getFollowing = async (userId, limit = 10) => { 483 | const result = await API.graphql({ 484 | query: gql` 485 | query getFollowing($userId:ID!, $limit:Int!) { 486 | getFollowing(userId: $userId, limit: $limit) { 487 | profiles { 488 | id 489 | name 490 | screenName 491 | imageUrl 492 | bio 493 | ... on OtherProfile { 494 | following 495 | followedBy 496 | } 497 | }, 498 | nextToken 499 | } 500 | } 501 | `, 502 | variables: { 503 | userId, 504 | limit 505 | }, 506 | authMode: "AMAZON_COGNITO_USER_POOLS" 507 | }) 508 | 509 | return result.data.getFollowing 510 | } 511 | 512 | const search = async (query, mode, limit, nextToken) => { 513 | const result = await API.graphql({ 514 | query: gql` 515 | query search($query: String!, $mode: SearchMode!, $limit: Int!, $nextToken: String) { 516 | search(query: $query, mode: $mode, limit: $limit, nextToken: $nextToken) { 517 | nextToken 518 | results { 519 | __typename 520 | ... on Tweet { 521 | id 522 | createdAt 523 | text 524 | liked 525 | likes 526 | retweeted 527 | retweets 528 | replies 529 | profile { 530 | id 531 | name 532 | screenName 533 | imageUrl 534 | } 535 | } 536 | ... on Reply { 537 | id 538 | text 539 | liked 540 | likes 541 | retweeted 542 | retweets 543 | replies 544 | profile { 545 | id 546 | name 547 | screenName 548 | imageUrl 549 | } 550 | inReplyToTweet { 551 | id 552 | profile { 553 | id 554 | name 555 | screenName 556 | imageUrl 557 | } 558 | createdAt 559 | ... on Tweet { 560 | text 561 | liked 562 | likes 563 | retweeted 564 | retweets 565 | replies 566 | } 567 | ... on Reply { 568 | text 569 | liked 570 | likes 571 | retweeted 572 | retweets 573 | replies 574 | } 575 | } 576 | inReplyToUsers { 577 | id 578 | name 579 | screenName 580 | imageUrl 581 | } 582 | } 583 | ... on OtherProfile { 584 | id 585 | name 586 | screenName 587 | imageUrl 588 | bio 589 | following 590 | followedBy 591 | } 592 | ... on MyProfile { 593 | id 594 | name 595 | screenName 596 | imageUrl 597 | bio 598 | } 599 | } 600 | } 601 | } 602 | `, 603 | variables: { 604 | query, 605 | mode, 606 | limit, 607 | nextToken 608 | }, 609 | authMode: "AMAZON_COGNITO_USER_POOLS" 610 | }) 611 | 612 | return result.data.search; 613 | } 614 | 615 | const getHashTag = async (hashTag, mode, limit, nextToken) => { 616 | const result = await API.graphql({ 617 | query: gql` 618 | query getHashTag($hashTag: String!, $mode: HashTagMode!, $limit: Int!, $nextToken: String) { 619 | getHashTag(hashTag: $hashTag, mode: $mode, limit: $limit, nextToken: $nextToken) { 620 | nextToken 621 | results { 622 | __typename 623 | ... on Tweet { 624 | id 625 | createdAt 626 | text 627 | liked 628 | likes 629 | retweeted 630 | retweets 631 | replies 632 | profile { 633 | id 634 | name 635 | screenName 636 | imageUrl 637 | } 638 | } 639 | ... on Reply { 640 | id 641 | text 642 | liked 643 | likes 644 | retweeted 645 | retweets 646 | replies 647 | profile { 648 | id 649 | name 650 | screenName 651 | imageUrl 652 | } 653 | inReplyToTweet { 654 | id 655 | profile { 656 | id 657 | name 658 | screenName 659 | imageUrl 660 | } 661 | createdAt 662 | ... on Tweet { 663 | text 664 | liked 665 | likes 666 | retweeted 667 | retweets 668 | replies 669 | } 670 | ... on Reply { 671 | text 672 | liked 673 | likes 674 | retweeted 675 | retweets 676 | replies 677 | } 678 | } 679 | inReplyToUsers { 680 | id 681 | name 682 | screenName 683 | imageUrl 684 | } 685 | } 686 | ... on OtherProfile { 687 | id 688 | name 689 | screenName 690 | imageUrl 691 | bio 692 | following 693 | followedBy 694 | } 695 | ... on MyProfile { 696 | id 697 | name 698 | screenName 699 | imageUrl 700 | bio 701 | } 702 | } 703 | } 704 | } 705 | `, 706 | variables: { 707 | hashTag, 708 | mode, 709 | limit, 710 | nextToken 711 | }, 712 | authMode: "AMAZON_COGNITO_USER_POOLS" 713 | }) 714 | 715 | return result.data.getHashTag; 716 | } 717 | 718 | const getOnNotifiedSubscription = (userId) => { 719 | const onNotified = { 720 | query: gql` 721 | subscription onNotified ($userId: ID!) { 722 | onNotified(userId: $userId) { 723 | ... on Retweeted { 724 | id 725 | createdAt 726 | retweetedBy 727 | tweetId 728 | retweetId 729 | type 730 | } 731 | ... on Liked { 732 | id 733 | createdAt 734 | likedBy 735 | tweetId 736 | type 737 | } 738 | ... on Mentioned { 739 | id 740 | createdAt 741 | mentionedBy 742 | mentionedByTweetId 743 | type 744 | } 745 | ... on Replied { 746 | id 747 | createdAt 748 | repliedBy 749 | tweetId 750 | replyTweetId 751 | type 752 | } 753 | ... on DMed { 754 | id 755 | message 756 | createdAt 757 | otherUserId 758 | type 759 | } 760 | } 761 | }`, 762 | variables: { 763 | userId: userId 764 | } 765 | }; 766 | 767 | return API.graphql(onNotified); 768 | } 769 | 770 | const listConversations = async (limit, nextToken) => { 771 | const result = await API.graphql({ 772 | query: gql` 773 | query listConversations($limit: Int!, $nextToken: String) { 774 | listConversations( 775 | limit: $limit 776 | nextToken: $nextToken 777 | ) { 778 | conversations { 779 | id 780 | otherUser { 781 | id 782 | name 783 | screenName 784 | imageUrl 785 | backgroundImageUrl 786 | bio 787 | location 788 | website 789 | birthdate 790 | createdAt 791 | followersCount 792 | followingCount 793 | tweetsCount 794 | likesCounts 795 | following 796 | followedBy 797 | } 798 | lastMessage 799 | lastModified 800 | } 801 | nextToken 802 | } 803 | } 804 | `, 805 | variables: { 806 | limit, 807 | nextToken 808 | }, 809 | authMode: "AMAZON_COGNITO_USER_POOLS" 810 | }) 811 | 812 | return result.data.listConversations; 813 | } 814 | 815 | const getDirectMessages = async (otherUserId, limit, nextToken) => { 816 | const result = await API.graphql({ 817 | query: gql` 818 | query getDirectMessages($otherUserId: ID!, $limit: Int!, $nextToken: String) { 819 | getDirectMessages( 820 | otherUserId: $otherUserId 821 | limit: $limit 822 | nextToken: $nextToken 823 | ) { 824 | messages { 825 | messageId 826 | message 827 | timestamp 828 | from { 829 | imageUrl 830 | screenName 831 | } 832 | } 833 | nextToken 834 | } 835 | } 836 | `, 837 | variables: { 838 | otherUserId, 839 | limit, 840 | nextToken 841 | }, 842 | authMode: "AMAZON_COGNITO_USER_POOLS" 843 | }) 844 | 845 | return result.data.getDirectMessages; 846 | } 847 | 848 | const sendDirectMessage = async (message, otherUserId) => { 849 | const result = await API.graphql({ 850 | query: gql` 851 | mutation sendDirectMessage($message: String!, $otherUserId: ID!) { 852 | sendDirectMessage( 853 | message: $message 854 | otherUserId: $otherUserId 855 | ) { 856 | id 857 | message:lastMessage 858 | lastModified 859 | otherUser { 860 | name 861 | screenName 862 | imageUrl 863 | } 864 | } 865 | } 866 | `, 867 | variables: { 868 | message, 869 | otherUserId, 870 | }, 871 | authMode: "AMAZON_COGNITO_USER_POOLS" 872 | }) 873 | 874 | return result.data.sendDirectMessage; 875 | } 876 | 877 | export { 878 | getMyProfile, 879 | getProfileByScreenName, 880 | getMyTimeline, 881 | getTweets, 882 | getImageUploadUrl, 883 | editMyProfile, 884 | tweet, 885 | retweet, 886 | unretweet, 887 | like, 888 | unlike, 889 | reply, 890 | follow, 891 | unfollow, 892 | getFollowers, 893 | getFollowing, 894 | search, 895 | getHashTag, 896 | getOnNotifiedSubscription, 897 | listConversations, 898 | getDirectMessages, 899 | sendDirectMessage, 900 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import './assets/tailwind.css' 4 | import Amplify from 'aws-amplify'; 5 | import '@aws-amplify/ui-vue'; 6 | import router from './router'; 7 | import store from './store'; 8 | import directives from './directives'; 9 | import filters from './filters'; 10 | import './registerServiceWorker' 11 | 12 | Amplify.configure({ 13 | Auth: { 14 | region: process.env.VUE_APP_AUTH_REGION, 15 | userPoolId: process.env.VUE_APP_AUTH_USER_POOL_ID, 16 | userPoolWebClientId: process.env.VUE_APP_AUTH_USER_POOL_WEB_CLIENT_ID, 17 | mandatorySignIn: true 18 | } 19 | }) 20 | 21 | const myAppConfig = { 22 | 'aws_appsync_graphqlEndpoint': process.env.VUE_APP_AWS_APPSYNC_GRAPHQL_ENDPOINT, 23 | 'aws_appsync_region': process.env.VUE_APP_AWS_APPSYNC_REGION, 24 | 'aws_appsync_authenticationType': process.env.VUE_APP_AWS_APPSYNC_AUTHENTICATION_TYPE 25 | } 26 | 27 | Amplify.configure(myAppConfig); 28 | 29 | Vue.config.productionTip = false 30 | 31 | Vue.use(directives); 32 | Vue.use(filters); 33 | 34 | new Vue({ 35 | router, 36 | store, 37 | render: h => h(App) 38 | }).$mount('#app') 39 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker' 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready () { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB' 11 | ) 12 | }, 13 | registered () { 14 | console.log('Service worker has been registered.') 15 | }, 16 | cached () { 17 | console.log('Content has been cached for offline use.') 18 | }, 19 | updatefound () { 20 | console.log('New content is downloading.') 21 | }, 22 | updated () { 23 | console.log('New content is available; please refresh.') 24 | }, 25 | offline () { 26 | console.log('No internet connection found. App is running in offline mode.') 27 | }, 28 | error (error) { 29 | console.error('Error during service worker registration:', error) 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/router/auth.guard.js: -------------------------------------------------------------------------------- 1 | import { Auth } from 'aws-amplify'; 2 | 3 | export default async (to, from, next) => { 4 | const isProtected = to.matched.some(route => route.meta.protected); 5 | const loggedIn = await Auth.currentUserInfo(); 6 | if (isProtected && !loggedIn) { 7 | next("/"); 8 | return; 9 | } 10 | next(); 11 | } -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | import Root from '../views/Root.vue' 4 | import AuthMiddleware from './auth.guard' 5 | 6 | // avoid NavigationDuplicated 7 | // https://github.com/vuejs/vue-router/issues/2881#issuecomment-520554378 8 | const originalPush = VueRouter.prototype.push 9 | VueRouter.prototype.push = function push(location, onResolve, onReject) { 10 | if (onResolve || onReject) 11 | return originalPush.call(this, location, onResolve, onReject) 12 | return originalPush.call(this, location).catch((err) => { 13 | if (VueRouter.isNavigationFailure(err)) { 14 | // resolve err 15 | return err 16 | } 17 | // rethrow error 18 | return Promise.reject(err) 19 | }) 20 | } 21 | 22 | Vue.use(VueRouter) 23 | 24 | const routes = [ 25 | { 26 | path: '/', 27 | name: 'Root', 28 | component: Root 29 | }, 30 | { 31 | path: '/login', 32 | name: 'LogIn', 33 | // route level code-splitting 34 | // this generates a separate chunk (about.[hash].js) for this route 35 | // which is lazy-loaded when the route is visited. 36 | component: () => import(/* webpackChunkName: "login" */ '../views/LogIn.vue') 37 | }, 38 | { 39 | path: '/home', 40 | name: 'Home', 41 | // route level code-splitting 42 | // this generates a separate chunk (about.[hash].js) for this route 43 | // which is lazy-loaded when the route is visited. 44 | component: () => import(/* webpackChunkName: "home" */ '../views/Home.vue'), 45 | meta: { protected: true } 46 | }, 47 | { 48 | path: '/search', 49 | name: 'Search', 50 | // route level code-splitting 51 | // this generates a separate chunk (about.[hash].js) for this route 52 | // which is lazy-loaded when the route is visited. 53 | component: () => import(/* webpackChunkName: "search" */ '../views/Search.vue'), 54 | meta: { protected: true } 55 | }, 56 | { 57 | path: '/hashtag', 58 | name: 'Hashtag', 59 | // route level code-splitting 60 | // this generates a separate chunk (about.[hash].js) for this route 61 | // which is lazy-loaded when the route is visited. 62 | component: () => import(/* webpackChunkName: "hashtag" */ '../views/Hashtag.vue'), 63 | meta: { protected: true } 64 | }, 65 | { 66 | path: '/notifications', 67 | name: 'Notifications', 68 | // route level code-splitting 69 | // this generates a separate chunk (about.[hash].js) for this route 70 | // which is lazy-loaded when the route is visited. 71 | component: () => import(/* webpackChunkName: "notifications" */ '../views/Notifications.vue'), 72 | meta: { protected: true } 73 | }, 74 | { 75 | path: '/messages', 76 | name: 'Messages', 77 | // route level code-splitting 78 | // this generates a separate chunk (about.[hash].js) for this route 79 | // which is lazy-loaded when the route is visited. 80 | component: () => import(/* webpackChunkName: "messages" */ '../views/Messages.vue'), 81 | props: true, 82 | meta: { protected: true } 83 | }, 84 | { 85 | path: '/:screenName', 86 | name: 'Profile', 87 | // route level code-splitting 88 | // this generates a separate chunk (about.[hash].js) for this route 89 | // which is lazy-loaded when the route is visited. 90 | component: () => import(/* webpackChunkName: "profile" */ '../views/Profile.vue'), 91 | meta: { protected: true } 92 | }, 93 | { 94 | path: '/:screenName/followers', 95 | name: 'Followers', 96 | // route level code-splitting 97 | // this generates a separate chunk (about.[hash].js) for this route 98 | // which is lazy-loaded when the route is visited. 99 | component: () => import(/* webpackChunkName: "followers" */ '../views/Followers.vue'), 100 | props: true, 101 | meta: { protected: true } 102 | }, 103 | { 104 | path: '/:screenName/following', 105 | name: 'Following', 106 | // route level code-splitting 107 | // this generates a separate chunk (about.[hash].js) for this route 108 | // which is lazy-loaded when the route is visited. 109 | component: () => import(/* webpackChunkName: "following" */ '../views/Following.vue'), 110 | props: true, 111 | meta: { protected: true } 112 | }, 113 | ] 114 | 115 | const router = new VueRouter({ 116 | routes 117 | }) 118 | 119 | router.beforeEach(AuthMiddleware) 120 | 121 | export default router 122 | -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | workbox.core.setCacheNameDetails({ prefix: "appsyncmasterclass-frontend" }); 2 | 3 | workbox.core.skipWaiting() 4 | workbox.core.clientsClaim() 5 | 6 | const cacheFiles = [ 7 | { 8 | "revision": "931caf57a56b47ef400c", 9 | "url": "/img/icons/android-chrome-192x192.png" 10 | }, 11 | { 12 | "revision": "81b4ff3d9ff09591cc33", 13 | "url": "/img/icons/favicon-16x16.png" 14 | }, 15 | { 16 | "revision": "29372bb30481defdc33d", 17 | "url": "/img/icons/favicon-32x32.png" 18 | }, 19 | { 20 | "revision": "e653ab4d124bf16b5232", 21 | "url": "https://ka-f.fontawesome.com/releases/v5.15.4/css/free.min.css?token=11146ec54d" 22 | }, 23 | { 24 | "revision": "e653ab4d124bf16b5232", 25 | "url": "https://ka-f.fontawesome.com/releases/v5.15.4/css/free-v4-shims.min.css?token=11146ec54d" 26 | }, 27 | { 28 | "revision": "e653ab4d124bf16b5232", 29 | "url": "https://ka-f.fontawesome.com/releases/v5.15.4/css/free-v4-font-face.min.css?token=11146ec54d" 30 | }, 31 | { 32 | "revision": "66b946627566f0b6fa10", 33 | "url": "service-worker.js" 34 | } 35 | ] 36 | 37 | self.__precacheManifest = cacheFiles.concat(self.__precacheManifest || []) 38 | workbox.precaching.precacheAndRoute(self.__precacheManifest, {}) -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import authentication from './modules/authentication'; 4 | import signup from './modules/signup'; 5 | import twitter from './modules/twitter'; 6 | import profilePage from './modules/twitter'; 7 | 8 | Vue.use(Vuex); 9 | 10 | export default new Vuex.Store({ 11 | modules: { 12 | authentication, 13 | signup, 14 | twitter, 15 | profilePage 16 | } 17 | }) -------------------------------------------------------------------------------- /src/store/modules/authentication/actions.js: -------------------------------------------------------------------------------- 1 | import { Auth } from 'aws-amplify' 2 | import AWS from 'aws-sdk' 3 | import router from '../../../router' 4 | 5 | AWS.config.region = process.env.VUE_APP_AUTH_REGION; 6 | const IDENTITY_POOL_ID = process.env.VUE_APP_AUTH_IDENTITY_POOL_ID; 7 | const STREAM_NAME = process.env.VUE_APP_AWS_KINESIS_FIREHOSE_DELIVERY_STREAM_NAME; 8 | 9 | AWS.config.credentials = new AWS.CognitoIdentityCredentials({ 10 | IdentityPoolId: IDENTITY_POOL_ID 11 | }) 12 | 13 | const FirehoseClient = new AWS.Firehose(); 14 | 15 | export default { 16 | loginUser({ commit }, user) { 17 | commit("USER_LOGIN", user); 18 | }, 19 | async logoutUser({ commit, dispatch }) { 20 | await Auth.signOut({ 21 | global: true 22 | }) 23 | commit("USER_LOGOUT"); 24 | dispatch("signup/setSignupStep", '', { root: true }); 25 | dispatch("twitter/unsubscribeNotifications", null, { root: true }); 26 | dispatch("twitter/resetState", null, { root: true }); 27 | dispatch("profilePage/resetState", null, { root: true }); 28 | router.push('/') 29 | }, 30 | async signUp({ commit }, form) { 31 | const user = await Auth.signUp({ 32 | username: form.username, 33 | password: form.password, 34 | attributes: { 35 | name: form.name, 36 | } 37 | }); 38 | commit("USER_SIGNUP", user); 39 | }, 40 | async confirmSignUp(_, form) { 41 | await Auth.confirmSignUp(form.email, form.verificationCode) 42 | }, 43 | async resendSignUp(_, form) { 44 | await Auth.resendSignUp(form.email); 45 | }, 46 | 47 | async signInUser({ dispatch }, form) { 48 | const user = await Auth.signIn(form.email, form.password); 49 | await dispatch("loginUser", user); 50 | await dispatch("twitter/setProfile", null, { root: true }); 51 | await dispatch("twitter/subscribeNotifications", null, { root: true }); 52 | router.push({ name: 'Home' }); 53 | }, 54 | 55 | async loginUserIfAlreadyAuthenticated({ dispatch }) { 56 | const user = await Auth.currentUserInfo(); 57 | if (user) { 58 | console.log('user is logged in already') 59 | await dispatch("loginUser", user); 60 | await dispatch("twitter/setProfile", null, { root: true }); 61 | await dispatch("twitter/subscribeNotifications", null, { root: true }); 62 | } 63 | }, 64 | 65 | async trackEvent(_, event) { 66 | const response = await FirehoseClient.putRecord({ 67 | DeliveryStreamName: STREAM_NAME, 68 | Record: { 69 | Data: JSON.stringify(event) 70 | } 71 | }).promise(); 72 | console.log(response); 73 | } 74 | }; -------------------------------------------------------------------------------- /src/store/modules/authentication/getters.js: -------------------------------------------------------------------------------- 1 | export default { 2 | loggedIn: state => state.loggedIn, 3 | user: state => state.user 4 | } -------------------------------------------------------------------------------- /src/store/modules/authentication/index.js: -------------------------------------------------------------------------------- 1 | import actions from './actions' 2 | import mutations from './mutations' 3 | import getters from './getters' 4 | 5 | const state = () => ({ 6 | loggedIn: false, 7 | user: undefined, 8 | }); 9 | 10 | export default { 11 | namespaced: true, 12 | actions, 13 | mutations, 14 | getters, 15 | state, 16 | } -------------------------------------------------------------------------------- /src/store/modules/authentication/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | USER_LOGIN(state, user) { 3 | state.user = user; 4 | state.loggedIn = true; 5 | }, 6 | USER_LOGOUT(state) { 7 | state.loggedIn = false; 8 | state.user = undefined; 9 | }, 10 | USER_SIGNUP(state, user) { 11 | state.user = user; 12 | } 13 | } -------------------------------------------------------------------------------- /src/store/modules/signup/actions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setSignupStep({ commit }, step) { 3 | commit("SIGNUP_STEP_SET", step); 4 | }, 5 | } -------------------------------------------------------------------------------- /src/store/modules/signup/getters.js: -------------------------------------------------------------------------------- 1 | export default { 2 | showModal: state => state.step, 3 | } -------------------------------------------------------------------------------- /src/store/modules/signup/index.js: -------------------------------------------------------------------------------- 1 | import actions from './actions'; 2 | import mutations from './mutations'; 3 | import getters from './getters'; 4 | 5 | const state = () => ({ 6 | step: '' 7 | }); 8 | 9 | export default { 10 | namespaced: true, 11 | actions, 12 | mutations, 13 | getters, 14 | state, 15 | } -------------------------------------------------------------------------------- /src/store/modules/signup/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | SIGNUP_STEP_SET(state, step) { 3 | state.step = step; 4 | }, 5 | } -------------------------------------------------------------------------------- /src/store/modules/twitter/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | getMyProfile, getProfileByScreenName, getImageUploadUrl, editMyProfile, 3 | getMyTimeline, tweet, getTweets, 4 | like, unlike, retweet, unretweet, reply, 5 | follow, unfollow, 6 | getFollowers, getFollowing, 7 | search, 8 | getHashTag, 9 | getOnNotifiedSubscription, 10 | listConversations, 11 | getDirectMessages, 12 | sendDirectMessage 13 | } from '../../../lib/backend' 14 | 15 | export default { 16 | async setProfile({ commit }) { 17 | const profile = await getMyProfile(); 18 | commit("PROFILE_SET", profile); 19 | }, 20 | 21 | async loadProfile({ commit, rootState }, screenName) { 22 | if (!screenName) return; 23 | if (rootState.twitter.profile.screenName == screenName) { 24 | const profile = await getMyProfile(); 25 | commit("PROFILE_SET", profile); 26 | } else { 27 | const profile = await getProfileByScreenName(screenName); 28 | commit("PROFILE_SET", profile); 29 | } 30 | }, 31 | 32 | async loadMyTimeline({ dispatch }) { 33 | await dispatch("getMyTimeline", 10); 34 | }, 35 | 36 | async loadTweets({ dispatch, rootState }, screenName) { 37 | if (!screenName) return; 38 | 39 | if (rootState.twitter.profile.screenName == screenName) { 40 | await dispatch("getTweets", { userId: rootState.twitter.profile.id, limit: 10 }); 41 | } else { 42 | const profile = await getProfileByScreenName(screenName); 43 | await dispatch("getTweets", { userId: profile.id, limit: 10 }); 44 | } 45 | }, 46 | 47 | async getMyTimeline({ commit }, limit) { 48 | const timeline = await getMyTimeline(limit); 49 | commit("TWITTER_TIMELINE", timeline); 50 | }, 51 | async createTweet({ commit, dispatch }, { text }) { 52 | const newTweet = await tweet(text); 53 | commit("TWITTER_CREATE", newTweet); 54 | await dispatch("getMyTimeline", 10); 55 | }, 56 | 57 | async getTweets({ commit }, { userId, limit, nextToken }) { 58 | const tweets = await getTweets(userId, limit, nextToken); 59 | commit("TWITTER_TIMELINE", tweets); 60 | }, 61 | 62 | async likeTweet(_, tweetId) { 63 | await like(tweetId); 64 | }, 65 | async unlikeTweet(_, tweetId) { 66 | await unlike(tweetId); 67 | }, 68 | async retweetTweet(_, tweetId) { 69 | await retweet(tweetId); 70 | }, 71 | async unretweetTweet(_, tweetId) { 72 | await unretweet(tweetId); 73 | }, 74 | async replyTweet({ dispatch }, { tweetId, text }) { 75 | await reply(tweetId, text); 76 | await dispatch("getMyTimeline", 10); 77 | }, 78 | 79 | async getImageUploadUrl(_, { extension, contentType }) { 80 | return await getImageUploadUrl(extension, contentType); 81 | }, 82 | async editMyProfile({ commit }, newProfile) { 83 | const profile = await editMyProfile(newProfile); 84 | commit("PROFILE_SET", profile); 85 | return profile; 86 | }, 87 | 88 | async followUser(_, profileId) { 89 | await follow(profileId); 90 | }, 91 | async unfollowUser(_, profileId) { 92 | await unfollow(profileId); 93 | }, 94 | 95 | async getFollowers({ commit }, { userId, limit }) { 96 | const followers = await getFollowers(userId, limit); 97 | commit("TWITTER_FOLLOWERS", followers); 98 | }, 99 | async getFollowing({ commit }, { userId, limit }) { 100 | const following = await getFollowing(userId, limit); 101 | commit("TWITTER_FOLLOWING", following); 102 | }, 103 | 104 | async loadMoreTweets({ commit, getters }, limit) { 105 | if (!getters.nextTokenTweets) return; 106 | const tweets = await getTweets(getters.profile.id, limit, getters.nextTokenTweets); 107 | commit("TWITTER_LOADMORE_TWEETS", tweets); 108 | }, 109 | async loadMoreMyTimeline({ commit, getters }, limit) { 110 | if (!getters.nextTokenTweets) return; 111 | const timeline = await getMyTimeline(limit, getters.nextTokenTweets); 112 | commit("TWITTER_LOADMORE_TWEETS", timeline); 113 | }, 114 | 115 | async loadSearch({ commit }, { query, mode, limit }) { 116 | const searchResults = await search(query, mode, limit); 117 | commit("TWITTER_SEARCH", searchResults); 118 | }, 119 | async loadMoreSearch({ commit, getters }, { query, mode, limit }) { 120 | if (!getters.nextTokenSearch) return; 121 | const searchResults = await search(query, mode, limit, getters.nextTokenSearch); 122 | commit("TWITTER_LOADMORE_SEARCH", searchResults); 123 | }, 124 | resetSearch({ commit }) { 125 | const searchResults = { 126 | results: [], 127 | nextToken: undefined, 128 | } 129 | commit("TWITTER_SEARCH", searchResults); 130 | }, 131 | 132 | async loadSearchHashTag({ commit }, { query, mode, limit }) { 133 | const q = query || ' '; // mandatory field 134 | const searchResults = await getHashTag(q, mode, limit); 135 | commit("TWITTER_SEARCH_HASHTAG", searchResults); 136 | }, 137 | async loadMoreSearchHashTag({ commit, getters }, { query, mode, limit }) { 138 | if (!getters.nextTokenSearch) return; 139 | const q = query || ' '; // mandatory field 140 | const searchResults = await getHashTag(q, mode, limit, getters.nextTokenSearch); 141 | commit("TWITTER_LOADMORE_SEARCH_HASHTAG", searchResults); 142 | }, 143 | resetSearchHashTag({ commit }) { 144 | const searchResults = { 145 | results: [], 146 | nextToken: undefined, 147 | } 148 | commit("TWITTER_SEARCH_HASHTAG", searchResults); 149 | }, 150 | 151 | async subscribeNotifications({ commit, getters, dispatch }) { 152 | if (!getters.profile.id || getters.subscription) return; 153 | const isFromActiveConversation = (userId, notification, activeConversation) => { 154 | const conversationId = userId < notification.otherUserId 155 | ? `${userId}_${notification.otherUserId}` 156 | : `${notification.otherUserId}_${userId}` 157 | return activeConversation && activeConversation.id == conversationId; 158 | } 159 | 160 | const userId = getters.profile.id; 161 | const subscription = getOnNotifiedSubscription(userId).subscribe({ 162 | next: async ({ value }) => { 163 | const notification = value.data.onNotified; 164 | if (notification.type == 'DMed') { 165 | await dispatch("loadConversations", 10); 166 | // only load messages if they are from the active conversation 167 | if (isFromActiveConversation(userId, notification, getters.conversation)) { 168 | await dispatch("getDirectMessages", { 169 | limit: 10, 170 | message: notification.message, 171 | otherUserId: notification.otherUserId, 172 | }); 173 | } 174 | commit("TWITTER_MESSAGES_NEW", notification); 175 | } else { 176 | await dispatch("getMyTimeline", 10); //cheeky update to see latest data 177 | commit("TWITTER_NOTIFICATIONS_NEW", notification); 178 | } 179 | }, 180 | }); 181 | commit("TWITTER_NOTIFICATIONS_SUBSCRIBE", subscription); 182 | }, 183 | 184 | resetNotifications({ commit }) { 185 | commit("TWITTER_NOTIFICATIONS_RESET"); 186 | }, 187 | unsubscribeNotifications({ commit }) { 188 | commit("TWITTER_NOTIFICATIONS_UNSUBSCRIBE"); 189 | }, 190 | 191 | async loadConversations({ commit }, limit) { 192 | const conversations = await listConversations(limit); 193 | commit("TWITTER_CONVERSATIONS_LOAD", conversations); 194 | }, 195 | resetMessages({ commit }) { 196 | commit("TWITTER_MESSAGES_RESET"); 197 | }, 198 | async getDirectMessages({ commit }, { otherUserId, limit, nextToken }) { 199 | const messages = await getDirectMessages(otherUserId, limit, nextToken); 200 | commit("TWITTER_MESSAGES_LOAD", messages); 201 | }, 202 | async loadMoreDirectMessages({ commit, getters }, { otherUserId, limit }) { 203 | if (!getters.nextTokenMessages) return; 204 | const messages = await getDirectMessages(otherUserId, limit, getters.nextTokenMessages); 205 | commit("TWITTER_LOADMORE_MESSAGES", messages); 206 | }, 207 | async sendDirectMessage({ commit, dispatch }, { message, otherUserId }) { 208 | await sendDirectMessage(message, otherUserId); 209 | await dispatch("loadConversations", 10); 210 | const messages = await getDirectMessages(otherUserId, 10); 211 | commit("TWITTER_MESSAGES_LOAD", messages); 212 | }, 213 | setActiveConversation({ commit }, conversation) { 214 | commit("TWITTER_CONVERSATION_ACTIVE_SET", conversation); 215 | }, 216 | 217 | resetState({ commit }) { 218 | commit("TWITTER_RESET_STATE"); 219 | }, 220 | }; -------------------------------------------------------------------------------- /src/store/modules/twitter/getters.js: -------------------------------------------------------------------------------- 1 | import Moment from 'moment'; 2 | 3 | export default { 4 | profile: state => state.profile, 5 | tweets: state => state.tweets.tweets, 6 | nextTokenTweets: state => state.tweets.nextToken, 7 | joinedDate: state => Moment(state.profile.createdAt).format('MMMM YYYY'), 8 | isSelf: state => screenName => state.profile.screenName == screenName, 9 | followers: state => state.followers.followers, 10 | nextTokenFollowers: state => state.followers.nextToken, 11 | following: state => state.following.following, 12 | nextTokenFollowing: state => state.following.nextToken, 13 | search: state => state.search.results, 14 | nextTokenSearch: state => state.search.nextToken, 15 | searcHashTag: state => state.search.results, 16 | nextTokenSearchHashTag: state => state.search.nextToken, 17 | all: state => state.notifications.all, 18 | mentions: state => state.notifications.mentions, 19 | newNotifications: state => state.notifications.newNotifications, 20 | subscription: state => state.notifications.subscription, 21 | 22 | newMessages: state => state.notifications.messages.newMessages, 23 | conversationsSet: state => state.notifications.messages.conversationsSet, 24 | hasNewMessages: state => conversation => { 25 | const set = state.notifications.messages.conversationsSet; 26 | const active = state.notifications.messages.active.conversation; 27 | return set.has(conversation.id) && ( 28 | (active && conversation.id != active.id) || !active 29 | ) 30 | }, 31 | conversations: state => state.notifications.messages.conversations, 32 | nextTokenConversations: state => state.notifications.messages.conversations.nextToken, 33 | messages: state => state.notifications.messages.active.messages, 34 | nextTokenMessages: state => state.notifications.messages.active.nextTokenMessages, 35 | conversation: state => state.notifications.messages.active.conversation, 36 | }; -------------------------------------------------------------------------------- /src/store/modules/twitter/index.js: -------------------------------------------------------------------------------- 1 | import actions from './actions'; 2 | import mutations from './mutations'; 3 | import getters from './getters'; 4 | 5 | const initialState = { 6 | profile: { 7 | id: '', 8 | createdAt: '1970-01-01', 9 | imageUrl: 'default_profile.png', 10 | }, 11 | tweets: { 12 | tweets: [], 13 | nextToken: undefined, 14 | }, 15 | followers: { 16 | followers: [], 17 | nextToken: undefined, 18 | }, 19 | following: { 20 | following: [], 21 | nextToken: undefined, 22 | }, 23 | search: { 24 | results: [], 25 | nextToken: undefined, 26 | }, 27 | notifications: { 28 | all: [], 29 | mentions: [], 30 | newNotifications: 0, 31 | subscription: undefined, 32 | messages: { 33 | conversations: [], 34 | nextToken: undefined, 35 | newMessages: 0, 36 | conversationsSet: new Set(), 37 | active: { 38 | conversation: undefined, 39 | messages: [], 40 | nextTokenMessages: undefined, 41 | }, 42 | }, 43 | }, 44 | }; 45 | const state = () => ({...initialState}); 46 | 47 | export default { 48 | namespaced: true, 49 | actions, 50 | mutations, 51 | getters, 52 | state, 53 | } -------------------------------------------------------------------------------- /src/store/modules/twitter/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | PROFILE_SET(state, profile) { 3 | state.profile = profile; 4 | }, 5 | 6 | TWITTER_TIMELINE(state, { tweets, nextToken }) { 7 | state.tweets.tweets = tweets; 8 | state.tweets.nextToken = nextToken; 9 | }, 10 | TWITTER_CREATE(state, newTweet) { 11 | const tweets = [...state.tweets.tweets]; 12 | state.tweets.tweets = tweets.unshift(newTweet); 13 | }, 14 | 15 | TWITTER_FOLLOWERS(state, { profiles, nextToken }) { 16 | state.followers.followers = profiles; 17 | state.followers.nextToken = nextToken; 18 | }, 19 | TWITTER_FOLLOWING(state, { profiles, nextToken }) { 20 | state.following.following = profiles; 21 | state.following.nextToken = nextToken; 22 | }, 23 | 24 | TWITTER_LOADMORE_TWEETS(state, { tweets, nextToken }) { 25 | state.tweets.tweets = [...state.tweets.tweets, ...tweets]; 26 | state.tweets.nextToken = nextToken; 27 | }, 28 | 29 | TWITTER_SEARCH(state, { results, nextToken }) { 30 | state.search.results = results; 31 | state.search.nextToken = nextToken; 32 | }, 33 | TWITTER_LOADMORE_SEARCH(state, { results, nextToken }) { 34 | state.search.results = [...state.search.results, ...results]; 35 | state.search.nextToken = nextToken; 36 | }, 37 | 38 | TWITTER_SEARCH_HASHTAG(state, { results, nextToken }) { 39 | state.search.results = results; 40 | state.search.nextToken = nextToken; 41 | }, 42 | TWITTER_LOADMORE_SEARCH_HASHTAG(state, { results, nextToken }) { 43 | state.search.results = [...state.search.results, ...results]; 44 | state.search.nextToken = nextToken; 45 | }, 46 | 47 | TWITTER_NOTIFICATIONS_SUBSCRIBE(state, subscription) { 48 | state.notifications.subscription = subscription; 49 | state.notifications.newNotifications = 0; 50 | state.notifications.messages.newMessages = 0; 51 | }, 52 | TWITTER_NOTIFICATIONS_UNSUBSCRIBE(state) { 53 | let subscription = state.notifications.subscription; 54 | if (subscription) { 55 | subscription.unsubscribe(); 56 | state.notifications.subscription = undefined; 57 | } 58 | }, 59 | TWITTER_NOTIFICATIONS_NEW(state, newMessage) { 60 | if (newMessage?.type == 'Mentioned') { 61 | state.notifications.mentions = [newMessage, ...state.notifications.mentions]; // reverse add order 62 | } else { 63 | state.notifications.all = [newMessage, ...state.notifications.all]; // reverse add order 64 | } 65 | state.notifications.newNotifications = state.notifications.newNotifications + 1; 66 | }, 67 | TWITTER_NOTIFICATIONS_RESET(state) { 68 | state.notifications.newNotifications = 0; 69 | }, 70 | 71 | TWITTER_CONVERSATIONS_LOAD(state, { conversations, nextToken }) { 72 | state.notifications.messages.conversations = conversations; 73 | state.notifications.messages.nextToken = nextToken; 74 | }, 75 | TWITTER_MESSAGES_NEW(state, notification) { 76 | let set = state.notifications.messages.conversationsSet; 77 | let c = state.notifications.messages.active.conversation; 78 | const conversationId = state.profile.id < notification.otherUserId 79 | ? `${state.profile.id}_${notification.otherUserId}` 80 | : `${notification.otherUserId}_${state.profile.id}`; 81 | 82 | // if conversation is active we ignore this 83 | const notActive = c => (!c || c.id != conversationId); 84 | if (notActive(c)) { 85 | if (!set.has(conversationId)) { 86 | set.add(conversationId); 87 | } 88 | state.notifications.messages.newMessages = state.notifications.messages.newMessages + 1; 89 | // trigger getters 90 | state.notifications.messages.conversations = [...state.notifications.messages.conversations]; 91 | } 92 | }, 93 | TWITTER_MESSAGES_RESET(state) { 94 | state.notifications.messages.newMessages = 0; 95 | }, 96 | TWITTER_MESSAGES_LOAD(state, { messages, nextToken }) { 97 | state.notifications.messages.active.messages = messages; 98 | state.notifications.messages.active.nextTokenMessages = nextToken; 99 | }, 100 | TWITTER_LOADMORE_MESSAGES(state, { messages, nextToken }) { 101 | state.notifications.messages.active.messages = [...state.notifications.messages.active.messages, ...messages]; 102 | state.notifications.messages.active.nextTokenMessages = nextToken; 103 | }, 104 | TWITTER_MESSAGES_SEND(state, newMessage) { 105 | state.notifications.messages.active.messages = [...state.notifications.messages.active.messages, newMessage]; 106 | }, 107 | TWITTER_CONVERSATION_ACTIVE_SET(state, conversation) { 108 | let set = state.notifications.messages.conversationsSet; 109 | if (set && conversation && set.has(conversation.id)) { 110 | set.delete(conversation.id); 111 | // we had a hit enough to clear all notifications in this implementation 112 | // we still can show there's messages for other conversations with new messages just not how many messages 113 | state.notifications.messages.newMessages = 0; 114 | } 115 | state.notifications.messages.conversationsSet = set; 116 | state.notifications.messages.active.conversation = conversation; 117 | state.notifications.messages.active.messages = []; 118 | state.notifications.messages.active.nextTokenMessages = undefined; 119 | }, 120 | 121 | TWITTER_RESET_STATE(state) { 122 | Object.assign(state, { 123 | profile: { 124 | id: '', 125 | createdAt: '1970-01-01', 126 | imageUrl: 'default_profile.png', 127 | }, 128 | tweets: { 129 | tweets: [], 130 | nextToken: undefined, 131 | }, 132 | followers: { 133 | followers: [], 134 | nextToken: undefined, 135 | }, 136 | following: { 137 | following: [], 138 | nextToken: undefined, 139 | }, 140 | search: { 141 | results: [], 142 | nextToken: undefined, 143 | }, 144 | notifications: { 145 | all: [], 146 | mentions: [], 147 | newNotifications: 0, 148 | subscription: undefined, 149 | messages: { 150 | conversations: [], 151 | nextToken: undefined, 152 | newMessages: 0, 153 | conversationsSet: new Set(), 154 | active: { 155 | conversation: undefined, 156 | messages: [], 157 | nextTokenMessages: undefined, 158 | }, 159 | }, 160 | }, 161 | }); 162 | } 163 | }; -------------------------------------------------------------------------------- /src/views/Followers.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | -------------------------------------------------------------------------------- /src/views/Following.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | -------------------------------------------------------------------------------- /src/views/Hashtag.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | -------------------------------------------------------------------------------- /src/views/LogIn.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /src/views/Messages.vue: -------------------------------------------------------------------------------- 1 | 151 | 152 | -------------------------------------------------------------------------------- /src/views/Notifications.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | -------------------------------------------------------------------------------- /src/views/Profile.vue: -------------------------------------------------------------------------------- 1 | 143 | 144 | -------------------------------------------------------------------------------- /src/views/Root.vue: -------------------------------------------------------------------------------- 1 | 196 | 197 | -------------------------------------------------------------------------------- /src/views/Search.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | 3 | module.exports = { 4 | purge: { content: ['./public/**/*.html', './src/**/*.vue'] }, 5 | darkMode: false, // or 'media' or 'class' 6 | theme: { 7 | container: { 8 | center: true 9 | }, 10 | maxHeight: { 11 | 'full': '85%', 12 | }, 13 | extend: { 14 | fontFamily: { 15 | sans: [...defaultTheme.fontFamily.sans] 16 | }, 17 | colors: { 18 | 'blue': '#1DA1F2', 19 | 'darkblue': '#2795D9', 20 | 'lightblue': '#EFF9FF', 21 | 'dark': '#657786', 22 | 'light': '#AAB8C2', 23 | 'lighter': '#E1E8ED', 24 | 'lightest': '#F5F8FA', 25 | } 26 | }, 27 | screens: { 28 | 'sm': '360px', 29 | // => @media (min-width: 360px) { ... } 30 | 31 | 'md': '768px', 32 | // => @media (min-width: 768px) { ... } 33 | 34 | 'lg': '1024px', 35 | // => @media (min-width: 1024px) { ... } 36 | 37 | 'xl': '1280px', 38 | // => @media (min-width: 1280px) { ... } 39 | 40 | '2xl': '1536px', 41 | // => @media (min-width: 1536px) { ... } 42 | }, 43 | }, 44 | variants: { 45 | extend: {}, 46 | }, 47 | plugins: [], 48 | } -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const manifest = require('./public/manifest.json') 2 | 3 | module.exports = { 4 | pwa: { 5 | name: manifest.short_name, 6 | themeColor: manifest.theme_color, 7 | msTileColor: manifest.background_color, 8 | appleMobileWebAppCapable: 'yes', 9 | appleMobileWebAppStatusBarStyle: 'black', 10 | workboxPluginMode: 'InjectManifest', 11 | workboxOptions: { 12 | swSrc: 'src/service-worker.js', 13 | } 14 | } 15 | } --------------------------------------------------------------------------------