├── .editorconfig ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public ├── icon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-circle-16x16.png │ ├── favicon-circle-32x32.png │ ├── favicon-circle.ico │ ├── favicon.ico │ ├── icon-circle-1000.png │ ├── icon-nobg.png │ ├── icon-square-1000.png │ ├── icon.svg │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ └── safari-pinned-tab.svg ├── index.html └── manifest.json ├── src ├── components │ ├── AddFeed │ │ ├── Preview │ │ │ └── index.js │ │ ├── Results │ │ │ └── index.js │ │ ├── Search │ │ │ └── index.js │ │ ├── index.js │ │ └── style.js │ ├── AppBar │ │ ├── MarkChannelRead.js │ │ ├── QuickNote.js │ │ ├── SettingsMenu.js │ │ ├── Title.js │ │ ├── index.js │ │ └── style.js │ ├── AppSettings │ │ ├── SyndicationSettings.js │ │ ├── index.js │ │ └── style.js │ ├── Auth │ │ └── index.js │ ├── AuthorAvatar │ │ └── index.js │ ├── ChannelMenu │ │ ├── ChannelMenuItem.js │ │ ├── NewChannelForm.js │ │ ├── index.js │ │ └── style.js │ ├── ChannelSettings │ │ ├── Blocked.js │ │ ├── ChannelSettingUrl.js │ │ ├── Following.js │ │ ├── Muted.js │ │ ├── index.js │ │ └── style.js │ ├── Donate │ │ └── index.js │ ├── ErrorBoundary │ │ ├── index.js │ │ └── style.js │ ├── GallerySlider │ │ ├── index.js │ │ └── style.js │ ├── GlobalShotcuts │ │ └── index.js │ ├── Home │ │ ├── index.js │ │ └── style.js │ ├── LandingPage │ │ ├── ExampleApp │ │ │ ├── example-data.js │ │ │ ├── index.js │ │ │ └── style.js │ │ ├── index.js │ │ └── style.js │ ├── Layout │ │ ├── Classic │ │ │ ├── Preview │ │ │ │ ├── index.js │ │ │ │ └── style.js │ │ │ ├── index.js │ │ │ └── style.js │ │ ├── Content.js │ │ ├── Gallery │ │ │ ├── index.js │ │ │ └── style.js │ │ ├── Map │ │ │ └── index.js │ │ ├── NoContent.js │ │ ├── Shortcuts.js │ │ ├── Timeline │ │ │ ├── index.js │ │ │ └── style.js │ │ ├── index.js │ │ └── style.js │ ├── LayoutSwitcher │ │ ├── index.js │ │ └── style.js │ ├── Login │ │ └── index.js │ ├── Map │ │ ├── Marker │ │ │ ├── index.js │ │ │ └── style.js │ │ └── index.js │ ├── Meta.js │ ├── MicropubEditorFull │ │ ├── index.js │ │ └── style.js │ ├── MicropubForm │ │ ├── index.js │ │ └── style.js │ ├── MicrosubNotifications │ │ ├── OpenButton.js │ │ ├── TitleBar.js │ │ ├── index.js │ │ └── style.js │ ├── Post │ │ ├── Actions │ │ │ ├── Base.js │ │ │ ├── Block.js │ │ │ ├── ConsoleLog.js │ │ │ ├── Like.js │ │ │ ├── MarkRead.js │ │ │ ├── MicropubDelete.js │ │ │ ├── MicropubUndelete.js │ │ │ ├── MicropubUpdate.js │ │ │ ├── Mute.js │ │ │ ├── Refetch.js │ │ │ ├── Remove.js │ │ │ ├── Reply.js │ │ │ ├── Repost.js │ │ │ ├── View.js │ │ │ ├── index.js │ │ │ └── style.js │ │ ├── Content │ │ │ ├── TruncatedContentLoader.js │ │ │ ├── index.js │ │ │ └── style.js │ │ ├── Header │ │ │ └── index.js │ │ ├── Location │ │ │ └── index.js │ │ ├── Meta.js │ │ ├── Photos │ │ │ ├── index.js │ │ │ └── style.js │ │ ├── ReplyContext │ │ │ ├── index.js │ │ │ └── style.js │ │ ├── Shortcuts.js │ │ ├── index.js │ │ └── style.js │ ├── ServiceWorker.js │ ├── SettingsModal │ │ ├── index.js │ │ └── style.js │ ├── Share │ │ └── index.js │ ├── ShortcutHelp │ │ ├── ShortcutTable.js │ │ └── index.js │ ├── SnackbarActions │ │ ├── Link.js │ │ └── Undo.js │ ├── Source │ │ └── index.js │ ├── TestMe │ │ ├── Tabs.js │ │ └── index.js │ └── Theme │ │ ├── index.js │ │ └── style.js ├── containers │ ├── App.js │ ├── Routes.js │ └── style.js ├── hooks │ ├── use-channels.js │ ├── use-current-channel.js │ ├── use-local-state.js │ ├── use-mark-channel-read.js │ ├── use-mark-read.js │ ├── use-mark-unread.js │ ├── use-micropub-create.js │ ├── use-micropub-query.js │ ├── use-micropub-update.js │ ├── use-timeline.js │ └── use-user.js ├── index.js ├── modules │ ├── apollo.js │ ├── author-to-avatar-data.js │ ├── get-image-proxy-url.js │ ├── keymap.js │ ├── layouts.js │ ├── load-user.js │ └── windows-functions.js ├── queries │ └── index.js ├── service-worker.js └── serviceWorkerRegistration.js ├── together-logo.png └── together-slides.pdf /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://pay.grant.codes/monthly/$5', 'https://pay.grant.codes'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something went wrong? Add a bug report! 4 | title: '' 5 | labels: "\U0001F41B bug" 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected Behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Browser(please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **More Info** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: "\U0001F4A1 enhancement" 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional info** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | 20 | data -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Jonathan LaCour, Grant Richmond. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![together](together-logo.png) 2 | 3 | The together project is an [IndieWeb](http://indieweb.org) 4 | environment for reading, discovering, and interacting with content. You might 5 | call it a [reader](http://indieweb.org/reader). 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | --- 27 | 28 | Together is a [React](https://facebook.github.io/react/) based application. To 29 | use it, you'll need a website that supports 30 | [Micropub](https://indieweb.org/Micropub), 31 | [IndieAuth](https://indieweb.org/IndieAuth) and 32 | [Microsub](https://indieweb.org/Microsub). 33 | 34 | ## Running locally in development mode 35 | 36 | You'll need `node` and `npm` installed. 37 | Once you have them, you can simply check out the repository and run `npm install`, followed 38 | by `npm run start`. The server part runs on port 3001 by default 39 | and a hot reloading frontend is available on port 3000 (ideal for frontend development) 40 | 41 | ## Running locally in production mode 42 | 43 | First, generate a production package: 44 | 45 | - `npm run build` 46 | 47 | Then, run the production package at port 8000: 48 | 49 | - `/usr/bin/node server --port 8000` 50 | 51 | --- 52 | 53 | Want to join in and get involved? Open some issues or submit PRs! 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "together", 3 | "version": "3.11.1", 4 | "private": true, 5 | "engines": { 6 | "node": "18" 7 | }, 8 | "devDependencies": { 9 | "react-scripts": "^4.0.3" 10 | }, 11 | "dependencies": { 12 | "@material-ui/core": "^4.11.0", 13 | "@material-ui/icons": "^4.9.1", 14 | "@material-ui/styles": "^4.10.0", 15 | "@rehooks/component-size": "^1.0.3", 16 | "@researchgate/react-intersection-list": "^3.0.10", 17 | "apollo-cache-inmemory": "^1.6.6", 18 | "apollo-client": "^2.6.10", 19 | "apollo-link-error": "^1.1.13", 20 | "apollo-link-http": "^1.5.17", 21 | "apollo-link-ws": "^1.0.20", 22 | "faker": "^4.1.0", 23 | "graphql": "^14.7.0", 24 | "graphql-tag": "^2.11.0", 25 | "html-react-parser": "^0.10.1", 26 | "install": "^0.13.0", 27 | "intersection-observer": "^0.7.0", 28 | "lodash": "^4.17.19", 29 | "micropub-client-editor": "^1.0.7", 30 | "moment": "^2.27.0", 31 | "notistack": "^0.8.9", 32 | "nuka-carousel": "^4.7.0", 33 | "pigeon-maps": "^0.13.0", 34 | "pigeon-overlay": "^0.2.3", 35 | "prop-types": "^15.7.2", 36 | "react": "^16.13.1", 37 | "react-apollo": "^2.5.6", 38 | "react-apollo-hooks": "^0.4.5", 39 | "react-dom": "^16.13.1", 40 | "react-helmet": "^5.2.1", 41 | "react-intersection-observer": "^8.26.2", 42 | "react-list": "^0.8.11", 43 | "react-router-dom": "^5.2.0", 44 | "react-shortcuts": "^2.1.0", 45 | "react-sortable-hoc": "^1.11.0", 46 | "subscriptions-transport-ws": "^0.9.16", 47 | "url-search-params-polyfill": "^6.0.0", 48 | "use-react-router": "^1.0.7", 49 | "viewport-mercator-project": "^6.1.1" 50 | }, 51 | "scripts": { 52 | "start": "NODE_OPTIONS='--openssl-legacy-provider' react-scripts start", 53 | "build": "NODE_OPTIONS='--openssl-legacy-provider' react-scripts build", 54 | "test": "NODE_OPTIONS='--openssl-legacy-provider' react-scripts test --env=jsdom", 55 | "eject": "NODE_OPTIONS='--openssl-legacy-provider' react-scripts eject" 56 | }, 57 | "prettier": { 58 | "singleQuote": true, 59 | "trailingComma": "es5", 60 | "semi": false 61 | }, 62 | "browserslist": [ 63 | ">0.2%", 64 | "not dead", 65 | "not ie <= 11", 66 | "not op_mini all" 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /public/icon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/icon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/icon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/favicon-16x16.png -------------------------------------------------------------------------------- /public/icon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/favicon-32x32.png -------------------------------------------------------------------------------- /public/icon/favicon-circle-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/favicon-circle-16x16.png -------------------------------------------------------------------------------- /public/icon/favicon-circle-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/favicon-circle-32x32.png -------------------------------------------------------------------------------- /public/icon/favicon-circle.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/favicon-circle.ico -------------------------------------------------------------------------------- /public/icon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/favicon.ico -------------------------------------------------------------------------------- /public/icon/icon-circle-1000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/icon-circle-1000.png -------------------------------------------------------------------------------- /public/icon/icon-nobg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/icon-nobg.png -------------------------------------------------------------------------------- /public/icon/icon-square-1000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/icon-square-1000.png -------------------------------------------------------------------------------- /public/icon/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/icon/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/mstile-144x144.png -------------------------------------------------------------------------------- /public/icon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/mstile-150x150.png -------------------------------------------------------------------------------- /public/icon/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/mstile-310x150.png -------------------------------------------------------------------------------- /public/icon/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/mstile-310x310.png -------------------------------------------------------------------------------- /public/icon/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/public/icon/mstile-70x70.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Together 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 |
36 | 46 |
47 | 48 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Together", 3 | "name": "Together", 4 | "description": "An IndieWeb powered Microsub reader", 5 | "icons": [ 6 | { 7 | "src": "/icon/android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/icon/android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "start_url": "index.html", 18 | "display": "standalone", 19 | "theme_color": "#104ebf", 20 | "background_color": "#104ebf", 21 | "orientation": "portrait-primary", 22 | "share_target": { 23 | "action": "/share/", 24 | "method": "GET", 25 | "enctype": "application/x-www-form-urlencoded", 26 | "params": { 27 | "title": "title", 28 | "text": "text", 29 | "url": "url" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/AddFeed/Preview/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { useQuery, useMutation } from 'react-apollo-hooks' 5 | import useReactRouter from 'use-react-router' 6 | import { Button, ListItem, ListItemText } from '@material-ui/core' 7 | import Post from '../../Post' 8 | import { PREVIEW, FOLLOW } from '../../../queries' 9 | import styles from '../style' 10 | 11 | const Preview = ({ classes, url, setActions, setLoading, handleClose }) => { 12 | const [hasSetActions, setHasSetActions] = useState(false) 13 | 14 | const { 15 | match: { 16 | params: { channelSlug }, 17 | }, 18 | } = useReactRouter() 19 | 20 | const { 21 | data: { preview: items }, 22 | loading, 23 | } = useQuery(PREVIEW, { variables: { url } }) 24 | 25 | const channel = channelSlug ? decodeURIComponent(channelSlug) : null 26 | const follow = useMutation(FOLLOW, { 27 | variables: { 28 | channel, 29 | url, 30 | }, 31 | }) 32 | 33 | const handleFollow = async () => { 34 | try { 35 | await follow() 36 | handleClose() 37 | setActions(null) 38 | } catch (err) { 39 | console.log('[Error following]', err) 40 | } 41 | } 42 | 43 | setLoading(loading ? 'Loading preview' : false) 44 | if (loading) { 45 | return null 46 | } 47 | 48 | if (!hasSetActions && setActions) { 49 | setHasSetActions(true) 50 | setActions(actions => [ 51 | , 54 | ...actions, 55 | ]) 56 | } 57 | 58 | if (!items || !items.length) { 59 | return ( 60 | 61 | 65 | 66 | ) 67 | } 68 | 69 | return ( 70 | 71 | {Array.isArray(items) && 72 | items.map((item, i) => ( 73 | 78 | ))} 79 | 80 | ) 81 | } 82 | 83 | Preview.propTypes = { 84 | url: PropTypes.string.isRequired, 85 | } 86 | 87 | export default withStyles(styles)(Preview) 88 | -------------------------------------------------------------------------------- /src/components/AddFeed/Results/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, Fragment } from 'react' 2 | import { useQuery } from 'react-apollo-hooks' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { 5 | Avatar, 6 | List, 7 | ListItem, 8 | ListItemText, 9 | ListItemAvatar, 10 | } from '@material-ui/core' 11 | import Preview from '../Preview' 12 | import { SEARCH } from '../../../queries' 13 | import styles from '../style' 14 | 15 | const Results = ({ classes, query, setActions, setLoading, handleClose }) => { 16 | const [preview, setPreview] = useState(null) 17 | const { 18 | loading, 19 | data: { search: results }, 20 | error, 21 | } = useQuery(SEARCH, { variables: { query } }) 22 | 23 | setLoading(loading ? 'Finding feeds' : false) 24 | if (loading) { 25 | return null 26 | } 27 | 28 | if (error || results.length === 0) { 29 | return ( 30 | 31 | 32 | 33 | ) 34 | } 35 | 36 | return ( 37 | 38 | {results.map((result, i) => ( 39 | 40 | { 45 | setActions(null) 46 | setPreview(preview === result.url ? null : result.url) 47 | }} 48 | className={ 49 | classes.result + (preview === result.url ? ' is-selected' : '') 50 | } 51 | > 52 | 53 | 54 | {result.photo 55 | ? null 56 | : result.url 57 | .replace('https://', '') 58 | .replace('http://', '') 59 | .replace('www.', '')[0]} 60 | 61 | 62 | 63 | 71 | 72 | {preview === result.url && ( 73 | { 78 | setPreview(null) 79 | handleClose() 80 | }} 81 | /> 82 | )} 83 | 84 | ))} 85 | 86 | ) 87 | } 88 | 89 | export default withStyles(styles)(Results) 90 | -------------------------------------------------------------------------------- /src/components/AddFeed/Search/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import PropTypes from 'prop-types' 4 | import { TextField, IconButton, InputAdornment } from '@material-ui/core' 5 | import SearchIcon from '@material-ui/icons/Search' 6 | import styles from '../style' 7 | 8 | const Search = ({ handleSearch, classes }) => { 9 | const [search, setSearch] = useState('') 10 | 11 | const doSearch = e => { 12 | e.preventDefault() 13 | if (search) { 14 | handleSearch(search) 15 | } 16 | } 17 | 18 | return ( 19 |
20 | setSearch(e.target.value)} 28 | InputProps={{ 29 | endAdornment: ( 30 | 31 | 37 | 38 | 39 | 40 | ), 41 | }} 42 | /> 43 | 44 | ) 45 | } 46 | 47 | Search.propTypes = { 48 | handleSearch: PropTypes.func.isRequired, 49 | } 50 | 51 | export default withStyles(styles)(Search) 52 | -------------------------------------------------------------------------------- /src/components/AddFeed/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import useReactRouter from 'use-react-router' 4 | import { 5 | Card, 6 | CardActions, 7 | Fab, 8 | Button, 9 | LinearProgress, 10 | } from '@material-ui/core' 11 | import AddIcon from '@material-ui/icons/Add' 12 | import Search from './Search' 13 | import Results from './Results' 14 | import styles from './style' 15 | 16 | const AddFeed = ({ classes }) => { 17 | const [open, setOpen] = useState(false) 18 | const [search, setSearch] = useState('') 19 | const [loading, setLoading] = useState(false) 20 | 21 | const defaultActions = [ 22 | , 32 | ] 33 | const [actions, setActions] = useState(defaultActions) 34 | if (!actions) { 35 | setActions(defaultActions) 36 | } 37 | 38 | const { 39 | match: { 40 | params: { channelSlug }, 41 | }, 42 | } = useReactRouter() 43 | const currentChannel = channelSlug ? decodeURIComponent(channelSlug) : null 44 | 45 | const handleCancel = e => { 46 | if (e && e.preventDefault) { 47 | e.preventDefault() 48 | } 49 | setOpen(false) 50 | setSearch(false) 51 | setActions(null) 52 | return false 53 | } 54 | 55 | const handleSearch = async query => { 56 | setSearch(query) 57 | return false 58 | } 59 | 60 | if (!currentChannel) { 61 | return null 62 | } 63 | 64 | return ( 65 |
66 | {!!open && ( 67 | <> 68 |
69 | 70 | {!!loading && } 71 | 72 | {!!search && ( 73 | 79 | )} 80 | {actions} 81 | 82 | 83 | )} 84 | 85 | setOpen(true)} 90 | > 91 | 92 | 93 |
94 | ) 95 | } 96 | 97 | AddFeed.defaultProps = { 98 | currentChannel: false, 99 | } 100 | 101 | export default withStyles(styles)(AddFeed) 102 | -------------------------------------------------------------------------------- /src/components/AddFeed/style.js: -------------------------------------------------------------------------------- 1 | const smallVertical = '@media (max-height: 600px)' 2 | 3 | export default theme => ({ 4 | container: { 5 | position: 'fixed', 6 | display: 'flex', 7 | flexDirection: 'column', 8 | alignItems: 'flex-end', 9 | right: 0, 10 | bottom: 0, 11 | padding: theme.spacing(2), 12 | paddingTop: 0, 13 | maxWidth: 'calc(100vw - 90px)', 14 | maxHeight: '100vh', 15 | zIndex: theme.zIndex.appBar, 16 | '&.is-open': { 17 | [smallVertical]: { 18 | zIndex: theme.zIndex.modal + 1, 19 | }, 20 | }, 21 | }, 22 | card: { 23 | display: 'flex', 24 | flexDirection: 'column', 25 | width: 360, 26 | flexGrow: 1, 27 | maxWidth: '100%', 28 | overflow: 'hidden', 29 | marginTop: theme.spacing(2), 30 | marginBottom: theme.spacing(2), 31 | }, 32 | cardInner: { 33 | position: 'relative', 34 | flexGrow: 1, 35 | flexShrink: 1, 36 | overflow: 'auto', 37 | paddingTop: 0, 38 | paddingBottom: 0, 39 | }, 40 | toolbarSpacer: { 41 | ...theme.mixins.toolbar, 42 | [smallVertical]: { 43 | display: 'none', 44 | }, 45 | }, 46 | fabButton: { 47 | flexShrink: 0, 48 | }, 49 | searchForm: { 50 | flexShrink: 0, 51 | borderBottom: '1px solid ' + theme.palette.divider, 52 | padding: theme.spacing(2), 53 | }, 54 | result: { 55 | '&.is-selected': { 56 | position: 'sticky', 57 | top: 0, 58 | width: '100%', 59 | zIndex: 1, 60 | backgroundColor: theme.palette.primary.main, 61 | color: theme.palette.primary.contrastText, 62 | }, 63 | }, 64 | resultText: { 65 | display: 'block', 66 | whiteSpace: 'nowrap', 67 | overflow: 'hidden', 68 | textOverflow: 'ellipsis', 69 | }, 70 | preview: { 71 | zIndex: 0, 72 | padding: 0, 73 | flexDirection: 'column', 74 | }, 75 | actions: { 76 | flexShrink: 0, 77 | boxSizing: 'border-box', 78 | borderTop: '1px solid ' + theme.palette.divider, 79 | justifyContent: 'flex-end', 80 | }, 81 | }) 82 | -------------------------------------------------------------------------------- /src/components/AppBar/MarkChannelRead.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IconButton, Tooltip } from '@material-ui/core' 3 | import ReadIcon from '@material-ui/icons/DoneAll' 4 | import useCurrentChannel from '../../hooks/use-current-channel' 5 | import useMarkChannelRead from '../../hooks/use-mark-channel-read' 6 | 7 | const MarkChannelRead = ({ classes }) => { 8 | const channel = useCurrentChannel() 9 | const markChannelRead = useMarkChannelRead() 10 | 11 | if (channel.unread === 0 || !channel.uid) { 12 | return null 13 | } 14 | 15 | return ( 16 | 17 | markChannelRead(channel.uid)} 20 | className={classes.menuAction} 21 | > 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | export default MarkChannelRead 29 | -------------------------------------------------------------------------------- /src/components/AppBar/QuickNote.js: -------------------------------------------------------------------------------- 1 | import React, { useState, Fragment } from 'react' 2 | import { useMutation } from 'react-apollo-hooks' 3 | import { useSnackbar } from 'notistack' 4 | import { withStyles } from '@material-ui/core/styles' 5 | import { Popover, IconButton, Tooltip, LinearProgress } from '@material-ui/core' 6 | import NoteIcon from '@material-ui/icons/Edit' 7 | import useUser from '../../hooks/use-user' 8 | import MicropubForm from '../MicropubForm' 9 | import styles from './style' 10 | import SnackbarLinkAction from '../SnackbarActions/Link' 11 | import SnackbarUndoAction from '../SnackbarActions/Undo' 12 | import { MICROPUB_CREATE, MICROPUB_DELETE } from '../../queries' 13 | import { defaultMakeCacheKey } from 'optimism' 14 | 15 | const QuickNote = ({ classes }) => { 16 | const { user } = useUser() 17 | const [popoverAnchor, setPopoverAnchor] = useState(null) 18 | const [loading, setLoading] = useState(false) 19 | const [defaultProperties, setDefaultProperties] = useState({}) 20 | const { enqueueSnackbar, closeSnackbar } = useSnackbar() 21 | const createNote = useMutation(MICROPUB_CREATE) 22 | const deleteMicropub = useMutation(MICROPUB_DELETE) 23 | 24 | const supportsMicropub = user && user.hasMicropub 25 | if (!supportsMicropub) { 26 | return null 27 | } 28 | 29 | const handleSubmit = async mf2 => { 30 | const originalAnchor = popoverAnchor 31 | const properties = mf2.properties 32 | setLoading(true) 33 | try { 34 | const { 35 | data: { micropubCreate: postUrl }, 36 | } = await createNote({ 37 | variables: { 38 | json: JSON.stringify(mf2), 39 | }, 40 | }) 41 | setLoading(false) 42 | enqueueSnackbar('Posted note', { 43 | variant: 'success', 44 | action: key => [ 45 | , 46 | { 48 | closeSnackbar(key) 49 | await deleteMicropub({ variables: { url: postUrl } }) 50 | enqueueSnackbar('Deleted post', { variant: 'success' }) 51 | setDefaultProperties(properties) 52 | setPopoverAnchor(originalAnchor) 53 | }} 54 | />, 55 | ], 56 | }) 57 | } catch (err) { 58 | setLoading(false) 59 | console.error('Error posting note', err) 60 | enqueueSnackbar('Error posting note', { variant: 'error' }) 61 | } 62 | setPopoverAnchor(null) 63 | } 64 | 65 | if ( 66 | user && 67 | user.settings.noteSyndication.length && 68 | !defaultProperties['mp-syndicate-to'] 69 | ) { 70 | setDefaultProperties({ 71 | ...defaultProperties, 72 | 'mp-syndicate-to': user.settings.noteSyndication, 73 | }) 74 | } 75 | 76 | return ( 77 | 78 | 79 | setPopoverAnchor(e.target)} 82 | className={classes.menuAction} 83 | > 84 | 85 | 86 | 87 | setPopoverAnchor(null)} 95 | onBackdropClick={() => setPopoverAnchor(null)} 96 | > 97 |
102 | setPopoverAnchor(null)} 106 | /> 107 |
108 | {loading && } 109 |
110 |
111 | ) 112 | } 113 | 114 | export default withStyles(styles)(QuickNote) 115 | -------------------------------------------------------------------------------- /src/components/AppBar/SettingsMenu.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { useApolloClient } from 'react-apollo-hooks' 4 | import { IconButton, Menu, MenuItem, Tooltip, Avatar } from '@material-ui/core' 5 | import SettingsIcon from '@material-ui/icons/Settings' 6 | import useCurrentChannel from '../../hooks/use-current-channel' 7 | import useLocalState from '../../hooks/use-local-state' 8 | import useUser from '../../hooks/use-user' 9 | import LayoutSwitcher from '../LayoutSwitcher' 10 | import getImageProxyUrl from '../../modules/get-image-proxy-url' 11 | import { version } from '../../../package.json' 12 | 13 | const SettingsMenu = ({ classes }) => { 14 | const client = useApolloClient() 15 | const [anchorEl, setAnchorEl] = useState(null) 16 | const [localState, setLocalState] = useLocalState() 17 | const { user } = useUser() 18 | const channel = useCurrentChannel() 19 | 20 | const logout = e => { 21 | window.localStorage.clear() 22 | client.resetStore() 23 | window.location.href = '/' 24 | } 25 | 26 | return ( 27 | 28 | 29 | {user && user.photo ? ( 30 | setAnchorEl(e.currentTarget)} 34 | className={classes.menuAvatar} 35 | /> 36 | ) : ( 37 | setAnchorEl(e.currentTarget)} 39 | className={classes.menuAction} 40 | > 41 | 42 | 43 | )} 44 | 45 | 46 | setAnchorEl(null)} 58 | > 59 | {!!channel._t_slug && ( 60 | 61 | Channel Settings 62 | 63 | )} 64 | {user && user.hasMicropub && ( 65 | 66 | My Posts 67 | 68 | )} 69 | 70 | App Settings 71 | 72 | 74 | setLocalState({ 75 | theme: localState.theme === 'light' ? 'dark' : 'light', 76 | }) 77 | } 78 | > 79 | {localState.theme === 'light' ? 'Dark' : 'Light'} Mode 80 | 81 | 82 | Donate 83 | 84 | Logout 85 | Version {version} 86 | 87 | 88 | 89 | ) 90 | } 91 | 92 | export default SettingsMenu 93 | -------------------------------------------------------------------------------- /src/components/AppBar/Title.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import { Typography } from '@material-ui/core' 3 | import useCurrentChannel from '../../hooks/use-current-channel' 4 | import Meta from '../Meta' 5 | 6 | const AppBarTitle = ({ className }) => { 7 | const channel = useCurrentChannel() 8 | let title = 'Together' 9 | let metaTitle = '' 10 | if (channel.name) { 11 | metaTitle = channel.name 12 | if (channel.unread) { 13 | metaTitle += ` (${channel.unread})` 14 | } 15 | } 16 | if (metaTitle) { 17 | title = metaTitle 18 | } 19 | 20 | return ( 21 | 22 | 23 | 24 | {title === 'Together' && ( 25 | Together icon 35 | )} 36 | 37 | 43 | {title} 44 | 45 | 46 | ) 47 | } 48 | 49 | export default AppBarTitle 50 | -------------------------------------------------------------------------------- /src/components/AppBar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { AppBar, Toolbar, IconButton, Tooltip } from '@material-ui/core' 4 | import ChannelsIcon from '@material-ui/icons/Menu' 5 | import useLocalState from '../../hooks/use-local-state' 6 | import MicrosubNotifications from '../MicrosubNotifications' 7 | import QuickNote from './QuickNote' 8 | import AppBarTitle from './Title' 9 | import SettingsMenu from './SettingsMenu' 10 | import MarkChannelRead from './MarkChannelRead' 11 | import styles from './style' 12 | 13 | const TogetherAppBar = ({ classes }) => { 14 | const [localState, setLocalState] = useLocalState() 15 | const toggleChannelsMenu = e => 16 | setLocalState({ channelsMenuOpen: !localState.channelsMenuOpen }) 17 | 18 | // TODO: Don't really love this solution, feels a bit hacky 19 | const rootClasses = [classes.root] 20 | if (localState.channelsMenuOpen) { 21 | rootClasses.push(classes.rootAboveDrawer) 22 | } 23 | 24 | return ( 25 | 26 | 27 | 28 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | export default withStyles(styles)(TogetherAppBar) 52 | -------------------------------------------------------------------------------- /src/components/AppBar/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | root: { 3 | width: '100%', 4 | zIndex: theme.zIndex.drawer + 1, 5 | background: 6 | theme.palette.type === 'dark' 7 | ? theme.palette.secondary.dark 8 | : theme.palette.primary.main, 9 | }, 10 | rootAboveDrawer: { 11 | zIndex: theme.zIndex.modal + 1, 12 | }, 13 | title: { 14 | flex: 1, 15 | fontWeight: 'normal', 16 | overflow: 'hidden', 17 | textOverflow: 'ellipsis', 18 | whiteSpace: 'nowrap', 19 | }, 20 | menuButton: { 21 | display: 'none', 22 | [theme.breakpoints.down('sm')]: { 23 | display: 'block', 24 | marginLeft: -16, 25 | marginRight: 0, 26 | }, 27 | }, 28 | menuAction: { 29 | color: theme.palette.primary.contrastText, 30 | }, 31 | menuAvatar: { 32 | color: theme.palette.primary.contrastText, 33 | marginLeft: 7, 34 | cursor: 'pointer', 35 | [theme.breakpoints.down('sm')]: { 36 | width: 34, 37 | height: 34, 38 | }, 39 | }, 40 | menuItem: { 41 | display: 'block', 42 | outline: 'none', 43 | textDecoration: 'none', 44 | color: theme.palette.text.primary, 45 | }, 46 | layoutSwitcher: { 47 | flexDirection: 'row', 48 | backgroundColor: theme.palette.primary.main, 49 | color: theme.palette.primary.dark, 50 | marginBottom: -8, 51 | marginTop: 8, 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /src/components/AppSettings/SyndicationSettings.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useMicropubQuery from '../../hooks/use-micropub-query' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { 5 | List, 6 | ListItem, 7 | ListSubheader, 8 | ListItemText, 9 | ListItemSecondaryAction, 10 | Button, 11 | Switch, 12 | LinearProgress, 13 | } from '@material-ui/core' 14 | import useUser from '../../hooks/use-user' 15 | import styles from './style' 16 | 17 | const SyndicationSettings = ({ classes }) => { 18 | const { data, error, refetch, networkStatus } = useMicropubQuery( 19 | 'syndicate-to', 20 | { notifyOnNetworkStatusChange: true } 21 | ) 22 | const { user, setOption } = useUser() 23 | 24 | const syndicationProviders = 25 | data && data['syndicate-to'] ? data['syndicate-to'] : false 26 | 27 | const toggleProvider = (name, provider) => { 28 | const syndication = user.settings[name] 29 | const index = syndication.findIndex(s => s === provider) 30 | if (index > -1) { 31 | syndication.splice(index, 1) 32 | } else { 33 | syndication.push(provider) 34 | } 35 | setOption(name, syndication) 36 | } 37 | 38 | if (error) { 39 | return null 40 | } 41 | 42 | if (networkStatus < 7 || !user) { 43 | return ( 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | const SyndicationSet = ({ title, settingKey }) => ( 51 | <> 52 | {title} 53 | {syndicationProviders.map(provider => { 54 | const handleChange = () => toggleProvider(settingKey, provider.uid) 55 | return ( 56 | 61 | {provider.name} 62 | 63 | 71 | 72 | 73 | ) 74 | })} 75 | 76 | ) 77 | 78 | if (syndicationProviders) { 79 | return ( 80 | 81 | 82 | 86 | 87 | 88 | Update Providers 89 | 90 | 98 | 99 | 100 | ) 101 | } 102 | 103 | return null 104 | } 105 | 106 | export default withStyles(styles)(SyndicationSettings) 107 | -------------------------------------------------------------------------------- /src/components/AppSettings/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import SettingsModal from '../SettingsModal' 4 | import SyndicationSettings from './SyndicationSettings' 5 | import styles from './style' 6 | 7 | const Settings = ({ classes }) => { 8 | return ( 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | export default withStyles(styles)(Settings) 16 | -------------------------------------------------------------------------------- /src/components/AppSettings/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | fieldset: { 3 | width: '100%', 4 | maxWidth: '24em', 5 | }, 6 | divider: { 7 | marginTop: 24, 8 | marginBottom: 24, 9 | }, 10 | close: { 11 | position: 'absolute', 12 | top: 0, 13 | right: 0, 14 | zIndex: 10, 15 | '&:hover button': { 16 | color: theme.palette.primary['900'], 17 | }, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/Auth/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import gql from 'graphql-tag' 3 | import { useMutation, useApolloClient } from 'react-apollo-hooks' 4 | import { useSnackbar } from 'notistack' 5 | import { Redirect } from 'react-router-dom' 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogContentText, 10 | DialogTitle, 11 | LinearProgress, 12 | } from '@material-ui/core' 13 | 14 | const LOGIN = gql` 15 | mutation login($code: String!, $state: String!) { 16 | login(code: $code, state: $state) { 17 | token 18 | } 19 | } 20 | ` 21 | 22 | const Auth = props => { 23 | const [status, setStatus] = useState(null) 24 | const [loading, setLoading] = useState(false) 25 | const login = useMutation(LOGIN) 26 | const { enqueueSnackbar } = useSnackbar() 27 | const client = useApolloClient() 28 | 29 | const handleLogin = async () => { 30 | setLoading(true) 31 | try { 32 | const urlParams = new URLSearchParams(window.location.search) 33 | const code = urlParams.get('code') 34 | const state = urlParams.get('state') 35 | const { data, errors } = await login({ variables: { code, state } }) 36 | if (errors) { 37 | console.error('[Error logging in]', errors) 38 | enqueueSnackbar('Error logging in', { variant: 'error' }) 39 | setStatus(false) 40 | } 41 | if (data && data.login && data.login.token) { 42 | const jwt = data.login.token 43 | enqueueSnackbar(`Welcome to Together`) 44 | client.resetStore() 45 | localStorage.setItem('token', jwt) 46 | window.location.href = '/' 47 | // setStatus(true) 48 | } 49 | } catch (err) { 50 | console.error('[Error logging in]', err) 51 | enqueueSnackbar('Error logging in', { variant: 'error' }) 52 | setStatus(false) 53 | } 54 | setLoading(false) 55 | } 56 | 57 | useEffect(() => { 58 | handleLogin() 59 | }, []) 60 | 61 | if (status === true) { 62 | return 63 | } 64 | 65 | if (status === false) { 66 | return 67 | } 68 | 69 | return ( 70 | {}}> 71 | {loading ? : null} 72 | Finalizing 73 | 74 | Finalizing login... 75 | 76 | 77 | ) 78 | } 79 | 80 | export default Auth 81 | -------------------------------------------------------------------------------- /src/components/AuthorAvatar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Avatar from '@material-ui/core/Avatar' 4 | import authorToAvatarData from '../../modules/author-to-avatar-data' 5 | 6 | const AuthorAvatar = ({ author, size }) => { 7 | const avatarData = authorToAvatarData(author) 8 | let style = { 9 | background: avatarData.color, 10 | } 11 | if (size) { 12 | style.width = size 13 | style.height = size 14 | } 15 | return ( 16 | 22 | 23 | {avatarData.src ? null : avatarData.initials} 24 | 25 | 26 | ) 27 | } 28 | 29 | AuthorAvatar.propTypes = { 30 | author: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, 31 | size: PropTypes.number, 32 | } 33 | 34 | export default AuthorAvatar 35 | -------------------------------------------------------------------------------- /src/components/ChannelMenu/ChannelMenuItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Link } from 'react-router-dom' 4 | import { ListItem, ListItemText, Badge } from '@material-ui/core' 5 | import { SortableElement } from 'react-sortable-hoc' 6 | import useLocalState from '../../hooks/use-local-state' 7 | import styles from './style' 8 | 9 | const ChannelMenuItem = ({ 10 | classes, 11 | channel, 12 | isFocused, 13 | current, 14 | ...props 15 | }) => { 16 | const [localState, setLocalState] = useLocalState() 17 | 18 | return ( 19 | { 25 | setLocalState({ channelsMenuOpen: false }) 26 | return true 27 | }} 28 | style={{ justifyContent: 'space-between' }} 29 | selected={!current && isFocused} 30 | {...props} 31 | > 32 | 36 | {(channel.unread || channel.unread === null) && ( 37 | 43 | )} 44 | 45 | ) 46 | } 47 | 48 | const Sortable = SortableElement(withStyles(styles)(ChannelMenuItem)) 49 | export { Sortable } 50 | export default withStyles(styles)(ChannelMenuItem) 51 | -------------------------------------------------------------------------------- /src/components/ChannelMenu/NewChannelForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { useMutation } from 'react-apollo-hooks' 4 | import useReactRouter from 'use-react-router' 5 | import { useSnackbar } from 'notistack' 6 | import { ListItem, ListItemText, TextField, Button } from '@material-ui/core' 7 | import AddIcon from '@material-ui/icons/Add' 8 | import { ADD_CHANNEL, GET_CHANNELS } from '../../queries' 9 | 10 | const NewChannelForm = ({ classes }) => { 11 | const [open, setOpen] = useState(false) 12 | const [name, setName] = useState('') 13 | const { history } = useReactRouter() 14 | const { enqueueSnackbar } = useSnackbar() 15 | 16 | const addChannel = useMutation(ADD_CHANNEL) 17 | 18 | const handleAddChannel = async e => { 19 | e.preventDefault() 20 | const { 21 | error, 22 | data: { addChannel: channel }, 23 | } = await addChannel({ 24 | variables: { name }, 25 | update: (proxy, { data: { addChannel: channel } }) => { 26 | // Read the data from our cache for this query. 27 | const data = proxy.readQuery({ 28 | query: GET_CHANNELS, 29 | }) 30 | // Add the new channel to the cache 31 | data.channels.unshift(channel) 32 | // Write our data back to the cache. 33 | proxy.writeQuery({ query: GET_CHANNELS, data }) 34 | }, 35 | }) 36 | if (error) { 37 | console.error('[Error adding channel]', error) 38 | enqueueSnackbar('Error adding channel', { variant: 'error' }) 39 | } else if (channel) { 40 | enqueueSnackbar(`Added channel ${channel.name}`, { variant: 'success' }) 41 | setName('') 42 | history.push(`/channel/${channel._t_slug}`) 43 | } 44 | setOpen(false) 45 | return false 46 | } 47 | 48 | if (!open) { 49 | return ( 50 | setOpen(true)} button> 51 | } 55 | /> 56 | 57 | ) 58 | } 59 | 60 | return ( 61 |
62 | setName(e.target.value)} 69 | /> 70 | 78 | 79 | ) 80 | } 81 | 82 | NewChannelForm.defaultProps = {} 83 | 84 | NewChannelForm.propTypes = { 85 | classes: PropTypes.object, 86 | } 87 | 88 | export default NewChannelForm 89 | -------------------------------------------------------------------------------- /src/components/ChannelMenu/style.js: -------------------------------------------------------------------------------- 1 | import { darken } from '@material-ui/core/styles/colorManipulator' 2 | 3 | export default theme => { 4 | const dark = theme.palette.type === 'dark' 5 | const highlight = theme.palette.primary.main 6 | const background = darken(theme.palette.background.default, dark ? 0.5 : 0.1) 7 | 8 | return { 9 | drawer: { 10 | width: theme.together.drawerWidth, 11 | flexShrink: 0, 12 | }, 13 | drawerPaper: { 14 | width: theme.together.drawerWidth, 15 | }, 16 | shortcuts: { 17 | display: 'flex', 18 | flexDirection: 'column', 19 | flexGrow: 1, 20 | outline: 'none', 21 | '&:focus, &.is-focused': { 22 | boxShadow: `0 0 4px inset ${highlight}`, 23 | }, 24 | }, 25 | toolbarSpacer: theme.mixins.toolbar, 26 | currentItem: { 27 | color: theme.palette.primary.contrastText, 28 | backgroundColor: theme.palette.primary.main, 29 | }, 30 | addButton: { 31 | display: 'block', 32 | textAlign: 'center', 33 | color: theme.palette.primary.main, 34 | }, 35 | addForm: { 36 | borderTop: '1px solid ' + theme.palette.divider, 37 | padding: theme.spacing(1), 38 | paddingBottom: 0, 39 | }, 40 | channelTextRoot: { 41 | overflow: 'hidden', 42 | textOverflow: 'ellipsis', 43 | whiteSpace: 'nowrap', 44 | }, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/ChannelSettings/Blocked.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { useQuery, useMutation } from 'react-apollo-hooks' 4 | import { GET_BLOCKED, UNBLOCK } from '../../queries' 5 | import { withStyles } from '@material-ui/core/styles' 6 | import { 7 | ListSubheader, 8 | ListItem, 9 | ListItemText, 10 | LinearProgress, 11 | } from '@material-ui/core' 12 | import ChannelSettingUrl from './ChannelSettingUrl' 13 | import styles from './style' 14 | 15 | const ChannelBlocked = ({ classes, channel }) => { 16 | const { 17 | data: { blocked }, 18 | loading, 19 | refetch, 20 | } = useQuery(GET_BLOCKED, { 21 | variables: { channel }, 22 | }) 23 | 24 | useEffect(() => { 25 | if (!loading && refetch) { 26 | refetch() 27 | } 28 | }, []) 29 | 30 | const unblockMutation = useMutation(UNBLOCK) 31 | 32 | const unblock = url => 33 | unblockMutation({ 34 | variables: { channel, url }, 35 | optimisticResponse: { 36 | __typename: 'Mutation', 37 | mute: true, 38 | }, 39 | update: (proxy, _) => { 40 | // Read the data from our cache for this query. 41 | const data = proxy.readQuery({ 42 | query: GET_BLOCKED, 43 | variables: { channel }, 44 | }) 45 | // Find and remove posts with the given author url 46 | data.blocked = data.blocked.filter(item => item.url !== url) 47 | // Write our data back to the cache. 48 | proxy.writeQuery({ query: GET_BLOCKED, variables: { channel }, data }) 49 | }, 50 | }) 51 | 52 | return ( 53 | <> 54 | Blocked 55 | 56 | {!!loading && ( 57 | 58 | 59 | 60 | )} 61 | 62 | {!!blocked && 63 | blocked.map(item => ( 64 | unblock(item.url)} 68 | onRemoveLabel={`Unblock ${item.url}`} 69 | /> 70 | ))} 71 | 72 | {!loading && (!blocked || blocked.length === 0) && ( 73 | 74 | No blocked urls in this channel 75 | 76 | )} 77 | 78 | ) 79 | } 80 | 81 | ChannelBlocked.propTypes = { 82 | channel: PropTypes.string, 83 | } 84 | 85 | export default withStyles(styles)(ChannelBlocked) 86 | -------------------------------------------------------------------------------- /src/components/ChannelSettings/ChannelSettingUrl.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { 5 | Link, 6 | ListItem, 7 | ListItemText, 8 | ListItemSecondaryAction, 9 | IconButton, 10 | } from '@material-ui/core' 11 | import CloseIcon from '@material-ui/icons/Close' 12 | import styles from './style' 13 | 14 | const ChannelSettingsUrl = ({ 15 | classes, 16 | url, 17 | name, 18 | type, 19 | onRemove, 20 | onRemoveLabel, 21 | }) => { 22 | return ( 23 | 24 | 28 | 34 | {name} 35 | 36 | 37 | 38 | {!!onRemove && ( 39 | 40 | 41 | 42 | 43 | 44 | )} 45 | 46 | ) 47 | } 48 | 49 | ChannelSettingsUrl.propTypes = { 50 | url: PropTypes.string.isRequired, 51 | type: PropTypes.string, 52 | onRemove: PropTypes.func, 53 | onRemoveLabel: PropTypes.string, 54 | } 55 | 56 | export default withStyles(styles)(ChannelSettingsUrl) 57 | -------------------------------------------------------------------------------- /src/components/ChannelSettings/Following.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { useQuery, useMutation } from 'react-apollo-hooks' 4 | import { GET_FOLLOWING, UNFOLLOW } from '../../queries' 5 | import { withStyles } from '@material-ui/core/styles' 6 | import { 7 | ListSubheader, 8 | ListItem, 9 | ListItemText, 10 | LinearProgress, 11 | } from '@material-ui/core' 12 | import ChannelSettingUrl from './ChannelSettingUrl' 13 | import styles from './style' 14 | 15 | const ChannelFollowing = ({ classes, channel }) => { 16 | const { 17 | data: { following }, 18 | loading, 19 | refetch, 20 | } = useQuery(GET_FOLLOWING, { 21 | variables: { channel }, 22 | }) 23 | 24 | useEffect(() => { 25 | if (!loading && refetch) { 26 | refetch() 27 | } 28 | }, []) 29 | 30 | const unfollowMutation = useMutation(UNFOLLOW) 31 | 32 | const unfollow = url => 33 | unfollowMutation({ 34 | variables: { channel, url }, 35 | optimisticResponse: { 36 | __typename: 'Mutation', 37 | mute: true, 38 | }, 39 | update: (proxy, _) => { 40 | // Read the data from our cache for this query. 41 | const data = proxy.readQuery({ 42 | query: GET_FOLLOWING, 43 | variables: { channel }, 44 | }) 45 | // Find and remove posts with the given author url 46 | data.following = data.following.filter(item => item.url !== url) 47 | // Write our data back to the cache. 48 | proxy.writeQuery({ query: GET_FOLLOWING, variables: { channel }, data }) 49 | }, 50 | }) 51 | 52 | return ( 53 | <> 54 | Following 55 | 56 | {!!loading && ( 57 | 58 | 59 | 60 | )} 61 | 62 | {!!following && 63 | following.map(item => ( 64 | unfollow(item.url)} 68 | onRemoveLabel={`Unfollow ${item.url}`} 69 | /> 70 | ))} 71 | 72 | {!loading && (!following || following.length === 0) && ( 73 | 74 | 75 | You are not following anything at the moment 76 | 77 | 78 | )} 79 | 80 | ) 81 | } 82 | 83 | ChannelFollowing.propTypes = { 84 | channel: PropTypes.string, 85 | } 86 | 87 | export default withStyles(styles)(ChannelFollowing) 88 | -------------------------------------------------------------------------------- /src/components/ChannelSettings/Muted.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { useQuery, useMutation } from 'react-apollo-hooks' 4 | import { GET_MUTED, UNMUTE } from '../../queries' 5 | import { withStyles } from '@material-ui/core/styles' 6 | import { 7 | ListSubheader, 8 | ListItem, 9 | ListItemText, 10 | LinearProgress, 11 | } from '@material-ui/core' 12 | import ChannelSettingUrl from './ChannelSettingUrl' 13 | import styles from './style' 14 | 15 | const ChannelMuted = ({ classes, channel }) => { 16 | const { 17 | data: { muted }, 18 | loading, 19 | refetch, 20 | } = useQuery(GET_MUTED, { 21 | variables: { channel }, 22 | }) 23 | 24 | useEffect(() => { 25 | if (!loading && refetch) { 26 | refetch() 27 | } 28 | }, []) 29 | 30 | const unmuteMutation = useMutation(UNMUTE) 31 | 32 | const unmute = url => 33 | unmuteMutation({ 34 | variables: { channel, url }, 35 | optimisticResponse: { 36 | __typename: 'Mutation', 37 | mute: true, 38 | }, 39 | update: (proxy, _) => { 40 | // Read the data from our cache for this query. 41 | const data = proxy.readQuery({ 42 | query: GET_MUTED, 43 | variables: { channel }, 44 | }) 45 | // Find and remove posts with the given author url 46 | data.muted = data.muted.filter(item => item.url !== url) 47 | // Write our data back to the cache. 48 | proxy.writeQuery({ query: GET_MUTED, variables: { channel }, data }) 49 | }, 50 | }) 51 | 52 | return ( 53 | <> 54 | Muted 55 | 56 | {!!loading && ( 57 | 58 | 59 | 60 | )} 61 | 62 | {!!muted && 63 | muted.map(item => ( 64 | unmute(item.url)} 68 | onRemoveLabel={`Unmute ${item.url}`} 69 | /> 70 | ))} 71 | 72 | {!loading && (!muted || muted.length === 0) && ( 73 | 74 | No muted urls in this channel 75 | 76 | )} 77 | 78 | ) 79 | } 80 | 81 | ChannelMuted.propTypes = { 82 | channel: PropTypes.string, 83 | } 84 | 85 | export default withStyles(styles)(ChannelMuted) 86 | -------------------------------------------------------------------------------- /src/components/ChannelSettings/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | list: { 3 | position: 'relative', 4 | height: '100%', 5 | }, 6 | fieldset: { 7 | display: 'block', 8 | maxWidth: '100%', 9 | }, 10 | following: { 11 | display: 'block', 12 | width: '100%', 13 | }, 14 | followingUrl: { 15 | overflow: 'hidden', 16 | whiteSpace: 'nowrap', 17 | textOverflow: 'ellipsis', 18 | }, 19 | delete: { 20 | color: 21 | theme.palette.type === 'dark' 22 | ? theme.palette.error.light 23 | : theme.palette.error.dark, 24 | borderColor: 25 | theme.palette.type === 'dark' 26 | ? theme.palette.error.light 27 | : theme.palette.error.dark, 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/Donate/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { 3 | Card, 4 | CardHeader, 5 | CardContent, 6 | CardActions, 7 | Typography, 8 | Link, 9 | TextField, 10 | MenuItem, 11 | Button, 12 | } from '@material-ui/core' 13 | import Meta from '../Meta' 14 | 15 | const Donate = () => { 16 | const [currency, setCurrency] = useState('$') 17 | const [amount, setAmount] = useState(5) 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | If you'd like to donate money towards the continued development of 26 | Together that would be extremely nice of you. 27 | 28 | 29 | I accept payments via{' '} 30 | my website. 31 | 32 | 33 | setCurrency(e.target.value)} 39 | style={{ marginRight: 12 }} 40 | > 41 | € EUR 42 | $ USD 43 | £ GBP 44 | 45 | setAmount(e.target.value)} 51 | min="2" 52 | /> 53 | 54 | 55 | 66 | 67 | 68 | ) 69 | } 70 | 71 | export default Donate 72 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import Card from '@material-ui/core/Card' 4 | import CardActions from '@material-ui/core/CardActions' 5 | import CardContent from '@material-ui/core/CardContent' 6 | import Button from '@material-ui/core/Button' 7 | import Typography from '@material-ui/core/Typography' 8 | import styles from './style' 9 | 10 | class ErrorBoundary extends Component { 11 | constructor(props) { 12 | super(props) 13 | this.state = { hasError: false, newIssueUrl: null } 14 | } 15 | 16 | componentDidCatch(error, info) { 17 | console.log('[React error]', { error, info }) 18 | 19 | const newIssueUrl = new URL( 20 | 'https://github.com/alltogethernow/web/issues/new' 21 | ) 22 | 23 | newIssueUrl.searchParams.set('template', 'bug_report.md') 24 | newIssueUrl.searchParams.set('title', 'React Error: ' + error.message) 25 | newIssueUrl.searchParams.set('labels', 'U0001F41B bug') 26 | 27 | const stackTrace = info && info.componentStack ? info.componentStack : '' 28 | newIssueUrl.searchParams.set( 29 | 'body', 30 | ` 31 | **Describe the bug** 32 | I encountered a bug on the url ${ 33 | typeof window !== 'undefined' 34 | ? window.location.href 35 | : '{copy and paste the url here}' 36 | } 37 | 38 | **To Reproduce** 39 | Steps to reproduce the behavior: 40 | 1. Go to '...' 41 | 2. Click on '....' 42 | 3. Scroll down to '....' 43 | 4. See error 44 | 45 | **Expected Behavior** 46 | A clear and concise description of what you expected to happen. 47 | 48 | **Screenshots** 49 | If applicable, add screenshots to help explain your problem. 50 | 51 | **Browser(please complete the following information):** 52 | - OS: [e.g. iOS] 53 | - Browser [e.g. chrome, safari] 54 | - Version [e.g. 22] 55 | 56 | **More Info** 57 | Add any other context about the problem here. 58 | 59 | **Stack Trace** 60 | ${stackTrace} 61 | ` 62 | ) 63 | this.setState({ hasError: true, newIssueUrl: newIssueUrl.href }) 64 | } 65 | 66 | render() { 67 | const { classes, children } = this.props 68 | const { hasError, newIssueUrl } = this.state 69 | return ( 70 | <> 71 | {children} 72 | {hasError && ( 73 | 74 | 75 | 76 | Uh Oh{' '} 77 | 78 | 🙈 79 | 80 | 81 | 82 | Something went wrong. If you look in the console you should see 83 | the error details 84 | 85 | 86 | 87 | 92 | 95 | 96 | {newIssueUrl && ( 97 | 98 | 101 | 102 | )} 103 | 109 | 110 | 111 | )} 112 | 113 | ) 114 | } 115 | } 116 | 117 | export default withStyles(styles)(ErrorBoundary) 118 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/style.js: -------------------------------------------------------------------------------- 1 | export default { 2 | card: { 3 | maxWidth: '96%', 4 | width: 400, 5 | position: 'fixed', 6 | top: '50%', 7 | left: '50%', 8 | transform: 'translate(-50%, -50%)', 9 | }, 10 | media: { 11 | height: 0, 12 | paddingTop: '56.25%', // 16:9 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/components/GallerySlider/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | loadMore: { 3 | width: '100%', 4 | marginTop: 16, 5 | }, 6 | carousel: {}, 7 | popup: { 8 | boxSizing: 'border-box', 9 | display: 'flex', 10 | justifyContent: 'center', 11 | alignItems: 'center', 12 | width: '100vw', 13 | height: '100vh', 14 | overflow: 'hidden', 15 | background: '#111', 16 | }, 17 | popupMedia: { 18 | display: 'block', 19 | width: '100%', 20 | height: '100%', 21 | objectFit: 'contain', 22 | }, 23 | button: { 24 | color: 'white', 25 | background: 'rgba(0,0,0,.4)', 26 | }, 27 | drawer: { 28 | display: 'block', 29 | boxSizing: 'border-box', 30 | width: 300, 31 | maxWidth: '80%', 32 | maxHeight: '100%', 33 | overflow: 'auto', 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /src/components/GlobalShotcuts/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Shortcuts } from 'react-shortcuts' 3 | import useReactRouter from 'use-react-router' 4 | import useLocalState from '../../hooks/use-local-state' 5 | import useChannels from '../../hooks/use-channels' 6 | 7 | const GlobalShortcutHandler = ({ children, className }) => { 8 | const { history } = useReactRouter() 9 | const { channels } = useChannels() 10 | const [localState, setLocalState] = useLocalState() 11 | 12 | const handleShortcuts = action => { 13 | if (action.startsWith('CHANNEL_')) { 14 | // Switch channel 15 | const channelIndex = parseInt(action.replace('CHANNEL_', '')) - 1 16 | if (channels && channels[channelIndex] && channels[channelIndex].uid) { 17 | // Switch to the selected channel 18 | const uid = channels[channelIndex].uid 19 | history.push(`/channel/${uid}`) 20 | setLocalState({ focusedComponent: 'timeline' }) 21 | } 22 | } else { 23 | // Other actions 24 | switch (action) { 25 | case 'NEW_POST': 26 | history.push('/editor') 27 | break 28 | case 'FOCUS_CHANNEL_LIST': 29 | setLocalState({ focusedComponent: 'channels' }) 30 | break 31 | case 'HELP': 32 | setLocalState({ shortcutHelpOpen: !!!localState.shortcutHelpOpen }) 33 | break 34 | case 'KONAMI': 35 | alert('Look at you. You are very clever') 36 | break 37 | default: 38 | // Nothing to do 39 | break 40 | } 41 | } 42 | } 43 | 44 | return ( 45 | 51 | {children} 52 | 53 | ) 54 | } 55 | 56 | export default GlobalShortcutHandler 57 | -------------------------------------------------------------------------------- /src/components/Home/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { 4 | List, 5 | Typography, 6 | Card, 7 | LinearProgress, 8 | CardContent, 9 | CardHeader, 10 | ButtonGroup, 11 | Button, 12 | } from '@material-ui/core' 13 | import { withStyles } from '@material-ui/styles' 14 | import { 15 | Settings as SettingsIcon, 16 | Edit as NoteIcon, 17 | FolderShared as PostsIcon, 18 | } from '@material-ui/icons' 19 | import ChannelMenuItem from '../ChannelMenu/ChannelMenuItem' 20 | import useChannels from '../../hooks/use-channels' 21 | import useUser from '../../hooks/use-user' 22 | import style from './style' 23 | 24 | const Home = ({ classes }) => { 25 | const { channels, loading } = useChannels() 26 | const { user } = useUser() 27 | const mainChannels = channels.filter((c) => c.uid !== 'notifications') || [] 28 | 29 | return ( 30 |
31 | {!!loading && } 32 | 33 | 34 | Welcome to Together 35 | 36 | 37 | {mainChannels.length > 0 && ( 38 | 39 | 40 | 41 | {mainChannels.map((channel) => ( 42 | 43 | ))} 44 | 45 | 46 | )} 47 | 48 | 49 | 50 | 51 | 52 | {user && user.hasMicropub && ( 53 | 56 | )} 57 | 64 | {user && user.hasMicropub && ( 65 | 68 | )} 69 | 70 | 71 | 72 |
73 | ) 74 | } 75 | 76 | export default withStyles(style)(Home) 77 | -------------------------------------------------------------------------------- /src/components/Home/style.js: -------------------------------------------------------------------------------- 1 | export default (theme) => ({ 2 | container: { 3 | display: 'block', 4 | justifyContent: 'center', 5 | boxSizing: 'border-box', 6 | width: '100%', 7 | height: '100%', 8 | overflow: 'auto', 9 | padding: theme.spacing(2), 10 | 11 | '& > *': { 12 | maxWidth: 600, 13 | width: '100%', 14 | }, 15 | }, 16 | title: { 17 | marginBottom: theme.spacing(4), 18 | }, 19 | channels: { 20 | display: 'flex', 21 | flexDirection: 'row', 22 | flexWrap: 'wrap', 23 | '& > *': { 24 | width: '50%', 25 | }, 26 | 27 | '& .MuiBadge-badge': { 28 | transform: 'scale(1) translate(0%, -50%)', 29 | }, 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /src/components/LandingPage/ExampleApp/example-data.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | const examplePost = (data = {}) => { 4 | const basePost = { 5 | _id: faker.random.uuid(), 6 | // name: faker.hacker.phrase(), 7 | content: { text: faker.hacker.phrase() }, 8 | published: faker.date.past(), 9 | url: process.env.PUBLIC_URL, 10 | author: { 11 | name: faker.name.findName(), 12 | photo: faker.image.avatar(), 13 | }, 14 | } 15 | 16 | return Object.assign({}, basePost, data) 17 | } 18 | 19 | const sortDate = (a, b) => b.published - a.published 20 | 21 | const timelinePosts = [ 22 | examplePost(), 23 | examplePost(), 24 | examplePost(), 25 | examplePost(), 26 | examplePost(), 27 | ].sort(sortDate) 28 | 29 | const mapPost = () => 30 | examplePost({ 31 | checkin: { 32 | name: faker.company.companyName(), 33 | latitude: faker.random.number({ 34 | precision: 0.00001, 35 | min: 40.396, 36 | max: 40.444, 37 | }), 38 | longitude: 39 | 0 - 40 | faker.random.number({ 41 | precision: 0.00001, 42 | min: 3.67, 43 | max: 3.728, 44 | }), 45 | }, 46 | }) 47 | 48 | const mapPosts = [mapPost(), mapPost(), mapPost(), mapPost(), mapPost()].sort( 49 | sortDate 50 | ) 51 | 52 | const photoPost = () => 53 | examplePost({ 54 | photo: [ 55 | 'https://picsum.photos/1400/800/?image=' + faker.random.number(1084), 56 | ], 57 | }) 58 | 59 | const galleryPosts = [ 60 | photoPost(), 61 | photoPost(), 62 | photoPost(), 63 | photoPost(), 64 | photoPost(), 65 | ].sort(sortDate) 66 | 67 | const classicPost = () => 68 | examplePost({ 69 | name: faker.lorem.words(), 70 | content: { html: '

' + faker.lorem.paragraphs(3, '

') + '

' }, 71 | }) 72 | 73 | const classicPosts = [ 74 | classicPost(), 75 | classicPost(), 76 | classicPost(), 77 | classicPost(), 78 | classicPost(), 79 | ].sort(sortDate) 80 | 81 | export default { 82 | timeline: { 83 | id: 'timeline', 84 | title: '📱 Timeline View', 85 | items: timelinePosts, 86 | }, 87 | classic: { 88 | id: 'classic', 89 | title: '📰 Classic View', 90 | items: classicPosts, 91 | }, 92 | gallery: { 93 | id: 'gallery', 94 | title: '📸 Gallery View', 95 | items: galleryPosts, 96 | }, 97 | map: { 98 | id: 'map', 99 | title: '🗺️ Map View', 100 | items: mapPosts, 101 | }, 102 | } 103 | -------------------------------------------------------------------------------- /src/components/LandingPage/ExampleApp/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { 4 | Typography, 5 | AppBar, 6 | Toolbar, 7 | Drawer, 8 | List, 9 | ListItem, 10 | ListItemText, 11 | IconButton, 12 | } from '@material-ui/core' 13 | import MenuIcon from '@material-ui/icons/Menu' 14 | import Timeline from '../../Layout/Timeline' 15 | import Gallery from '../../Layout/Gallery' 16 | import Classic from '../../Layout/Classic' 17 | import Map from '../../Layout/Map' 18 | import style from './style' 19 | // import PropTypes from 'prop-types' 20 | 21 | import exampleData from './example-data' 22 | 23 | const ExampleApp = ({ classes }) => { 24 | const [menuOpen, setMenuOpen] = useState(false) 25 | const [selectedPreview, setSelectedPreview] = useState( 26 | Object.keys(exampleData)[0] 27 | ) 28 | 29 | return ( 30 |
31 | 32 | 33 | setMenuOpen(!menuOpen)} 37 | className={classes.menuButton} 38 | > 39 | 40 | 41 | 42 | {exampleData[selectedPreview].title} 43 | 44 | 45 | 46 |
47 | 56 | 57 | {Object.values(exampleData).map(example => ( 58 | setSelectedPreview(example.id)} 62 | selected={selectedPreview === example.id} 63 | > 64 | 65 | 66 | ))} 67 | 68 | 69 |
70 | {selectedPreview === 'timeline' && ( 71 | 72 | )} 73 | {selectedPreview === 'classic' && ( 74 | 75 | )} 76 | {selectedPreview === 'gallery' && ( 77 | 78 | )} 79 | {selectedPreview === 'map' && ( 80 |
81 | 82 |
83 | )} 84 |
85 |
86 |
87 | ) 88 | } 89 | 90 | export default withStyles(style)(ExampleApp) 91 | -------------------------------------------------------------------------------- /src/components/LandingPage/ExampleApp/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | wrapper: { 3 | display: 'flex', 4 | flexDirection: 'column', 5 | flexWrap: 'nowrap', 6 | justifyContent: 'flex-start', 7 | alignItems: 'stretch', 8 | overflow: 'hidden', 9 | // overflow: 'auto', 10 | borderRadius: theme.shape.borderRadius, 11 | border: `2px solid ${theme.palette.primary.main}`, 12 | marginLeft: 'auto', 13 | marginRight: 'auto', 14 | marginBottom: theme.spacing(2), 15 | marginTop: theme.spacing(2), 16 | maxWidth: 1200, 17 | }, 18 | menuButton: { 19 | marginRight: 20, 20 | [theme.breakpoints.up('md')]: { 21 | display: 'none', 22 | }, 23 | }, 24 | inner: { 25 | position: 'relative', 26 | display: 'flex', 27 | flexGrow: 1, 28 | flexShrink: 1, 29 | flexDirection: 'row', 30 | justifyContent: 'flex-start', 31 | alignItems: 'stretch', 32 | height: 550, 33 | maxHeight: '70vh', 34 | }, 35 | drawer: { 36 | [theme.breakpoints.down('sm')]: { 37 | width: 240, 38 | position: 'absolute', 39 | top: 0, 40 | bottom: 0, 41 | left: 0, 42 | zIndex: 2, 43 | maxWidth: '90%', 44 | '&.is-open': {}, 45 | // flexShrink: 0, 46 | }, 47 | [theme.breakpoints.up('md')]: { 48 | width: 240, 49 | // flexShrink: 0, 50 | }, 51 | }, 52 | drawerPaper: { 53 | position: 'static', 54 | [theme.breakpoints.up('md')]: { 55 | transform: 'none !important', 56 | visibility: 'visible !important', 57 | }, 58 | }, 59 | timeline: { 60 | flexGrow: 1, 61 | flexShrink: 1, 62 | overflow: 'auto', 63 | }, 64 | exampleMap: { 65 | display: 'flex', 66 | width: '100%', 67 | height: '100%', 68 | alignItems: 'stretch', 69 | justifyContent: 'stretch', 70 | 71 | '& > div': { 72 | width: '100%', 73 | height: '100%', 74 | }, 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /src/components/LandingPage/style.js: -------------------------------------------------------------------------------- 1 | export default theme => { 2 | return { 3 | container: { 4 | width: '100%', 5 | maxWidth: 900, 6 | marginLeft: 'auto', 7 | marginRight: 'auto', 8 | padding: theme.spacing(2), 9 | }, 10 | header: { 11 | position: 'relative', 12 | display: 'flex', 13 | alignItems: 'center', 14 | justifyContent: 'stretch', 15 | paddingTop: theme.spacing(2), 16 | paddingBottom: theme.spacing(2), 17 | overflow: 'hidden', 18 | textAlign: 'center', 19 | minHeight: '50vh', 20 | background: theme.palette.primary.main, 21 | color: theme.palette.getContrastText(theme.palette.primary.main), 22 | boxShadow: theme.shadows[3], 23 | }, 24 | logo: { 25 | display: 'block', 26 | width: 100, 27 | height: 100, 28 | marginLeft: 'auto', 29 | marginRight: 'auto', 30 | marginBottom: theme.spacing(2), 31 | }, 32 | title: { 33 | lineHeight: 1.2, 34 | marginBottom: theme.spacing(2), 35 | color: 'inherit', 36 | }, 37 | tagline: { 38 | color: 'inherit', 39 | }, 40 | login: { 41 | position: 'fixed', 42 | top: theme.spacing(2), 43 | right: theme.spacing(4), 44 | background: theme.palette.primary.light, 45 | }, 46 | feature: { 47 | textAlign: 'center', 48 | fontSize: 20, 49 | padding: theme.spacing(2), 50 | marginTop: theme.spacing(5), 51 | marginBottom: theme.spacing(5), 52 | [theme.breakpoints.up('sm')]: { 53 | width: '50%', 54 | }, 55 | }, 56 | featureIcon: { 57 | fontSize: 80, 58 | color: theme.palette.primary.light, 59 | [theme.breakpoints.up('md')]: { 60 | fontSize: 100, 61 | }, 62 | }, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/Layout/Classic/Preview/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { ListItem, ListItemText, ListItemAvatar } from '@material-ui/core' 5 | import AuthorAvatar from '../../../AuthorAvatar' 6 | import moment from 'moment' 7 | import styles from './style' 8 | 9 | const ClassicPreview = ({ classes, post, onClick, highlighted }) => { 10 | const getPreviewText = (maxLength = 80) => { 11 | const item = post 12 | let text = '' 13 | 14 | if (item.name) { 15 | text = item.name 16 | } else if (item.summary) { 17 | text = item.summary 18 | } else if (item.content) { 19 | const contentObject = item.content 20 | if (contentObject.value) { 21 | text = contentObject.value 22 | } else if (contentObject.html) { 23 | text = contentObject.html.replace(/<\/?[^>]+(>|$)/g, '') 24 | } 25 | } 26 | 27 | if (text.length > maxLength) { 28 | text = text.substring(0, maxLength) 29 | text += '…' 30 | } 31 | 32 | return text 33 | } 34 | 35 | // Parse published date 36 | let date = 'unknown' 37 | if (post.published) { 38 | date = moment(post.published).fromNow() 39 | } 40 | 41 | let classNames = [classes.item] 42 | 43 | if (post._is_read) { 44 | classNames.push(classes.read) 45 | } 46 | if (highlighted) { 47 | classNames.push(classes.highlighted) 48 | } 49 | 50 | return ( 51 | 59 | 60 | 61 | 62 | 67 | 68 | ) 69 | } 70 | 71 | ClassicPreview.defaultProps = { 72 | post: {}, 73 | highlighted: false, 74 | onClick: () => {}, 75 | } 76 | 77 | ClassicPreview.propTypes = { 78 | post: PropTypes.object.isRequired, 79 | highlighted: PropTypes.bool.isRequired, 80 | onClick: PropTypes.func, 81 | } 82 | 83 | export default withStyles(styles)(ClassicPreview) 84 | -------------------------------------------------------------------------------- /src/components/Layout/Classic/Preview/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | item: { 3 | paddingLeft: theme.spacing(2), 4 | paddingRight: theme.spacing(2), 5 | [theme.breakpoints.up('sm')]: { 6 | paddingLeft: theme.spacing(1), 7 | paddingRight: theme.spacing(1), 8 | }, 9 | [theme.breakpoints.up('md')]: { 10 | paddingLeft: theme.spacing(2), 11 | paddingRight: theme.spacing(2), 12 | }, 13 | }, 14 | read: { 15 | opacity: 0.5, 16 | }, 17 | highlighted: { 18 | opacity: 1, 19 | color: theme.palette.primary.contrastText, 20 | backgroundColor: 21 | theme.palette.type === 'dark' 22 | ? theme.palette.secondary.main 23 | : theme.palette.primary.main, 24 | }, 25 | text: { 26 | paddingRight: 0, 27 | color: 'inherit', 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/Layout/Classic/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | wrapper: { 3 | position: 'relative', 4 | display: 'flex', 5 | flexDirection: 'row', 6 | width: '100%', 7 | height: '100%', 8 | overflow: 'hidden', 9 | outline: 'none', 10 | zIndex: 1, 11 | }, 12 | previewColumn: { 13 | width: '100%', 14 | height: '100%', 15 | overflow: 'auto', 16 | overscrollBehaviorY: 'contain', 17 | borderRight: '1px solid ' + theme.palette.divider, 18 | flexShrink: 0, 19 | [theme.breakpoints.up('sm')]: { 20 | width: 250, 21 | }, 22 | [theme.breakpoints.up('md')]: { 23 | width: 300, 24 | }, 25 | [theme.breakpoints.up('lg')]: { 26 | width: 400, 27 | }, 28 | }, 29 | postColumn: { 30 | flexGrow: 1, 31 | // overflow: 'auto', 32 | position: 'absolute', 33 | width: '100%', 34 | height: '100%', 35 | // iOS hack thing 36 | overflowY: 'scroll', 37 | overscrollBehaviorY: 'contain', 38 | '-webkit-overflow-scrolling': 'touch', 39 | [theme.breakpoints.up('sm')]: { 40 | position: 'relative', 41 | }, 42 | }, 43 | postShortcuts: { 44 | height: '100%', 45 | }, 46 | loadMore: { 47 | width: '100%', 48 | }, 49 | post: { 50 | margin: 0, 51 | minHeight: 'calc(100% - 48px)', 52 | maxWidth: 700, 53 | boxShadow: 'none', 54 | paddingBottom: 48, // This is kind of a hack because safari position fixed is broken 55 | }, 56 | postNav: { 57 | top: 'auto', 58 | width: '100%', 59 | left: 0, 60 | bottom: 0, 61 | maxWidth: 700, 62 | boxShadow: 'none', 63 | }, 64 | closePost: { 65 | [theme.breakpoints.up('sm')]: { 66 | display: 'none', 67 | }, 68 | }, 69 | }) 70 | -------------------------------------------------------------------------------- /src/components/Layout/Content.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { LinearProgress } from '@material-ui/core' 4 | import AddFeed from '../AddFeed' 5 | import Gallery from './Gallery' 6 | import Map from './Map' 7 | import Classic from './Classic' 8 | import Timeline from './Timeline' 9 | import NoContent from './NoContent' 10 | import layouts from '../../modules/layouts' 11 | import styles from './style' 12 | 13 | const Content = ({ classes, channel, data, fetchMore, networkStatus }) => { 14 | // Use the correct component for the channel view 15 | const layout = channel && channel._t_layout ? channel._t_layout : 'timeline' 16 | const viewFilter = layouts.find((l) => l.id === layout).filter 17 | let View = () => null 18 | switch (layout) { 19 | case 'gallery': 20 | View = Gallery 21 | break 22 | case 'classic': 23 | View = Classic 24 | break 25 | case 'map': 26 | View = Map 27 | break 28 | default: 29 | View = Timeline 30 | break 31 | } 32 | 33 | const isEmpty = !( 34 | data && 35 | data.timeline && 36 | data.timeline.items && 37 | data.timeline.items.length 38 | ) 39 | 40 | return ( 41 | <> 42 | {networkStatus < 7 && } 43 | 44 | {isEmpty && networkStatus >= 7 && } 45 | 46 | {!isEmpty && ( 47 | 53 | )} 54 | 55 | 56 | 57 | ) 58 | } 59 | 60 | export default withStyles(styles)(Content) 61 | -------------------------------------------------------------------------------- /src/components/Layout/Gallery/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | galleryWrapper: { 3 | overflow: 'auto', 4 | height: '100%', 5 | overscrollBehaviorY: 'contain', 6 | }, 7 | loadMore: { 8 | width: '100%', 9 | marginTop: 16, 10 | }, 11 | video: { 12 | left: '50%', 13 | height: '100%', 14 | width: 'auto', 15 | position: 'relative', 16 | transform: 'translateX(-50%)', 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /src/components/Layout/NoContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Typography } from '@material-ui/core' 4 | import styles from './style' 5 | 6 | const NoContent = ({ classes }) => ( 7 |
8 | 9 | 10 | 🤷‍ 11 | {' '} 12 | Nothing to show 13 | 14 | 15 | Maybe you need to subscribe to a site or select a different channel 16 | 17 |
18 | ) 19 | 20 | export default withStyles(styles)(NoContent) 21 | -------------------------------------------------------------------------------- /src/components/Layout/Shortcuts.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { Shortcuts } from 'react-shortcuts' 5 | import useLocalState from '../../hooks/use-local-state' 6 | import styles from './style' 7 | 8 | const LayoutShortcuts = ({ 9 | focusComponent, 10 | onNext, 11 | onPrevious, 12 | onMarkRead, 13 | onSelectPost, 14 | children, 15 | className, 16 | classes, 17 | ...props 18 | }) => { 19 | const ref = useRef() 20 | const [localState, setLocalState] = useLocalState() 21 | 22 | // Focus the timeline when the focused component is set 23 | useEffect(() => { 24 | const el = ref.current._domNode 25 | if ( 26 | localState.focusedComponent === 'timeline' && 27 | el !== document.activeElement 28 | ) { 29 | el.focus() 30 | } 31 | }, [localState.focusedComponent]) 32 | 33 | // Handle keypresses 34 | const handleShortcuts = action => { 35 | switch (action) { 36 | case 'NEXT': 37 | onNext() 38 | break 39 | case 'PREVIOUS': 40 | onPrevious() 41 | break 42 | case 'SELECT_POST': 43 | setLocalState({ focusedComponent: 'post' }) 44 | onSelectPost() 45 | break 46 | case 'FOCUS_CHANNEL_LIST': 47 | setLocalState({ focusedComponent: 'channels' }) 48 | break 49 | case 'MARK_READ': 50 | onMarkRead() 51 | break 52 | default: 53 | // Nothing to handle 54 | break 55 | } 56 | } 57 | 58 | // Add class names and is-focused 59 | let shortcutsClassName = classes.shortcuts + ' layoutScrollTop' 60 | if (className) { 61 | shortcutsClassName += ' ' + className 62 | } 63 | if (localState.focusedComponent === 'timeline') { 64 | shortcutsClassName += ' is-focused' 65 | } 66 | 67 | return ( 68 | 75 | {children} 76 | 77 | ) 78 | } 79 | 80 | LayoutShortcuts.defaultProps = { 81 | onNext: () => {}, 82 | onPrevious: () => {}, 83 | onSelectPost: () => {}, 84 | onMarkRead: () => {}, 85 | } 86 | 87 | LayoutShortcuts.propTypes = { 88 | onNext: PropTypes.func.isRequired, 89 | onPrevious: PropTypes.func.isRequired, 90 | onSelectPost: PropTypes.func.isRequired, 91 | onMarkRead: PropTypes.func.isRequired, 92 | } 93 | 94 | export default withStyles(styles)(LayoutShortcuts) 95 | -------------------------------------------------------------------------------- /src/components/Layout/Timeline/index.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import PropTypes from 'prop-types' 3 | import useMarkRead from '../../../hooks/use-mark-read' 4 | import { withStyles } from '@material-ui/core/styles' 5 | import 'intersection-observer' 6 | import { InView } from 'react-intersection-observer' 7 | import Button from '@material-ui/core/Button' 8 | import ReactList from 'react-list' 9 | import Shortcuts from '../Shortcuts' 10 | import Post from '../../Post' 11 | import styles from './style' 12 | 13 | const Timeline = ({ 14 | classes, 15 | posts, 16 | channel, 17 | loadMore, 18 | loading, 19 | shownActions = null, 20 | }) => { 21 | const ref = useRef() 22 | const markRead = useMarkRead() 23 | 24 | const handleIntersection = async (inView, entry) => { 25 | if (!entry || !entry.intersectionRatio) { 26 | return null 27 | } 28 | 29 | const target = entry.target 30 | const itemId = target.dataset.id 31 | const itemIsRead = target.dataset.isread === 'true' 32 | 33 | if (channel && channel._t_autoRead && !itemIsRead) { 34 | markRead(channel.uid, itemId) 35 | } 36 | 37 | const isSecondLastItem = 38 | posts.length > 2 && itemId === posts[posts.length - 2]._id 39 | 40 | if (channel && channel._t_infiniteScroll && isSecondLastItem && loadMore) { 41 | loadMore() 42 | } 43 | 44 | return null 45 | } 46 | 47 | return ( 48 | { 50 | if (ref.current && ref.current.scrollParent) { 51 | ref.current.scrollParent.scrollBy(0, 50) 52 | } 53 | }} 54 | onPrevious={() => { 55 | if (ref.current && ref.current.scrollParent) { 56 | ref.current.scrollParent.scrollBy(0, -50) 57 | } 58 | }} 59 | onMarkRead={() => {}} 60 | className={classes.timeline} 61 | > 62 | ( 65 | 72 | 73 | 74 | )} 75 | length={posts.length} 76 | type="simple" 77 | minSize={5} 78 | /> 79 | 80 | {channel && loadMore && ( 81 | 88 | )} 89 | 90 | ) 91 | } 92 | 93 | Timeline.defaultProps = { 94 | posts: [], 95 | } 96 | 97 | Timeline.propTypes = { 98 | posts: PropTypes.array.isRequired, 99 | } 100 | 101 | export default withStyles(styles)(Timeline) 102 | -------------------------------------------------------------------------------- /src/components/Layout/Timeline/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | timeline: { 3 | display: 'block', 4 | boxSizing: 'border-box', 5 | width: '100%', 6 | height: '100%', 7 | padding: theme.spacing(2), 8 | paddingTop: 0, 9 | overflow: 'auto', 10 | overscrollBehaviorY: 'contain', 11 | outline: 'none', 12 | '& > div': { 13 | maxWidth: 600, 14 | }, 15 | }, 16 | shortcuts: { 17 | outline: 'none', 18 | }, 19 | loadMore: { 20 | display: 'block', 21 | width: '100%', 22 | marginTop: 40, 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /src/components/Layout/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import useCurrentChannel from '../../hooks/use-current-channel' 4 | import useTimeline from '../../hooks/use-timeline' 5 | import AddFeed from '../AddFeed' 6 | import Content from './Content' 7 | import styles from './style' 8 | 9 | const ContentFetcher = ({ channel }) => { 10 | const { data, fetchMore, networkStatus } = useTimeline({ 11 | channel: channel.uid, 12 | unreadOnly: channel._t_unreadOnly, 13 | }) 14 | 15 | return ( 16 | 22 | ) 23 | } 24 | 25 | const Layout = ({ classes }) => { 26 | const channel = useCurrentChannel() 27 | 28 | return ( 29 |
30 | {channel && channel.uid && } 31 | 32 |
33 | ) 34 | } 35 | 36 | export default withStyles(styles)(Layout) 37 | -------------------------------------------------------------------------------- /src/components/Layout/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | shortcuts: { 3 | boxSizing: 'border-box', 4 | position: 'relative', 5 | outline: 'none', 6 | '&:focus, &.is-focused': { 7 | boxShadow: `0 0 4px inset ${ 8 | theme.palette.type === 'dark' 9 | ? theme.palette.secondary.main 10 | : theme.palette.primary.main 11 | }`, 12 | }, 13 | // '&:focus::after, &.is-focused::after': { 14 | // content: '""', 15 | // display: 'block', 16 | // pointerEvents: 'none', 17 | // position: 'absolute', 18 | // top: 0, 19 | // bottom: 0, 20 | // left: 0, 21 | // right: 0, 22 | // border: `2px solid ${ 23 | // theme.palette.type === 'dark' 24 | // ? theme.palette.secondary.main 25 | // : theme.palette.primary.main 26 | // }`, 27 | // }, 28 | }, 29 | noPosts: { 30 | padding: theme.spacing(2), 31 | }, 32 | loading: { 33 | position: 'fixed', 34 | top: 0, 35 | right: 0, 36 | left: 0, 37 | zIndex: 9999, 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /src/components/LayoutSwitcher/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { IconButton, Tooltip } from '@material-ui/core' 4 | import { useMutation } from 'react-apollo-hooks' 5 | import gql from 'graphql-tag' 6 | import useCurrentChannel from '../../hooks/use-current-channel' 7 | import layouts from '../../modules/layouts' 8 | import styles from './style' 9 | 10 | export const UPDATE_LAYOUT = gql` 11 | mutation updateLayout($uid: String!, $_t_layout: String!) { 12 | updateChannel(uid: $uid, _t_layout: $_t_layout) { 13 | uid 14 | _t_layout 15 | } 16 | } 17 | ` 18 | 19 | const LayoutSwitcher = ({ classes, className }) => { 20 | const channel = useCurrentChannel() 21 | const updateChannel = useMutation(UPDATE_LAYOUT) 22 | 23 | if (!channel.uid) { 24 | return null 25 | } 26 | 27 | return ( 28 |
29 | {layouts.map(layout => { 30 | const Icon = layout.icon 31 | let iconClassName = classes.icon 32 | // Add class if layout selected 33 | if (layout.id === channel._t_layout) { 34 | iconClassName += ' ' + classes.iconSelected 35 | } 36 | 37 | return ( 38 | 43 | 46 | updateChannel({ 47 | variables: { uid: channel.uid, _t_layout: layout.id }, 48 | }) 49 | } 50 | > 51 | 52 | 53 | 54 | ) 55 | })} 56 |
57 | ) 58 | } 59 | 60 | export default withStyles(styles)(LayoutSwitcher) 61 | -------------------------------------------------------------------------------- /src/components/LayoutSwitcher/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | menu: { 3 | display: 'flex', 4 | flexDirection: 'column', 5 | overflow: 'visible', 6 | background: theme.palette.primary.main, 7 | }, 8 | icon: { 9 | color: 'rgba(255,255,255,.3)', 10 | '&:hover': { 11 | color: theme.palette.primary.contrastText, 12 | }, 13 | }, 14 | iconSelected: { 15 | color: theme.palette.primary.contrastText, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /src/components/Login/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Redirect } from 'react-router-dom' 4 | import { 5 | TextField, 6 | Dialog, 7 | DialogContent, 8 | DialogContentText, 9 | DialogTitle, 10 | LinearProgress, 11 | InputAdornment, 12 | IconButton, 13 | } from '@material-ui/core' 14 | import LoginIcon from '@material-ui/icons/Send' 15 | import gql from 'graphql-tag' 16 | import { useMutation } from 'react-apollo-hooks' 17 | import useLocalState from '../../hooks/use-local-state' 18 | 19 | const GET_AUTH_URL = gql` 20 | mutation GetAuthUrl($url: String!) { 21 | getAuthUrl(url: $url) 22 | } 23 | ` 24 | 25 | const Login = ({ onClose }) => { 26 | const [localState] = useLocalState() 27 | const [me, setMe] = useState('') 28 | const [loading, setLoading] = useState(false) 29 | 30 | const getAuthUrl = useMutation(GET_AUTH_URL, { 31 | variables: { url: me }, 32 | }) 33 | 34 | // Gets the auth url and redirects on submit 35 | const handleSubmit = async e => { 36 | e.preventDefault() 37 | setLoading(true) 38 | 39 | try { 40 | const { data, error } = await getAuthUrl() 41 | const { getAuthUrl: url } = data 42 | if (error || !url) { 43 | alert('Uh oh, there was an error getting your authorization url') 44 | console.log('[Error getting auth url]', error) 45 | } 46 | window.location.href = url 47 | } catch (err) { 48 | console.log('[Error getting auth url]', err) 49 | } 50 | setLoading(false) 51 | return false 52 | } 53 | 54 | if (localState && localState.token) { 55 | return 56 | } 57 | 58 | return ( 59 | 60 | {loading ? : null} 61 | Login 62 | 63 | 64 | Hey! Welcome to Together. Get started by logging in with your website. 65 | 66 | 67 |
68 | setMe(e.target.value)} 77 | variant="outlined" 78 | disabled={loading} 79 | fullWidth 80 | InputProps={{ 81 | endAdornment: ( 82 | 83 | 84 | 85 | 86 | 87 | ), 88 | }} 89 | /> 90 | 91 |
92 |
93 | ) 94 | } 95 | 96 | Login.defaultProps = { 97 | onClose: () => {}, 98 | } 99 | 100 | Login.propTypes = { 101 | onClose: PropTypes.func.isRequired, 102 | } 103 | 104 | export default Login 105 | -------------------------------------------------------------------------------- /src/components/Map/Marker/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import Popover from '@material-ui/core/Popover' 5 | import AuthorAvatar from '../../AuthorAvatar' 6 | import Post from '../../Post' 7 | import styles, { markerSize } from './style' 8 | 9 | const MapMarker = ({ classes, post, postOpen = false, author, left, top }) => { 10 | const [open, setOpen] = useState(postOpen) 11 | const [anchor, setAnchor] = useState(null) 12 | 13 | // Open / close on prop change 14 | useEffect(() => { 15 | if (postOpen !== open) { 16 | setOpen(postOpen) 17 | } 18 | }, [postOpen]) 19 | 20 | return ( 21 | 22 |
{ 25 | setOpen(true) 26 | setAnchor(e.target) 27 | e.preventDefault() 28 | }} 29 | style={{ left, top }} 30 | > 31 | 32 |
33 | {!!post && ( 34 | setOpen(false)} 48 | onBackdropClick={() => setOpen(false)} 49 | > 50 | 60 | 61 | )} 62 |
63 | ) 64 | } 65 | 66 | MapMarker.defaultProps = { 67 | author: '?', 68 | } 69 | 70 | MapMarker.propTypes = { 71 | post: PropTypes.object, 72 | postOpen: PropTypes.bool, 73 | author: PropTypes.any.isRequired, 74 | } 75 | 76 | export default withStyles(styles)(MapMarker) 77 | -------------------------------------------------------------------------------- /src/components/Map/Marker/style.js: -------------------------------------------------------------------------------- 1 | const markerSize = 24 2 | export { markerSize } 3 | export default { 4 | marker: { 5 | position: 'absolute', 6 | width: markerSize, 7 | height: markerSize, 8 | marginTop: 0 - markerSize / 2, 9 | marginLeft: 0 - markerSize / 2, 10 | fontSize: markerSize - markerSize / 2, 11 | pointer: 'cursor', 12 | transition: 'transform .2s', 13 | '&:hover': { 14 | transform: 'scale(1.05)', 15 | }, 16 | }, 17 | popover: { 18 | padding: '13px 20px', 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Map/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Map from 'pigeon-maps' 4 | import useLocalState from '../../hooks/use-local-state' 5 | 6 | const TogetherMap = ({ children, ...props }) => { 7 | const [localState] = useLocalState() 8 | 9 | return ( 10 | 13 | localState.theme === 'dark' 14 | ? `https://cartodb-basemaps-c.global.ssl.fastly.net/dark_all/${z}/${x}/${y}@2x.png` 15 | : `https://a.tile.openstreetmap.se/hydda/full/${z}/${x}/${y}.png` 16 | } 17 | > 18 | {children} 19 | 20 | ) 21 | } 22 | 23 | TogetherMap.propTypes = { 24 | center: PropTypes.array.isRequired, 25 | } 26 | 27 | TogetherMap.defaultProps = { 28 | height: 200, 29 | zoom: 12, 30 | metaWheelZoom: true, 31 | } 32 | 33 | export default TogetherMap 34 | -------------------------------------------------------------------------------- /src/components/Meta.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Helmet } from 'react-helmet' 3 | // import useLocalState from '../hooks/use-local-state' 4 | 5 | const Meta = ({ title = '' }) => { 6 | // const [localState] = useLocalState() 7 | 8 | return 9 | } 10 | 11 | export default Meta 12 | -------------------------------------------------------------------------------- /src/components/MicropubEditorFull/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | wrapper: { 3 | height: '100%', 4 | overflow: 'auto', 5 | }, 6 | container: { 7 | padding: theme.spacing(3), 8 | maxWidth: 800, 9 | '& .ql-editor': { 10 | color: 11 | theme.palette.type === 'dark' 12 | ? theme.palette.primary.contrastText 13 | : 'inherit', 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/MicropubForm/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | container: { 3 | display: 'block', 4 | overflow: 'hidden', 5 | minWidth: 300, 6 | maxWidth: '100%', 7 | }, 8 | expandedContainer: { 9 | width: '100%', 10 | }, 11 | submitButton: { 12 | float: 'right', 13 | }, 14 | submitButtonIcon: { 15 | marginLeft: '.5em', 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /src/components/MicrosubNotifications/OpenButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { Tooltip, IconButton, CircularProgress } from '@material-ui/core' 5 | import { 6 | Notifications as NotificationsIcon, 7 | NotificationsActive as HasNotificationsIcon, 8 | } from '@material-ui/icons' 9 | import styles from './style' 10 | 11 | const NotificationsOpenButton = ({ 12 | name, 13 | unread, 14 | classes, 15 | loading, 16 | handleOpen, 17 | buttonClass, 18 | open, 19 | }) => { 20 | return ( 21 | 22 | 23 | 30 | {unread > 0 ? : } 31 | 32 | {loading && !open && ( 33 | 34 | )} 35 | 36 | 37 | ) 38 | } 39 | 40 | NotificationsOpenButton.propTypes = { 41 | name: PropTypes.string.isRequired, 42 | unread: PropTypes.number.isRequired, 43 | buttonClass: PropTypes.string.isRequired, 44 | loading: PropTypes.bool.isRequired, 45 | handleOpen: PropTypes.func.isRequired, 46 | open: PropTypes.bool.isRequired, 47 | } 48 | 49 | export default withStyles(styles)(NotificationsOpenButton) 50 | -------------------------------------------------------------------------------- /src/components/MicrosubNotifications/TitleBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { 4 | Tooltip, 5 | IconButton, 6 | AppBar, 7 | Toolbar, 8 | Typography, 9 | } from '@material-ui/core' 10 | import { ClearAll as MarkAllReadIcon } from '@material-ui/icons' 11 | import useMarkChannelRead from '../../hooks/use-mark-channel-read' 12 | import styles from './style' 13 | 14 | const NotificationsTitleBar = ({ classes, unread, title }) => { 15 | const markChannelRead = useMarkChannelRead() 16 | 17 | return ( 18 | 19 | 20 | 21 | {title} 22 | 23 | {unread > 0 && ( 24 | 25 | markChannelRead('notifications')} 28 | > 29 | 30 | 31 | 32 | )} 33 | 34 | 35 | ) 36 | } 37 | 38 | export default withStyles(styles)(NotificationsTitleBar) 39 | -------------------------------------------------------------------------------- /src/components/MicrosubNotifications/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { Popover, Divider, Button, CircularProgress } from '@material-ui/core' 5 | import OpenButton from './OpenButton' 6 | import TitleBar from './TitleBar' 7 | import Post from '../Post' 8 | import styles from './style' 9 | import useTimeline from '../../hooks/use-timeline' 10 | import useChannels from '../../hooks/use-channels' 11 | 12 | const Channel = props => { 13 | const { channels } = useChannels() 14 | const channel = channels.find(c => c.uid === 'notifications') 15 | 16 | return channel ? : null 17 | } 18 | 19 | const NotificationsList = ({ classes, buttonClass, channel }) => { 20 | const [open, setOpen] = useState(false) 21 | const [anchor, setAnchor] = useState(null) 22 | const { data, fetchMore, networkStatus, error } = useTimeline({ 23 | channel: 'notifications', 24 | }) 25 | const loading = networkStatus < 7 26 | 27 | if (error) { 28 | console.warn('Error loading notifications', error) 29 | return null 30 | } 31 | 32 | if (Object.keys(data).length === 0) { 33 | return null 34 | } 35 | 36 | const { 37 | timeline: { after, items: notifications }, 38 | } = data 39 | 40 | return ( 41 | 42 | { 47 | setAnchor(e.target) 48 | setOpen(true) 49 | }} 50 | buttonClass={buttonClass} 51 | name={channel.name} 52 | /> 53 | setOpen(false)} 65 | onBackdropClick={() => setOpen(false)} 66 | className={classes.popover} 67 | > 68 | {loading && } 69 |
70 | {notifications.map(post => ( 71 | 72 |
73 | 78 |
79 | 80 |
81 | ))} 82 | {after && ( 83 | 91 | )} 92 |
93 | 94 |
95 |
96 | ) 97 | } 98 | 99 | NotificationsList.defaultProps = { 100 | buttonClass: '', 101 | } 102 | 103 | NotificationsList.propTypes = { 104 | buttonClass: PropTypes.string.isRequired, 105 | } 106 | 107 | export default withStyles(styles)(Channel) 108 | -------------------------------------------------------------------------------- /src/components/MicrosubNotifications/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | icon: { position: 'relative', display: 'inline-block' }, 3 | loadingIcon: { 4 | opacity: 0.7, 5 | }, 6 | iconSpinner: { 7 | position: 'absolute', 8 | top: 0, 9 | left: 0, 10 | zIndex: 1, 11 | color: theme.palette.grey[100], 12 | opacity: 0.4, 13 | }, 14 | popover: { 15 | paper: { 16 | width: '100%', 17 | maxHeight: '100%', 18 | [theme.breakpoints.up('sm')]: { 19 | width: 400, 20 | minWidth: 400, 21 | maxWidth: '90%', 22 | }, 23 | }, 24 | [theme.breakpoints.up('sm')]: { 25 | maxHeight: '80%', 26 | }, 27 | }, 28 | container: { 29 | width: '100%', 30 | [theme.breakpoints.up('sm')]: { 31 | width: 400, 32 | }, 33 | }, 34 | loadMore: {}, 35 | spinner: { 36 | position: 'fixed', 37 | top: 'calc(50% - 25px)', 38 | left: 'calc(50% - 25px)', 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /src/components/Post/Actions/Base.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MenuItem, Tooltip, IconButton, ListItemIcon } from '@material-ui/core' 3 | 4 | const TogetherCardBaseAction = ({ 5 | title, 6 | icon, 7 | onClick, 8 | menuItem = false, 9 | loading = false, 10 | }) => { 11 | if (menuItem) { 12 | return ( 13 | 14 | {icon} 15 | {title} 16 | 17 | ) 18 | } else { 19 | return ( 20 | 21 | 22 | {icon} 23 | 24 | 25 | ) 26 | } 27 | } 28 | 29 | export default TogetherCardBaseAction 30 | -------------------------------------------------------------------------------- /src/components/Post/Actions/Block.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BaseAction from './Base' 3 | import BlockIcon from '@material-ui/icons/Block' 4 | import { useMutation } from 'react-apollo-hooks' 5 | import { useSnackbar } from 'notistack' 6 | import { BLOCK, GET_TIMELINE } from '../../../queries' 7 | 8 | const ActionBlock = ({ url, channel, menuItem }) => { 9 | const { enqueueSnackbar } = useSnackbar() 10 | 11 | const block = useMutation(BLOCK, { 12 | variables: { channel, url }, 13 | optimisticResponse: { 14 | __typename: 'Mutation', 15 | block: true, 16 | }, 17 | update: (proxy, _) => { 18 | // Read the data from our cache for this query. 19 | const data = proxy.readQuery({ 20 | query: GET_TIMELINE, 21 | variables: { channel }, 22 | }) 23 | // Find and remove posts with the given author url 24 | data.timeline.items = data.timeline.items.filter( 25 | post => post.author.url !== url 26 | ) 27 | // Write our data back to the cache. 28 | proxy.writeQuery({ query: GET_TIMELINE, variables: { channel }, data }) 29 | }, 30 | }) 31 | 32 | const handleBlock = async e => { 33 | try { 34 | await block() 35 | enqueueSnackbar('User blocked', { variant: 'success' }) 36 | } catch (err) { 37 | console.error('Error blocking user', err) 38 | enqueueSnackbar('Error blocking user', { variant: 'error' }) 39 | } 40 | } 41 | 42 | return ( 43 | } 47 | menuItem={menuItem} 48 | /> 49 | ) 50 | } 51 | 52 | export default ActionBlock 53 | -------------------------------------------------------------------------------- /src/components/Post/Actions/ConsoleLog.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BaseAction from './Base' 3 | import LogIcon from '@material-ui/icons/DeveloperMode' 4 | 5 | const ActionConsoleLog = ({ post, menuItem }) => ( 6 | console.log(post)} 8 | title="Log to console" 9 | icon={} 10 | menuItem={menuItem} 11 | /> 12 | ) 13 | 14 | export default ActionConsoleLog 15 | -------------------------------------------------------------------------------- /src/components/Post/Actions/Like.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useMutation } from 'react-apollo-hooks' 3 | import { useSnackbar } from 'notistack' 4 | import LikeIcon from '@material-ui/icons/ThumbUp' 5 | import useUser from '../../../hooks/use-user' 6 | import BaseAction from './Base' 7 | import SnackbarLinkAction from '../../SnackbarActions/Link' 8 | import SnackbarUndoAction from '../../SnackbarActions/Undo' 9 | import { MICROPUB_CREATE, MICROPUB_DELETE } from '../../../queries' 10 | 11 | const ActionLike = ({ url, menuItem }) => { 12 | const [loading, setLoading] = useState(false) 13 | const { enqueueSnackbar, closeSnackbar } = useSnackbar() 14 | const { user } = useUser() 15 | let mf2 = { 16 | type: ['h-entry'], 17 | properties: { 18 | 'like-of': [url], 19 | }, 20 | } 21 | if (user && user.settings.likeSyndication.length) { 22 | mf2.properties['mp-syndicate-to'] = user.settings.likeSyndication 23 | } 24 | const createLike = useMutation(MICROPUB_CREATE, { 25 | variables: { 26 | json: JSON.stringify(mf2), 27 | }, 28 | }) 29 | const micropubDelete = useMutation(MICROPUB_DELETE) 30 | 31 | const onClick = async e => { 32 | try { 33 | setLoading(true) 34 | const { 35 | data: { micropubCreate: postUrl }, 36 | } = await createLike() 37 | enqueueSnackbar('Posted Like', { 38 | variant: 'success', 39 | action: key => [ 40 | , 41 | { 43 | closeSnackbar(key) 44 | await micropubDelete({ variables: { url: postUrl } }) 45 | enqueueSnackbar('Like deleted', { variant: 'success' }) 46 | }} 47 | />, 48 | ], 49 | }) 50 | } catch (err) { 51 | console.error('Error posting like', err) 52 | enqueueSnackbar('Error posting like', { variant: 'error' }) 53 | } 54 | setLoading(false) 55 | } 56 | 57 | return ( 58 | } 61 | onClick={onClick} 62 | menuItem={menuItem} 63 | loading={loading} 64 | /> 65 | ) 66 | } 67 | 68 | export default ActionLike 69 | -------------------------------------------------------------------------------- /src/components/Post/Actions/MarkRead.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSnackbar } from 'notistack' 3 | import useMarkRead from '../../../hooks/use-mark-read' 4 | import useMarkUnread from '../../../hooks/use-mark-unread' 5 | import ReadIcon from '@material-ui/icons/PanoramaFishEye' 6 | import UnreadIcon from '@material-ui/icons/Lens' 7 | import BaseAction from './Base' 8 | 9 | const ActionMarkRead = ({ _id, isRead, channel, menuItem }) => { 10 | const { enqueueSnackbar } = useSnackbar() 11 | 12 | const markRead = useMarkRead() 13 | const markUnread = useMarkUnread() 14 | 15 | const handleClick = async e => { 16 | const actionName = isRead ? 'unread' : 'read' 17 | try { 18 | if (isRead) { 19 | await markUnread(channel, _id) 20 | } else { 21 | await markRead(channel, _id) 22 | } 23 | enqueueSnackbar(`Post marked ${actionName}`, { variant: 'success' }) 24 | } catch (err) { 25 | console.error(`Error marking post ${actionName}`, err) 26 | enqueueSnackbar(`Error marking post ${actionName}`, { variant: 'error' }) 27 | } 28 | } 29 | 30 | return ( 31 | : } 35 | menuItem={menuItem} 36 | /> 37 | ) 38 | } 39 | 40 | export default ActionMarkRead 41 | -------------------------------------------------------------------------------- /src/components/Post/Actions/MicropubDelete.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import RemoveIcon from '@material-ui/icons/Delete' 3 | import BaseAction from './Base' 4 | import { useSnackbar } from 'notistack' 5 | import { useMutation } from 'react-apollo-hooks' 6 | import { MICROPUB_DELETE } from '../../../queries' 7 | 8 | const ActionDelete = ({ url, menuItem }) => { 9 | const { enqueueSnackbar } = useSnackbar() 10 | const [loading, setLoading] = useState(false) 11 | 12 | const removePost = useMutation(MICROPUB_DELETE, { 13 | variables: { url }, 14 | }) 15 | 16 | const handleRemove = async e => { 17 | try { 18 | setLoading(true) 19 | await removePost() 20 | enqueueSnackbar('Post deleted', { variant: 'success' }) 21 | } catch (err) { 22 | console.error('Error deleting post', err) 23 | enqueueSnackbar('Error deleting post', { variant: 'error' }) 24 | } 25 | setLoading(false) 26 | } 27 | 28 | return ( 29 | } 33 | menuItem={menuItem} 34 | loading={loading} 35 | /> 36 | ) 37 | } 38 | 39 | export default ActionDelete 40 | -------------------------------------------------------------------------------- /src/components/Post/Actions/MicropubUndelete.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import RestoreIcon from '@material-ui/icons/RestoreFromTrash' 3 | import BaseAction from './Base' 4 | import { useSnackbar } from 'notistack' 5 | import { useMutation } from 'react-apollo-hooks' 6 | import { MICROPUB_UNDELETE } from '../../../queries' 7 | 8 | const ActionUndelete = ({ url, menuItem }) => { 9 | const [loading, setLoading] = useState(false) 10 | const { enqueueSnackbar } = useSnackbar() 11 | 12 | const undelete = useMutation(MICROPUB_UNDELETE, { 13 | variables: { url }, 14 | }) 15 | 16 | const handleRemove = async e => { 17 | try { 18 | setLoading(true) 19 | await undelete() 20 | enqueueSnackbar('Post restored', { variant: 'success' }) 21 | } catch (err) { 22 | console.error('Error undeleting post', err) 23 | enqueueSnackbar('Error undeleting post', { variant: 'error' }) 24 | } 25 | setLoading(false) 26 | } 27 | 28 | return ( 29 | } 33 | menuItem={menuItem} 34 | loading={loading} 35 | /> 36 | ) 37 | } 38 | 39 | export default ActionUndelete 40 | -------------------------------------------------------------------------------- /src/components/Post/Actions/MicropubUpdate.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useReactRouter from 'use-react-router' 3 | import EditIcon from '@material-ui/icons/Edit' 4 | import BaseAction from './Base' 5 | import { kebabCase } from 'lodash' 6 | 7 | const toMf2 = jf2 => { 8 | let mf2 = {} 9 | for (const key in jf2) { 10 | if (jf2.hasOwnProperty(key)) { 11 | if (!key.startsWith('_')) { 12 | if (key === 'content') { 13 | mf2.content = 14 | jf2.content.html || jf2.content.text 15 | ? [{ html: jf2.content.html, value: jf2.content.text }] 16 | : [jf2.content] 17 | } else { 18 | let value = jf2[key] 19 | if (value !== null) { 20 | if (!Array.isArray(value)) { 21 | value = [value] 22 | } 23 | mf2[kebabCase(key)] = value 24 | } 25 | } 26 | } 27 | } 28 | } 29 | return mf2 30 | } 31 | 32 | const ActionUpdate = ({ post, menuItem, onClose }) => { 33 | const { history } = useReactRouter() 34 | 35 | const openEditor = e => { 36 | history.push('/editor', { update: true, properties: toMf2(post) }) 37 | if (onClose) { 38 | onClose() 39 | } 40 | } 41 | 42 | return ( 43 | } 47 | menuItem={menuItem} 48 | /> 49 | ) 50 | } 51 | 52 | export default ActionUpdate 53 | -------------------------------------------------------------------------------- /src/components/Post/Actions/Mute.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MuteIcon from '@material-ui/icons/VolumeOff' 3 | import BaseAction from './Base' 4 | import { useMutation } from 'react-apollo-hooks' 5 | import { useSnackbar } from 'notistack' 6 | import { MUTE, GET_TIMELINE } from '../../../queries' 7 | 8 | const ActionMute = ({ url, channel, menuItem }) => { 9 | const { enqueueSnackbar } = useSnackbar() 10 | 11 | const mute = useMutation(MUTE, { 12 | variables: { channel, url }, 13 | optimisticResponse: { 14 | __typename: 'Mutation', 15 | mute: true, 16 | }, 17 | update: (proxy, _) => { 18 | // Read the data from our cache for this query. 19 | const data = proxy.readQuery({ 20 | query: GET_TIMELINE, 21 | variables: { channel }, 22 | }) 23 | // Find and remove posts with the given author url 24 | data.timeline.items = data.timeline.items.filter( 25 | post => post.author.url !== url 26 | ) 27 | // Write our data back to the cache. 28 | proxy.writeQuery({ query: GET_TIMELINE, variables: { channel }, data }) 29 | }, 30 | }) 31 | 32 | const handleMute = async e => { 33 | try { 34 | await mute() 35 | enqueueSnackbar('User muted', { variant: 'success' }) 36 | } catch (err) { 37 | console.error('Error muting user', err) 38 | enqueueSnackbar('Error muting user', { variant: 'error' }) 39 | } 40 | } 41 | 42 | return ( 43 | } 47 | menuItem={menuItem} 48 | /> 49 | ) 50 | } 51 | 52 | export default ActionMute 53 | -------------------------------------------------------------------------------- /src/components/Post/Actions/Refetch.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useMutation } from 'react-apollo-hooks' 3 | import { useSnackbar } from 'notistack' 4 | import RefetchIcon from '@material-ui/icons/Refresh' 5 | import BaseAction from './Base' 6 | import { REFETCH_POST } from '../../../queries' 7 | 8 | const ActionRefetch = ({ url, _id, menuItem, handleClose }) => { 9 | const [loading, setLoading] = useState(false) 10 | const { enqueueSnackbar } = useSnackbar() 11 | const refetchPost = useMutation(REFETCH_POST, { 12 | variables: { post: _id, url }, 13 | }) 14 | 15 | const onClick = async e => { 16 | setLoading(true) 17 | const { 18 | data: { refetchPost: update }, 19 | error, 20 | } = await refetchPost() 21 | setLoading(false) 22 | if (error) { 23 | enqueueSnackbar('Error loading post content', { variant: 'error' }) 24 | } else if (!update || !update.content) { 25 | enqueueSnackbar('Could not parse post content', { variant: 'warning' }) 26 | } 27 | handleClose() 28 | } 29 | 30 | return ( 31 | } 35 | menuItem={menuItem} 36 | loading={loading} 37 | /> 38 | ) 39 | } 40 | 41 | export default ActionRefetch 42 | -------------------------------------------------------------------------------- /src/components/Post/Actions/Remove.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import RemoveIcon from '@material-ui/icons/Delete' 3 | import BaseAction from './Base' 4 | import { useSnackbar } from 'notistack' 5 | import { useMutation } from 'react-apollo-hooks' 6 | import { REMOVE_POST, GET_TIMELINE } from '../../../queries' 7 | 8 | const ActionRemove = ({ _id, channel, menuItem }) => { 9 | const { enqueueSnackbar } = useSnackbar() 10 | 11 | const removePost = useMutation(REMOVE_POST, { 12 | variables: { channel, post: _id }, 13 | optimisticResponse: { 14 | __typename: 'Mutation', 15 | removePost: { 16 | _id, 17 | __typename: 'Post', 18 | }, 19 | }, 20 | update: (proxy, _) => { 21 | // Read the data from our cache for this query. 22 | const data = proxy.readQuery({ 23 | query: GET_TIMELINE, 24 | variables: { channel }, 25 | }) 26 | // Find and remove the post 27 | data.timeline.items = data.timeline.items.filter(post => post._id !== _id) 28 | // Write our data back to the cache. 29 | proxy.writeQuery({ query: GET_TIMELINE, variables: { channel }, data }) 30 | }, 31 | }) 32 | 33 | const handleRemove = async e => { 34 | try { 35 | await removePost() 36 | enqueueSnackbar('Post removed', { variant: 'success' }) 37 | } catch (err) { 38 | console.error('Error removing post', err) 39 | enqueueSnackbar('Error removing post', { variant: 'error' }) 40 | } 41 | } 42 | 43 | return ( 44 | } 48 | menuItem={menuItem} 49 | /> 50 | ) 51 | } 52 | 53 | export default ActionRemove 54 | -------------------------------------------------------------------------------- /src/components/Post/Actions/Reply.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from 'react' 2 | import { useMutation } from 'react-apollo-hooks' 3 | import { useSnackbar } from 'notistack' 4 | import ReplyIcon from '@material-ui/icons/Reply' 5 | import { Popover, LinearProgress } from '@material-ui/core' 6 | import useUser from '../../../hooks/use-user' 7 | import BaseAction from './Base' 8 | import SnackbarLink from '../../SnackbarActions/Link' 9 | import SnackbarUndo from '../../SnackbarActions/Undo' 10 | import MicropubForm from '../../MicropubForm' 11 | import { MICROPUB_CREATE, MICROPUB_DELETE } from '../../../queries' 12 | 13 | const ActionReply = ({ url, menuItem }) => { 14 | const { enqueueSnackbar, closeSnackbar } = useSnackbar() 15 | const { user } = useUser() 16 | const [loading, setLoading] = useState(false) 17 | const [popoverAnchor, setPopoverAnchor] = useState(null) 18 | const [defaultProperties, setDefaultProperties] = useState({ 19 | 'in-reply-to': url, 20 | }) 21 | 22 | const createRepost = useMutation(MICROPUB_CREATE) 23 | const micropubDelete = useMutation(MICROPUB_DELETE) 24 | 25 | const handleSubmit = async mf2 => { 26 | setLoading(true) 27 | const properties = mf2.properties 28 | const oldPopoverAnchor = popoverAnchor 29 | try { 30 | const { 31 | data: { micropubCreate: postUrl }, 32 | } = await createRepost({ 33 | variables: { 34 | json: JSON.stringify(mf2), 35 | }, 36 | }) 37 | setLoading(false) 38 | enqueueSnackbar('Posted reply', { 39 | variant: 'success', 40 | action: key => [ 41 | , 42 | { 44 | closeSnackbar(key) 45 | await micropubDelete({ variables: { url: postUrl } }) 46 | enqueueSnackbar('Deleted reply', { variant: 'success' }) 47 | setDefaultProperties(properties) 48 | setPopoverAnchor(oldPopoverAnchor) 49 | }} 50 | />, 51 | ], 52 | }) 53 | } catch (err) { 54 | setLoading(false) 55 | console.error('Error posting like', err) 56 | enqueueSnackbar('Error posting like', { variant: 'error' }) 57 | } 58 | setPopoverAnchor(null) 59 | } 60 | 61 | if ( 62 | user && 63 | user.settings.noteSyndication.length && 64 | !defaultProperties['mp-syndicate-to'] 65 | ) { 66 | setDefaultProperties({ 67 | ...defaultProperties, 68 | 'mp-syndicate-to': user.settings.noteSyndication, 69 | }) 70 | } 71 | 72 | return ( 73 | 74 | } 77 | onClick={e => setPopoverAnchor(e.target)} 78 | menuItem={menuItem} 79 | loading={loading} 80 | /> 81 | setPopoverAnchor(null)} 89 | onBackdropClick={() => setPopoverAnchor(null)} 90 | > 91 |
96 | 100 |
101 | {loading && } 102 |
103 |
104 | ) 105 | } 106 | 107 | export default ActionReply 108 | -------------------------------------------------------------------------------- /src/components/Post/Actions/Repost.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useMutation } from 'react-apollo-hooks' 3 | import { useSnackbar } from 'notistack' 4 | import RepostIcon from '@material-ui/icons/Repeat' 5 | import useUser from '../../../hooks/use-user' 6 | import BaseAction from './Base' 7 | import SnackbarLinkAction from '../../SnackbarActions/Link' 8 | import SnackbarUndo from '../../SnackbarActions/Undo' 9 | import { MICROPUB_CREATE, MICROPUB_DELETE } from '../../../queries' 10 | 11 | const ActionRepost = ({ url, menuItem }) => { 12 | const [loading, setLoading] = useState(false) 13 | const { enqueueSnackbar, closeSnackbar } = useSnackbar() 14 | const { user } = useUser() 15 | let mf2 = { 16 | type: ['h-entry'], 17 | properties: { 18 | 'repost-of': [url], 19 | }, 20 | } 21 | if (user && user.settings.repostSyndication.length) { 22 | mf2.properties['mp-syndicate-to'] = user.settings.repostSyndication 23 | } 24 | const createRepost = useMutation(MICROPUB_CREATE, { 25 | variables: { 26 | json: JSON.stringify(mf2), 27 | }, 28 | }) 29 | const micropubDelete = useMutation(MICROPUB_DELETE) 30 | 31 | const onClick = async e => { 32 | try { 33 | setLoading(true) 34 | const { 35 | data: { micropubCreate: postUrl }, 36 | } = await createRepost() 37 | enqueueSnackbar('Successfully reposted', { 38 | variant: 'success', 39 | action: key => [ 40 | , 41 | { 43 | closeSnackbar(key) 44 | await micropubDelete({ variables: { url: postUrl } }) 45 | enqueueSnackbar('Deleted repost', { variant: 'success' }) 46 | }} 47 | />, 48 | ], 49 | }) 50 | } catch (err) { 51 | console.error('Error reposting', err) 52 | enqueueSnackbar('Error reposting', { variant: 'error' }) 53 | } 54 | setLoading(false) 55 | } 56 | return ( 57 | } 61 | menuItem={menuItem} 62 | loading={loading} 63 | /> 64 | ) 65 | } 66 | 67 | export default ActionRepost 68 | -------------------------------------------------------------------------------- /src/components/Post/Actions/View.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BaseAction from './Base' 3 | import ViewIcon from '@material-ui/icons/Link' 4 | 5 | const ActionView = ({ url, menuItem }) => ( 6 | 7 | {}} icon={} /> 8 | 9 | ) 10 | 11 | export default ActionView 12 | -------------------------------------------------------------------------------- /src/components/Post/Actions/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | actions: { 3 | display: 'flex', 4 | }, 5 | moreButton: { 6 | marginLeft: 'auto', 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /src/components/Post/Content/TruncatedContentLoader.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { useMutation } from 'react-apollo-hooks' 4 | import Button from '@material-ui/core/Button' 5 | import CircularProgress from '@material-ui/core/CircularProgress' 6 | import { useSnackbar } from 'notistack' 7 | import { REFETCH_POST } from '../../../queries' 8 | 9 | const hasTruncatedContent = post => { 10 | const html = 11 | post && post.content ? post.content.html || post.content.text : null 12 | const url = post && post.url ? post.url : null 13 | 14 | if (!html || !url) { 15 | return false 16 | } 17 | 18 | // If twitter url then almost certainly not truncated 19 | if (new URL(url).hostname === 'twitter.com') { 20 | return false 21 | } 22 | 23 | const el = document.createElement('div') 24 | el.innerHTML = html 25 | if (el.innerText.endsWith('...')) { 26 | // The content ends with an ellipsis, it is probably truncated 27 | return true 28 | } 29 | if (url) { 30 | const selfLink = el.querySelector(`a[href^="${url}"]`) 31 | if (selfLink) { 32 | // The content links to itself. It might be truncated 33 | const linkText = selfLink.innerText.toLowerCase() 34 | const truncateWords = ['continue', 'read', 'more'] 35 | for (const word of truncateWords) { 36 | if (linkText.includes(word)) { 37 | // The text contains one of the words above so probably is truncated 38 | return true 39 | } 40 | } 41 | } 42 | } 43 | return false 44 | } 45 | 46 | const TruncatedContentLoader = ({ post }) => { 47 | const [loading, setLoading] = useState(false) 48 | const [isTruncated, setIsTruncated] = useState(hasTruncatedContent(post)) 49 | const { enqueueSnackbar } = useSnackbar() 50 | const refetchPost = useMutation(REFETCH_POST, { 51 | variables: { post: post._id, url: post.url }, 52 | }) 53 | 54 | useEffect(() => { 55 | setIsTruncated(hasTruncatedContent(post)) 56 | }, [post]) 57 | 58 | const handleLoad = async e => { 59 | e.preventDefault() 60 | setLoading(true) 61 | const { 62 | data: { refetchPost: update }, 63 | error, 64 | } = await refetchPost() 65 | setLoading(false) 66 | setIsTruncated(false) 67 | if (error) { 68 | enqueueSnackbar('Error loading post content', { variant: 'error' }) 69 | } else if (!update || !update.content) { 70 | enqueueSnackbar('Could not parse post content', { variant: 'warning' }) 71 | } 72 | } 73 | 74 | if (isTruncated) { 75 | return ( 76 | 93 | ) 94 | } 95 | return null 96 | } 97 | 98 | TruncatedContentLoader.propTypes = { 99 | post: PropTypes.object.isRequired, 100 | } 101 | 102 | export default TruncatedContentLoader 103 | -------------------------------------------------------------------------------- /src/components/Post/Content/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import CardContent from '@material-ui/core/CardContent' 4 | import Collapse from '@material-ui/core/Collapse' 5 | import Typography from '@material-ui/core/Typography' 6 | import Button from '@material-ui/core/Button' 7 | import Divider from '@material-ui/core/Divider' 8 | import ExpandIcon from '@material-ui/icons/ExpandMore' 9 | import CollapseIcon from '@material-ui/icons/ExpandLess' 10 | import parse from 'html-react-parser' 11 | import TruncatedContentLoader from './TruncatedContentLoader' 12 | import style from './style' 13 | 14 | const TogetherCardContent = ({ classes, post, expandable = false }) => { 15 | const [expanded, setExpanded] = useState(false) 16 | 17 | const isExpandable = () => { 18 | let contentLength = 0 19 | if (post.content && post.content.text) { 20 | contentLength = post.content.text.length 21 | } else if (post.content && post.content.html) { 22 | contentLength = post.content.html.length 23 | } 24 | if (contentLength > 300) { 25 | return expandable 26 | } 27 | return false 28 | } 29 | 30 | const getContent = () => { 31 | if (post.summary && !post.content) { 32 | return { 33 | component: 'p', 34 | content: post.summary, 35 | } 36 | } 37 | 38 | if (post.content && post.content.html) { 39 | return { 40 | component: 'div', 41 | content: post.content.html, 42 | } 43 | } 44 | 45 | if (post.content && post.content.text) { 46 | return { 47 | component: 'p', 48 | content: post.content.text, 49 | } 50 | } 51 | 52 | return null 53 | } 54 | 55 | const content = getContent() 56 | 57 | if (content && content.component === 'div' && content.content) { 58 | content.content = parse(content.content, { 59 | replace: (domNode) => { 60 | // Open links in new tabs always 61 | if (domNode.name === 'a') { 62 | domNode.attribs.target = '_blank' 63 | domNode.attribs.rel = 'noopener noreferrer' 64 | } 65 | }, 66 | }) 67 | } 68 | 69 | return ( 70 | 71 | {!!post.name && ( 72 | 78 | )} 79 | 80 | {!!content && ( 81 | 85 | 86 | {content.content} 87 | 88 | 89 | 90 | )} 91 | 92 | {isExpandable() && ( 93 | 94 | 95 | 104 | 105 | )} 106 | 107 | ) 108 | } 109 | export default withStyles(style)(TogetherCardContent) 110 | -------------------------------------------------------------------------------- /src/components/Post/Content/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | content: { 3 | '& img': { 4 | maxWidth: '100%', 5 | height: 'auto', 6 | }, 7 | '& blockquote': { 8 | borderLeft: '4px solid ' + theme.palette.primary.main, 9 | paddingLeft: theme.spacing(2), 10 | marginLeft: theme.spacing(2), 11 | '& blockquote': { 12 | marginLeft: theme.spacing(1), 13 | }, 14 | }, 15 | // Emoji images' 16 | '& img[src^="https://s.w.org/images/core/emoji"]': { 17 | width: '1em', 18 | }, 19 | }, 20 | divider: { 21 | marginBottom: theme.spacing(1), 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/Post/Header/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { CardHeader } from '@material-ui/core' 4 | import moment from 'moment' 5 | import useReactRouter from 'use-react-router' 6 | import AuthorAvatar from '../../AuthorAvatar' 7 | import useCurrentChannel from '../../../hooks/use-current-channel' 8 | import authorToAvatarData from '../../../modules/author-to-avatar-data' 9 | 10 | const TogetherCardHeader = ({ item, shownActions }) => { 11 | const { history } = useReactRouter() 12 | const channel = useCurrentChannel() 13 | // Parse author data 14 | const avatarData = authorToAvatarData(item.author) 15 | 16 | // Parse published date 17 | let date = 'unknown' 18 | if (item.published) { 19 | date = moment(item.published).fromNow() 20 | } 21 | 22 | if (!Array.isArray(shownActions)) { 23 | shownActions = ['consoleLog', 'markRead', 'remove'] 24 | if (item.url) { 25 | shownActions.push('view') 26 | shownActions.push('refetch') 27 | } 28 | if (item.url && !item['likeOf'] && !item['repostOf']) { 29 | shownActions.push('like', 'repost', 'reply') 30 | } 31 | } 32 | 33 | const authorNameLink = avatarData.href ? ( 34 | 35 | {avatarData.alt} 36 | 37 | ) : ( 38 | avatarData.alt 39 | ) 40 | 41 | return ( 42 | { 44 | if (item._source && history && channel) { 45 | e.preventDefault() 46 | history.push(`/channel/${channel._t_slug}/${item._source}`) 47 | } 48 | }} 49 | title={authorNameLink} 50 | subheader={date} 51 | avatar={} 52 | /> 53 | ) 54 | } 55 | 56 | TogetherCardHeader.propTypes = { 57 | item: PropTypes.object.isRequired, 58 | } 59 | 60 | export default TogetherCardHeader 61 | -------------------------------------------------------------------------------- /src/components/Post/Location/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import Map from '../../Map' 3 | import MapMarker from '../../Map/Marker' 4 | import CardContent from '@material-ui/core/CardContent' 5 | import Typography from '@material-ui/core/Typography' 6 | 7 | const TogetherCardLocation = ({ location, author }) => { 8 | let lat = false 9 | let lng = false 10 | if (!location) { 11 | return null 12 | } 13 | if (location.latitude && location.longitude) { 14 | lat = parseFloat(location.latitude) 15 | lng = parseFloat(location.longitude) 16 | } 17 | 18 | return ( 19 | 20 | {location.name && ( 21 | 22 | {location.name} 23 | 24 | )} 25 | {lat !== false && lng !== false && ( 26 | 27 | 28 | 29 | )} 30 | 31 | ) 32 | } 33 | 34 | export default TogetherCardLocation 35 | -------------------------------------------------------------------------------- /src/components/Post/Meta.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { CardContent, Chip, Tooltip } from '@material-ui/core' 4 | import CategoryIcon from '@material-ui/icons/Label' 5 | import StatusIcon from '@material-ui/icons/Public' 6 | import VisibilityIcon from '@material-ui/icons/Visibility' 7 | 8 | const metaProperties = ['postStatus', 'visibility', 'category'] 9 | 10 | const styles = theme => ({ 11 | chip: { 12 | marginRight: theme.spacing(1), 13 | marginBottom: theme.spacing(1), 14 | }, 15 | }) 16 | 17 | function stringToColor(string) { 18 | if (!string) { 19 | return '#000' 20 | } 21 | let hash = 0 22 | for (let i = 0; i < string.length; i++) { 23 | hash = string.charCodeAt(i) + ((hash << 5) - hash) 24 | } 25 | let color = '#' 26 | for (let x = 0; x < 3; x++) { 27 | const value = (hash >> (x * 8)) & 0xff 28 | color += ('00' + value.toString(16)).substr(-2) 29 | } 30 | return color 31 | } 32 | 33 | const PostMeta = ({ item = {}, classes }) => { 34 | if (!metaProperties.find(prop => !!item[prop])) { 35 | return null 36 | } 37 | return ( 38 | 39 | {!!item.category && 40 | item.category.map((cat, i) => { 41 | return ( 42 | 43 | } 47 | style={{ background: stringToColor(cat) }} 48 | /> 49 | 50 | ) 51 | })} 52 | {!!item.postStatus && ( 53 | 54 | } 58 | /> 59 | 60 | )} 61 | {!!item.visibility && ( 62 | 63 | } 67 | /> 68 | 69 | )} 70 | 71 | ) 72 | } 73 | 74 | export default withStyles(styles)(PostMeta) 75 | -------------------------------------------------------------------------------- /src/components/Post/Photos/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import GridList from '@material-ui/core/GridList' 4 | import GridListTile from '@material-ui/core/GridListTile' 5 | import GallerySlider from '../../GallerySlider' 6 | import resizeImage from '../../../modules/get-image-proxy-url' 7 | import style from './style' 8 | 9 | const TogetherCardPhotos = ({ classes, photos }) => { 10 | const [selectedPhoto, setSelectedPhoto] = useState(null) 11 | 12 | const Photos = () => { 13 | if (photos.length === 1) { 14 | return ( 15 | setSelectedPhoto(0)} 20 | /> 21 | ) 22 | } else if (Array.isArray(photos)) { 23 | let cols = photos.length 24 | if (cols === 4) { 25 | cols = 2 26 | } 27 | if (cols > 3) { 28 | cols = 3 29 | } 30 | let cellHeight = 200 31 | let cardWidth = document.getElementById('root').clientWidth - 49 - 12 - 12 32 | if (cardWidth < 600) { 33 | cellHeight = Math.floor(cardWidth / 3) 34 | } 35 | return ( 36 | 37 | {photos.map((photo, i) => ( 38 | setSelectedPhoto(i)} 42 | > 43 | 51 | 52 | ))} 53 | 54 | ) 55 | } 56 | return null 57 | } 58 | 59 | return ( 60 | 61 | 62 | {selectedPhoto !== null && ( 63 | ({ photo }))} 65 | startIndex={selectedPhoto} 66 | onClose={() => setSelectedPhoto(null)} 67 | open={true} 68 | /> 69 | )} 70 | 71 | ) 72 | } 73 | 74 | export default withStyles(style)(TogetherCardPhotos) 75 | -------------------------------------------------------------------------------- /src/components/Post/Photos/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | fullImage: { 3 | display: 'block', 4 | maxWidth: '100%', 5 | margin: '0 auto', 6 | height: 'auto', 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /src/components/Post/ReplyContext/index.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import CardContent from '@material-ui/core/CardContent' 4 | import Typography from '@material-ui/core/Typography' 5 | import LikeIcon from '@material-ui/icons/ThumbUp' 6 | import BookmarkIcon from '@material-ui/icons/Bookmark' 7 | import ReplyIcon from '@material-ui/icons/Reply' 8 | import RepostIcon from '@material-ui/icons/Repeat' 9 | import QuoteIcon from '@material-ui/icons/FormatQuote' 10 | import Post from '../index' 11 | import style from './style' 12 | 13 | const TogetherCardReplyContext = ({ type, url, reference, classes }) => { 14 | let icon = null 15 | switch (type) { 16 | case 'reply': 17 | icon = 18 | break 19 | case 'like': 20 | icon = 21 | break 22 | case 'repost': 23 | icon = 24 | break 25 | case 'bookmark': 26 | icon = 27 | break 28 | case 'quotation': 29 | icon = 30 | break 31 | default: 32 | icon = null 33 | break 34 | } 35 | return ( 36 | 37 | 38 | 39 | {icon} 40 | 41 | {url} 42 | 43 | 44 | 45 | {reference && ( 46 | 47 | 51 | 52 | )} 53 | 54 | ) 55 | } 56 | export default withStyles(style)(TogetherCardReplyContext) 57 | -------------------------------------------------------------------------------- /src/components/Post/ReplyContext/style.js: -------------------------------------------------------------------------------- 1 | import { darken } from '@material-ui/core/styles/colorManipulator' 2 | 3 | export default theme => ({ 4 | replyContext: { 5 | background: 6 | theme.palette.type === 'dark' 7 | ? darken(theme.palette.background.paper, 0.2) 8 | : darken(theme.palette.background.paper, 0.07), 9 | }, 10 | replyUrl: { 11 | whiteSpace: 'nowrap', 12 | overflow: 'hidden', 13 | textOverflow: 'ellipsis', 14 | }, 15 | icon: { 16 | marginRight: 10, 17 | marginBottom: -5, 18 | width: 18, 19 | height: 18, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/Post/Shortcuts.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { Shortcuts } from 'react-shortcuts' 5 | import useLocalState from '../../hooks/use-local-state' 6 | import useMarkRead from '../../hooks/use-mark-read' 7 | import useMarkUnread from '../../hooks/use-mark-unread' 8 | import useCurrentChannel from '../../hooks/use-current-channel' 9 | 10 | const styles = theme => { 11 | const color = 12 | theme.palette.type === 'dark' 13 | ? theme.palette.secondary.main 14 | : theme.palette.primary.main 15 | return { 16 | main: { 17 | display: 'block', 18 | outline: 'none', 19 | '&:focus, &.is-focused': { 20 | boxShadow: `inset 0 0 4px ${color}`, 21 | }, 22 | }, 23 | } 24 | } 25 | 26 | const PostShortcuts = ({ 27 | children, 28 | classes, 29 | focus, 30 | onNext, 31 | scrollElement, 32 | post, 33 | className, 34 | ...props 35 | }) => { 36 | const ref = useRef() 37 | const [localState, setLocalState] = useLocalState() 38 | const markRead = useMarkRead() 39 | const markUnread = useMarkUnread() 40 | const channel = useCurrentChannel() 41 | 42 | useEffect(() => { 43 | const el = ref.current._domNode 44 | if ( 45 | focus && 46 | localState.focusedComponent === 'post' && 47 | el !== document.activeElement 48 | ) { 49 | el.focus() 50 | } 51 | }, [focus, localState.focusedComponent]) 52 | 53 | const handleShortcuts = action => { 54 | switch (action) { 55 | case 'SCROLL_DOWN': 56 | if (scrollElement) { 57 | scrollElement.scrollBy(0, 50) 58 | } 59 | break 60 | case 'SCROLL_UP': 61 | if (scrollElement) { 62 | scrollElement.scrollBy(0, -50) 63 | } 64 | break 65 | case 'TO_TIMELINE': 66 | setLocalState({ focusedComponent: 'timeline' }) 67 | break 68 | case 'NEXT': 69 | onNext() 70 | break 71 | case 'OPEN': 72 | if (post.url) { 73 | window.open(post.url, '_blank') 74 | } 75 | break 76 | case 'TOGGLE_READ': 77 | if (channel && channel.uid) { 78 | if (post._is_read) { 79 | markUnread(channel.uid, post._id) 80 | } else { 81 | markRead(channel.uid, post._id) 82 | } 83 | } 84 | break 85 | default: 86 | // Nothing to handle 87 | break 88 | } 89 | } 90 | 91 | const classNames = [classes.main] 92 | if (className) { 93 | classNames.push(className) 94 | } 95 | if (localState.focusedComponent === 'post' && focus) { 96 | classNames.push('is-focused') 97 | } 98 | 99 | return ( 100 | 107 | {children} 108 | 109 | ) 110 | } 111 | 112 | PostShortcuts.defaultProps = { 113 | focus: false, 114 | onNext: () => {}, 115 | } 116 | 117 | PostShortcuts.propTypes = { 118 | post: PropTypes.object.isRequired, 119 | focus: PropTypes.bool.isRequired, 120 | onNext: PropTypes.func.isRequired, 121 | } 122 | 123 | export default withStyles(styles)(PostShortcuts) 124 | -------------------------------------------------------------------------------- /src/components/Post/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | card: { 3 | maxWidth: '100%', 4 | marginTop: theme.spacing(3), 5 | marginBottom: theme.spacing(3), 6 | overflow: 'hidden', 7 | }, 8 | video: { 9 | width: '100%', 10 | objectFit: 'contain', 11 | background: '#111', 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/components/ServiceWorker.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useSnackbar } from 'notistack' 3 | import { IconButton } from '@material-ui/core' 4 | import ReloadIcon from '@material-ui/icons/Refresh' 5 | import * as serviceWorker from '../serviceWorkerRegistration' 6 | 7 | const ReloadButton = ({ reg }) => { 8 | const { enqueueSnackbar } = useSnackbar() 9 | 10 | return ( 11 | { 14 | try { 15 | const res = reg.waiting.postMessage('skipWaiting') 16 | console.log({ res }) 17 | // window.location.reload(true) 18 | } catch (err) { 19 | console.error('[Error skipping service worker waiting]', err) 20 | enqueueSnackbar('Error loading new version', { variant: 'error' }) 21 | } 22 | }} 23 | > 24 | 25 | 26 | ) 27 | } 28 | 29 | const ServiceWorker = () => { 30 | const { enqueueSnackbar } = useSnackbar() 31 | 32 | useEffect(() => { 33 | serviceWorker.register({ 34 | onUpdate: (reg) => { 35 | enqueueSnackbar('New version available. Click to reload.', { 36 | action: [], 37 | }) 38 | }, 39 | }) 40 | }, []) 41 | 42 | return null 43 | } 44 | 45 | export default ServiceWorker 46 | -------------------------------------------------------------------------------- /src/components/SettingsModal/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withRouter } from 'react-router' 4 | import { withStyles } from '@material-ui/core/styles' 5 | import { 6 | Dialog, 7 | DialogContent, 8 | Slide, 9 | AppBar, 10 | Toolbar, 11 | Typography, 12 | } from '@material-ui/core' 13 | import IconButton from '@material-ui/core/IconButton' 14 | import CloseIcon from '@material-ui/icons/Close' 15 | import useReactRouter from 'use-react-router' 16 | import styles from './style' 17 | 18 | const Transition = React.forwardRef(function Transition(props, ref) { 19 | return 20 | }) 21 | 22 | const SettingsModal = ({ 23 | classes, 24 | children, 25 | singleColumn, 26 | onClose, 27 | title, 28 | contentStyle, 29 | ...dialogProps 30 | }) => { 31 | const { history } = useReactRouter() 32 | const [open, setOpen] = useState(true) 33 | 34 | const handleClose = () => { 35 | setOpen(false) 36 | if (onClose) { 37 | onClose() 38 | } else { 39 | history.push('/') 40 | } 41 | } 42 | 43 | return ( 44 | 54 | 55 | 56 | 57 | {title} 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 | 69 | {children} 70 | 71 |
72 |
73 | ) 74 | } 75 | 76 | SettingsModal.defaultProps = { 77 | singleColumn: false, 78 | } 79 | 80 | SettingsModal.propTypes = { 81 | title: PropTypes.string.isRequired, 82 | onClose: PropTypes.func, 83 | singleColumn: PropTypes.bool.isRequired, 84 | } 85 | 86 | export default withRouter(withStyles(styles)(SettingsModal)) 87 | -------------------------------------------------------------------------------- /src/components/SettingsModal/style.js: -------------------------------------------------------------------------------- 1 | export default theme => ({ 2 | dialogPaper: { 3 | overflow: 'hidden', 4 | }, 5 | wrapper: { 6 | position: 'relative', 7 | width: '50em', 8 | maxWidth: '100%', 9 | overflow: 'auto', 10 | }, 11 | title: { 12 | flex: 1, 13 | fontWeight: 'normal', 14 | overflow: 'hidden', 15 | textOverflow: 'ellipsis', 16 | whiteSpace: 'nowrap', 17 | }, 18 | singleColumn: { 19 | display: 'block', 20 | }, 21 | twoColumns: { 22 | // [theme.breakpoints.up('sm')]: { 23 | // display: 'flex', 24 | // flexWrap: 'wrap', 25 | // flexDirection: 'row', 26 | // justifyContent: 'space-between', 27 | // '& > *': { 28 | // width: '48%', 29 | // }, 30 | // }, 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /src/components/Share/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Typography } from '@material-ui/core' 3 | import Meta from '../Meta' 4 | import Post from '../Post' 5 | 6 | const isUrl = string => { 7 | try { 8 | new URL(string) 9 | return true 10 | } catch (_) { 11 | return false 12 | } 13 | } 14 | 15 | const Share = () => { 16 | const parsedUrl = new URL(window.location) 17 | const title = parsedUrl.searchParams.get('title') 18 | const text = parsedUrl.searchParams.get('text') 19 | let url = parsedUrl.searchParams.get('url') 20 | 21 | if (!url && isUrl(title)) { 22 | url = title 23 | } else if (!url && isUrl(text)) { 24 | url = text 25 | } 26 | 27 | let cardTitle = 'Sharing' 28 | if (url) { 29 | cardTitle += ' ' + url 30 | } 31 | 32 | const post = { 33 | _id: null, 34 | _is_read: true, 35 | url: url, 36 | name: title, 37 | content: { 38 | text: text, 39 | // html: text, 40 | }, 41 | } 42 | 43 | return ( 44 |
45 | 46 | 47 | {cardTitle} 48 | 49 | 53 |
54 | ) 55 | } 56 | 57 | export default Share 58 | -------------------------------------------------------------------------------- /src/components/ShortcutHelp/ShortcutTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Table from '@material-ui/core/Table' 3 | import TableBody from '@material-ui/core/TableBody' 4 | import TableCell from '@material-ui/core/TableCell' 5 | import TableRow from '@material-ui/core/TableRow' 6 | import Typography from '@material-ui/core/Typography' 7 | import Chip from '@material-ui/core/Chip' 8 | 9 | const ShortcutTable = ({ title, keys }) => ( 10 |
11 | 15 | {title} 16 | 17 | 18 | 19 | {keys.map(key => ( 20 | 21 | {key.name} 22 | 23 | {key.shortcuts.map(key => ( 24 | 29 | ))} 30 | 31 | 32 | ))} 33 | 34 |
35 |
36 | ) 37 | 38 | export default ShortcutTable 39 | -------------------------------------------------------------------------------- /src/components/ShortcutHelp/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SettingsModal from '../SettingsModal' 3 | import keymap from '../../modules/keymap' 4 | import useLocalState from '../../hooks/use-local-state' 5 | import ShortcutTable from './ShortcutTable' 6 | 7 | const globalKeys = [ 8 | { 9 | name: 'Focus Channels List', 10 | shortcuts: keymap.GLOBAL.FOCUS_CHANNEL_LIST, 11 | }, 12 | { 13 | name: 'Show Keyboard Shorcuts', 14 | shortcuts: keymap.GLOBAL.HELP, 15 | }, 16 | { 17 | name: 'Open New Post Editor', 18 | shortcuts: keymap.GLOBAL.NEW_POST, 19 | }, 20 | { 21 | name: 'Load Channel', 22 | shortcuts: ['ctrl+1-9', 'meta+1-9', 'alt+1-9'], 23 | }, 24 | ] 25 | 26 | const channelListKeys = [ 27 | { 28 | name: 'Next Channel', 29 | shortcuts: keymap.CHANNEL_LIST.NEXT, 30 | }, 31 | { 32 | name: 'Previous Channel', 33 | shortcuts: keymap.CHANNEL_LIST.PREVIOUS, 34 | }, 35 | { 36 | name: 'Load Channel', 37 | shortcuts: keymap.CHANNEL_LIST.SELECT_CHANNEL, 38 | }, 39 | ] 40 | 41 | const channelKeys = [ 42 | { 43 | name: 'Next Post', 44 | shortcuts: keymap.TIMELINE.NEXT, 45 | }, 46 | { 47 | name: 'Previous Post', 48 | shortcuts: keymap.TIMELINE.PREVIOUS, 49 | }, 50 | { 51 | name: 'Select Post', 52 | shortcuts: keymap.TIMELINE.SELECT_POST, 53 | }, 54 | { 55 | name: 'Focus Channel List', 56 | shortcuts: keymap.TIMELINE.FOCUS_CHANNEL_LIST, 57 | }, 58 | { 59 | name: 'Toggle Selected Post Read', 60 | shortcuts: keymap.TIMELINE.MARK_READ, 61 | }, 62 | ] 63 | 64 | const singlePostKeys = [ 65 | { 66 | name: 'Next Post', 67 | shortcuts: keymap.POST.NEXT, 68 | }, 69 | { 70 | name: 'Open Post Url', 71 | shortcuts: keymap.POST.OPEN, 72 | }, 73 | { 74 | name: 'Toggle Post Read', 75 | shortcuts: keymap.POST.TOGGLE_READ, 76 | }, 77 | { 78 | name: 'Back to Post List', 79 | shortcuts: keymap.POST.TO_TIMELINE, 80 | }, 81 | { 82 | name: 'Scroll Up', 83 | shortcuts: keymap.POST.SCROLL_UP, 84 | }, 85 | { 86 | name: 'Scroll Down', 87 | shortcuts: keymap.POST.SCROLL_DOWN, 88 | }, 89 | ] 90 | 91 | const ShortcutHelp = () => { 92 | const [localState, setLocalState] = useLocalState() 93 | const open = !!localState.shortcutHelpOpen 94 | return ( 95 | setLocalState({ shortcutHelpOpen: false })} 100 | singleColumn 101 | disableAutoFocus 102 | > 103 | 104 | 105 | 106 | 107 | 108 | ) 109 | } 110 | 111 | export default ShortcutHelp 112 | -------------------------------------------------------------------------------- /src/components/SnackbarActions/Link.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IconButton } from '@material-ui/core' 3 | import LinkIcon from '@material-ui/icons/Link' 4 | 5 | const SnackbarLinkAction = ({ url }) => { 6 | return ( 7 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default SnackbarLinkAction 20 | -------------------------------------------------------------------------------- /src/components/SnackbarActions/Undo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IconButton } from '@material-ui/core' 3 | import UndoIcon from '@material-ui/icons/Replay' 4 | 5 | const SnackbarLinkAction = ({ onClick }) => { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | export default SnackbarLinkAction 14 | -------------------------------------------------------------------------------- /src/components/Source/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useReactRouter from 'use-react-router' 3 | import { CircularProgress } from '@material-ui/core' 4 | import useCurrentChannel from '../../hooks/use-current-channel' 5 | import SettingsModal from '../SettingsModal' 6 | import Timeline from '../Layout/Content' 7 | import useTimeline from '../../hooks/use-timeline' 8 | import useLocalState from '../../hooks/use-local-state' 9 | import theme from '../Theme/style' 10 | 11 | const Source = ({ 12 | match: { 13 | params: { source }, 14 | }, 15 | }) => { 16 | const [localState] = useLocalState() 17 | const muiTheme = theme(localState.theme) 18 | const { history } = useReactRouter() 19 | const channel = useCurrentChannel() 20 | const { data, fetchMore, networkStatus, loading } = useTimeline({ 21 | source, 22 | channel: channel.uid, 23 | }) 24 | 25 | const handleClose = () => { 26 | if (history) { 27 | history.push('/channel/' + channel._t_slug) 28 | } 29 | } 30 | 31 | let title = `${loading ? 'Loading' : 'Loaded'} Source` 32 | if (data.timeline && data.timeline.source && data.timeline.source.name) { 33 | title = data.timeline.source.name 34 | } 35 | 36 | return ( 37 | 47 | {loading ? ( 48 | 49 | ) : ( 50 | 56 | )} 57 | 58 | ) 59 | } 60 | 61 | export default Source 62 | -------------------------------------------------------------------------------- /src/components/TestMe/Tabs.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import useReactRouter from 'use-react-router' 3 | import { Link } from 'react-router-dom' 4 | import useMicropubQuery from '../../hooks/use-micropub-query' 5 | import { LinearProgress, AppBar, Tabs, Tab } from '@material-ui/core' 6 | 7 | const TabLink = ({ label, postType = '' }) => { 8 | return 9 | } 10 | 11 | const PostTypeTabs = () => { 12 | const [value, setValue] = useState(0) 13 | const { 14 | match: { 15 | params: { postType }, 16 | }, 17 | } = useReactRouter() 18 | const { data: config, loading } = useMicropubQuery('config') 19 | const postTypes = config && config['post-types'] ? config['post-types'] : [] 20 | 21 | // Set the tab index when url changes or post types loaded 22 | useEffect(() => { 23 | postTypes.forEach((type, i) => { 24 | if (type.type === postType) { 25 | setValue(i + 1) 26 | } 27 | }) 28 | }, [postType, postTypes]) 29 | 30 | return ( 31 | 36 | setValue(newValue)} 39 | indicatorColor="primary" 40 | textColor="primary" 41 | variant="scrollable" 42 | scrollButtons="auto" 43 | > 44 | 45 | {postTypes.map((type, i) => ( 46 | 51 | ))} 52 | 53 | {!!loading && } 54 | 55 | ) 56 | } 57 | 58 | export default PostTypeTabs 59 | -------------------------------------------------------------------------------- /src/components/TestMe/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useReactRouter from 'use-react-router' 3 | import { useQuery } from 'react-apollo-hooks' 4 | import gql from 'graphql-tag' 5 | import { FRAGMENT_POST } from '../../queries' 6 | import { CircularProgress, Typography } from '@material-ui/core' 7 | import Tabs from './Tabs' 8 | import Timeline from '../Layout/Timeline' 9 | 10 | const GET_POSTS = gql` 11 | query GetMicropubPosts($postType: String, $after: String, $before: String) { 12 | micropubPosts(postType: $postType, before: $before, after: $after) { 13 | after 14 | before 15 | items { 16 | ...PostFragment 17 | refs { 18 | ...PostFragment 19 | } 20 | } 21 | } 22 | } 23 | ${FRAGMENT_POST} 24 | ` 25 | 26 | const MicropubPosts = props => { 27 | const { 28 | match: { 29 | params: { postType }, 30 | }, 31 | } = useReactRouter() 32 | 33 | let query = { 34 | notifyOnNetworkStatusChange: true, 35 | } 36 | if (postType) { 37 | query.variables = { postType } 38 | } 39 | 40 | const { 41 | networkStatus, 42 | data: { micropubPosts: res }, 43 | fetchMore, 44 | } = useQuery(GET_POSTS, query) 45 | const loading = networkStatus < 7 46 | 47 | const loadMore = 48 | res && res.items && res.items.length 49 | ? () => { 50 | fetchMore({ 51 | query: GET_POSTS, 52 | variables: { postType, after: res.after }, 53 | updateQuery: (previousResult, { fetchMoreResult }) => ({ 54 | micropubPosts: { 55 | after: fetchMoreResult.micropubPosts.after, 56 | before: previousResult.micropubPosts.before, 57 | items: [ 58 | ...previousResult.micropubPosts.items, 59 | ...fetchMoreResult.micropubPosts.items, 60 | ], 61 | __typename: previousResult.micropubPosts.__typename, 62 | }, 63 | }), 64 | }) 65 | } 66 | : null 67 | 68 | return ( 69 |
77 | 78 |
79 | {!loading && res && res.items && res.items.length === 0 && ( 80 | 81 | Nothing found... 82 | 83 | )} 84 | {res && res.items && ( 85 | 98 | )} 99 | {loading && ( 100 | 101 | )} 102 |
103 |
104 | ) 105 | } 106 | 107 | export default MicropubPosts 108 | -------------------------------------------------------------------------------- /src/components/Theme/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MuiThemeProvider } from '@material-ui/core/styles' 3 | import CssBaseline from '@material-ui/core/CssBaseline' 4 | import useLocalState from '../../hooks/use-local-state' 5 | import theme from './style' 6 | 7 | const Theme = ({ children }) => { 8 | const [localState] = useLocalState() 9 | const muiTheme = theme(localState.theme) 10 | 11 | return ( 12 | 13 | 14 | {children} 15 | 16 | ) 17 | } 18 | export default Theme 19 | -------------------------------------------------------------------------------- /src/components/Theme/style.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles' 2 | // import secondary from '@material-ui/core/colors/indigo' 3 | // import primary from '@material-ui/core/colors/blue' 4 | 5 | export default (type) => { 6 | const dark = type === 'dark' ? true : false 7 | return createMuiTheme({ 8 | typography: { 9 | fontFamily: 10 | 'system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif', 11 | fontWeightLight: 300, 12 | fontWeightRegular: 400, 13 | fontWeightMedium: 900, 14 | }, 15 | palette: { 16 | type, 17 | primary: { main: '#104ebf' }, 18 | secondary: { main: '#1c1f24' }, 19 | // primary: { 20 | // light: dark ? '#222' : '#5e92f3', 21 | // main: dark ? '#111' : '#1565c0', 22 | // dark: dark ? '#000000' : '#003c8f', 23 | // contrastText: dark ? '#fff' : '#fff', 24 | // }, 25 | // secondary: { 26 | // light: dark ? '#5e92f3' : '#5472d3', 27 | // main: dark ? '#1565c0' : '#0d47a1', 28 | // dark: dark ? '#003c8f' : '#002171', 29 | // contrastText: dark ? '#fff' : '#fff', 30 | // }, 31 | background: { 32 | paper: dark ? '#222' : '#fff', 33 | default: dark ? '#111' : '#fafafa', 34 | }, 35 | }, 36 | together: { 37 | drawerWidth: 240, 38 | }, 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ApolloProvider } from 'react-apollo' 3 | import { ApolloProvider as ApolloHooksProvider } from 'react-apollo-hooks' 4 | import client from '../modules/apollo' 5 | import Routes from './Routes' 6 | import Theme from '../components/Theme' 7 | 8 | const App = () => ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | 18 | export default App 19 | -------------------------------------------------------------------------------- /src/containers/style.js: -------------------------------------------------------------------------------- 1 | const scrollbarWidth = 6 2 | let modifyScrollbars = false 3 | 4 | if (navigator && navigator.platform) { 5 | const searches = ['Win' /*, 'Linux'*/] // Linux catches for android devices sometimes which is annoying 6 | const res = searches.find(search => navigator.platform.includes(search)) 7 | if (res) { 8 | modifyScrollbars = true 9 | } 10 | } 11 | 12 | const scrollbarStyles = theme => 13 | modifyScrollbars 14 | ? { 15 | '*::-webkit-scrollbar': { 16 | width: scrollbarWidth, 17 | height: scrollbarWidth, 18 | }, 19 | 20 | '*::-webkit-scrollbar-thumb': { 21 | backgroundColor: theme.palette.text.hint, 22 | borderRadius: scrollbarWidth / 2, 23 | }, 24 | } 25 | : {} 26 | 27 | export default theme => ({ 28 | appWrapper: { 29 | width: '100%', 30 | height: '100%', 31 | flexDirection: 'column', 32 | flexWrap: 'nowrap', 33 | overflow: 'hidden', 34 | }, 35 | '@global': { 36 | 'html, body, #root': { 37 | margin: 0, 38 | padding: 0, 39 | fontFamily: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif`, 40 | height: '100%', 41 | overflowX: 'hidden', 42 | overscrollBehavior: 'none', 43 | }, 44 | 'a[href^=http]': { 45 | color: 46 | theme.palette.type === 'dark' 47 | ? theme.palette.primary.light 48 | : theme.palette.primary.main, 49 | }, 50 | ...scrollbarStyles(theme), 51 | }, 52 | root: { 53 | background: theme.palette.background.default, 54 | flexGrow: 1, 55 | flexShrink: 1, 56 | flexWrap: 'nowrap', 57 | position: 'relative', 58 | transition: 'transform .3s', 59 | overflow: 'hidden', 60 | width: '100%', 61 | }, 62 | main: { 63 | background: theme.palette.background.default, 64 | overflow: 'hidden', 65 | flexGrow: 1, 66 | flexShrink: 1, 67 | }, 68 | }) 69 | -------------------------------------------------------------------------------- /src/hooks/use-channels.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-apollo-hooks' 2 | import { GET_CHANNELS } from '../queries' 3 | 4 | const useChannels = () => { 5 | const res = useQuery(GET_CHANNELS, { 6 | pollInterval: 60 * 1000, 7 | }) 8 | const channels = res.data.channels ? res.data.channels : [] 9 | // return { channels: channels.filter(c => c.uid !== 'notifications'), ...res } 10 | return { channels, ...res } 11 | } 12 | 13 | export default useChannels 14 | -------------------------------------------------------------------------------- /src/hooks/use-current-channel.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-apollo-hooks' 2 | import useReactRouter from 'use-react-router' 3 | import { GET_CHANNELS } from '../queries' 4 | 5 | const useChannels = () => { 6 | const res = useQuery(GET_CHANNELS) 7 | const { 8 | location: { pathname }, 9 | } = useReactRouter() 10 | const channels = res.data.channels ? res.data.channels : [] 11 | let channel = {} 12 | let channelSlug = null 13 | if (pathname.startsWith('/channel/')) { 14 | const parts = pathname.split('/') 15 | channelSlug = parts[2] 16 | } 17 | if (channels.length && channelSlug) { 18 | channel = channels.find(c => c._t_slug === channelSlug) || {} 19 | } 20 | return channel 21 | } 22 | 23 | export default useChannels 24 | -------------------------------------------------------------------------------- /src/hooks/use-local-state.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useState, useEffect } from 'react' 2 | import { useQuery, useApolloClient } from 'react-apollo-hooks' 3 | import gql from 'graphql-tag' 4 | 5 | const GET_ALL = gql` 6 | { 7 | theme @client 8 | token @client 9 | channelsMenuOpen @client 10 | focusedComponent @client 11 | shortcutHelpOpen @client 12 | } 13 | ` 14 | 15 | export default function() { 16 | const client = useApolloClient() 17 | const { data: localState } = useQuery(GET_ALL) 18 | 19 | const [state, setState] = useState(localState || {}) 20 | 21 | const set = useCallback(data => { 22 | client.writeData({ data }) 23 | if (data.theme) { 24 | localStorage.setItem('together-theme', data.theme) 25 | } 26 | setState(Object.assign({}, state.current, data)) 27 | }, []) 28 | 29 | useEffect(() => { 30 | setState(localState) 31 | }, [localState]) 32 | 33 | return [state, set] 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks/use-mark-channel-read.js: -------------------------------------------------------------------------------- 1 | import { useMutation, useApolloClient } from 'react-apollo-hooks' 2 | import { MARK_CHANNEL_READ, GET_CHANNELS, GET_TIMELINE } from '../queries' 3 | 4 | export default function() { 5 | const client = useApolloClient() 6 | const markRead = useMutation(MARK_CHANNEL_READ) 7 | 8 | return channel => { 9 | // Get the most recent post id in the timeline 10 | let post = null 11 | const { 12 | timeline: { items }, 13 | } = client.readQuery({ 14 | query: GET_TIMELINE, 15 | variables: { channel }, 16 | }) 17 | post = items[0]._id 18 | 19 | markRead({ 20 | variables: { channel, post }, 21 | optimisticResponse: { 22 | __typename: 'Mutation', 23 | markChannelRead: { 24 | uid: channel, 25 | unread: 0, 26 | __typename: 'Channel', 27 | }, 28 | }, 29 | update: (proxy, _) => { 30 | const timelineData = proxy.readQuery({ 31 | query: GET_TIMELINE, 32 | variables: { channel }, 33 | }) 34 | // Update all cached posts to be marked read 35 | if (timelineData.timeline && timelineData.timeline.items) { 36 | timelineData.timeline.items = timelineData.timeline.items.map( 37 | item => { 38 | item._is_read = true 39 | return item 40 | } 41 | ) 42 | } 43 | // Write our data back to the cache. 44 | proxy.writeQuery({ 45 | query: GET_TIMELINE, 46 | variables: { channel }, 47 | data: timelineData, 48 | }) 49 | 50 | const channelData = proxy.readQuery({ query: GET_CHANNELS }) 51 | channelData.channels.find(c => c.uid === channel).unread = 0 52 | proxy.writeQuery({ query: GET_CHANNELS, data: channelData }) 53 | }, 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/hooks/use-mark-read.js: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'react-apollo-hooks' 2 | import { MARK_POST_READ, GET_CHANNELS } from '../queries' 3 | 4 | export default function() { 5 | const markRead = useMutation(MARK_POST_READ) 6 | return (channel, post) => 7 | markRead({ 8 | variables: { channel, post }, 9 | optimisticResponse: { 10 | __typename: 'Mutation', 11 | markPostRead: { 12 | _id: post, 13 | _is_read: true, 14 | __typename: 'Post', 15 | }, 16 | }, 17 | update: (proxy, _) => { 18 | // Read the data from our cache for this query. 19 | const data = proxy.readQuery({ 20 | query: GET_CHANNELS, 21 | }) 22 | // Decrement unread count on selected channel 23 | data.channels = data.channels.map(c => { 24 | if (c.uid === channel && c.unread && Number.isInteger(c.unread)) { 25 | c.unread-- 26 | } 27 | return c 28 | }) 29 | // Write our data back to the cache. 30 | proxy.writeQuery({ query: GET_CHANNELS, data }) 31 | }, 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/use-mark-unread.js: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'react-apollo-hooks' 2 | import { MARK_POST_UNREAD, GET_CHANNELS } from '../queries' 3 | 4 | export default function() { 5 | const markRead = useMutation(MARK_POST_UNREAD) 6 | return (channel, post) => 7 | markRead({ 8 | variables: { channel, post }, 9 | optimisticResponse: { 10 | __typename: 'Mutation', 11 | markPostUnread: { 12 | _id: post, 13 | _is_read: false, 14 | __typename: 'Post', 15 | }, 16 | }, 17 | update: (proxy, _) => { 18 | // Read the data from our cache for this query. 19 | const data = proxy.readQuery({ 20 | query: GET_CHANNELS, 21 | }) 22 | // Increment channel unread count 23 | data.channels = data.channels.map(c => { 24 | if (c.uid === channel && Number.isInteger(c.unread)) { 25 | c.unread++ 26 | } 27 | return c 28 | }) 29 | // Write our data back to the cache. 30 | proxy.writeQuery({ query: GET_CHANNELS, data }) 31 | }, 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/use-micropub-create.js: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'react-apollo-hooks' 2 | import { MICROPUB_CREATE } from '../queries' 3 | 4 | export default function() { 5 | const create = useMutation(MICROPUB_CREATE) 6 | 7 | return async mf2 => { 8 | const { 9 | data: { micropubCreate: postUrl }, 10 | } = await create({ 11 | variables: { 12 | json: JSON.stringify(mf2), 13 | }, 14 | }) 15 | return postUrl 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/use-micropub-query.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-apollo-hooks' 2 | import { MICROPUB_QUERY } from '../queries' 3 | 4 | export default function(query, options = {}) { 5 | const { data: rawData, ...res } = useQuery(MICROPUB_QUERY, { 6 | variables: { query }, 7 | ...options, 8 | }) 9 | 10 | let data = {} 11 | 12 | if (rawData && rawData.micropubQuery) { 13 | data = JSON.parse(rawData.micropubQuery) 14 | } 15 | 16 | return { data, ...res } 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/use-micropub-update.js: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'react-apollo-hooks' 2 | import { MICROPUB_UPDATE } from '../queries' 3 | 4 | export default function() { 5 | const update = useMutation(MICROPUB_UPDATE) 6 | 7 | return async (url, updateData) => { 8 | console.log(url, updateData) 9 | const { 10 | data: { micropubUpdate: postUrl }, 11 | } = await update({ 12 | variables: { 13 | url: url, 14 | json: JSON.stringify(updateData), 15 | }, 16 | }) 17 | return postUrl 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/use-timeline.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useQuery, useSubscription } from 'react-apollo-hooks' 3 | import { GET_TIMELINE, TIMELINE_SUBSCRIPTION } from '../queries' 4 | 5 | export default function({ channel, source = null, unreadOnly = false }) { 6 | const variables = { channel } 7 | if (source !== null) { 8 | variables.source = source 9 | } 10 | if (unreadOnly === true) { 11 | variables.unreadOnly = true 12 | } 13 | 14 | // Get the initial timeline 15 | const query = useQuery(GET_TIMELINE, { 16 | variables, 17 | notifyOnNetworkStatusChange: true, 18 | }) 19 | 20 | // Save the before value in state using the initial timeline 21 | const [before, setBefore] = useState( 22 | query.data.timeline ? query.data.timeline.before : null 23 | ) 24 | 25 | // Reset the before value timeline result changes 26 | useEffect(() => { 27 | if (query.data.timeline && query.data.timeline.before !== before) { 28 | setBefore(query.data.timeline.before) 29 | } 30 | }, [query.data.timeline]) 31 | 32 | // Refetch the timeline if going back to a cached channel timeline 33 | useEffect(() => { 34 | if (query.refetch && channel && !query.loading) { 35 | setTimeout(query.refetch, 200) 36 | } 37 | }, [channel]) 38 | 39 | // Also subscribe to the timeline to get updates via websocket 40 | useSubscription(TIMELINE_SUBSCRIPTION, { 41 | variables: { ...variables, before }, 42 | onSubscriptionData: ({ 43 | client, 44 | subscriptionData: { 45 | data: { timelineSubscription: timeline }, 46 | }, 47 | }) => { 48 | // When websocket data received we need to update the cache 49 | if (timeline.before && timeline.before !== before) { 50 | // Update the before state with the new one 51 | setBefore(timeline.before) 52 | if (timeline.items.length) { 53 | // Get the currently cached timeline 54 | const cache = client.readQuery({ 55 | query: GET_TIMELINE, 56 | variables: { 57 | channel: timeline.channel, 58 | source: timeline.source ? timeline.source._id : null, 59 | }, 60 | }) 61 | // Update cached before 62 | cache.timeline.before = timeline.before 63 | // Add any new items to the start of the cache 64 | cache.timeline.items.unshift(...timeline.items) 65 | // Store the cache again 66 | client.writeQuery({ 67 | query: GET_TIMELINE, 68 | variables: { 69 | channel: timeline.channel, 70 | source: timeline.source ? timeline.source._id : null, 71 | }, 72 | data: cache, 73 | }) 74 | } 75 | } 76 | }, 77 | }) 78 | 79 | const fetchMore = () => { 80 | if ( 81 | query.networkStatus === 7 && 82 | query.data.timeline && 83 | query.data.timeline.after 84 | ) { 85 | query.fetchMore({ 86 | query: GET_TIMELINE, 87 | variables: { 88 | ...variables, 89 | after: query.data.timeline.after, 90 | }, 91 | updateQuery: (previousResult, { fetchMoreResult }) => ({ 92 | timeline: { 93 | channel: fetchMoreResult.timeline.channel, 94 | after: fetchMoreResult.timeline.after, 95 | before: previousResult.timeline.before, 96 | items: [ 97 | ...previousResult.timeline.items, 98 | ...fetchMoreResult.timeline.items, 99 | ], 100 | __typename: previousResult.timeline.__typename, 101 | }, 102 | }), 103 | }) 104 | } 105 | } 106 | 107 | return { data: null, networkStatus: 7, error: null, ...query, fetchMore } 108 | } 109 | -------------------------------------------------------------------------------- /src/hooks/use-user.js: -------------------------------------------------------------------------------- 1 | import { useQuery, useMutation } from 'react-apollo-hooks' 2 | import { useSnackbar } from 'notistack' 3 | import { GET_USER, SET_USER_OPTION } from '../queries' 4 | 5 | export default function() { 6 | const { enqueueSnackbar } = useSnackbar() 7 | const { 8 | data: { user }, 9 | ...res 10 | } = useQuery(GET_USER) 11 | const setUserOption = useMutation(SET_USER_OPTION) 12 | 13 | const setOption = async (key, value) => { 14 | const val = typeof value === 'object' ? JSON.stringify(value) : value 15 | const res = await setUserOption({ 16 | variables: { key, value: val }, 17 | optimisticResponse: { 18 | __typename: 'Mutation', 19 | setUserOption: true, 20 | }, 21 | update: (proxy, _) => { 22 | // Read the data from our cache for this query. 23 | const data = proxy.readQuery({ 24 | query: GET_USER, 25 | }) 26 | // Reorder the channels 27 | data.user.settings[key] = value 28 | // Write our data back to the cache. 29 | proxy.writeQuery({ query: GET_USER, data }) 30 | }, 31 | }) 32 | 33 | if (res.error) { 34 | console.error('[Error setting user option]', res.error) 35 | enqueueSnackbar('Error setting option', { variant: 'error' }) 36 | } 37 | 38 | return res 39 | } 40 | 41 | return { user, setOption, ...res } 42 | } 43 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './containers/App' 4 | import * as serviceWorkerRegistration from './serviceWorkerRegistration' 5 | 6 | const rootEl = document.getElementById('root') 7 | 8 | ReactDOM.render(, rootEl) 9 | 10 | if (module.hot) { 11 | console.log('Hot fire 🔥🔥🔥') 12 | module.hot.accept('./containers/App', () => { 13 | const NextApp = require('./containers/App').default 14 | ReactDOM.render(, rootEl) 15 | }) 16 | } 17 | 18 | serviceWorkerRegistration.register() 19 | -------------------------------------------------------------------------------- /src/modules/author-to-avatar-data.js: -------------------------------------------------------------------------------- 1 | import resizeImage from './get-image-proxy-url' 2 | 3 | function getDomain(string) { 4 | try { 5 | const url = new URL(string) 6 | let domain = url.hostname 7 | if (domain.indexOf('www.') === 0) { 8 | domain = domain.slice(4) 9 | } 10 | return domain 11 | } catch (err) { 12 | return string 13 | } 14 | } 15 | 16 | function stringToColor(string) { 17 | if (!string) { 18 | return '#000' 19 | } 20 | let hash = 0 21 | for (let i = 0; i < string.length; i++) { 22 | hash = string.charCodeAt(i) + ((hash << 5) - hash) 23 | } 24 | let color = '#' 25 | for (let x = 0; x < 3; x++) { 26 | const value = (hash >> (x * 8)) & 0xff 27 | color += ('00' + value.toString(16)).substr(-2) 28 | } 29 | return color 30 | } 31 | 32 | export default function(author) { 33 | let avatar = { 34 | alt: 'Unknown', 35 | initials: '?', 36 | } 37 | if (author) { 38 | if (typeof author === 'string') { 39 | avatar.alt = author 40 | avatar.href = author 41 | if (avatar.alt.indexOf('http') === 0) { 42 | avatar.initials = getDomain(avatar.alt)[0].toUpperCase() 43 | } else { 44 | avatar.initials = avatar.alt[0].toUpperCase() 45 | } 46 | } else if (typeof author === 'object') { 47 | avatar.alt = author.name 48 | avatar.src = author.photo 49 | avatar.href = author.url 50 | if (avatar.alt) { 51 | let initials = avatar.alt.match(/\b\w/g) || [] 52 | initials = ( 53 | (initials.shift() || '') + (initials.pop() || '') 54 | ).toUpperCase() 55 | avatar.initials = initials 56 | } else if (avatar.href) { 57 | avatar.alt = getDomain(avatar.href) 58 | avatar.initials = avatar.alt[0].toUpperCase() 59 | } 60 | if (avatar.src) { 61 | avatar.src = resizeImage(avatar.src, { w: 50, h: 50, t: 'square' }) 62 | } 63 | } 64 | } 65 | avatar.color = stringToColor(avatar.href) 66 | return avatar 67 | } 68 | -------------------------------------------------------------------------------- /src/modules/get-image-proxy-url.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Uses https://images.weserv.nl/ to provide a resized image proxy. 3 | */ 4 | export default function(originalUrl, options = { dpr: 1, il: true }) { 5 | const url = encodeURIComponent( 6 | originalUrl.replace('http://', '').replace('https://', '') 7 | ) 8 | let proxyUrl = `https://images.weserv.nl/?url=${url}&errorredirect=${url}` 9 | for (const key in options) { 10 | if (options.hasOwnProperty(key)) { 11 | const value = options[key] 12 | if (value === true) { 13 | proxyUrl += '&' + key 14 | } else { 15 | proxyUrl += `&${key}=${value}` 16 | } 17 | } 18 | } 19 | return proxyUrl 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/keymap.js: -------------------------------------------------------------------------------- 1 | export default { 2 | CHANNEL_LIST: { 3 | NEXT: ['j', 'down', 'space'], 4 | PREVIOUS: ['k', 'up'], 5 | SELECT_CHANNEL: ['l', 'enter', 'right'], 6 | }, 7 | TIMELINE: { 8 | NEXT: ['j', 'down', 'space'], 9 | PREVIOUS: ['k', 'up'], 10 | SELECT_POST: ['l', 'enter', 'right', 'v'], 11 | FOCUS_CHANNEL_LIST: ['h', 'left'], 12 | MARK_READ: ['m'], 13 | }, 14 | POST: { 15 | SCROLL_DOWN: ['j'], 16 | SCROLL_UP: ['k'], 17 | TO_TIMELINE: ['h'], 18 | NEXT: ['space'], 19 | OPEN: ['v'], 20 | TOGGLE_READ: ['m'], 21 | // TODO: 22 | // Load full content 23 | // Previous 24 | // Like 25 | // Repost 26 | // Reply 27 | // 28 | }, 29 | GLOBAL: { 30 | KONAMI: ['up up down down left right left right b a'], 31 | CHANNEL_1: ['ctrl+1', 'meta+1', 'alt+1'], 32 | CHANNEL_2: ['ctrl+2', 'meta+2', 'alt+2'], 33 | CHANNEL_3: ['ctrl+3', 'meta+3', 'alt+3'], 34 | CHANNEL_4: ['ctrl+4', 'meta+4', 'alt+4'], 35 | CHANNEL_5: ['ctrl+5', 'meta+5', 'alt+5'], 36 | CHANNEL_6: ['ctrl+6', 'meta+6', 'alt+6'], 37 | CHANNEL_7: ['ctrl+7', 'meta+7', 'alt+7'], 38 | CHANNEL_8: ['ctrl+8', 'meta+8', 'alt+8'], 39 | CHANNEL_9: ['ctrl+9', 'meta+9', 'alt+9'], 40 | NEW_POST: ['ctrl+n', 'meta+n', 'alt+n'], 41 | FOCUS_CHANNEL_LIST: ['c'], 42 | HELP: ['?'], 43 | // TODO: 44 | // Open notifications 45 | // Mark channel read 46 | // Jump to timeline 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /src/modules/layouts.js: -------------------------------------------------------------------------------- 1 | import PhotoIcon from '@material-ui/icons/PhotoCamera' 2 | import CheckinIcon from '@material-ui/icons/LocationOn' 3 | import AllIcon from '@material-ui/icons/Chat' 4 | import ClassicIcon from '@material-ui/icons/ChromeReaderMode' 5 | 6 | const layouts = [ 7 | { 8 | id: 'timeline', 9 | name: 'Timeline', 10 | icon: AllIcon, 11 | filter: post => true, 12 | }, 13 | { 14 | id: 'classic', 15 | name: 'Classic', 16 | icon: ClassicIcon, 17 | filter: post => true, 18 | }, 19 | { 20 | id: 'gallery', 21 | name: 'Gallery', 22 | icon: PhotoIcon, 23 | filter: post => post.photo, 24 | }, 25 | { 26 | id: 'map', 27 | name: 'Map', 28 | icon: CheckinIcon, 29 | filter: post => 30 | (post.location && post.location.latitude && post.location.longitude) || 31 | (post.checkin && post.checkin.latitude && post.checkin.longitude), 32 | }, 33 | ] 34 | 35 | export default layouts 36 | -------------------------------------------------------------------------------- /src/modules/load-user.js: -------------------------------------------------------------------------------- 1 | export default function(id, setSetting, setUserOption) { 2 | return new Promise((resolve, reject) => { 3 | // users 4 | // .get(id) 5 | // .then(user => { 6 | // // Load the user id into the settings as well just to make life a little easier 7 | // setSetting('userId', user._id) 8 | // if (user.settings) { 9 | // Object.keys(user.settings).forEach(key => { 10 | // const value = user.settings[key] 11 | // setSetting(key, value, false) 12 | // }) 13 | // delete user.settings 14 | // } 15 | // if (user.channels) { 16 | // setSetting('channels', user.channels, false) 17 | // delete user.channels 18 | // } 19 | // Object.keys(user).forEach(key => { 20 | // const value = user[key] 21 | // setUserOption(key, value, false) 22 | // }) 23 | // resolve() 24 | // }) 25 | // .catch(err => reject(err)) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/windows-functions.js: -------------------------------------------------------------------------------- 1 | export const getTheme = () => { 2 | if (window.Windows) { 3 | const uiSettings = new window.Windows.UI.ViewManagement.UISettings() 4 | const color = uiSettings.getColorValue( 5 | window.Windows.UI.ViewManagement.UIColorType.background 6 | ) 7 | if (color.b === 0) { 8 | return 'dark' 9 | } else { 10 | return 'light' 11 | } 12 | } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { 13 | return 'dark' 14 | } else if (window.matchMedia('(prefers-color-scheme: light)').matches) { 15 | return 'light' 16 | } 17 | return null 18 | } 19 | 20 | export const changeTitleBarTheme = theme => { 21 | if (window.Windows && window.Windows.UI.ViewManagement.ApplicationView) { 22 | const customColors = 23 | theme === 'dark' 24 | ? { 25 | backgroundColor: { a: 255, r: 17, g: 17, b: 17 }, 26 | foregroundColor: { a: 255, r: 255, g: 255, b: 255 }, 27 | inactiveBackgroundColor: { a: 255, r: 34, g: 34, b: 34 }, 28 | inactiveForegroundColor: { a: 255, r: 250, g: 250, b: 250 }, 29 | } 30 | : { 31 | backgroundColor: { a: 255, r: 21, g: 101, b: 192 }, 32 | foregroundColor: { a: 255, r: 255, g: 255, b: 255 }, 33 | inactiveBackgroundColor: { a: 255, r: 0, g: 60, b: 143 }, 34 | inactiveForegroundColor: { a: 255, r: 250, g: 250, b: 250 }, 35 | } 36 | 37 | let titleBar = window.Windows.UI.ViewManagement.ApplicationView.getForCurrentView() 38 | .titleBar 39 | titleBar.backgroundColor = customColors.backgroundColor 40 | titleBar.foregroundColor = customColors.foregroundColor 41 | titleBar.inactiveBackgroundColor = customColors.inactiveBackgroundColor 42 | titleBar.inactiveForegroundColor = customColors.inactiveForegroundColor 43 | } 44 | } 45 | 46 | export const notification = (title, body = '') => { 47 | if (!window.Windows) return null 48 | const toastNotificationXmlTemplate = ` 49 | 50 | 51 | 52 | 53 | 54 | 55 | ` 56 | 57 | // Create ToastNotification as XML Doc 58 | let toastXml = new window.Windows.Data.Xml.Dom.XmlDocument() 59 | toastXml.loadXml(toastNotificationXmlTemplate) 60 | 61 | // Set notification texts 62 | let textNodes = toastXml.getElementsByTagName('text') 63 | textNodes[0].innerText = title 64 | if (body) { 65 | textNodes[1].innerText = body 66 | } 67 | 68 | // Create a toast notification from the XML, then create a ToastNotifier object to send the toast. 69 | const toast = new window.Windows.UI.Notifications.ToastNotification(toastXml) 70 | window.Windows.UI.Notifications.ToastNotificationManager.createToastNotifier().show( 71 | toast 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | 3 | // This service worker can be customized! 4 | // See https://developers.google.com/web/tools/workbox/modules 5 | // for the list of available Workbox modules, or add any other 6 | // code you'd like. 7 | // You can also remove this file if you'd prefer not to use a 8 | // service worker, and the Workbox build step will be skipped. 9 | 10 | import { clientsClaim } from 'workbox-core' 11 | import { ExpirationPlugin } from 'workbox-expiration' 12 | import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching' 13 | import { registerRoute } from 'workbox-routing' 14 | import { StaleWhileRevalidate } from 'workbox-strategies' 15 | 16 | clientsClaim() 17 | 18 | // Precache all of the assets generated by your build process. 19 | // Their URLs are injected into the manifest variable below. 20 | // This variable must be present somewhere in your service worker file, 21 | // even if you decide not to use precaching. See https://cra.link/PWA 22 | precacheAndRoute(self.__WB_MANIFEST) 23 | 24 | // Set up App Shell-style routing, so that all navigation requests 25 | // are fulfilled with your index.html shell. Learn more at 26 | // https://developers.google.com/web/fundamentals/architecture/app-shell 27 | const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$') 28 | registerRoute( 29 | // Return false to exempt requests from being fulfilled by index.html. 30 | ({ request, url }) => { 31 | // If this isn't a navigation, skip. 32 | if (request.mode !== 'navigate') { 33 | return false 34 | } // If this is a URL that starts with /_, skip. 35 | 36 | if (url.pathname.startsWith('/_')) { 37 | return false 38 | } // If this looks like a URL for a resource, because it contains // a file extension, skip. 39 | 40 | if (url.pathname.match(fileExtensionRegexp)) { 41 | return false 42 | } // Return true to signal that we want to use the handler. 43 | 44 | return true 45 | }, 46 | createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html') 47 | ) 48 | 49 | // An example runtime caching route for requests that aren't handled by the 50 | // precache, in this case same-origin .png requests like those from in public/ 51 | registerRoute( 52 | // Add in any other file extensions or routing criteria as needed. 53 | ({ url }) => 54 | url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst. 55 | new StaleWhileRevalidate({ 56 | cacheName: 'images', 57 | plugins: [ 58 | // Ensure that once this runtime cache reaches a maximum size the 59 | // least-recently used images are removed. 60 | new ExpirationPlugin({ maxEntries: 50 }), 61 | ], 62 | }) 63 | ) 64 | 65 | // This allows the web app to trigger skipWaiting via 66 | // registration.waiting.postMessage({type: 'SKIP_WAITING'}) 67 | self.addEventListener('message', (event) => { 68 | if (event.data && event.data.type === 'SKIP_WAITING') { 69 | self.skipWaiting() 70 | } 71 | }) 72 | 73 | // Any other custom service worker logic can go here. 74 | -------------------------------------------------------------------------------- /together-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/together-logo.png -------------------------------------------------------------------------------- /together-slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alltogethernow/web/90c17cd307c689c31ebc04b88bce24c5b7c06051/together-slides.pdf --------------------------------------------------------------------------------