├── .browserslistrc ├── .eslintrc.cjs ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .stylelintrc ├── LICENSE ├── README.md ├── img ├── clock.svg ├── comments.svg ├── down.svg ├── menu.svg ├── popular.svg └── up.svg ├── package-lock.json ├── package.json ├── screenshot.png ├── src ├── app │ ├── api.ts │ ├── mailAuth.ts │ ├── main.tsx │ └── settings.sample.ts ├── enums │ ├── ApiAction.ts │ ├── Color.ts │ ├── NotificationScrollType.ts │ ├── NotificationType.ts │ ├── PostListPostType.ts │ ├── PostListSortType.ts │ ├── PostOwn.ts │ ├── Section.ts │ ├── StickyType.ts │ ├── ToastType.ts │ ├── TokenType.ts │ ├── UserHandle.ts │ ├── UserType.ts │ └── VoteType.ts ├── index.html ├── index.ts ├── interfaces │ ├── IApiChannelPostListCombo.ts │ ├── IApiChannelsMeta.ts │ ├── IApiConfig.ts │ ├── IApiGcmAccount.ts │ ├── IApiGcmVerification.ts │ ├── IApiHashtagPostListCombo.ts │ ├── IApiKarma.ts │ ├── IApiLocationPostListCombo.ts │ ├── IApiNotificationAvailable.ts │ ├── IApiNotifications.ts │ ├── IApiPin.ts │ ├── IApiPollVotes.ts │ ├── IApiPostAdded.ts │ ├── IApiPostDetails.ts │ ├── IApiPostDetailsPost.ts │ ├── IApiPostListCombo.ts │ ├── IApiPostListPost.ts │ ├── IApiPostListSingle.ts │ ├── IApiRecommendedChannels.ts │ ├── IApiRefreshToken.ts │ ├── IApiRegister.ts │ ├── IApiShare.ts │ ├── IApiSticky.ts │ ├── IApiVote.ts │ ├── IChannel.ts │ ├── IJodelAction.ts │ ├── ILocation.ts │ ├── INotification.ts │ ├── IPost.ts │ ├── ISettings.ts │ ├── IToast.ts │ ├── IToken.ts │ └── JodelThunkAction.ts ├── manifest.webmanifest ├── redux │ ├── actions.ts │ ├── actions │ │ ├── action.consts.ts │ │ ├── api.ts │ │ ├── state.tsx │ │ └── toasts.actions.ts │ ├── reducers.ts │ ├── reducers │ │ ├── account.tsx │ │ ├── entities.tsx │ │ ├── postsBySection.tsx │ │ ├── settings.tsx │ │ ├── toasts.ts │ │ └── viewState.tsx │ └── selectors │ │ ├── app.ts │ │ ├── channels.ts │ │ ├── notifications.ts │ │ ├── posts.ts │ │ └── view.ts ├── sw.ts ├── translations │ └── de.ts ├── utils │ ├── bytes.utils.ts │ ├── notification.utils.ts │ ├── picture.utils.ts │ └── utils.ts └── views │ ├── AddButton.scss │ ├── AddButton.tsx │ ├── AddPost.scss │ ├── AddPost.tsx │ ├── App.tsx │ ├── AppSettings.tsx │ ├── BackButton.tsx │ ├── BigPicture.scss │ ├── BigPicture.tsx │ ├── ChannelList.scss │ ├── ChannelList.tsx │ ├── ChannelListItem.scss │ ├── ChannelListItem.tsx │ ├── ChannelTopBar.tsx │ ├── ChildInfo.tsx │ ├── ColorPicker.tsx │ ├── EmailVerification.tsx │ ├── FirstStart.tsx │ ├── HashtagTopBar.tsx │ ├── Jodel.tsx │ ├── Location.tsx │ ├── Menu.scss │ ├── Menu.tsx │ ├── Message.tsx │ ├── NotificationList.scss │ ├── NotificationList.tsx │ ├── NotificationListItem.scss │ ├── NotificationListItem.tsx │ ├── Post.tsx │ ├── PostDetails.tsx │ ├── PostList.tsx │ ├── PostListContainer.tsx │ ├── PostListItem.tsx │ ├── PostTopBar.scss │ ├── PostTopBar.tsx │ ├── Progress.tsx │ ├── ScrollToBottomButton.tsx │ ├── Search.tsx │ ├── SectionLink.tsx │ ├── SelectDeviceUid.tsx │ ├── SelectLocation.scss │ ├── SelectLocation.tsx │ ├── SelectLocationMap.tsx │ ├── ShareLink.scss │ ├── ShareLink.tsx │ ├── SortTypeLink.tsx │ ├── Sticky.tsx │ ├── StickyList.tsx │ ├── Time.scss │ ├── Time.tsx │ ├── Toast.scss │ ├── Toast.tsx │ ├── ToastContainer.scss │ ├── ToastContainer.tsx │ ├── TopBar.tsx │ ├── Vote.scss │ ├── Vote.tsx │ └── map │ ├── Map.scss │ ├── Map.tsx │ ├── MapCircle.tsx │ ├── MapMarker.scss │ ├── MapMarker.tsx │ └── map-utils.tsx ├── style ├── main.scss └── settings.scss ├── tsconfig.json ├── typings └── react-document-title │ └── index.d.ts └── webpack.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Browsers that we support 2 | 3 | > 0.5% 4 | Last 3 versions 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignorePatterns: ['/src/sw.ts'], 3 | env: { 4 | browser: true, 5 | es6: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 12 | 'plugin:import/recommended', 13 | 'plugin:import/typescript', 14 | 'prettier', 15 | 'plugin:react-hooks/recommended', 16 | ], 17 | parser: '@typescript-eslint/parser', 18 | parserOptions: { 19 | project: 'tsconfig.json', 20 | sourceType: 'module', 21 | }, 22 | plugins: ['eslint-plugin-import', 'eslint-plugin-jsdoc', '@typescript-eslint'], 23 | rules: { 24 | '@typescript-eslint/array-type': 'warn', 25 | '@typescript-eslint/consistent-type-assertions': 'warn', 26 | '@typescript-eslint/consistent-type-imports': 'warn', 27 | '@typescript-eslint/dot-notation': 'warn', 28 | '@typescript-eslint/member-delimiter-style': [ 29 | 'warn', 30 | { 31 | multiline: { 32 | delimiter: 'semi', 33 | requireLast: true, 34 | }, 35 | singleline: { 36 | delimiter: 'semi', 37 | requireLast: false, 38 | }, 39 | }, 40 | ], 41 | '@typescript-eslint/no-empty-interface': 'off', 42 | '@typescript-eslint/no-floating-promises': 'off', 43 | '@typescript-eslint/no-non-null-assertion': 'off', 44 | '@typescript-eslint/no-shadow': [ 45 | 'warn', 46 | { 47 | hoist: 'all', 48 | }, 49 | ], 50 | '@typescript-eslint/no-unused-expressions': 'warn', 51 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 52 | '@typescript-eslint/prefer-for-of': 'warn', 53 | '@typescript-eslint/prefer-function-type': 'warn', 54 | '@typescript-eslint/prefer-namespace-keyword': 'warn', 55 | '@typescript-eslint/quotes': ['warn', 'single'], 56 | '@typescript-eslint/semi': ['warn', 'always'], 57 | '@typescript-eslint/unified-signatures': 'warn', 58 | 'arrow-parens': ['warn', 'as-needed'], 59 | eqeqeq: ['warn', 'smart'], 60 | 'guard-for-in': 'warn', 61 | 'import/order': [ 62 | 'warn', 63 | { 64 | 'newlines-between': 'always', 65 | alphabetize: { order: 'asc', caseInsensitive: true }, 66 | groups: ['builtin', 'external', 'parent', 'sibling', 'index'], 67 | }, 68 | ], 69 | 'jsdoc/check-alignment': 'warn', 70 | 'jsdoc/check-indentation': 'warn', 71 | 'jsdoc/newline-after-description': 'warn', 72 | 'max-len': [ 73 | 'warn', 74 | { 75 | code: 140, 76 | }, 77 | ], 78 | 'new-parens': 'warn', 79 | 'no-bitwise': 'warn', 80 | 'no-caller': 'warn', 81 | 'no-console': [ 82 | 'warn', 83 | { 84 | allow: [ 85 | 'warn', 86 | 'dir', 87 | 'time', 88 | 'timeEnd', 89 | 'timeLog', 90 | 'trace', 91 | 'assert', 92 | 'clear', 93 | 'count', 94 | 'countReset', 95 | 'group', 96 | 'groupEnd', 97 | 'table', 98 | 'debug', 99 | 'info', 100 | 'dirxml', 101 | 'error', 102 | 'groupCollapsed', 103 | 'Console', 104 | 'profile', 105 | 'profileEnd', 106 | 'timeStamp', 107 | 'context', 108 | ], 109 | }, 110 | ], 111 | '@typescript-eslint/no-empty-function': 'warn', 112 | 'no-eval': 'warn', 113 | 'no-new-wrappers': 'warn', 114 | 'no-throw-literal': 'warn', 115 | 'no-trailing-spaces': 'warn', 116 | 'no-undef-init': 'warn', 117 | 'no-unused-labels': 'warn', 118 | 'no-var': 'warn', 119 | 'object-shorthand': 'warn', 120 | 'one-var': ['warn', 'never'], 121 | 'prefer-const': 'warn', 122 | 'no-constant-condition': ['error', { checkLoops: false }], 123 | radix: 'warn', 124 | 'spaced-comment': [ 125 | 'warn', 126 | 'always', 127 | { 128 | markers: ['/'], 129 | }, 130 | ], 131 | '@typescript-eslint/no-misused-promises': [ 132 | 'error', 133 | { 134 | checksVoidReturn: false, 135 | }, 136 | ], 137 | }, 138 | }; 139 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 6 * * 0' 11 | 12 | jobs: 13 | analyse: 14 | name: Analyse 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | # Override language selection by uncommenting this and choosing your languages 34 | # with: 35 | # languages: go, javascript, csharp, python, cpp, java 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v1 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.swp 3 | *~ 4 | *.iml 5 | dist/ 6 | .idea/* 7 | !.idea/codeStyles/ 8 | /src/app/settings.ts 9 | /profiling.json 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-prettier", "stylelint-config-standard-scss"], 3 | "plugins": ["stylelint-order"], 4 | "ignoreFiles": [], 5 | "rules": { 6 | "block-closing-brace-newline-before": ["always"], 7 | "block-no-empty": true, 8 | "block-opening-brace-newline-after": ["always"], 9 | "color-hex-case": "lower", 10 | "color-hex-length": "short", 11 | "color-no-invalid-hex": true, 12 | "declaration-block-trailing-semicolon": "always", 13 | "declaration-colon-space-after": "always", 14 | "declaration-colon-space-before": "never", 15 | "indentation": 4, 16 | "length-zero-no-unit": true, 17 | "max-empty-lines": 1, 18 | "media-feature-colon-space-after": "always", 19 | "media-feature-colon-space-before": "never", 20 | "no-eol-whitespace": true, 21 | "no-extra-semicolons": true, 22 | "no-missing-end-of-source-newline": true, 23 | "number-leading-zero": "always", 24 | "number-no-trailing-zeros": true, 25 | "order/properties-alphabetical-order": true, 26 | "property-case": "lower", 27 | "rule-empty-line-before": [ 28 | "always-multi-line", 29 | { 30 | "except": ["first-nested"], 31 | "ignore": ["after-comment"] 32 | } 33 | ], 34 | "selector-class-pattern": ".*", 35 | "selector-list-comma-newline-after": "always", 36 | "selector-list-comma-newline-before": "never-multi-line", 37 | "selector-max-empty-lines": 0, 38 | "selector-max-specificity": "0,4,0", 39 | "selector-max-id": 0, 40 | "selector-pseudo-element-colon-notation": "double", 41 | "string-quotes": "single", 42 | "unit-case": "lower", 43 | "unit-no-unknown": true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JodelJS 2 | 3 | Unofficial web app for [Jodel](https://jodel-app.com/). The app runs completely client-side in the user’s browser. Supports creating new accounts, reading posts and posting text/images. 4 | The account information is stored in the browser’s localStorage. 5 | 6 | DesktopJodel Screenshot 7 | 8 | Before building the app, create your own `settings.ts` file in `src/app/` by copying `settings.sample.ts`. 9 | 10 | Build app: 11 | 12 | npm install 13 | npm run build 14 | 15 | Copy the files in `dist/` to your webspace and open index.html in your browser. 16 | 17 | ## Develop 18 | 19 | After first clone and after updating: 20 | 21 | npm install 22 | 23 | Start watcher to compile ts and run dev webserver: 24 | 25 | npm start 26 | 27 | ## License 28 | 29 | Copyright: AsamK 2016-2020 30 | 31 | Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html 32 | -------------------------------------------------------------------------------- /img/clock.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /img/comments.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /img/down.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /img/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /img/popular.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /img/up.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jodeljs", 3 | "description": "Jodel webapp", 4 | "license": "GPL-3.0", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+ssh://git@github.com:AsamK/JodelJS.git" 8 | }, 9 | "dependencies": { 10 | "classnames": "2.3.x", 11 | "leaflet": "1.8.x", 12 | "nprogress": "0.2.x", 13 | "react": "18.2.x", 14 | "react-document-title": "2.0.x", 15 | "react-dom": "18.2.x", 16 | "react-intl": "6.1.x", 17 | "react-redux": "8.0.x", 18 | "redux": "4.2.x", 19 | "redux-freeze": "0.1.x", 20 | "redux-thunk": "2.4.x", 21 | "reselect": "4.1.x", 22 | "tslib": "2.4.x" 23 | }, 24 | "devDependencies": { 25 | "@types/leaflet": "1.8.x", 26 | "@types/nprogress": "0.2.x", 27 | "@types/react": "18.0.x", 28 | "@types/react-dom": "18.0.x", 29 | "@types/react-redux": "7.1.x", 30 | "@types/webpack-env": "1.18.x", 31 | "@typescript-eslint/eslint-plugin": "5.37.x", 32 | "@typescript-eslint/parser": "5.37.x", 33 | "autoprefixer": "10.4.11", 34 | "clean-webpack-plugin": "4.0.x", 35 | "copy-webpack-plugin": "11.0.x", 36 | "css-loader": "6.7.x", 37 | "css-minimizer-webpack-plugin": "4.1.x", 38 | "eslint": "8.23.x", 39 | "eslint-config-prettier": "8.5.x", 40 | "eslint-plugin-import": "2.26.x", 41 | "eslint-plugin-jsdoc": "39.3.x", 42 | "eslint-plugin-react-hooks": "4.6.x", 43 | "html-loader": "4.1.x", 44 | "html-webpack-plugin": "5.5.x", 45 | "mini-css-extract-plugin": "2.6.x", 46 | "normalize.css": "8.0.x", 47 | "postcss-loader": "7.0.x", 48 | "prettier": "^2.7.1", 49 | "sass": "1.54.x", 50 | "sass-loader": "13.0.x", 51 | "source-map-loader": "4.0.x", 52 | "style-loader": "3.3.x", 53 | "stylelint": "14.12.x", 54 | "stylelint-config-prettier": "^9.0.3", 55 | "stylelint-config-standard-scss": "5.0.x", 56 | "stylelint-order": "5.0.x", 57 | "stylelint-scss": "4.3.x", 58 | "terser-webpack-plugin": "5.3.x", 59 | "ts-loader": "9.3.x", 60 | "typescript": "4.8.x", 61 | "webpack": "5.74.x", 62 | "webpack-bundle-analyzer": "4.6.x", 63 | "webpack-cli": "4.10.x", 64 | "webpack-dev-server": "4.11.x", 65 | "workbox-webpack-plugin": "6.5.x" 66 | }, 67 | "scripts": { 68 | "start": "webpack serve --mode=development", 69 | "build": "webpack --mode=production", 70 | "stylelint": "stylelint ./***/*.scss", 71 | "stylelint-fix": "stylelint ./***/*.scss --fix", 72 | "eslint": "eslint src", 73 | "eslint-fix": "eslint src --fix", 74 | "prettier": "prettier --check .", 75 | "prettier-fix": "prettier --write .", 76 | "lint": "npm run prettier && npm run eslint && npm run stylelint", 77 | "lint-fix": "npm run prettier-fix && npm run eslint-fix && npm run stylelint-fix", 78 | "bundle-report": "webpack --analyze" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AsamK/JodelJS/b569a23f44f4af4b5979cf2468a22c54ae9afe44/screenshot.png -------------------------------------------------------------------------------- /src/app/mailAuth.ts: -------------------------------------------------------------------------------- 1 | import Settings from './settings'; 2 | 3 | const headers = new Headers([['Content-Type', 'application/json']]); 4 | 5 | export async function requestEmailVerification(email: string) { 6 | const payload = { 7 | email, 8 | }; 9 | 10 | const res = await fetch(Settings.EMAIL_REQUEST_HELPER_URL, { 11 | body: JSON.stringify(payload), 12 | headers, 13 | method: 'POST', 14 | mode: 'cors', 15 | }); 16 | 17 | if (!res.ok) { 18 | throw res; 19 | } 20 | } 21 | 22 | export interface FirebaseTokenResponse { 23 | access_token: string; 24 | user_id: string; 25 | } 26 | 27 | export async function generateFirebaseToken( 28 | email: string, 29 | linkFromEmail: string, 30 | ): Promise { 31 | const payload = { 32 | email, 33 | link: linkFromEmail, 34 | }; 35 | 36 | const res = await fetch(Settings.EMAIL_CONFIRM_HELPER_URL, { 37 | body: JSON.stringify(payload), 38 | headers, 39 | method: 'POST', 40 | mode: 'cors', 41 | }); 42 | 43 | if (res.ok) { 44 | return (await res.json()) as FirebaseTokenResponse; 45 | } 46 | 47 | throw res; 48 | } 49 | -------------------------------------------------------------------------------- /src/app/settings.sample.ts: -------------------------------------------------------------------------------- 1 | import type { ISettings } from '../interfaces/ISettings'; 2 | 3 | const Settings: ISettings = { 4 | // API Server and Path 5 | API_SERVER: 'https://api.jodelapis.com/api', 6 | 7 | // Android client id 8 | CLIENT_ID: '81e8a76e-1e02-4d17-9ba0-8a7020261b26', 9 | 10 | // Client type for signed requests 11 | CLIENT_TYPE: 'android_8.4.0', 12 | 13 | // Default location, if browser location is not available 14 | DEFAULT_LOCATION: undefined, 15 | 16 | // Helper server url to create GCM account 17 | GCM_ACCOUNT_HELPER_URL: 'http://127.0.0.1:9090/account', 18 | 19 | // Helper server url to receive GCM verification message 20 | GCM_RECEIVE_HELPER_URL: 'http://127.0.0.1:9090/verification', 21 | 22 | EMAIL_REQUEST_HELPER_URL: 'http://127.0.0.1:9090/email/request', 23 | EMAIL_CONFIRM_HELPER_URL: 'http://127.0.0.1:9090/email/confirm', 24 | 25 | // Key for signed requests 26 | KEY: 'GNyUrEmBdEkihJOIoUTXbCQmBpDSxfFNGCuaWAUH', 27 | 28 | // Colors for posts, the Server prevents other colors 29 | POST_COLORS: ['06A3CB', 'DD5F5F', 'FFBA00', 'FF9908', '8ABDB0', '9EC41C'], 30 | }; 31 | 32 | export default Settings; 33 | -------------------------------------------------------------------------------- /src/enums/ApiAction.ts: -------------------------------------------------------------------------------- 1 | export type ApiAction = 2 | | 'SetHomeStarted' 3 | | 'SetHomeCompleted' 4 | | 'NewestFeedSelected' 5 | | 'MostCommentedFeedSelected'; 6 | -------------------------------------------------------------------------------- /src/enums/Color.ts: -------------------------------------------------------------------------------- 1 | export type Color = '06A3CB' | 'DD5F5F' | 'FFBA00' | 'FF9908' | '8ABDB0' | '9EC41C'; 2 | -------------------------------------------------------------------------------- /src/enums/NotificationScrollType.ts: -------------------------------------------------------------------------------- 1 | export const enum NotificationScrollType { 2 | BOTTOM = 'bottom', 3 | TOP = 'top', 4 | } 5 | -------------------------------------------------------------------------------- /src/enums/NotificationType.ts: -------------------------------------------------------------------------------- 1 | export const enum NotificationType { 2 | PIN = 'pin', 3 | OJ_THANKS = 'oj_thanks', 4 | OJ_REPLY_REPLY = 'oj_reply_reply', 5 | OJ_REPLY_MENTION = 'oj_reply_mention', 6 | OJ_PIN_REPLY = 'oj_pin_reply', 7 | REPLY = 'reply', 8 | REPLY_MENTION = 'reply_mention', 9 | REPLY_REPLY = 'reply_reply', 10 | VOTE_REPLY = 'vote_reply', 11 | VOTE_POST = 'vote_post', 12 | } 13 | -------------------------------------------------------------------------------- /src/enums/PostListPostType.ts: -------------------------------------------------------------------------------- 1 | export const enum PostListPostType { 2 | PICTURES = 'pictures', 3 | } 4 | -------------------------------------------------------------------------------- /src/enums/PostListSortType.ts: -------------------------------------------------------------------------------- 1 | export const enum PostListSortType { 2 | RECENT = 'recent', 3 | DISCUSSED = 'discussed', 4 | POPULAR = 'popular', 5 | } 6 | -------------------------------------------------------------------------------- /src/enums/PostOwn.ts: -------------------------------------------------------------------------------- 1 | export const enum PostOwn { 2 | OWN = 'own', 3 | FRIEND = 'friend', 4 | } 5 | -------------------------------------------------------------------------------- /src/enums/Section.ts: -------------------------------------------------------------------------------- 1 | // TODO maybe add Channel to enum and add additional selectedChannel state 2 | export type Section = SectionEnum | string; 3 | 4 | export const enum SectionEnum { 5 | LOCATION = 'location', 6 | MINE = 'mine', 7 | MINE_REPLIES = 'mineReplies', 8 | MINE_VOTES = 'mineVotes', 9 | MINE_PINNED = 'minePinned', 10 | } 11 | -------------------------------------------------------------------------------- /src/enums/StickyType.ts: -------------------------------------------------------------------------------- 1 | export const enum StickyType { 2 | LINK = 'link', 3 | INFO = 'info', 4 | BUTTONS = 'buttons', 5 | } 6 | -------------------------------------------------------------------------------- /src/enums/ToastType.ts: -------------------------------------------------------------------------------- 1 | export const enum ToastType { 2 | ERROR, 3 | WARNING, 4 | INFO, 5 | } 6 | -------------------------------------------------------------------------------- /src/enums/TokenType.ts: -------------------------------------------------------------------------------- 1 | export const enum TokenType { 2 | BEARER = 'bearer', 3 | } 4 | -------------------------------------------------------------------------------- /src/enums/UserHandle.ts: -------------------------------------------------------------------------------- 1 | export const enum UserHandle { 2 | OJ = 'oj', 3 | REPLIER = 'replier', 4 | } 5 | -------------------------------------------------------------------------------- /src/enums/UserType.ts: -------------------------------------------------------------------------------- 1 | export const enum UserType { 2 | STUDENT = 'student', // Student 3 | APPRENTICE = 'apprentice', // Azubi 4 | EMPLOYEE = 'employee', 5 | HIGH_SCHOOL = 'high_school', // Schüler 6 | HIGH_SCHOOL_GRADUATE = 'high_school_graduate', // Abiturient 7 | OTHER = 'other', 8 | } 9 | -------------------------------------------------------------------------------- /src/enums/VoteType.ts: -------------------------------------------------------------------------------- 1 | export const enum VoteType { 2 | NOT_VOTED = '', 3 | UP = 'up', 4 | DOWN = 'down', 5 | } 6 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Inofficial Jodel WebApp 11 | 12 | 13 | 14 | 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // noinspection TsLint 2 | import 'normalize.css'; 3 | 4 | import '../style/main.scss'; 5 | import './app/main'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 8 | if (process.env.NODE_ENV === 'production') { 9 | let serviceWorkerRegistered = false; 10 | if ('serviceWorker' in navigator) { 11 | window.addEventListener('load', () => { 12 | navigator.serviceWorker 13 | .register('sw.js') 14 | .then(reg => { 15 | serviceWorkerRegistered = true; 16 | reg.addEventListener('updatefound', () => { 17 | console.info('[SW] Update found'); 18 | const newSW = reg.installing; 19 | newSW?.addEventListener('statechange', () => { 20 | if (newSW?.state === 'installed') { 21 | console.info('[SW] Reloading to update'); 22 | window.location.reload(); 23 | } 24 | }); 25 | }); 26 | }) 27 | .catch(() => { 28 | // Failed to register service worker, maybe blocked by user agent 29 | serviceWorkerRegistered = false; 30 | }); 31 | }); 32 | 33 | document.addEventListener('visibilitychange', async () => { 34 | if (serviceWorkerRegistered && !document.hidden) { 35 | (await navigator.serviceWorker.getRegistration())?.update(); 36 | } 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/interfaces/IApiChannelPostListCombo.ts: -------------------------------------------------------------------------------- 1 | import type { IApiPostListCombo } from './IApiPostListCombo'; 2 | 3 | export interface IApiChannelPostListCombo extends IApiPostListCombo { 4 | followers_count: number; 5 | country_followers_count: number; 6 | sponsored: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/IApiChannelsMeta.ts: -------------------------------------------------------------------------------- 1 | export interface IApiChannelsMeta { 2 | channels: IApiChannelsMetaChannel[]; 3 | } 4 | 5 | export interface IApiChannelsMetaChannel { 6 | channel: string; 7 | unread: boolean; 8 | followers: number; 9 | sponsored: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/interfaces/IApiConfig.ts: -------------------------------------------------------------------------------- 1 | import type { UserType } from '../enums/UserType'; 2 | 3 | export interface IApiExperiment { 4 | features: string[]; 5 | group: string; 6 | name: string; 7 | } 8 | 9 | export interface IApiConfig { 10 | age: number; 11 | can_change_type: boolean; 12 | channels_follow_limit: number; 13 | experiments: IApiExperiment[]; 14 | feedInternationalized: boolean; 15 | feedInternationalizable: boolean; 16 | followed_channels?: string[]; 17 | followed_hashtags?: string[]; 18 | home_set: boolean; 19 | home_name: string; 20 | home_clear_allowed: boolean; 21 | location: string; 22 | min_post_length: number; 23 | home_min_post_length: number; 24 | moderation_notify: boolean; 25 | moderator: boolean; 26 | pending_deletion: boolean; 27 | special_post_colors: string[]; 28 | triple_feed_enabled: boolean; 29 | user_blocked: boolean; 30 | user_profiling_types: string[]; 31 | user_type: UserType | null; 32 | verified: boolean; 33 | } 34 | -------------------------------------------------------------------------------- /src/interfaces/IApiGcmAccount.ts: -------------------------------------------------------------------------------- 1 | export interface IApiAndroidAccount { 2 | security_token: string; 3 | android_id: string; 4 | } 5 | 6 | export interface IApiGcmAccount { 7 | android_account: IApiAndroidAccount; 8 | gcm_token: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/IApiGcmVerification.ts: -------------------------------------------------------------------------------- 1 | export interface IApiGcmVerification { 2 | verification: { 3 | server_time: number; 4 | verification_code: string; 5 | type: 'silent_verification'; 6 | }; 7 | error?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/IApiHashtagPostListCombo.ts: -------------------------------------------------------------------------------- 1 | import type { IApiPostListCombo } from './IApiPostListCombo'; 2 | 3 | export interface IApiHashtagPostListCombo extends IApiPostListCombo { 4 | followers_count: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/IApiKarma.ts: -------------------------------------------------------------------------------- 1 | export interface IApiKarma { 2 | karma: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/interfaces/IApiLocationPostListCombo.ts: -------------------------------------------------------------------------------- 1 | import type { IApiPostListCombo } from './IApiPostListCombo'; 2 | import type { IApiSticky } from './IApiSticky'; 3 | 4 | export interface IApiLocationPostListCombo extends IApiPostListCombo { 5 | stickies: IApiSticky[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/IApiNotificationAvailable.ts: -------------------------------------------------------------------------------- 1 | export interface IApiNotificationAvailable { 2 | available: boolean; 3 | } 4 | -------------------------------------------------------------------------------- /src/interfaces/IApiNotifications.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '../enums/Color'; 2 | import type { NotificationScrollType } from '../enums/NotificationScrollType'; 3 | import type { NotificationType } from '../enums/NotificationType'; 4 | 5 | export interface IApiNotifications { 6 | notifications: IApiNotification[]; 7 | } 8 | 9 | export interface IApiNotification { 10 | color: Color; 11 | last_interaction: string; 12 | message: string; 13 | notification_id: string; 14 | post_id: string; 15 | read: boolean; 16 | replier?: number; 17 | scroll: NotificationScrollType; 18 | seen: boolean; 19 | thumbnail_url: string; 20 | type: NotificationType; 21 | user_id: string; 22 | vote_count?: number; 23 | } 24 | -------------------------------------------------------------------------------- /src/interfaces/IApiPin.ts: -------------------------------------------------------------------------------- 1 | export interface IApiPin { 2 | pin_count: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/interfaces/IApiPollVotes.ts: -------------------------------------------------------------------------------- 1 | export interface IApiPollVotes { 2 | poll_votes: [number]; 3 | } 4 | -------------------------------------------------------------------------------- /src/interfaces/IApiPostAdded.ts: -------------------------------------------------------------------------------- 1 | import type { IApiPostDetailsPost } from './IApiPostDetailsPost'; 2 | 3 | export interface IApiPostAdded { 4 | post_id: string; 5 | created_at: number; 6 | parent: IApiPostDetailsPost | null; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/IApiPostDetails.ts: -------------------------------------------------------------------------------- 1 | import type { IApiPostDetailsPost, IApiPostReplyPost } from './IApiPostDetailsPost'; 2 | 3 | export interface IApiPostDetails { 4 | details: IApiPostDetailsPost; 5 | replies: IApiPostReplyPost[]; 6 | next: string | null; 7 | hasPrev: boolean; 8 | remaining: number; 9 | shareable: boolean; 10 | readonly: boolean; 11 | votable: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /src/interfaces/IApiPostDetailsPost.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '../enums/Color'; 2 | import type { PostOwn } from '../enums/PostOwn'; 3 | import type { UserHandle } from '../enums/UserHandle'; 4 | import type { VoteType } from '../enums/VoteType'; 5 | 6 | import type { IApiPostListPost } from './IApiPostListPost'; 7 | import type { IApiLocation } from './IPost'; 8 | 9 | export interface IApiPostDetailsPost extends IApiPostListPost { 10 | notifications_enabled: boolean; 11 | } 12 | 13 | export interface IApiPostReplyPost { 14 | child_count: number; 15 | collapse: boolean; 16 | color: Color; 17 | created_at: string; 18 | discovered_by: number; 19 | distance: number; 20 | from_home?: boolean; 21 | got_thanks: boolean; 22 | image_approved: string; 23 | image_url?: string; 24 | image_headers: { 25 | Host: string; 26 | 'X-Amz-Date': string; 27 | 'x-amz-content-sha256': string; 28 | Authorization: string; 29 | }; 30 | location: IApiLocation; 31 | message: string; 32 | notifications_enabled: boolean; 33 | oj_replied: boolean; 34 | parent_id: string; 35 | pin_count: number; 36 | post_id: string; 37 | post_own: PostOwn; 38 | promoted: boolean; 39 | replier: number; 40 | reply_timestamp: string; 41 | thumbnail_url?: string; 42 | updated_at: string; 43 | user_handle: UserHandle; 44 | vote_count: number; 45 | voted?: VoteType; 46 | } 47 | -------------------------------------------------------------------------------- /src/interfaces/IApiPostListCombo.ts: -------------------------------------------------------------------------------- 1 | import type { IApiPostListPost } from './IApiPostListPost'; 2 | 3 | export interface IApiPostListCombo { 4 | max: number; 5 | recent: IApiPostListPost[]; 6 | replied: IApiPostListPost[]; 7 | voted: IApiPostListPost[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/IApiPostListPost.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '../enums/Color'; 2 | import type { PostOwn } from '../enums/PostOwn'; 3 | import type { UserHandle } from '../enums/UserHandle'; 4 | import type { VoteType } from '../enums/VoteType'; 5 | 6 | import type { IApiPostReplyPost } from './IApiPostDetailsPost'; 7 | import type { IApiLocation } from './IPost'; 8 | 9 | export interface IApiPostListPost { 10 | badge?: string; 11 | channel?: string; 12 | child_count: number; 13 | children: IApiPostReplyPost[]; 14 | color: Color; 15 | created_at: string; 16 | discovered_by: number; 17 | distance: number; 18 | from_home?: boolean; 19 | got_thanks: boolean; 20 | image_approved: string; 21 | image_url?: string; 22 | location: IApiLocation; 23 | message: string; 24 | oj_replied: boolean; 25 | pin_count: number; 26 | pinned?: boolean; 27 | post_id: string; 28 | post_own: PostOwn; 29 | replier: number; 30 | news_url?: string; 31 | news_cta?: string; 32 | share_count: number; 33 | thumbnail_url?: string; 34 | updated_at: string; 35 | user_handle: UserHandle; 36 | view_count: number; 37 | vote_count: number; 38 | voted?: VoteType; 39 | image_headers: { 40 | Host: string; 41 | 'X-Amz-Date': string; 42 | 'x-amz-content-sha256': string; 43 | Authorization: string; 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/interfaces/IApiPostListSingle.ts: -------------------------------------------------------------------------------- 1 | import type { IApiPostListPost } from './IApiPostListPost'; 2 | 3 | export interface IApiPostListSingle { 4 | max: number; 5 | posts: IApiPostListPost[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/IApiRecommendedChannels.ts: -------------------------------------------------------------------------------- 1 | export interface IApiChannel { 2 | channel: string; 3 | image_url: string; 4 | followers: number; 5 | country_followers: number; 6 | } 7 | 8 | export interface IApiRecommendedChannels { 9 | recommended: IApiChannel[]; 10 | local: IApiChannel[]; 11 | country: IApiChannel[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/interfaces/IApiRefreshToken.ts: -------------------------------------------------------------------------------- 1 | import type { TokenType } from '../enums/TokenType'; 2 | 3 | export interface IApiRefreshToken { 4 | access_token: string; 5 | token_type: TokenType; 6 | expires_in: number; 7 | expiration_date: number; 8 | upgraded: boolean; 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/IApiRegister.ts: -------------------------------------------------------------------------------- 1 | import type { TokenType } from '../enums/TokenType'; 2 | 3 | export interface IApiRegister { 4 | access_token: string; 5 | refresh_token: string; 6 | token_type: TokenType; 7 | expires_in: number; 8 | expiration_date: number; 9 | distinct_id: string; 10 | returning: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /src/interfaces/IApiShare.ts: -------------------------------------------------------------------------------- 1 | export interface IApiShare { 2 | url: string; 3 | share_count: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/interfaces/IApiSticky.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '../enums/Color'; 2 | import type { StickyType } from '../enums/StickyType'; 3 | 4 | export interface IApiStickyButton { 5 | title: string; 6 | } 7 | 8 | export interface IApiSticky { 9 | message: string; 10 | type: StickyType; 11 | stickypost_id: string; 12 | color: Color; 13 | location_name: string; 14 | buttons?: IApiStickyButton[]; 15 | link?: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/interfaces/IApiVote.ts: -------------------------------------------------------------------------------- 1 | import type { IApiPostReplyPost } from './IApiPostDetailsPost'; 2 | import type { IApiPostListPost } from './IApiPostListPost'; 3 | 4 | export interface IApiVote { 5 | post: IApiPostListPost | IApiPostReplyPost; 6 | vote_count: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/IChannel.ts: -------------------------------------------------------------------------------- 1 | export interface IChannel { 2 | channel: string; 3 | image_url?: string; 4 | followers?: number; 5 | country_followers?: number; 6 | unread?: boolean; 7 | sponsored?: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/ILocation.ts: -------------------------------------------------------------------------------- 1 | export interface IGeoCoordinates { 2 | latitude: number; 3 | longitude: number; 4 | } 5 | 6 | export interface ILocation extends IGeoCoordinates { 7 | city: string; 8 | country: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/INotification.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '../enums/Color'; 2 | import type { NotificationScrollType } from '../enums/NotificationScrollType'; 3 | import type { NotificationType } from '../enums/NotificationType'; 4 | 5 | export interface INotification { 6 | post_id: string; 7 | type: NotificationType; 8 | user_id: string; 9 | last_interaction: string; 10 | message: string; 11 | read: boolean; 12 | seen: boolean; 13 | scroll: NotificationScrollType; 14 | replier?: number; 15 | vote_count?: number; 16 | color: Color; 17 | notification_id: string; 18 | thumbnail_url: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/interfaces/IPost.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '../enums/Color'; 2 | import type { PostOwn } from '../enums/PostOwn'; 3 | import type { UserHandle } from '../enums/UserHandle'; 4 | import type { VoteType } from '../enums/VoteType'; 5 | 6 | export interface IApiLocation { 7 | name: string; 8 | } 9 | 10 | export interface IPost { 11 | badge?: string; 12 | channel?: string; 13 | child_count?: number; 14 | children?: string[]; 15 | color: Color; 16 | collapse?: boolean; 17 | created_at: string; 18 | discovered_by: number; 19 | distance: number; 20 | from_home?: boolean; 21 | got_thanks?: boolean; 22 | image_approved?: string; 23 | image_headers: { 24 | Host: string; 25 | 'X-Amz-Date': string; 26 | 'x-amz-content-sha256': string; 27 | Authorization: string; 28 | }; 29 | image_url?: string; 30 | location: IApiLocation; 31 | message: string; 32 | next_reply?: string | null; 33 | notifications_enabled?: boolean; 34 | oj_filtered?: boolean; 35 | oj_replied: boolean; 36 | parent_id?: string; 37 | pin_count?: number; 38 | pinned?: boolean; 39 | post_id: string; 40 | post_own: PostOwn; 41 | promoted: boolean; 42 | replier?: number; 43 | reply_timestamp?: string; 44 | news_url?: string; 45 | news_cta?: string; 46 | share_count?: number; 47 | shareable?: boolean; 48 | thumbnail_url?: string; 49 | updated_at: string; 50 | user_handle: UserHandle; 51 | video_url?: string; 52 | view_count?: number; 53 | vote_count: number; 54 | voted?: VoteType; 55 | votable?: boolean; 56 | poll_id?: string; 57 | poll_options?: [string]; 58 | poll_votes?: [number]; 59 | poll_vote?: number; 60 | } 61 | -------------------------------------------------------------------------------- /src/interfaces/ISettings.ts: -------------------------------------------------------------------------------- 1 | import type { Color } from '../enums/Color'; 2 | 3 | export interface ISettings { 4 | API_SERVER: string; 5 | CLIENT_ID: string; 6 | CLIENT_TYPE: string; 7 | DEFAULT_LOCATION?: { latitude: number; longitude: number }; 8 | GCM_ACCOUNT_HELPER_URL: string; 9 | GCM_RECEIVE_HELPER_URL: string; 10 | EMAIL_REQUEST_HELPER_URL: string; 11 | EMAIL_CONFIRM_HELPER_URL: string; 12 | KEY: string; 13 | POST_COLORS: Color[]; 14 | } 15 | -------------------------------------------------------------------------------- /src/interfaces/IToast.ts: -------------------------------------------------------------------------------- 1 | import type { ToastType } from '../enums/ToastType'; 2 | 3 | export interface IToast { 4 | id: number; 5 | message: string; 6 | type: ToastType; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/IToken.ts: -------------------------------------------------------------------------------- 1 | import type { TokenType } from '../enums/TokenType'; 2 | 3 | export interface IToken { 4 | distinctId: string; 5 | refresh: string; 6 | access: string; 7 | expirationDate: number; 8 | type: TokenType; 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/JodelThunkAction.ts: -------------------------------------------------------------------------------- 1 | import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; 2 | 3 | import type { JodelApi } from '../app/api'; 4 | import type { IJodelAppStore } from '../redux/reducers'; 5 | 6 | import type { IJodelAction } from './IJodelAction'; 7 | 8 | interface IExtraArgument { 9 | api: JodelApi; 10 | } 11 | 12 | export type JodelThunkAction = ThunkAction< 13 | R, 14 | IJodelAppStore, 15 | IExtraArgument, 16 | IJodelAction 17 | >; 18 | export type JodelThunkDispatch = ThunkDispatch; 19 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Unofficial Jodel Web App", 3 | "short_name": "JodelJS", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#fff", 7 | "description": "A web app for using Jodel.", 8 | "theme_color": "#ff9908", 9 | "icons": [ 10 | { 11 | "src": "/favicon.ico", 12 | "sizes": "16x16", 13 | "type": "image/icon" 14 | }, 15 | { 16 | "src": "/favicon.ico", 17 | "sizes": "512x512", 18 | "type": "image/icon" 19 | } 20 | ], 21 | "related_applications": [ 22 | { 23 | "platform": "web" 24 | }, 25 | { 26 | "platform": "play", 27 | "url": "https://play.google.com/store/apps/details?id=com.tellm.android.app" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/redux/actions.ts: -------------------------------------------------------------------------------- 1 | import { PostListSortType } from '../enums/PostListSortType'; 2 | import type { Section } from '../enums/Section'; 3 | import type { TokenType } from '../enums/TokenType'; 4 | import type { JodelThunkAction } from '../interfaces/JodelThunkAction'; 5 | import { randomValueHex } from '../utils/bytes.utils'; 6 | 7 | import { 8 | fetchPostsIfNeeded, 9 | getConfig, 10 | getFollowedChannelsMeta, 11 | getKarma, 12 | getNotifications, 13 | getRecommendedChannels, 14 | getSuggestedHashtags, 15 | setDeviceUid, 16 | setLocation, 17 | setNotificationPostRead, 18 | updatePost, 19 | } from './actions/api'; 20 | import { 21 | invalidatePosts, 22 | _selectPicture, 23 | _selectPost, 24 | _setPermissionDenied, 25 | _setToken, 26 | _showAddPost, 27 | _showChannelList, 28 | _showNotifications, 29 | _showSearch, 30 | _showSettings, 31 | _switchPostListSortType, 32 | _switchPostSection, 33 | } from './actions/state'; 34 | import { locationSelector } from './selectors/app'; 35 | 36 | export * from './actions/state'; 37 | export * from './actions/api'; 38 | 39 | export function switchPostSection(section: Section): JodelThunkAction { 40 | return (dispatch, getState) => { 41 | if (getState().viewState.postSection !== section) { 42 | dispatch(switchPostListSortType(PostListSortType.RECENT)); 43 | } 44 | dispatch(_switchPostSection(section)); 45 | dispatch(_selectPost(null)); 46 | dispatch(invalidatePosts(section)); 47 | dispatch(fetchPostsIfNeeded(section)); 48 | }; 49 | } 50 | 51 | export function switchPostListSortType(sortType: PostListSortType): JodelThunkAction { 52 | return (dispatch, getState) => { 53 | if (getState().viewState.postListSortType !== sortType) { 54 | dispatch(_switchPostListSortType(sortType)); 55 | } 56 | }; 57 | } 58 | 59 | export function updatePosts(): JodelThunkAction { 60 | return (dispatch, getState) => { 61 | const section = getState().viewState.postSection; 62 | dispatch(invalidatePosts(section)); 63 | dispatch(fetchPostsIfNeeded(section)); 64 | }; 65 | } 66 | 67 | export function selectPost(postId: string | null): JodelThunkAction { 68 | return dispatch => { 69 | dispatch(_selectPost(postId)); 70 | if (postId != null) { 71 | dispatch(updatePost(postId, true)); 72 | } 73 | }; 74 | } 75 | 76 | export function selectPostFromNotification(postId: string): JodelThunkAction { 77 | return dispatch => { 78 | dispatch(setNotificationPostRead(postId)); 79 | dispatch(_selectPost(postId)); 80 | if (postId != null) { 81 | dispatch(updatePost(postId, true)); 82 | } 83 | }; 84 | } 85 | 86 | export function selectPicture(postId: string): JodelThunkAction { 87 | return dispatch => { 88 | dispatch(_selectPicture(postId)); 89 | }; 90 | } 91 | 92 | export function updateLocation(): JodelThunkAction { 93 | return (dispatch, getState) => { 94 | if (getState().settings.useBrowserLocation && 'geolocation' in navigator) { 95 | /* geolocation is available */ 96 | navigator.geolocation.getCurrentPosition( 97 | position => { 98 | const state = getState(); 99 | const loc = locationSelector(state); 100 | const latitude = Math.round(position.coords.latitude * 100) / 100; 101 | const longitude = Math.round(position.coords.longitude * 100) / 100; 102 | if (!loc || loc.latitude !== latitude || loc.longitude !== longitude) { 103 | dispatch(setLocation(latitude, longitude)); 104 | if (state.account.token && state.account.token.access !== undefined) { 105 | dispatch(updatePosts()); 106 | } 107 | } 108 | }, 109 | err => { 110 | // TODO do something useful 111 | switch (err.code) { 112 | case err.PERMISSION_DENIED: 113 | break; 114 | case err.POSITION_UNAVAILABLE: 115 | break; 116 | case err.TIMEOUT: 117 | break; 118 | } 119 | }, 120 | ); 121 | } 122 | }; 123 | } 124 | 125 | export function setToken( 126 | distinctId: string, 127 | accessToken: string, 128 | refreshToken: string, 129 | expirationDate: number, 130 | tokenType: TokenType, 131 | ): JodelThunkAction { 132 | return dispatch => { 133 | // TODO clear cached posts 134 | dispatch(_setToken(distinctId, accessToken, refreshToken, expirationDate, tokenType)); 135 | dispatch(getConfig()); 136 | dispatch(updatePosts()); 137 | dispatch(getNotifications()); 138 | dispatch(getKarma()); 139 | }; 140 | } 141 | 142 | export function createNewAccount(firebaseUid?: string, firebaseJWT?: string): JodelThunkAction { 143 | return dispatch => { 144 | const deviceUid = randomValueHex(32); 145 | dispatch(setDeviceUid(deviceUid, firebaseUid, firebaseJWT)); 146 | }; 147 | } 148 | 149 | export function setPermissionDenied(permissionDenied: boolean): JodelThunkAction { 150 | return (dispatch, getState) => { 151 | const account = getState().account; 152 | if (account.deviceUid && permissionDenied && !account.permissionDenied) { 153 | dispatch(_setPermissionDenied(permissionDenied)); 154 | } 155 | }; 156 | } 157 | 158 | export function showAddPost(visible: boolean): JodelThunkAction { 159 | return dispatch => { 160 | dispatch(_showAddPost(visible)); 161 | }; 162 | } 163 | 164 | export function showSettings(visible: boolean): JodelThunkAction { 165 | return dispatch => { 166 | dispatch(_showSettings(visible)); 167 | }; 168 | } 169 | 170 | export function showChannelList(visible: boolean): JodelThunkAction { 171 | return dispatch => { 172 | if (visible) { 173 | dispatch(getRecommendedChannels()); 174 | dispatch(getFollowedChannelsMeta()); 175 | } 176 | dispatch(_showChannelList(visible)); 177 | }; 178 | } 179 | 180 | export function showNotifications(visible: boolean): JodelThunkAction { 181 | return dispatch => { 182 | dispatch(_showNotifications(visible)); 183 | }; 184 | } 185 | 186 | export function showSearch(visible: boolean): JodelThunkAction { 187 | return dispatch => { 188 | dispatch(getSuggestedHashtags()); 189 | dispatch(_showSearch(visible)); 190 | }; 191 | } 192 | -------------------------------------------------------------------------------- /src/redux/actions/action.consts.ts: -------------------------------------------------------------------------------- 1 | export const CLOSE_STICKY = 'CLOSE_STICKY'; 2 | export const HIDE_TOAST = 'HIDE_TOAST'; 3 | export const INVALIDATE_POSTS = 'INVALIDATE_POSTS'; 4 | export const PINNED_POST = 'PINNED_POST'; 5 | export const RECEIVE_NOTIFICATIONS = 'RECEIVE_NOTIFICATIONS'; 6 | export const RECEIVE_POST = 'RECEIVE_POST'; 7 | export const RECEIVE_POSTS = 'RECEIVE_POSTS'; 8 | export const REPLACE_VIEW_STATE = 'REPLACE_VIEW_STATE'; 9 | export const SELECT_PICTURE = 'SELECT_PICTURE'; 10 | export const SELECT_POST = 'SELECT_POST'; 11 | export const SET_CHANNELS_META = 'SET_CHANNELS_META'; 12 | export const SET_CONFIG = 'SET_CONFIG'; 13 | export const SET_COUNTRY_CHANNELS = 'SET_COUNTRY_CHANNELS'; 14 | export const SET_DEVICE_UID = 'SET_DEVICE_UID'; 15 | export const SET_IS_FETCHING = 'SET_IS_FETCHING'; 16 | export const SET_KARMA = 'SET_KARMA'; 17 | export const SET_LOCAL_CHANNELS = 'SET_LOCAL_CHANNELS'; 18 | export const SET_LOCATION = 'SET_LOCATION'; 19 | export const SET_NOTIFICATION_POST_READ = 'SET_NOTIFICATION_POST_READ'; 20 | export const SET_PERMISSION_DENIED = 'SET_PERMISSION_DENIED'; 21 | export const SET_RECOMMENDED_CHANNELS = 'SET_RECOMMENDED_CHANNELS'; 22 | export const SET_SUGGESTED_HASHTAGS = 'SET_SUGGESTED_HASHTAGS'; 23 | export const SET_TOKEN = 'SET_TOKEN'; 24 | export const SET_TOKEN_PENDING = 'SET_TOKEN_PENDING'; 25 | export const SET_USE_BROWSER_LOCATION = 'SET_USE_BROWSER_LOCATION'; 26 | export const SET_USE_HOME_LOCATION = 'SET_USE_HOME_LOCATION'; 27 | export const SET_USER_TYPE_RESPONSE = 'SET_USER_TYPE_RESPONSE'; 28 | export const SHARE_LINK = 'SHARE_LINK'; 29 | export const SHARE_LINK_CLOSE = 'SHARE_LINK_CLOSE'; 30 | export const SHOW_ADD_POST = 'SHOW_ADD_POST'; 31 | export const SHOW_CHANNEL_LIST = 'SHOW_CHANNEL_LIST'; 32 | export const SHOW_NOTIFICATIONS = 'SHOW_NOTIFICATIONS'; 33 | export const SHOW_SEARCH = 'SHOW_SEARCH'; 34 | export const SHOW_SETTINGS = 'SHOW_SETTINGS'; 35 | export const SHOW_TOAST = 'SHOW_TOAST'; 36 | export const SWITCH_POST_LIST_SORT_TYPE = 'SWITCH_POST_LIST_CONTAINER_STATE'; 37 | export const SWITCH_POST_SECTION = 'SWITCH_POST_SECTION'; 38 | export const VOTED_POST = 'VOTED_POST'; 39 | export const VOTED_POLL = 'VOTED_POLL'; 40 | -------------------------------------------------------------------------------- /src/redux/actions/toasts.actions.ts: -------------------------------------------------------------------------------- 1 | import type { ToastType } from '../../enums/ToastType'; 2 | import type { IJodelAction } from '../../interfaces/IJodelAction'; 3 | 4 | import { HIDE_TOAST, SHOW_TOAST } from './action.consts'; 5 | 6 | let nextToastId = 0; 7 | 8 | export function showToast(message: string, type: ToastType): IJodelAction { 9 | return { 10 | payload: { 11 | toast: { 12 | id: nextToastId++, 13 | message, 14 | type, 15 | }, 16 | }, 17 | type: SHOW_TOAST, 18 | }; 19 | } 20 | 21 | export function hideToast(toastId: number): IJodelAction { 22 | return { 23 | payload: { toastId }, 24 | type: HIDE_TOAST, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/redux/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import type { IJodelAction } from '../interfaces/IJodelAction'; 4 | import type { IToast } from '../interfaces/IToast'; 5 | 6 | import type { IAccountStore } from './reducers/account'; 7 | import { account } from './reducers/account'; 8 | import type { IEntitiesStore } from './reducers/entities'; 9 | import { entities } from './reducers/entities'; 10 | import type { IPostsBySectionStore } from './reducers/postsBySection'; 11 | import { postsBySection } from './reducers/postsBySection'; 12 | import type { ISettingsStore } from './reducers/settings'; 13 | import { settings } from './reducers/settings'; 14 | import { toasts } from './reducers/toasts'; 15 | import type { IViewStateStore } from './reducers/viewState'; 16 | import { viewState } from './reducers/viewState'; 17 | 18 | export type IJodelAppStore = Readonly; 19 | 20 | interface IJodelAppStoreMutable { 21 | entities: IEntitiesStore; 22 | postsBySection: IPostsBySectionStore; 23 | viewState: IViewStateStore; 24 | account: IAccountStore; 25 | settings: ISettingsStore; 26 | toasts: readonly IToast[]; 27 | } 28 | 29 | export const JodelApp = combineReducers({ 30 | account, 31 | entities, 32 | postsBySection, 33 | settings, 34 | toasts, 35 | viewState, 36 | }); 37 | -------------------------------------------------------------------------------- /src/redux/reducers/account.tsx: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import type { IApiConfig } from '../../interfaces/IApiConfig'; 4 | import type { IJodelAction } from '../../interfaces/IJodelAction'; 5 | import type { IToken } from '../../interfaces/IToken'; 6 | import { 7 | SET_CONFIG, 8 | SET_COUNTRY_CHANNELS, 9 | SET_DEVICE_UID, 10 | SET_KARMA, 11 | SET_LOCAL_CHANNELS, 12 | SET_PERMISSION_DENIED, 13 | SET_RECOMMENDED_CHANNELS, 14 | SET_SUGGESTED_HASHTAGS, 15 | SET_TOKEN, 16 | SET_TOKEN_PENDING, 17 | SET_USER_TYPE_RESPONSE, 18 | } from '../actions/action.consts'; 19 | 20 | export const ACCOUNT_VERSION = 3; 21 | 22 | export function migrateAccount(storedState: IAccountStore, oldVersion: number): IAccountStore { 23 | const newState: Partial = {}; 24 | if (oldVersion < 2) { 25 | newState.recommendedChannels = []; 26 | } 27 | if (oldVersion < 3) { 28 | newState.localChannels = []; 29 | } 30 | newState.refreshingToken = false; 31 | return { ...storedState, ...newState }; 32 | } 33 | 34 | export type IAccountStore = Readonly; 35 | 36 | interface IAccountStoreMutable { 37 | karma: number; 38 | deviceUid: string | null; 39 | token: IToken | null; 40 | refreshingToken: boolean; 41 | config: IApiConfig | null; 42 | permissionDenied: boolean; 43 | recommendedChannels: readonly string[]; 44 | localChannels: readonly string[]; 45 | countryChannels: readonly string[]; 46 | suggestedHashtags: readonly string[]; 47 | } 48 | 49 | export const account = combineReducers({ 50 | config, 51 | countryChannels, 52 | deviceUid, 53 | karma, 54 | localChannels, 55 | permissionDenied, 56 | recommendedChannels, 57 | refreshingToken, 58 | suggestedHashtags, 59 | token, 60 | }); 61 | 62 | function karma(state = 0, action: IJodelAction): typeof state { 63 | switch (action.type) { 64 | case SET_KARMA: 65 | return action.payload.karma; 66 | default: 67 | return state; 68 | } 69 | } 70 | 71 | function deviceUid(state: string | null = null, action: IJodelAction): typeof state { 72 | switch (action.type) { 73 | case SET_DEVICE_UID: 74 | return action.payload.deviceUid; 75 | default: 76 | return state; 77 | } 78 | } 79 | 80 | function token(state: IToken | null = null, action: IJodelAction): typeof state { 81 | switch (action.type) { 82 | case SET_TOKEN: 83 | return action.payload.token; 84 | default: 85 | return state; 86 | } 87 | } 88 | 89 | function refreshingToken(state = false, action: IJodelAction): typeof state { 90 | switch (action.type) { 91 | case SET_TOKEN_PENDING: 92 | return true; 93 | case SET_TOKEN: 94 | return false; 95 | default: 96 | return state; 97 | } 98 | } 99 | 100 | function config(state: IApiConfig | null = null, action: IJodelAction): typeof state { 101 | switch (action.type) { 102 | case SET_CONFIG: 103 | return action.payload.config; 104 | case SET_USER_TYPE_RESPONSE: 105 | if (!state) { 106 | return state; 107 | } 108 | 109 | return { 110 | ...state, 111 | can_change_type: false, 112 | user_type: action.payload.userType, 113 | }; 114 | default: 115 | return state; 116 | } 117 | } 118 | 119 | function permissionDenied(state = false, action: IJodelAction): typeof state { 120 | switch (action.type) { 121 | case SET_TOKEN: 122 | return false; 123 | case SET_PERMISSION_DENIED: 124 | return action.payload.permissionDenied; 125 | default: 126 | return state; 127 | } 128 | } 129 | 130 | function recommendedChannels(state: readonly string[] = [], action: IJodelAction): typeof state { 131 | switch (action.type) { 132 | case SET_RECOMMENDED_CHANNELS: 133 | return action.payload.entitiesChannels.map(c => c.channel); 134 | default: 135 | return state; 136 | } 137 | } 138 | 139 | function localChannels(state: readonly string[] = [], action: IJodelAction): typeof state { 140 | switch (action.type) { 141 | case SET_LOCAL_CHANNELS: 142 | return action.payload.entitiesChannels.map(c => c.channel); 143 | default: 144 | return state; 145 | } 146 | } 147 | 148 | function countryChannels(state: readonly string[] = [], action: IJodelAction): typeof state { 149 | switch (action.type) { 150 | case SET_COUNTRY_CHANNELS: 151 | return action.payload.entitiesChannels.map(c => c.channel); 152 | default: 153 | return state; 154 | } 155 | } 156 | 157 | function suggestedHashtags(state: readonly string[] = [], action: IJodelAction): typeof state { 158 | switch (action.type) { 159 | case SET_SUGGESTED_HASHTAGS: 160 | return action.payload.suggestedHashtags; 161 | default: 162 | return state; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/redux/reducers/postsBySection.tsx: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import type { IJodelAction } from '../../interfaces/IJodelAction'; 4 | import { 5 | INVALIDATE_POSTS, 6 | RECEIVE_POST, 7 | RECEIVE_POSTS, 8 | SET_IS_FETCHING, 9 | } from '../actions/action.consts'; 10 | 11 | function uniq(a: string[]): string[] { 12 | const seen: { [key: string]: boolean } = {}; 13 | return a.filter(item => (Object.hasOwnProperty.call(seen, item) ? false : (seen[item] = true))); 14 | } 15 | 16 | export interface IPostSection { 17 | readonly isFetching: boolean; 18 | readonly didInvalidate: boolean; 19 | readonly lastUpdated: number | null; 20 | readonly postsBySortType: IPostsBySortType; 21 | } 22 | 23 | export interface IPostsBySortType { 24 | readonly [key: string]: string[]; 25 | } 26 | 27 | export interface IPostsBySectionStore { 28 | readonly [key: string]: IPostSection; 29 | } 30 | 31 | export function postsBySection( 32 | state: IPostsBySectionStore = {}, 33 | action: IJodelAction, 34 | ): typeof state { 35 | switch (action.type) { 36 | case RECEIVE_POSTS: 37 | case INVALIDATE_POSTS: 38 | case SET_IS_FETCHING: 39 | return { 40 | ...state, 41 | [action.payload.section]: posts(state[action.payload.section], action), 42 | }; 43 | default: 44 | return state; 45 | } 46 | } 47 | 48 | const posts = combineReducers({ 49 | didInvalidate, 50 | isFetching, 51 | lastUpdated, 52 | postsBySortType, 53 | }); 54 | 55 | function isFetching(state = false, action: IJodelAction): typeof state { 56 | switch (action.type) { 57 | case RECEIVE_POST: 58 | case RECEIVE_POSTS: 59 | return false; 60 | case SET_IS_FETCHING: 61 | return action.payload.isFetching; 62 | default: 63 | return state; 64 | } 65 | } 66 | 67 | function didInvalidate(state = false, action: IJodelAction): typeof state { 68 | switch (action.type) { 69 | case RECEIVE_POSTS: 70 | if (action.payload.append) { 71 | return state; 72 | } 73 | return false; 74 | case INVALIDATE_POSTS: 75 | return true; 76 | default: 77 | return state; 78 | } 79 | } 80 | 81 | function lastUpdated(state: number | null = null, action: IJodelAction): typeof state { 82 | switch (action.type) { 83 | case RECEIVE_POSTS: 84 | return !action.payload.append && action.receivedAt ? action.receivedAt : state; 85 | default: 86 | return state; 87 | } 88 | } 89 | 90 | function postsBySortType(state: IPostsBySortType = {}, action: IJodelAction): typeof state { 91 | switch (action.type) { 92 | case RECEIVE_POSTS: { 93 | const newState: { [key: string]: string[] } = {}; 94 | if (action.payload.append) { 95 | action.payload.postsBySortType.forEach( 96 | p => (newState[p.sortType] = uniq([...state[p.sortType], ...p.posts])), 97 | ); 98 | } else { 99 | action.payload.postsBySortType.forEach(p => (newState[p.sortType] = p.posts)); 100 | } 101 | return { 102 | ...state, 103 | ...newState, 104 | }; 105 | } 106 | default: 107 | return state; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/redux/reducers/settings.tsx: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import type { IJodelAction } from '../../interfaces/IJodelAction'; 4 | import type { ILocation } from '../../interfaces/ILocation'; 5 | import { 6 | RECEIVE_POSTS, 7 | SET_LOCATION, 8 | SET_USE_BROWSER_LOCATION, 9 | SET_USE_HOME_LOCATION, 10 | } from '../actions/action.consts'; 11 | 12 | export const SETTINGS_VERSION = 1; 13 | 14 | export function migrateSettings(storedState: ISettingsStore, _oldVersion: number): ISettingsStore { 15 | if (storedState.location) { 16 | if (!storedState.location.latitude || !storedState.location.longitude) { 17 | return { 18 | ...storedState, 19 | location: null, 20 | }; 21 | } 22 | } 23 | return storedState; 24 | } 25 | 26 | export interface ISettingsStore { 27 | readonly location: ILocation | null; 28 | readonly useBrowserLocation: boolean; 29 | readonly useHomeLocation: boolean; 30 | readonly channelsLastRead: { readonly [key: string]: number }; 31 | } 32 | 33 | export const settings = combineReducers({ 34 | channelsLastRead, 35 | location, 36 | useBrowserLocation, 37 | useHomeLocation, 38 | }); 39 | 40 | function location(state: Readonly | null = null, action: IJodelAction): typeof state { 41 | switch (action.type) { 42 | case SET_LOCATION: 43 | return { ...state, ...action.payload.location }; 44 | default: 45 | return state; 46 | } 47 | } 48 | 49 | function useBrowserLocation(state = true, action: IJodelAction): typeof state { 50 | switch (action.type) { 51 | case SET_USE_BROWSER_LOCATION: 52 | return action.payload.useBrowserLocation; 53 | default: 54 | return state; 55 | } 56 | } 57 | 58 | function useHomeLocation(state = false, action: IJodelAction): typeof state { 59 | switch (action.type) { 60 | case SET_USE_HOME_LOCATION: 61 | return action.payload.useHomeLocation; 62 | default: 63 | return state; 64 | } 65 | } 66 | 67 | function channelsLastRead( 68 | state: { readonly [key: string]: number } = {}, 69 | action: IJodelAction, 70 | ): typeof state { 71 | switch (action.type) { 72 | case RECEIVE_POSTS: { 73 | if (action.payload.append || !action.payload.section.startsWith('channel:')) { 74 | return state; 75 | } 76 | const channel = action.payload.section.substring(8); 77 | return { 78 | ...state, 79 | [channel]: action.receivedAt, 80 | }; 81 | } 82 | default: 83 | return state; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/redux/reducers/toasts.ts: -------------------------------------------------------------------------------- 1 | import type { IJodelAction } from '../../interfaces/IJodelAction'; 2 | import type { IToast } from '../../interfaces/IToast'; 3 | import { HIDE_TOAST, SHOW_TOAST } from '../actions/action.consts'; 4 | 5 | export function toasts(state: readonly IToast[] = [], action: IJodelAction): typeof state { 6 | switch (action.type) { 7 | case SHOW_TOAST: 8 | return [...state, action.payload.toast]; 9 | case HIDE_TOAST: { 10 | const toastId = action.payload.toastId; 11 | return state.filter(toast => toast.id !== toastId); 12 | } 13 | default: 14 | return state; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/redux/selectors/app.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | import type { IJodelAppStore } from '../reducers'; 4 | 5 | import { selectedChannelNameSelector } from './channels'; 6 | import { selectedPostIdSelector } from './posts'; 7 | 8 | export const deviceUidSelector = (state: IJodelAppStore) => state.account.deviceUid; 9 | 10 | export const isConfigAvailableSelector = (state: IJodelAppStore) => !!state.account.config; 11 | 12 | export const isRegisteredSelector = (state: IJodelAppStore) => !!state.account.token; 13 | 14 | export const karmaSelector = (state: IJodelAppStore) => state.account.karma; 15 | 16 | export const userAgeSelector = (state: IJodelAppStore) => 17 | !state.account.config ? null : state.account.config.age; 18 | 19 | export const userTypeSelector = (state: IJodelAppStore) => 20 | !state.account.config ? null : state.account.config.user_type; 21 | 22 | export const canChangeUserTypeSelector = (state: IJodelAppStore) => 23 | !state.account.config ? false : state.account.config.can_change_type; 24 | 25 | export const specialPostColorsSelector = (state: IJodelAppStore) => 26 | !state.account.config ? [] : state.account.config.special_post_colors; 27 | 28 | export const addPostChannelSelector = createSelector( 29 | selectedPostIdSelector, 30 | selectedChannelNameSelector, 31 | (selectedPostId, selectedChannelName) => (selectedPostId ? undefined : selectedChannelName), 32 | ); 33 | 34 | export const locationSelector = (store: IJodelAppStore) => store.settings.location; 35 | 36 | export const isLocationKnownSelector = (store: IJodelAppStore) => !!locationSelector(store); 37 | 38 | export const toastsSelector = (state: IJodelAppStore) => state.toasts; 39 | 40 | export const accessTokenSelector = (state: IJodelAppStore) => { 41 | if (!state.account.token) { 42 | return undefined; 43 | } 44 | return state.account.token.access; 45 | }; 46 | 47 | export const isRefreshingTokenSelector = (state: IJodelAppStore) => state.account.refreshingToken; 48 | -------------------------------------------------------------------------------- /src/redux/selectors/channels.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | import type { IChannel } from '../../interfaces/IChannel'; 4 | import type { IJodelAppStore } from '../reducers'; 5 | 6 | import { selectedSectionSelector } from './view'; 7 | 8 | /* Begin Helpers **/ 9 | 10 | function getChannel(channels: { [key: string]: IChannel }, channel: string): IChannel { 11 | const c = channels[channel]; 12 | if (!c) { 13 | return { channel }; 14 | } 15 | return c; 16 | } 17 | 18 | /* End Helpers */ 19 | 20 | export const selectedChannelNameSelector = createSelector( 21 | selectedSectionSelector, 22 | (selectedSection): string | undefined => 23 | !selectedSection || !selectedSection.startsWith('channel:') 24 | ? undefined 25 | : selectedSection.substring(8), 26 | ); 27 | 28 | const followedChannelNamesSelector = (state: IJodelAppStore) => 29 | state.account.config?.followed_channels ?? []; 30 | 31 | export const isSelectedChannelFollowedSelector = createSelector( 32 | selectedChannelNameSelector, 33 | followedChannelNamesSelector, 34 | (selectedChannelName, followedChannelNames) => { 35 | if (!selectedChannelName) { 36 | return false; 37 | } 38 | const s = selectedChannelName.toLowerCase(); 39 | return !!followedChannelNames.find(c => c.toLowerCase() === s); 40 | }, 41 | ); 42 | export const selectedChannelNameLikeFollowedSelector = createSelector( 43 | selectedChannelNameSelector, 44 | followedChannelNamesSelector, 45 | (selectedChannelName, followedChannelNames): string | undefined => { 46 | if (!selectedChannelName) { 47 | return undefined; 48 | } 49 | const s = selectedChannelName.toLowerCase(); 50 | return followedChannelNames.find(c => c.toLowerCase() === s) || selectedChannelName; 51 | }, 52 | ); 53 | 54 | const recommendedChannelNamesSelector = (state: IJodelAppStore) => 55 | state.account.recommendedChannels; 56 | const localChannelNamesSelector = (state: IJodelAppStore) => state.account.localChannels; 57 | const countryChannelNamesSelector = (state: IJodelAppStore) => state.account.countryChannels; 58 | 59 | const channelsSelector = (state: IJodelAppStore) => state.entities.channels; 60 | 61 | export const followedChannelsSelector = createSelector( 62 | followedChannelNamesSelector, 63 | channelsSelector, 64 | (followedChannelNames, channels): IChannel[] => 65 | followedChannelNames.map(channel => getChannel(channels, channel)), 66 | ); 67 | 68 | export const recommendedChannelsSelector = createSelector( 69 | recommendedChannelNamesSelector, 70 | followedChannelNamesSelector, 71 | channelsSelector, 72 | (recommendedChannelNames, followedChannelNames, channels): IChannel[] => 73 | recommendedChannelNames 74 | .filter( 75 | channel => 76 | !followedChannelNames.find(c => c.toLowerCase() === channel.toLowerCase()), 77 | ) 78 | .map(channel => getChannel(channels, channel)), 79 | ); 80 | 81 | export const localChannelsSelector = createSelector( 82 | localChannelNamesSelector, 83 | followedChannelNamesSelector, 84 | channelsSelector, 85 | (localChannelNames, followedChannelNames, channels): IChannel[] => 86 | localChannelNames 87 | .filter( 88 | channel => 89 | !followedChannelNames.find(c => c.toLowerCase() === channel.toLowerCase()), 90 | ) 91 | .map(channel => getChannel(channels, channel)), 92 | ); 93 | 94 | export const countryChannelsSelector = createSelector( 95 | countryChannelNamesSelector, 96 | followedChannelNamesSelector, 97 | channelsSelector, 98 | (countryChannelNames, followedChannelNames, channels): IChannel[] => 99 | countryChannelNames 100 | .filter( 101 | channel => 102 | !followedChannelNames.find(c => c.toLowerCase() === channel.toLowerCase()), 103 | ) 104 | .map(channel => getChannel(channels, channel)), 105 | ); 106 | 107 | export const selectedChannelFollowersCountSelector = createSelector( 108 | selectedChannelNameSelector, 109 | channelsSelector, 110 | (selectedChannelName, channels): number => { 111 | if (!selectedChannelName) { 112 | return 0; 113 | } 114 | const followers = getChannel(channels, selectedChannelName).followers; 115 | return !followers ? 0 : followers; 116 | }, 117 | ); 118 | -------------------------------------------------------------------------------- /src/redux/selectors/notifications.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | import type { IJodelAppStore } from '../reducers'; 4 | 5 | export const notificationsSelector = (state: IJodelAppStore) => state.entities.notifications; 6 | 7 | export const unreadNotificationsCountSelector = createSelector( 8 | notificationsSelector, 9 | (notifications): number => notifications.filter(n => !n.read).length, 10 | ); 11 | -------------------------------------------------------------------------------- /src/redux/selectors/posts.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | import type { IPost } from '../../interfaces/IPost'; 4 | import type { IJodelAppStore } from '../reducers'; 5 | 6 | import { selectedSectionSelector, selectedSortTypeSelector } from './view'; 7 | 8 | /* Begin Helpers **/ 9 | 10 | function getPost(posts: { [key: string]: IPost }, postId: string): IPost | null { 11 | return posts[postId] || null; 12 | } 13 | 14 | /* End Helpers */ 15 | 16 | export const selectedPostIdSelector = (state: IJodelAppStore) => state.viewState.selectedPostId; 17 | const selectedPicturePostIdSelector = (state: IJodelAppStore) => 18 | state.viewState.selectedPicturePostId; 19 | 20 | export const stickiesSelector = (state: IJodelAppStore) => state.entities.stickies; 21 | 22 | const postsSelector = (state: IJodelAppStore) => state.entities.posts; 23 | 24 | export const selectedPostSelector = createSelector( 25 | selectedPostIdSelector, 26 | postsSelector, 27 | (selectedPostId, posts): IPost | null => 28 | !selectedPostId ? null : getPost(posts, selectedPostId), 29 | ); 30 | 31 | export const selectedPostChildrenSelector = createSelector( 32 | selectedPostSelector, 33 | postsSelector, 34 | (selectedPost, posts): IPost[] | null => 35 | !selectedPost 36 | ? null 37 | : !selectedPost.children 38 | ? [] 39 | : selectedPost.children 40 | .map(childId => getPost(posts, childId)) 41 | .filter((child): child is IPost => !!child), 42 | ); 43 | 44 | export const selectedPicturePostSelector = createSelector( 45 | selectedPicturePostIdSelector, 46 | postsSelector, 47 | (selectedPicturePostId, posts): IPost | null => 48 | !selectedPicturePostId ? null : getPost(posts, selectedPicturePostId), 49 | ); 50 | 51 | const postsBySectionSelector = (state: IJodelAppStore) => state.postsBySection; 52 | 53 | const selectedSectionPostsSelector = createSelector( 54 | selectedSectionSelector, 55 | postsBySectionSelector, 56 | (selectedSection, postsBySection) => postsBySection[selectedSection], 57 | ); 58 | 59 | export const selectedSectionLastUpdatedSelector = createSelector( 60 | selectedSectionPostsSelector, 61 | selectedSectionPosts => (!selectedSectionPosts ? undefined : selectedSectionPosts.lastUpdated), 62 | ); 63 | 64 | export const isSelectedSectionFetchingSelector = createSelector( 65 | selectedSectionPostsSelector, 66 | selectedSectionPosts => (!selectedSectionPosts ? false : selectedSectionPosts.isFetching), 67 | ); 68 | 69 | const selectedSectionSortPostIdsSelector = createSelector( 70 | selectedSectionPostsSelector, 71 | selectedSortTypeSelector, 72 | (selectedSectionPosts, selectedSortType) => 73 | !selectedSectionPosts ? [] : selectedSectionPosts.postsBySortType[selectedSortType] || [], 74 | ); 75 | 76 | export const selectedSectionSortPostsSelector = createSelector( 77 | selectedSectionSortPostIdsSelector, 78 | postsSelector, 79 | (selectedSectionSortPostIds, posts): IPost[] => 80 | selectedSectionSortPostIds 81 | .map(postId => getPost(posts, postId)) 82 | .filter((post): post is IPost => !!post && post.promoted === false), 83 | ); 84 | -------------------------------------------------------------------------------- /src/redux/selectors/view.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | import type { IJodelAppStore } from '../reducers'; 4 | 5 | export const selectedSortTypeSelector = (state: IJodelAppStore) => state.viewState.postListSortType; 6 | 7 | export const selectedSectionSelector = (state: IJodelAppStore) => state.viewState.postSection; 8 | 9 | export const shareLinkSelector = (state: IJodelAppStore) => state.viewState.shareLink; 10 | 11 | export const selectedHashtagNameSelector = createSelector( 12 | selectedSectionSelector, 13 | selectedSection => 14 | !selectedSection || !selectedSection.startsWith('hashtag:') 15 | ? undefined 16 | : selectedSection.substring(8), 17 | ); 18 | 19 | export const channelListVisibleSelector = (state: IJodelAppStore) => 20 | state.viewState.channelList.visible; 21 | 22 | export const settingsVisibleSelector = (state: IJodelAppStore) => state.viewState.settings.visible; 23 | 24 | export const notificationsVisibleSelector = (state: IJodelAppStore) => 25 | state.viewState.notifications.visible; 26 | 27 | export const searchVisibleSelector = (state: IJodelAppStore) => state.viewState.search.visible; 28 | 29 | export const addPostVisibleSelector = (state: IJodelAppStore) => state.viewState.addPost.visible; 30 | -------------------------------------------------------------------------------- /src/sw.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | declare var self: ServiceWorkerGlobalScope; 4 | import {} from '.'; 5 | 6 | type ManifestEntry = { 7 | url: string; 8 | revision: string; 9 | }; 10 | 11 | const INJECTED_MANIFEST: ManifestEntry[] = (self as any).__WB_MANIFEST; 12 | const CACHE_NAME = 'jodel-cache-v1'; 13 | const META_ENTRY = '__meta-data__'; 14 | const NAVIGATE_TARGET = '.'; 15 | 16 | const MANIFEST = INJECTED_MANIFEST.map(entry => ({ 17 | ...entry, 18 | url: new URL(entry.url, location.toString()).toString(), 19 | })); 20 | 21 | const metadataMap = manifestToMap(MANIFEST); 22 | 23 | self.addEventListener('install', event => { 24 | self.skipWaiting(); 25 | event.waitUntil( 26 | precache().catch(e => console.error('SW precache failed: ' + JSON.stringify(e))), 27 | ); 28 | }); 29 | 30 | self.addEventListener('activate', event => { 31 | event.waitUntil( 32 | caches 33 | .keys() 34 | .then(cacheNames => 35 | Promise.all( 36 | cacheNames 37 | .filter(cacheName => cacheName !== CACHE_NAME) 38 | .map(cacheName => caches.delete(cacheName)), 39 | ), 40 | ) 41 | .then(() => self.clients.claim()), 42 | ); 43 | }); 44 | 45 | self.addEventListener('fetch', event => { 46 | if (event.request.method !== 'GET') { 47 | return; 48 | } 49 | 50 | if (metadataMap.has(event.request.url)) { 51 | event.respondWith(fromCacheFallbackNetwork(event.request)); 52 | } 53 | 54 | if (event.request.mode === 'navigate') { 55 | event.respondWith(fromCacheFallbackNetwork(NAVIGATE_TARGET)); 56 | } 57 | }); 58 | 59 | async function precache(): Promise { 60 | const cache = await caches.open(CACHE_NAME); 61 | 62 | let oldMetadata; 63 | try { 64 | oldMetadata = manifestToMap(await getCurrentMetadata()); 65 | } catch (e) { 66 | console.warn('[SW] Invalid cache metadata', e); 67 | oldMetadata = new Map(); 68 | } 69 | 70 | const waitFor = []; 71 | // Add navigate target 72 | waitFor.push(cache.add(NAVIGATE_TARGET)); 73 | 74 | // Add new assets 75 | for (const entry of MANIFEST) { 76 | if (entry.revision === oldMetadata.get(entry.url)) { 77 | continue; 78 | } 79 | waitFor.push(cache.add(entry.url)); 80 | } 81 | 82 | // Cleanup old assets 83 | for (const oldEntryUrl of oldMetadata.keys()) { 84 | if (metadataMap.has(oldEntryUrl)) { 85 | continue; 86 | } 87 | 88 | waitFor.push( 89 | cache.delete(oldEntryUrl).then(() => { 90 | /*ignore*/ 91 | }), 92 | ); 93 | } 94 | 95 | await Promise.all(waitFor); 96 | await storeCurrentMetadata(MANIFEST); 97 | } 98 | 99 | async function fromCacheFallbackNetwork(request: RequestInfo): Promise { 100 | const cache = await caches.open(CACHE_NAME); 101 | const response = await cache.match(request); 102 | 103 | return response || fetch(request); 104 | } 105 | 106 | async function getCurrentMetadata(): Promise { 107 | const cache = await caches.open(CACHE_NAME); 108 | const response = await cache.match(META_ENTRY); 109 | 110 | if (!response) { 111 | return []; 112 | } 113 | 114 | return await response.json(); 115 | } 116 | 117 | async function storeCurrentMetadata(manifest: ManifestEntry[]): Promise { 118 | const cache = await caches.open(CACHE_NAME); 119 | await cache.put(META_ENTRY, new Response(JSON.stringify(manifest))); 120 | } 121 | 122 | function manifestToMap(manifest: ManifestEntry[]): Map { 123 | const map = new Map(); 124 | for (const entry of manifest) { 125 | map.set(entry.url, entry.revision); 126 | } 127 | return map; 128 | } 129 | -------------------------------------------------------------------------------- /src/translations/de.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | account: 'Konto', 3 | channels: 'Kanäle', 4 | device_uid_existing: 'Device UID des bestehenden Kontos', 5 | device_uid_invalid: 'Die Device UID sollte aus genau 64 hexadezimal Ziffern bestehen.', 6 | device_uid_new: 'Neues Jodel Konto erstellen', 7 | device_uid_use_existing: 'Bestehendes Jodel Konto nutzen', 8 | jodel_register: 'Jodeln beginnen', 9 | location: 'Standort', 10 | location_current: 'Aktueller Standort', 11 | location_error: 12 | 'Zum erstmaligen Anmelden muss der aktuelle Standort bekannt sein.\n' + 13 | 'Die Standort Abfrage war jedoch noch nicht erfolgreich.', 14 | location_error_retry: 'Erneut versuchen', 15 | location_latitude: 'Breitengrad', 16 | location_longitude: 'Längengrad', 17 | location_manual: 'Standort manuell setzen', 18 | location_refresh: 'Standort aktualisieren', 19 | location_use_browser: 'Standort vom Browser abfragen', 20 | my_answers: 'Meine Antworten', 21 | my_karma: 'Mein Karma', 22 | my_pins: 'Meine Pins', 23 | my_posts: 'Meine Jodel', 24 | my_votes: 'Meine Votes', 25 | near_posts: 'In der Nähe', 26 | notifications: 'Benachrichtigungen', 27 | search: 'Suche', 28 | settings: 'Einstellungen', 29 | user_type: 'Benutzertyp', 30 | 'user_type.apprentice': 'Azubi', 31 | 'user_type.employee': 'Angestellter', 32 | 'user_type.high_school': 'Schüler', 33 | 'user_type.high_school_graduate': 'Abiturient', 34 | 'user_type.other': 'Sonstiges', 35 | 'user_type.student': 'Student', 36 | welcome_title: 'Willkommen bei der inoffiziellen Jodel Web App', 37 | }; 38 | -------------------------------------------------------------------------------- /src/utils/bytes.utils.ts: -------------------------------------------------------------------------------- 1 | function getRandomInt(min: number, max: number): number { 2 | min = Math.ceil(min); 3 | max = Math.floor(max); 4 | return Math.floor(Math.random() * (max - min)) + min; 5 | } 6 | 7 | export function randomValueHex(byteCount: number): string { 8 | let rawBytes = new Uint8Array(byteCount); 9 | try { 10 | rawBytes = crypto.getRandomValues(rawBytes); 11 | } catch (e) { 12 | // Old browser, insecure but works 13 | for (let i = 0; i < byteCount; ++i) { 14 | rawBytes[i] = getRandomInt(0, 256); 15 | } 16 | } 17 | return toHex(rawBytes); 18 | } 19 | 20 | export function toHex(a: ArrayBuffer): string { 21 | const byteArray = Array.from(new Uint8Array(a)); 22 | return byteArray.map(b => b.toString(16).padStart(2, '0')).join(''); 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/notification.utils.ts: -------------------------------------------------------------------------------- 1 | import { NotificationType } from '../enums/NotificationType'; 2 | import type { INotification } from '../interfaces/INotification'; 3 | 4 | export function getNotificationDescription(notification: INotification): string { 5 | switch (notification.type) { 6 | case NotificationType.OJ_REPLY_REPLY: 7 | return 'OJ hat geantwortet'; 8 | case NotificationType.OJ_REPLY_MENTION: 9 | return 'OJ hat dich erwähnt'; 10 | case NotificationType.OJ_THANKS: 11 | return 'OJ bedankt sich'; 12 | case NotificationType.OJ_PIN_REPLY: 13 | return 'OJ Antwort auf deinen Pin'; 14 | case NotificationType.REPLY: 15 | return 'Antwort auf deinen Jodel'; 16 | case NotificationType.REPLY_MENTION: 17 | return 'Jemand hat dich erwähnt'; 18 | case NotificationType.REPLY_REPLY: 19 | return 'Antwort auf deine Antwort'; 20 | case NotificationType.VOTE_REPLY: 21 | return `Deine Antwort hat ${notification.vote_count ?? '-'} Votes`; 22 | case NotificationType.VOTE_POST: 23 | return `Dein Jodel hat ${notification.vote_count ?? '-'} Votes`; 24 | case NotificationType.PIN: 25 | return 'Antwort auf deinen Pin'; 26 | default: 27 | return notification.type; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/picture.utils.ts: -------------------------------------------------------------------------------- 1 | function convertImageUrlToImage(url: string): Promise { 2 | return new Promise((resolve, reject) => { 3 | if (!url) { 4 | reject('Error: Given picture url is empty'); 5 | return; 6 | } 7 | 8 | const image = new Image(); 9 | image.onload = () => { 10 | resolve(image); 11 | }; 12 | image.onerror = (ev: unknown) => reject(ev); 13 | image.src = url; 14 | }); 15 | } 16 | 17 | function convertBlobToDataUrl(blob: Blob): Promise { 18 | return new Promise((resolve, reject) => { 19 | const fileReader = new FileReader(); 20 | fileReader.onload = () => { 21 | if (fileReader.result) { 22 | resolve(fileReader.result as string); 23 | } else { 24 | reject('Failed to read file'); 25 | } 26 | }; 27 | fileReader.readAsDataURL(blob); 28 | }); 29 | } 30 | 31 | function resizeImage(image: HTMLImageElement, newWidth: number, newHeight: number): Promise { 32 | return new Promise((resolve, reject) => { 33 | const canvas = document.createElement('canvas'); 34 | 35 | canvas.width = newWidth; 36 | canvas.height = newHeight; 37 | 38 | const context = canvas.getContext('2d'); 39 | if (!context) { 40 | reject('Failed to get canvas context'); 41 | return; 42 | } 43 | context.drawImage(image, 0, 0, newWidth, newHeight); 44 | canvas.toBlob( 45 | im => { 46 | if (!im) { 47 | reject('Failed to create blob from canvas'); 48 | return; 49 | } 50 | resolve(im); 51 | }, 52 | 'image/jpeg', 53 | 9, 54 | ); 55 | }); 56 | } 57 | 58 | export function resizePicture(blob: Blob, maxWidth: number): Promise { 59 | return convertBlobToDataUrl(blob).then(url => resizePictureUrl(url, maxWidth)); 60 | } 61 | 62 | export function resizePictureUrl(imageUrl: string, maxWidth: number): Promise { 63 | return convertImageUrlToImage(imageUrl).then(image => { 64 | if (image.naturalWidth <= maxWidth) { 65 | return Promise.resolve(imageUrl); 66 | } 67 | 68 | const width = maxWidth; 69 | const height = (image.naturalHeight / image.naturalWidth) * width; 70 | return resizeImage(image, width, height).then(blob => convertBlobToDataUrl(blob)); 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function toFormUrlencoded(form: { 2 | [key: string]: string | number | boolean | undefined; 3 | }): string { 4 | return Object.keys(form) 5 | .filter(key => form[key] != null) 6 | .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(form[key]!)) 7 | .join('&'); 8 | } 9 | -------------------------------------------------------------------------------- /src/views/AddButton.scss: -------------------------------------------------------------------------------- 1 | .add-button { 2 | background-color: rgba(0, 0, 0, 0.3); 3 | border: solid 0.3em #fff; 4 | border-radius: 4em; 5 | color: #fff; 6 | cursor: pointer; 7 | height: 4em; 8 | left: 50%; 9 | margin-left: -2em; 10 | position: fixed; 11 | text-align: center; 12 | width: 4em; 13 | 14 | &:hover { 15 | background-color: rgba(66, 189, 255, 0.76); 16 | } 17 | 18 | &:active { 19 | background-color: rgba(66, 189, 255, 0.76); 20 | } 21 | 22 | &::before { 23 | content: '+'; 24 | font-size: 3em; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/views/AddButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './AddButton.scss'; 4 | 5 | export interface IAddButtonProps { 6 | onClick: (e: React.MouseEvent) => void; 7 | } 8 | 9 | const AddButton = ({ onClick }: IAddButtonProps) => { 10 | return
; 11 | }; 12 | 13 | export default AddButton; 14 | -------------------------------------------------------------------------------- /src/views/AddPost.scss: -------------------------------------------------------------------------------- 1 | @import '../../style/settings.scss'; 2 | 3 | .add-post { 4 | background-color: #eee; 5 | bottom: 0; 6 | display: none; 7 | left: 0; 8 | position: fixed; 9 | right: 0; 10 | text-align: center; 11 | top: 0; 12 | z-index: $z-index-overlay; 13 | 14 | &.visible { 15 | display: block; 16 | } 17 | 18 | textarea { 19 | height: 10em; 20 | width: 100%; 21 | } 22 | 23 | img { 24 | height: 100px; 25 | } 26 | 27 | .colorPicker { 28 | color: white; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/views/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import DocumentTitle from 'react-document-title'; 3 | import { IntlProvider } from 'react-intl'; 4 | import { Provider } from 'react-redux'; 5 | import type { Store } from 'redux'; 6 | 7 | import { Jodel } from './Jodel'; 8 | 9 | interface IAppProps { 10 | locale: string; 11 | messages: { [key: string]: string }; 12 | store: Store; 13 | } 14 | 15 | export const App = ({ locale, messages, store }: IAppProps) => ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /src/views/BackButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface IBackButtonProps { 4 | onClick: (e: React.MouseEvent) => void; 5 | } 6 | 7 | const BackButton = ({ onClick }: IBackButtonProps) => { 8 | return ( 9 |
10 | Zurück 11 |
12 | ); 13 | }; 14 | 15 | export default BackButton; 16 | -------------------------------------------------------------------------------- /src/views/BigPicture.scss: -------------------------------------------------------------------------------- 1 | @import '../../style/settings.scss'; 2 | 3 | .big-picture { 4 | background-color: black; 5 | bottom: 0; 6 | left: 0; 7 | position: fixed; 8 | right: 0; 9 | top: 0; 10 | z-index: $z-index-overlay; 11 | 12 | img { 13 | height: 100%; 14 | object-fit: contain; 15 | width: 100%; 16 | } 17 | 18 | video { 19 | height: 100%; 20 | width: 100%; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/views/BigPicture.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { IPost } from '../interfaces/IPost'; 4 | import './BigPicture.scss'; 5 | 6 | export interface IBigPictureProps { 7 | post: IPost; 8 | } 9 | 10 | const BigPicture = ({ post }: IBigPictureProps) => { 11 | const imgRef = React.useRef(null); 12 | React.useEffect(() => { 13 | imgRef.current?.requestFullscreen?.(); 14 | }, []); 15 | return ( 16 |
window.history.back()} ref={imgRef}> 17 | {'video_url' in post && post.video_url ? ( 18 | 19 | ) : post.image_url ? ( 20 | {post.message} 21 | ) : null} 22 | {post.message} 23 |
24 | ); 25 | }; 26 | 27 | export default BigPicture; 28 | -------------------------------------------------------------------------------- /src/views/ChannelList.scss: -------------------------------------------------------------------------------- 1 | @import '../../style/settings.scss'; 2 | 3 | .channel-list { 4 | background-color: #eee; 5 | bottom: 0; 6 | left: 0; 7 | overflow-y: scroll; 8 | position: absolute; 9 | right: 0; 10 | top: $top-bar-height; 11 | z-index: $z-index-sub-layer; 12 | } 13 | 14 | .channel-list_header { 15 | background-color: orange; 16 | color: white; 17 | letter-spacing: 0.25em; 18 | margin-bottom: 1px; 19 | padding: 1em; 20 | text-transform: uppercase; 21 | } 22 | 23 | .channel-list_filter { 24 | display: flex; 25 | flex-direction: column; 26 | } 27 | 28 | .channel-list_recommended, 29 | .channel-list_local, 30 | .channel-list_country { 31 | align-items: center; 32 | background: #ddd; 33 | color: #555; 34 | display: flex; 35 | justify-content: space-between; 36 | letter-spacing: 0.25em; 37 | margin-bottom: 1px; 38 | padding: 1em; 39 | text-transform: uppercase; 40 | } 41 | 42 | .channel-list_local, 43 | .channel-list_country { 44 | cursor: pointer; 45 | 46 | &:hover { 47 | background: #eee; 48 | } 49 | } 50 | 51 | .channel-count { 52 | font-size: 0.7rem; 53 | letter-spacing: initial; 54 | } 55 | -------------------------------------------------------------------------------- /src/views/ChannelList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { IChannel } from '../interfaces/IChannel'; 5 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 6 | import { switchPostSection } from '../redux/actions'; 7 | import type { IJodelAppStore } from '../redux/reducers'; 8 | import { 9 | countryChannelsSelector, 10 | followedChannelsSelector, 11 | localChannelsSelector, 12 | recommendedChannelsSelector, 13 | } from '../redux/selectors/channels'; 14 | 15 | import './ChannelList.scss'; 16 | import { ChannelListItem } from './ChannelListItem'; 17 | 18 | interface IChannelListComponentProps { 19 | channels: IChannel[]; 20 | recommendedChannels: IChannel[]; 21 | localChannels: IChannel[]; 22 | countryChannels: IChannel[]; 23 | onChannelClick: (channelName: string) => void; 24 | } 25 | 26 | interface IChannelListComponentState { 27 | channelFilter: string; 28 | showLocalChannels: boolean; 29 | showCountryChannels: boolean; 30 | } 31 | 32 | export class ChannelListComponent extends React.Component< 33 | IChannelListComponentProps, 34 | IChannelListComponentState 35 | > { 36 | private static lastScrollPosition = 0; 37 | 38 | public state: IChannelListComponentState = { 39 | channelFilter: '', 40 | showCountryChannels: false, 41 | showLocalChannels: false, 42 | }; 43 | 44 | private scrollable = React.createRef(); 45 | 46 | public componentDidMount(): void { 47 | if (this.scrollable.current) { 48 | this.scrollable.current.scrollTop = ChannelListComponent.lastScrollPosition; 49 | } 50 | } 51 | 52 | public componentWillUnmount(): void { 53 | if (this.scrollable.current) { 54 | ChannelListComponent.lastScrollPosition = this.scrollable.current.scrollTop; 55 | } 56 | } 57 | 58 | public render(): React.ReactElement | null { 59 | const { channels, recommendedChannels, localChannels, countryChannels, onChannelClick } = 60 | this.props; 61 | const channelNodes = channels.map(channel => { 62 | return this.createChannelNode(channel, onChannelClick, true); 63 | }); 64 | const recommendedChannelNodes = recommendedChannels.map(channel => { 65 | return this.createChannelNode(channel, onChannelClick, true); 66 | }); 67 | const localChannelNodes = !this.state.showLocalChannels 68 | ? null 69 | : localChannels.map(channel => { 70 | return this.createChannelNode(channel, onChannelClick, false); 71 | }); 72 | const countryChannelNodes = !this.state.showCountryChannels 73 | ? null 74 | : countryChannels.map(channel => { 75 | return this.createChannelNode(channel, onChannelClick, false); 76 | }); 77 | return ( 78 |
79 |
Kanäle(beta)
80 |
81 | 87 | {!this.state.channelFilter ? null : ( 88 | 94 | )} 95 |
96 | {channelNodes} 97 | {recommendedChannelNodes.length === 0 ? null : ( 98 |
99 | Vorschläge 100 |
{recommendedChannels.length}
101 |
102 | )} 103 | {recommendedChannelNodes} 104 | {localChannels.length === 0 ? null : ( 105 |
106 | Lokale 107 |
{localChannels.length}
108 |
109 | )} 110 | {localChannelNodes} 111 | {countryChannels.length === 0 ? null : ( 112 |
113 | Landesweit 114 |
{countryChannels.length}
115 |
116 | )} 117 | {countryChannelNodes} 118 |
119 | ); 120 | } 121 | 122 | private createChannelNode( 123 | channel: IChannel, 124 | onChannelClick: (channel: string) => void, 125 | showImage: boolean, 126 | ): React.ReactElement | null { 127 | if ( 128 | this.state.channelFilter && 129 | !channel.channel.toLowerCase().includes(this.state.channelFilter.toLowerCase()) 130 | ) { 131 | return null; 132 | } 133 | return ( 134 | 140 | ); 141 | } 142 | 143 | private onFilterChange = (e: React.ChangeEvent) => { 144 | this.setState({ channelFilter: e.target.value }); 145 | }; 146 | 147 | private onToggleLocalChannels = () => { 148 | this.setState({ showLocalChannels: !this.state.showLocalChannels }); 149 | }; 150 | 151 | private onToggleCountryChannels = () => { 152 | this.setState({ showCountryChannels: !this.state.showCountryChannels }); 153 | }; 154 | } 155 | 156 | const mapStateToProps = (state: IJodelAppStore) => { 157 | return { 158 | channels: followedChannelsSelector(state), 159 | countryChannels: countryChannelsSelector(state), 160 | localChannels: localChannelsSelector(state), 161 | recommendedChannels: recommendedChannelsSelector(state), 162 | }; 163 | }; 164 | 165 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 166 | return { 167 | onChannelClick(channelName: string): void { 168 | dispatch(switchPostSection('channel:' + channelName)); 169 | }, 170 | }; 171 | }; 172 | 173 | export default connect(mapStateToProps, mapDispatchToProps)(ChannelListComponent); 174 | -------------------------------------------------------------------------------- /src/views/ChannelListItem.scss: -------------------------------------------------------------------------------- 1 | .channel-list-item { 2 | background: #fff; 3 | color: #333; 4 | cursor: pointer; 5 | display: flex; 6 | justify-items: center; 7 | margin-bottom: 1px; 8 | padding: 1em; 9 | 10 | &.unread { 11 | font-weight: bold; 12 | } 13 | } 14 | 15 | .channel-list-item_picture { 16 | background-size: cover; 17 | flex: none; 18 | height: 2em; 19 | margin-right: 1em; 20 | width: 2em; 21 | } 22 | 23 | .channel-list-item_title { 24 | flex: auto; 25 | margin-bottom: 0.5em; 26 | margin-top: 0.5em; 27 | overflow: hidden; 28 | text-overflow: ellipsis; 29 | } 30 | 31 | .channel-list-item_info { 32 | flex: 0 1 auto; 33 | margin-left: 0.5em; 34 | } 35 | 36 | .channel-list-item_info-followers, 37 | .channel-list-item_info-country-followers { 38 | font-size: 0.7rem; 39 | text-align: right; 40 | } 41 | -------------------------------------------------------------------------------- /src/views/ChannelListItem.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | 4 | import type { IChannel } from '../interfaces/IChannel'; 5 | import './ChannelListItem.scss'; 6 | 7 | export class ChannelListItem extends React.PureComponent<{ 8 | channel: IChannel; 9 | onChannelClick: (channel: string) => void; 10 | showImage: boolean; 11 | }> { 12 | public render(): React.ReactElement | null { 13 | const { channel, showImage } = this.props; 14 | return ( 15 |
19 | {showImage && channel.image_url ? ( 20 |
24 | ) : undefined} 25 |
@{channel.channel}
26 |
27 | {channel.sponsored ? ( 28 |
(Sponsored)
29 | ) : undefined} 30 | {channel.followers ? ( 31 |
32 | {channel.followers} Followers 33 |
34 | ) : undefined} 35 | {channel.country_followers ? ( 36 |
37 | {channel.country_followers} Country wide 38 |
39 | ) : undefined} 40 |
41 |
42 | ); 43 | } 44 | 45 | private onChannelClick = () => { 46 | this.props.onChannelClick(this.props.channel.channel); 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/views/ChannelTopBar.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | 5 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 6 | import { followChannel } from '../redux/actions'; 7 | import type { IJodelAppStore } from '../redux/reducers'; 8 | import { 9 | isSelectedChannelFollowedSelector, 10 | selectedChannelFollowersCountSelector, 11 | selectedChannelNameLikeFollowedSelector, 12 | } from '../redux/selectors/channels'; 13 | 14 | import BackButton from './BackButton'; 15 | 16 | export interface IChannelTopBarProps { 17 | onFollowClick: (channel: string, follow: boolean) => void; 18 | channel?: string; 19 | followerCount: number; 20 | isFollowing: boolean; 21 | } 22 | 23 | const ChannelTopBar = ({ 24 | onFollowClick, 25 | channel, 26 | followerCount, 27 | isFollowing, 28 | }: IChannelTopBarProps) => { 29 | return !channel ? null : ( 30 |
31 | window.history.back()} /> 32 |
@{channel}
33 |
34 | {followerCount > 0 ? followerCount : null} 35 |
onFollowClick(channel, !isFollowing)} 38 | >
39 |
40 |
41 | ); 42 | }; 43 | 44 | const mapStateToProps = (state: IJodelAppStore) => { 45 | return { 46 | channel: selectedChannelNameLikeFollowedSelector(state), 47 | followerCount: selectedChannelFollowersCountSelector(state), 48 | isFollowing: isSelectedChannelFollowedSelector(state), 49 | }; 50 | }; 51 | 52 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 53 | return { 54 | onFollowClick: (channel: string, follow: boolean) => { 55 | dispatch(followChannel(channel, follow)); 56 | }, 57 | }; 58 | }; 59 | 60 | export default connect(mapStateToProps, mapDispatchToProps)(ChannelTopBar); 61 | -------------------------------------------------------------------------------- /src/views/ChildInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface IChildInfoProps { 4 | child_count: number; 5 | } 6 | 7 | const ChildInfo = ({ child_count }: IChildInfoProps) => ( 8 |
0 ? undefined : 'hidden' }}> 9 | {child_count} Kommentare 10 |
11 | ); 12 | 13 | export default ChildInfo; 14 | -------------------------------------------------------------------------------- /src/views/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Settings from '../app/settings'; 4 | import type { Color } from '../enums/Color'; 5 | 6 | export interface IColorPickerProps { 7 | color?: Color; 8 | onChange: (color: Color) => void; 9 | } 10 | 11 | export default class ColorPicker extends React.PureComponent { 12 | constructor(props: IColorPickerProps) { 13 | super(props); 14 | } 15 | 16 | public render(): React.ReactElement | null { 17 | const { color, onChange } = this.props; 18 | const colorNodes = Settings.POST_COLORS.map(c => { 19 | return ( 20 | 29 | ); 30 | }); 31 | return
{colorNodes}
; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/views/EmailVerification.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { FirebaseTokenResponse } from '../app/mailAuth'; 4 | import { generateFirebaseToken, requestEmailVerification } from '../app/mailAuth'; 5 | 6 | export const EmailVerification: React.FC<{ onToken: (token: FirebaseTokenResponse) => void }> = ({ 7 | onToken, 8 | }) => { 9 | const [email, setEmail] = React.useState(''); 10 | const [emailLink, setEmailLink] = React.useState(''); 11 | const [verificationRequested, setVerificationRequested] = React.useState(false); 12 | return ( 13 |
14 | 15 | setEmail(e.target.value)} /> 16 | 23 | {verificationRequested && ( 24 | <> 25 | 26 | setEmailLink(e.target.value)} 30 | /> 31 | 34 | 35 | )} 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/views/FirstStart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage } from 'react-intl'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import type { FirebaseTokenResponse } from '../app/mailAuth'; 6 | import Settings from '../app/settings'; 7 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 8 | import { 9 | createNewAccount, 10 | setUseBrowserLocation, 11 | updateLocation, 12 | _setLocation, 13 | } from '../redux/actions'; 14 | import { setDeviceUid } from '../redux/actions/api'; 15 | import type { IJodelAppStore } from '../redux/reducers'; 16 | import { deviceUidSelector, locationSelector } from '../redux/selectors/app'; 17 | 18 | import { EmailVerification } from './EmailVerification'; 19 | import { SelectDeviceUid } from './SelectDeviceUid'; 20 | import { SelectLocation } from './SelectLocation'; 21 | 22 | const FirstStart: React.FC = () => { 23 | const dispatch = useDispatch(); 24 | const initialDeviceUid = useSelector(deviceUidSelector); 25 | const location = useSelector(locationSelector); 26 | const useBrowserLocation = useSelector( 27 | (state: IJodelAppStore) => state.settings.useBrowserLocation, 28 | ); 29 | 30 | const [deviceUid, setInputDeviceUid] = React.useState(initialDeviceUid); 31 | const [firebaseToken, setFirebaseToken] = React.useState(null); 32 | 33 | const triggerUpdateLocation = () => { 34 | dispatch(updateLocation()); 35 | }; 36 | 37 | return ( 38 |
39 |

40 | 44 |

45 | {firebaseToken ? ( 46 | Successfully verified an email address for registration. 47 | ) : ( 48 | 49 | )} 50 |
{ 52 | e.preventDefault(); 53 | if (!deviceUid) { 54 | dispatch( 55 | createNewAccount(firebaseToken?.user_id, firebaseToken?.access_token), 56 | ); 57 | } else { 58 | dispatch( 59 | setDeviceUid( 60 | deviceUid, 61 | firebaseToken?.user_id, 62 | firebaseToken?.access_token, 63 | ), 64 | ); 65 | } 66 | }} 67 | > 68 |

69 | 70 |

71 |
72 | 73 |
74 |

75 | 76 |

77 |
78 | { 82 | dispatch(setUseBrowserLocation(newUseBrowserLocation)); 83 | if (!newLocation) { 84 | if (newUseBrowserLocation || !Settings.DEFAULT_LOCATION) { 85 | return; 86 | } 87 | newLocation = { 88 | latitude: Settings.DEFAULT_LOCATION.latitude, 89 | longitude: Settings.DEFAULT_LOCATION.longitude, 90 | }; 91 | } 92 | dispatch(_setLocation(newLocation.latitude, newLocation.longitude)); 93 | }} 94 | onLocationRequested={triggerUpdateLocation} 95 | /> 96 | {!location ? ( 97 |
98 |
99 | 106 |
107 | 108 | 112 | 113 |
114 | ) : ( 115 | '' 116 | )} 117 |
118 | 121 |
122 |
123 | ); 124 | }; 125 | 126 | export default FirstStart; 127 | -------------------------------------------------------------------------------- /src/views/HashtagTopBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 5 | import type { IJodelAppStore } from '../redux/reducers'; 6 | import { selectedHashtagNameSelector } from '../redux/selectors/view'; 7 | 8 | import BackButton from './BackButton'; 9 | 10 | export interface IHashtagTopBarProps { 11 | hashtag: string | undefined; 12 | } 13 | 14 | const HashtagTopBarComponent = ({ hashtag }: IHashtagTopBarProps) => { 15 | return !hashtag ? null : ( 16 |
17 | window.history.back()} /> 18 |
#{hashtag}
19 |
20 | ); 21 | }; 22 | 23 | const mapStateToProps = (state: IJodelAppStore) => { 24 | return { 25 | hashtag: selectedHashtagNameSelector(state), 26 | }; 27 | }; 28 | 29 | const mapDispatchToProps = (_dispatch: JodelThunkDispatch) => { 30 | return {}; 31 | }; 32 | 33 | export const HashtagTopBar = connect(mapStateToProps, mapDispatchToProps)(HashtagTopBarComponent); 34 | -------------------------------------------------------------------------------- /src/views/Jodel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { IPost } from '../interfaces/IPost'; 5 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 6 | import { fetchPostsIfNeeded } from '../redux/actions'; 7 | import { getNotificationsIfAvailable } from '../redux/actions/api'; 8 | import type { IJodelAppStore } from '../redux/reducers'; 9 | import { 10 | deviceUidSelector, 11 | isConfigAvailableSelector, 12 | isRegisteredSelector, 13 | } from '../redux/selectors/app'; 14 | import { selectedPicturePostSelector, selectedPostIdSelector } from '../redux/selectors/posts'; 15 | import { 16 | addPostVisibleSelector, 17 | channelListVisibleSelector, 18 | notificationsVisibleSelector, 19 | searchVisibleSelector, 20 | settingsVisibleSelector, 21 | } from '../redux/selectors/view'; 22 | 23 | import BigPicture from './BigPicture'; 24 | import ChannelTopBar from './ChannelTopBar'; 25 | import FirstStart from './FirstStart'; 26 | import { HashtagTopBar } from './HashtagTopBar'; 27 | import PostDetails from './PostDetails'; 28 | import { PostListContainer } from './PostListContainer'; 29 | import { PostTopBar } from './PostTopBar'; 30 | import Progress from './Progress'; 31 | import ShareLink from './ShareLink'; 32 | import { ToastContainer } from './ToastContainer'; 33 | import { TopBar } from './TopBar'; 34 | 35 | const LazyAddPost = React.lazy(() => import('./AddPost')); 36 | const LazyNotificationList = React.lazy(() => import('./NotificationList')); 37 | const LazySearch = React.lazy(() => import('./Search')); 38 | const LazyAppSettings = React.lazy(() => import('./AppSettings')); 39 | const LazyChannelList = React.lazy(() => import('./ChannelList')); 40 | 41 | export interface IJodelProps { 42 | addPostVisible: boolean; 43 | selectedPostId: string | null; 44 | selectedPicturePost: IPost | null; 45 | settingsVisible: boolean; 46 | deviceUid: string | null; 47 | isRegistered: boolean; 48 | channelListVisible: boolean; 49 | notificationsVisible: boolean; 50 | searchVisible: boolean; 51 | isConfigAvailable: boolean; 52 | refresh: () => void; 53 | } 54 | 55 | class JodelComponent extends React.Component { 56 | private timer: number | undefined; 57 | 58 | public componentDidMount(): void { 59 | this.timer = window.setInterval(this.refresh, 20000); 60 | } 61 | 62 | public componentWillUnmount(): void { 63 | if (this.timer) { 64 | clearInterval(this.timer); 65 | } 66 | } 67 | 68 | public render(): React.ReactElement | null { 69 | if (!this.props.deviceUid) { 70 | return ( 71 |
72 | 73 | 74 |
75 | ); 76 | } else if (!this.props.isConfigAvailable) { 77 | return null; 78 | } 79 | 80 | let content = null; 81 | 82 | if (this.props.addPostVisible) { 83 | content = ; 84 | } else if (this.props.notificationsVisible) { 85 | content = ; 86 | } else if (this.props.searchVisible) { 87 | content = ; 88 | } else if (this.props.settingsVisible) { 89 | content = ; 90 | } else if (this.props.channelListVisible) { 91 | content = ; 92 | } else if (this.props.selectedPostId != null) { 93 | content = ( 94 |
95 | 96 | 97 |
98 | ); 99 | } else { 100 | content = ( 101 |
102 | 103 | 104 | 105 |
106 | ); 107 | } 108 | let overlay = null; 109 | if (this.props.selectedPicturePost) { 110 | overlay = ; 111 | } 112 | 113 | return ( 114 |
115 | 116 | 117 | ...
}>{content} 118 | {overlay} 119 | 120 | 121 |
122 | ); 123 | } 124 | 125 | private refresh = () => { 126 | if (!this.props.isRegistered) { 127 | return; 128 | } 129 | this.props.refresh(); 130 | }; 131 | } 132 | 133 | const mapStateToProps = (state: IJodelAppStore) => { 134 | return { 135 | addPostVisible: addPostVisibleSelector(state), 136 | channelListVisible: channelListVisibleSelector(state), 137 | deviceUid: deviceUidSelector(state), 138 | isConfigAvailable: isConfigAvailableSelector(state), 139 | isRegistered: isRegisteredSelector(state), 140 | notificationsVisible: notificationsVisibleSelector(state), 141 | searchVisible: searchVisibleSelector(state), 142 | selectedPicturePost: selectedPicturePostSelector(state), 143 | selectedPostId: selectedPostIdSelector(state), 144 | settingsVisible: settingsVisibleSelector(state), 145 | }; 146 | }; 147 | 148 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 149 | return { 150 | refresh(): void { 151 | dispatch(fetchPostsIfNeeded()); 152 | dispatch(getNotificationsIfAvailable()); 153 | }, 154 | }; 155 | }; 156 | 157 | export const Jodel = connect(mapStateToProps, mapDispatchToProps)(JodelComponent); 158 | -------------------------------------------------------------------------------- /src/views/Location.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | 4 | export interface ILocationProps { 5 | distance: number; 6 | location: string; 7 | fromHome: boolean; 8 | } 9 | 10 | export default class Location extends React.Component { 11 | public render(): React.ReactElement | null { 12 | const { distance, location, fromHome } = this.props; 13 | return ( 14 |
15 |
{distance}km weg
16 |
{location}
17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/views/Menu.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:math'; 2 | 3 | @import '../../style/settings.scss'; 4 | 5 | .menu { 6 | cursor: pointer; 7 | flex: none; 8 | height: math.div($top-bar-height, 3); 9 | padding: math.div($top-bar-height, 3) 0.6em; 10 | 11 | &::before { 12 | background: url('../../img/menu.svg') no-repeat center; 13 | content: ' '; 14 | display: block; 15 | height: math.div($top-bar-height, 3); 16 | width: $top-bar-height * 0.5; 17 | } 18 | } 19 | 20 | .menu_new-notifications::after { 21 | background-color: blue; 22 | border-radius: 10px; 23 | content: ' '; 24 | height: 10px; 25 | left: $top-bar-height * 0.5; 26 | position: absolute; 27 | top: $top-bar-height * 0.25; 28 | width: 10px; 29 | } 30 | 31 | .menu_content { 32 | background-color: #eee; 33 | box-shadow: $menu-shadow; 34 | left: 0; 35 | list-style-type: none; 36 | padding: 0; 37 | position: absolute; 38 | z-index: $z-index-menu-layer; 39 | } 40 | 41 | .menu_entry { 42 | > * { 43 | height: 2em; 44 | padding: 1em 1.3em 1em 1em; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/views/Menu.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import { FormattedMessage, FormattedNumber } from 'react-intl'; 4 | import { connect } from 'react-redux'; 5 | 6 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 7 | import { showNotifications, showSearch, showSettings } from '../redux/actions'; 8 | import { showPictureOfDay } from '../redux/actions/api'; 9 | import type { IJodelAppStore } from '../redux/reducers'; 10 | import { unreadNotificationsCountSelector } from '../redux/selectors/notifications'; 11 | 12 | import './Menu.scss'; 13 | import { SectionLink } from './SectionLink'; 14 | 15 | interface IMenuComponentProps { 16 | showSettingsCallback: () => void; 17 | showNotificationsCallback: () => void; 18 | showSearchCallback: () => void; 19 | showPictureOfDayCallback: () => void; 20 | unreadNotifications: number; 21 | } 22 | 23 | interface IMenuComponentState { 24 | menuOpen: boolean; 25 | } 26 | 27 | class MenuComponent extends React.Component { 28 | constructor(props: IMenuComponentProps) { 29 | super(props); 30 | this.state = { 31 | menuOpen: false, 32 | }; 33 | } 34 | 35 | public render(): React.ReactElement | null { 36 | const { 37 | unreadNotifications, 38 | showPictureOfDayCallback, 39 | showNotificationsCallback, 40 | showSettingsCallback, 41 | showSearchCallback, 42 | } = this.props; 43 | return ( 44 |
0, 47 | })} 48 | tabIndex={99999999} 49 | onClick={() => this.setState({ menuOpen: !this.state.menuOpen })} 50 | > 51 | {!this.state.menuOpen ? ( 52 | '' 53 | ) : ( 54 |
    55 |
  • 56 |
    57 | 61 |
    62 |
  • 63 |
  • 64 | 65 |
  • 66 |
  • 67 | 68 |
  • 69 |
  • 70 | 71 |
  • 72 |
  • 73 | 74 |
  • 75 |
  • 76 |
    77 | 81 | {unreadNotifications === 0 ? ( 82 | '' 83 | ) : ( 84 | <> 85 |  ( 86 | ) 87 | 88 | )} 89 |
    90 |
  • 91 |
  • 92 |
    93 | 94 |
    95 |
  • 96 |
  • 97 |
    98 | 99 |
    100 |
  • 101 |
102 | )} 103 |
104 | ); 105 | } 106 | } 107 | 108 | const mapStateToProps = (state: IJodelAppStore) => { 109 | return { 110 | unreadNotifications: unreadNotificationsCountSelector(state), 111 | }; 112 | }; 113 | 114 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 115 | return { 116 | showNotificationsCallback: () => dispatch(showNotifications(true)), 117 | showPictureOfDayCallback: () => dispatch(showPictureOfDay()), 118 | showSearchCallback: () => dispatch(showSearch(true)), 119 | showSettingsCallback: () => dispatch(showSettings(true)), 120 | }; 121 | }; 122 | 123 | export const Menu = connect(mapStateToProps, mapDispatchToProps)(MenuComponent); 124 | -------------------------------------------------------------------------------- /src/views/Message.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface IMessageProps { 4 | message: string; 5 | link?: { url: string; title?: string }; 6 | onAtClick: (e: React.MouseEvent, channel: string) => void; 7 | onHashtagClick: (e: React.MouseEvent, channel: string) => void; 8 | } 9 | 10 | export default class Message extends React.Component { 11 | public render(): React.ReactElement | null { 12 | const { message, link, onAtClick, onHashtagClick } = this.props; 13 | const linkReg = /([^#@]*)([@#])([^\s#@:;.,]*)|([^[]*)\[([^[\]]*)\]\(([^()]*)\)/gm; 14 | let previousIndex = 0; 15 | const messageParts = []; 16 | while (true) { 17 | const regResult = linkReg.exec(message); 18 | 19 | if (regResult === null) { 20 | messageParts.push(message.substring(previousIndex)); 21 | break; 22 | } 23 | if (regResult[1] !== undefined) { 24 | messageParts.push(regResult[1]); 25 | if (regResult[2] === '@') { 26 | const channel = regResult[3]; 27 | messageParts.push( 28 | onAtClick(e, channel)} 32 | > 33 | @{channel} 34 | , 35 | ); 36 | } else if (regResult[2] === '#') { 37 | const hashtag = regResult[3]; 38 | messageParts.push( 39 | onHashtagClick(e, hashtag)} 43 | > 44 | #{hashtag} 45 | , 46 | ); 47 | } 48 | } else if (regResult[4] !== undefined) { 49 | messageParts.push(regResult[4]); 50 | const linkLabel = regResult[5]; 51 | const linkTarget = regResult[6]; 52 | messageParts.push( 53 | 60 | {linkLabel} 61 | , 62 | ); 63 | } 64 | previousIndex = linkReg.lastIndex; 65 | } 66 | 67 | return ( 68 |
69 | {messageParts} 70 | {link ? ( 71 |
72 | {link.title}: {link.url} 73 |
74 | ) : null} 75 |
76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/views/NotificationList.scss: -------------------------------------------------------------------------------- 1 | @import '../../style/settings.scss'; 2 | 3 | .notification-list { 4 | background-color: #eee; 5 | bottom: 0; 6 | left: 0; 7 | overflow-y: scroll; 8 | position: absolute; 9 | right: 0; 10 | top: $top-bar-height; 11 | z-index: $z-index-sub-layer; 12 | } 13 | -------------------------------------------------------------------------------- /src/views/NotificationList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { INotification } from '../interfaces/INotification'; 5 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 6 | import { selectPostFromNotification } from '../redux/actions'; 7 | import type { IJodelAppStore } from '../redux/reducers'; 8 | import { notificationsSelector } from '../redux/selectors/notifications'; 9 | 10 | import './NotificationList.scss'; 11 | import { NotificationListItem } from './NotificationListItem'; 12 | 13 | export interface INotificationListComponentProps { 14 | notifications: readonly INotification[]; 15 | selectPost: (postId: string) => void; 16 | } 17 | 18 | class NotificationListComponent extends React.PureComponent { 19 | public constructor(props: INotificationListComponentProps) { 20 | super(props); 21 | } 22 | 23 | public render(): React.ReactElement | null { 24 | const { notifications, selectPost } = this.props; 25 | return ( 26 |
27 | {notifications.length === 0 28 | ? 'Noch keine Benachrichtigungen vorhanden' 29 | : notifications.map(n => ( 30 | 34 | ))} 35 |
36 | ); 37 | } 38 | } 39 | 40 | const mapStateToProps = (state: IJodelAppStore) => { 41 | return { 42 | notifications: notificationsSelector(state), 43 | }; 44 | }; 45 | 46 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 47 | return { 48 | selectPost: (postId: string) => dispatch(selectPostFromNotification(postId)), 49 | }; 50 | }; 51 | 52 | export default connect(mapStateToProps, mapDispatchToProps)(NotificationListComponent); 53 | -------------------------------------------------------------------------------- /src/views/NotificationListItem.scss: -------------------------------------------------------------------------------- 1 | .notification-list-item { 2 | background-color: white; 3 | cursor: pointer; 4 | display: flex; 5 | margin-bottom: 1px; 6 | padding: 10px 0; 7 | 8 | &.unread .details { 9 | font-weight: bolder; 10 | } 11 | 12 | .type { 13 | flex: none; 14 | font-size: 0.6em; 15 | overflow: hidden; 16 | width: 40px; 17 | } 18 | 19 | .details { 20 | display: flex; 21 | flex: 1 1; 22 | flex-direction: column; 23 | margin: 0 10px; 24 | overflow: hidden; 25 | 26 | .info-text { 27 | font-size: 0.8em; 28 | } 29 | 30 | .message, 31 | .info-text { 32 | flex: auto; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | white-space: nowrap; 36 | } 37 | } 38 | 39 | .time { 40 | flex: none; 41 | font-size: 0.8em; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/views/NotificationListItem.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | 4 | import type { INotification } from '../interfaces/INotification'; 5 | import { getNotificationDescription } from '../utils/notification.utils'; 6 | 7 | import './NotificationListItem.scss'; 8 | import { Time } from './Time'; 9 | 10 | interface INotificationListItemProps { 11 | notification: INotification; 12 | selectPost: (postId: string) => void; 13 | } 14 | 15 | export const NotificationListItem = ({ notification, selectPost }: INotificationListItemProps) => { 16 | return ( 17 |
{ 21 | selectPost(notification.post_id); 22 | }} 23 | > 24 |
{notification.type}
25 |
26 |
{getNotificationDescription(notification)}
27 |
{notification.message}
28 |
29 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/views/PostDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { IPost } from '../interfaces/IPost'; 5 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 6 | import { fetchMoreComments, showAddPost } from '../redux/actions'; 7 | import type { IJodelAppStore } from '../redux/reducers'; 8 | import { isLocationKnownSelector } from '../redux/selectors/app'; 9 | import { selectedPostChildrenSelector, selectedPostSelector } from '../redux/selectors/posts'; 10 | 11 | import AddButton from './AddButton'; 12 | import { Post } from './Post'; 13 | import PostList from './PostList'; 14 | import ScrollToBottomButton from './ScrollToBottomButton'; 15 | 16 | interface IPostDetailsPropsComponent { 17 | post: IPost | null; 18 | postChildren: IPost[] | null; 19 | locationKnown: boolean; 20 | onAddClick: (e: React.MouseEvent) => void; 21 | onLoadMore: () => void; 22 | } 23 | 24 | export class PostDetailsComponent extends React.Component { 25 | private scrollAtBottom = false; 26 | 27 | private scrollable = React.createRef(); 28 | 29 | constructor(props: IPostDetailsPropsComponent) { 30 | super(props); 31 | } 32 | 33 | public componentDidUpdate(prevProps: IPostDetailsPropsComponent): void { 34 | if (this.props.post === null) { 35 | return; 36 | } else if (prevProps.post !== null && prevProps.post.post_id === this.props.post.post_id) { 37 | this.scrollAtBottom = false; 38 | return; 39 | } 40 | if (this.scrollable.current) { 41 | this.scrollable.current.scrollTop = 0; 42 | } 43 | } 44 | 45 | public componentDidMount(): void { 46 | if (this.scrollable.current) { 47 | this.scrollable.current.addEventListener('scroll', this.onScroll); 48 | } 49 | this.scrollAtBottom = false; 50 | } 51 | 52 | public componentWillUnmount(): void { 53 | if (this.scrollable.current) { 54 | this.scrollable.current.removeEventListener('scroll', this.onScroll); 55 | } 56 | } 57 | 58 | public render(): React.ReactElement | null { 59 | const { post, postChildren, locationKnown, onAddClick } = this.props; 60 | if (!post) { 61 | return null; 62 | } 63 | const childPosts = postChildren ? postChildren : []; 64 | 65 | return ( 66 |
67 | 68 | 69 | {locationKnown ? : ''} 70 | 71 |
72 | ); 73 | } 74 | 75 | private onScroll = () => { 76 | const element = this.scrollable.current; 77 | if (!element || !this.props.onLoadMore) { 78 | return; 79 | } 80 | const isNearBottom = 81 | element.scrollTop > 0 && 82 | element.scrollTop + element.clientHeight >= element.scrollHeight - 500; 83 | if (isNearBottom && this.scrollAtBottom !== isNearBottom) { 84 | this.scrollAtBottom = isNearBottom; 85 | this.props.onLoadMore(); 86 | } else { 87 | this.scrollAtBottom = isNearBottom; 88 | } 89 | }; 90 | 91 | private scrollToBottom = () => { 92 | if (this.scrollable.current) { 93 | this.scrollable.current.scrollTop = this.scrollable.current.scrollHeight; 94 | } 95 | }; 96 | 97 | private onPostClick = () => { 98 | // Do nothing 99 | }; 100 | } 101 | 102 | const mapStateToProps = (state: IJodelAppStore) => { 103 | return { 104 | locationKnown: isLocationKnownSelector(state), 105 | post: selectedPostSelector(state), 106 | postChildren: selectedPostChildrenSelector(state), 107 | }; 108 | }; 109 | 110 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 111 | return { 112 | onAddClick(): void { 113 | dispatch(showAddPost(true)); 114 | }, 115 | onLoadMore(): void { 116 | dispatch(fetchMoreComments()); 117 | }, 118 | }; 119 | }; 120 | 121 | export default connect(mapStateToProps, mapDispatchToProps)(PostDetailsComponent); 122 | -------------------------------------------------------------------------------- /src/views/PostList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { IPost } from '../interfaces/IPost'; 4 | 5 | import { PostListItem } from './PostListItem'; 6 | 7 | export interface IPostListProps { 8 | posts: IPost[]; 9 | sortType?: string; 10 | section?: string; 11 | lastUpdated?: number; 12 | parentPost?: IPost; 13 | onPostClick: (post: IPost) => void; 14 | onLoadMore?: () => void; 15 | connectScrollTarget?: (element: HTMLElement) => void; 16 | } 17 | 18 | export default class PostList extends React.PureComponent { 19 | private scrollAtBottom = false; 20 | 21 | private scrollable = React.createRef(); 22 | 23 | constructor(props: IPostListProps) { 24 | super(props); 25 | } 26 | 27 | public componentDidMount(): void { 28 | if (this.scrollable.current) { 29 | this.scrollable.current.addEventListener('scroll', this.onScroll); 30 | if (this.props.connectScrollTarget) { 31 | this.props.connectScrollTarget(this.scrollable.current); 32 | } 33 | } 34 | this.scrollAtBottom = false; 35 | } 36 | 37 | public componentDidUpdate(prevProps: IPostListProps): void { 38 | if ( 39 | prevProps.sortType !== this.props.sortType || 40 | prevProps.section !== this.props.section || 41 | prevProps.lastUpdated !== this.props.lastUpdated 42 | ) { 43 | if (this.scrollable.current) { 44 | this.scrollable.current.scrollTop = 0; 45 | } 46 | } 47 | } 48 | 49 | public componentWillUnmount(): void { 50 | if (this.scrollable.current) { 51 | this.scrollable.current.removeEventListener('scroll', this.onScroll); 52 | } 53 | } 54 | 55 | public render(): React.ReactElement | null { 56 | const { posts, parentPost, onPostClick } = this.props; 57 | const postNodes = posts.map(post => ( 58 | 64 | )); 65 | return ( 66 |
67 | {postNodes} 68 |
69 | ); 70 | } 71 | 72 | private onScroll = () => { 73 | const element = this.scrollable.current; 74 | if (!element || !this.props.onLoadMore) { 75 | return; 76 | } 77 | const newFlag = 78 | element.scrollTop > 0 && 79 | element.scrollTop + element.clientHeight >= element.scrollHeight - 500; 80 | if (this.scrollAtBottom !== newFlag && newFlag) { 81 | this.scrollAtBottom = newFlag; 82 | this.props.onLoadMore(); 83 | } else { 84 | this.scrollAtBottom = newFlag; 85 | } 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/views/PostListContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { PostListSortType } from '../enums/PostListSortType'; 5 | import type { IPost } from '../interfaces/IPost'; 6 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 7 | import { fetchMorePosts, selectPost, showAddPost, updatePosts } from '../redux/actions'; 8 | import type { IJodelAppStore } from '../redux/reducers'; 9 | import { isLocationKnownSelector } from '../redux/selectors/app'; 10 | import { 11 | selectedSectionLastUpdatedSelector, 12 | selectedSectionSortPostsSelector, 13 | } from '../redux/selectors/posts'; 14 | import { selectedSectionSelector, selectedSortTypeSelector } from '../redux/selectors/view'; 15 | 16 | import AddButton from './AddButton'; 17 | import PostList from './PostList'; 18 | import { SortTypeLink } from './SortTypeLink'; 19 | import { StickyList } from './StickyList'; 20 | 21 | export interface IPostListContainerProps {} 22 | 23 | export interface IPostListContainerComponentProps extends IPostListContainerProps { 24 | section: string; 25 | sortType: PostListSortType; 26 | lastUpdated?: number; 27 | posts: IPost[]; 28 | locationKnown: boolean; 29 | onPostClick: (post: IPost) => void; 30 | onLoadMore?: () => void; 31 | onAddClick: (e: React.MouseEvent) => void; 32 | onRefresh: () => void; // TODO implement 33 | } 34 | 35 | class PostListContainerComponent extends React.PureComponent { 36 | private static lastScrollPosition = 0; 37 | private static lastScrollSection: string | undefined; 38 | 39 | private scrollable: HTMLElement | undefined; 40 | 41 | public constructor(props: IPostListContainerComponentProps) { 42 | super(props); 43 | } 44 | 45 | public componentWillUnmount(): void { 46 | if (this.scrollable) { 47 | PostListContainerComponent.lastScrollPosition = this.scrollable.scrollTop; 48 | PostListContainerComponent.lastScrollSection = this.props.section + this.props.sortType; 49 | } 50 | } 51 | 52 | public render(): React.ReactElement | null { 53 | const { 54 | posts, 55 | section, 56 | sortType, 57 | lastUpdated, 58 | locationKnown, 59 | onPostClick, 60 | onAddClick, 61 | onLoadMore, 62 | } = this.props; 63 | return ( 64 |
65 | {section !== 'location' ? null : } 66 | 75 | {locationKnown ? : ''} 76 |
77 | 78 | 79 | 80 |
81 |
82 | ); 83 | } 84 | 85 | private connectScrollTarget = (target: HTMLElement) => { 86 | this.scrollable = target; 87 | if (!target) { 88 | return; 89 | } 90 | if ( 91 | PostListContainerComponent.lastScrollSection === 92 | this.props.section + this.props.sortType 93 | ) { 94 | target.scrollTop = PostListContainerComponent.lastScrollPosition; 95 | } else { 96 | PostListContainerComponent.lastScrollPosition = 0; 97 | } 98 | }; 99 | } 100 | 101 | const mapStateToProps = (state: IJodelAppStore) => { 102 | return { 103 | lastUpdated: selectedSectionLastUpdatedSelector(state) || undefined, 104 | locationKnown: isLocationKnownSelector(state), 105 | posts: selectedSectionSortPostsSelector(state), 106 | section: selectedSectionSelector(state), 107 | sortType: selectedSortTypeSelector(state), 108 | }; 109 | }; 110 | 111 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 112 | return { 113 | onAddClick(): void { 114 | dispatch(showAddPost(true)); 115 | }, 116 | onLoadMore(): void { 117 | dispatch(fetchMorePosts()); 118 | }, 119 | onPostClick(post: IPost): void { 120 | dispatch(selectPost(post != null ? post.post_id : null)); 121 | }, 122 | onRefresh(): void { 123 | dispatch(updatePosts()); 124 | }, 125 | }; 126 | }; 127 | 128 | export const PostListContainer = connect( 129 | mapStateToProps, 130 | mapDispatchToProps, 131 | )(PostListContainerComponent); 132 | -------------------------------------------------------------------------------- /src/views/PostListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { UserHandle } from '../enums/UserHandle'; 4 | import type { IPost } from '../interfaces/IPost'; 5 | 6 | import { Post } from './Post'; 7 | 8 | export class PostListItem extends React.PureComponent<{ 9 | post: IPost; 10 | parentPostId?: string; 11 | onPostClick: (post: IPost) => void; 12 | }> { 13 | public render(): React.ReactElement | null { 14 | const { post, parentPostId } = this.props; 15 | 16 | const author = 17 | parentPostId == null 18 | ? post.badge 19 | : post.user_handle === UserHandle.OJ 20 | ? 'OJ' 21 | : post.replier 22 | ? `C${post.replier}` 23 | : undefined; 24 | 25 | return ( 26 | 32 | ); 33 | } 34 | 35 | private onPostClick = () => { 36 | this.props.onPostClick(this.props.post); 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/views/PostTopBar.scss: -------------------------------------------------------------------------------- 1 | @import '../../style/settings.scss'; 2 | 3 | .post-top-bar { 4 | background-color: #eee; 5 | box-shadow: $bar-shadow; 6 | display: flex; 7 | flex: none; 8 | height: $post-top-bar-height; 9 | justify-content: space-between; 10 | width: 100%; 11 | z-index: $z-index-sub-layer; 12 | 13 | .right-buttons { 14 | display: flex; 15 | flex: none; 16 | } 17 | } 18 | 19 | .share { 20 | flex: none; 21 | } 22 | 23 | .share-button { 24 | cursor: pointer; 25 | display: inline-block; 26 | padding: 1em; 27 | 28 | &::before { 29 | content: 'Share'; 30 | } 31 | } 32 | 33 | .oj-filter { 34 | flex: none; 35 | } 36 | 37 | .oj-filter-button { 38 | cursor: pointer; 39 | display: inline-block; 40 | padding: 0.94em; 41 | 42 | &::before { 43 | content: 'OJ'; 44 | } 45 | 46 | &.oj-filtered { 47 | background-color: #ccc; 48 | } 49 | } 50 | 51 | .pin { 52 | flex: none; 53 | } 54 | 55 | .pin-button { 56 | cursor: pointer; 57 | display: inline-block; 58 | padding: 1em; 59 | 60 | &::before { 61 | content: 'Pin'; 62 | } 63 | 64 | &.pinned::before { 65 | content: 'Unpin'; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/views/PostTopBar.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | 5 | import type { IPost } from '../interfaces/IPost'; 6 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 7 | import { pin } from '../redux/actions'; 8 | import { ojFilterPost, sharePost } from '../redux/actions/api'; 9 | import type { IJodelAppStore } from '../redux/reducers'; 10 | import { selectedPostSelector } from '../redux/selectors/posts'; 11 | 12 | import BackButton from './BackButton'; 13 | import './PostTopBar.scss'; 14 | 15 | export interface IPostTopBarProps {} 16 | 17 | export interface IPostTopBarStateProps extends IPostTopBarProps { 18 | post: IPost | null; 19 | } 20 | 21 | export interface IPostTopBarComponentProps extends IPostTopBarStateProps { 22 | onBackClick: () => void; 23 | onPinClick: () => void; 24 | onShareClick: () => void; 25 | onOjFilterClick: () => void; 26 | } 27 | 28 | const PostTopBarComponent = (props: IPostTopBarComponentProps) => { 29 | const { onBackClick, onOjFilterClick, onPinClick, onShareClick, post } = props; 30 | if (!post) { 31 | return null; 32 | } 33 | const pinned = post.pinned && post.pinned; 34 | return ( 35 |
36 | 37 |
38 | {post.shareable && post.shareable ? ( 39 |
40 | {post.share_count && post.share_count > 0 ? post.share_count : ''} 41 |
42 |
43 | ) : ( 44 | '' 45 | )} 46 |
47 |
53 |
54 |
55 | {post.pin_count && post.pin_count > 0 ? post.pin_count : ''} 56 |
60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | const mapStateToProps = (state: IJodelAppStore) => { 67 | return { 68 | post: selectedPostSelector(state), 69 | }; 70 | }; 71 | 72 | const mapDispatchToProps = (dispatch: JodelThunkDispatch, ownProps: IPostTopBarStateProps) => { 73 | return { 74 | onBackClick: () => { 75 | window.history.back(); 76 | }, 77 | onOjFilterClick: () => { 78 | if (!ownProps.post) { 79 | return; 80 | } 81 | dispatch(ojFilterPost(ownProps.post.post_id, !ownProps.post.oj_filtered)); 82 | }, 83 | onPinClick: () => { 84 | if (!ownProps.post) { 85 | return; 86 | } 87 | const isPinned = ownProps.post.pinned && ownProps.post.pinned; 88 | dispatch(pin(ownProps.post.post_id, !isPinned)); 89 | }, 90 | onShareClick: () => { 91 | if (!ownProps.post) { 92 | return; 93 | } 94 | dispatch(sharePost(ownProps.post.post_id)); 95 | }, 96 | }; 97 | }; 98 | 99 | export const PostTopBar = connect(mapStateToProps)( 100 | connect(() => ({}), mapDispatchToProps)(PostTopBarComponent), 101 | ); 102 | -------------------------------------------------------------------------------- /src/views/Progress.tsx: -------------------------------------------------------------------------------- 1 | import NProgress from 'nprogress'; 2 | import 'nprogress/nprogress.css'; 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | 6 | import type { IJodelAppStore } from '../redux/reducers'; 7 | import { isSelectedSectionFetchingSelector } from '../redux/selectors/posts'; 8 | 9 | interface IProgressProps { 10 | isFetching: boolean; 11 | } 12 | 13 | class Progress extends React.PureComponent { 14 | private timer: number | null = null; 15 | 16 | public componentDidUpdate(prevProps: IProgressProps): void { 17 | if (prevProps.isFetching === this.props.isFetching) { 18 | return; 19 | } 20 | 21 | if (this.props.isFetching) { 22 | this.timer = window.setTimeout(() => NProgress.start(), 150); 23 | } else { 24 | if (this.timer) { 25 | clearTimeout(this.timer); 26 | } 27 | if (NProgress.isStarted()) { 28 | NProgress.done(); 29 | } 30 | } 31 | } 32 | 33 | public componentWillUnmount(): void { 34 | if (this.timer) { 35 | clearTimeout(this.timer); 36 | } 37 | if (NProgress.isStarted()) { 38 | NProgress.done(); 39 | } 40 | } 41 | 42 | public render(): React.ReactElement | null { 43 | return null; 44 | } 45 | } 46 | 47 | const mapStateToProps = (state: IJodelAppStore) => { 48 | return { 49 | isFetching: isSelectedSectionFetchingSelector(state), 50 | }; 51 | }; 52 | 53 | export default connect(mapStateToProps)(Progress); 54 | -------------------------------------------------------------------------------- /src/views/ScrollToBottomButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface IButtonProps { 4 | onClick: (e: React.MouseEvent) => void; 5 | } 6 | 7 | const ScrollToBottomButton = ({ onClick }: IButtonProps) => ( 8 |
9 | ); 10 | export default ScrollToBottomButton; 11 | -------------------------------------------------------------------------------- /src/views/Search.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 5 | import { searchPosts } from '../redux/actions/api'; 6 | import type { IJodelAppStore } from '../redux/reducers'; 7 | 8 | interface ISearchComponentProps { 9 | suggestedHashtags: readonly string[]; 10 | searchPosts: (message: string, suggested: boolean) => void; 11 | } 12 | 13 | interface ISearchComponentState { 14 | searchText: string; 15 | } 16 | 17 | class SearchComponent extends React.Component { 18 | constructor(props: ISearchComponentProps) { 19 | super(props); 20 | this.state = { 21 | searchText: '', 22 | }; 23 | } 24 | 25 | public render(): React.ReactElement | null { 26 | return ( 27 |
28 |
{ 31 | e.preventDefault(); 32 | this.props.searchPosts(this.state.searchText, false); 33 | }} 34 | > 35 | this.setState({ searchText: e.target.value })} 39 | /> 40 | 41 |
42 |
43 |
44 | {this.props.suggestedHashtags.map(hashtag => ( 45 |
#{hashtag}
46 | ))} 47 |
48 |
49 | ); 50 | } 51 | } 52 | 53 | const mapStateToProps = (state: IJodelAppStore) => { 54 | return { 55 | suggestedHashtags: state.account.suggestedHashtags, 56 | }; 57 | }; 58 | 59 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 60 | return { 61 | searchPosts: (message: string, suggested: boolean) => 62 | dispatch(searchPosts(message, suggested)), 63 | }; 64 | }; 65 | 66 | export default connect(mapStateToProps, mapDispatchToProps)(SearchComponent); 67 | -------------------------------------------------------------------------------- /src/views/SectionLink.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import { FormattedMessage } from 'react-intl'; 4 | import { connect } from 'react-redux'; 5 | 6 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 7 | import { switchPostSection } from '../redux/actions'; 8 | import type { IJodelAppStore } from '../redux/reducers'; 9 | import { selectedSectionSelector } from '../redux/selectors/view'; 10 | 11 | interface ISectionLinkProps { 12 | section: string; 13 | } 14 | 15 | interface ISectionLinkComponentProps extends ISectionLinkProps { 16 | active?: boolean; 17 | onClick?: () => void; 18 | } 19 | 20 | const SectionLinkComponent = ({ section, active, onClick }: ISectionLinkComponentProps) => { 21 | let name; 22 | switch (section) { 23 | case 'location': 24 | name = ; 25 | break; 26 | case 'mine': 27 | name = ; 28 | break; 29 | case 'mineReplies': 30 | name = ; 31 | break; 32 | case 'mineVotes': 33 | name = ; 34 | break; 35 | case 'minePinned': 36 | name = ; 37 | break; 38 | } 39 | return ( 40 |
44 | {name} 45 |
46 | ); 47 | }; 48 | 49 | const mapStateToProps = (state: IJodelAppStore, ownProps: ISectionLinkProps) => { 50 | return { 51 | active: ownProps.section === selectedSectionSelector(state), 52 | }; 53 | }; 54 | 55 | const mapDispatchToProps = (dispatch: JodelThunkDispatch, ownProps: ISectionLinkProps) => { 56 | return { 57 | onClick: () => { 58 | dispatch(switchPostSection(ownProps.section)); 59 | }, 60 | }; 61 | }; 62 | 63 | export const SectionLink = connect(mapStateToProps, mapDispatchToProps)(SectionLinkComponent); 64 | -------------------------------------------------------------------------------- /src/views/SelectDeviceUid.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage } from 'react-intl'; 3 | 4 | const CREATE_NEW = 'CREATE_NEW'; 5 | const USE_EXISTING = 'USE_EXISTING'; 6 | 7 | export interface ISelectDeviceUidProps { 8 | deviceUid: string | null; 9 | setDeviceUid: (deviceUid: string | null) => void; 10 | } 11 | 12 | export interface ISelectDeviceUidState { 13 | deviceUid: string; 14 | radioState: string; 15 | } 16 | 17 | export class SelectDeviceUid extends React.PureComponent< 18 | ISelectDeviceUidProps, 19 | ISelectDeviceUidState 20 | > { 21 | constructor(props: ISelectDeviceUidProps) { 22 | super(props); 23 | this.state = { 24 | deviceUid: !props.deviceUid ? '' : props.deviceUid, 25 | radioState: CREATE_NEW, 26 | }; 27 | } 28 | 29 | public render(): React.ReactElement | null { 30 | return ( 31 |
32 | 44 | 56 | {this.state.radioState === USE_EXISTING ? ( 57 | 77 | ) : ( 78 | '' 79 | )} 80 |
81 | ); 82 | } 83 | 84 | private handleChangeText = (event: React.ChangeEvent) => { 85 | this.setState({ deviceUid: event.target.value }); 86 | this.props.setDeviceUid(event.target.value); 87 | }; 88 | 89 | private handleChangeRadio = (event: React.ChangeEvent) => { 90 | switch (event.target.value) { 91 | case CREATE_NEW: 92 | this.props.setDeviceUid(null); 93 | break; 94 | case USE_EXISTING: 95 | this.props.setDeviceUid(this.state.deviceUid); 96 | break; 97 | } 98 | this.setState({ radioState: event.target.value }); 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/views/SelectLocation.scss: -------------------------------------------------------------------------------- 1 | .select-location { 2 | &_type { 3 | display: flex; 4 | flex-wrap: wrap; 5 | margin-bottom: 0.5rem; 6 | 7 | label { 8 | flex: none; 9 | margin-right: 1rem; 10 | white-space: nowrap; 11 | } 12 | } 13 | 14 | &_manual { 15 | label { 16 | display: block; 17 | } 18 | 19 | input { 20 | height: 2rem; 21 | } 22 | } 23 | 24 | a { 25 | cursor: pointer; 26 | text-decoration: underline; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/views/SelectLocation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage, FormattedNumber } from 'react-intl'; 3 | 4 | import type { IGeoCoordinates } from '../interfaces/ILocation'; 5 | import './SelectLocation.scss'; 6 | 7 | const USE_BROWSER_LOCATION = 'USE_BROWSER_LOCATION'; 8 | const MANUAL = 'MANUAL'; 9 | 10 | const LazyMapComponent = React.lazy(() => import('./SelectLocationMap')); 11 | 12 | export interface ISelectLocationProps { 13 | location: IGeoCoordinates | null; 14 | useBrowserLocation: boolean; 15 | onChange: (useBrowserLocation: boolean, location: IGeoCoordinates | null) => void; 16 | onLocationRequested: () => void; 17 | } 18 | 19 | export class SelectLocation extends React.PureComponent { 20 | constructor(props: ISelectLocationProps) { 21 | super(props); 22 | } 23 | 24 | public render(): React.ReactElement | null { 25 | const { location, useBrowserLocation, onLocationRequested } = this.props; 26 | return ( 27 |
28 |
29 | 41 | 53 |
54 | {useBrowserLocation ? ( 55 |
56 |

57 | 61 | :{' '} 62 | {!location ? ( 63 | '(Unbekannt)' 64 | ) : ( 65 | <> 66 | ,{' '} 67 | 68 | 69 | )} 70 |

71 | 72 | 76 | 77 |
78 | ) : ( 79 |
80 | ...
}> 81 | 85 | 86 | 97 | 108 |
109 | )} 110 | 111 | ); 112 | } 113 | 114 | private handleChangeLocation = (location: IGeoCoordinates) => { 115 | this.props.onChange(this.props.useBrowserLocation, location); 116 | }; 117 | 118 | private handleChangeLatitude = (event: React.ChangeEvent) => { 119 | const latitudeNumber = Number.parseFloat(event.target.value); 120 | if (isNaN(latitudeNumber) || latitudeNumber < -90 || latitudeNumber > 90) { 121 | return; 122 | } 123 | const longitude = this.props.location ? this.props.location.longitude : 0; 124 | this.props.onChange(this.props.useBrowserLocation, { latitude: latitudeNumber, longitude }); 125 | }; 126 | 127 | private handleChangeLongitude = (event: React.ChangeEvent) => { 128 | const longitudeNumber = Number.parseFloat(event.target.value.replace(',', '.')); 129 | if (isNaN(longitudeNumber) || longitudeNumber < -180 || longitudeNumber > 180) { 130 | return; 131 | } 132 | const latitude = this.props.location ? this.props.location.latitude : 0; 133 | this.props.onChange(this.props.useBrowserLocation, { 134 | latitude, 135 | longitude: longitudeNumber, 136 | }); 137 | }; 138 | 139 | private handleChangeRadio = (event: React.ChangeEvent) => { 140 | switch (event.target.value) { 141 | case USE_BROWSER_LOCATION: 142 | this.props.onChange(true, this.props.location); 143 | break; 144 | case MANUAL: 145 | this.props.onChange(false, this.props.location); 146 | break; 147 | } 148 | }; 149 | } 150 | -------------------------------------------------------------------------------- /src/views/SelectLocationMap.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { IGeoCoordinates } from '../interfaces/ILocation'; 4 | 5 | import MapComponent from './map/Map'; 6 | import MapCircleComponent from './map/MapCircle'; 7 | import MapMarkerComponent from './map/MapMarker'; 8 | 9 | interface ISelectLocationMapProps { 10 | location: IGeoCoordinates | null; 11 | onLocationChanged: (location: IGeoCoordinates) => void; 12 | } 13 | 14 | export default ({ location, onLocationChanged }: ISelectLocationMapProps) => ( 15 | 16 | {!location ? null : ( 17 | <> 18 | 22 | 23 | 24 | )} 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /src/views/ShareLink.scss: -------------------------------------------------------------------------------- 1 | @import '../../style/settings.scss'; 2 | 3 | .shareLink { 4 | display: flex; 5 | flex-direction: column; 6 | position: absolute; 7 | top: 5em; 8 | left: 5em; 9 | right: 5em; 10 | z-index: $z-index-overlay; 11 | background-color: gray; 12 | padding: 1em; 13 | 14 | .link { 15 | display: flex; 16 | margin-bottom: 1em; 17 | } 18 | 19 | .linkDisplay { 20 | flex: auto; 21 | min-width: 0; 22 | } 23 | 24 | .shareButton { 25 | flex: none; 26 | margin-left: 1em; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/views/ShareLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { IJodelAppStore } from '../redux/reducers'; 5 | import { shareLinkSelector } from '../redux/selectors/view'; 6 | import './ShareLink.scss'; 7 | 8 | interface IShareLinkProps { 9 | link: string | null; 10 | } 11 | 12 | function shareLink(link: string): void { 13 | navigator.share({ 14 | url: link, 15 | }); 16 | } 17 | 18 | function onClose(): void { 19 | window.history.back(); 20 | } 21 | 22 | function ShareLink({ link }: IShareLinkProps): React.ReactElement | null { 23 | if (!link) { 24 | return null; 25 | } 26 | return ( 27 |
28 |
29 | 30 | {!('share' in navigator) ? null : ( 31 | 34 | )} 35 |
36 | 37 |
38 | ); 39 | } 40 | 41 | const mapStateToProps = (state: IJodelAppStore) => { 42 | return { 43 | link: shareLinkSelector(state), 44 | }; 45 | }; 46 | 47 | export default connect(mapStateToProps)(ShareLink); 48 | -------------------------------------------------------------------------------- /src/views/SortTypeLink.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { PostListSortType } from '../enums/PostListSortType'; 6 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 7 | import { switchPostListSortType } from '../redux/actions'; 8 | import type { IJodelAppStore } from '../redux/reducers'; 9 | import { selectedSortTypeSelector } from '../redux/selectors/view'; 10 | 11 | interface ISortTypeLinkProps { 12 | sortType: PostListSortType; 13 | } 14 | 15 | interface ISortTypeLinkComponentProps extends ISortTypeLinkProps { 16 | active: boolean; 17 | sortType: PostListSortType; 18 | onClick: () => void; 19 | } 20 | 21 | const SortTypeLinkComponent = ({ sortType, active, onClick }: ISortTypeLinkComponentProps) => ( 22 |
33 | ); 34 | 35 | const mapStateToProps = (state: IJodelAppStore, ownProps: ISortTypeLinkProps) => { 36 | return { 37 | active: ownProps.sortType === selectedSortTypeSelector(state), 38 | }; 39 | }; 40 | 41 | const mapDispatchToProps = (dispatch: JodelThunkDispatch, ownProps: ISortTypeLinkProps) => { 42 | return { 43 | onClick: () => { 44 | dispatch(switchPostListSortType(ownProps.sortType)); 45 | }, 46 | }; 47 | }; 48 | 49 | export const SortTypeLink = connect(mapStateToProps, mapDispatchToProps)(SortTypeLinkComponent); 50 | -------------------------------------------------------------------------------- /src/views/Sticky.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { IApiSticky } from '../interfaces/IApiSticky'; 4 | 5 | import Message from './Message'; 6 | 7 | export interface IStickyProps { 8 | sticky: IApiSticky; 9 | onCloseClick: () => void; 10 | onLinkClick: (link: string) => void; 11 | onButtonClick: (title: string) => void; 12 | switchToHashtag: (hashtag: string) => void; 13 | switchToChannel: (channel: string) => void; 14 | } 15 | 16 | export class Sticky extends React.PureComponent { 17 | constructor(props: IStickyProps) { 18 | super(props); 19 | } 20 | 21 | public render(): React.ReactElement | null { 22 | const { 23 | sticky, 24 | onCloseClick, 25 | onLinkClick, 26 | onButtonClick, 27 | switchToChannel, 28 | switchToHashtag, 29 | } = this.props; 30 | const stickyLink = sticky.link; 31 | const stickyButtons = sticky.buttons; 32 | return ( 33 |
34 | 37 | { 40 | e.stopPropagation(); 41 | switchToChannel(channel); 42 | }} 43 | onHashtagClick={(e, hashtag) => { 44 | e.stopPropagation(); 45 | switchToHashtag(hashtag); 46 | }} 47 | /> 48 | {stickyLink ? ( 49 | onLinkClick(stickyLink)}> 50 | Linked Post 51 | 52 | ) : undefined} 53 | {stickyButtons ? ( 54 |
55 | {stickyButtons.map((button, index) => ( 56 | 63 | ))} 64 |
65 | ) : undefined} 66 |
{sticky.location_name}
67 |
68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/views/StickyList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { IApiSticky } from '../interfaces/IApiSticky'; 5 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 6 | import { selectPost, switchPostSection } from '../redux/actions'; 7 | import { closeSticky } from '../redux/actions/api'; 8 | import type { IJodelAppStore } from '../redux/reducers'; 9 | import { stickiesSelector } from '../redux/selectors/posts'; 10 | 11 | import { Sticky } from './Sticky'; 12 | 13 | export interface IStickyListProps { 14 | stickies: readonly IApiSticky[]; 15 | clickStickyClose: (sticky: string) => void; 16 | clickStickyLink: (postId: string) => void; 17 | clickStickyButton: (postId: string, buttonTitle: string) => void; 18 | switchToHashtag: (hashtag: string) => void; 19 | switchToChannel: (channel: string) => void; 20 | } 21 | 22 | class StickyListComponent extends React.Component { 23 | constructor(props: IStickyListProps) { 24 | super(props); 25 | } 26 | 27 | public render(): React.ReactElement | null { 28 | const { 29 | stickies, 30 | clickStickyClose, 31 | clickStickyLink, 32 | clickStickyButton, 33 | switchToHashtag, 34 | switchToChannel, 35 | } = this.props; 36 | const stickyNodes = stickies.map(sticky => { 37 | return ( 38 | clickStickyClose(sticky.stickypost_id)} 42 | onButtonClick={title => clickStickyButton(sticky.stickypost_id, title)} 43 | onLinkClick={link => clickStickyLink(link)} 44 | switchToHashtag={hashtag => switchToHashtag(hashtag)} 45 | switchToChannel={channel => switchToChannel(channel)} 46 | /> 47 | ); 48 | }); 49 | return
{stickyNodes}
; 50 | } 51 | } 52 | 53 | const mapStateToProps = (state: IJodelAppStore) => { 54 | return { 55 | stickies: stickiesSelector(state), 56 | }; 57 | }; 58 | 59 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 60 | return { 61 | clickStickyButton: (_postId: string, _buttonTitle: string) => { 62 | /* TODO implement */ 63 | }, 64 | clickStickyClose: (stickyId: string) => dispatch(closeSticky(stickyId)), 65 | clickStickyLink: (postId: string) => dispatch(selectPost(postId)), 66 | switchToChannel: (channel: string) => dispatch(switchPostSection('channel:' + channel)), 67 | switchToHashtag: (hashtag: string) => dispatch(switchPostSection('hashtag:' + hashtag)), 68 | }; 69 | }; 70 | 71 | export const StickyList = connect(mapStateToProps, mapDispatchToProps)(StickyListComponent); 72 | -------------------------------------------------------------------------------- /src/views/Time.scss: -------------------------------------------------------------------------------- 1 | .post-time { 2 | color: rgba(255, 255, 255, 0.5); 3 | float: left; 4 | font-size: 0.8em; 5 | width: 30%; 6 | } 7 | -------------------------------------------------------------------------------- /src/views/Time.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './Time.scss'; 4 | 5 | export interface ITimeProps { 6 | time: string; 7 | } 8 | 9 | export class Time extends React.Component { 10 | private timer?: number; 11 | 12 | public componentDidMount(): void { 13 | this.timer = window.setInterval(this.tick, 1000); 14 | } 15 | 16 | public componentWillUnmount(): void { 17 | if (this.timer) { 18 | clearInterval(this.timer); 19 | } 20 | this.timer = undefined; 21 | } 22 | 23 | public render(): React.ReactElement | null { 24 | const { time } = this.props; 25 | let diff = new Date().valueOf() - new Date(time).valueOf(); 26 | if (diff < 0) { 27 | // Future date shouldn't happen 28 | diff = 0; 29 | } 30 | let age; 31 | let timerInterval; 32 | const seconds = Math.trunc(diff / 1000); 33 | const minutes = Math.trunc(seconds / 60); 34 | const hours = Math.trunc(minutes / 60); 35 | const days = Math.trunc(hours / 24); 36 | if (days > 0) { 37 | age = `${days}d`; 38 | timerInterval = 1000 * 60 * 60; 39 | } else if (hours > 0) { 40 | age = `${hours}h`; 41 | timerInterval = 1000 * 60 * 15; 42 | } else if (minutes > 0) { 43 | age = `${minutes}min`; 44 | timerInterval = 1000 * 15; 45 | } else { 46 | age = `${seconds}s`; 47 | timerInterval = 1000; 48 | } 49 | if (this.timer) { 50 | clearInterval(this.timer); 51 | this.timer = window.setInterval(this.tick, timerInterval); 52 | } 53 | return ( 54 |
55 | {age} 56 |
57 | ); 58 | } 59 | 60 | private tick = () => { 61 | this.forceUpdate(); 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/views/Toast.scss: -------------------------------------------------------------------------------- 1 | @import '../../style/settings.scss'; 2 | 3 | .toast { 4 | box-shadow: $toast-shadow; 5 | cursor: pointer; 6 | left: 0; 7 | margin: 10px 20px; 8 | position: absolute; 9 | right: 0; 10 | top: 0; 11 | 12 | &-message { 13 | padding: 10px; 14 | } 15 | 16 | &-error { 17 | background-color: red; 18 | } 19 | 20 | &-warning { 21 | background-color: orange; 22 | } 23 | 24 | &-info { 25 | background-color: white; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/views/Toast.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | 4 | import { ToastType } from '../enums/ToastType'; 5 | import type { IToast } from '../interfaces/IToast'; 6 | import './Toast.scss'; 7 | 8 | export interface IToastProps { 9 | toast: IToast; 10 | onClick: (toastId: number) => void; 11 | } 12 | 13 | export const Toast = ({ toast, onClick }: IToastProps) => { 14 | return ( 15 |
onClick(toast.id)} 22 | > 23 |
{toast.message}
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/views/ToastContainer.scss: -------------------------------------------------------------------------------- 1 | @import '../../style/settings.scss'; 2 | 3 | .toast-container { 4 | position: relative; 5 | z-index: $z-index-toast-layer; 6 | } 7 | -------------------------------------------------------------------------------- /src/views/ToastContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import type { IToast } from '../interfaces/IToast'; 5 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 6 | import { hideToast } from '../redux/actions/toasts.actions'; 7 | import type { IJodelAppStore } from '../redux/reducers'; 8 | import { toastsSelector } from '../redux/selectors/app'; 9 | 10 | import { Toast } from './Toast'; 11 | import './ToastContainer.scss'; 12 | 13 | interface IToastContainerComponentProps { 14 | toasts: readonly IToast[]; 15 | onToastClick: (toastId: number) => void; 16 | } 17 | 18 | const ToastContainerComponent = ({ toasts, onToastClick }: IToastContainerComponentProps) => { 19 | return ( 20 |
21 | {toasts.map(toast => ( 22 | 23 | ))} 24 |
25 | ); 26 | }; 27 | 28 | const mapStateToProps = (state: IJodelAppStore) => { 29 | return { 30 | toasts: toastsSelector(state), 31 | }; 32 | }; 33 | 34 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 35 | return { 36 | onToastClick: (toastId: number) => { 37 | dispatch(hideToast(toastId)); 38 | }, 39 | }; 40 | }; 41 | 42 | export const ToastContainer = connect(mapStateToProps, mapDispatchToProps)(ToastContainerComponent); 43 | -------------------------------------------------------------------------------- /src/views/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage, FormattedNumber } from 'react-intl'; 3 | import { connect } from 'react-redux'; 4 | 5 | import type { JodelThunkDispatch } from '../interfaces/JodelThunkAction'; 6 | import { showChannelList, showSettings } from '../redux/actions'; 7 | import type { IJodelAppStore } from '../redux/reducers'; 8 | import { karmaSelector } from '../redux/selectors/app'; 9 | 10 | import { Menu } from './Menu'; 11 | import { SectionLink } from './SectionLink'; 12 | 13 | interface ITopBarComponentProps { 14 | karma: number; 15 | onKarmaClick: () => void; 16 | onChannelsClick: () => void; 17 | } 18 | 19 | function TopBarComponent({ 20 | karma, 21 | onKarmaClick, 22 | onChannelsClick, 23 | }: ITopBarComponentProps): React.ReactElement | null { 24 | return ( 25 |
26 | 27 |
28 | 29 |
30 | 31 |
32 |
33 |
34 | {karma > 0 ? '+' : ''} 35 | 36 |
37 | 38 |
39 |
40 |
41 | ); 42 | } 43 | 44 | const mapStateToProps = (state: IJodelAppStore) => { 45 | return { 46 | karma: karmaSelector(state), 47 | }; 48 | }; 49 | 50 | const mapDispatchToProps = (dispatch: JodelThunkDispatch) => { 51 | return { 52 | onKarmaClick(): void { 53 | dispatch(showSettings(true)); 54 | }, 55 | onChannelsClick(): void { 56 | dispatch(showChannelList(true)); 57 | }, 58 | }; 59 | }; 60 | 61 | export const TopBar = connect(mapStateToProps, mapDispatchToProps)(TopBarComponent); 62 | -------------------------------------------------------------------------------- /src/views/Vote.scss: -------------------------------------------------------------------------------- 1 | .post-vote { 2 | float: left; 3 | text-align: center; 4 | width: 10%; 5 | 6 | &_up-vote { 7 | cursor: pointer; 8 | padding-bottom: 0.5em; 9 | padding-top: 0.5em; 10 | width: 3em; 11 | 12 | &::before { 13 | background: url('../../img/up.svg') no-repeat; 14 | background-size: 1.5em auto; 15 | content: ''; 16 | display: block; 17 | height: 1em; 18 | margin-left: 0.75em; 19 | } 20 | 21 | &.post-vote_down { 22 | visibility: hidden; 23 | } 24 | 25 | &.post-vote_up { 26 | cursor: inherit; 27 | opacity: 0.5; 28 | } 29 | } 30 | 31 | &_count { 32 | width: 3em; 33 | } 34 | 35 | &_down-vote { 36 | cursor: pointer; 37 | padding-bottom: 0.5em; 38 | padding-top: 0.5em; 39 | width: 3em; 40 | 41 | &::before { 42 | background: url('../../img/down.svg') no-repeat; 43 | background-size: 1.5em auto; 44 | content: ''; 45 | display: block; 46 | height: 1em; 47 | margin-left: 0.75em; 48 | } 49 | 50 | &.post-vote_up { 51 | visibility: hidden; 52 | } 53 | 54 | &.post-vote_down { 55 | cursor: inherit; 56 | opacity: 0.5; 57 | } 58 | } 59 | 60 | &_just-voted { 61 | animation: 400ms voting; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/views/Vote.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import React from 'react'; 3 | 4 | import type { VoteType } from '../enums/VoteType'; 5 | import './Vote.scss'; 6 | 7 | export interface IVoteProps { 8 | vote_count: number; 9 | voted: VoteType; 10 | upvote: (e: React.MouseEvent) => void; 11 | downvote: (e: React.MouseEvent) => void; 12 | } 13 | 14 | interface IVoteState { 15 | justDownVoted: boolean; 16 | justUpVoted: boolean; 17 | } 18 | 19 | export default class Vote extends React.Component { 20 | public state = { 21 | justDownVoted: false, 22 | justUpVoted: false, 23 | }; 24 | 25 | public render(): React.ReactElement | null { 26 | const { vote_count, voted, upvote, downvote } = this.props; 27 | const { justUpVoted, justDownVoted } = this.state; 28 | return ( 29 |
30 |
{ 36 | this.setState({ justUpVoted: true }); 37 | upvote(e); 38 | }} 39 | /> 40 |
{vote_count}
41 |
{ 47 | this.setState({ justDownVoted: true }); 48 | downvote(e); 49 | }} 50 | /> 51 |
52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/views/map/Map.scss: -------------------------------------------------------------------------------- 1 | .map-root { 2 | height: 200px; 3 | } 4 | -------------------------------------------------------------------------------- /src/views/map/Map.tsx: -------------------------------------------------------------------------------- 1 | import type { Map as LeafletMap } from 'leaflet'; 2 | import { map, tileLayer } from 'leaflet'; 3 | import 'leaflet/dist/leaflet.css'; 4 | import React from 'react'; 5 | 6 | import type { IGeoCoordinates } from '../../interfaces/ILocation'; 7 | 8 | import { LeafletMapContext } from './map-utils'; 9 | import './Map.scss'; 10 | 11 | interface IMapComponentProps extends React.PropsWithChildren { 12 | location: IGeoCoordinates | null; 13 | } 14 | 15 | interface IMapComponentState { 16 | map: LeafletMap | undefined; 17 | } 18 | 19 | export default class MapComponent extends React.Component { 20 | public state: IMapComponentState = { 21 | map: undefined, 22 | }; 23 | 24 | private mapElement = React.createRef(); 25 | 26 | public componentDidMount(): void { 27 | const leafletMap = map(this.mapElement.current!); 28 | if (this.props.location) { 29 | leafletMap.setView( 30 | { 31 | lat: this.props.location.latitude, 32 | lng: this.props.location.longitude, 33 | }, 34 | 13, 35 | ); 36 | } 37 | 38 | const tiles = tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 39 | attribution: 40 | '\xa9 OpenStreetMap' + 41 | ' contributors', 42 | maxZoom: 18, 43 | }); 44 | tiles.addTo(leafletMap); 45 | 46 | this.setState({ 47 | map: leafletMap, 48 | }); 49 | } 50 | 51 | public render(): React.ReactElement | null { 52 | return ( 53 |
54 | 55 | {this.props.children} 56 | 57 |
58 | ); 59 | } 60 | 61 | public componentDidUpdate(): void { 62 | const leafletMap = this.state.map; 63 | if (leafletMap && this.props.location) { 64 | leafletMap.panTo({ 65 | lat: this.props.location.latitude, 66 | lng: this.props.location.longitude, 67 | }); 68 | } 69 | } 70 | 71 | public componentWillUnmount(): void { 72 | const leafletMap = this.state.map; 73 | if (leafletMap) { 74 | leafletMap.remove(); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/views/map/MapCircle.tsx: -------------------------------------------------------------------------------- 1 | import type { Circle, Map as LeafletMap } from 'leaflet'; 2 | import { circle } from 'leaflet'; 3 | import React from 'react'; 4 | 5 | import type { IGeoCoordinates } from '../../interfaces/ILocation'; 6 | 7 | import { withLeafletMap } from './map-utils'; 8 | 9 | interface IMapCircleProps { 10 | map: LeafletMap; 11 | location: IGeoCoordinates; 12 | radius: number; 13 | } 14 | 15 | class MapCircleComponent extends React.PureComponent { 16 | private circleShape: Circle | undefined; 17 | 18 | public componentDidMount(): void { 19 | const { location, radius } = this.props; 20 | this.circleShape = circle( 21 | { 22 | lat: location.latitude, 23 | lng: location.longitude, 24 | }, 25 | { 26 | radius, 27 | }, 28 | ); 29 | this.circleShape.addTo(this.props.map); 30 | } 31 | 32 | public componentDidUpdate(): void { 33 | const { location, radius } = this.props; 34 | this.circleShape!.setLatLng({ 35 | lat: location.latitude, 36 | lng: location.longitude, 37 | }); 38 | this.circleShape!.setRadius(radius); 39 | } 40 | 41 | public componentWillUnmount(): void { 42 | this.circleShape!.remove(); 43 | } 44 | 45 | public render(): React.ReactElement | null { 46 | return null; 47 | } 48 | } 49 | 50 | export default withLeafletMap()(MapCircleComponent); 51 | -------------------------------------------------------------------------------- /src/views/map/MapMarker.scss: -------------------------------------------------------------------------------- 1 | .map-marker-icon { 2 | background-image: url('../../../node_modules/leaflet/dist/images/marker-icon.png'); 3 | width: 25px; 4 | height: 41px; 5 | } 6 | -------------------------------------------------------------------------------- /src/views/map/MapMarker.tsx: -------------------------------------------------------------------------------- 1 | import type { Map as LeafletMap, Marker } from 'leaflet'; 2 | import { divIcon, marker } from 'leaflet'; 3 | import React from 'react'; 4 | 5 | import type { IGeoCoordinates } from '../../interfaces/ILocation'; 6 | 7 | import { withLeafletMap } from './map-utils'; 8 | import './MapMarker.scss'; 9 | 10 | interface IMapMarkerProps { 11 | map: LeafletMap; 12 | location: IGeoCoordinates; 13 | onMarkerMoved: (location: IGeoCoordinates) => void; 14 | } 15 | 16 | class MapMarkerComponent extends React.PureComponent { 17 | private locationMarker: Marker | undefined; 18 | 19 | public componentDidMount(): void { 20 | this.locationMarker = marker( 21 | { 22 | lat: this.props.location.latitude, 23 | lng: this.props.location.longitude, 24 | }, 25 | { 26 | draggable: true, 27 | icon: divIcon({ 28 | className: 'map-marker-icon', 29 | iconAnchor: [12, 41], 30 | iconSize: [25, 41], 31 | popupAnchor: [1, -34], 32 | shadowSize: [41, 41], 33 | tooltipAnchor: [16, -28], 34 | }), 35 | }, 36 | ); 37 | this.locationMarker.on('dragend', () => { 38 | const loc = this.locationMarker!.getLatLng(); 39 | this.props.onMarkerMoved({ latitude: loc.lat, longitude: loc.lng }); 40 | }); 41 | this.locationMarker.addTo(this.props.map); 42 | } 43 | 44 | public componentDidUpdate(): void { 45 | this.locationMarker!.setLatLng({ 46 | lat: this.props.location.latitude, 47 | lng: this.props.location.longitude, 48 | }); 49 | } 50 | 51 | public componentWillUnmount(): void { 52 | this.locationMarker!.remove(); 53 | } 54 | 55 | public render(): React.ReactElement | null { 56 | return null; 57 | } 58 | } 59 | 60 | export default withLeafletMap()(MapMarkerComponent); 61 | -------------------------------------------------------------------------------- /src/views/map/map-utils.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | import type { Map as LeafletMap } from 'leaflet'; 6 | import React from 'react'; 7 | 8 | export const LeafletMapContext = React.createContext(undefined); 9 | 10 | interface IWithMapProps { 11 | map: LeafletMap; 12 | } 13 | 14 | /** Exclude from object O all keys that are also in object E 15 | */ 16 | type ObjectExclude = { [K in Exclude]: O[K] }; 17 | 18 | type BaseComponentWrappedProps = IWithMapProps & React.ClassAttributes; 19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 20 | type OuterComponentProps = ObjectExclude; 21 | 22 | export const withLeafletMap = 23 | () => 24 |

( 25 | Component: React.ComponentType

, 26 | ): React.ComponentType< 27 | React.ClassAttributes & OuterComponentProps 28 | > => { 29 | const forwardRef: React.ForwardRefRenderFunction< 30 | typeof Component, 31 | OuterComponentProps 32 | > = (innerProps, ref) => { 33 | const ComponentAny = Component as any; 34 | return ( 35 | 36 | {map => 37 | !map ? null : ( 38 | 39 | ) 40 | } 41 | 42 | ) as any; 43 | }; 44 | 45 | const name = Component.displayName || Component.name; 46 | forwardRef.displayName = `withLeafletMap(${name})`; 47 | 48 | return React.forwardRef>( 49 | forwardRef, 50 | ) as any; 51 | }; 52 | -------------------------------------------------------------------------------- /style/settings.scss: -------------------------------------------------------------------------------- 1 | $z-index-overlay: 200; 2 | $z-index-toast-layer: 150; 3 | $z-index-menu-layer: 100; 4 | $z-index-top-layer: 100; 5 | $z-index-sub-layer: 80; 6 | $top-bar-height: 3em; 7 | $post-top-bar-height: 3em; 8 | $channel-top-bar-height: 3em; 9 | $bottom-bar-height: 3em; 10 | $bar-shadow: 0 0 6px 3px rgb(0 0 0 / 16%), 0 0 6px 3px rgb(0 0 0 / 23%); 11 | $menu-shadow: 0 0 6px rgb(0 0 0 / 16%), 0 0 6px rgb(0 0 0 / 23%); 12 | $toast-shadow: 0 0 6px rgb(0 0 0 / 16%), 0 0 6px rgb(0 0 0 / 23%); 13 | $button-shadow: 0 5px 5px rgb(0 0 0 / 16%); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "strict": true, 6 | "noImplicitReturns": true, 7 | "noUnusedLocals": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "importHelpers": true, 10 | "downlevelIteration": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "module": "esNext", 14 | "moduleResolution": "node", 15 | "target": "es6", 16 | "lib": ["es2017", "dom"], 17 | "jsx": "react", 18 | "types": ["webpack-env"], 19 | "baseUrl": ".", 20 | "paths": { 21 | "*": ["typings/*"] 22 | } 23 | }, 24 | "include": ["./src/**/*"], 25 | "exclude": ["./src/sw.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /typings/react-document-title/index.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export = DocumentTitle; 4 | 5 | declare namespace DocumentTitle { 6 | interface IDocumentTitleProps extends React.PropsWithChildren { 7 | title: string; 8 | } 9 | } 10 | 11 | declare class DocumentTitle extends React.Component {} 12 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 5 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | const TerserPlugin = require('terser-webpack-plugin'); 7 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 8 | const { InjectManifest } = require('workbox-webpack-plugin'); 9 | 10 | module.exports = function (env, argv) { 11 | const isProduction = argv.mode === 'production'; 12 | const createSourceMaps = !isProduction; 13 | 14 | return { 15 | output: { 16 | filename: isProduction ? '[name].[contenthash].js' : '[name].js', 17 | path: __dirname + '/dist', 18 | publicPath: isProduction ? '' : undefined, 19 | }, 20 | 21 | // Enable sourcemaps for debugging webpack's output. 22 | devtool: createSourceMaps && 'source-map', 23 | 24 | resolve: { 25 | // Add '.ts' and '.tsx' as resolvable extensions. 26 | extensions: ['.ts', '.tsx', '.js', '.json'], 27 | mainFields: ['browser', 'module', 'jsnext:main', 'main'], 28 | }, 29 | 30 | module: { 31 | rules: [ 32 | // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'. 33 | { 34 | test: /\.tsx?$/, 35 | loader: 'ts-loader', 36 | type: isProduction ? 'javascript/esm' : 'javascript/auto', // hotloading needs commonjs enabled 37 | }, 38 | // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'. 39 | { 40 | enforce: 'pre', 41 | test: /\.js$/, 42 | loader: 'source-map-loader', 43 | }, 44 | { 45 | test: /\.(png|jpg|gif|svg)$/, 46 | type: 'asset', 47 | }, 48 | { 49 | test: /\.s?css$/, 50 | use: [ 51 | isProduction ? MiniCssExtractPlugin.loader : 'style-loader', 52 | { loader: 'css-loader', options: { sourceMap: createSourceMaps } }, 53 | { 54 | loader: 'postcss-loader', 55 | options: { 56 | postcssOptions: { 57 | plugins: [require.resolve('autoprefixer')], 58 | }, 59 | sourceMap: createSourceMaps, 60 | }, 61 | }, 62 | { loader: 'sass-loader', options: { sourceMap: createSourceMaps } }, 63 | ], 64 | }, 65 | { 66 | test: /\.html$/, 67 | use: [ 68 | { 69 | loader: 'html-loader', 70 | options: { minimize: isProduction }, 71 | }, 72 | ], 73 | }, 74 | ], 75 | }, 76 | plugins: [ 77 | new HtmlWebpackPlugin({ 78 | template: 'src/index.html', 79 | }), 80 | new CopyWebpackPlugin({ 81 | patterns: ['src/manifest.webmanifest'], 82 | }), 83 | ...(isProduction 84 | ? [ 85 | new CleanWebpackPlugin(), 86 | new MiniCssExtractPlugin({ 87 | filename: isProduction ? '[name].[contenthash].css' : '[name].css', 88 | }), 89 | new InjectManifest({ 90 | swSrc: './src/sw.ts', 91 | swDest: 'sw.js', 92 | }), 93 | ] 94 | : []), 95 | ], 96 | optimization: { 97 | splitChunks: { 98 | chunks: 'all', 99 | }, 100 | runtimeChunk: true, 101 | minimizer: [ 102 | new TerserPlugin({ 103 | parallel: true, 104 | terserOptions: { 105 | sourceMap: createSourceMaps, 106 | }, 107 | }), 108 | new CssMinimizerPlugin(), 109 | ], 110 | }, 111 | 112 | devServer: { 113 | static: false, 114 | }, 115 | }; 116 | }; 117 | --------------------------------------------------------------------------------