├── .editconfig ├── .gitignore ├── LICENSE ├── README.md ├── ionic.config.json ├── ionic_copy.js ├── logo └── logo.sketch ├── package.json ├── sass.js ├── scripts ├── build.sh ├── buildAndRun.sh ├── dependencyReport.sh ├── fileSize.js └── serve.sh ├── server ├── .gitignore ├── config.js ├── index.js ├── package.json └── yarn.lock ├── src ├── actions │ ├── auth.ts │ ├── feed.ts │ ├── index.ts │ ├── mentions.ts │ ├── search.ts │ ├── trends.ts │ ├── tweet.ts │ ├── userLikes.ts │ ├── userTweets.ts │ └── users.ts ├── app │ ├── app.component.ts │ ├── app.html │ ├── app.module.ts │ ├── app.scss │ ├── http.wrapper.ts │ ├── main.ts │ └── shared.module.ts ├── assets │ └── icon │ │ ├── app_144.png │ │ ├── app_168.png │ │ ├── app_192.png │ │ ├── app_48.png │ │ ├── app_512.png │ │ ├── app_72.png │ │ ├── app_96.png │ │ └── favicon.ico ├── components │ ├── avatar-toolbar │ │ ├── avatar-toolbar.html │ │ ├── avatar-toolbar.scss │ │ └── avatar-toolbar.ts │ ├── avatar │ │ ├── avatar.html │ │ ├── avatar.scss │ │ └── avatar.ts │ ├── cover │ │ ├── cover.html │ │ ├── cover.scss │ │ └── cover.ts │ ├── feed │ │ ├── feed.html │ │ ├── feed.scss │ │ └── feed.ts │ ├── media │ │ ├── media.html │ │ ├── media.scss │ │ └── media.ts │ ├── menu │ │ ├── menu.html │ │ ├── menu.module.ts │ │ ├── menu.scss │ │ └── menu.ts │ ├── message-fab │ │ ├── message-fab.html │ │ ├── message-fab.scss │ │ └── message-fab.ts │ ├── og │ │ ├── og.html │ │ ├── og.scss │ │ └── og.ts │ ├── profile-header │ │ ├── profile-header.html │ │ ├── profile-header.scss │ │ └── profile-header.ts │ ├── spinner │ │ ├── spinner.html │ │ ├── spinner.scss │ │ └── spinner.ts │ ├── trendingHashtags │ │ ├── trendingHashtags.html │ │ ├── trendingHashtags.scss │ │ └── trendingHashtags.ts │ ├── tweet-fab │ │ ├── tweet-fab.html │ │ ├── tweet-fab.scss │ │ └── tweet-fab.ts │ ├── tweet-text │ │ ├── tweet-text.html │ │ ├── tweet-text.scss │ │ └── tweet-text.ts │ └── tweet │ │ ├── tweet.html │ │ ├── tweet.scss │ │ └── tweet.ts ├── declarations.d.ts ├── decorators │ └── index.ts ├── effects │ └── auth.ts ├── index.html ├── manifest.json ├── ngsw-manifest.json ├── pages │ ├── feed │ │ ├── feed.html │ │ ├── feed.module.ts │ │ ├── feed.scss │ │ └── feed.ts │ ├── home │ │ ├── home.html │ │ ├── home.module.ts │ │ ├── home.scss │ │ └── home.ts │ ├── login │ │ ├── login.html │ │ ├── login.module.ts │ │ ├── login.scss │ │ └── login.ts │ ├── mentions │ │ ├── mentions.html │ │ ├── mentions.module.ts │ │ ├── mentions.scss │ │ └── mentions.ts │ ├── messages │ │ ├── messages.html │ │ ├── messages.module.ts │ │ ├── messages.scss │ │ └── messages.ts │ ├── notifications │ │ ├── notifications.html │ │ ├── notifications.module.ts │ │ ├── notifications.scss │ │ └── notifications.ts │ ├── profile │ │ ├── profile.html │ │ ├── profile.module.ts │ │ ├── profile.scss │ │ └── profile.ts │ ├── search-tab │ │ ├── search-tab.html │ │ ├── search-tab.module.ts │ │ ├── search-tab.scss │ │ └── search-tab.ts │ ├── search │ │ ├── search.html │ │ ├── search.module.ts │ │ ├── search.scss │ │ └── search.ts │ ├── tweet-details │ │ ├── tweet-details.html │ │ ├── tweet-details.module.ts │ │ ├── tweet-details.scss │ │ └── tweet-details.ts │ └── tweet │ │ ├── tweet.html │ │ ├── tweet.module.ts │ │ ├── tweet.scss │ │ ├── tweet.ts │ │ └── tweetValidator.ts ├── providers │ ├── auth │ │ └── auth.ts │ ├── feed │ │ └── feed.ts │ ├── index.ts │ ├── mentions │ │ └── mentions.ts │ ├── search │ │ └── search.ts │ ├── service-worker │ │ └── service-worker.ts │ ├── storage │ │ └── storage.ts │ ├── trends │ │ └── trends.ts │ ├── tweet │ │ └── tweet.ts │ ├── twitter │ │ └── twitter.ts │ ├── user-likes │ │ └── user-likes.ts │ ├── user-tweets │ │ └── user-tweets.ts │ └── users │ │ └── users.ts ├── reducers │ ├── auth.ts │ ├── feed.ts │ ├── index.ts │ ├── mentions.ts │ ├── search.ts │ ├── trends.ts │ ├── tweets.ts │ ├── userLikes.ts │ ├── userTweets.ts │ └── users.ts ├── store.ts └── theme │ ├── _keyframes.scss │ ├── _mixins.scss │ ├── ionicons-icons.scss │ └── variables.scss ├── tsconfig.json ├── tslint.json ├── webpack.js └── yarn.lock /.editconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifies intentionally untracked files to ignore when using Git 2 | # http://git-scm.com/docs/gitignore 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | *.log 7 | *.tmp 8 | *.tmp.* 9 | log.txt 10 | *.sublime-project 11 | *.sublime-workspace 12 | .vscode/ 13 | npm-debug.log* 14 | 15 | .idea/ 16 | .sass-cache/ 17 | .tmp/ 18 | .versions/ 19 | coverage/ 20 | dist/ 21 | node_modules/ 22 | tmp/ 23 | temp/ 24 | hooks/ 25 | resources/ 26 | platforms/ 27 | plugins/ 28 | plugins/android.json 29 | plugins/ios.json 30 | www/ 31 | build/ 32 | $RECYCLE.BIN/ 33 | 34 | .DS_Store 35 | Thumbs.db 36 | UserInterfaceState.xcuserstate 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Julien Renaux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Tweet, Like, Retweet and Reply 2 | ![tweet](https://user-images.githubusercontent.com/1388706/27761023-b94622f6-5e54-11e7-8c45-fa6aeb56c728.gif) 3 | ![like_retweet_reply](https://user-images.githubusercontent.com/1388706/27760951-8b3685be-5e53-11e7-9044-224042fa16c2.gif) 4 | 5 | ## Open tweets, retweets, Profile page 6 | ![expand_tweets](https://user-images.githubusercontent.com/1388706/27760952-8b527b02-5e53-11e7-8195-e96b77be66e0.gif) 7 | ![profile_sticky_segment](https://user-images.githubusercontent.com/1388706/28865595-b4504f80-7770-11e7-97a4-e38c43010b59.gif) 8 | 9 | ## Search 10 | ![search](https://user-images.githubusercontent.com/1388706/27760973-e02011b2-5e53-11e7-9a66-9aee329da8d9.gif) 11 | 12 | ## Service Worker Update 13 | ![sw_reload](https://user-images.githubusercontent.com/1388706/28865593-b4404e32-7770-11e7-8a13-b043b9d89001.gif) 14 | 15 | ## [Ngrx/store](https://github.com/ngrx/store) powered 16 | 17 | ![ngrx](https://user-images.githubusercontent.com/1388706/27774130-7005ecec-5f8b-11e7-8462-41124fa76af1.gif) 18 | 19 | ## Ionic-twitter-pwa Performance 20 | 21 | ### [lighthouse](https://chrome.google.com/webstore/detail/lighthouse/blipmdconlkpinefehnmjammfjpmpbjk?hl=en) results 22 | 23 | ![image](https://user-images.githubusercontent.com/1388706/27761320-fe9a67c2-5e59-11e7-84f2-a27882e07eb9.png) 24 | 25 | ### [testmysite](https://testmysite.withgoogle.com) results 26 | 27 | ![image](https://user-images.githubusercontent.com/1388706/27823192-98835cfe-60a8-11e7-820e-fe684f068796.png) 28 | 29 | ## mobile.twitter.com Performance 30 | 31 | ### [lighthouse](https://chrome.google.com/webstore/detail/lighthouse/blipmdconlkpinefehnmjammfjpmpbjk?hl=en) results 32 | 33 | ![image](https://user-images.githubusercontent.com/1388706/27774064-84ee196a-5f89-11e7-86a6-2aaff19701a2.png) 34 | 35 | ### [testmysite](https://testmysite.withgoogle.com) results 36 | 37 | ![image](https://user-images.githubusercontent.com/1388706/27823365-244fbfde-60a9-11e7-9a4a-fbba4bdfd4e9.png) 38 | 39 | ## Thanks 40 | 41 | @webmaxru for [his PWA logo source](https://github.com/webmaxru/progressive-web-apps-logo) 42 | 43 | ![logo](./src/assets/icon/app_96.png) 44 | -------------------------------------------------------------------------------- /ionic.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ionic-twitter-pwa", 3 | "app_id": "d9027fdc", 4 | "type": "ionic-angular" 5 | } 6 | -------------------------------------------------------------------------------- /ionic_copy.js: -------------------------------------------------------------------------------- 1 | // this is a custom dictionary to make it easy to extend/override 2 | // provide a name for an entry, it can be anything such as 'copyAssets' or 'copyFonts' 3 | // then provide an object with a `src` array of globs and a `dest` string 4 | module.exports = { 5 | copyAssets: { 6 | src: ['{{SRC}}/assets/**/*'], 7 | dest: '{{WWW}}/assets' 8 | }, 9 | copyIndexContent: { 10 | src: ['{{SRC}}/index.html', '{{SRC}}/manifest.json', /*'{{SRC}}/service-worker.js'*/], 11 | dest: '{{WWW}}' 12 | }, 13 | copyFonts: { 14 | src: ['{{ROOT}}/node_modules/ionicons/dist/fonts/**/*', '{{ROOT}}/node_modules/ionic-angular/fonts/roboto*'], 15 | dest: '{{WWW}}/assets/fonts' 16 | }, 17 | copyPolyfills: { 18 | src: ['{{ROOT}}/node_modules/ionic-angular/polyfills/polyfills.js'], 19 | dest: '{{BUILD}}' 20 | }, 21 | copySwToolbox: { 22 | src: [/*'{{ROOT}}/node_modules/sw-toolbox/sw-toolbox.js'*/], 23 | dest: '{{BUILD}}' 24 | } 25 | } -------------------------------------------------------------------------------- /logo/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/logo/logo.sketch -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ionic-twitter-pwa", 3 | "version": "0.0.1", 4 | "author": "Ionic Framework", 5 | "homepage": "http://ionicframework.com/", 6 | "private": true, 7 | "scripts": { 8 | "clean": "ionic-app-scripts clean", 9 | "lint": "ionic-app-scripts lint", 10 | "start": "./scripts/serve.sh", 11 | "build": "./scripts/build.sh", 12 | "fileSize": "./scripts/fileSize.js", 13 | "buildAndRun": "./scripts/buildAndRun.sh", 14 | "report:dependencies": "./scripts/dependencyReport.sh" 15 | }, 16 | "dependencies": { 17 | "@angular/common": "4.1.2", 18 | "@angular/compiler": "4.1.2", 19 | "@angular/compiler-cli": "4.1.2", 20 | "@angular/core": "4.1.2", 21 | "@angular/forms": "4.1.2", 22 | "@angular/http": "4.1.2", 23 | "@angular/platform-browser": "4.1.2", 24 | "@angular/platform-browser-dynamic": "4.1.2", 25 | "@angular/service-worker": "^1.0.0-beta.14", 26 | "@ionic-native/core": "^3.12.1", 27 | "@ionic-native/network": "^3.12.1", 28 | "@ionic/storage": "2.0.1", 29 | "@ngrx/core": "^1.2.0", 30 | "@ngrx/effects": "^2.0.3", 31 | "@ngrx/store": "^2.2.2", 32 | "@ngrx/store-devtools": "^3.2.4", 33 | "angularfire2": "^4.0.0-rc.0", 34 | "date-fns": "^1.28.5", 35 | "firebase": "^4.0.0", 36 | "intl-messageformat": "^1.3.0", 37 | "ionic-angular": "3.5.0", 38 | "ionicons": "3.0.0", 39 | "javascript-time-ago": "^0.4.9", 40 | "lodash": "^4.17.4", 41 | "rxjs": "5.1.1", 42 | "sw-toolbox": "3.6.0", 43 | "twitter-text": "^1.14.3", 44 | "zone.js": "0.8.10" 45 | }, 46 | "devDependencies": { 47 | "@angular/animations": "^4.1.3", 48 | "@angular/platform-server": "^4.1.3", 49 | "@angular/router": "^4.1.3", 50 | "@ionic/app-scripts": "1.3.12-201707071759", 51 | "@ionic/cli-plugin-ionic-angular": "1.3.1", 52 | "@types/node": "^7.0.22", 53 | "@types/webpack": "^2.2.15", 54 | "csv-write-stream": "^2.0.0", 55 | "http-server": "^0.10.0", 56 | "ionic": "^3.4.0", 57 | "ng-pwa-tools": "^0.0.15", 58 | "purify-css": "^1.2.5", 59 | "source-map-explorer": "^1.3.3", 60 | "ts-node": "^3.0.6", 61 | "typescript": "^2.3.4" 62 | }, 63 | "description": "An Ionic project", 64 | "config": { 65 | "ionic_webpack": "./webpack.js", 66 | "ionic_copy": "./ionic_copy.js", 67 | "ionic_sass": "./sass.js" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /sass.js: -------------------------------------------------------------------------------- 1 | 2 | // https://www.npmjs.com/package/node-sass 3 | 4 | module.exports = { 5 | 6 | /** 7 | * outputFilename: The filename of the saved CSS file 8 | * from the sass build. The directory which it is saved in 9 | * is set within the "buildDir" config options. 10 | */ 11 | outputFilename: process.env.IONIC_OUTPUT_CSS_FILE_NAME, 12 | 13 | /** 14 | * sourceMap: If source map should be built or not. 15 | */ 16 | sourceMap: false, 17 | 18 | /** 19 | * outputStyle: How node-sass should output the css file. 20 | */ 21 | outputStyle: 'expanded', 22 | 23 | /** 24 | * autoprefixer: The config options for autoprefixer. 25 | * Excluding this config will skip applying autoprefixer. 26 | * https://www.npmjs.com/package/autoprefixer 27 | */ 28 | autoprefixer: { 29 | browsers: [ 30 | 'last 2 versions', 31 | 'iOS >= 8', 32 | 'Android >= 4.4', 33 | 'Explorer >= 11', 34 | 'ExplorerMobile >= 11' 35 | ], 36 | cascade: false 37 | }, 38 | 39 | /** 40 | * includePaths: Used by node-sass for additional 41 | * paths to search for sass imports by just name. 42 | */ 43 | includePaths: [ 44 | 'node_modules/ionic-angular/themes', 45 | 'node_modules/ionicons/dist/scss', 46 | 'node_modules/ionic-angular/fonts' 47 | ], 48 | 49 | /** 50 | * includeFiles: An array of regex patterns to search for 51 | * sass files in the same directory as the component module. 52 | * If a file matches both include and exclude patterns, then 53 | * the file will be excluded. 54 | */ 55 | includeFiles: [ 56 | /\.(s(c|a)ss)$/i 57 | ], 58 | 59 | /** 60 | * excludeFiles: An array of regex patterns for files which 61 | * should be excluded. If a file matches both include and exclude 62 | * patterns, then the file will be excluded. 63 | * https://github.com/ionic-team/ionic/blob/master/src/themes/ionic.components.scss 64 | */ 65 | excludeFiles: [ 66 | /\.(wp|ios).(scss)$/i, 67 | /(action-sheet|alert|badge|card|checkbox|chip|datetime|grid|item-reorder|item-sliding|label|loading|note|picker|popover|radio|range|select|select|slides|toggle|virtual-scroll|cordova)/i, 68 | ], 69 | 70 | /** 71 | * variableSassFiles: Lists out the files which include 72 | * only sass variables. These variables are the first sass files 73 | * to be imported so their values override default variables. 74 | */ 75 | variableSassFiles: [ 76 | '{{SRC}}/theme/variables.scss' 77 | ], 78 | 79 | /** 80 | * directoryMaps: Compiled JS modules may be within a different 81 | * directory than its source file and sibling component sass files. 82 | * For example, NGC places it's files within the .tmp directory 83 | * but doesn't copy over its sass files. This is useful so sass 84 | * also checks the JavaScript's source directory for sass files. 85 | */ 86 | directoryMaps: { 87 | '{{TMP}}': '{{SRC}}' 88 | }, 89 | 90 | /** 91 | * excludeModules: Used just as a way to skip over 92 | * modules which we know wouldn't have any sass to be 93 | * bundled. "excludeModules" isn't necessary, but is a 94 | * good way to speed up build times by skipping modules. 95 | */ 96 | excludeModules: [ 97 | '@angular', 98 | 'commonjs-proxy', 99 | 'core-js', 100 | 'ionic-native', 101 | 'rxjs', 102 | 'zone.js' 103 | ] 104 | 105 | }; -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PATH=$PATH:$(npm bin) 3 | set -x 4 | 5 | BUILDFOLDER=build/ 6 | 7 | # clean up previous build 8 | rm -fr $BUILDFOLDER 9 | 10 | # Prod build 11 | ionic-app-scripts build --prod \ 12 | --wwwDir $BUILDFOLDER 13 | 14 | # remove unused css (~20% gain) 15 | purifycss $BUILDFOLDER"build/main.css" \ 16 | $BUILDFOLDER"build/*.js" \ 17 | --info \ 18 | --min \ 19 | --out $BUILDFOLDER"build/main.css" \ 20 | --whitelist ion-backdrop .bar-button-default ion-icon 21 | 22 | # ngu-app-shell --module src/app/app.module.ts 23 | 24 | # Generate our SW manifest 25 | ngu-sw-manifest --out $BUILDFOLDER"ngsw-manifest.json" \ 26 | --dist $BUILDFOLDER 27 | # --module src/app/app.module.ts \ 28 | 29 | # Copy basic SW file 30 | cp node_modules/@angular/service-worker/bundles/worker-basic.min.js $BUILDFOLDER 31 | mv $BUILDFOLDER"worker-basic.min.js" $BUILDFOLDER"worker-basic.js" -------------------------------------------------------------------------------- /scripts/buildAndRun.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PATH=$PATH:$(npm bin) 3 | set -x 4 | 5 | BUILDFOLDER=build/ 6 | 7 | # clean up previous build 8 | rm -fr $BUILDFOLDER 9 | 10 | # Prod build 11 | ionic-app-scripts build --wwwDir $BUILDFOLDER 12 | 13 | # ngu-app-shell --module src/app/app.module.ts 14 | 15 | # Generate our SW manifest 16 | ngu-sw-manifest --out $BUILDFOLDER"ngsw-manifest.json" \ 17 | --dist $BUILDFOLDER 18 | # --module src/app/app.module.ts \ 19 | 20 | # Copy basic SW file 21 | cp node_modules/@angular/service-worker/bundles/worker-basic.js $BUILDFOLDER 22 | 23 | node server/index.js & http-server -o -p 3000 $BUILDFOLDER -------------------------------------------------------------------------------- /scripts/dependencyReport.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PATH=$PATH:$(npm bin) 3 | set -x 4 | 5 | # clean up previous build 6 | rm -fr www/ 7 | 8 | ionic-app-scripts build --prod \ 9 | --generateSourceMap true 10 | 11 | source-map-explorer www/build/main.js www/build/main.js.map -------------------------------------------------------------------------------- /scripts/fileSize.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const csvWriter = require('csv-write-stream'); 5 | 6 | const BUILDFOLDER = path.join(__dirname, '..', 'build/build'); 7 | const OUTPUT = path.join(__dirname, '..', 'build/fileSize.csv'); 8 | 9 | console.log('BUILDFOLDER', BUILDFOLDER) 10 | console.log('OUTPUT', OUTPUT) 11 | 12 | const folderData = getFileInfoFromFolder(BUILDFOLDER); 13 | 14 | const headers = folderData.map(line => line.name); 15 | var writer = csvWriter({ headers }) 16 | writer.pipe(fs.createWriteStream(OUTPUT)) 17 | writer.write(folderData.map(line => line.size)) 18 | writer.end() 19 | 20 | function getFileInfoFromFolder(route) { 21 | let files = fs.readdirSync(route, 'utf8'); 22 | let response = []; 23 | let totalSize = 0; 24 | for (let name of files) { 25 | const filePath = `${BUILDFOLDER}/${name}`; 26 | const size = fs.statSync(filePath).size; 27 | totalSize += size; 28 | response.push({ name, size }); 29 | } 30 | response.push({ name: 'Total', size: totalSize }); 31 | return response; 32 | } -------------------------------------------------------------------------------- /scripts/serve.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PATH=$PATH:$(npm bin) 3 | set -x 4 | 5 | ionic-app-scripts serve & 6 | cd server/ && node ./index.js -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | serviceAccountKey.json 2 | .env -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | port: process.env.SERVER_PORT || 5000 3 | } 4 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | const serviceAccount = require("./serviceAccountKey.json"); 20 | const admin = require('firebase-admin'); 21 | const express = require('express'); 22 | const Twitter = require('twitter'); 23 | const bodyParser = require('body-parser'); 24 | const ogs = require('open-graph-scraper'); 25 | 26 | const config = require('./config'); 27 | require('dotenv').config(); 28 | 29 | const app = module.exports = express(); 30 | 31 | admin.initializeApp({ 32 | credential: admin.credential.cert(serviceAccount), 33 | databaseURL: "https://ionic-twitter-pwa.firebaseio.com" 34 | }); 35 | 36 | const allowCrossDomain = function (req, res, next) { 37 | var origin = req.headers.origin; 38 | if (origin === 'twitter-pwa.julienrenaux.fr' || origin.includes('127.0.0.1') || origin.includes('localhost')) { 39 | res.setHeader('Access-Control-Allow-Origin', origin); 40 | } 41 | res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); 42 | res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 43 | 44 | next(); 45 | } 46 | 47 | const authenticate = (req, res, next) => { 48 | if (req.method === 'OPTIONS') { 49 | var headers = {}; 50 | headers["Access-Control-Allow-Origin"] = req.headers.origin; 51 | headers["Access-Control-Allow-Methods"] = "POST, GET, PUT, DELETE, OPTIONS"; 52 | headers["Access-Control-Allow-Credentials"] = false; 53 | headers["Access-Control-Max-Age"] = '86400'; // 24 hours 54 | headers["Access-Control-Allow-Headers"] = 'Content-Type, Authorization'; 55 | res.writeHead(200, headers); 56 | res.end(); 57 | } else { 58 | if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) { 59 | res.status(403).send('Unauthorized'); 60 | return; 61 | } 62 | const [idToken, access_token_key, access_token_secret] = req.headers.authorization.split('Bearer ')[1].split(','); 63 | admin.auth().verifyIdToken(idToken).then(decodedIdToken => { 64 | req.user = decodedIdToken; 65 | req.access_token_key = access_token_key; 66 | req.access_token_secret = access_token_secret; 67 | next(); 68 | }).catch(error => { 69 | res.status(403).send('Unauthorized after check'); 70 | }); 71 | } 72 | }; 73 | 74 | app.use(bodyParser.json()); 75 | app.use(allowCrossDomain); 76 | app.use(authenticate); 77 | 78 | app.post('/api/og-scrapper', (req, res) => { 79 | var client = getTwitterClient(req); 80 | ogs(req.body, function (error, results) { 81 | (!error) ? res.status(200).json(results) : res.status(400).json(results); 82 | }); 83 | }); 84 | 85 | app.post('/api/feed', (req, res) => { 86 | var client = getTwitterClient(req); 87 | client.get('statuses/home_timeline', req.body, function (error, body, response) { 88 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 89 | }); 90 | }); 91 | 92 | app.post('/api/timeline', (req, res) => { 93 | var client = getTwitterClient(req); 94 | client.get('statuses/user_timeline', req.body, function (error, body, response) { 95 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 96 | }); 97 | }); 98 | 99 | app.post('/api/tweet', (req, res) => { 100 | var client = getTwitterClient(req); 101 | client.post('statuses/update', req.body, function (error, body, response) { 102 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 103 | }); 104 | }); 105 | 106 | app.post('/api/retweet', (req, res) => { 107 | var client = getTwitterClient(req); 108 | client.post(`statuses/retweet/${req.body.id}`, Object.assign({ trim_user: false }, req.body || {}), function (error, body, response) { 109 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 110 | }); 111 | }); 112 | 113 | app.post('/api/unretweet', (req, res) => { 114 | var client = getTwitterClient(req); 115 | client.post(`statuses/unretweet/${req.body.id}`, Object.assign({ trim_user: false }, req.body || {}), function (error, body, response) { 116 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 117 | }); 118 | }); 119 | 120 | app.post('/api/mentions', (req, res) => { 121 | var client = getTwitterClient(req); 122 | client.get('statuses/mentions_timeline', req.body, function (error, body, response) { 123 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 124 | }); 125 | }); 126 | 127 | app.post('/api/favorites/list', (req, res) => { 128 | var client = getTwitterClient(req); 129 | client.get('favorites/list', Object.assign({ include_entities: true }, req.body || {}), function (error, body, response) { 130 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 131 | }); 132 | }); 133 | 134 | app.post('/api/favorites/create', (req, res) => { 135 | var client = getTwitterClient(req); 136 | client.post('favorites/create', Object.assign({ include_entities: true }, req.body || {}), function (error, body, response) { 137 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 138 | }); 139 | }); 140 | 141 | app.post('/api/favorites/destroy', (req, res) => { 142 | var client = getTwitterClient(req); 143 | client.post('favorites/destroy', Object.assign({ include_entities: true }, req.body || {}), function (error, body, response) { 144 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 145 | }); 146 | }); 147 | 148 | app.post('/api/messages', (req, res) => { 149 | var client = getTwitterClient(req); 150 | client.get('direct_messages', req.body, function (error, body, response) { 151 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 152 | }); 153 | }); 154 | 155 | app.post('/api/user', (req, res) => { 156 | var client = getTwitterClient(req); 157 | client.get('users/show', req.body, function (error, body, response) { 158 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 159 | }); 160 | }); 161 | 162 | app.post('/api/covers', (req, res) => { 163 | var client = getTwitterClient(req); 164 | client.get('users/profile_banner', req.body, function (error, body, response) { 165 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 166 | }); 167 | }); 168 | 169 | app.post('/api/trending', (req, res) => { 170 | var client = getTwitterClient(req); 171 | client.get('trends/place', Object.assign({ id: 1 }, req.body || {}), function (error, body, response) { 172 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 173 | }); 174 | }); 175 | 176 | app.post('/api/search/recent', (req, res) => { 177 | var client = getTwitterClient(req); 178 | client.get('search/tweets.json', Object.assign({ result_type: 'recent' }, req.body || {}), function (error, body, response) { 179 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 180 | }); 181 | }); 182 | 183 | app.post('/api/search/popular', (req, res) => { 184 | var client = getTwitterClient(req); 185 | client.get('search/tweets.json', Object.assign({ result_type: 'popular' }, req.body || {}), function (error, body, response) { 186 | (!error) ? res.status(200).json(body) : res.status(400).json(error); 187 | }); 188 | }); 189 | 190 | app.listen(config.port, (err) => { 191 | console.log(`server listening on port ${config.port}`) 192 | }); 193 | 194 | function getTwitterClient(req) { 195 | return new Twitter({ 196 | consumer_key: process.env.CONSUMER_KEY, 197 | consumer_secret: process.env.CONSUMER_SECRET, 198 | access_token_key: req.access_token_key, 199 | access_token_secret: req.access_token_secret, 200 | }); 201 | } -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ionic-twitter-pwa", 3 | "description": "Express server to act as a proxy to server twitter resources on the same domain", 4 | "scripts": { 5 | "start": "better-npm-run start", 6 | "reload": "better-npm-run reload", 7 | "stop": "pm2 stop twitter-pwa", 8 | "delete": "pm2 delete twitter-pwa", 9 | "logs": "pm2 logs twitter-pwa" 10 | }, 11 | "betterScripts": { 12 | "start": { 13 | "command": "pm2 start ./index.js --name='twitter-pwa'", 14 | "env": { 15 | "NODE_ENV": "production", 16 | "PORT": 3001 17 | } 18 | }, 19 | "reload": { 20 | "command": "pm2 reload ./index.js --name='twitter-pwa' --force", 21 | "env": { 22 | "NODE_ENV": "production", 23 | "PORT": 3001 24 | } 25 | } 26 | }, 27 | "private": true, 28 | "dependencies": { 29 | "body-parser": "^1.17.2", 30 | "express": "^4.15.2", 31 | "firebase": "^4.0.0", 32 | "firebase-admin": "~4.1.2", 33 | "open-graph-scraper": "^2.5.1", 34 | "twitter": "^1.7.0" 35 | }, 36 | "devDependencies": { 37 | "better-npm-run": "^0.0.15", 38 | "dotenv": "^4.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/actions/auth.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const ADD_AUTH_USER = 'ADD_AUTH_USER'; 4 | export const ADD_AUTH_CREDENTIAL = 'ADD_AUTH_CREDENTIAL'; 5 | export const CLEAN_AUTH = 'CLEAN_AUTH'; 6 | export const LOGIN = 'LOGIN'; 7 | export const LOGIN_FAILED = 'LOGIN_FAILED'; 8 | export const LOGOUT = 'LOGOUT'; 9 | 10 | export const addAuthUser = (user): Action => ({ 11 | type: ADD_AUTH_USER, 12 | payload: { user } 13 | }); 14 | 15 | export const addAuthCredential = (credential): Action => ({ 16 | type: ADD_AUTH_CREDENTIAL, 17 | payload: { credential } 18 | }); 19 | 20 | export const login = (user): Action => ({ 21 | type: LOGIN, 22 | payload: { user } 23 | }); 24 | 25 | export const cleanAuth = (): Action => ({ 26 | type: CLEAN_AUTH 27 | }); 28 | 29 | export const logout = (): Action => ({ 30 | type: LOGOUT 31 | }); -------------------------------------------------------------------------------- /src/actions/feed.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const FEED_FETCH = 'FEED_FETCH'; 4 | export const FEED_FETCHED = 'FEED_FETCHED'; 5 | export const FEED_ERROR = 'FEED_ERROR'; 6 | 7 | export const fetchFeed = (): Action => ({ 8 | type: FEED_FETCH, 9 | }); 10 | 11 | export const fetchedFeed = (feed, reset = false): Action => ({ 12 | type: FEED_FETCHED, 13 | payload: { feed, reset }, 14 | }); 15 | 16 | export const errorFeed = (): Action => ({ 17 | type: FEED_ERROR, 18 | }); 19 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | export const INIT = 'INIT'; 2 | export const ON_BEFORE_UNLOAD = 'ON_BEFORE_UNLOAD'; 3 | 4 | export * from './auth'; 5 | export * from './users'; 6 | export * from './userTweets'; 7 | export * from './userLikes'; 8 | export * from './feed'; 9 | export * from './trends'; 10 | export * from './mentions'; 11 | export * from './tweet'; 12 | export * from './search'; 13 | -------------------------------------------------------------------------------- /src/actions/mentions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const MENTIONS_FETCH = 'MENTIONS_FETCH'; 4 | export const MENTIONS_FETCHED = 'MENTIONS_FETCHED'; 5 | export const MENTIONS_ERROR = 'MENTIONS_ERROR'; 6 | 7 | export const fetchMentions = (): Action => ({ 8 | type: MENTIONS_FETCH, 9 | }); 10 | 11 | export const fetchedMentions = (feed, reset = false): Action => ({ 12 | type: MENTIONS_FETCHED, 13 | payload: { feed, reset } 14 | }); 15 | 16 | export const errorMentions = (): Action => ({ 17 | type: MENTIONS_ERROR, 18 | }); 19 | -------------------------------------------------------------------------------- /src/actions/search.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const SEARCH_FETCH = 'SEARCH_FETCH'; 4 | export const SEARCH_FETCHED = 'SEARCH_FETCHED'; 5 | export const SEARCH_ERROR = 'SEARCH_ERROR'; 6 | 7 | export const fetchSearch = (term): Action => ({ 8 | type: SEARCH_FETCH, 9 | payload: { term }, 10 | }); 11 | 12 | export const fetchedSearch = (term, feed, reset = false): Action => ({ 13 | type: SEARCH_FETCHED, 14 | payload: { term, feed, reset }, 15 | }); 16 | 17 | export const errorSearch = (term): Action => ({ 18 | type: SEARCH_ERROR, 19 | payload: { term }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/actions/trends.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const TRENDS_FETCH = 'TRENDS_FETCH'; 4 | export const TRENDS_FETCHED = 'TRENDS_FETCHED'; 5 | export const TRENDS_ERROR = 'TRENDS_ERROR'; 6 | 7 | export const fetchTrends = (): Action => ({ 8 | type: TRENDS_FETCH, 9 | }); 10 | 11 | export const fetchedTrends = (payload): Action => ({ 12 | type: TRENDS_FETCHED, 13 | payload 14 | }); 15 | 16 | export const errorTrends = (): Action => ({ 17 | type: TRENDS_ERROR, 18 | }); 19 | -------------------------------------------------------------------------------- /src/actions/tweet.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const TWEET_RETWEET = 'TWEET_RETWEET'; 4 | export const TWEET_UNRETWEET = 'TWEET_UNRETWEET'; 5 | export const TWEET_FAVORITE = 'TWEET_FAVORITE'; 6 | export const TWEET_UNFAVORITE = 'TWEET_UNFAVORITE'; 7 | 8 | export const tweetRetweet = (tweet, id): Action => ({ 9 | type: TWEET_RETWEET, 10 | payload: { tweet, id } 11 | }); 12 | 13 | export const tweetUnretweet = (tweet, id): Action => ({ 14 | type: TWEET_UNRETWEET, 15 | payload: { tweet, id } 16 | }); 17 | 18 | export const tweetFavorite = (tweet): Action => ({ 19 | type: TWEET_FAVORITE, 20 | payload: { tweet } 21 | }); 22 | 23 | export const tweetUnfavorite = (tweet): Action => ({ 24 | type: TWEET_UNFAVORITE, 25 | payload: { tweet } 26 | }); 27 | -------------------------------------------------------------------------------- /src/actions/userLikes.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const USER_LIKES_FETCH = 'USER_LIKES_FETCH'; 4 | export const USER_LIKES_FETCHED = 'USER_LIKES_FETCHED'; 5 | export const USER_LIKES_ERROR = 'USER_LIKES_ERROR'; 6 | 7 | export const fetchUserLikes = (username): Action => ({ 8 | type: USER_LIKES_FETCH, 9 | payload: { username }, 10 | }); 11 | 12 | export const fetchedUserLikes = (username, feed, reset = false): Action => ({ 13 | type: USER_LIKES_FETCHED, 14 | payload: { username, feed, reset }, 15 | }); 16 | 17 | export const errorUserLikes = (username): Action => ({ 18 | type: USER_LIKES_ERROR, 19 | payload: { username }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/actions/userTweets.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const USER_TWEETS_FETCH = 'USER_TWEETS_FETCH'; 4 | export const USER_TWEETS_FETCHED = 'USER_TWEETS_FETCHED'; 5 | export const USER_TWEETS_ERROR = 'USER_TWEETS_ERROR'; 6 | 7 | export const fetchUserTweets = (username): Action => ({ 8 | type: USER_TWEETS_FETCH, 9 | payload: { username }, 10 | }); 11 | 12 | export const fetchedUserTweets = (username, feed, reset = false): Action => ({ 13 | type: USER_TWEETS_FETCHED, 14 | payload: { username, feed, reset }, 15 | }); 16 | 17 | export const errorUserTweets = (username): Action => ({ 18 | type: USER_TWEETS_ERROR, 19 | payload: { username }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/actions/users.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@ngrx/store'; 2 | 3 | export const ADD_TWITTER_USER = 'ADD_TWITTER_USER'; 4 | export const ADD_CURRENT_TWITTER_USER = 'ADD_CURRENT_TWITTER_USER'; 5 | 6 | export const addTwitterUser = (user): Action => ({ 7 | type: ADD_TWITTER_USER, 8 | payload: { user } 9 | }); 10 | 11 | export const addCurrentTwitterUser = (user): Action => ({ 12 | type: ADD_CURRENT_TWITTER_USER, 13 | payload: { user } 14 | }); -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Component, ViewChild } from '@angular/core'; 3 | import { Nav, Platform } from 'ionic-angular'; 4 | 5 | import { StorageProvider, AuthProvider, ServiceWorkerProvider } from '../providers'; 6 | 7 | @Component({ 8 | templateUrl: 'app.html' 9 | }) 10 | export class MyApp { 11 | @ViewChild(Nav) nav: Nav; 12 | isMenuEnabled$: Observable; 13 | rootPage: any = 'HomePage'; 14 | previousAuthState: boolean; 15 | 16 | constructor( 17 | public platform: Platform, 18 | public storageProvider: StorageProvider, 19 | public authProvider: AuthProvider, 20 | public swProvider: ServiceWorkerProvider, 21 | ) { 22 | this.isMenuEnabled$ = this.authProvider.isAuthenticated$(); 23 | 24 | this.platform.ready().then(() => { 25 | this.storageProvider.run(); 26 | this.authProvider.run(); 27 | this.swProvider.run(); 28 | this.authProvider.isAuthenticated$().debounceTime(100).subscribe(isAuthenticated => { 29 | if (this.previousAuthState !== isAuthenticated) { 30 | console.log('isAuthenticated', isAuthenticated, ) 31 | 32 | if (isAuthenticated && (location.hash === "" || location.hash.includes('login'))) { 33 | this.nav.setRoot('HomePage'); 34 | } else if (!isAuthenticated && !location.hash.includes('login')) { 35 | this.nav.setRoot('LoginPage'); 36 | } 37 | } 38 | this.previousAuthState = isAuthenticated; 39 | }); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { 3 | ErrorHandler, 4 | NgModule, 5 | APP_INITIALIZER, 6 | Injector, 7 | } from '@angular/core'; 8 | import { ServiceWorkerModule } from '@angular/service-worker'; 9 | import { HttpModule, Http, XHRBackend, RequestOptions } from '@angular/http'; 10 | import { Storage } from '@ionic/storage'; 11 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 12 | import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular'; 13 | import { AngularFireModule } from 'angularfire2'; 14 | import { AngularFireAuth } from 'angularfire2/auth'; 15 | import { Network } from '@ionic-native/network'; 16 | 17 | import 'javascript-time-ago/intl-messageformat-global'; 18 | import 'intl-messageformat/dist/locale-data/en'; 19 | 20 | import 'rxjs/add/observable/of'; 21 | import 'rxjs/add/observable/throw'; 22 | import 'rxjs/add/observable/combineLatest'; 23 | import 'rxjs/add/operator/map'; 24 | import 'rxjs/add/operator/first'; 25 | import 'rxjs/add/operator/skip'; 26 | import 'rxjs/add/operator/distinctUntilChanged'; 27 | import 'rxjs/add/operator/debounceTime'; 28 | import 'rxjs/add/operator/switchMap'; 29 | import 'rxjs/add/operator/catch'; 30 | import 'rxjs/add/operator/toPromise'; 31 | import 'rxjs/add/operator/finally'; 32 | 33 | import { MyApp } from './app.component'; 34 | import { STORE } from '../store'; 35 | import { 36 | StorageProvider, 37 | TwitterProvider, 38 | UsersProvider, 39 | FeedProvider, 40 | AuthProvider, 41 | TrendsProvider, 42 | ServiceWorkerProvider, 43 | SearchProvider, 44 | MentionsProvider, 45 | TweetProvider, 46 | UserTweetsProvider, 47 | UserLikesProvider, 48 | } from '../providers'; 49 | import { MenuComponentModule } from '../components/menu/menu.module'; 50 | import { HttpWrapper } from './http.wrapper'; 51 | 52 | export function provideStorage() { 53 | return new Storage({ name: '__twitter-pwa' }); 54 | } 55 | 56 | export function appInitializerStorageFactory(storage: StorageProvider) { 57 | return () => storage.init(); 58 | } 59 | 60 | export function provideHttp( 61 | xhrBackend: XHRBackend, 62 | requestOptions: RequestOptions, 63 | authProvider: AuthProvider, 64 | injector: Injector, 65 | ): Http { 66 | return new HttpWrapper(xhrBackend, requestOptions, authProvider, injector); 67 | } 68 | 69 | @NgModule({ 70 | declarations: [MyApp], 71 | imports: [ 72 | BrowserModule, 73 | HttpModule, 74 | IonicModule.forRoot(MyApp, { 75 | iconMode: 'md', 76 | mode: 'md' 77 | }), 78 | AngularFireModule.initializeApp({ 79 | apiKey: 'AIzaSyAVDXvCWcmED4zI4LmAMlVJr21ul7z5DyQ', 80 | authDomain: 'ionic-twitter-pwa.firebaseapp.com', 81 | databaseURL: 'https://ionic-twitter-pwa.firebaseio.com', 82 | projectId: 'ionic-twitter-pwa', 83 | storageBucket: 'ionic-twitter-pwa.appspot.com', 84 | messagingSenderId: '635051733996', 85 | }), 86 | ...STORE, 87 | MenuComponentModule, 88 | ServiceWorkerModule, 89 | BrowserAnimationsModule, 90 | ], 91 | bootstrap: [IonicApp], 92 | entryComponents: [MyApp], 93 | providers: [ 94 | { provide: Storage, useFactory: provideStorage }, 95 | { provide: ErrorHandler, useClass: IonicErrorHandler }, 96 | { 97 | provide: Http, 98 | useFactory: provideHttp, 99 | deps: [XHRBackend, RequestOptions, AuthProvider, Injector], 100 | }, 101 | { 102 | provide: APP_INITIALIZER, 103 | useFactory: appInitializerStorageFactory, 104 | deps: [StorageProvider], 105 | multi: true, 106 | }, 107 | AngularFireAuth, 108 | StorageProvider, 109 | UsersProvider, 110 | FeedProvider, 111 | AuthProvider, 112 | TwitterProvider, 113 | TrendsProvider, 114 | ServiceWorkerProvider, 115 | SearchProvider, 116 | MentionsProvider, 117 | TweetProvider, 118 | UserTweetsProvider, 119 | UserLikesProvider, 120 | Network, 121 | ], 122 | }) 123 | export class AppModule { } 124 | -------------------------------------------------------------------------------- /src/app/app.scss: -------------------------------------------------------------------------------- 1 | // http://ionicframework.com/docs/v2/theming/ 2 | 3 | 4 | // App Global Sass 5 | // -------------------------------------------------- 6 | // Put style rules here that you want to apply globally. These 7 | // styles are for the entire app and not just one component. 8 | // Additionally, this file can be also used as an entry point 9 | // to import other Sass files to be included in the output CSS. 10 | // 11 | // Shared Sass variables, which can be used to adjust Ionic's 12 | // default Sass variables, belong in "theme/variables.scss". 13 | // 14 | // To declare rules for a specific mode, create a child rule 15 | // for the .md, .ios, or .wp mode classes. The mode class is 16 | // automatically applied to the element in the app. 17 | [flex]{ 18 | display: flex; 19 | flex: 1; 20 | } 21 | 22 | .primary-menu{ 23 | padding: 10px 0; 24 | border-bottom: 1px solid lighten(color($colors, gray), 40%) 25 | } 26 | 27 | .misc-menu{ 28 | 29 | } -------------------------------------------------------------------------------- /src/app/http.wrapper.ts: -------------------------------------------------------------------------------- 1 | import { logout } from './../actions/auth'; 2 | import { Store } from '@ngrx/store'; 3 | import { Injectable, Injector } from '@angular/core'; 4 | import { 5 | Http, 6 | Headers, 7 | RequestOptionsArgs, 8 | Request, 9 | Response, 10 | ConnectionBackend, 11 | RequestOptions, 12 | } from '@angular/http'; 13 | import { ToastController } from 'ionic-angular'; 14 | import { Observable } from 'rxjs/Observable'; 15 | 16 | import 'rxjs/add/operator/do'; 17 | import 'rxjs/add/observable/throw'; 18 | 19 | import { AppState } from './../reducers'; 20 | import { AuthProvider } from './../providers'; 21 | 22 | @Injectable() 23 | export class HttpWrapper extends Http { 24 | public store: Store; 25 | public toastCtrl: ToastController; 26 | 27 | constructor( 28 | protected _backend: ConnectionBackend, 29 | protected _defaultOptions: RequestOptions, 30 | public authProvider: AuthProvider, 31 | public injector: Injector, 32 | ) { 33 | super(_backend, _defaultOptions); 34 | this.toastCtrl = this.injector.get(ToastController); 35 | } 36 | 37 | request( 38 | url: string | Request, 39 | options: RequestOptionsArgs = new RequestOptions({}), 40 | ): Observable { 41 | this.onRequest(url, options); 42 | 43 | return super.request(url, options).do( 44 | (r: Response) => { 45 | this.onResponse(url, r); 46 | }, 47 | err => { 48 | this.onError(url, err); 49 | }, 50 | ); 51 | } 52 | 53 | private onRequest( 54 | url: string | Request, 55 | options: RequestOptionsArgs = new RequestOptions({}), 56 | ) { 57 | console.log('url', url); 58 | // const headers = new Headers(); 59 | // const { stsTokenManager: { accessToken } } = this.authProvider.getUser(); 60 | // const { access_token_key, access_token_secret } = this.authProvider.getCredential(); 61 | 62 | // headers.set('Content-Type', 'application/json'); 63 | // headers.set('Authorization', `Bearer ${accessToken},${access_token_key},${access_token_secret}`); 64 | // return new RequestOptions({ headers }); 65 | // let path = (typeof url === 'string') ? url : url.url; 66 | // if (path.startsWith(this.config.getApiRoute())) { 67 | // if (typeof url === 'string') { 68 | // options.withCredentials = true; 69 | // if (!options.headers) { 70 | // options.headers = new Headers(); 71 | // } 72 | // this.store.select('token').take(1).subscribe((token => { options.headers.set('auth-token', String(token)); })); 73 | // options.headers.set('app-version', this.config.getVersion()); 74 | // options.headers.set('app-platform', _get(window, 'cordova.platformId') || 'UNKNOWN'); 75 | // options.headers.set('app-env', this.config.getEnv()); 76 | // } else { 77 | // url.withCredentials = true; 78 | // this.store.select('token').take(1).subscribe((token => { url.headers.set('auth-token', String(token)); })); 79 | // url.headers.set('app-version', this.config.getVersion()); 80 | // url.headers.set('app-platform', _get(window, 'cordova.platformId') || 'UNKNOWN'); 81 | // url.headers.set('app-env', this.config.getEnv()); 82 | // } 83 | // } 84 | } 85 | 86 | private onResponse(url, response) { 87 | // Listen to the API response (except authentication). 88 | // let path = (typeof url === 'string') ? url : url.url; 89 | // if (path.startsWith(this.config.getApi('baseUrl')) && !path.startsWith(this.config.getApiRoute('/authentication'))) { 90 | // let cacheVersion = parseInt(response.headers.get('user-cache-version')); 91 | // this.injector.get(Fv).doCacheVersionCheck(cacheVersion); 92 | // let userVersion = parseInt(response.headers.get('user-version')); 93 | // if (userVersion > this.userVersion) { 94 | // this.userVersion = userVersion; 95 | // this.store.dispatch(setUserVersion(this.userVersion)); 96 | // } 97 | // let alert = parseInt(response.headers.get('alert-count')); 98 | // let feed = parseInt(response.headers.get('feed-count')); 99 | // let badge = parseInt(response.headers.get('badge-count')); 100 | // this.store.dispatch(setCounter(alert, feed, badge)); 101 | // } 102 | } 103 | 104 | private onError(url, err): Observable { 105 | if (err.status >= 400 && err.status < 600) { 106 | if (err.status === 403) { 107 | let toast = this.toastCtrl.create({ 108 | message: 'Unauthorized. Loging out in 3 secs...', 109 | duration: 3000, 110 | }); 111 | toast.onDidDismiss((data, role) => this.authProvider.logout()); 112 | toast.present(); 113 | } else { 114 | const body = JSON.parse(err._body); 115 | this.toastCtrl.create({ 116 | message: body[0].message, 117 | duration: 3000, 118 | }).present(); 119 | } 120 | } 121 | return Observable.throw(err); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/app/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | 3 | import { AppModule } from './app.module'; 4 | 5 | platformBrowserDynamic() 6 | .bootstrapModule(AppModule) 7 | .then(() => { 8 | console.log('bootstrap') 9 | if ('serviceWorker' in navigator) { 10 | navigator.serviceWorker.register('worker-basic.js') 11 | .then((reg) => { 12 | console.log('SW reg', reg); 13 | if (reg.installing) { 14 | console.log('SW installing'); 15 | } else if (reg.waiting) { 16 | console.log('SW installed'); 17 | } else if (reg.active) { 18 | console.log('SW active'); 19 | } 20 | }) 21 | .catch(err => console.error('SW error', err)); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { IonicModule } from 'ionic-angular'; 4 | import { AvatarComponent } from '../components/avatar/avatar'; 5 | import { TweetFabComponent } from '../components/tweet-fab/tweet-fab'; 6 | import { MessageFabComponent } from '../components/message-fab/message-fab'; 7 | import { AvatarToolbarComponent } from '../components/avatar-toolbar/avatar-toolbar'; 8 | import { ProfileHeaderComponent } from '../components/profile-header/profile-header'; 9 | import { FeedComponent } from '../components/feed/feed'; 10 | import { TweetComponent } from '../components/tweet/tweet'; 11 | import { OgComponent } from '../components/og/og'; 12 | import { MediaComponent } from '../components/media/media'; 13 | import { SpinnerComponent } from '../components/spinner/spinner'; 14 | import { TrendingHashtagsComponent } from '../components/trendingHashtags/trendingHashtags'; 15 | import { TweetTextComponent } from '../components/tweet-text/tweet-text'; 16 | 17 | @NgModule({ 18 | imports: [CommonModule, IonicModule], 19 | declarations: [ 20 | AvatarComponent, 21 | AvatarToolbarComponent, 22 | TweetFabComponent, 23 | MessageFabComponent, 24 | ProfileHeaderComponent, 25 | FeedComponent, 26 | TweetComponent, 27 | OgComponent, 28 | MediaComponent, 29 | SpinnerComponent, 30 | TrendingHashtagsComponent, 31 | TweetTextComponent, 32 | ], 33 | exports: [ 34 | AvatarComponent, 35 | AvatarToolbarComponent, 36 | TweetFabComponent, 37 | MessageFabComponent, 38 | ProfileHeaderComponent, 39 | FeedComponent, 40 | TweetComponent, 41 | OgComponent, 42 | MediaComponent, 43 | SpinnerComponent, 44 | TrendingHashtagsComponent, 45 | TweetTextComponent, 46 | ], 47 | }) 48 | export class SharedLazyModule {} 49 | -------------------------------------------------------------------------------- /src/assets/icon/app_144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/src/assets/icon/app_144.png -------------------------------------------------------------------------------- /src/assets/icon/app_168.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/src/assets/icon/app_168.png -------------------------------------------------------------------------------- /src/assets/icon/app_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/src/assets/icon/app_192.png -------------------------------------------------------------------------------- /src/assets/icon/app_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/src/assets/icon/app_48.png -------------------------------------------------------------------------------- /src/assets/icon/app_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/src/assets/icon/app_512.png -------------------------------------------------------------------------------- /src/assets/icon/app_72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/src/assets/icon/app_72.png -------------------------------------------------------------------------------- /src/assets/icon/app_96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/src/assets/icon/app_96.png -------------------------------------------------------------------------------- /src/assets/icon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shprink/ionic-angular-twitter-pwa/cf340588cdaa98866589ddb302ee464a63b98d1c/src/assets/icon/favicon.ico -------------------------------------------------------------------------------- /src/components/avatar-toolbar/avatar-toolbar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 |
8 |
9 |
-------------------------------------------------------------------------------- /src/components/avatar-toolbar/avatar-toolbar.scss: -------------------------------------------------------------------------------- 1 | avatar-toolbar { 2 | .toolbar-content { 3 | display: flex; 4 | flex-direction: row; 5 | align-items: center; 6 | avatar { 7 | 8 | } 9 | .toolbar-title { 10 | > div { 11 | display: flex; 12 | flex: 1; 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/components/avatar-toolbar/avatar-toolbar.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { MenuController } from 'ionic-angular'; 3 | import { Observable } from 'rxjs/Observable'; 4 | 5 | import { UsersProvider } from './../../providers'; 6 | import { ITwitterUser } from './../../reducers'; 7 | /** 8 | * Generated class for the AvatarToolbarComponent component. 9 | * 10 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 11 | * for more info on Angular Components. 12 | */ 13 | @Component({ 14 | selector: 'avatar-toolbar', 15 | templateUrl: 'avatar-toolbar.html' 16 | }) 17 | export class AvatarToolbarComponent { 18 | user$: Observable 19 | 20 | constructor( 21 | private menuCtrl: MenuController, 22 | private users: UsersProvider, 23 | ) { } 24 | 25 | ngOnInit() { 26 | this.user$ = this.users.getCurrentUser$(); 27 | } 28 | 29 | openMenu() { 30 | this.menuCtrl.open(); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/components/avatar/avatar.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | avatar 8 | -------------------------------------------------------------------------------- /src/components/avatar/avatar.scss: -------------------------------------------------------------------------------- 1 | avatar { 2 | margin: 0 10px; 3 | cursor: pointer; 4 | display: inline-block; 5 | ion-avatar { 6 | &[type="mini"] img { 7 | width: 24px; 8 | height: 24px; 9 | } 10 | &[size="normal"] img { 11 | width: 48px; 12 | height: 48px; 13 | } 14 | &[size="bigger"] img { 15 | width: 73px; 16 | height: 73px; 17 | } 18 | img { 19 | border-radius: 50%; 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/components/avatar/avatar.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { ITwitterUser } from './../../reducers'; 4 | /** 5 | * Generated class for the AvatarComponent component. 6 | * 7 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 8 | * for more info on Angular Components. 9 | */ 10 | @Component({ 11 | selector: 'avatar', 12 | templateUrl: 'avatar.html' 13 | }) 14 | export class AvatarComponent { 15 | @Input() 'user': ITwitterUser; 16 | @Input() 'size': string = 'normal'; // mini, normal or bigger 17 | profile_image: string; 18 | 19 | constructor() { } 20 | 21 | ngOnChanges(changes: any) { 22 | if (changes.user.currentValue && changes.user.currentValue !== changes.user.previousValue) { 23 | this.profile_image = changes.user.currentValue.profile_image_url_https.replace('normal', this.size); 24 | } 25 | } 26 | 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/components/cover/cover.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | cover 6 | 7 |

{{user.name}}

8 |

@{{user.screen_name}}

9 |
-------------------------------------------------------------------------------- /src/components/cover/cover.scss: -------------------------------------------------------------------------------- 1 | cover { 2 | color: white; 3 | > div { 4 | background-repeat: no-repeat; 5 | background-size: cover; 6 | height: 160px; 7 | padding: 10px 15px; 8 | display: flex; 9 | flex-direction: column; 10 | h2 { 11 | font-size: 16px; 12 | margin-bottom: 7px; 13 | margin-top: 15px; 14 | } 15 | h3 { 16 | font-size: 14px; 17 | margin-bottom: 0px; 18 | } 19 | h2, h3 { 20 | text-shadow: 0px 0px 1px #000; 21 | } 22 | ion-avatar img { 23 | width: 50px; 24 | height: 50px; 25 | border-radius: 50%; 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/components/cover/cover.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { ITwitterUser } from '../../reducers'; 4 | 5 | /** 6 | * Generated class for the CoverComponent component. 7 | * 8 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 9 | * for more info on Angular Components. 10 | */ 11 | @Component({ 12 | selector: 'cover', 13 | templateUrl: 'cover.html' 14 | }) 15 | export class CoverComponent { 16 | @Input() user: ITwitterUser; 17 | @Input() onAvatarClick: (e) => void; 18 | 19 | constructor() { } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/components/feed/feed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/feed/feed.scss: -------------------------------------------------------------------------------- 1 | feed { 2 | 3 | } -------------------------------------------------------------------------------- /src/components/feed/feed.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ElementRef } from '@angular/core'; 2 | import { InfiniteScroll, Refresher } from 'ionic-angular'; 3 | 4 | import { ITweet } from './../../reducers'; 5 | /** 6 | * Generated class for the FeedComponent component. 7 | * 8 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 9 | * for more info on Angular Components. 10 | */ 11 | @Component({ 12 | selector: 'feed', 13 | templateUrl: 'feed.html', 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | }) 16 | export class FeedComponent { 17 | @Input() addRefresher: boolean = true; 18 | @Input() content: ITweet[]; 19 | @Input() isFetching: boolean; 20 | @Output() onInit: EventEmitter = new EventEmitter(); 21 | @Output() onInfinite: EventEmitter = new EventEmitter(); 22 | @Output() onRefresh: EventEmitter = new EventEmitter(); 23 | 24 | constructor(private elementRef: ElementRef) {} 25 | 26 | ngOnInit() { 27 | this.onInit.emit(); 28 | } 29 | 30 | doRefresh(refresher: Refresher) { 31 | this.onRefresh.emit(refresher); 32 | } 33 | 34 | doInfinite(infiniteScroll: InfiniteScroll) { 35 | this.onInfinite.emit(infiniteScroll); 36 | } 37 | 38 | trackById(index, item) { 39 | return item.id_str; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/media/media.html: -------------------------------------------------------------------------------- 1 | 2 |
-------------------------------------------------------------------------------- /src/components/media/media.scss: -------------------------------------------------------------------------------- 1 | media { 2 | height: 180px; 3 | overflow: hidden; 4 | display: block; 5 | .media-img { 6 | margin-top: 10px; 7 | background-repeat: no-repeat; 8 | background-size: cover; 9 | background-position: center center; 10 | height: 180px; 11 | padding: 10px 15px; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | } -------------------------------------------------------------------------------- /src/components/media/media.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { ITweetEntitiesMedia } from './../../reducers'; 4 | /** 5 | * Generated class for the MediaComponent component. 6 | * 7 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 8 | * for more info on Angular Components. 9 | */ 10 | @Component({ 11 | selector: 'media', 12 | templateUrl: 'media.html' 13 | }) 14 | export class MediaComponent { 15 | 16 | @Input() 'data': ITweetEntitiesMedia; 17 | 18 | constructor() { 19 | console.log('Hello MediaComponent Component'); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/components/menu/menu.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /src/components/menu/menu.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | import { MenuComponent } from './menu'; 4 | 5 | import { CoverComponent } from '../cover/cover'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | MenuComponent, 10 | CoverComponent, 11 | ], 12 | imports: [ 13 | IonicPageModule.forChild(MenuComponent), 14 | ], 15 | exports: [ 16 | MenuComponent, 17 | CoverComponent 18 | ] 19 | }) 20 | export class MenuComponentModule { } 21 | -------------------------------------------------------------------------------- /src/components/menu/menu.scss: -------------------------------------------------------------------------------- 1 | menu { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/menu/menu.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Component, Input } from '@angular/core'; 3 | import { Nav } from 'ionic-angular'; 4 | 5 | import { UsersProvider, AuthProvider } from './../../providers'; 6 | import { ITwitterUser } from './../../reducers'; 7 | 8 | /** 9 | * Generated class for the MenuComponent component. 10 | * 11 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 12 | * for more info on Angular Components. 13 | */ 14 | @Component({ 15 | selector: 'menu', 16 | templateUrl: 'menu.html' 17 | }) 18 | export class MenuComponent { 19 | user$: Observable; 20 | @Input() content: Nav; 21 | 22 | constructor( 23 | public authProvider: AuthProvider, 24 | public usersProvider: UsersProvider, 25 | ) { 26 | console.log('Hello MenuComponent Component', this.content); 27 | } 28 | 29 | ngOnInit() { 30 | this.user$ = this.usersProvider.getCurrentUser$(); 31 | } 32 | 33 | goToProfile = (e) => { 34 | const { screen_name } = this.usersProvider.getCurrentUser(); 35 | this.content.push('ProfilePage', { handle: screen_name }); 36 | } 37 | 38 | logout() { 39 | this.authProvider.logout(); 40 | this.content.setRoot('LoginPage') 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/components/message-fab/message-fab.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /src/components/message-fab/message-fab.scss: -------------------------------------------------------------------------------- 1 | message-fab { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/components/message-fab/message-fab.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | /** 4 | * Generated class for the MessageFabComponent component. 5 | * 6 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 7 | * for more info on Angular Components. 8 | */ 9 | @Component({ 10 | selector: 'message-fab', 11 | templateUrl: 'message-fab.html' 12 | }) 13 | export class MessageFabComponent { 14 | 15 | constructor() { 16 | console.log('Hello MessageFabComponent Component'); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/components/og/og.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | {{text}} 4 |
5 | -------------------------------------------------------------------------------- /src/components/og/og.scss: -------------------------------------------------------------------------------- 1 | og { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/components/og/og.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { ITweetEntitiesUrl } from './../../reducers'; 4 | /** 5 | * Generated class for the OgComponent component. 6 | * 7 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 8 | * for more info on Angular Components. 9 | */ 10 | @Component({ 11 | selector: 'og', 12 | templateUrl: 'og.html' 13 | }) 14 | export class OgComponent { 15 | 16 | // @Input() 'data': ITweetEntitiesMedia; 17 | 18 | constructor() { 19 | console.log('Hello OgComponent Component'); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/components/profile-header/profile-header.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | 5 |

{{user.name}}

6 |

@{{user.screen_name}}

7 | 8 |
9 | {{user.location}} 10 | {{user.url}} 11 |
12 |
13 | {{user.friends_count}} Following 14 | {{user.followers_count}} Followers 15 |
16 |
-------------------------------------------------------------------------------- /src/components/profile-header/profile-header.scss: -------------------------------------------------------------------------------- 1 | profile-header { 2 | display: block; 3 | background-color: white; 4 | border-bottom: 1px solid #eee; 5 | padding-bottom: 10px; 6 | .cover { 7 | background-repeat: no-repeat; 8 | background-size: cover; 9 | height: 160px; 10 | padding: 10px 15px; 11 | display: flex; 12 | flex-direction: column; 13 | & + .user-details { 14 | margin-top: -40px; 15 | } 16 | } 17 | ion-avatar img{ 18 | border: 3px solid white; 19 | background-color: white; 20 | } 21 | .user-details { 22 | padding: 10px; 23 | 24 | h2 { 25 | font-size: 18px; 26 | margin-bottom: 7px; 27 | margin-top: 5px; 28 | } 29 | h3 { 30 | font-size: 12px; 31 | margin-bottom: 0px; 32 | color: color($colors, gray) 33 | } 34 | .following-row { 35 | color: color($colors, gray); 36 | .count{ 37 | color: black; 38 | font-weight: bold; 39 | } 40 | .followers{ 41 | margin-left: 30px; 42 | } 43 | } 44 | .location { 45 | margin: 12px 0; 46 | color: color($colors, gray); 47 | ion-icon { 48 | margin: 0 5px; 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/components/profile-header/profile-header.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { ITwitterUser } from './../../reducers'; 4 | 5 | /** 6 | * Generated class for the ProfileHeaderComponent component. 7 | * 8 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 9 | * for more info on Angular Components. 10 | */ 11 | @Component({ 12 | selector: 'profile-header', 13 | templateUrl: 'profile-header.html' 14 | }) 15 | export class ProfileHeaderComponent { 16 | @Input() user: ITwitterUser; 17 | 18 | constructor() { } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/spinner/spinner.html: -------------------------------------------------------------------------------- 1 | 7 |
8 | 9 |
-------------------------------------------------------------------------------- /src/components/spinner/spinner.scss: -------------------------------------------------------------------------------- 1 | spinner { 2 | > div { 3 | background-color: $loader-bg-color; 4 | z-index: $loader-zIndex; 5 | position: absolute; 6 | top: 0; 7 | bottom: 0; 8 | left: 0; 9 | right: 0; 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | ion-spinner { 14 | width: $loader-width; 15 | height: $loader-height; 16 | line, circle { 17 | stroke: $loader-stroke !important; 18 | } 19 | } 20 | p { 21 | margin-top: 15px; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/spinner/spinner.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; 2 | import { trigger, state, style, animate, transition } from '@angular/animations'; 3 | 4 | /* 5 | Generated class for the Spinner component. 6 | 7 | See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 8 | for more info on Angular 2 Components. 9 | */ 10 | @Component({ 11 | selector: 'spinner', 12 | templateUrl: 'spinner.html', 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | animations: [ 15 | trigger('visibilityChanged', [ 16 | state('1', style({ opacity: 1, transform: 'scale(1.0)' })), 17 | state('0', style({ opacity: 0, transform: 'scale(0.8)', display: 'none' 18 | })), 19 | transition('* => *', animate('300ms')) 20 | ]) 21 | ] 22 | }) 23 | export class SpinnerComponent { 24 | 25 | @Input() isVisible : boolean = true; 26 | 27 | constructor() { } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/components/trendingHashtags/trendingHashtags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/trendingHashtags/trendingHashtags.scss: -------------------------------------------------------------------------------- 1 | feed { 2 | ion-item { 3 | h2 { 4 | font-size: 1.5rem !important; 5 | } 6 | .name { 7 | font-weight: bold; 8 | } 9 | .handle { 10 | color: color($colors, gray); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/components/trendingHashtags/trendingHashtags.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | import { InfiniteScroll, Refresher } from 'ionic-angular'; 3 | 4 | import { ITrendingHashtag } from './../../reducers'; 5 | /** 6 | * Generated class for the TrendingHashtagsComponent component. 7 | * 8 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 9 | * for more info on Angular Components. 10 | */ 11 | @Component({ 12 | selector: 'trending-hashtags', 13 | templateUrl: 'trendingHashtags.html', 14 | }) 15 | export class TrendingHashtagsComponent { 16 | @Input() content: ITrendingHashtag[] = []; 17 | @Input() isFetching: boolean; 18 | @Output() onRefresh: EventEmitter = new EventEmitter(); 19 | @Output() onClick: EventEmitter = new EventEmitter(); 20 | 21 | constructor() {} 22 | 23 | doRefresh(refresher: Refresher) { 24 | this.onRefresh.emit(refresher); 25 | } 26 | 27 | doClick(hashtag: ITrendingHashtag) { 28 | this.onClick.emit(hashtag); 29 | } 30 | 31 | trackById(index, item) { 32 | return item.id; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/tweet-fab/tweet-fab.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/tweet-fab/tweet-fab.scss: -------------------------------------------------------------------------------- 1 | tweet-fab { 2 | } -------------------------------------------------------------------------------- /src/components/tweet-fab/tweet-fab.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ModalController } from 'ionic-angular'; 3 | 4 | /** 5 | * Generated class for the TweetFabComponent component. 6 | * 7 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 8 | * for more info on Angular Components. 9 | */ 10 | @Component({ 11 | selector: 'tweet-fab', 12 | templateUrl: 'tweet-fab.html' 13 | }) 14 | export class TweetFabComponent { 15 | 16 | constructor( 17 | private modalCtrl: ModalController 18 | ) { 19 | console.log('Hello TweetFabComponent Component'); 20 | } 21 | 22 | createTweet() { 23 | let tweetModal = this.modalCtrl.create('TweetPage') 24 | // loginModal.onDidDismiss( data => this.nav.setRoot('HomePage')); 25 | tweetModal.present(); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/components/tweet-text/tweet-text.html: -------------------------------------------------------------------------------- 1 | 2 |

-------------------------------------------------------------------------------- /src/components/tweet-text/tweet-text.scss: -------------------------------------------------------------------------------- 1 | tweet-text { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/components/tweet-text/tweet-text.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, ViewChild, ElementRef } from '@angular/core'; 2 | import { autoLinkWithJSON, autoLink } from 'twitter-text'; 3 | import { App } from 'ionic-angular'; 4 | 5 | import { ITweetEntities } from './../../reducers/tweets'; 6 | /** 7 | * Generated class for the TweetTextComponent component. 8 | * 9 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 10 | * for more info on Angular Components. 11 | */ 12 | @Component({ 13 | selector: 'tweet-text', 14 | templateUrl: 'tweet-text.html' 15 | }) 16 | export class TweetTextComponent { 17 | @Input() text: string; 18 | @Input() entities: ITweetEntities; 19 | @ViewChild('TweetContent') tweetContent: ElementRef; 20 | options = { 21 | hashtagUrlBase: `javascript:void(0);`, 22 | usernameUrlBase: `javascript:void(0);`, 23 | } 24 | 25 | constructor( 26 | public appCtrl: App, 27 | ) { } 28 | 29 | ngOnChanges(changes: any) { 30 | if (changes.text && changes.text.currentValue && this.tweetContent && changes.text.currentValue !== changes.text.previousValue) { 31 | this.text = this.entities 32 | ? autoLinkWithJSON(this.text, this.entities || {}, this.options) 33 | : autoLink(this.text, this.options); 34 | this.tweetContent.nativeElement.innerHTML = this.text; 35 | const links = this.tweetContent.nativeElement.querySelectorAll('a'); 36 | 37 | [].forEach.call(links, a => { 38 | if (a.classList.contains('username')) { 39 | a.onclick = e => this.goToProfile(e, a.innerText); 40 | } else if (a.classList.contains('hashtag')) { 41 | a.onclick = e => this.goToSearch(e, a.innerText); 42 | } else { 43 | a.onclick = e => this.openLink(e, a.getAttribute('href')); 44 | } 45 | }); 46 | } 47 | } 48 | 49 | openLink(e, url) { 50 | e.preventDefault(); 51 | e.stopPropagation(); 52 | window.open(url, '_blank'); 53 | }; 54 | 55 | goToProfile(e, handle) { 56 | e.preventDefault(); 57 | e.stopPropagation(); 58 | this.appCtrl.getRootNav().push('ProfilePage', { handle }); 59 | }; 60 | 61 | goToSearch(e, searchTerm) { 62 | e.preventDefault(); 63 | e.stopPropagation(); 64 | this.appCtrl.getRootNav().push('SearchPage', { query: encodeURIComponent(searchTerm) }); 65 | }; 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/components/tweet/tweet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |

{{data.user?.name}}

6 | @{{data.user?.screen_name}} 7 | · 8 | {{timeAgo}} 9 |
10 | 11 | 12 | 13 | 14 |
15 | 18 | 22 | 26 |
27 |
-------------------------------------------------------------------------------- /src/components/tweet/tweet.scss: -------------------------------------------------------------------------------- 1 | tweet { 2 | tweet { 3 | margin-top: 10px; 4 | display: block; 5 | border: 1px solid $gray-lighter; 6 | border-radius: 5px; 7 | .item-inner{ 8 | border: 0 !important; 9 | } 10 | } 11 | .item-inner { 12 | padding: 10px 0; 13 | ion-label{ 14 | overflow: hidden; 15 | flex:1; 16 | } 17 | } 18 | .title { 19 | flex-direction: row; 20 | display: flex; 21 | h2 { 22 | font-size: 1.5rem !important; 23 | font-weight: bold; 24 | margin-right: 3px; 25 | white-space: nowrap; 26 | } 27 | > span { 28 | margin: 0 3px; 29 | color: color($colors, gray); 30 | @include ellipsis(); 31 | } 32 | } 33 | avatar{ 34 | margin: 0 !important; 35 | } 36 | ion-item { 37 | align-items: baseline !important; 38 | } 39 | .actions{ 40 | margin-top: 10px; 41 | display: flex; 42 | flex-direction: row; 43 | justify-content: space-between; 44 | button { 45 | margin: 0; 46 | font-size: 1.7rem !important; 47 | color: color($colors, gray); 48 | &.retweet.active { 49 | color: color($colors, secondary) 50 | } 51 | &.favorite.active { 52 | color: color($colors, danger) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/tweet/tweet.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { App } from 'ionic-angular'; 3 | import { ModalController } from 'ionic-angular'; 4 | import javascript_time_ago from 'javascript-time-ago' 5 | import _get from 'lodash/get'; 6 | 7 | import { ITweet, ITweetEntitiesMedia } from './../../reducers'; 8 | import { TweetProvider } from './../../providers'; 9 | 10 | javascript_time_ago.locale(require('javascript-time-ago/locales/en')) 11 | const time_ago = new javascript_time_ago('en'); 12 | /** 13 | * Generated class for the TweetComponent component. 14 | * 15 | * See https://angular.io/docs/ts/latest/api/core/index/ComponentMetadata-class.html 16 | * for more info on Angular Components. 17 | */ 18 | @Component({ 19 | selector: 'tweet', 20 | templateUrl: 'tweet.html', 21 | }) 22 | export class TweetComponent { 23 | media: ITweetEntitiesMedia; 24 | timeAgo: string; 25 | text: string; 26 | 27 | @Input() data: ITweet; 28 | @Input() inDetailsPage: boolean = false; 29 | @Input() retweetedStatus: boolean = false; 30 | 31 | constructor( 32 | public appCtrl: App, 33 | public tweetProvider: TweetProvider, 34 | private modalCtrl: ModalController, 35 | ) { } 36 | 37 | ngOnInit() { 38 | this.media = _get(this.data, 'entities.media[0]'); 39 | this.timeAgo = time_ago.format(new Date(this.data.created_at)); 40 | } 41 | 42 | goToProfile(e, handle) { 43 | e.preventDefault(); 44 | e.stopPropagation(); 45 | this.appCtrl.getRootNav().push('ProfilePage', { handle }); 46 | }; 47 | 48 | goToTweetDetails(e) { 49 | e.preventDefault(); 50 | e.stopPropagation(); 51 | if (!this.inDetailsPage) { 52 | this.appCtrl.getRootNav().push('TweetDetailsPage', { id: this.data.id_str }); 53 | } 54 | } 55 | 56 | retweet(e) { 57 | e.preventDefault(); 58 | e.stopPropagation(); 59 | return this.tweetProvider.retweet$(!this.data.retweeted, this.data.id_str).subscribe() 60 | } 61 | 62 | favorite(e) { 63 | e.preventDefault(); 64 | e.stopPropagation(); 65 | return this.tweetProvider.favorite$(!this.data.favorited, this.data.id_str).subscribe() 66 | } 67 | 68 | reply(e) { 69 | e.preventDefault(); 70 | e.stopPropagation(); 71 | let tweetModal = this.modalCtrl.create('TweetPage', { 72 | in_reply_to_status_id: this.data.id_str, 73 | username: this.data.userHandle, 74 | }) 75 | tweetModal.present(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Declaration files are how the Typescript compiler knows about the type information(or shape) of an object. 3 | They're what make intellisense work and make Typescript know all about your code. 4 | 5 | A wildcard module is declared below to allow third party libraries to be used in an app even if they don't 6 | provide their own type declarations. 7 | 8 | To learn more about using third party libraries in an Ionic app, check out the docs here: 9 | http://ionicframework.com/docs/v2/resources/third-party-libs/ 10 | 11 | For more info on type definition files, check out the Typescript docs here: 12 | https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html 13 | */ 14 | declare module '*'; 15 | declare let __DEV__: string; 16 | declare let __PROD__: string; 17 | declare let __APIURI__: string; -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { MenuController } from 'ionic-angular'; 2 | import { AuthProvider } from '../providers'; 3 | 4 | export function canEnterIfAuthenticated(target) { 5 | target.prototype.ionViewCanEnter = function () { 6 | return this.injector.get(AuthProvider).isAuthenticated(); 7 | } 8 | } 9 | 10 | export function bindOpenMenu(target) { 11 | target.prototype.openMenu = function () { 12 | this.injector.get(MenuController).open(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/effects/auth.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, Effect } from '@ngrx/effects'; 3 | import { Observable } from 'rxjs/Observable'; 4 | 5 | import { TwitterProvider } from './../providers'; 6 | import { addCurrentTwitterUser } from './../actions'; 7 | 8 | @Injectable() 9 | export class AuthEffects { 10 | constructor( 11 | private twitterProvider: TwitterProvider, 12 | private actions$: Actions, 13 | ) {} 14 | 15 | @Effect() login$ = this.actions$ 16 | .ofType('LOGIN') 17 | // Map the payload into JSON to use as the request body 18 | .map(action => JSON.stringify(action.payload)) 19 | .debounceTime(100) 20 | .switchMap(action => 21 | this.twitterProvider 22 | .getUser$() 23 | .first() 24 | .map(user => addCurrentTwitterUser(user)) 25 | .catch(() => Observable.of({ type: 'LOGIN_FAILED' })), 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ionic Twitter PWA 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 68 | 70 | 71 | 73 | 74 | 75 | 78 | 79 | 80 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#ffffff", 3 | "description": "Twitter PWA rewritten with Ionic", 4 | "display": "standalone", 5 | "icons": [ 6 | { 7 | "src": "assets/icon/app_48.png", 8 | "sizes": "48x48", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "assets/icon/app_72.png", 13 | "sizes": "72x72", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "assets/icon/app_96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "assets/icon/app_144.png", 23 | "sizes": "144x144", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "assets/icon/app_168.png", 28 | "sizes": "168x168", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "assets/icon/app_192.png", 33 | "sizes": "192x192", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "assets/icon/app_512.png", 38 | "sizes": "512x512", 39 | "type": "image/png" 40 | } 41 | ], 42 | "name": "Twitter PWA", 43 | "orientation": "portrait", 44 | "short_name": "Twitter PWA", 45 | "start_url": "/", 46 | "theme_color": "#ffffff" 47 | } 48 | -------------------------------------------------------------------------------- /src/ngsw-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "routing": { 3 | "index": "/index.html", 4 | "routes": { 5 | "/": { 6 | "prefix": false 7 | }, 8 | "/#/nav": { 9 | "prefix": true 10 | } 11 | } 12 | }, 13 | "dynamic": { 14 | "group": [ 15 | { 16 | "name": "twitter proxy api", 17 | "urls": { 18 | "http://127.0.0.1:5000/api/": { 19 | "match": "prefix" 20 | }, 21 | "https://twitter-pwa.julienrenaux.fr/api/": { 22 | "match": "prefix" 23 | } 24 | }, 25 | "cache": { 26 | "optimizeFor": "freshness", 27 | "maxAgeMs": 3600000, 28 | "maxEntries": 20, 29 | "strategy": "lru" 30 | } 31 | } 32 | ] 33 | } 34 | } -------------------------------------------------------------------------------- /src/pages/feed/feed.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | Home 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/pages/feed/feed.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { FeedPage } from './feed'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | FeedPage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(FeedPage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | FeedPage 17 | ] 18 | }) 19 | export class FeedPageModule {} 20 | -------------------------------------------------------------------------------- /src/pages/feed/feed.scss: -------------------------------------------------------------------------------- 1 | page-feed { 2 | > ion-content { 3 | > .scroll-content { 4 | overflow: hidden; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/feed/feed.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 3 | import { Component, Injector } from '@angular/core'; 4 | import { 5 | IonicPage, 6 | NavController, 7 | NavParams, 8 | InfiniteScroll, 9 | Refresher, 10 | } from 'ionic-angular'; 11 | 12 | import { canEnterIfAuthenticated } from '../../decorators'; 13 | import { FeedProvider, UsersProvider } from '../../providers'; 14 | import { ITweet } from './../../reducers'; 15 | /** 16 | * Generated class for the FeedPage page. 17 | * 18 | * See http://ionicframework.com/docs/components/#navigation for more info 19 | * on Ionic pages and navigation. 20 | */ 21 | @canEnterIfAuthenticated 22 | @IonicPage() 23 | @Component({ 24 | selector: 'page-feed', 25 | templateUrl: 'feed.html', 26 | }) 27 | export class FeedPage { 28 | feed$: Observable; 29 | fetching$: Observable; 30 | page: number = 0; 31 | itemsToDisplay$ = new BehaviorSubject(1); 32 | 33 | constructor( 34 | public navCtrl: NavController, 35 | public navParams: NavParams, 36 | public feedProvider: FeedProvider, 37 | public usersProvider: UsersProvider, 38 | public injector: Injector, 39 | ) { } 40 | 41 | ionViewDidLoad() { 42 | this.feed$ = this.feedProvider.getFeedPaginated$(this.itemsToDisplay$); 43 | this.fetching$ = this.feedProvider.isFetching$(); 44 | 45 | const hasFeed = this.feedProvider.hasFeed(); 46 | if (!hasFeed) { 47 | console.log('init') 48 | this.feedProvider 49 | .fetch$() 50 | .first() 51 | .subscribe(() => { }, error => console.log('feed error', error)); 52 | } 53 | } 54 | 55 | refresh(refresher: Refresher) { 56 | console.log('refresh') 57 | this.feedProvider 58 | .fetch$() 59 | .first() 60 | .finally(() => refresher.complete()) 61 | .subscribe(() => { }, error => console.log('feed error', error)); 62 | } 63 | 64 | loadMore(infiniteScroll: InfiniteScroll) { 65 | console.log('loadMore') 66 | let currentLength; 67 | this.feed$ 68 | .first() 69 | .subscribe((items: ITweet[]) => (currentLength = items.length)); 70 | 71 | console.log('loadMore', this.feedProvider.feedLength(), currentLength) 72 | if (this.feedProvider.feedLength() > currentLength) { 73 | this.nextPage(); 74 | infiniteScroll.complete(); 75 | } else { 76 | this.feedProvider 77 | .fetchNextPage$() 78 | .first() 79 | .finally(() => infiniteScroll.complete()) 80 | .subscribe( 81 | () => this.nextPage(), 82 | error => console.log('feed error', error), 83 | ); 84 | } 85 | } 86 | 87 | nextPage = (): void => { 88 | this.page += 1; 89 | this.itemsToDisplay$.next(this.page); 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /src/pages/home/home.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/pages/home/home.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | import { HomePage } from './home'; 4 | 5 | @NgModule({ 6 | declarations: [ 7 | HomePage, 8 | ], 9 | imports: [ 10 | IonicPageModule.forChild(HomePage), 11 | ], 12 | exports: [ 13 | HomePage 14 | ] 15 | }) 16 | export class HomePageModule {} 17 | -------------------------------------------------------------------------------- /src/pages/home/home.scss: -------------------------------------------------------------------------------- 1 | page-home { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/home/home.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { IonicPage, NavController, NavParams } from 'ionic-angular'; 3 | 4 | /** 5 | * Generated class for the HomePage page. 6 | * 7 | * See http://ionicframework.com/docs/components/#navigation for more info 8 | * on Ionic pages and navigation. 9 | */ 10 | @IonicPage() 11 | @Component({ 12 | selector: 'page-home', 13 | templateUrl: 'home.html', 14 | }) 15 | export class HomePage { 16 | 17 | FeedPage: any = 'FeedPage'; 18 | SearchTabPage: any = 'SearchTabPage'; 19 | MentionsPage: any = 'MentionsPage'; 20 | MessagesPage: any = 'MessagesPage'; 21 | 22 | constructor(public navCtrl: NavController, public navParams: NavParams) { 23 | } 24 | 25 | ionViewDidLoad() { 26 | console.log('ionViewDidLoad HomePage'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/login/login.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | twitter pwa icon 11 |

Ionic Twitter PWA

12 | 13 |
14 | Crafted by @julienrenaux 15 |
16 | -------------------------------------------------------------------------------- /src/pages/login/login.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | import { LoginPage } from './login'; 4 | 5 | @NgModule({ 6 | declarations: [ 7 | LoginPage, 8 | ], 9 | imports: [ 10 | IonicPageModule.forChild(LoginPage), 11 | ], 12 | exports: [ 13 | LoginPage 14 | ] 15 | }) 16 | export class LoginPageModule {} 17 | -------------------------------------------------------------------------------- /src/pages/login/login.scss: -------------------------------------------------------------------------------- 1 | page-login { 2 | .scroll-content { 3 | @include verticalCentered(); 4 | flex-direction: column; 5 | } 6 | h1 { 7 | font-size: 3rem; 8 | } 9 | hr { 10 | margin: 30px 0 10px 0; 11 | width: 100px; 12 | } 13 | .github-corner { 14 | svg { 15 | fill: color($colors, primary); 16 | } 17 | &:hover .octo-arm { 18 | animation: octocat-wave 560ms ease-in-out 19 | } 20 | } 21 | @keyframes octocat-wave { 22 | 0%, 100% { 23 | transform: rotate(0) 24 | } 25 | 20%, 60% { 26 | transform: rotate(-25deg) 27 | } 28 | 40%, 80% { 29 | transform: rotate(10deg) 30 | } 31 | } 32 | @media (max-width:500px) { 33 | .github-corner:hover .octo-arm { 34 | animation: none 35 | } 36 | .github-corner .octo-arm { 37 | animation: octocat-wave 560ms ease-in-out 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/pages/login/login.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { IonicPage, NavController, NavParams, ViewController } from 'ionic-angular'; 3 | 4 | import { AuthProvider } from '../../providers'; 5 | 6 | /** 7 | * Generated class for the LoginPage page. 8 | * 9 | * See http://ionicframework.com/docs/components/#navigation for more info 10 | * on Ionic pages and navigation. 11 | */ 12 | @IonicPage() 13 | @Component({ 14 | selector: 'page-login', 15 | templateUrl: 'login.html', 16 | }) 17 | export class LoginPage { 18 | platforms: string[]; 19 | constructor( 20 | public navCtrl: NavController, 21 | public viewCtrl: ViewController, 22 | public navParams: NavParams, 23 | public authProvider: AuthProvider, 24 | ) { 25 | } 26 | 27 | ionViewDidLoad() { 28 | console.log('ionViewDidLoad LoginPage'); 29 | } 30 | 31 | // ionViewCanEnter(): Promise { 32 | // return new Promise((resolve, reject) => { 33 | // const canEnter = !this.authProvider.isAuthenticated(); 34 | // console.log('login canEnter', canEnter) 35 | // if (!canEnter) { 36 | // this.goToHomePage(); 37 | // } 38 | // resolve(canEnter); 39 | // }); 40 | // } 41 | 42 | ionViewCanLeave(): boolean { 43 | console.log('login ionViewCanLeave', this.authProvider.isAuthenticated()) 44 | return this.authProvider.isAuthenticated(); 45 | } 46 | 47 | login() { 48 | this.authProvider.login(); 49 | } 50 | 51 | dismiss() { 52 | this.viewCtrl.dismiss(); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/pages/mentions/mentions.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | Mentions 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/pages/mentions/mentions.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { MentionsPage } from './mentions'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | MentionsPage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(MentionsPage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | MentionsPage 17 | ] 18 | }) 19 | export class MentionsPageModule {} 20 | -------------------------------------------------------------------------------- /src/pages/mentions/mentions.scss: -------------------------------------------------------------------------------- 1 | page-mentions { 2 | > ion-content { 3 | > .scroll-content { 4 | overflow: hidden; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/mentions/mentions.ts: -------------------------------------------------------------------------------- 1 | import { Component, Injector } from '@angular/core'; 2 | import { Observable } from 'rxjs/Observable'; 3 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 4 | import { IonicPage, NavController, NavParams, Refresher, InfiniteScroll } from 'ionic-angular'; 5 | 6 | import { MentionsProvider } from './../../providers'; 7 | import { canEnterIfAuthenticated } from '../../decorators'; 8 | import { ITweet } from './../../reducers'; 9 | /** 10 | * Generated class for the MentionsPage page. 11 | * 12 | * See http://ionicframework.com/docs/components/#navigation for more info 13 | * on Ionic pages and navigation. 14 | */ 15 | @canEnterIfAuthenticated 16 | @IonicPage() 17 | @Component({ 18 | selector: 'page-mentions', 19 | templateUrl: 'mentions.html', 20 | }) 21 | export class MentionsPage { 22 | feed$: Observable; 23 | fetching$: Observable; 24 | page: number = 0; 25 | itemsToDisplay$ = new BehaviorSubject(1); 26 | 27 | constructor( 28 | public navCtrl: NavController, 29 | public navParams: NavParams, 30 | public injector: Injector, 31 | public mentionsProvider: MentionsProvider, 32 | ) { } 33 | 34 | ionViewDidLoad() { 35 | this.feed$ = this.mentionsProvider.getMentionsPaginated$(this.itemsToDisplay$); 36 | this.fetching$ = this.mentionsProvider.isFetching$(); 37 | 38 | const hasFeed = this.mentionsProvider.hasFeed(); 39 | if (!hasFeed) { 40 | console.log('hasFeed', hasFeed) 41 | this.mentionsProvider 42 | .fetch$() 43 | .first() 44 | .subscribe(() => { }, error => console.log('feed error', error)); 45 | } 46 | } 47 | 48 | refresh(refresher: Refresher) { 49 | console.log('refresh') 50 | this.mentionsProvider 51 | .fetch$() 52 | .first() 53 | .finally(() => refresher.complete()) 54 | .subscribe(() => { }, error => console.log('feed error', error)); 55 | } 56 | 57 | loadMore(infiniteScroll: InfiniteScroll) { 58 | console.log('loadMore') 59 | let currentLength; 60 | this.feed$ 61 | .first() 62 | .subscribe((items: ITweet[]) => (currentLength = items.length)); 63 | 64 | if (this.mentionsProvider.feedLength() > currentLength) { 65 | this.nextPage(); 66 | infiniteScroll.complete(); 67 | } else { 68 | this.mentionsProvider 69 | .fetchNextPage$() 70 | .first() 71 | .finally(() => infiniteScroll.complete()) 72 | .subscribe( 73 | () => this.nextPage(), 74 | error => console.log('feed error', error), 75 | ); 76 | } 77 | } 78 | 79 | nextPage = (): void => { 80 | this.page += 1; 81 | this.itemsToDisplay$.next(this.page); 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/pages/messages/messages.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | Messages 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/pages/messages/messages.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { MessagesPage } from './messages'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | MessagesPage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(MessagesPage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | MessagesPage 17 | ] 18 | }) 19 | export class MessagesPageModule {} 20 | -------------------------------------------------------------------------------- /src/pages/messages/messages.scss: -------------------------------------------------------------------------------- 1 | page-messages { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/messages/messages.ts: -------------------------------------------------------------------------------- 1 | import { Component, Injector } from '@angular/core'; 2 | 3 | import { IonicPage, NavController, NavParams } from 'ionic-angular'; 4 | 5 | import { TwitterProvider } from '../../providers'; 6 | import { canEnterIfAuthenticated } from '../../decorators'; 7 | /** 8 | * Generated class for the MessagesPage page. 9 | * 10 | * See http://ionicframework.com/docs/components/#navigation for more info 11 | * on Ionic pages and navigation. 12 | */ 13 | @canEnterIfAuthenticated 14 | @IonicPage() 15 | @Component({ 16 | selector: 'page-messages', 17 | templateUrl: 'messages.html', 18 | }) 19 | export class MessagesPage { 20 | 21 | constructor( 22 | public navCtrl: NavController, 23 | public navParams: NavParams, 24 | public twitter: TwitterProvider, 25 | public injector: Injector, 26 | ) { 27 | } 28 | 29 | ionViewDidLoad() { 30 | console.log('ionViewDidLoad MessagesPage'); 31 | this.twitter.getDirectMessages().subscribe() 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/notifications/notifications.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | Notifications 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/pages/notifications/notifications.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { NotificationsPage } from './notifications'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | NotificationsPage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(NotificationsPage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | NotificationsPage 17 | ] 18 | }) 19 | export class NotificationsPageModule {} 20 | -------------------------------------------------------------------------------- /src/pages/notifications/notifications.scss: -------------------------------------------------------------------------------- 1 | page-notifications { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/notifications/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Component, Injector } from '@angular/core'; 2 | import { IonicPage, NavController, NavParams } from 'ionic-angular'; 3 | 4 | import { TwitterProvider } from './../../providers'; 5 | import { canEnterIfAuthenticated } from '../../decorators'; 6 | /** 7 | * Generated class for the NotificationsPage page. 8 | * 9 | * See http://ionicframework.com/docs/components/#navigation for more info 10 | * on Ionic pages and navigation. 11 | */ 12 | @canEnterIfAuthenticated 13 | @IonicPage() 14 | @Component({ 15 | selector: 'page-notifications', 16 | templateUrl: 'notifications.html', 17 | }) 18 | export class NotificationsPage { 19 | constructor( 20 | public navCtrl: NavController, 21 | public navParams: NavParams, 22 | public injector: Injector, 23 | public twitterProvider: TwitterProvider, 24 | ) {} 25 | 26 | ionViewDidLoad() { 27 | console.log('ionViewDidLoad NotificationsPage'); 28 | this.twitterProvider.getMentions$().subscribe(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/profile/profile.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | {{user?.name}} 12 | 13 | 14 | 15 | 16 | 17 |
18 | 23 | 24 | Tweets 25 | 26 | 27 | Likes 28 | 29 | 30 |
31 | 37 | 38 | 44 | 45 |
-------------------------------------------------------------------------------- /src/pages/profile/profile.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { ProfilePage } from './profile'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | ProfilePage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(ProfilePage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | ProfilePage 17 | ] 18 | }) 19 | export class ProfilePageModule {} 20 | -------------------------------------------------------------------------------- /src/pages/profile/profile.scss: -------------------------------------------------------------------------------- 1 | page-profile { 2 | ion-navbar { 3 | background-repeat: no-repeat; 4 | background-size: cover; 5 | ion-title { 6 | display: none; 7 | } 8 | ion-icon { 9 | color: white; 10 | text-shadow: 0px 0px 1px #000; 11 | } 12 | &.not-afix{ 13 | background-image: none !important; 14 | } 15 | &.afix{ 16 | transition: opacity 200ms linear; 17 | box-shadow: inset 0px 0px 217px -8px rgba(0,0,0,0.75); 18 | ion-title{ 19 | display: block; 20 | animation-name: fadeInUp; 21 | animation-duration: 0.5s; 22 | > div { 23 | color: white; 24 | } 25 | } 26 | } 27 | } 28 | .scroll-content { 29 | padding: 0 !important; 30 | } 31 | .segment-wrapper { 32 | height: 42px; 33 | ion-segment{ 34 | background-color: white; 35 | &.afix{ 36 | position: fixed; 37 | top: 56px; 38 | z-index: 2; 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /src/pages/profile/profile.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 2 | import { Observable } from 'rxjs/Observable'; 3 | import { Component, ViewChild, ElementRef, NgZone } from '@angular/core'; 4 | import { 5 | IonicPage, NavController, NavParams, 6 | InfiniteScroll, Refresher, Content 7 | } from 'ionic-angular'; 8 | 9 | import { ITwitterUser, ITweet } from './../../reducers'; 10 | import { UsersProvider, UserTweetsProvider, UserLikesProvider } from './../../providers'; 11 | /** 12 | * Generated class for the ProfilePage page. 13 | * 14 | * See http://ionicframework.com/docs/components/#navigation for more info 15 | * on Ionic pages and navigation. 16 | */ 17 | @IonicPage({ 18 | segment: 'profile/:handle' 19 | }) 20 | @Component({ 21 | selector: 'page-profile', 22 | templateUrl: 'profile.html', 23 | }) 24 | export class ProfilePage { 25 | afix: boolean = false; 26 | tab: string = 'tweets'; 27 | handle: string; 28 | user: ITwitterUser; 29 | // tweets 30 | tweets$: Observable; 31 | tweetsFetching$: Observable; 32 | tweetsPage: number = 0; 33 | tweetsItemsToDisplay$ = new BehaviorSubject(1); 34 | // likes 35 | likes$: Observable; 36 | likesFetching$: Observable; 37 | likesPage: number = 0; 38 | likesItemsToDisplay$ = new BehaviorSubject(1); 39 | 40 | @ViewChild(Content) content: Content; 41 | @ViewChild('segmentWrapper') segmentWrapper: ElementRef; 42 | 43 | constructor( 44 | public navCtrl: NavController, 45 | public navParams: NavParams, 46 | private usersProvider: UsersProvider, 47 | private userTweetsProvider: UserTweetsProvider, 48 | private userLikesProvider: UserLikesProvider, 49 | public zone: NgZone, 50 | ) { 51 | } 52 | 53 | ionViewDidLoad() { 54 | this.handle = this.navParams.get('handle'); 55 | this.usersProvider.getUserById$(this.handle).subscribe(user => this.user = user); 56 | 57 | if (!this.usersProvider.doesUserExist(this.handle)) { 58 | this.usersProvider.fetchUser$(this.handle) 59 | .first() 60 | .subscribe(() => { }, error => console.log('fetchUser error', error)); 61 | } 62 | 63 | this.tweets$ = this.userTweetsProvider.getUserTweetsPaginated$(this.handle, this.tweetsItemsToDisplay$); 64 | this.tweetsFetching$ = this.userTweetsProvider.isFetching$(); 65 | 66 | this.likes$ = this.userLikesProvider.getUserLikesPaginated$(this.handle, this.likesItemsToDisplay$); 67 | this.likesFetching$ = this.userLikesProvider.isFetching$(); 68 | 69 | this.init(); 70 | } 71 | 72 | ngAfterViewInit() { 73 | this.content.ionScroll.subscribe(e => { 74 | const top = this.segmentWrapper.nativeElement.getBoundingClientRect().top; 75 | if (top <= 56 && !this.afix) { 76 | this.zone.run(()=> this.afix = true); 77 | } else if (top > 56 && this.afix) { 78 | this.zone.run(()=> this.afix = false); 79 | } 80 | }); 81 | } 82 | 83 | init(selectedSegment = this.tab) { 84 | selectedSegment === 'tweets' ? this.initTweets() : this.initLikes(); 85 | } 86 | 87 | initTweets() { 88 | const hasFeed = this.userTweetsProvider.hasUserTweets(this.handle); 89 | if (!hasFeed) { 90 | console.log('init userTweetsProvider') 91 | this.userTweetsProvider 92 | .fetch$(this.handle) 93 | .first() 94 | .subscribe(() => { }, error => console.log('feed error', error)); 95 | } 96 | } 97 | 98 | loadMoreTweets(infiniteScroll: InfiniteScroll) { 99 | console.log('loadMoreTweets') 100 | let currentLength; 101 | this.tweets$.first().subscribe((items: ITweet[]) => (currentLength = items.length)); 102 | 103 | if (this.userTweetsProvider.userTweetsLength() > currentLength) { 104 | this.nextTweetsPage(); 105 | infiniteScroll.complete(); 106 | } else { 107 | this.userTweetsProvider 108 | .fetchNextPage$(this.handle) 109 | .first() 110 | .finally(() => infiniteScroll.complete()) 111 | .subscribe( 112 | () => this.nextTweetsPage(), 113 | error => console.log('feed error', error), 114 | ); 115 | } 116 | } 117 | 118 | nextTweetsPage = (): void => { 119 | this.tweetsPage += 1; 120 | this.tweetsItemsToDisplay$.next(this.tweetsPage); 121 | }; 122 | 123 | initLikes() { 124 | const hasFeed = this.userLikesProvider.hasUserLikes(this.handle); 125 | if (!hasFeed) { 126 | console.log('init userLikesProvider') 127 | this.userLikesProvider 128 | .fetch$(this.handle) 129 | .first() 130 | .subscribe(() => { }, error => console.log('feed error', error)); 131 | } 132 | } 133 | 134 | refreshLikes(refresher: Refresher) { 135 | console.log('refreshLikes') 136 | this.userLikesProvider 137 | .fetch$(this.handle) 138 | .first() 139 | .finally(() => refresher.complete()) 140 | .subscribe(() => { }, error => console.log('feed error', error)); 141 | } 142 | 143 | loadMoreLikes(infiniteScroll: InfiniteScroll) { 144 | console.log('loadMoreLikes') 145 | let currentLength; 146 | this.tweets$.first().subscribe((items: ITweet[]) => (currentLength = items.length)); 147 | 148 | if (this.userLikesProvider.userLikesLength() > currentLength) { 149 | this.nextLikesPage(); 150 | infiniteScroll.complete(); 151 | } else { 152 | this.userLikesProvider 153 | .fetchNextPage$(this.handle) 154 | .first() 155 | .finally(() => infiniteScroll.complete()) 156 | .subscribe( 157 | () => this.nextLikesPage(), 158 | error => console.log('feed error', error), 159 | ); 160 | } 161 | } 162 | 163 | nextLikesPage = (): void => { 164 | this.likesPage += 1; 165 | this.likesItemsToDisplay$.next(this.likesPage); 166 | }; 167 | 168 | changeTab({ value }) { 169 | this.init(value); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/pages/search-tab/search-tab.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | Trending Now 18 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/pages/search-tab/search-tab.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { SearchTabPage } from './search-tab'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | SearchTabPage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(SearchTabPage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | SearchTabPage 17 | ] 18 | }) 19 | export class SearchTabPageModule {} 20 | -------------------------------------------------------------------------------- /src/pages/search-tab/search-tab.scss: -------------------------------------------------------------------------------- 1 | page-search-tab { 2 | > ion-content { 3 | > .scroll-content { 4 | overflow: hidden; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/search-tab/search-tab.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Component, Injector } from '@angular/core'; 3 | import { 4 | App, IonicPage, NavController, 5 | NavParams, ModalController, Refresher 6 | } from 'ionic-angular'; 7 | 8 | import { canEnterIfAuthenticated } from '../../decorators'; 9 | import { TrendsProvider } from './../../providers'; 10 | import { ITrendingHashtag } from './../../reducers'; 11 | /** 12 | * Generated class for the SearchTabPage page. 13 | * 14 | * See http://ionicframework.com/docs/components/#navigation for more info 15 | * on Ionic pages and navigation. 16 | */ 17 | @canEnterIfAuthenticated 18 | @IonicPage() 19 | @Component({ 20 | selector: 'page-search-tab', 21 | templateUrl: 'search-tab.html', 22 | }) 23 | export class SearchTabPage { 24 | trendingHashtags$: Observable 25 | fetching$: Observable; 26 | searchTerm: string; 27 | 28 | constructor( 29 | public navCtrl: NavController, 30 | public navParams: NavParams, 31 | public injector: Injector, 32 | public trendsProvider: TrendsProvider, 33 | private modalCtrl: ModalController, 34 | public appCtrl: App, 35 | ) { } 36 | 37 | ionViewDidLoad() { 38 | console.log('ionViewDidLoad SearchTabPage'); 39 | this.trendingHashtags$ = this.trendsProvider.getTrendsHashtags$(); 40 | this.fetching$ = this.trendsProvider.isFetching$(); 41 | } 42 | 43 | ionViewWillLeave() { 44 | console.log('ionViewWillLeave SearchTabPage'); 45 | } 46 | 47 | ionViewWillEnter() { 48 | const canFetch = this.trendsProvider.canFetchNewContent(); 49 | if (canFetch) { 50 | console.log('init') 51 | this.trendsProvider 52 | .fetch$() 53 | .first() 54 | .subscribe(() => { }, error => console.log('trends error', error)); 55 | } 56 | } 57 | 58 | refresh(refresher: Refresher) { 59 | console.log('refresh') 60 | this.trendsProvider 61 | .fetch$() 62 | .first() 63 | .finally(() => refresher.complete()) 64 | .subscribe(() => { }, error => console.log('trends error', error)); 65 | } 66 | 67 | searchFromInput(e) { 68 | this.appCtrl.getRootNav().push('SearchPage', { query: encodeURIComponent(this.searchTerm) }); 69 | this.searchTerm = ''; 70 | } 71 | 72 | search(item) { 73 | this.appCtrl.getRootNav().push('SearchPage', { query: item.query }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/search/search.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 24 | 28 | 29 | -------------------------------------------------------------------------------- /src/pages/search/search.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { SearchPage } from './search'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | SearchPage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(SearchPage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | SearchPage 17 | ] 18 | }) 19 | export class SearchPageModule {} 20 | -------------------------------------------------------------------------------- /src/pages/search/search.scss: -------------------------------------------------------------------------------- 1 | page-search { 2 | > ion-content { 3 | > .scroll-content { 4 | overflow: hidden; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/search/search.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 3 | import { Component, Injector } from '@angular/core'; 4 | import { IonicPage, NavController, NavParams, Refresher, InfiniteScroll } from 'ionic-angular'; 5 | 6 | import { canEnterIfAuthenticated } from '../../decorators'; 7 | import { SearchProvider } from './../../providers'; 8 | import { ITweet } from './../../reducers'; 9 | /** 10 | * Generated class for the SearchPage page. 11 | * 12 | * See http://ionicframework.com/docs/components/#navigation for more info 13 | * on Ionic pages and navigation. 14 | */ 15 | @canEnterIfAuthenticated 16 | @IonicPage({ 17 | segment: 'search/:query' 18 | }) 19 | @Component({ 20 | selector: 'page-search', 21 | templateUrl: 'search.html', 22 | }) 23 | export class SearchPage { 24 | query: string; 25 | feed$: Observable; 26 | fetching$: Observable; 27 | page: number = 0; 28 | itemsToDisplay$ = new BehaviorSubject(1); 29 | 30 | constructor( 31 | public navCtrl: NavController, 32 | public navParams: NavParams, 33 | public injector: Injector, 34 | public searchProvider: SearchProvider 35 | ) { 36 | // this.navCtrl.insert(this.navCtrl.length() - 1, 'HomePage'); 37 | } 38 | 39 | ionViewDidLoad() { 40 | this.query = decodeURIComponent(this.navParams.get('query')); 41 | this.init(); 42 | } 43 | 44 | init() { 45 | this.feed$ = this.searchProvider.getSearchPaginated$(this.query, this.itemsToDisplay$); 46 | this.fetching$ = this.searchProvider.isFetching$(); 47 | 48 | const hasFeed = this.searchProvider.hasSearch(this.query); 49 | if (!hasFeed) { 50 | console.log('hasFeed', hasFeed) 51 | this.searchProvider 52 | .fetch$(this.query) 53 | .first() 54 | .subscribe(() => { }, error => console.log('feed error', error)); 55 | } 56 | } 57 | 58 | // searchFromInput(e) { 59 | // this.appCtrl.getRootNav().push('SearchPage', { query: encodeURIComponent(this.searchTerm) }); 60 | // this.searchTerm = ''; 61 | // } 62 | 63 | search(e) { 64 | this.init(); 65 | } 66 | 67 | refresh(refresher: Refresher) { 68 | console.log('refresh') 69 | this.searchProvider 70 | .fetch$(this.query) 71 | .first() 72 | .finally(() => refresher.complete()) 73 | .subscribe(() => { }, error => console.log('feed error', error)); 74 | } 75 | 76 | loadMore(infiniteScroll: InfiniteScroll) { 77 | console.log('loadMore') 78 | let currentLength; 79 | this.feed$ 80 | .first() 81 | .subscribe((items: ITweet[]) => (currentLength = items.length)); 82 | 83 | if (this.searchProvider.searchLength(this.query) > currentLength) { 84 | this.nextPage(); 85 | infiniteScroll.complete(); 86 | } else { 87 | this.searchProvider 88 | .fetchNextPage$(this.query) 89 | .first() 90 | .finally(() => infiniteScroll.complete()) 91 | .subscribe( 92 | () => this.nextPage(), 93 | error => console.log('feed error', error), 94 | ); 95 | } 96 | } 97 | 98 | nextPage = (): void => { 99 | this.page += 1; 100 | this.itemsToDisplay$.next(this.page); 101 | }; 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/pages/tweet-details/tweet-details.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | Tweet 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/pages/tweet-details/tweet-details.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { TweetDetailsPage } from './tweet-details'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | TweetDetailsPage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(TweetDetailsPage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | TweetDetailsPage 17 | ] 18 | }) 19 | export class TweetDetailsPageModule { } 20 | -------------------------------------------------------------------------------- /src/pages/tweet-details/tweet-details.scss: -------------------------------------------------------------------------------- 1 | page-tweet-details { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/tweet-details/tweet-details.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Component } from '@angular/core'; 3 | import { IonicPage, NavController, NavParams } from 'ionic-angular'; 4 | 5 | import { ITweet } from './../../reducers'; 6 | import { TweetProvider } from './../../providers'; 7 | /** 8 | * Generated class for the TweetDetailsPage page. 9 | * 10 | * See http://ionicframework.com/docs/components/#navigation for more info 11 | * on Ionic pages and navigation. 12 | */ 13 | @IonicPage({ 14 | segment: 'status/:id' 15 | }) 16 | @Component({ 17 | selector: 'page-tweet-details', 18 | templateUrl: 'tweet-details.html', 19 | }) 20 | export class TweetDetailsPage { 21 | tweet$: Observable; 22 | 23 | constructor( 24 | public navCtrl: NavController, 25 | public navParams: NavParams, 26 | public tweetProvider: TweetProvider) { 27 | } 28 | 29 | ionViewDidLoad() { 30 | console.log('ionViewDidLoad TweetDetailsPage'); 31 | this.tweet$ = this.tweetProvider.getById$(this.navParams.get('id')); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/tweet/tweet.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |

{{ replyingTo }}

21 |
22 | 23 | 24 | 31 | 32 | 33 |
34 |
35 | 36 | 37 | 38 | 39 | {{(characterCount$ | async) || maxCharacter}} 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/pages/tweet/tweet.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IonicPageModule } from 'ionic-angular'; 3 | 4 | import { TweetPage } from './tweet'; 5 | import { SharedLazyModule } from '../../app/shared.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | TweetPage, 10 | ], 11 | imports: [ 12 | IonicPageModule.forChild(TweetPage), 13 | SharedLazyModule, 14 | ], 15 | exports: [ 16 | TweetPage 17 | ] 18 | }) 19 | export class TweetPageModule {} 20 | -------------------------------------------------------------------------------- /src/pages/tweet/tweet.scss: -------------------------------------------------------------------------------- 1 | page-tweet { 2 | .toolbar-content { 3 | &.toolbar-content-ios { 4 | text-align: right; 5 | } 6 | } 7 | .replying-to{ 8 | padding: 10px; 9 | } 10 | ion-footer { 11 | .toolbar-content { 12 | align-items: center; 13 | display: flex; 14 | } 15 | .counter{ 16 | margin-right: 5px; 17 | color: $twitter-gray-text; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/pages/tweet/tweet.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Component, ViewChild } from '@angular/core'; 3 | import { FormBuilder, Validators, FormGroup } from '@angular/forms'; 4 | import { IonicPage, NavController, NavParams, ViewController, ToastController } from 'ionic-angular'; 5 | import { getTweetLength, extractMentionsOrListsWithIndices } from 'twitter-text'; 6 | 7 | import { tweetValidator } from './tweetValidator'; 8 | import { UsersProvider, TwitterProvider, TweetProvider } from './../../providers'; 9 | import { ITwitterUser, ITweet } from './../../reducers'; 10 | /** 11 | * Generated class for the TweetPage page. 12 | * 13 | * See http://ionicframework.com/docs/components/#navigation for more info 14 | * on Ionic pages and navigation. 15 | */ 16 | @IonicPage({ 17 | segment: '' 18 | }) 19 | @Component({ 20 | selector: 'page-tweet', 21 | templateUrl: 'tweet.html', 22 | }) 23 | export class TweetPage { 24 | replyingTo$: Observable; 25 | username: string; 26 | in_reply_to_status_id: string; 27 | maxCharacter: number = 140; 28 | tweetForm: FormGroup; 29 | characterCount$: Observable; 30 | user$: Observable 31 | @ViewChild('textarea') textarea; 32 | 33 | constructor( 34 | public navCtrl: NavController, 35 | public navParams: NavParams, 36 | public viewCtrl: ViewController, 37 | public twitterProvider: TwitterProvider, 38 | public tweetProvider: TweetProvider, 39 | public formBuilder: FormBuilder, 40 | public toastCtrl: ToastController, 41 | private users: UsersProvider, 42 | ) { 43 | this.tweetForm = this.formBuilder.group({ 44 | tweet: ['', [Validators.required, tweetValidator]], 45 | }); 46 | this.characterCount$ = this.tweetForm.valueChanges 47 | .map(({ tweet }) => this.maxCharacter - getTweetLength(tweet)); 48 | } 49 | 50 | ionViewDidLoad() { 51 | this.user$ = this.users.getCurrentUser$(); 52 | this.in_reply_to_status_id = this.navParams.get('in_reply_to_status_id'); 53 | this.username = this.navParams.get('username'); 54 | if (this.in_reply_to_status_id && this.username) { 55 | this.replyingTo$ = this.tweetProvider.getById$(this.in_reply_to_status_id) 56 | .map((tweet: ITweet) => { 57 | const usernames = ['@' + this.username, ...extractMentionsOrListsWithIndices(tweet.text) 58 | .map(res => '@' + res.screenName)]; 59 | let usernamesToString = usernames.join(' '); 60 | if (usernames.length > 1) { 61 | const lastUsername = usernames.splice(-1, 1); 62 | usernamesToString = usernames.join(' ') + ' & ' + lastUsername 63 | } 64 | return 'Replying to ' + usernamesToString; 65 | } 66 | ); 67 | } 68 | // wait for the animation to finish before focus 69 | setTimeout(() => this.textarea.setFocus(), 400); 70 | } 71 | 72 | ionViewDidLeave() { 73 | 74 | } 75 | 76 | tweet() { 77 | let status = this.tweetForm.value.tweet; 78 | if (this.in_reply_to_status_id && this.username) { 79 | status = `@${this.username} ${status}` 80 | } 81 | this.twitterProvider.tweet(status, this.in_reply_to_status_id).subscribe(() => { 82 | this.toastCtrl.create({ 83 | message: 'Tweet sent!', 84 | duration: 3000 85 | }).present(); 86 | this.dismiss(); 87 | }, () => { 88 | this.toastCtrl.create({ 89 | message: 'Something wrong happened, please try again!', 90 | duration: 3000 91 | }).present(); 92 | }); 93 | } 94 | 95 | dismiss() { 96 | this.viewCtrl.dismiss(); 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /src/pages/tweet/tweetValidator.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from '@angular/forms'; 2 | import { getTweetLength } from 'twitter-text'; 3 | 4 | export function tweetValidator(c: FormControl) { 5 | return getTweetLength(c.value) > 140 ? { 6 | validateTweet: { 7 | valid: false 8 | } 9 | } : null; 10 | } -------------------------------------------------------------------------------- /src/providers/auth/auth.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Injectable } from '@angular/core'; 3 | import { Store } from '@ngrx/store'; 4 | import { AngularFireAuth } from 'angularfire2/auth'; 5 | import { Network } from '@ionic-native/network'; 6 | 7 | import * as firebase from 'firebase/app'; 8 | 9 | import { AppState, IUser, ICredential, IAuthState } from '../../reducers'; 10 | import { 11 | addAuthCredential, 12 | cleanAuth, logout, login 13 | } from '../../actions'; 14 | /* 15 | Generated class for the AuthProvider provider. 16 | 17 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 18 | for more info on providers and Angular 2 DI. 19 | */ 20 | @Injectable() 21 | export class AuthProvider { 22 | isOnline: boolean = navigator.onLine; 23 | 24 | constructor( 25 | public afAuth: AngularFireAuth, 26 | public store: Store, 27 | private network: Network, 28 | ) { 29 | this.network.onConnect().subscribe(() => this.isOnline = true); 30 | this.network.onDisconnect().subscribe(() => this.isOnline = false); 31 | } 32 | 33 | run() { 34 | const credential = this.getCredential(); 35 | if (credential) { // checking credential on app ready 36 | this.afAuth.auth.signInAndRetrieveDataWithCredential( 37 | firebase.auth.TwitterAuthProvider.credential(credential.access_token_key, credential.access_token_secret) 38 | ).then( 39 | result => this.store.dispatch(addAuthCredential(result.credential)), 40 | error => { 41 | // If offline we want the last user to stay logged in 42 | this.isOnline && this.logout() 43 | }); 44 | } else { 45 | this.logout(); 46 | } 47 | 48 | this.afAuth.authState.distinctUntilChanged().subscribe((user) => { 49 | console.log('authState user', user) 50 | if (!user) return; 51 | this.store.dispatch(login(user.toJSON())); 52 | }, () => { 53 | this.store.dispatch(cleanAuth()); 54 | }); 55 | } 56 | 57 | login(): firebase.Promise { 58 | return this.afAuth.auth.signInWithPopup(new firebase.auth.TwitterAuthProvider()) 59 | .then(result => this.store.dispatch(addAuthCredential(result.credential))); 60 | } 61 | 62 | logout(): void { 63 | this.afAuth.auth.signOut(); 64 | this.store.dispatch(logout()); 65 | } 66 | 67 | isAuthenticated$(): Observable { 68 | return this.store.select(state => state.auth) 69 | .map(({ credential, user }: IAuthState) => credential !== null && user !== null); 70 | } 71 | 72 | isAuthenticated(): boolean { 73 | let isAuthenticated; 74 | this.isAuthenticated$().first().subscribe(isAuth => isAuthenticated = isAuth); 75 | return isAuthenticated; 76 | } 77 | 78 | getCredential$(): Observable { 79 | return this.store.select(state => state.auth.credential); 80 | } 81 | 82 | getCredential(): ICredential { 83 | let credentialData: ICredential; 84 | this.getCredential$().first().subscribe((credential: ICredential) => { console.log('credential2', credential); return credentialData = credential }); 85 | return credentialData; 86 | } 87 | 88 | getUser$(): Observable { 89 | return this.store.select(state => state.auth.user); 90 | } 91 | 92 | getUser(): IUser { 93 | let userData: IUser; 94 | this.getUser$().first().subscribe((user: IUser) => userData = user); 95 | return userData; 96 | } 97 | 98 | getProvider$(): Observable { 99 | return this.store.select(state => state.auth.provider); 100 | } 101 | 102 | getProvider(): firebase.UserInfo { 103 | let providerData: firebase.UserInfo; 104 | this.getProvider$().first().subscribe((provider: firebase.UserInfo) => providerData = provider); 105 | console.log('getProvider', providerData) 106 | return providerData; 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/providers/feed/feed.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 2 | import { Observable } from 'rxjs/Observable'; 3 | import { Injectable } from '@angular/core'; 4 | import { Store } from '@ngrx/store'; 5 | import _take from 'lodash/take'; 6 | import _without from 'lodash/without'; 7 | 8 | import { AppState, ITweet, IUsersState } from '../../reducers'; 9 | import { fetchFeed, fetchedFeed, errorFeed } from '../../actions'; 10 | import { TwitterProvider } from './../twitter/twitter'; 11 | import { createTweetObject } from '../tweet/tweet'; 12 | /* 13 | Generated class for the FeedProvider provider. 14 | 15 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 16 | for more info on providers and Angular 2 DI. 17 | */ 18 | @Injectable() 19 | export class FeedProvider { 20 | constructor( 21 | public store: Store, 22 | private twitterProvider: TwitterProvider, 23 | ) { 24 | console.log('Hello FeedProvider Provider'); 25 | } 26 | 27 | isFetching$(): Observable { 28 | return this.store.select(state => state.feed.fetching); 29 | } 30 | 31 | getFeed$(): Observable { 32 | return this.store.select(state => state.feed.list); 33 | } 34 | 35 | getFeedPaginated$(pageBSubject: BehaviorSubject, perPage: number = 10, ): Observable { 36 | return Observable.combineLatest( 37 | this.store.select(state => state.feed.list), 38 | this.store.select(state => state.tweets), 39 | this.store.select(state => state.users), 40 | pageBSubject, 41 | (feed: ITweet[], tweets: ITweet[], users: IUsersState, page) => _without(_take(feed, page * perPage) 42 | .map(tweetId => createTweetObject(tweets[tweetId], tweets, users)), null) 43 | ); 44 | } 45 | 46 | getLastTweetId(): string { 47 | let lastItem: string; 48 | this.getFeed$() 49 | .first() 50 | .subscribe((items: string[]) => (lastItem = items[items.length - 1])); 51 | return lastItem; 52 | } 53 | 54 | hasFeed(): boolean { 55 | let hasFeed: boolean; 56 | this.getFeed$() 57 | .first() 58 | .subscribe((items: string[]) => (hasFeed = items.length !== 0)); 59 | return hasFeed; 60 | } 61 | 62 | feedLength(): number { 63 | let feedLength: number; 64 | this.getFeed$() 65 | .first() 66 | .subscribe((items: string[]) => (feedLength = items.length)); 67 | return feedLength; 68 | } 69 | 70 | fetch$() { 71 | this.store.dispatch(fetchFeed()); 72 | return this.twitterProvider 73 | .getFeed$({ include_entities: true }) 74 | .debounceTime(500) 75 | .map(feed => this.store.dispatch(fetchedFeed(feed, true))) 76 | .catch(error => { 77 | console.error('fetch$', error) 78 | this.store.dispatch(errorFeed()); 79 | return Observable.of(null); 80 | }); 81 | } 82 | 83 | fetchNextPage$() { 84 | const lastTweetId = this.getLastTweetId(); 85 | if (!lastTweetId) return Observable.of(null); 86 | this.store.dispatch(fetchFeed()); 87 | return this.twitterProvider 88 | .getFeed$({ max_id: lastTweetId, include_entities: true }) 89 | .debounceTime(500) 90 | .map(feed => this.store.dispatch(fetchedFeed(feed))) 91 | .catch(error => { 92 | console.error('fetchNextPage$', error) 93 | this.store.dispatch(errorFeed()); 94 | return Observable.of(null); 95 | }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './twitter/twitter'; 2 | export * from './storage/storage'; 3 | export * from './users/users'; 4 | export * from './user-tweets/user-tweets'; 5 | export * from './user-likes/user-likes'; 6 | export * from './feed/feed'; 7 | export * from './auth/auth'; 8 | export * from './trends/trends'; 9 | export * from './search/search'; 10 | export * from './mentions/mentions'; 11 | export * from './tweet/tweet'; 12 | export * from './service-worker/service-worker'; -------------------------------------------------------------------------------- /src/providers/mentions/mentions.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs/Observable'; 4 | import { Store } from '@ngrx/store'; 5 | import _get from 'lodash/get'; 6 | import _take from 'lodash/take'; 7 | import _without from 'lodash/without'; 8 | 9 | import { AppState, ITweet, IUsersState } from '../../reducers'; 10 | import { fetchMentions, fetchedMentions, errorMentions } from '../../actions'; 11 | import { TwitterProvider } from './../twitter/twitter'; 12 | /* 13 | Generated class for the MentionsProvider provider. 14 | 15 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 16 | for more info on providers and Angular 2 DI. 17 | */ 18 | @Injectable() 19 | export class MentionsProvider { 20 | 21 | constructor( 22 | public store: Store, 23 | private twitterProvider: TwitterProvider, 24 | ) { 25 | console.log('Hello MentionsProvider Provider'); 26 | } 27 | 28 | isFetching$(): Observable { 29 | return this.store.select(state => state.mentions.fetching); 30 | } 31 | 32 | getFeed$(): Observable { 33 | return this.store.select(state => state.mentions.list); 34 | } 35 | 36 | getMentionsPaginated$(pageBSubject: BehaviorSubject, perPage: number = 10, ): Observable { 37 | return Observable.combineLatest( 38 | this.store.select(state => state.mentions.list), 39 | this.store.select(state => state.tweets), 40 | this.store.select(state => state.users), 41 | pageBSubject, 42 | (feed: ITweet[], tweets: ITweet[], users: IUsersState, page) => _without(_take(feed, page * perPage) 43 | .map(tweetId => { 44 | const tweet = tweets[tweetId]; 45 | if (!tweet) return null; 46 | return { 47 | ...tweet, // avoid state mutation 48 | user: users[tweet.userHandle.toLowerCase()] 49 | }; 50 | }), null) 51 | ); 52 | } 53 | 54 | getLastTweetId(): string { 55 | let lastItem: string; 56 | this.getFeed$() 57 | .first() 58 | .subscribe((items: string[]) => (lastItem = items[items.length - 1])); 59 | return lastItem; 60 | } 61 | 62 | hasFeed(): boolean { 63 | let hasFeed: boolean; 64 | this.getFeed$() 65 | .first() 66 | .subscribe((items: string[]) => (hasFeed = items.length !== 0)); 67 | return hasFeed; 68 | } 69 | 70 | feedLength(): number { 71 | let feedLength: number; 72 | this.getFeed$() 73 | .first() 74 | .subscribe((items: string[]) => (feedLength = items.length)); 75 | return feedLength; 76 | } 77 | 78 | fetch$() { 79 | this.store.dispatch(fetchMentions()); 80 | return this.twitterProvider 81 | .getMentions$({ include_entities: true }) 82 | .debounceTime(500) 83 | .map(feed => this.store.dispatch(fetchedMentions(feed, true))) 84 | .catch(error => { 85 | console.error('fetch$', error) 86 | this.store.dispatch(errorMentions()); 87 | return Observable.of(null); 88 | }); 89 | } 90 | 91 | fetchNextPage$() { 92 | const lastTweetId = this.getLastTweetId(); 93 | if (!lastTweetId) return Observable.of(null); 94 | this.store.dispatch(fetchMentions()); 95 | return this.twitterProvider 96 | .getMentions$({ max_id: lastTweetId, include_entities: true }) 97 | .debounceTime(500) 98 | .map(feed => this.store.dispatch(fetchedMentions(feed))) 99 | .catch(error => { 100 | console.error('fetchNextPage$', error) 101 | this.store.dispatch(errorMentions()); 102 | return Observable.of(null); 103 | }); 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/providers/search/search.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 2 | import { Observable } from 'rxjs/Observable'; 3 | import { Injectable } from '@angular/core'; 4 | import { Store } from '@ngrx/store'; 5 | import _take from 'lodash/take'; 6 | import _get from 'lodash/get'; 7 | import _without from 'lodash/without'; 8 | 9 | import { AppState, ITweet, IUsersState } from '../../reducers'; 10 | import { fetchSearch, fetchedSearch, errorSearch } from '../../actions'; 11 | import { TwitterProvider } from './../twitter/twitter'; 12 | import { createTweetObject } from '../tweet/tweet'; 13 | /* 14 | Generated class for the SearchProvider provider. 15 | 16 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 17 | for more info on providers and Angular 2 DI. 18 | */ 19 | @Injectable() 20 | export class SearchProvider { 21 | constructor( 22 | public store: Store, 23 | private twitterProvider: TwitterProvider, 24 | ) { 25 | console.log('Hello SearchProvider Provider'); 26 | } 27 | 28 | isFetching$(term = ''): Observable { 29 | return this.store.select(state => _get(state, `search[${term}].fetching`, false)); 30 | } 31 | 32 | search$(term = ''): Observable { 33 | return this.store.select(state => _get(state, `search[${term}].list`, [])); 34 | } 35 | 36 | getSearchPaginated$(term = '', pageBSubject: BehaviorSubject, perPage: number = 10, ): Observable { 37 | return Observable.combineLatest( 38 | this.search$(term), 39 | this.store.select(state => state.tweets), 40 | this.store.select(state => state.users), 41 | pageBSubject, 42 | (feed: ITweet[], tweets: ITweet[], users: IUsersState, page) => _without(_take(feed, page * perPage) 43 | .map(tweetId => tweets[tweetId] 44 | ? createTweetObject(tweets[tweetId], tweets, users) 45 | : null 46 | ), null) 47 | ); 48 | } 49 | 50 | getLastTweetId(term = ''): string { 51 | let lastItem: string; 52 | this.search$(term) 53 | .first() 54 | .subscribe((items: string[]) => (lastItem = items[items.length - 1])); 55 | return lastItem; 56 | } 57 | 58 | hasSearch(term = ''): boolean { 59 | let hasSearch: boolean; 60 | this.search$(term) 61 | .first() 62 | .subscribe((items: string[]) => (hasSearch = items.length !== 0)); 63 | return hasSearch; 64 | } 65 | 66 | searchLength(term = ''): number { 67 | let searchLength: number; 68 | this.search$(term) 69 | .first() 70 | .subscribe((items: string[]) => (searchLength = items.length)); 71 | return searchLength; 72 | } 73 | 74 | fetch$(term) { 75 | this.store.dispatch(fetchSearch(term)); 76 | return this.twitterProvider 77 | .search$(term, 'popular', { include_entities: true }) 78 | .debounceTime(500) 79 | .map(res => this.store.dispatch(fetchedSearch(term, res.statuses, true))) 80 | .catch(error => { 81 | console.error('fetch$', error) 82 | this.store.dispatch(errorSearch(term)); 83 | return Observable.of(null); 84 | }); 85 | } 86 | 87 | fetchNextPage$(term) { 88 | const lastTweetId = this.getLastTweetId(term); 89 | if (!lastTweetId) return Observable.of(null); 90 | this.store.dispatch(fetchSearch(term)); 91 | return this.twitterProvider 92 | .search$(term, 'popular', { max_id: lastTweetId, include_entities: true }) 93 | .debounceTime(500) 94 | .map(res => this.store.dispatch(fetchedSearch(term, res.statuses))) 95 | .catch(error => { 96 | console.error('fetchNextPage$', error) 97 | this.store.dispatch(errorSearch(term)); 98 | return Observable.of(null); 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/providers/service-worker/service-worker.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { NgServiceWorker } from '@angular/service-worker'; 3 | import { ToastController } from 'ionic-angular'; 4 | /* 5 | Generated class for the ServiceWorkerProvider provider. 6 | 7 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 8 | for more info on providers and Angular 2 DI. 9 | */ 10 | @Injectable() 11 | export class ServiceWorkerProvider { 12 | 13 | constructor( 14 | public sw: NgServiceWorker, 15 | public toastCtrl: ToastController, 16 | ) {} 17 | 18 | run() { 19 | this.sw.log().subscribe(logs => console.log('SW logs', logs)); 20 | this.sw.updates.subscribe(res => { 21 | if (res.type === 'pending') { 22 | let toast = this.toastCtrl.create({ 23 | message: 'A new version is available, reload ', 24 | closeButtonText: "Reload", 25 | showCloseButton: true, 26 | }); 27 | toast.onDidDismiss((data, role) => { 28 | if (role === 'close') { 29 | this.sw.activateUpdate(res.version) 30 | .subscribe(value => value && location.reload()); 31 | }; 32 | }); 33 | toast.present(); 34 | } 35 | }); 36 | this.sw.checkForUpdate(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/providers/storage/storage.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Storage as IonicStorage } from '@ionic/storage'; 3 | import { Store } from '@ngrx/store'; 4 | import _slice from 'lodash/slice'; 5 | import _uniq from 'lodash/uniq'; 6 | import _pickBy from 'lodash/pickBy'; 7 | 8 | import { AppState, IAuthState, IFeed, IUsersState, ITrends, IMentions, ITweet } from './../../reducers'; 9 | import { INIT, ON_BEFORE_UNLOAD } from './../../actions'; 10 | 11 | /* 12 | Generated class for the StorageProvider provider. 13 | 14 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 15 | for more info on providers and Angular 2 DI. 16 | */ 17 | @Injectable() 18 | export class StorageProvider { 19 | 20 | constructor( 21 | private store: Store, 22 | public storage: IonicStorage 23 | ) { } 24 | 25 | init() { 26 | // POPULATE STORE 27 | // because this blocks => https://github.com/ngrx/store/pull/217 28 | let defaultState = {}; 29 | const storagePromise = this.storage.forEach((val, key) => { 30 | defaultState[key] = val; 31 | }); 32 | 33 | return storagePromise 34 | .then(() => this.store.dispatch({ type: INIT, payload: defaultState })) 35 | } 36 | 37 | run() { 38 | this.store.select('auth').skip(1).debounceTime(500).subscribe((auth: IAuthState) => { 39 | console.log('saving auth'); 40 | this.storage.set('auth', auth); 41 | }); 42 | 43 | this.store.select('trends').skip(1).debounceTime(500).subscribe((trends: ITrends) => { 44 | console.log('saving trends'); 45 | this.storage.set('trends', trends); 46 | }); 47 | 48 | this.store.select(state => state).skip(1).debounceTime(500).subscribe(this.cleanupAndSaveState); 49 | 50 | window.onbeforeunload = () => { 51 | this.store.dispatch({ type: ON_BEFORE_UNLOAD }); 52 | }; 53 | } 54 | 55 | cleanupAndSaveState = (state: AppState) => { 56 | console.log('saving state'); 57 | // FEED 58 | const first20Feed = _slice(state.feed.list, 0, 20); 59 | this.storage.set('feed', { fetching: false, list: first20Feed }); 60 | 61 | // MENTION 62 | const first20Mentions = _slice(state.mentions.list, 0, 20); 63 | this.storage.set('mentions', { fetching: false, list: first20Mentions }); 64 | 65 | // TWEETS 66 | const tweetIdsToKeep = _uniq([...first20Feed, ...first20Mentions]); 67 | const tweetsToKeep = _pickBy(state.tweets, (v, k) => tweetIdsToKeep.includes(k)); 68 | const retweetIdsToKeep = Object.values(tweetsToKeep) 69 | .filter(tweet => tweet.retweeted_status_id).map(tweet => tweet.retweeted_status_id); 70 | const retweetsToKeep = _pickBy(state.tweets, (v, k) => retweetIdsToKeep.includes(k)); 71 | const tweetsState = { ...tweetsToKeep, ...retweetsToKeep }; 72 | this.storage.set('tweets', tweetsState); 73 | 74 | // USERS 75 | const userHandlesToKeep = Object.values(tweetsState).map(tweet => tweet.userHandle); 76 | const usersToKeep = _pickBy(state.users, (v, k) => userHandlesToKeep.includes(k)); 77 | this.storage.set('users', usersToKeep); 78 | } 79 | } -------------------------------------------------------------------------------- /src/providers/trends/trends.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Injectable } from '@angular/core'; 3 | import { Store } from '@ngrx/store'; 4 | import fnsParse from 'date-fns/parse'; 5 | import fnsDiffInMinutes from 'date-fns/difference_in_minutes'; 6 | 7 | import { AppState, ITrends, ITrendingHashtag } from '../../reducers'; 8 | import { fetchTrends, fetchedTrends, errorTrends } from '../../actions'; 9 | import { TwitterProvider } from './../twitter/twitter'; 10 | /* 11 | Generated class for the TrendsProvider provider. 12 | 13 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 14 | for more info on providers and Angular 2 DI. 15 | */ 16 | const REFRESH_TRENDS_INTERVAL = 5; // in minutes 17 | 18 | @Injectable() 19 | export class TrendsProvider { 20 | constructor( 21 | public store: Store, 22 | private twitterProvider: TwitterProvider, 23 | ) { 24 | console.log('Hello TrendsProvider Provider'); 25 | } 26 | 27 | isFetching$(): Observable { 28 | return this.store.select(state => state.trends.fetching); 29 | } 30 | 31 | getTrends$(): Observable { 32 | return this.store.select(state => state.trends); 33 | } 34 | 35 | getTrends(): ITrends { 36 | let trends: ITrends; 37 | this.store 38 | .select(state => state.trends) 39 | .first() 40 | .subscribe((trendsState: ITrends) => (trends = trendsState)); 41 | return trends; 42 | } 43 | 44 | getTrendsHashtags$(): Observable { 45 | return this.store.select(state => state.trends.hashtags); 46 | } 47 | 48 | getTrendsAsOf$(): Observable { 49 | return this.store.select(state => state.trends.as_of); 50 | } 51 | 52 | getTrendsAsOf(): string { 53 | let as_of: string; 54 | this.getTrendsAsOf$().first().subscribe(date => (as_of = date)); 55 | return as_of; 56 | } 57 | 58 | hasTrendingHashtags(): boolean { 59 | let hasTrendingHashtags: boolean; 60 | this.getTrendsHashtags$() 61 | .first() 62 | .subscribe( 63 | (hashtags: ITrendingHashtag[]) => 64 | (hasTrendingHashtags = hashtags.length !== 0), 65 | ); 66 | return hasTrendingHashtags; 67 | } 68 | 69 | canFetchNewContent() { 70 | const date = fnsParse(this.getTrendsAsOf()); 71 | if (fnsDiffInMinutes(Date.now(), date) < REFRESH_TRENDS_INTERVAL) { 72 | console.info(`Trends created less than ${REFRESH_TRENDS_INTERVAL} min ago, keeping existing data`); 73 | return false; 74 | } 75 | return true; 76 | } 77 | 78 | fetch$() { 79 | this.store.dispatch(fetchTrends()); 80 | return this.twitterProvider 81 | .getTrends$() 82 | .debounceTime(500) 83 | .map(trends => this.store.dispatch(fetchedTrends(trends))) 84 | .catch(error => { 85 | this.store.dispatch(errorTrends()); 86 | return Observable.of(null); 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/providers/tweet/tweet.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Injectable } from '@angular/core'; 3 | import { Store } from '@ngrx/store'; 4 | 5 | import { AppState, ITweet, IUsersState } from '../../reducers'; 6 | import { 7 | tweetFavorite, tweetUnfavorite, 8 | tweetRetweet, tweetUnretweet 9 | } from '../../actions'; 10 | import { TwitterProvider } from './../twitter/twitter'; 11 | 12 | 13 | /* 14 | Generated class for the TweetProvider provider. 15 | 16 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 17 | for more info on providers and Angular DI. 18 | */ 19 | @Injectable() 20 | export class TweetProvider { 21 | 22 | constructor( 23 | public store: Store, 24 | private twitterProvider: TwitterProvider, 25 | ) { } 26 | 27 | getById$(id_str): Observable { 28 | return Observable.combineLatest( 29 | this.store.select(state => state.tweets[id_str]), 30 | this.store.select(state => state.tweets), 31 | this.store.select(state => state.users), 32 | (tweet: ITweet, tweets: ITweet[], users: IUsersState) => createTweetObject(tweet, tweets, users)); 33 | } 34 | 35 | favorite$(handleDo, id) { 36 | return this.twitterProvider.favorite$(handleDo, id) 37 | .debounceTime(500) 38 | .map(tweet => this.store.dispatch(handleDo ? tweetFavorite(tweet) : tweetUnfavorite(tweet))); 39 | } 40 | 41 | retweet$(handleDo, id) { 42 | return this.twitterProvider.retweet$(handleDo, id) 43 | .debounceTime(500) 44 | .map(tweet => this.store.dispatch(handleDo ? tweetRetweet(tweet, id) : tweetUnretweet(tweet, id))); 45 | } 46 | 47 | } 48 | 49 | export function createTweetObject(tweet: ITweet, tweets, users) { 50 | if (!tweet) return null; 51 | let tweetWithEntities = { 52 | ...tweet, // avoid state mutation 53 | user: users[tweet.userHandle.toLowerCase()], 54 | } 55 | if (tweet.retweeted_status_id) { 56 | const retweeted_status = tweets[tweet.retweeted_status_id]; 57 | tweetWithEntities.retweeted_status = { 58 | ...retweeted_status, 59 | user: users[retweeted_status.userHandle.toLowerCase()] 60 | } 61 | } 62 | return tweetWithEntities; 63 | } -------------------------------------------------------------------------------- /src/providers/twitter/twitter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Http, Headers, RequestOptions } from '@angular/http'; 3 | import { Store } from '@ngrx/store'; 4 | import { Observable } from 'rxjs/Observable'; 5 | import { Platform } from 'ionic-angular'; 6 | 7 | import { AppState, ITwitterUser, ITrends } from '../../reducers'; 8 | import { AuthProvider } from './../auth/auth'; 9 | 10 | const authRequiredError = { error: 'Auth is required' }; 11 | /* 12 | Generated class for the TwitterProvider provider. 13 | 14 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 15 | for more info on providers and Angular 2 DI. 16 | */ 17 | @Injectable() 18 | export class TwitterProvider { 19 | constructor( 20 | public http: Http, 21 | public store: Store, 22 | public platform: Platform, 23 | public authProvider: AuthProvider, 24 | ) { } 25 | 26 | getRequestOptions() { 27 | const headers = new Headers(); 28 | const { stsTokenManager: { accessToken } } = this.authProvider.getUser(); 29 | const { access_token_key, access_token_secret } = this.authProvider.getCredential(); 30 | 31 | headers.set('Content-Type', 'application/json'); 32 | headers.set('Authorization', `Bearer ${accessToken},${access_token_key},${access_token_secret}`); 33 | return new RequestOptions({ headers }); 34 | } 35 | 36 | getDirectMessages() { 37 | return this.http.post(`${__APIURI__}api/messages`, {}, this.getRequestOptions()).map(res => res.json()); 38 | } 39 | 40 | /** 41 | * Get home timeline 42 | * 43 | * @param count 44 | * @param since_id more recent than 45 | * @param max_id older than 46 | */ 47 | getFeed$(options = {}): Observable { 48 | return this.authProvider.isAuthenticated() 49 | ? this.http.post(`${__APIURI__}api/feed`, options, 50 | this.getRequestOptions()).map(res => res.json()) 51 | : Observable.throw(authRequiredError); 52 | } 53 | 54 | /** 55 | * Get mentions timeline 56 | * 57 | * @param count 58 | * @param since_id more recent than 59 | * @param max_id older than 60 | */ 61 | getMentions$(options = {}): Observable { 62 | return this.authProvider.isAuthenticated() 63 | ? this.http.post(`${__APIURI__}api/mentions`, options, 64 | this.getRequestOptions()).map(res => res.json()) 65 | : Observable.throw(authRequiredError); 66 | } 67 | 68 | getTimeline$(screen_name, options = {}): Observable { 69 | return this.authProvider.isAuthenticated() 70 | ? this.http.post(`${__APIURI__}api/timeline`, { ...options, screen_name }, 71 | this.getRequestOptions()).map(res => res.json()) 72 | : Observable.throw(authRequiredError); 73 | } 74 | 75 | getTrends$(): Observable { // do not allow localized trending topics for now 76 | return this.authProvider.isAuthenticated() 77 | ? this.http.post(`${__APIURI__}api/trending`, {}, 78 | this.getRequestOptions()) 79 | .map(res => res.json()) 80 | .map(res => res[0]) 81 | : Observable.throw(authRequiredError); 82 | } 83 | 84 | tweet(status, in_reply_to_status_id?) { 85 | return this.authProvider.isAuthenticated() 86 | ? this.http.post(`${__APIURI__}api/tweet`, { status, in_reply_to_status_id }, this.getRequestOptions()) 87 | .map(res => res.json()) 88 | : Observable.throw(authRequiredError); 89 | } 90 | 91 | getOpenGraphData(url) { 92 | return this.authProvider.isAuthenticated() 93 | ? this.http.post(`${__APIURI__}api/og-scrapper`, { 94 | url 95 | }, this.getRequestOptions()).map(res => res.json()) 96 | : Observable.throw(authRequiredError); 97 | } 98 | 99 | getUser$(screen_name?): Observable { 100 | const provider = this.authProvider.getProvider(); 101 | const params = screen_name 102 | ? { screen_name } 103 | : { user_id: provider && provider.uid }; 104 | 105 | return this.authProvider.isAuthenticated() 106 | ? this.http.post(`${__APIURI__}api/user`, params, 107 | this.getRequestOptions()).map(res => res.json()) 108 | : Observable.throw(authRequiredError); 109 | } 110 | 111 | search$(q, type = 'popular', options = {}): Observable { 112 | return this.authProvider.isAuthenticated() 113 | ? this.http.post(`${__APIURI__}api/search/${type}`, { ...options, q: encodeURI(q) }, 114 | this.getRequestOptions()).map(res => res.json()) 115 | : Observable.throw(authRequiredError); 116 | } 117 | 118 | getFavoriteList$(id: string, options = {}): Observable { 119 | return this.authProvider.isAuthenticated() 120 | ? this.http.post(`${__APIURI__}api/favorites/list`, { ...options, id }, 121 | this.getRequestOptions()).map(res => res.json()) 122 | : Observable.throw(authRequiredError); 123 | } 124 | 125 | favorite$(handleDo: boolean = false, id): Observable { 126 | return this.authProvider.isAuthenticated() 127 | ? this.http.post(`${__APIURI__}api/favorites/${handleDo ? 'create' : 'destroy'}`, { id }, 128 | this.getRequestOptions()).map(res => res.json()) 129 | : Observable.throw(authRequiredError); 130 | } 131 | 132 | retweet$(handleDo: boolean = false, id): Observable { 133 | return this.authProvider.isAuthenticated() 134 | ? this.http.post(`${__APIURI__}api/${handleDo ? 'retweet' : 'unretweet'}`, { id }, 135 | this.getRequestOptions()).map(res => res.json()) 136 | : Observable.throw(authRequiredError); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/providers/user-likes/user-likes.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 2 | import { Observable } from 'rxjs/Observable'; 3 | import { Injectable } from '@angular/core'; 4 | import { Store } from '@ngrx/store'; 5 | import _take from 'lodash/take'; 6 | import _get from 'lodash/get'; 7 | import _without from 'lodash/without'; 8 | 9 | import { AppState, ITweet, IUsersState } from '../../reducers'; 10 | import { fetchUserLikes, fetchedUserLikes, errorUserLikes } from '../../actions'; 11 | import { TwitterProvider } from './../twitter/twitter'; 12 | import { createTweetObject } from '../tweet/tweet'; 13 | /* 14 | Generated class for the UserLikesProvider provider. 15 | 16 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 17 | for more info on providers and Angular 2 DI. 18 | */ 19 | @Injectable() 20 | export class UserLikesProvider { 21 | constructor( 22 | public store: Store, 23 | private twitterProvider: TwitterProvider, 24 | ) { 25 | console.log('Hello UserLikesProvider Provider'); 26 | } 27 | 28 | isFetching$(username = ''): Observable { 29 | return this.store.select(state => _get(state, `userLikes[${username}].fetching`, false)); 30 | } 31 | 32 | getUserLikes$(username = ''): Observable { 33 | return this.store.select(state => _get(state, `userLikes[${username}].list`, [])); 34 | } 35 | 36 | getUserLikesPaginated$(username = '', pageBSubject: BehaviorSubject, perPage: number = 10, ): Observable { 37 | return Observable.combineLatest( 38 | this.getUserLikes$(username), 39 | this.store.select(state => state.tweets), 40 | this.store.select(state => state.users), 41 | pageBSubject, 42 | (feed: ITweet[], tweets: ITweet[], users: IUsersState, page) => _without(_take(feed, page * perPage) 43 | .map(tweetId => tweets[tweetId] 44 | ? createTweetObject(tweets[tweetId], tweets, users) 45 | : null 46 | ), null) 47 | ); 48 | } 49 | 50 | getLastTweetId(username = ''): string { 51 | let lastItem: string; 52 | this.getUserLikes$(username) 53 | .first() 54 | .subscribe((items: string[]) => (lastItem = items[items.length - 1])); 55 | return lastItem; 56 | } 57 | 58 | hasUserLikes(username = ''): boolean { 59 | let hasUserLikes: boolean; 60 | this.getUserLikes$(username) 61 | .first() 62 | .subscribe((items: string[]) => (hasUserLikes = items.length !== 0)); 63 | return hasUserLikes; 64 | } 65 | 66 | userLikesLength(username = ''): number { 67 | let userLikesLength: number; 68 | this.getUserLikes$(username) 69 | .first() 70 | .subscribe((items: string[]) => (userLikesLength = items.length)); 71 | return userLikesLength; 72 | } 73 | 74 | fetch$(username) { 75 | this.store.dispatch(fetchUserLikes(username)); 76 | return this.twitterProvider 77 | .getFavoriteList$(username, { include_entities: true }) 78 | .debounceTime(500) 79 | .map(res => this.store.dispatch(fetchedUserLikes(username, res, true))) 80 | .catch(error => { 81 | console.error('fetch$', error) 82 | this.store.dispatch(errorUserLikes(username)); 83 | return Observable.of(null); 84 | }); 85 | } 86 | 87 | fetchNextPage$(username) { 88 | const lastTweetId = this.getLastTweetId(username); 89 | if (!lastTweetId) return Observable.of(null); 90 | this.store.dispatch(fetchUserLikes(username)); 91 | return this.twitterProvider 92 | .getFavoriteList$(username, { max_id: lastTweetId, include_entities: true }) 93 | .debounceTime(500) 94 | .map(res => this.store.dispatch(fetchedUserLikes(username, res))) 95 | .catch(error => { 96 | console.error('fetchNextPage$', error) 97 | this.store.dispatch(errorUserLikes(username)); 98 | return Observable.of(null); 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/providers/user-tweets/user-tweets.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 2 | import { Observable } from 'rxjs/Observable'; 3 | import { Injectable } from '@angular/core'; 4 | import { Store } from '@ngrx/store'; 5 | import _take from 'lodash/take'; 6 | import _get from 'lodash/get'; 7 | import _without from 'lodash/without'; 8 | 9 | import { AppState, ITweet, IUsersState } from '../../reducers'; 10 | import { fetchUserTweets, fetchedUserTweets, errorUserTweets } from '../../actions'; 11 | import { TwitterProvider } from './../twitter/twitter'; 12 | import { createTweetObject } from '../tweet/tweet'; 13 | /* 14 | Generated class for the UserTweetsProvider provider. 15 | 16 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 17 | for more info on providers and Angular 2 DI. 18 | */ 19 | @Injectable() 20 | export class UserTweetsProvider { 21 | constructor( 22 | public store: Store, 23 | private twitterProvider: TwitterProvider, 24 | ) { 25 | console.log('Hello UserTweetsProvider Provider'); 26 | } 27 | 28 | isFetching$(username = ''): Observable { 29 | return this.store.select(state => _get(state, `userTweets[${username}].fetching`, false)); 30 | } 31 | 32 | getUserTweets$(username = ''): Observable { 33 | return this.store.select(state => _get(state, `userTweets[${username}].list`, [])); 34 | } 35 | 36 | getUserTweetsPaginated$(username = '', pageBSubject: BehaviorSubject, perPage: number = 10, ): Observable { 37 | return Observable.combineLatest( 38 | this.getUserTweets$(username), 39 | this.store.select(state => state.tweets), 40 | this.store.select(state => state.users), 41 | pageBSubject, 42 | (feed: ITweet[], tweets: ITweet[], users: IUsersState, page) => _without(_take(feed, page * perPage) 43 | .map(tweetId => tweets[tweetId] 44 | ? createTweetObject(tweets[tweetId], tweets, users) 45 | : null 46 | ), null) 47 | ); 48 | } 49 | 50 | getLastTweetId(username = ''): string { 51 | let lastItem: string; 52 | this.getUserTweets$(username) 53 | .first() 54 | .subscribe((items: string[]) => (lastItem = items[items.length - 1])); 55 | return lastItem; 56 | } 57 | 58 | hasUserTweets(username = ''): boolean { 59 | let hasUserTweets: boolean; 60 | this.getUserTweets$(username) 61 | .first() 62 | .subscribe((items: string[]) => (hasUserTweets = items.length !== 0)); 63 | return hasUserTweets; 64 | } 65 | 66 | userTweetsLength(username = ''): number { 67 | let userTweetsLength: number; 68 | this.getUserTweets$(username) 69 | .first() 70 | .subscribe((items: string[]) => (userTweetsLength = items.length)); 71 | return userTweetsLength; 72 | } 73 | 74 | fetch$(username) { 75 | this.store.dispatch(fetchUserTweets(username)); 76 | return this.twitterProvider 77 | .getTimeline$(username, { include_entities: true }) 78 | .debounceTime(500) 79 | .map(res => this.store.dispatch(fetchedUserTweets(username, res, true))) 80 | .catch(error => { 81 | console.error('fetch$', error) 82 | this.store.dispatch(errorUserTweets(username)); 83 | return Observable.of(null); 84 | }); 85 | } 86 | 87 | fetchNextPage$(username) { 88 | const lastTweetId = this.getLastTweetId(username); 89 | if (!lastTweetId) return Observable.of(null); 90 | this.store.dispatch(fetchUserTweets(username)); 91 | return this.twitterProvider 92 | .getTimeline$(username, { max_id: lastTweetId, include_entities: true }) 93 | .debounceTime(500) 94 | .map(res => this.store.dispatch(fetchedUserTweets(username, res))) 95 | .catch(error => { 96 | console.error('fetchNextPage$', error) 97 | this.store.dispatch(errorUserTweets(username)); 98 | return Observable.of(null); 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/providers/users/users.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs/Observable'; 2 | import { Injectable } from '@angular/core'; 3 | import { Store } from '@ngrx/store'; 4 | import _get from 'lodash/get'; 5 | 6 | import { AppState, ITwitterUser } from '../../reducers'; 7 | import { TwitterProvider } from './../twitter/twitter'; 8 | import { addTwitterUser } from '../../actions'; 9 | 10 | /* 11 | Generated class for the UsersProvider provider. 12 | 13 | See https://angular.io/docs/ts/latest/guide/dependency-injection.html 14 | for more info on providers and Angular 2 DI. 15 | */ 16 | @Injectable() 17 | export class UsersProvider { 18 | 19 | constructor( 20 | public store: Store, 21 | private twitterProvider: TwitterProvider, 22 | ) { 23 | console.log('Hello UsersProvider Provider'); 24 | } 25 | 26 | getCurrentUser$() { 27 | return Observable.combineLatest( 28 | this.store.select(state => state.auth.screen_name), 29 | this.store.select(state => state.users), 30 | (screen_name, users: any) => screen_name && _get(users, `[${screen_name}]`) 31 | ); 32 | } 33 | 34 | getCurrentUser() { 35 | let user; 36 | this.getCurrentUser$().first().subscribe((u: ITwitterUser) => user = u); 37 | return user; 38 | } 39 | 40 | doesUserExist(handle: string) { 41 | let doesUserExist: boolean; 42 | this.getUserById$(handle) 43 | .first() 44 | .subscribe((u: ITwitterUser) => doesUserExist = typeof u !== 'undefined'); 45 | return doesUserExist; 46 | } 47 | 48 | fetchUser$(handle: string) { 49 | return this.twitterProvider.getUser$(handle) 50 | .debounceTime(500) 51 | .map(user => this.store.dispatch(addTwitterUser(user))) 52 | .catch(error => { 53 | return Observable.of(null); 54 | }); 55 | } 56 | 57 | getUserById$(handle: string = '') { 58 | return this.store.select(state => state.users[handle.toLowerCase()]); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/reducers/auth.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | import { 3 | ADD_AUTH_CREDENTIAL, ADD_AUTH_USER, INIT, CLEAN_AUTH, LOGOUT, LOGIN, ADD_CURRENT_TWITTER_USER 4 | } from '../actions'; 5 | import _get from 'lodash/get'; 6 | 7 | import * as firebase from 'firebase/app'; 8 | 9 | const defaultState = { 10 | user: null, 11 | credential: null, 12 | provider: null, 13 | screen_name: null 14 | }; 15 | 16 | export const authReducer: ActionReducer = (state: IAuthState = defaultState, action: Action) => { 17 | const payload = action.payload; 18 | 19 | switch (action.type) { 20 | case LOGIN: { 21 | const provider = _get(payload.user, 'providerData[0]'); 22 | console.log('provider', provider) 23 | // delete payload.user.providerData; 24 | return Object.assign({}, state, { 25 | user: payload.user, 26 | provider 27 | }); 28 | } 29 | 30 | case ADD_AUTH_USER: { 31 | const provider = _get(payload.user, 'providerData[0]'); 32 | delete payload.user.providerData; 33 | return Object.assign({}, state, { 34 | user: payload.user, 35 | provider 36 | }); 37 | } 38 | 39 | case ADD_AUTH_CREDENTIAL: { 40 | return Object.assign({}, state, { 41 | credential: mapCredential(payload.credential) 42 | }); 43 | } 44 | 45 | case ADD_CURRENT_TWITTER_USER: { 46 | return Object.assign({}, state, { 47 | screen_name: payload.user.screen_name.toLowerCase() 48 | }); 49 | } 50 | 51 | case INIT: { 52 | return payload.auth || defaultState; 53 | } 54 | 55 | // case LOGIN_FAILED: 56 | case CLEAN_AUTH: 57 | case LOGOUT: { 58 | return defaultState; 59 | } 60 | 61 | default: 62 | return state; 63 | } 64 | } 65 | 66 | function mapCredential({ accessToken, secret }) { 67 | return { 68 | access_token_key: accessToken, 69 | access_token_secret: secret 70 | } 71 | } 72 | 73 | export interface IStsTokenManager { 74 | accessToken: string; 75 | apiKey: string; 76 | expirationTime: number; 77 | refreshToken: string; 78 | } 79 | 80 | export interface IUser { 81 | uid: string; 82 | apiKey: string; 83 | appName: string; 84 | authDomain: string; 85 | displayName: string; 86 | email: string | null; 87 | emailVerified: boolean; 88 | isAnonymous: boolean; 89 | phoneNumber: string | null; 90 | photoURL: string; 91 | redirectEventId: string | null; 92 | stsTokenManager: IStsTokenManager; 93 | } 94 | 95 | export interface ICredential { 96 | access_token_key: string; 97 | access_token_secret: string; 98 | } 99 | 100 | export interface ICover { 101 | h: number; 102 | w: number; 103 | url: string; 104 | } 105 | 106 | export interface ICovers { 107 | ipad: ICover; 108 | ipad_retina: ICover; 109 | web: ICover; 110 | web_retina: ICover; 111 | mobile: ICover; 112 | mobile_retina: ICover; 113 | "300x100": ICover; 114 | "600x200": ICover; 115 | "1500x500": ICover; 116 | "1080x360": ICover; 117 | } 118 | 119 | export interface IAuthState { 120 | user: IUser, 121 | credential: ICredential, 122 | provider: firebase.UserInfo, 123 | screen_name: string 124 | } -------------------------------------------------------------------------------- /src/reducers/feed.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | 3 | import { FEED_FETCH, FEED_FETCHED, FEED_ERROR, LOGOUT, INIT } from '../actions'; 4 | 5 | const defaultState = { 6 | fetching: false, 7 | list: [], 8 | }; 9 | 10 | export const feedReducer: ActionReducer = ( 11 | state: IFeed = defaultState, 12 | action: Action, 13 | ) => { 14 | const payload = action.payload; 15 | 16 | switch (action.type) { 17 | case FEED_FETCH: { 18 | return Object.assign({}, state, { fetching: true }); 19 | } 20 | 21 | case FEED_ERROR: { 22 | return Object.assign({}, state, { fetching: false }); 23 | } 24 | 25 | case FEED_FETCHED: { 26 | const newItems = payload.feed.map(item => item.id_str); 27 | return { 28 | fetching: false, 29 | list: payload.reset ? newItems : [...state.list, ...newItems], 30 | }; 31 | } 32 | 33 | case INIT: { 34 | if (!payload.feed) return state; 35 | return { 36 | ...payload.feed, 37 | fetching: false 38 | }; 39 | } 40 | 41 | case LOGOUT: { 42 | return defaultState; 43 | } 44 | 45 | default: 46 | return state; 47 | } 48 | }; 49 | 50 | export interface IFeed { 51 | fetching: boolean; 52 | list: string[]; 53 | } 54 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { authReducer, IAuthState } from './auth'; 2 | import { usersReducer, IUsersState } from './users'; 3 | import { userTweetsReducer, IUserTweets } from './userTweets'; 4 | import { userLikesReducer, IUserLikes } from './userLikes'; 5 | import { feedReducer, IFeed } from './feed'; 6 | import { trendsReducer, ITrends } from './trends'; 7 | import { mentionsReducer, IMentions } from './mentions'; 8 | import { tweetsReducer, ITweets } from './tweets'; 9 | import { searchReducer, ISearch } from './search'; 10 | 11 | export * from './auth'; 12 | export * from './users'; 13 | export * from './userTweets'; 14 | export * from './userLikes'; 15 | export * from './feed'; 16 | export * from './trends'; 17 | export * from './mentions'; 18 | export * from './tweets'; 19 | export * from './search'; 20 | 21 | export interface AppState { 22 | auth: IAuthState; 23 | users: IUsersState; 24 | userTweets: IUserTweets; 25 | userLikes: IUserLikes; 26 | feed: IFeed; 27 | trends: ITrends; 28 | mentions: IMentions; 29 | tweets: ITweets; 30 | search: ISearch; 31 | } 32 | 33 | export const Reducers = { 34 | auth: authReducer, 35 | users: usersReducer, 36 | userTweets: userTweetsReducer, 37 | userLikes: userLikesReducer, 38 | feed: feedReducer, 39 | trends: trendsReducer, 40 | mentions: mentionsReducer, 41 | tweets: tweetsReducer, 42 | search: searchReducer, 43 | } -------------------------------------------------------------------------------- /src/reducers/mentions.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | 3 | import { MENTIONS_FETCH, MENTIONS_FETCHED, MENTIONS_ERROR, LOGOUT, INIT } from '../actions'; 4 | 5 | const defaultState = { 6 | fetching: false, 7 | list: [], 8 | }; 9 | 10 | export const mentionsReducer: ActionReducer = (state: IMentions = defaultState, action: Action) => { 11 | const payload = action.payload; 12 | 13 | switch (action.type) { 14 | case MENTIONS_FETCH: { 15 | return Object.assign({}, state, { fetching: true }); 16 | } 17 | 18 | case MENTIONS_ERROR: { 19 | return Object.assign({}, state, { fetching: false }); 20 | } 21 | 22 | case MENTIONS_FETCHED: { 23 | const newItems = payload.feed.map(item => item.id_str); 24 | return { 25 | fetching: false, 26 | list: payload.reset ? newItems : [...state.list, ...newItems], 27 | }; 28 | } 29 | 30 | case INIT: { 31 | if (!payload.mentions) return state; 32 | return { 33 | ...payload.mentions, 34 | fetching: false 35 | }; 36 | } 37 | 38 | case LOGOUT: { 39 | return defaultState; 40 | } 41 | 42 | default: 43 | return state; 44 | } 45 | } 46 | 47 | export interface IMentions { 48 | fetching: boolean; 49 | list: string[]; 50 | } 51 | -------------------------------------------------------------------------------- /src/reducers/search.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | 3 | import { SEARCH_FETCH, SEARCH_FETCHED, SEARCH_ERROR, LOGOUT } from '../actions'; 4 | 5 | const defaultState = {}; 6 | 7 | export const searchReducer: ActionReducer = (state: ISearch = defaultState, action: Action, ) => { 8 | const payload = action.payload; 9 | 10 | switch (action.type) { 11 | case SEARCH_FETCH: { 12 | return { 13 | ...state, 14 | [payload.term]: { 15 | ...state[payload.term], 16 | fetching: true 17 | } 18 | } 19 | } 20 | 21 | case SEARCH_ERROR: { 22 | return { 23 | ...state, 24 | [payload.term]: { 25 | ...state[payload.term], 26 | fetching: false 27 | } 28 | } 29 | } 30 | 31 | case SEARCH_FETCHED: { 32 | const newItems = payload.feed.map(item => item.id_str); 33 | const list = payload.reset || !state[payload.term] 34 | ? newItems 35 | : [...state[payload.term].list, ...newItems]; 36 | return { 37 | ...state, 38 | [payload.term]: { 39 | fetching: false, 40 | list 41 | } 42 | }; 43 | } 44 | 45 | case LOGOUT: { 46 | return defaultState; 47 | } 48 | 49 | default: 50 | return state; 51 | } 52 | }; 53 | 54 | export interface ISearchItem { 55 | fetching: boolean; 56 | list: string[]; 57 | } 58 | 59 | export interface ISearch { 60 | [key: string]: ISearchItem 61 | } -------------------------------------------------------------------------------- /src/reducers/trends.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | 3 | import { TRENDS_FETCHED, TRENDS_ERROR, TRENDS_FETCH, LOGOUT, INIT } from '../actions'; 4 | 5 | const defaultState = { 6 | fetching: false, 7 | hashtags: [], 8 | as_of: null, 9 | created_at: null, 10 | locations: null, 11 | }; 12 | 13 | export const trendsReducer: ActionReducer = (state: ITrends = defaultState, action: Action) => { 14 | const payload = action.payload; 15 | 16 | switch (action.type) { 17 | case TRENDS_FETCH: { 18 | return Object.assign({}, state, { fetching: true }); 19 | } 20 | 21 | case TRENDS_ERROR: { 22 | return Object.assign({}, state, { fetching: false }); 23 | } 24 | 25 | case TRENDS_FETCHED: { 26 | const { trends: hashtags, ...rest } = payload; 27 | return { 28 | fetching: false, 29 | hashtags, 30 | ...rest, 31 | } 32 | } 33 | 34 | case INIT: { 35 | return payload.trends || defaultState; 36 | } 37 | 38 | case LOGOUT: { 39 | return defaultState; 40 | } 41 | 42 | default: 43 | return state; 44 | } 45 | } 46 | 47 | export interface ITrendingHashtag { 48 | name: number; 49 | url: string; 50 | promoted_content: string; 51 | query: string; 52 | tweet_volume: any; 53 | } 54 | 55 | export interface ITrends { 56 | fetching: boolean, 57 | as_of: string | null; 58 | created_at: string | null; 59 | hashtags: ITrendingHashtag[]; 60 | locations: any; 61 | } 62 | -------------------------------------------------------------------------------- /src/reducers/tweets.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | import _pickBy from 'lodash/pickBy'; 3 | 4 | import { 5 | MENTIONS_FETCHED, FEED_FETCHED, LOGOUT, INIT, SEARCH_FETCHED, 6 | TWEET_RETWEET, TWEET_UNRETWEET, TWEET_FAVORITE, TWEET_UNFAVORITE, 7 | USER_TWEETS_FETCHED, USER_LIKES_FETCHED 8 | } from '../actions'; 9 | import { ITwitterUser } from './users'; 10 | 11 | const defaultState = {}; 12 | 13 | const propertiesToKeep: string[] = [ 14 | 'id', 15 | 'id_str', 16 | 'created_at', 17 | 'text', 18 | 'truncated', 19 | 'user', 20 | 'favorite_count', 21 | 'favorited', 22 | 'retweet_count', 23 | 'retweeted', 24 | 'retweeted_status', 25 | 'entities', 26 | ]; 27 | 28 | export const tweetsReducer: ActionReducer = (state: ITweets = defaultState, action: Action, ) => { 29 | const payload = action.payload; 30 | 31 | switch (action.type) { 32 | case USER_TWEETS_FETCHED: 33 | case USER_LIKES_FETCHED: 34 | case SEARCH_FETCHED: 35 | case MENTIONS_FETCHED: 36 | case FEED_FETCHED: { 37 | return { ...state, ...filterTweetList(payload.feed, propertiesToKeep) }; 38 | } 39 | 40 | case TWEET_RETWEET: { 41 | return { 42 | ...state, 43 | [payload.id]: { 44 | ...state[payload.id], 45 | retweeted: true 46 | } 47 | }; 48 | } 49 | 50 | case TWEET_UNRETWEET: { 51 | return { 52 | ...state, 53 | [payload.id]: { 54 | ...state[payload.id], 55 | retweeted: false 56 | } 57 | }; 58 | } 59 | 60 | case TWEET_FAVORITE: 61 | case TWEET_UNFAVORITE: { 62 | return { ...state, ...filterTweetList([payload.tweet], propertiesToKeep) }; 63 | } 64 | 65 | case INIT: { 66 | return payload.tweets || state; 67 | } 68 | 69 | case LOGOUT: { 70 | return defaultState; 71 | } 72 | 73 | default: 74 | return state; 75 | } 76 | }; 77 | 78 | function filterTweetProperties(tweet) { 79 | let feedItem = _pickBy(tweet, (v, k) => propertiesToKeep.includes(k)); 80 | feedItem.userHandle = feedItem.user.screen_name.toLowerCase(); 81 | delete feedItem.user; 82 | return feedItem; 83 | } 84 | 85 | export function filterTweetList(list = [], propertiesToKeep = []) { 86 | const feedItems = {}; 87 | list.forEach(item => { 88 | let feedItem = filterTweetProperties(item); 89 | if (feedItem.retweeted_status) { // if the tweet contains another tweet 90 | let retweetedFeedItem = filterTweetProperties(feedItem.retweeted_status); 91 | feedItems[retweetedFeedItem.id_str] = retweetedFeedItem; 92 | feedItem.retweeted_status_id = retweetedFeedItem.id_str; 93 | delete feedItem.retweeted_status; 94 | } 95 | feedItems[feedItem.id_str] = feedItem; 96 | }); 97 | return feedItems; 98 | } 99 | 100 | // https://dev.twitter.com/overview/api/entities-in-twitter-objects 101 | export interface ITweetEntities { 102 | hashtags: ITweetEntitiesHashtag[]; 103 | symbols: ITweetEntitiesSymbol[]; 104 | url: ITweetEntitiesUrl[]; 105 | media: ITweetEntitiesMedia[]; 106 | user_mentions: ITweetEntitiesMention[]; 107 | } 108 | 109 | export interface ITweetEntitiesHashtag { 110 | text: string; 111 | indices: number[]; 112 | } 113 | 114 | export interface ITweetEntitiesSymbol { 115 | text: string; 116 | indices: number[]; 117 | } 118 | 119 | export interface ITweetEntitiesUrl { 120 | display_url: string; 121 | expanded_url: string; 122 | url: string; 123 | indices: number[]; 124 | } 125 | 126 | export interface ITweetEntitiesMedia { 127 | type: string; 128 | media_url_https: string; 129 | } 130 | 131 | export interface ITweetEntitiesMention { 132 | id: number; 133 | id_str: string; 134 | name: string; 135 | screen_name: string; 136 | indices: number[]; 137 | } 138 | 139 | export interface ITweet { 140 | id: number; 141 | id_str: string; 142 | created_at: string; 143 | text: string; 144 | truncated: boolean; 145 | favorited: boolean; 146 | favorite_count: number; 147 | retweeted: boolean; 148 | retweet_count: number; 149 | userHandle?: string; 150 | user?: ITwitterUser; 151 | retweeted_status_id?: string; 152 | retweeted_status?: ITweet; 153 | entities: ITweetEntities; 154 | } 155 | 156 | export interface ITweets { 157 | [key: string]: ITweet 158 | } -------------------------------------------------------------------------------- /src/reducers/userLikes.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | import _get from 'lodash/get'; 3 | 4 | import { USER_LIKES_FETCH, USER_LIKES_FETCHED, USER_LIKES_ERROR, LOGOUT, INIT } from '../actions'; 5 | 6 | const defaultState = {}; 7 | 8 | export const userLikesReducer: ActionReducer = (state: IUserLikes = defaultState, action: Action, ) => { 9 | const payload = action.payload; 10 | 11 | switch (action.type) { 12 | case USER_LIKES_FETCH: { 13 | return { 14 | ...state, 15 | [payload.username]: { 16 | ...state[payload.username], 17 | fetching: true 18 | } 19 | } 20 | } 21 | 22 | case USER_LIKES_ERROR: { 23 | return { 24 | ...state, 25 | [payload.username]: { 26 | ...state[payload.username], 27 | fetching: false 28 | } 29 | } 30 | } 31 | 32 | case USER_LIKES_FETCHED: { 33 | const newItems = payload.feed.map(item => item.id_str); 34 | const list = payload.reset || !state[payload.username] 35 | ? newItems 36 | : [...state[payload.username].list, ...newItems]; 37 | return { 38 | ...state, 39 | [payload.username]: { 40 | fetching: false, 41 | list 42 | } 43 | }; 44 | } 45 | 46 | case INIT: { 47 | return payload.userLikes || defaultState; 48 | } 49 | 50 | case LOGOUT: { 51 | return defaultState; 52 | } 53 | 54 | default: 55 | return state; 56 | } 57 | }; 58 | 59 | export interface IUserLikesFeed { 60 | fetching: boolean; 61 | list: string[]; 62 | } 63 | 64 | export interface IUserLikes { 65 | [key: string]: IUserLikesFeed 66 | } 67 | -------------------------------------------------------------------------------- /src/reducers/userTweets.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | import _get from 'lodash/get'; 3 | 4 | import { USER_TWEETS_FETCH, USER_TWEETS_FETCHED, USER_TWEETS_ERROR, LOGOUT, INIT } from '../actions'; 5 | 6 | const defaultState = {}; 7 | 8 | export const userTweetsReducer: ActionReducer = (state: IUserTweets = defaultState, action: Action, ) => { 9 | const payload = action.payload; 10 | 11 | switch (action.type) { 12 | case USER_TWEETS_FETCH: { 13 | return { 14 | ...state, 15 | [payload.username]: { 16 | ...state[payload.username], 17 | fetching: true 18 | } 19 | } 20 | } 21 | 22 | case USER_TWEETS_ERROR: { 23 | return { 24 | ...state, 25 | [payload.username]: { 26 | ...state[payload.username], 27 | fetching: false 28 | } 29 | } 30 | } 31 | 32 | case USER_TWEETS_FETCHED: { 33 | const newItems = payload.feed.map(item => item.id_str); 34 | const list = payload.reset || !state[payload.username] 35 | ? newItems 36 | : [...state[payload.username].list, ...newItems]; 37 | return { 38 | ...state, 39 | [payload.username]: { 40 | fetching: false, 41 | list 42 | } 43 | }; 44 | } 45 | 46 | case INIT: { 47 | return payload.userTweets || defaultState; 48 | } 49 | 50 | case LOGOUT: { 51 | return defaultState; 52 | } 53 | 54 | default: 55 | return state; 56 | } 57 | }; 58 | 59 | export interface IUserTweetsFeed { 60 | fetching: boolean; 61 | list: string[]; 62 | } 63 | 64 | export interface IUserTweets { 65 | [key: string]: IUserTweetsFeed 66 | } 67 | -------------------------------------------------------------------------------- /src/reducers/users.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | import { 3 | ADD_TWITTER_USER, ADD_CURRENT_TWITTER_USER, FEED_FETCHED, 4 | MENTIONS_FETCHED, INIT, SEARCH_FETCHED, USER_TWEETS_FETCHED, 5 | USER_LIKES_FETCHED 6 | } from '../actions'; 7 | import _pickBy from 'lodash/pickBy'; 8 | 9 | const defaultState = {}; 10 | 11 | const propertiesToKeep: string[] = [ 12 | 'id', 'id_str', 'screen_name', 'name', 'description', 'url', 13 | 'location', 'followers_count', 'friends_count', 14 | 'profile_background_image_url_https', 'profile_banner_url', 15 | 'profile_image_url_https', 'profile_use_background_image', 'following' 16 | ]; 17 | 18 | export const usersReducer: ActionReducer = (state: IUsersState = defaultState, action: Action) => { 19 | const payload = action.payload; 20 | 21 | switch (action.type) { 22 | case ADD_CURRENT_TWITTER_USER: 23 | case ADD_TWITTER_USER: { 24 | return Object.assign({}, state, { 25 | [payload.user.screen_name.toLowerCase()]: filterUser(payload.user) 26 | }); 27 | } 28 | 29 | case USER_TWEETS_FETCHED: 30 | case USER_LIKES_FETCHED: 31 | case SEARCH_FETCHED: 32 | case MENTIONS_FETCHED: 33 | case FEED_FETCHED: { 34 | if (!payload.feed) return state; 35 | const users = {}; 36 | payload.feed.forEach(item => { 37 | users[item.user.screen_name.toLowerCase()] = filterUser(item.user); 38 | if (item.retweeted_status) { 39 | users[item.retweeted_status.user.screen_name.toLowerCase()] = filterUser(item.retweeted_status.user); 40 | } 41 | }); 42 | return Object.assign({}, state, users); 43 | } 44 | 45 | case INIT: { 46 | return payload.users || defaultState; 47 | } 48 | 49 | default: 50 | return state; 51 | } 52 | } 53 | 54 | function filterUser(user) { 55 | return _pickBy(user, (v, k) => propertiesToKeep.includes(k)) 56 | } 57 | 58 | export interface ITwitterUser { 59 | id: number; 60 | id_str: string; 61 | name: string; 62 | screen_name: string; 63 | description: string; 64 | url: string; 65 | location: string; 66 | followers_count: number; 67 | friends_count: number; 68 | profile_background_color: string; 69 | profile_background_image_url_https: string; 70 | profile_banner_url: string; 71 | profile_image_url_https: string; 72 | profile_link_color: string; 73 | profile_text_color: string; 74 | profile_use_background_image: boolean; 75 | } 76 | 77 | export interface IUsersState { 78 | [key: number]: ITwitterUser 79 | } -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { StoreModule, combineReducers } from '@ngrx/store'; 2 | import { EffectsModule } from '@ngrx/effects'; 3 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 4 | 5 | import { Reducers } from './reducers'; 6 | import { AuthEffects } from './effects/auth'; 7 | 8 | const combinedReducers = combineReducers(Reducers); 9 | 10 | export function reducer(state: any, action: any) { 11 | return combinedReducers(state, action); 12 | } 13 | 14 | let modules = [ 15 | StoreModule.provideStore(reducer), 16 | EffectsModule.run(AuthEffects) 17 | ]; 18 | 19 | if (localStorage.getItem('debug') === "true") { 20 | modules.push(StoreDevtoolsModule.instrumentOnlyWithExtension()) 21 | } 22 | 23 | export const STORE = modules; -------------------------------------------------------------------------------- /src/theme/_keyframes.scss: -------------------------------------------------------------------------------- 1 | @keyframes fadeInUp { 2 | from { 3 | opacity: 0; 4 | transform: translate3d(0, 100%, 0); 5 | } 6 | 7 | to { 8 | opacity: 1; 9 | transform: none; 10 | } 11 | } -------------------------------------------------------------------------------- /src/theme/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin verticalCentered() { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | 7 | @mixin ellipsis(){ 8 | white-space: nowrap; 9 | overflow: hidden; 10 | text-overflow: ellipsis; 11 | } -------------------------------------------------------------------------------- /src/theme/ionicons-icons.scss: -------------------------------------------------------------------------------- 1 | @import "ionicons-variables"; 2 | // Ionicons 3 | // -------------------------- 4 | 5 | @font-face { 6 | font-family: "Ionicons"; 7 | src:url("#{$ionicons-font-path}/ionicons.eot?v=#{$ionicons-version}"); 8 | src:url("#{$ionicons-font-path}/ionicons.eot?v=#{$ionicons-version}#iefix") format("embedded-opentype"), 9 | url("#{$ionicons-font-path}/ionicons.woff2?v=#{$ionicons-version}") format("woff2"), 10 | url("#{$ionicons-font-path}/ionicons.woff?v=#{$ionicons-version}") format("woff"), 11 | url("#{$ionicons-font-path}/ionicons.ttf?v=#{$ionicons-version}") format("truetype"), 12 | url("#{$ionicons-font-path}/ionicons.svg?v=#{$ionicons-version}#Ionicons") format("svg"); 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | 17 | .ion { 18 | display: inline-block; 19 | font-family: "Ionicons"; 20 | speak: none; 21 | font-style: normal; 22 | font-weight: normal; 23 | font-variant: normal; 24 | text-transform: none; 25 | text-rendering: auto; 26 | line-height: 1; 27 | -webkit-font-smoothing: antialiased; 28 | -moz-osx-font-smoothing: grayscale; 29 | } 30 | 31 | // Ionicons Icon Font CSS 32 | // -------------------------- 33 | // complete list https://github.com/ionic-team/ionicons/blob/3.0/dist/scss/ionicons-icons.scss 34 | .ion-md-home:before { content: "\f30c"; } 35 | .ion-md-search:before { content: "\f375"; } 36 | .ion-md-notifications:before { content: "\f338"; } 37 | .ion-md-chatboxes:before { content: "\f2b6"; } 38 | .ion-md-close:before { content: "\f2c0"; } 39 | .ion-md-arrow-back:before { content: "\f27d"; } 40 | .ion-md-pin:before { content: "\f34a"; } 41 | .ion-md-link:before { content: "\f22e"; } 42 | .ion-md-repeat:before { content: "\f36a"; } 43 | .ion-md-heart:before { content: "\f308"; } 44 | .ion-md-mail:before { content: "\f322"; } 45 | 46 | .ion-md-home:before, 47 | .ion-md-search:before, 48 | .ion-md-notifications:before, 49 | .ion-md-chatboxes:before, 50 | .ion-md-close:before, 51 | .ion-md-arrow-back:before, 52 | .ion-md-pin:before, 53 | .ion-md-link:before, 54 | .ion-md-repeat:before, 55 | .ion-md-heart:before, 56 | .ion-md-mail:before 57 | { 58 | @extend .ion 59 | } 60 | -------------------------------------------------------------------------------- /src/theme/variables.scss: -------------------------------------------------------------------------------- 1 | // Ionic Variables and Theming. For more info, please see: 2 | // http://ionicframework.com/docs/v2/theming/ 3 | $font-path: "../assets/fonts"; 4 | 5 | // @import "ionic.globals.md"; 6 | 7 | 8 | // Shared Variables 9 | // -------------------------------------------------- 10 | // To customize the look and feel of this app, you can override 11 | // the Sass variables found in Ionic's source scss files. 12 | // To view all the possible Ionic variables, see: 13 | // http://ionicframework.com/docs/v2/theming/overriding-ionic-variables/ 14 | 15 | 16 | 17 | 18 | // Named Color Variables 19 | // -------------------------------------------------- 20 | // Named colors makes it easy to reuse colors on various components. 21 | // It's highly recommended to change the default colors 22 | // to match your app's branding. Ionic uses a Sass map of 23 | // colors so you can add, rename and remove colors as needed. 24 | // The "primary" color is the only required color in the map. 25 | 26 | $gray-base: #000 !default; 27 | $gray-darker: lighten($gray-base, 13.5%) !default; 28 | $gray-dark: lighten($gray-base, 20%) !default; 29 | $gray: lighten($gray-base, 33.5%) !default; 30 | $gray-light: lighten($gray-base, 46.7%) !default; 31 | $gray-light2: lighten($gray-base, 55%) !default; 32 | $gray-light3: lighten($gray-base, 65%) !default; 33 | $gray-light4: lighten($gray-base, 75%) !default; 34 | $gray-lighter: lighten($gray-base, 93.5%) !default; 35 | $gray-lighter2: lighten($gray-base, 95%) !default; 36 | $gray-lighter3: lighten($gray-base, 97%) !default; 37 | 38 | $twitter-blue: #1da1f2; 39 | $twitter-gray-text: #657786; 40 | 41 | $colors: ( 42 | primary: $twitter-blue, 43 | secondary: #32db64, 44 | danger: #f53d3d, 45 | light: #f4f4f4, 46 | dark: #222, 47 | gray: $twitter-gray-text 48 | ); 49 | 50 | $loader-width: 50px !default; 51 | $loader-height: 50px !default; 52 | $loader-zIndex: 1002 !default; 53 | $loader-bg-color: white !default; 54 | $loader-stroke: color($colors, primary) !default; 55 | 56 | // menu 57 | // ------------------------------ 58 | $split-pane-side-max-width: 304px !default; 59 | 60 | 61 | // App iOS Variables 62 | // -------------------------------------------------- 63 | // iOS only Sass variables can go here 64 | 65 | 66 | 67 | 68 | // App Material Design Variables 69 | // -------------------------------------------------- 70 | // Material Design only Sass variables can go here 71 | 72 | 73 | 74 | 75 | // App Windows Variables 76 | // -------------------------------------------------- 77 | // Windows only Sass variables can go here 78 | 79 | 80 | 81 | 82 | // App Theme 83 | // -------------------------------------------------- 84 | // Ionic apps can have different themes applied, which can 85 | // then be future customized. This import comes last 86 | // so that the above variables are used and Ionic's 87 | // default are overridden. 88 | 89 | @import "ionic.theme.default.md"; 90 | 91 | 92 | // Ionicons 93 | // -------------------------------------------------- 94 | // The premium icon font for Ionic. For more info, please see: 95 | // http://ionicframework.com/docs/v2/ionicons/ 96 | 97 | // @import "ionic.ionicons"; 98 | @import "./ionicons-icons"; 99 | 100 | // Fonts 101 | // -------------------------------------------------- 102 | 103 | @import "roboto"; 104 | // @import "noto-sans"; 105 | @import "./mixins"; 106 | @import "./keyframes"; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": false, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "lib": [ 8 | "dom", 9 | "es2017" 10 | ], 11 | "module": "es2015", 12 | "moduleResolution": "node", 13 | "sourceMap": true, 14 | "target": "es5" 15 | }, 16 | "include": [ 17 | "src/**/*.ts" 18 | ], 19 | "exclude": [ 20 | "node_modules" 21 | ], 22 | "compileOnSave": false, 23 | "atom": { 24 | "rewriteTsconfig": false 25 | } 26 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-duplicate-variable": true, 4 | "no-unused-variable": [ 5 | true 6 | ] 7 | }, 8 | "rulesDirectory": [ 9 | "node_modules/tslint-eslint-rules/dist/rules" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /webpack.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var ionicWebpackFactory = require(process.env.IONIC_WEBPACK_FACTORY); 4 | var ModuleConcatPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin'); 5 | 6 | module.exports = { 7 | entry: process.env.IONIC_APP_ENTRY_POINT, 8 | output: { 9 | path: '{{BUILD}}', 10 | publicPath: 'build/', 11 | filename: '[name].js', 12 | devtoolModuleFilenameTemplate: ionicWebpackFactory.getSourceMapperFunction(), 13 | }, 14 | devtool: process.env.IONIC_SOURCE_MAP_TYPE, 15 | 16 | resolve: { 17 | extensions: ['.ts', '.js', '.json'], 18 | modules: [path.resolve('node_modules')] 19 | }, 20 | 21 | module: { 22 | loaders: [ 23 | { 24 | test: /\.json$/, 25 | loader: 'json-loader' 26 | }, 27 | { 28 | test: /\.ts$/, 29 | loader: process.env.IONIC_WEBPACK_LOADER 30 | }, 31 | { 32 | test: /\.js$/, 33 | loader: process.env.IONIC_WEBPACK_TRANSPILE_LOADER 34 | } 35 | ] 36 | }, 37 | 38 | plugins: [ 39 | ionicWebpackFactory.getIonicEnvironmentPlugin(), 40 | ionicWebpackFactory.getCommonChunksPlugin(), 41 | new ModuleConcatPlugin(), 42 | new webpack.NormalModuleReplacementPlugin( 43 | /dist[\\\/]scss[\\\/]ionicons-icons.scss$/, 44 | path.join(__dirname, '/src/theme/ionicons-icons.scss') 45 | ), 46 | new webpack.DefinePlugin({ 47 | __DEV__: process.env.IONIC_ENV === 'dev', 48 | __PROD__: process.env.IONIC_ENV === 'prod', 49 | __APIURI__: JSON.stringify(process.env.IONIC_ENV === 'prod' 50 | ? '//twitter-pwa.julienrenaux.fr/' 51 | : `//127.0.0.1:${process.env.SERVER_PORT || 5000}/`) 52 | }), 53 | ], 54 | 55 | // Some libraries import Node modules but don't use them in the browser. 56 | // Tell Webpack to provide empty mocks for them so importing them works. 57 | node: { 58 | fs: 'empty', 59 | net: 'empty', 60 | tls: 'empty' 61 | } 62 | }; --------------------------------------------------------------------------------