├── .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 | 
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 |
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 |
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 |
49 |
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 |
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 |
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 |
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 |
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 |
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 |
42 |
43 |
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 | }>
54 | New Post
55 |
56 | )}
57 | }
61 | >
62 | Settings
63 |
64 | {user && user.hasMicropub && (
65 | }>
66 | My Posts
67 |
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 |
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 |
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 |
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 |
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 |
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 |
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
--------------------------------------------------------------------------------