Revert "Merge pull request #173 from hyperspacedev/develop-1.1.0-feed-bug-masonry"

This reverts commit dc83c5c224, reversing
changes made to 9966aec312.
This commit is contained in:
Marquis Kurt 2020-02-17 17:38:08 -05:00
parent dc83c5c224
commit c02b20c4bb
No known key found for this signature in database
GPG Key ID: 725636D259F5402D
43 changed files with 915 additions and 3205 deletions

View File

@ -12,27 +12,8 @@ jobs:
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Change desktop field
run: |
from json import load, dump
json_dict = {}
with open('public/config.json', 'r') as file:
json_dict = load(file)
json_dict["location"] = "desktop"
with open('public/config.json', 'w+') as out:
dump(json_dict, out)
shell: python
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build-desktop-win
- name: Upload Windows executable
uses: actions/upload-artifact@v1
if: success()
with:
name: 'Windows executable (output dir)'
path: dist
npm run build-desktop-win

View File

@ -1,24 +1,10 @@
Hyperspace Desktop
Copyright Hyperspace Developers 2020
Hyperspace
Copyright Hyperspace developers 2019
NON-VIOLENT PUBLIC LICENSE v4
Preamble
The Non-Violent Public license is a freedom-respecting sharealike license
for both the author of a work as well as those subject to a work. It aims
to protect the basic rights of human beings from exploitation and the earth
from plunder. It aims to ensure a copyrighted work is forever available
for public use, modification, and redistribution under the same terms so
long as the work is not used for harm. For more information about the NPL
refer to the official webpage
Official Webpage: https://thufie.lain.haus/NPL.html
Terms and Conditions
NON-VIOLENT PUBLIC LICENSE v1
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
NON-VIOLENT PUBLIC LICENSE v4 ("LICENSE"). THE WORK IS PROTECTED BY
NON-VIOLENT PUBLIC LICENSE v1 ("LICENSE"). THE WORK IS PROTECTED BY
COPYRIGHT AND ALL OTHER APPLICABLE LAWS. ANY USE OF THE WORK OTHER THAN
AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. BY
EXERCISING ANY RIGHTS TO THE WORK PROVIDED IN THIS LICENSE, YOU AGREE
@ -52,9 +38,8 @@ AND CONDITIONS OF THIS LICENSE.
timed-relation with a moving image ("synching") will be
considered an Adaptation for the purpose of this License.
c. "Bodily Harm" means any physical hurt or injury to a person that
interferes with the health or comfort of the person and that is more
more than merely transient or trifling in nature.
c. "Bodily Harm" means any action of one person towards another
in an intentional manner.
d. "Collection" means a collection of literary or artistic
works, such as encyclopedias and anthologies, or performances,
@ -76,7 +61,8 @@ AND CONDITIONS OF THIS LICENSE.
f. "Incarceration" means confinement in a jail, prison, or any
other place where individuals of any kind are held against
either their will or the will of their legal guardians.
either their will or the will of their legal guardians by physical
means.
g. "Licensor" means the individual, individuals, entity or
entities that offer(s) the Work under the terms of this License.
@ -148,23 +134,13 @@ AND CONDITIONS OF THIS LICENSE.
through which the Original Author and/or Distributor originally
created, derived, and/or modified it.
o. "Surveilling" means the use of the Work to either
overtly or covertly observe and record persons and or their
activities.
o. "Surveilling" means the use of the Work to
overtly or covertly observe persons or their activities.
p. "Web Service" means the use of a piece of Software to
interpret or modify information that is subsequently and directly
served to users over the Internet.
q. "Discriminate" means the use of a work to differentiate between
humans in a such a way which prioritizes some above others on the
basis of percieved membership within certain groups.
r. "Hate Speech" means communication or any form
of expression which is solely for the purpose of expressing hatred
for some group or advocating a form of Discrimination
(to Discriminate per definition in (q)) between humans.
2. FAIR DEALING RIGHTS
Nothing in this License is intended to reduce, limit, or restrict any
@ -201,6 +177,7 @@ AND CONDITIONS OF THIS LICENSE.
exercise the rights in other media and formats. Subject to
Section 8(g), all rights not expressly granted by Licensor are
hereby reserved.
4. RESTRICTIONS
@ -255,15 +232,15 @@ AND CONDITIONS OF THIS LICENSE.
or tracking individuals for financial gain.
iii. You do not use the Work in an Act of War.
iv. You do not use the Work for the purpose of supporting
or profiting from an Act of War.
an Act of War.
v. You do not use the Work for the purpose of Incarceration.
vi. You do not use the Work for the purpose of extracting
oil, gas, or coal.
vii. You do not use the Work for the purpose of
expediting, coordinating, or facilitating paid work
undertaken by individuals under the age of 12 years.
viii. You do not use the Work to either Discriminate or
spread Hate Speech on the basis of sex, sexual orientation,
viii. You do not use the Work to either discriminate or
spread hate speech on the basis of sex, sexual orientation,
gender identity, race, age, disability, color, national origin,
religion, or lower economic status.
@ -442,4 +419,4 @@ AND CONDITIONS OF THIS LICENSE.
additional rights not granted under this License, such
additional rights are deemed to be included in the License; this
License is not intended to restrict the license of any rights
under applicable law.
under applicable law.

View File

@ -9,7 +9,7 @@
[![Matrix room](https://img.shields.io/matrix/hypermasto:matrix.org.svg)](https://matrix.to/#/#hypermasto:matrix.org)
[![Discord server](https://img.shields.io/discord/554108687434907660.svg?color=blueviolet&label=discord)](https://discord.gg/c69AXwk)
![Build Status](https://github.com/hyperspacedev/hyperspace/workflows/Node%20CI/badge.svg) [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/hyperspacedev/hyperspace?include_prereleases)](https://github.com/hyperspacedev/hyperspace/releases) [![License: NPLv4+](https://img.shields.io/badge/license-NPLv4%2B-blue.svg)](LICENSE.txt) [![Hyperspace](https://snapcraft.io/hyperspace/badge.svg)](https://snapcraft.io/hyperspace)
![Build Status](https://github.com/hyperspacedev/hyperspace/workflows/Node%20CI/badge.svg) [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/hyperspacedev/hyperspace?include_prereleases)](https://github.com/hyperspacedev/hyperspace/releases) <!-- [![iTunes App Store](https://img.shields.io/itunes/v/1454139710?label=Mac%20App%20Store&logo=apple&logoColor=white)](https://apps.apple.com/us/app/hyperspace/id1454139710?mt=12)--> [![Hyperspace](https://snapcraft.io/hyperspace/badge.svg)](https://snapcraft.io/hyperspace)
Hyperspace is the fluffiest client for Mastodon and other fediverse networks written in TypeScript and React. Hyperspace offers a fun, clean, fast, and responsive design that scales beautifully across devices and enhances the fediverse experience.
@ -127,12 +127,12 @@ You'll also want to modify the `notarize.js` file to change the details from the
## Licensing and Credits
Hyperspace is licensed under the [Non-violent Public License v4+](LICENSE.txt), a permissive license under the conditions that you do not use this for any unethical purposes and to file patent claims. Please read what your rights are as a Hyperspace user/developer in the license for more information.
Hyperspace is licensed under the [Non-violent Public License](LICENSE), a permissive license under the conditions that you do not use this for any unethical purposes and to file patent claims. Please read what your rights are as a Hyperspace user/developer in the license for more information.
Hyperspace has been made possible by the React, TypeScript, Megalodon, and Material-UI projects as well our [Patrons](patreon.md) and our contributors on GitHub.
## Contribute
Contribution guidelines are available in the [contributing file](.github/contributing.md) and when you make an issue/pull request. Additionally, you can access our [Code of Conduct](.github/code_of_conduct.md).
Contrubition guidelines are available in the [contributing file](.github/contributing.md) and when you make an issue/pull request. Additionally, you can access our [Code of Conduct](.github/code_of_conduct.md).
If you want to aid the project in other ways, consider supporting the project on [Patreon](https://patreon.com/hyperspacedev).

117
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "hyperspace",
"version": "1.1.0-beta2",
"version": "1.0.4",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1055,23 +1055,13 @@
}
},
"@material-ui/icons": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.5.1.tgz",
"integrity": "sha512-YZ/BgJbXX4a0gOuKWb30mBaHaoXRqPanlePam83JQPZ/y4kl+3aW0Wv9tlR70hB5EGAkEJGW5m4ktJwMgxQAeA==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-3.0.2.tgz",
"integrity": "sha512-QY/3gJnObZQ3O/e6WjH+0ah2M3MOgLOzCy8HTUoUx9B6dDrS18vP7Ycw3qrDEKlB6q1KNxy6CZHm5FCauWGy2g==",
"dev": true,
"requires": {
"@babel/runtime": "^7.4.4"
},
"dependencies": {
"@babel/runtime": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.2.tgz",
"integrity": "sha512-JONRbXbTXc9WQE2mAZd1p0Z3DZ/6vaQIkgYMSTP3KjRCyd7rCZCcfhCyX+YjwcKxcZ82UrxbRD358bpExNgrjw==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.2"
}
}
"@babel/runtime": "^7.2.0",
"recompose": "0.28.0 - 0.30.0"
}
},
"@material-ui/system": {
@ -9461,9 +9451,9 @@
"dev": true
},
"handlebars": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz",
"integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.0.tgz",
"integrity": "sha512-xkRtOt3/3DzTKMOt3xahj2M/EqNhY988T+imYSlMgs5fVhLN2fmKVVj0LtEGmb+3UUYV5Qmm1052Mm3dIQxOvw==",
"dev": true,
"requires": {
"neo-async": "^2.6.0",
@ -10448,9 +10438,9 @@
}
},
"invert-kv": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz",
"integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz",
"integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=",
"dev": true
},
"ip": {
@ -11848,12 +11838,12 @@
"integrity": "sha512-u93kb2fPbIrfzBuLjZE+w+fJbUUMhNDXxNmMfaqNgpfQf1CO5ZSe2LfsnBqVAk7i/2NF48OSoRj+Xe2VT+lE8Q=="
},
"lcid": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz",
"integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz",
"integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=",
"dev": true,
"requires": {
"invert-kv": "^2.0.0"
"invert-kv": "^1.0.0"
}
},
"left-pad": {
@ -12275,22 +12265,12 @@
}
},
"mem": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz",
"integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mem/-/mem-1.1.0.tgz",
"integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=",
"dev": true,
"requires": {
"map-age-cleaner": "^0.1.1",
"mimic-fn": "^2.0.0",
"p-is-promise": "^2.0.0"
},
"dependencies": {
"mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true
}
"mimic-fn": "^1.0.0"
}
},
"memory-fs": {
@ -13256,40 +13236,14 @@
"dev": true
},
"os-locale": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz",
"integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz",
"integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==",
"dev": true,
"requires": {
"execa": "^1.0.0",
"lcid": "^2.0.0",
"mem": "^4.0.0"
},
"dependencies": {
"execa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
"integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
"dev": true,
"requires": {
"cross-spawn": "^6.0.0",
"get-stream": "^4.0.0",
"is-stream": "^1.1.0",
"npm-run-path": "^2.0.0",
"p-finally": "^1.0.0",
"signal-exit": "^3.0.0",
"strip-eof": "^1.0.0"
}
},
"get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
"integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
"dev": true,
"requires": {
"pump": "^3.0.0"
}
}
"execa": "^0.7.0",
"lcid": "^1.0.0",
"mem": "^1.1.0"
}
},
"os-tmpdir": {
@ -16822,11 +16776,6 @@
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"dev": true
},
"react-masonry-css": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.14.tgz",
"integrity": "sha512-oAPVOCMApTT0HkxZJy84yU1EWaaQNZnJE0DjDMy/L+LxZoJEph4RRXsT9ppPKbFSo/tCzj+cCLwiBHjZmZ2eXA=="
},
"react-router": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz",
@ -19912,9 +19861,9 @@
}
},
"typescript": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.2.tgz",
"integrity": "sha512-ml7V7JfiN2Xwvcer+XAf2csGO1bPBdRbFCkYBczNZggrBZ9c7G3riSUeJmqEU5uOtXNPMhE3n+R4FA/3YOAWOQ==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.1.tgz",
"integrity": "sha512-3NSMb2VzDQm8oBTLH6Nj55VVtUEpe/rgkIzMir0qVoLyjDZlnMBva0U6vDiV3IH+sl/Yu6oP5QwsAQtHPmDd2Q==",
"dev": true
},
"ua-parser-js": {
@ -21476,16 +21425,16 @@
"dev": true
},
"yargs": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-11.1.1.tgz",
"integrity": "sha512-PRU7gJrJaXv3q3yQZ/+/X6KBswZiaQ+zOmdprZcouPYtQgvNU35i+68M4b1ZHLZtYFT5QObFLV+ZkmJYcwKdiw==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-11.1.0.tgz",
"integrity": "sha512-NwW69J42EsCSanF8kyn5upxvjp5ds+t3+udGBeTbFnERA+lF541DDpMawzo4z6W/QrzNM18D+BPMiOBibnFV5A==",
"dev": true,
"requires": {
"cliui": "^4.0.0",
"decamelize": "^1.1.1",
"find-up": "^2.1.0",
"get-caller-file": "^1.0.1",
"os-locale": "^3.1.0",
"os-locale": "^2.0.0",
"require-directory": "^2.1.1",
"require-main-filename": "^1.0.1",
"set-blocking": "^2.0.0",

View File

@ -1,115 +1,114 @@
{
"name": "hyperspace",
"productName": "Hyperspace Desktop",
"version": "1.1.0-beta4",
"description": "A beautiful, fluffy client for the fediverse",
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
"repository": "https://github.com/hyperspacedev/hyperspace.git",
"private": true,
"homepage": "./",
"devDependencies": {
"@date-io/moment": "^1.3.11",
"@material-ui/core": "^3.9.3",
"@material-ui/icons": "^4.5.1",
"@types/emoji-mart": "^2.11.0",
"@types/jest": "^24.0.18",
"@types/node": "11.11.6",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@types/react-router-dom": "^4.3.5",
"@types/react-swipeable-views": "latest",
"axios": "^0.19.0",
"electron": "^6.0.11",
"electron-builder": "^21.2.0",
"emoji-mart": "^2.11.1",
"file-dialog": "^0.0.7",
"material-ui-pickers": "^2.2.4",
"mdi-material-ui": "^5.18.0",
"megalodon": "^0.6.4",
"moment": "^2.24.0",
"notistack": "^0.5.1",
"prettier": "1.18.2",
"query-string": "^6.8.3",
"react": "^16.10.2",
"react-dom": "^16.10.2",
"react-router-dom": "^5.1.2",
"react-scripts": "^2.1.8",
"react-swipeable-views": "^0.13.3",
"react-web-share-api": "^0.0.2",
"typescript": "^3.7.2"
"name": "hyperspace",
"productName": "Hyperspace Desktop",
"version": "1.0.4",
"description": "A beautiful, fluffy client for the fediverse",
"author": "Marquis Kurt <hyperspacedev@marquiskurt.net>",
"repository": "https://github.com/hyperspacedev/hyperspace.git",
"private": true,
"homepage": "./",
"devDependencies": {
"@date-io/moment": "^1.3.11",
"@material-ui/core": "^3.9.3",
"@material-ui/icons": "^3.0.2",
"@types/emoji-mart": "^2.11.0",
"@types/jest": "^24.0.18",
"@types/node": "11.11.6",
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@types/react-router-dom": "^4.3.5",
"@types/react-swipeable-views": "latest",
"axios": "^0.19.0",
"electron": "^6.0.11",
"electron-builder": "^21.2.0",
"emoji-mart": "^2.11.1",
"file-dialog": "^0.0.7",
"material-ui-pickers": "^2.2.4",
"mdi-material-ui": "^5.18.0",
"megalodon": "^0.6.4",
"moment": "^2.24.0",
"notistack": "^0.5.1",
"prettier": "1.18.2",
"query-string": "^6.8.3",
"react": "^16.10.2",
"react-dom": "^16.10.2",
"react-router-dom": "^5.1.2",
"react-scripts": "^2.1.8",
"react-swipeable-views": "^0.13.3",
"react-web-share-api": "^0.0.2",
"typescript": "3.4.1"
},
"dependencies": {
"electron-notarize": "^0.1.1",
"electron-updater": "^4.1.2",
"electron-window-state": "^5.0.3"
},
"main": "public/electron.js",
"scripts": {
"start": "HTTPS=true react-scripts start",
"electrify": "npm run build; electron .",
"electrify-nobuild": "electron .",
"build": "react-scripts build",
"create-mac-icon": "cd desktop; iconutil -c icns app.iconset; cd ..",
"build-desktop": "npm run build; npm run create-mac-icon; electron-builder -p 'never' -mwl deb AppImage snap",
"build-desktop-win": "electron-builder -p 'never' -w",
"build-desktop-darwin": "npm run create-mac-icon; electron-builder -p 'never' -m",
"build-desktop-darwin-nosign": "npm run create-mac-icon; electron-builder -p 'never' -m dmg -c.mac.identity=null -c.afterSign=\"desktop/donothing.js\"",
"build-desktop-linux": "electron-builder -p 'never' -l deb AppImage snap",
"build-desktop-linux-select": "electron-builder -p 'never' -l ",
"check-prettier": "prettier --check src/**/**.tsx",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"build": {
"appId": "net.marquiskurt.hyperspace",
"afterSign": "desktop/notarize.js",
"directories": {
"buildResources": "desktop"
},
"dependencies": {
"electron-notarize": "^0.1.1",
"electron-updater": "^4.1.2",
"electron-window-state": "^5.0.3",
"react-masonry-css": "^1.0.14"
"mac": {
"category": "public.app-category.social-networking",
"icon": "desktop/app.icns",
"target": [
"dmg",
"mas"
],
"darkModeSupport": true,
"hardenedRuntime": true
},
"main": "public/electron.js",
"scripts": {
"start": "react-scripts start",
"electrify": "npm run build; electron .",
"electrify-nobuild": "electron .",
"build": "react-scripts build",
"create-mac-icon": "cd desktop; iconutil -c icns app.iconset; cd ..",
"build-desktop": "npm run build; npm run create-mac-icon; electron-builder -p 'never' -mwl deb AppImage snap",
"build-desktop-win": "electron-builder -p 'never' -w",
"build-desktop-darwin": "npm run create-mac-icon; electron-builder -p 'never' -m",
"build-desktop-darwin-nosign": "npm run create-mac-icon; electron-builder -p 'never' -m dmg -c.mac.identity=null -c.afterSign=\"desktop/donothing.js\"",
"build-desktop-linux": "electron-builder -p 'never' -l deb AppImage snap",
"build-desktop-linux-select": "electron-builder -p 'never' -l ",
"check-prettier": "prettier --check src/**/**.tsx",
"test": "react-scripts test",
"eject": "react-scripts eject"
"mas": {
"entitlements": "desktop/entitlements.mas.plist",
"entitlementsInherit": "desktop/entitlements.mas.inherit.plist",
"provisioningProfile": "desktop/embedded.provisionprofile"
},
"eslintConfig": {
"extends": "react-app"
"dmg": {
"sign": false
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"build": {
"appId": "net.marquiskurt.hyperspace",
"afterSign": "desktop/notarize.js",
"directories": {
"buildResources": "desktop"
},
"mac": {
"category": "public.app-category.social-networking",
"icon": "desktop/app.icns",
"target": [
"dmg",
"mas"
],
"darkModeSupport": true,
"hardenedRuntime": true
},
"mas": {
"entitlements": "desktop/entitlements.mas.plist",
"entitlementsInherit": "desktop/entitlements.mas.inherit.plist",
"provisioningProfile": "desktop/embedded.provisionprofile"
},
"dmg": {
"sign": false
},
"win": {
"target": [
"nsis"
],
"icon": "desktop/app.ico"
},
"linux": {
"target": [
"${@:1}"
],
"icon": "linux",
"category": "Network"
},
"snap": {
"confinement": "strict",
"summary": "A beautiful, fluffy client for the fediverse"
}
"win": {
"target": [
"nsis"
],
"icon": "desktop/app.ico"
},
"linux": {
"target": [
"${@:1}"
],
"icon": "linux",
"category": "Network"
},
"snap": {
"confinement": "strict",
"summary": "A beautiful, fluffy client for the fediverse"
}
}
}

View File

@ -1,12 +1,12 @@
{
"version": "1.1.0",
"location": "https://hyperspaceapp-next.herokuapp.com",
"version": "1.0.4",
"location": "https://hyperspaceapp.herokuapp.com",
"branding": {
"name": "Hyperspace",
"logo": "logo.svg",
"background": "background.png"
},
"developer": true,
"developer": false,
"federation": {
"universalLogin": true,
"allowPublicPosts": true,
@ -20,7 +20,7 @@
"account": "774314"
},
"license": {
"name": "Non-violent Public License v4+",
"name": "Non-violent Public License",
"url": "https://thufie.lain.haus/NPL.html"
},
"repository": "https://github.com/hyperspacedev/hyperspace"

View File

@ -312,14 +312,6 @@ function createMenubar() {
click() {
safelyGoTo("hyperspace://hyperspace/app/#/messages")
}
},
{ type: 'separator' },
{
label: 'Activity',
accelerator: 'Alt+CmdOrCtrl+A',
click() {
safelyGoTo("hyperspace://hyperspace/app/#/activity")
}
}
]
},
@ -334,7 +326,7 @@ function createMenubar() {
}
},
{
label: 'Recommendations',
label: 'Recommendations...',
accelerator: "Alt+CmdOrCtrl+R",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/recommended")
@ -348,13 +340,6 @@ function createMenubar() {
safelyGoTo("hyperspace://hyperspace/app/#/you")
}
},
{
label: 'Follow Requests',
accelerator: "Alt+CmdOrCtrl+E",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/requests")
}
},
{
label: 'Blocked Servers',
accelerator: "Shift+CmdOrCtrl+B",

View File

@ -8,7 +8,9 @@ import AboutPage from "./pages/About";
import Settings from "./pages/Settings";
import { getUserDefaultBool, getUserDefaultTheme } from "./utilities/settings";
import ProfilePage from "./pages/ProfilePage";
import TimelinePage from "./pages/Timeline";
import HomePage from "./pages/Home";
import LocalPage from "./pages/Local";
import PublicPage from "./pages/Public";
import Conversation from "./pages/Conversation";
import NotificationsPage from "./pages/Notifications";
import SearchPage from "./pages/Search";
@ -19,8 +21,6 @@ import RecommendationsPage from "./pages/Recommendations";
import Missingno from "./pages/Missingno";
import Blocked from "./pages/Blocked";
import You from "./pages/You";
import RequestsPage from "./pages/Requests";
import ActivityPage from "./pages/Activity";
import { withSnackbar } from "notistack";
import { PrivateRoute } from "./interfaces/overrides";
import { userLoggedIn } from "./utilities/accounts";
@ -96,47 +96,10 @@ class App extends Component<any, IAppState> {
<Route path="/welcome" component={WelcomePage} />
<div>
{this.state.showLayout ? <AppLayout /> : null}
<PrivateRoute
exact
path="/"
render={(props: any) => (
<TimelinePage
{...props}
stream="/streaming/user"
timeline="/timelines/home"
/>
)}
/>
<PrivateRoute
path="/home"
render={(props: any) => (
<TimelinePage
{...props}
stream="/streaming/user"
timeline="/timelines/home"
/>
)}
/>
<PrivateRoute
path="/local"
render={(props: any) => (
<TimelinePage
{...props}
stream="/streaming/public/local"
timeline="/timelines/public?local=true"
/>
)}
/>
<PrivateRoute
path="/public"
render={(props: any) => (
<TimelinePage
{...props}
stream="/streaming/public"
timeline="/timelines/public"
/>
)}
/>
<PrivateRoute exact path="/" component={HomePage} />
<PrivateRoute path="/home" component={HomePage} />
<PrivateRoute path="/local" component={LocalPage} />
<PrivateRoute path="/public" component={PublicPage} />
<PrivateRoute path="/messages" component={MessagesPage} />
<PrivateRoute
path="/notifications"
@ -160,8 +123,6 @@ class App extends Component<any, IAppState> {
path="/recommended"
component={RecommendationsPage}
/>
<PrivateRoute path="/requests" component={RequestsPage} />
<PrivateRoute path="/activity" component={ActivityPage} />
</div>
</MuiThemeProvider>
);

View File

@ -35,8 +35,7 @@ export const styles = (theme: Theme) =>
titleBarText: {
fontSize: 12,
paddingTop: 2,
paddingBottom: 1,
color: theme.palette.getContrastText(theme.palette.primary.main)
paddingBottom: 1
},
appBar: {
zIndex: 1000,
@ -58,10 +57,6 @@ export const styles = (theme: Theme) =>
display: "none"
}
},
appBarBackButton: {
marginLeft: -12,
marginRight: 20
},
appBarTitle: {
display: "none",
[theme.breakpoints.up("md")]: {

View File

@ -28,7 +28,6 @@ import {
ListItem,
Tooltip
} from "@material-ui/core";
import MenuIcon from "@material-ui/icons/Menu";
import SearchIcon from "@material-ui/icons/Search";
import NotificationsIcon from "@material-ui/icons/Notifications";
@ -42,10 +41,6 @@ import InfoIcon from "@material-ui/icons/Info";
import CreateIcon from "@material-ui/icons/Create";
import SupervisedUserCircleIcon from "@material-ui/icons/SupervisedUserCircle";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";
import TrendingUpIcon from "@material-ui/icons/TrendingUp";
import BuildIcon from "@material-ui/icons/Build";
import ArrowBackIcon from "@material-ui/icons/ArrowBack";
import { styles } from "./AppLayout.styles";
import { MultiAccount, UAccount } from "../../types/Account";
import {
@ -68,81 +63,30 @@ import {
getAccountRegistry,
removeAccountFromRegistry
} from "../../utilities/accounts";
import { isChildView } from "../../utilities/appbar";
/**
* The pre-define state interface for the app layout.
*/
interface IAppLayoutState {
/**
* Whether the account menu is open or not.
*/
acctMenuOpen: boolean;
/**
* Whether the drawer is open (mobile-only).
*/
drawerOpenOnMobile: boolean;
/**
* The current user signed in.
*/
currentUser?: UAccount;
/**
* The number of notifications received.
*/
notificationCount: number;
/**
* Whether the log out dialog is open.
*/
logOutOpen: boolean;
/**
* Whether federation has been enabled in the config.
*/
enableFederation?: boolean;
/**
* The brand name of the app, if not "Hyperspace".
*/
brandName?: string;
/**
* Whether the app is in development mode.
*/
developerMode?: boolean;
}
/**
* The base app layout class. Responsible for the search bar, navigation menus, etc.
*/
export class AppLayout extends Component<any, IAppLayoutState> {
/**
* The Mastodon client to operate with.
*/
client: Mastodon;
/**
* A stream listener to listen for new streaming events from Mastodon.
*/
streamListener: any;
/**
* Construct the app layout.
* @param props The properties to pass in.
*/
constructor(props: any) {
super(props);
// Create the Mastodon client
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
// Initialize the state
this.state = {
drawerOpenOnMobile: false,
acctMenuOpen: false,
@ -150,20 +94,14 @@ export class AppLayout extends Component<any, IAppLayoutState> {
logOutOpen: false
};
// Bind functions as properties to this class for reference
this.toggleDrawerOnMobile = this.toggleDrawerOnMobile.bind(this);
this.toggleAcctMenu = this.toggleAcctMenu.bind(this);
this.clearBadge = this.clearBadge.bind(this);
}
/**
* Run post-mount tasks such as getting account data and refreshing the config file.
*/
componentDidMount() {
// Get the account data.
this.getAccountData();
// Read the config file and then update the state.
getConfig().then((result: any) => {
if (result !== undefined) {
let config: Config = result;
@ -177,25 +115,18 @@ export class AppLayout extends Component<any, IAppLayoutState> {
}
});
// Listen for notifications.
this.streamNotifications();
}
/**
* Get updated credentials from Mastodon or pull information from local storage.
*/
getAccountData() {
// Try to get updated credentials from Mastodon.
this.client
.get("/accounts/verify_credentials")
.then((resp: any) => {
// Update the account if possible.
let data: UAccount = resp.data;
this.setState({ currentUser: data });
sessionStorage.setItem("id", data.id);
})
.catch((err: Error) => {
// Otherwise, pull from local storage.
this.props.enqueueSnackbar(
"Couldn't find profile info: " + err.name
);
@ -205,14 +136,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
});
}
/**
* Set up a stream listener and listen for notifications.
*/
streamNotifications() {
// Set up the stream listener.
this.streamListener = this.client.stream("/streaming/user");
// Set the count if the user asked to display the total count.
if (getUserDefaultBool("displayAllOnNotificationBadge")) {
this.client.get("/notifications").then((resp: any) => {
let notifArray = resp.data;
@ -220,17 +146,14 @@ export class AppLayout extends Component<any, IAppLayoutState> {
});
}
// Listen for notifications.
this.streamListener.on("notification", (notif: Notification) => {
const notificationCount = this.state.notificationCount + 1;
this.setState({ notificationCount });
// Update the badge on the desktop.
if (isDesktopApp()) {
getElectronApp().setBadgeCount(notificationCount);
}
// Set up a push notification if the window isn't in focus.
if (!document.hasFocus()) {
let primaryMessage = "";
let secondaryMessage = "";
@ -289,54 +212,34 @@ export class AppLayout extends Component<any, IAppLayoutState> {
break;
}
// Respectfully send the notification request.
sendNotificationRequest(primaryMessage, secondaryMessage);
}
});
}
/**
* Toggle the account menu.
*/
toggleAcctMenu() {
this.setState({ acctMenuOpen: !this.state.acctMenuOpen });
}
/**
* Toggle the app drawer, if on mobile.
*/
toggleDrawerOnMobile() {
this.setState({
drawerOpenOnMobile: !this.state.drawerOpenOnMobile
});
}
/**
* Toggle the logout dialog.
*/
toggleLogOutDialog() {
this.setState({ logOutOpen: !this.state.logOutOpen });
}
/**
* Perform a search and redirect to the search page.
* @param what The query input from the search box
*/
searchForQuery(what: string) {
what = what.replace(/^#/g, "tag:");
console.log(what);
window.location.href = isDesktopApp()
? "hyperspace://hyperspace/app/index.html#/search?query=" + what
: "/#/search?query=" + what;
}
/**
* Clear login information, remove the account from the registry, and reload the web page.
*/
logOutAndRestart() {
let loginData = localStorage.getItem("login");
if (loginData) {
// Remove account from the registry.
let registry = getAccountRegistry();
registry.forEach((registryItem: MultiAccount, index: number) => {
@ -348,20 +251,15 @@ export class AppLayout extends Component<any, IAppLayoutState> {
}
});
// Clear some of the local storage fields.
let items = ["login", "account", "baseurl", "access_token"];
items.forEach(entry => {
localStorage.removeItem(entry);
});
// Finally, reload.
window.location.reload();
}
}
/**
* Clear the notifications badge.
*/
clearBadge() {
if (!getUserDefaultBool("displayAllOnNotificationBadge")) {
this.setState({ notificationCount: 0 });
@ -372,9 +270,6 @@ export class AppLayout extends Component<any, IAppLayoutState> {
}
}
/**
* Render the title bar.
*/
titlebar() {
const { classes } = this.props;
if (isDarwinApp()) {
@ -395,20 +290,13 @@ export class AppLayout extends Component<any, IAppLayoutState> {
return (
<div className={classes.titleBarRoot}>
<Typography className={classes.titleBarText}>
<BuildIcon
color="inherit"
style={{ fontSize: "1em", verticalAlign: "middle" }}
/>{" "}
Careful: you're running in developer mode.
🛠 Careful: you're running in developer mode.
</Typography>
</div>
);
}
}
/**
* Render the app drawer. On the desktop, this appears as a sidebar in larger layouts.
*/
appDrawer() {
const { classes } = this.props;
return (
@ -542,13 +430,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
</LinkableListItem>
<Divider />
</div>
<ListSubheader>Community</ListSubheader>
<LinkableListItem button key="activity" to="/activity">
<ListItemIcon>
<TrendingUpIcon />
</ListItemIcon>
<ListItemText primary="Activity" />
</LinkableListItem>
<ListSubheader>More</ListSubheader>
<LinkableListItem
button
key="recommended"
@ -557,10 +439,8 @@ export class AppLayout extends Component<any, IAppLayoutState> {
<ListItemIcon>
<GroupIcon />
</ListItemIcon>
<ListItemText primary="Recommended" />
<ListItemText primary="Who to follow" />
</LinkableListItem>
<Divider />
<ListSubheader>More</ListSubheader>
<LinkableListItem button key="settings" to="/settings">
<ListItemIcon>
<SettingsIcon />
@ -578,9 +458,6 @@ export class AppLayout extends Component<any, IAppLayoutState> {
);
}
/**
* Render the entire layout.
*/
render() {
const { classes } = this.props;
return (
@ -589,18 +466,6 @@ export class AppLayout extends Component<any, IAppLayoutState> {
{this.titlebar()}
<AppBar className={classes.appBar} position="static">
<Toolbar>
{isDesktopApp() &&
isChildView(window.location.hash) ? (
<IconButton
className={classes.appBarBackButton}
color="inherit"
aria-label="Go back"
onClick={() => window.history.back()}
>
<ArrowBackIcon />
</IconButton>
) : null}
<IconButton
className={classes.appBarMenuButton}
color="inherit"
@ -752,15 +617,6 @@ export class AppLayout extends Component<any, IAppLayoutState> {
Edit profile
</ListItemText>
</LinkableListItem>
<LinkableListItem
button={true}
to={"/requests"}
>
<ListItemText>
Manage follow requests
</ListItemText>
</LinkableListItem>
<Divider />
<LinkableListItem
to={"/welcome"}
button={true}
@ -792,7 +648,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
variant="temporary"
anchor={"left"}
open={this.state.drawerOpenOnMobile}
onClick={this.toggleDrawerOnMobile}
onClose={this.toggleDrawerOnMobile}
classes={{ paper: classes.drawerPaper }}
>
{this.appDrawer()}

View File

@ -7,7 +7,6 @@ import {
} from "@material-ui/core";
import { styles } from "./Attachment.styles";
import { Attachment } from "../../types/Attachment";
import AudioPlayer from "../AudioPlayer";
import SwipeableViews from "react-swipeable-views";
interface IAttachmentProps {
@ -77,15 +76,11 @@ class AttachmentComponent extends Component<
className={classes.mediaObject}
/>
);
case "audio":
return <AudioPlayer src={slide.url} id={slide.id} />;
case "gifv":
return (
<video
autoPlay
loop
<img
src={slide.url}
title={slide.description ? slide.description : ""}
alt={slide.description ? slide.description : ""}
className={classes.mediaObject}
/>
);
@ -111,36 +106,33 @@ class AttachmentComponent extends Component<
);
})}
</SwipeableViews>
{this.state.totalSteps > 1 ? (
<MobileStepper
steps={this.state.totalSteps}
position="static"
activeStep={this.state.currentStep}
className={classes.mobileStepper}
nextButton={
<Button
size="small"
onClick={() => this.moveForward()}
disabled={
this.state.currentStep ===
this.state.totalSteps - 1
}
>
Next
</Button>
}
backButton={
<Button
size="small"
onClick={() => this.moveBack()}
disabled={this.state.currentStep === 0}
>
Back
</Button>
}
/>
) : null}
<br />
<MobileStepper
steps={this.state.totalSteps}
position="static"
activeStep={this.state.currentStep}
className={classes.mobileStepper}
nextButton={
<Button
size="small"
onClick={() => this.moveForward()}
disabled={
this.state.currentStep ===
this.state.totalSteps - 1
}
>
Next
</Button>
}
backButton={
<Button
size="small"
onClick={() => this.moveBack()}
disabled={this.state.currentStep === 0}
>
Back
</Button>
}
/>
<Typography variant="caption">
{mediaItem.description
? mediaItem.description

View File

@ -1,17 +0,0 @@
import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) =>
createStyles({
root: {
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.action.disabledBackground,
borderWidth: 1,
borderStyle: "solid"
},
progressBar: {
width: "100%"
},
download: {
color: `${theme.palette.action.active} !important`
}
});

View File

@ -1,141 +0,0 @@
import React, { Component } from "react";
import {
Toolbar,
IconButton,
withStyles,
LinearProgress,
Tooltip
} from "@material-ui/core";
import { LinkableIconButton } from "../../interfaces/overrides";
import FastRewindIcon from "@material-ui/icons/FastRewind";
import FastForwardIcon from "@material-ui/icons/FastForward";
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import PauseIcon from "@material-ui/icons/Pause";
import CloudDownloadIcon from "@material-ui/icons/CloudDownload";
import { styles } from "./AudioPlayer.styles";
interface IAudioPlayerProps {
src: string;
id: string;
classes: any;
}
interface IAudioPlayerState {
src: string;
elementId: string;
playing: boolean;
progress: number;
}
class AudioPlayer extends Component<IAudioPlayerProps, IAudioPlayerState> {
constructor(props: any) {
super(props);
this.state = {
src: this.props.src,
elementId: "audioplayer-" + this.props.id,
playing: false,
progress: 0
};
}
componentDidMount() {
let audioPlayerElement = this.getAudioPlayer();
if (audioPlayerElement) {
audioPlayerElement.ontimeupdate = () => {
let music = audioPlayerElement as HTMLAudioElement;
let progress = 100 * (music.currentTime / music.duration);
this.setState({ progress });
};
}
}
getAudioPlayer(): HTMLAudioElement | null {
return document.getElementById(
this.state.elementId
) as HTMLAudioElement | null;
}
toggleAudio() {
let audioPlayerElement = this.getAudioPlayer();
if (audioPlayerElement && this.state.playing) {
audioPlayerElement.pause();
this.setState({ playing: false });
} else if (audioPlayerElement) {
audioPlayerElement.play();
this.setState({ playing: true });
}
}
fastForward() {
let audioPlayerElement = this.getAudioPlayer();
if (audioPlayerElement) {
audioPlayerElement.currentTime += 15.0;
}
}
rewind() {
let audioPlayerElement = this.getAudioPlayer();
if (audioPlayerElement) {
audioPlayerElement.currentTime -= 15.0;
}
}
render() {
const { classes } = this.props;
return (
<div className={classes.root}>
<audio
id={this.state.elementId}
src={this.state.src}
autoPlay={false}
/>
<Toolbar>
<Tooltip title="Rewind by 15s">
<IconButton onClick={() => this.rewind()}>
<FastRewindIcon />
</IconButton>
</Tooltip>
<Tooltip title={this.state.playing ? "Pause" : "Play"}>
<IconButton onClick={() => this.toggleAudio()}>
{this.state.playing ? (
<PauseIcon />
) : (
<PlayArrowIcon />
)}
</IconButton>
</Tooltip>
<Tooltip title="Fast-forward by 15s">
<IconButton onClick={() => this.fastForward()}>
<FastForwardIcon />
</IconButton>
</Tooltip>
<LinearProgress
className={classes.progressBar}
variant="determinate"
color={"secondary"}
value={this.state.progress}
/>
<Tooltip title="Download">
<IconButton
href={this.state.src}
target="_blank"
rel="noopener noreferrer nofollower"
className={classes.download}
>
<CloudDownloadIcon />
</IconButton>
</Tooltip>
</Toolbar>
</div>
);
}
}
export default withStyles(styles)(AudioPlayer);

View File

@ -1,3 +0,0 @@
import AudioPlayer from "./AudioPlayer";
export default AudioPlayer;

View File

@ -81,15 +81,6 @@ export const styles = (theme: Theme) =>
paddingTop: theme.spacing.unit,
paddingBottom: theme.spacing.unit
},
postAuthorAccount: {
color: theme.palette.grey[500],
marginLeft: theme.spacing.unit * 0.5,
},
postReblogIcon: {
marginBottom: theme.spacing.unit * -0.5,
marginLeft: theme.spacing.unit * 0.5,
marginRight: theme.spacing.unit * 0.5,
},
postAuthorEmoji: {
height: theme.typography.fontSize,
verticalAlign: "middle"

View File

@ -101,11 +101,6 @@ export class Post extends React.Component<any, IPostState> {
});
}
shouldComponentUpdate(nextProps: any, nextState: any) {
if (nextState == this.state) return false;
return true;
}
togglePostMenu() {
this.setState({ menuIsOpen: !this.state.menuIsOpen });
}
@ -401,58 +396,24 @@ export class Post extends React.Component<any, IPostState> {
getReblogAuthors(post: Status) {
const { classes } = this.props;
let author = post.reblog ? post.reblog.account : post.account;
let emojis = author.emojis;
let reblogger = post.reblog ? post.account : undefined;
if (reblogger != undefined) {
emojis.concat(reblogger.emojis);
if (post.reblog) {
let author = post.reblog.account;
let origString = `<span>${author.display_name ||
author.username} (@${author.acct}) 🔄 ${post.account
.display_name || post.account.username}</span>`;
let emojis = author.emojis;
emojis.concat(post.account.emojis);
return emojifyString(origString, emojis, classes.postAuthorEmoji);
} else {
let author = post.account;
let origString = `<span>${author.display_name ||
author.username} (@${author.acct})</span>`;
return emojifyString(
origString,
author.emojis,
classes.postAuthorEmoji
);
}
return (
<>
<span
dangerouslySetInnerHTML={{
__html: emojifyString(
author.display_name || author.username,
emojis,
classes.postAuthorEmoji
)
}}
></span>
<span
className={classes.postAuthorAccount}
dangerouslySetInnerHTML={{
__html:
"@" +
emojifyString(
author.acct || author.username,
emojis,
classes.postAuthorEmoji
)
}}
></span>
{reblogger ? (
<>
<AutorenewIcon
fontSize="small"
className={classes.postReblogIcon}
/>
<span
dangerouslySetInnerHTML={{
__html: emojifyString(
reblogger.display_name ||
reblogger.username,
emojis,
classes.postAuthorEmoji
)
}}
></span>
</>
) : null}
</>
);
}
getMentions(mention: [Mention]) {
@ -539,63 +500,86 @@ export class Post extends React.Component<any, IPostState> {
}
}
/**
* Get the post's URL
* @param post The post to get the URL from
* @returns A string containing the post's URI
*/
getMastodonUrl(post: Status) {
return post.reblog ? post.reblog.uri : post.uri;
let url = "";
if (post.reblog) {
url = post.reblog.uri;
} else {
url = post.uri;
}
return url;
}
/**
* Tell server a post has been un/favorited and update post state
* @param post The post to un/favorite
*/
async toggleFavorite(post: Status) {
let action: string = post.favourited ? "unfavourite" : "favourite";
try {
// favorite the original post, not the reblog
let resp: any = await this.client.post(
`/statuses/${post.reblog ? post.reblog.id : post.id}/${action}`
);
// compensate for slow server update
if (action === "unfavourite") {
resp.data.favourites_count -= 1;
// if you unlike both original and reblog before refresh
// and the post has only one favorite:
if (resp.data.favourites_count < 0) {
resp.data.favourites_count = 0;
}
}
this.setState({ post: resp.data as Status });
} catch (e) {
this.props.enqueueSnackbar(`Could not ${action} post: ${e.name}`);
console.error(e.message);
toggleFavorited(post: Status) {
let _this = this;
if (post.favourited) {
this.client
.post(`/statuses/${post.id}/unfavourite`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
_this.props.enqueueSnackbar(
`Couldn't unfavorite post: ${err.name}`,
{
variant: "error"
}
);
console.log(err.message);
});
} else {
this.client
.post(`/statuses/${post.id}/favourite`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
_this.props.enqueueSnackbar(
`Couldn't favorite post: ${err.name}`,
{
variant: "error"
}
);
console.log(err.message);
});
}
}
/**
* Tell server a post has been un/reblogged and update post state
* @param post The post to un/reblog
*/
async toggleReblog(post: Status) {
let action: string =
post.reblogged || post.reblog ? "unreblog" : "reblog";
try {
// modify the original post, not the reblog
let resp: any = await this.client.post(
`/statuses/${post.reblog ? post.reblog.id : post.id}/${action}`
);
// compensate for slow server update
if (action === "unreblog") {
resp.data.reblogs_count -= 1;
}
if (resp.data.reblog) resp.data = resp.data.reblog;
this.setState({ post: resp.data as Status });
} catch (e) {
this.props.enqueueSnackbar(`Could not ${action} post: ${e.name}`);
console.error(e.message);
toggleReblogged(post: Status) {
if (post.reblogged) {
this.client
.post(`/statuses/${post.id}/unreblog`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't unboost post: ${err.name}`,
{
variant: "error"
}
);
console.log(err.message);
});
} else {
this.client
.post(`/statuses/${post.id}/reblog`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't boost post: ${err.name}`,
{
variant: "error"
}
);
console.log(err.message);
});
}
}
@ -640,210 +624,234 @@ export class Post extends React.Component<any, IPostState> {
const { classes } = this.props;
const post = this.state.post;
return (
<Card
className={classes.post}
id={`post_${post.id}`}
elevation={this.props.threadHeader ? 0 : 1}
>
<CardHeader
avatar={
<LinkableAvatar
to={`/profile/${
post.reblog
? post.reblog.account.id
: post.account.id
}`}
src={
post.reblog
? post.reblog.account.avatar_static
: post.account.avatar_static
}
/>
}
action={
<Tooltip title="More" placement="left">
<IconButton
key={`${post.id}_submenu`}
id={`${post.id}_submenu`}
onClick={() => this.togglePostMenu()}
>
<MoreVertIcon />
</IconButton>
</Tooltip>
}
title={
<Typography>{this.getReblogAuthors(post)}</Typography>
}
subheader={moment(post.created_at).format(
"MMMM Do YYYY [at] h:mm A"
)}
/>
{post.reblog ? this.getReblogOfPost(post.reblog) : null}
{post.sensitive
? this.getSensitiveContent(post.spoiler_text, post)
: post.reblog
? null
: this.materializeContent(post)}
{post.reblog && post.reblog.mentions.length > 0
? this.getMentions(post.reblog.mentions)
: this.getMentions(post.mentions)}
{post.reblog && post.reblog.tags.length > 0
? this.getTags(post.reblog.tags)
: this.getTags(post.tags)}
<CardActions>
<Tooltip title="Reply">
<LinkableIconButton
to={`/compose?reply=${
post.reblog ? post.reblog.id : post.id
}&visibility=${post.visibility}&acct=${
post.reblog
? post.reblog.account.acct
: post.account.acct
}`}
>
<ReplyIcon />
</LinkableIconButton>
</Tooltip>
<Typography>
{post.reblog
? post.reblog.replies_count
: post.replies_count}
</Typography>
<Tooltip title="Favorite">
<IconButton onClick={() => this.toggleFavorite(post)}>
<FavoriteIcon
className={
post.favourited ? classes.postDidAction : ""
<Zoom in={true}>
<Card
className={classes.post}
id={`post_${post.id}`}
elevation={this.props.threadHeader ? 0 : 1}
>
<CardHeader
avatar={
<LinkableAvatar
to={`/profile/${
post.reblog
? post.reblog.account.id
: post.account.id
}`}
src={
post.reblog
? post.reblog.account.avatar_static
: post.account.avatar_static
}
/>
</IconButton>
</Tooltip>
<Typography>{post.favourites_count}</Typography>
<Tooltip title="Boost">
<IconButton onClick={() => this.toggleReblog(post)}>
<AutorenewIcon
className={
}
action={
<Tooltip title="More" placement="left">
<IconButton
key={`${post.id}_submenu`}
id={`${post.id}_submenu`}
onClick={() => this.togglePostMenu()}
>
<MoreVertIcon />
</IconButton>
</Tooltip>
}
title={
<Typography
dangerouslySetInnerHTML={{
__html: this.getReblogAuthors(post)
}}
/>
}
subheader={moment(post.created_at).format(
"MMMM Do YYYY [at] h:mm A"
)}
/>
{post.reblog ? this.getReblogOfPost(post.reblog) : null}
{post.sensitive
? this.getSensitiveContent(post.spoiler_text, post)
: post.reblog
? null
: this.materializeContent(post)}
{post.reblog && post.reblog.mentions.length > 0
? this.getMentions(post.reblog.mentions)
: this.getMentions(post.mentions)}
{post.reblog && post.reblog.tags.length > 0
? this.getTags(post.reblog.tags)
: this.getTags(post.tags)}
<CardActions>
<Tooltip title="Reply">
<LinkableIconButton
to={`/compose?reply=${
post.reblog ? post.reblog.id : post.id
}&visibility=${post.visibility}&acct=${
post.reblog
? post.reblog.reblogged
? post.reblog.account.acct
: post.account.acct
}`}
>
<ReplyIcon />
</LinkableIconButton>
</Tooltip>
<Typography>
{post.reblog
? post.reblog.replies_count
: post.replies_count}
</Typography>
<Tooltip title="Favorite">
<IconButton
onClick={() => this.toggleFavorited(post)}
>
<FavoriteIcon
className={
post.reblog
? post.reblog.favourited
? classes.postDidAction
: ""
: post.favourited
? classes.postDidAction
: ""
: post.reblogged
? classes.postDidAction
: ""
}
/>
</IconButton>
</Tooltip>
<Typography>
{post.reblog
? post.reblog.reblogs_count
: post.reblogs_count}
</Typography>
<Tooltip
className={classes.desktopOnly}
title="View thread"
>
<LinkableIconButton
to={`/conversation/${
post.reblog ? post.reblog.id : post.id
}`}
>
<ForumIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip
className={classes.desktopOnly}
title="Open in Web"
>
<IconButton
href={this.getMastodonUrl(post)}
rel="noreferrer"
target="_blank"
>
<OpenInNewIcon />
</IconButton>
</Tooltip>
<div className={classes.postFlexGrow} />
<div className={classes.postTypeIconDiv}>
{this.showVisibilityIcon(post.visibility)}
</div>
</CardActions>
<Menu
id="postmenu"
anchorEl={document.getElementById(`${post.id}_submenu`)}
open={this.state.menuIsOpen}
onClose={() => this.togglePostMenu()}
>
<ShareMenu
config={{
params: {
title: `@${post.account.username} posted on Mastodon: `,
text: post.content,
url: this.getMastodonUrl(post)
},
onShareSuccess: () =>
this.props.enqueueSnackbar("Post shared!", {
variant: "success"
}),
onShareError: (error: Error) => {
if (error.name != "AbortError")
this.props.enqueueSnackbar(
`Couldn't share post: ${error.name}`,
{ variant: "error" }
);
}
}}
/>
{post.reblog ? (
<div className={classes.postReblogMenu}>
<LinkableMenuItem
to={`/profile/${post.reblog.account.id}`}
}
/>
</IconButton>
</Tooltip>
<Typography>
{post.reblog
? post.reblog.favourites_count
: post.favourites_count}
</Typography>
<Tooltip title="Boost">
<IconButton
onClick={() => this.toggleReblogged(post)}
>
View author profile
</LinkableMenuItem>
<AutorenewIcon
className={
post.reblog
? post.reblog.reblogged
? classes.postDidAction
: ""
: post.reblogged
? classes.postDidAction
: ""
}
/>
</IconButton>
</Tooltip>
<Typography>
{post.reblog
? post.reblog.reblogs_count
: post.reblogs_count}
</Typography>
<Tooltip
className={classes.desktopOnly}
title="View thread"
>
<LinkableIconButton
to={`/conversation/${
post.reblog ? post.reblog.id : post.id
}`}
>
<ForumIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip
className={classes.desktopOnly}
title="Open in Web"
>
<IconButton
href={this.getMastodonUrl(post)}
rel="noreferrer"
target="_blank"
>
<OpenInNewIcon />
</IconButton>
</Tooltip>
<div className={classes.postFlexGrow} />
<div className={classes.postTypeIconDiv}>
{this.showVisibilityIcon(post.visibility)}
</div>
</CardActions>
<Menu
id="postmenu"
anchorEl={document.getElementById(`${post.id}_submenu`)}
open={this.state.menuIsOpen}
onClose={() => this.togglePostMenu()}
>
<ShareMenu
config={{
params: {
title: `@${post.account.username} posted on Mastodon: `,
text: post.content,
url: this.getMastodonUrl(post)
},
onShareSuccess: () =>
this.props.enqueueSnackbar("Post shared!", {
variant: "success"
}),
onShareError: (error: Error) => {
if (error.name != "AbortError")
this.props.enqueueSnackbar(
`Couldn't share post: ${error.name}`,
{ variant: "error" }
);
}
}}
/>
{post.reblog ? (
<div className={classes.postReblogMenu}>
<LinkableMenuItem
to={`/profile/${post.reblog.account.id}`}
>
View author profile
</LinkableMenuItem>
<LinkableMenuItem
to={`/profile/${post.account.id}`}
>
View reblogger profile
</LinkableMenuItem>
</div>
) : (
<LinkableMenuItem
to={`/profile/${post.account.id}`}
>
View reblogger profile
View profile
</LinkableMenuItem>
</div>
) : (
<LinkableMenuItem to={`/profile/${post.account.id}`}>
View profile
</LinkableMenuItem>
)}
<div className={classes.mobileOnly}>
<Divider />
<LinkableMenuItem
to={`/conversation/${
post.reblog ? post.reblog.id : post.id
}`}
>
View thread
</LinkableMenuItem>
<MenuItem
component="a"
href={this.getMastodonUrl(post)}
rel="noreferrer"
target="_blank"
>
Open in Web
</MenuItem>
</div>
{this.state.myAccount &&
post.account.id === this.state.myAccount ? (
<div>
)}
<div className={classes.mobileOnly}>
<Divider />
<MenuItem
onClick={() => this.togglePostDeleteDialog()}
<LinkableMenuItem
to={`/conversation/${
post.reblog ? post.reblog.id : post.id
}`}
>
Delete
View thread
</LinkableMenuItem>
<MenuItem
component="a"
href={this.getMastodonUrl(post)}
rel="noreferrer"
target="_blank"
>
Open in Web
</MenuItem>
</div>
) : null}
{this.showDeleteDialog()}
</Menu>
</Card>
{this.state.myAccount &&
post.account.id === this.state.myAccount ? (
<div>
<Divider />
<MenuItem
onClick={() =>
this.togglePostDeleteDialog()
}
>
Delete
</MenuItem>
</div>
) : null}
{this.showDeleteDialog()}
</Menu>
</Card>
</Zoom>
);
}
}

View File

@ -78,14 +78,14 @@ export const ProfileRoute = (rest: any, component: Component) => (
export const PrivateRoute = (props: IPrivateRouteProps) => {
const { component, render, ...rest } = props;
const redir = (comp: any) =>
userLoggedIn() ? comp : <Redirect to="/welcome" />;
return (
<Route
{...rest}
render={(compProps: any) =>
redir(
React.createElement(render ? render : component, compProps)
userLoggedIn() ? (
React.createElement(component, compProps)
) : (
<Redirect to="/welcome" />
)
}
/>
@ -93,6 +93,5 @@ export const PrivateRoute = (props: IPrivateRouteProps) => {
};
interface IPrivateRouteProps extends RouteProps {
component?: any;
render?: any;
component: any;
}

View File

@ -1,8 +0,0 @@
/**
* A Generic dictionary with the value of a specific type.
*
* Keys _must_ be strings.
*/
export interface Dictionary<T> {
[Key: string]: T;
}

View File

@ -1,344 +0,0 @@
import React, { Component } from "react";
import {
withStyles,
Typography,
CircularProgress,
ListSubheader,
Link,
Paper,
List,
ListItem,
ListItemText,
ListItemAvatar,
ListItemSecondaryAction,
Tooltip,
IconButton
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import { UAccount, Account } from "../types/Account";
import { Tag } from "../types/Tag";
import Mastodon from "megalodon";
import { LinkableAvatar, LinkableIconButton } from "../interfaces/overrides";
import moment from "moment";
import FireplaceIcon from "@material-ui/icons/Fireplace";
import TrendingUpIcon from "@material-ui/icons/TrendingUp";
import SearchIcon from "@material-ui/icons/Search";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
interface IActivityPageState {
user?: UAccount;
trendingTags?: [Tag];
activeProfileDirectory?: [Account];
newProfileDirectory?: [Account];
viewLoading: boolean;
viewLoaded?: boolean;
viewErrored?: boolean;
}
class ActivityPage extends Component<any, IActivityPageState> {
client: Mastodon;
constructor(props: any) {
super(props);
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
this.state = {
viewLoading: true
};
}
componentDidMount() {
this.getAccountData();
this.client
.get("/trends", { limit: 3 })
.then((resp: any) => {
let trendingTags: [Tag] = resp.data;
this.setState({ trendingTags });
})
.catch((err: Error) => {
this.setState({
viewLoading: false,
viewErrored: true
});
console.error(err.message);
});
this.client
.get("/directory", { local: true, order: "active", limit: 5 })
.then((resp: any) => {
let profileDirectory: [Account] = resp.data;
this.setState({
activeProfileDirectory: profileDirectory
});
})
.catch((err: Error) => {
this.setState({
viewLoading: false,
viewErrored: true
});
console.log(err.message);
});
this.client
.get("/directory", { local: true, order: "new", limit: 5 })
.then((resp: any) => {
let profileDirectory: [Account] = resp.data;
this.setState({
newProfileDirectory: profileDirectory,
viewLoading: false,
viewLoaded: true
});
})
.catch((err: Error) => {
this.setState({
viewLoading: false,
viewErrored: true
});
console.log(err.message);
});
}
getAccountData() {
this.client
.get("/accounts/verify_credentials")
.then((resp: any) => {
let data: UAccount = resp.data;
this.setState({ user: data });
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't find profile info: " + err.name
);
console.error(err.message);
let acct = localStorage.getItem("account") as string;
this.setState({ user: JSON.parse(acct) });
});
}
render() {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
<div
style={{
textAlign: "center"
}}
>
<FireplaceIcon style={{ fontSize: 64 }} color="action" />
<Typography variant="h6">
Hey there,{" "}
{this.state.user
? this.state.user.display_name ||
this.state.user.acct
: "user"}
!
</Typography>
<Typography paragraph>
Take a look at what's been happening on your instance.
</Typography>
</div>
{this.state.viewLoaded ? (
<div>
<ListSubheader>Trending hashtags</ListSubheader>
{this.state.trendingTags &&
this.state.trendingTags.length > 0 ? (
<Paper>
<List className={classes.pageListConstraints}>
{this.state.trendingTags.map((tag: Tag) => (
<ListItem
id={"trending_tag_" + tag.name}
key={"trending_tag_" + tag.name}
>
<ListItemAvatar>
<TrendingUpIcon />
</ListItemAvatar>
<ListItemText
primary={"#" + tag.name}
secondary={
tag.history
? `${tag.history[0].accounts} people talking in ${tag.history[0].uses} posts`
: "Couldn't determine usage"
}
/>
<ListItemSecondaryAction>
<Tooltip title="Search">
<LinkableIconButton
to={`/search?query=tag:${tag.name}`}
>
<SearchIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="View on web">
<IconButton>
<OpenInNewIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</Paper>
) : (
<Typography paragraph>
It looks like there aren't any trending tags on
your instance as of right now.
</Typography>
)}
<br />
<ListSubheader>Who's been active</ListSubheader>
{this.state.activeProfileDirectory &&
this.state.activeProfileDirectory.length > 0 ? (
<Paper>
<List className={classes.pageListConstraints}>
{this.state.activeProfileDirectory.map(
(account: Account) => (
<ListItem
key={
"account_active_" +
account.acct
}
id={
"account_active_" +
account.acct
}
>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${account.id}`}
src={
account.avatar_static
}
/>
</ListItemAvatar>
<ListItemText
primary={
`${account.display_name} (@${account.username})` ||
`@${account.username}`
}
secondary={`Last posted ${moment(
account.last_status_at
)
.startOf("minute")
.fromNow()}`}
/>
<ListItemSecondaryAction>
<Tooltip title="View account">
<LinkableIconButton
to={`/profile/${account.id}`}
>
<AssignmentIndIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
)
)}
</List>
</Paper>
) : (
<Typography paragraph>
It looks like there aren't any active people in
the profile directory yet.
</Typography>
)}
<br />
<ListSubheader>New arrivals</ListSubheader>
{this.state.newProfileDirectory &&
this.state.newProfileDirectory.length > 0 ? (
<Paper>
<List className={classes.pageListConstraints}>
{this.state.newProfileDirectory.map(
(account: Account) => (
<ListItem
key={
"account_new_" +
account.acct
}
id={
"account_new_" +
account.acct
}
>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${account.id}`}
src={
account.avatar_static
}
/>
</ListItemAvatar>
<ListItemText
primary={
`${account.display_name} (@${account.username})` ||
`@${account.username}`
}
secondary={`Joined ${moment(
account.created_at
)
.startOf("minute")
.fromNow()}`}
/>
<ListItemSecondaryAction>
<Tooltip title="View account">
<LinkableIconButton
to={`/profile/${account.id}`}
>
<AssignmentIndIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
)
)}
</List>
</Paper>
) : (
<Typography paragraph>
It looks like there aren't any new arrivals
listed in the profile directory yet.
</Typography>
)}
</div>
) : null}
{this.state.viewErrored ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading instance activity.
</Typography>
</Paper>
) : (
<span />
)}
{this.state.viewLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
<br />
<div>
<Typography variant="caption">
Trending hashtags and the profile directory may not
appear here if your instance isn't up to date. Check the{" "}
<Link href="/#/about">about page</Link> to see if your
instance is running the latest version.
</Typography>
</div>
</div>
);
}
}
export default withStyles(styles)(ActivityPage);

View File

@ -45,25 +45,5 @@ export const styles = (theme: Theme) =>
},
pollWizardFlexGrow: {
flexGrow: 1
},
draftDisplayArea: {
display: "flex",
paddingLeft: 8,
paddingRight: 8,
paddingTop: 4,
paddingBottom: 4,
borderColor: theme.palette.action.disabledBackground,
borderWidth: 0.25,
borderStyle: "solid",
borderRadius: 2,
verticalAlign: "middle",
marginLeft: 16,
marginRight: 16
},
draftText: {
padding: theme.spacing.unit / 2
},
draftFlexGrow: {
flexGrow: 1
}
});

View File

@ -23,7 +23,7 @@ import { parse as parseParams, ParsedQuery } from "query-string";
import { styles } from "./Compose.styles";
import { UAccount } from "../types/Account";
import { Visibility } from "../types/Visibility";
import AttachFileIcon from "@material-ui/icons/AttachFile";
import CameraAltIcon from "@material-ui/icons/CameraAlt";
import TagFacesIcon from "@material-ui/icons/TagFaces";
import HowToVoteIcon from "@material-ui/icons/HowToVote";
import VisibilityIcon from "@material-ui/icons/Visibility";
@ -39,140 +39,55 @@ import ComposeMediaAttachment from "../components/ComposeMediaAttachment";
import EmojiPicker from "../components/EmojiPicker";
import { DateTimePicker, MuiPickersUtilsProvider } from "material-ui-pickers";
import MomentUtils from "@date-io/moment";
import {
getUserDefaultVisibility,
getConfig,
getUserDefaultBool
} from "../utilities/settings";
import { draftExists, writeDraft, loadDraft } from "../utilities/compose";
import { getUserDefaultVisibility, getConfig } from "../utilities/settings";
/**
* The state for the Composer page.
*/
interface IComposerState {
/**
* The current user as an Account.
*/
account: UAccount;
/**
* The visibility of the post.
*/
visibility: Visibility;
/**
* Whether there should be a content warning.
*/
sensitive: boolean;
/**
* The content warning message.
*/
sensitiveText?: string;
/**
* Whether the visibility drop-down should be visible.
*/
visibilityMenu: boolean;
/**
* The text contents of the post.
*/
text: string;
/**
* The remaining amount of characters.
*/
remainingChars: number;
/**
* An optional reply ID.
*/
reply?: string;
/**
* The account to reply to, if it exists.
*/
acct?: string;
/**
* An optional list of media attachments.
*/
attachments?: [Attachment];
/**
* An optional poll for the post.
*/
poll?: PollWizard;
/**
* The expiration date of a poll, if it exists.
*/
pollExpiresDate?: any;
/**
* Whether the emoji picker should be visible.
*/
showEmojis: boolean;
/**
* Whether or not the account's instance is federated.
*/
federated: boolean;
}
/**
* The Compose page contains all of the information to create a UI for post creation.
*/
class Composer extends Component<any, IComposerState> {
/**
* The Mastodon client to work with.
*/
client: Mastodon;
/**
* Construct the Compose page by generating the Mastodon client and setting default values.
* @param props The properties passed into the Compose component, usually the page queries.
*/
constructor(props: any) {
super(props);
// Generate the Mastodon client
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
// Set the initial state
this.state = {
account: JSON.parse(localStorage.getItem("account") as string),
visibility: getUserDefaultVisibility(),
sensitive: false,
visibilityMenu: false,
text: "",
remainingChars: getUserDefaultBool("imposeCharacterLimit")
? 500
: 9999999999999,
remainingChars: 500,
showEmojis: false,
federated: true
};
}
/**
* Run any additional state checks and setup once the page has mounted. This includes
* parsing the query parameters and loading the configuration, as well as defining the
* clipboard listener.
*/
componentDidMount() {
// Parse the parameters and get the account information if available.
let state = this.getComposerParams(this.props);
let text = state.acct ? `@${state.acct}: ` : "";
this.client.get("/accounts/verify_credentials").then((resp: any) => {
let account: UAccount = resp.data;
this.setState({ account });
});
// Get the configuration and load the config values.
getConfig().then((config: any) => {
this.setState({
federated: config.federation.allowPublicPosts,
@ -180,43 +95,11 @@ class Composer extends Component<any, IComposerState> {
acct: state.acct,
visibility: state.visibility,
text,
remainingChars: getUserDefaultBool("imposeCharacterLimit")
? 500 - text.length
: 99999999
remainingChars: 500 - text.length
});
});
// Attach the paste listener to listen for the clipboard and upload media
// if possible.
window.addEventListener("paste", (evt: Event) => {
let thePasteEvent = evt as ClipboardEvent;
let fileList: File[] = [];
if (thePasteEvent.clipboardData != null) {
let clipitems = thePasteEvent.clipboardData.items;
if (clipitems != undefined) {
for (let i = 0; i < clipitems.length; i++) {
if (clipitems[i].type.indexOf("image") != -1) {
let clipfile = clipitems[i].getAsFile();
if (clipfile != null) {
fileList.push(clipfile);
}
}
}
if (fileList.length > 0) {
this.uploadMedia(fileList);
}
}
}
});
}
/**
* Reload the properties and set the state to those new properties. This usually
* occurs when the page is either reloaded or changes but React doesn't see the
* properties change.
* @param props The properties passed into the Compose component, usually the page queries.
*/
componentWillReceiveProps(props: any) {
let state = this.getComposerParams(props);
let text = state.acct ? `@${state.acct}: ` : "";
@ -225,42 +108,10 @@ class Composer extends Component<any, IComposerState> {
acct: state.acct,
visibility: state.visibility,
text,
remainingChars: getUserDefaultBool("imposeCharacterLimit")
? 500 - text.length
: 99999999
remainingChars: 500 - text.length
});
}
/**
* Check if there is unsaved text and store it as a draft.
*/
componentWillUnmount() {
if (this.state.text !== "") {
writeDraft(
this.state.text,
this.state.reply ? Number(this.state.reply) : -999
);
this.props.enqueueSnackbar("Draft saved.");
}
}
/**
* Restore the draft from session storage and pre-load it into the state.
*/
restoreDraft() {
const draft = loadDraft();
const text = draft.contents;
const reply =
draft.replyId !== -999 ? draft.replyId.toString() : undefined;
this.setState({ text, reply });
this.props.enqueueSnackbar("Restored draft.");
}
/**
* Check the location string and attempt to parse it into a parsed query.
* @param location The location string from React Router.
* @returns The ParsedQuery object containing all of the parameters.
*/
checkComposerParams(location?: string): ParsedQuery {
let params = "";
if (location !== undefined && typeof location === "string") {
@ -271,11 +122,6 @@ class Composer extends Component<any, IComposerState> {
return parseParams(params);
}
/**
* Check the property's location string, parse it, and return it.
* @param props The properties passed into the Compose component, usually the page queries.
* @returns An object containing the reply ID, reply account, and visibility.
*/
getComposerParams(props: any) {
let params = this.checkComposerParams(props.location);
let reply: string = "";
@ -298,44 +144,52 @@ class Composer extends Component<any, IComposerState> {
};
}
/**
* Update the text in the state and calculate the remaining character length.
* @param text The text to update the state to
*/
updateTextFromField(text: string) {
this.setState({
text,
remainingChars: getUserDefaultBool("imposeCharacterLimit")
? 500 - text.length
: 99999999
});
this.setState({ text, remainingChars: 500 - text.length });
}
/**
* Update the content warning text in the state
* @param sensitiveText The text to update the state to
*/
updateWarningFromField(sensitiveText: string) {
this.setState({ sensitiveText });
}
/**
* Update the visibility in the state
* @param visibility The visibility to update the state to
*/
changeVisibility(visibility: Visibility) {
this.setState({ visibility });
}
/**
* Open a file dialog to let the user choose files to upload to the server and then upload them.
*/
promptMediaDialog() {
uploadMedia() {
filedialog({
multiple: false,
accept: ".jpeg,.jpg,.png,.gif,.webm,.mp4,.mov,.ogg,.wav,.mp3,.flac"
accept: "image/*, video/*"
})
.then((media: FileList) => this.uploadMedia(media))
.then((media: FileList) => {
let mediaForm = new FormData();
mediaForm.append("file", media[0]);
this.props.enqueueSnackbar("Uploading media...", {
persist: true,
key: "media-upload"
});
this.client
.post("/media", mediaForm)
.then((resp: any) => {
let attachment: Attachment = resp.data;
let attachments = this.state.attachments;
if (attachments) {
attachments.push(attachment);
} else {
attachments = [attachment];
}
this.setState({ attachments });
this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar("Media uploaded.");
})
.catch((err: Error) => {
this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar(
"Couldn't upload media: " + err.name,
{ variant: "error" }
);
});
})
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
variant: "error"
@ -344,54 +198,6 @@ class Composer extends Component<any, IComposerState> {
});
}
/**
* Upload a list of files to Mastodon as attachments. Reads the first item in the list.
* This also updates the attachments state after a successful upload.
* @param media The list of files (`FileList` or `File[]`) to send to Mastodon.
*/
uploadMedia(media: FileList | File[]) {
// Create a new FormData for Mastodon
let mediaForm = new FormData();
mediaForm.append("file", media[0]);
// Let the user know we're uploading the file
this.props.enqueueSnackbar("Uploading media...", {
persist: true,
key: "media-upload"
});
// Try to upload the media to the server.
this.client
.post("/media", mediaForm)
// If we succeed, get the attachments and update the state.
.then((resp: any) => {
let attachment: Attachment = resp.data;
let attachments = this.state.attachments;
if (attachments) {
attachments.push(attachment);
} else {
attachments = [attachment];
}
this.setState({ attachments });
this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar("Media uploaded.");
})
// If we fail, display an error.
.catch((err: Error) => {
this.props.closeSnackbar("media-upload");
this.props.enqueueSnackbar(
"Couldn't upload media: " + err.name,
{ variant: "error" }
);
});
}
/**
* Iterate through the attachments and grab the attachments' IDs.
* @returns A list of IDs as `string[]`
*/
getOnlyMediaIds() {
let ids: string[] = [];
if (this.state.attachments) {
@ -402,10 +208,6 @@ class Composer extends Component<any, IComposerState> {
return ids;
}
/**
* Update the list of attachments by inserting an attachment.
* @param attachment The attachment to insert into the attachments list.
*/
fetchAttachmentAfterUpdate(attachment: Attachment) {
let attachments = this.state.attachments;
if (attachments) {
@ -418,10 +220,6 @@ class Composer extends Component<any, IComposerState> {
}
}
/**
* Remove an attachment from the list of attachments and update the state.
* @param attachment The attachment to remove from the list
*/
deleteMediaAttachment(attachment: Attachment) {
let attachments = this.state.attachments;
if (attachments) {
@ -435,10 +233,6 @@ class Composer extends Component<any, IComposerState> {
}
}
/**
* Insert an emoji at the end of text string and update the state
* @param e The emoji to insert into the text
*/
insertEmoji(e: any) {
if (e.custom) {
let text = this.state.text + e.colons;
@ -455,9 +249,6 @@ class Composer extends Component<any, IComposerState> {
}
}
/**
* Create an empty poll.
*/
createPoll() {
if (this.state.poll === undefined) {
let expiration = new Date();
@ -477,9 +268,6 @@ class Composer extends Component<any, IComposerState> {
}
}
/**
* Insert a new poll item into the poll.
*/
addPollItem() {
if (
this.state.poll !== undefined &&
@ -502,11 +290,6 @@ class Composer extends Component<any, IComposerState> {
}
}
/**
* Edit an existing poll item with new text
* @param position The position of the poll item in the list
* @param newTitle The new text to update
*/
editPollItem(position: number, newTitle: any) {
if (this.state.poll !== undefined) {
let poll = this.state.poll;
@ -524,10 +307,6 @@ class Composer extends Component<any, IComposerState> {
}
}
/**
* Removes a poll item from the poll
* @param item The item to remove
*/
removePollItem(item: string) {
if (
this.state.poll !== undefined &&
@ -554,16 +333,13 @@ class Composer extends Component<any, IComposerState> {
}
}
/**
* Set the expiration date of the poll.
* @param date The new expiration date
*/
setPollExpires(date: string) {
let currentDate = new Date();
let newDate = new Date(date);
let poll = this.state.poll;
if (poll) {
let expiry = (newDate.getTime() - currentDate.getTime()) / 1000;
console.log(expiry);
if (expiry >= 1800) {
poll.expires_at = expiry.toString();
this.setState({ poll, pollExpiresDate: date });
@ -577,38 +353,25 @@ class Composer extends Component<any, IComposerState> {
}
}
/**
* Remove the poll from the post.
*/
removePoll() {
this.setState({
poll: undefined
});
}
/**
* Check if the user presses the Ctrl/Cmd+Enter key and post to the server if possible.
* @param event The keyboard event
*/
postViaKeyboard(event: any) {
if ((event.metaKey || event.ctrlKey) && event.keyCode === 13) {
this.post();
}
}
/**
* Send the post to Mastodon and return to the previous page, if possible.
*/
post() {
// First, finalize the poll.
let pollOptions: string[] = [];
if (this.state.poll) {
this.state.poll.options.forEach((option: PollWizardOption) => {
pollOptions.push(option.title);
});
}
// Send a post request to Mastodon.
this.client
.post("/statuses", {
status: this.state.text,
@ -625,52 +388,31 @@ class Composer extends Component<any, IComposerState> {
}
: null
})
// If we succeed, send a success message, clear the status
// text field, and go back.
.then(() => {
this.props.enqueueSnackbar("Posted!");
// This is necessary to prevent session drafts from saving
// posts that were already posted.
this.setState({ text: "" });
window.history.back();
})
// Otherwise, show an error message and don't do anything.
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't post: " + err.name);
console.error(err.message);
console.log(err.message);
});
}
/**
* Toggle the content warning section.
*/
toggleSensitive() {
this.setState({ sensitive: !this.state.sensitive });
}
/**
* Toggle the visibility drop down menu.
*/
toggleVisibilityMenu() {
this.setState({ visibilityMenu: !this.state.visibilityMenu });
}
/**
* Toggle the emoji picker.
*/
toggleEmojis() {
this.setState({ showEmojis: !this.state.showEmojis });
}
/**
* Render all of the components on the page given a set of classes.
*/
render() {
const { classes } = this.props;
console.log(this.state);
return (
<Dialog
@ -727,28 +469,18 @@ class Composer extends Component<any, IComposerState> {
}}
value={this.state.text}
/>
{getUserDefaultBool("imposeCharacterLimit") ? (
<Typography
variant="caption"
className={
this.state.remainingChars <= 100
? classes.charsReachingLimit
: null
}
>
{`${this.state.remainingChars} character${
this.state.remainingChars === 1 ? "" : "s"
} remaining`}
</Typography>
) : (
<Typography variant="caption">
<WarningIcon className={classes.warningCaption} />{" "}
You have the character limit turned off. Make sure
that your post matches your instance's character
limit before posting.
</Typography>
)}
<Typography
variant="caption"
className={
this.state.remainingChars <= 100
? classes.charsReachingLimit
: null
}
>
{`${this.state.remainingChars} character${
this.state.remainingChars === 1 ? "" : "s"
} remaining`}
</Typography>
{this.state.attachments &&
this.state.attachments.length > 0 ? (
<div className={classes.composeAttachmentArea}>
@ -873,13 +605,13 @@ class Composer extends Component<any, IComposerState> {
) : null}
</DialogContent>
<Toolbar className={classes.dialogActions}>
<Tooltip title="Add photos, videos, or audio">
<Tooltip title="Add photos or videos">
<IconButton
disabled={this.state.poll !== undefined}
onClick={() => this.promptMediaDialog()}
onClick={() => this.uploadMedia()}
id="compose-media"
>
<AttachFileIcon />
<CameraAltIcon />
</IconButton>
</Tooltip>
<Tooltip title="Insert emoji">
@ -962,21 +694,6 @@ class Composer extends Component<any, IComposerState> {
) : null}
</Menu>
</Toolbar>
{draftExists() ? (
<DialogContent className={classes.draftDisplayArea}>
<Typography className={classes.draftText}>
You have an unsaved post.
</Typography>
<div className={classes.draftFlexGrow} />
<Button
color="primary"
size="small"
onClick={() => this.restoreDraft()}
>
Restore
</Button>
</DialogContent>
) : null}
<DialogActions>
<Button color="secondary" onClick={() => this.post()}>
Post

View File

@ -14,8 +14,6 @@ import Post from "../components/Post";
import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface IHomePageState {
@ -25,14 +23,8 @@ interface IHomePageState {
viewDidLoad?: boolean;
viewDidError?: boolean;
viewDidErrorCode?: any;
isMasonryLayout?: boolean;
}
/**
* The base class for the home timeline.
* @deprecated Use TimelinePage with the props `timeline="/timelines/home"`
* and `stream="/streaming/user"`.
*/
class HomePage extends Component<any, IHomePageState> {
client: Mastodon;
streamListener: StreamListener;
@ -42,8 +34,7 @@ class HomePage extends Component<any, IHomePageState> {
this.state = {
viewIsLoading: true,
backlogPosts: null,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
backlogPosts: null
};
this.client = new Mastodon(
@ -163,11 +154,9 @@ class HomePage extends Component<any, IHomePageState> {
render() {
const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints}${
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
}`;
return (
<div className={containerClasses}>
<div className={classes.pageLayoutMaxConstraints}>
{this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
@ -196,46 +185,15 @@ class HomePage extends Component<any, IHomePageState> {
) : null}
{this.state.posts ? (
<div>
{this.state.isMasonryLayout ? (
<Masonry
breakpointCols={{
default: 4,
2000: 3,
1400: 2,
1050: 1
}}
className={classes.masonryGrid}
columnClassName={
classes["my-masonry-grid_column"]
}
>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</Masonry>
) : (
<div>
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
</div>
)}
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
<br />
{this.state.viewDidLoad && !this.state.viewDidError ? (
<div

View File

@ -14,8 +14,6 @@ import Post from "../components/Post";
import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface ILocalPageState {
@ -25,14 +23,8 @@ interface ILocalPageState {
viewDidLoad?: boolean;
viewDidError?: boolean;
viewDidErrorCode?: any;
isMasonryLayout?: boolean;
}
/**
* The base class for the local timeline.
* @deprecated Use TimelinePage with the props `timeline="/timelines/public?local=true"`
* and `stream="/streaming/public/local"`.
*/
class LocalPage extends Component<any, ILocalPageState> {
client: Mastodon;
streamListener: StreamListener;
@ -42,8 +34,7 @@ class LocalPage extends Component<any, ILocalPageState> {
this.state = {
viewIsLoading: true,
backlogPosts: null,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
backlogPosts: null
};
this.client = new Mastodon(
@ -164,12 +155,9 @@ class LocalPage extends Component<any, ILocalPageState> {
render() {
const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints}${
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
}`;
return (
<div className={containerClasses}>
<div className={classes.pageLayoutMaxConstraints}>
{this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
@ -198,50 +186,15 @@ class LocalPage extends Component<any, ILocalPageState> {
) : null}
{this.state.posts ? (
<div>
{this.state.isMasonryLayout ? (
<Masonry
breakpointCols={{
default: 4,
2000: 3,
1400: 2,
1050: 1
}}
className={classes.masonryGrid}
columnClassName={
classes["my-masonry-grid_column"]
}
>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</Masonry>
) : (
<div>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</div>
)}
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
<br />
{this.state.viewDidLoad && !this.state.viewDidError ? (
<div

View File

@ -10,12 +10,10 @@ import {
ListItemAvatar,
Avatar,
ListItemSecondaryAction,
Tooltip,
Typography
Tooltip
} from "@material-ui/core";
import PersonIcon from "@material-ui/icons/Person";
import ForumIcon from "@material-ui/icons/Forum";
import MailIcon from "@material-ui/icons/Mail";
import { styles } from "./PageLayout.styles";
import Mastodon from "megalodon";
import { Status } from "../types/Status";
@ -72,82 +70,67 @@ class MessagesPage extends Component<any, IMessagesState> {
return innerContent;
}
renderMessage(message: Status) {
return (
<ListItem>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${message.account.id}`}
alt={message.account.username}
src={message.account.avatar_static}
>
<PersonIcon />
</LinkableAvatar>
</ListItemAvatar>
<ListItemText
primary={
message.account.display_name ||
"@" + message.account.acct
}
secondary={this.removeHTMLContent(message.content)}
/>
<ListItemSecondaryAction>
<Tooltip title="View conversation">
<LinkableIconButton to={`/conversation/${message.id}`}>
<ForumIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
render() {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
{this.state.viewDidLoad ? (
<div className={classes.pageListContsraints}>
{this.state.posts && this.state.posts.length > 0 ? (
<div>
<ListSubheader>Recent messages</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
{this.state.posts
? this.state.posts.map(
(message: Status) =>
this.renderMessage(
message
)
)
: null}
</List>
</Paper>
<br />
</div>
) : (
<div>
<div
className={
classes.pageLayoutEmptyTextConstraints
}
style={{ textAlign: "center" }}
>
<MailIcon
color="action"
style={{ fontSize: 48 }}
/>
<Typography variant="h6">
You don't have any messages.
</Typography>
<Typography paragraph>
Why not interact with the fediverse a
bit by sending a message?
</Typography>
<br />
</div>
</div>
)}
<ListSubheader>Recent messages</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
{this.state.posts
? this.state.posts.map(
(message: Status) => {
return (
<ListItem>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${message.account.id}`}
alt={
message
.account
.username
}
src={
message
.account
.avatar_static
}
>
<PersonIcon />
</LinkableAvatar>
</ListItemAvatar>
<ListItemText
primary={
message.account
.display_name ||
"@" +
message
.account
.acct
}
secondary={this.removeHTMLContent(
message.content
)}
/>
<ListItemSecondaryAction>
<Tooltip title="View conversation">
<LinkableIconButton
to={`/conversation/${message.id}`}
>
<ForumIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
)
: null}
</List>
</Paper>
<br />
</div>
) : null}
{this.state.viewIsLoading ? (

View File

@ -17,128 +17,56 @@ import {
DialogContent,
DialogContentText,
DialogActions,
Tooltip,
Menu,
MenuItem
Tooltip
} from "@material-ui/core";
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
import PersonIcon from "@material-ui/icons/Person";
import PersonAddIcon from "@material-ui/icons/PersonAdd";
import DeleteIcon from "@material-ui/icons/Delete";
import { styles } from "./PageLayout.styles";
import {
LinkableIconButton,
LinkableAvatar,
LinkableMenuItem
} from "../interfaces/overrides";
import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
import ForumIcon from "@material-ui/icons/Forum";
import ReplyIcon from "@material-ui/icons/Reply";
import NotificationsIcon from "@material-ui/icons/Notifications";
import MoreVertIcon from "@material-ui/icons/MoreVert";
import Mastodon from "megalodon";
import { Notification } from "../types/Notification";
import { Account } from "../types/Account";
import { Relationship } from "../types/Relationship";
import { withSnackbar } from "notistack";
import { Dictionary } from "../interfaces/utils";
/**
* The state interface for the notifications page.
*/
interface INotificationsPageState {
/**
* The list of notifications, if it exists.
*/
notifications?: [Notification];
/**
* Whether the view is still loading.
*/
viewIsLoading: boolean;
/**
* Whether the view has loaded.
*/
viewDidLoad?: boolean;
/**
* Whether the view has loaded but in error.
*/
viewDidError?: boolean;
/**
* The error code for an errored state, if possible.
*/
viewDidErrorCode?: string;
/**
* Whether the delete confirmation dialog should be open.
*/
deleteDialogOpen: boolean;
/**
* Whether the menu should be open on smaller devices.
*/
mobileMenuOpen: Dictionary<boolean>;
}
/**
* The notifications page.
*/
class NotificationsPage extends Component<any, INotificationsPageState> {
/**
* The Mastodon object to perform notification operations on.
*/
client: Mastodon;
/**
* The stream listener for tuning in to notifications.
*/
streamListener: any;
/**
* Construct the notifications page.
* @param props The properties to pass in
*/
constructor(props: any) {
super(props);
// Create the Mastodon object.
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
// Initialize the state.
this.state = {
viewIsLoading: true,
deleteDialogOpen: false,
mobileMenuOpen: {}
deleteDialogOpen: false
};
}
/**
* Perform pre-mount tasks.
*/
componentWillMount() {
// Get the list of notifications and update the state.
this.client
.get("/notifications")
.then((resp: any) => {
let notifications: [Notification] = resp.data;
let notifMenus: Dictionary<boolean> = {};
notifications.forEach((notif: Notification) => {
notifMenus[notif.id] = false;
});
this.setState({
notifications,
viewIsLoading: false,
viewDidLoad: true,
mobileMenuOpen: notifMenus
viewDidLoad: true
});
})
.catch((err: Error) => {
@ -151,17 +79,10 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
});
}
/**
* Perform post-mount tasks.
*/
componentDidMount() {
// Start listening for new notifications after fetching.
this.streamNotifications();
}
/**
* Set up a stream listener and keep updating notifications.
*/
streamNotifications() {
this.streamListener = this.client.stream("/streaming/user");
@ -174,25 +95,10 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
});
}
/**
* Toggle the state of the delete dialog.
*/
toggleDeleteDialog() {
this.setState({ deleteDialogOpen: !this.state.deleteDialogOpen });
}
toggleMobileMenu(id: string) {
let mobileMenuOpen = this.state.mobileMenuOpen;
mobileMenuOpen[id] = !mobileMenuOpen[id];
this.setState({ mobileMenuOpen });
}
/**
* Strip HTML content from a string containing HTML content.
*
* @param text The sanitized HTML to strip
* @returns A string containing the contents of the sanitized HTML
*/
removeHTMLContent(text: string) {
const div = document.createElement("div");
div.innerHTML = text;
@ -202,10 +108,6 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
return innerContent;
}
/**
* Remove a notification from the server.
* @param id The notification's ID
*/
removeNotification(id: string) {
this.client
.post(`/notifications/${id}/dismiss`)
@ -237,9 +139,6 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
});
}
/**
* Purge all notifications from the server.
*/
removeAllNotifications() {
this.client
.post("/notifications/clear")
@ -257,10 +156,6 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
});
}
/**
* Render a single notification unit to be used in a list
* @param notif The notification to work with.
*/
createNotification(notif: Notification) {
const { classes } = this.props;
let primary = "";
@ -333,108 +228,6 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
}
/>
<ListItemSecondaryAction>
{this.getActions(notif)}
</ListItemSecondaryAction>
</ListItem>
);
}
/**
* Follow an account from a notification if already not followed.
* @param acct The account to follow, if possible
*/
followMember(acct: Account) {
// Get the relationships for this account.
this.client
.get(`/accounts/relationships`, { id: acct.id })
.then((resp: any) => {
// Returns a list, so grab only the first item.
let relationship: Relationship = resp.data[0];
// Follow if not following already.
if (relationship.following == false) {
this.client
.post(`/accounts/${acct.id}/follow`)
.then((resp: any) => {
this.props.enqueueSnackbar(
"You are now following this account."
);
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't follow account: " + err.name,
{ variant: "error" }
);
console.error(err.message);
});
}
// Otherwise notify the user.
else {
this.props.enqueueSnackbar(
"You already follow this account."
);
}
})
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't find relationship.", {
variant: "error"
});
});
}
getActions = (notif: Notification) => {
const { classes } = this.props;
return (
<>
<IconButton
onClick={() => this.toggleMobileMenu(notif.id)}
className={classes.mobileOnly}
id={`notification-list-${notif.id}`}
>
<MoreVertIcon />
</IconButton>
<Menu
open={this.state.mobileMenuOpen[notif.id]}
anchorEl={document.getElementById(
`notification-list-${notif.id}`
)}
onClose={() => this.toggleMobileMenu(notif.id)}
>
{notif.type == "follow" ? (
<>
<LinkableMenuItem
to={`profile/${notif.account.id}`}
>
View Profile
</LinkableMenuItem>
<MenuItem
onClick={() => this.followMember(notif.account)}
>
Follow
</MenuItem>
</>
) : null}
{notif.type == "mention" && notif.status ? (
<LinkableMenuItem
to={`/compose?reply=${
notif.status.reblog
? notif.status.reblog.id
: notif.status.id
}&visibility=${notif.status.visibility}&acct=${
notif.status.reblog
? notif.status.reblog.account.acct
: notif.status.account.acct
}`}
>
Reply
</LinkableMenuItem>
) : null}
<MenuItem onClick={() => this.removeNotification(notif.id)}>
Remove
</MenuItem>
</Menu>
<div className={classes.desktopOnly}>
{notif.type === "follow" ? (
<span>
<Tooltip title="View profile">
@ -492,14 +285,28 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
</>
</ListItemSecondaryAction>
</ListItem>
);
};
}
followMember(acct: Account) {
this.client
.post(`/accounts/${acct.id}/follow`)
.then((resp: any) => {
this.props.enqueueSnackbar(
"You are now following this account."
);
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
"Couldn't follow account: " + err.name,
{ variant: "error" }
);
console.error(err.message);
});
}
/**
* Render the notification page.
*/
render() {
const { classes } = this.props;
return (
@ -530,20 +337,12 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
</Paper>
</div>
) : (
<div
className={classes.pageLayoutEmptyTextConstraints}
style={{ textAlign: "center" }}
>
<NotificationsIcon
color="action"
style={{ fontSize: 48 }}
/>
<Typography variant="h6">All clear!</Typography>
<div className={classes.pageLayoutEmptyTextConstraints}>
<Typography variant="h4">All clear!</Typography>
<Typography paragraph>
It looks like you have no notifications. Why not
get the conversation going with a new post?
</Typography>
<br />
</div>
)
) : null}

View File

@ -1,4 +1,4 @@
import { Theme, createStyles, FormHelperText } from "@material-ui/core";
import { Theme, createStyles } from "@material-ui/core";
import { isDarwinApp } from "../utilities/desktop";
import { isAppbarExpanded } from "../utilities/appbar";
@ -323,21 +323,5 @@ export const styles = (theme: Theme) =>
display: "block"
},
backgroundColor: theme.palette.primary.main
},
pageLayoutMasonry: {
paddingLeft: theme.spacing.unit * 3,
paddingRight: theme.spacing.unit * 3
},
masonryGrid: {
display: "flex",
width: "auto"
},
"my-masonry-grid_column": {
// non-standard name fixes react-masonry-css bug :shrug:
padding: 5
},
noTopPaddingMargin: {
marginTop: 0,
paddingTop: 0
}
});

View File

@ -25,8 +25,6 @@ import Post from "../components/Post";
import { withSnackbar } from "notistack";
import { LinkableIconButton } from "../interfaces/overrides";
import { emojifyString } from "../utilities/emojis";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "..//utilities/settings";
import AccountEditIcon from "mdi-material-ui/AccountEdit";
import PersonAddIcon from "@material-ui/icons/PersonAdd";
@ -46,7 +44,6 @@ interface IProfilePageState {
viewDidError?: boolean;
viewDidErrorCode?: string;
blockDialogOpen: boolean;
isMasonryLayout?: boolean;
}
class ProfilePage extends Component<any, IProfilePageState> {
@ -62,8 +59,7 @@ class ProfilePage extends Component<any, IProfilePageState> {
this.state = {
viewIsLoading: true,
blockDialogOpen: false,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
blockDialogOpen: false
};
}
@ -309,36 +305,8 @@ class ProfilePage extends Component<any, IProfilePageState> {
}
}
renderPosts(posts: Status[]) {
const { classes } = this.props;
const postComponents = posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client} />;
});
if (this.state.isMasonryLayout) {
return (
<Masonry
className={classes.masonryGrid}
columnClassName={classes["my-masonry-grid_column"]}
breakpointCols={{
default: 4,
2000: 3,
1400: 2,
1050: 1
}}
>
{postComponents}
</Masonry>
);
} else {
return <div>{postComponents}</div>;
}
}
render() {
const { classes } = this.props;
const containerClasses = `${classes.pageContentLayoutConstraints} ${
this.state.isMasonryLayout ? classes.pageLayoutMasonry : ""
}`;
return (
<div className={classes.pageLayoutMinimalConstraints}>
<div className={classes.pageHeroBackground}>
@ -496,7 +464,7 @@ class ProfilePage extends Component<any, IProfilePageState> {
</div>
</div>
</div>
<div className={containerClasses}>
<div className={classes.pageContentLayoutConstraints}>
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
@ -514,7 +482,15 @@ class ProfilePage extends Component<any, IProfilePageState> {
)}
{this.state.posts ? (
<div>
{this.renderPosts(this.state.posts)}
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
<br />
{this.state.viewDidLoad &&
!this.state.viewDidError ? (

View File

@ -14,8 +14,6 @@ import Post from "../components/Post";
import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar } from "notistack";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
interface IPublicPageState {
@ -25,14 +23,8 @@ interface IPublicPageState {
viewDidLoad?: boolean;
viewDidError?: boolean;
viewDidErrorCode?: any;
isMasonryLayout?: boolean;
}
/**
* The base class for the public timeline.
* @deprecated Use TimelinePage with the props `timeline="/timelines/public"`
* and `stream="/streaming/public"`.
*/
class PublicPage extends Component<any, IPublicPageState> {
client: Mastodon;
streamListener: StreamListener;
@ -42,8 +34,7 @@ class PublicPage extends Component<any, IPublicPageState> {
this.state = {
viewIsLoading: true,
backlogPosts: null,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
backlogPosts: null
};
this.client = new Mastodon(
@ -163,12 +154,9 @@ class PublicPage extends Component<any, IPublicPageState> {
render() {
const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints}${
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
}`;
return (
<div className={containerClasses}>
<div className={classes.pageLayoutMaxConstraints}>
{this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
@ -197,50 +185,15 @@ class PublicPage extends Component<any, IPublicPageState> {
) : null}
{this.state.posts ? (
<div>
{this.state.isMasonryLayout ? (
<Masonry
breakpointCols={{
default: 4,
2000: 3,
1400: 2,
1050: 1
}}
className={classes.masonryGrid}
columnClassName={
classes["my-masonry-grid_column"]
}
>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</Masonry>
) : (
<div>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
>
<Post
key={post.id}
post={post}
client={this.client}
/>
</div>
);
})}
</div>
)}
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
<br />
{this.state.viewDidLoad && !this.state.viewDidError ? (
<div

View File

@ -6,22 +6,25 @@ import {
ListItem,
Paper,
ListItemText,
Avatar,
ListItemSecondaryAction,
ListItemAvatar,
ListSubheader,
CircularProgress,
IconButton,
Tooltip,
Link
Divider,
Tooltip
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import Mastodon from "megalodon";
import { Account } from "../types/Account";
import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
import PersonAddIcon from "@material-ui/icons/PersonAdd";
import CheckIcon from "@material-ui/icons/Check";
import CloseIcon from "@material-ui/icons/Close";
import { withSnackbar, withSnackbarProps } from "notistack";
import GroupIcon from "@material-ui/icons/Group";
interface IRecommendationsPageProps extends withSnackbarProps {
classes: any;
@ -32,6 +35,7 @@ interface IRecommendationsPageState {
viewDidLoad?: boolean;
viewDidError?: Boolean;
viewDidErrorCode?: string;
requestedFollows?: [Account];
followSuggestions?: [Account];
}
@ -53,6 +57,21 @@ class RecommendationsPage extends Component<
}
componentDidMount() {
this.client
.get("/follow_requests")
.then((resp: any) => {
let requestedFollows: [Account] = resp.data;
this.setState({ requestedFollows });
})
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.name
});
console.error(err.message);
});
this.client
.get("/suggestions")
.then((resp: any) => {
@ -106,6 +125,110 @@ class RecommendationsPage extends Component<
});
}
handleFollowRequest(acct: Account, type: "authorize" | "reject") {
this.client
.post(`/follow_requests/${acct.id}/${type}`)
.then((resp: any) => {
let requestedFollows = this.state.requestedFollows;
if (requestedFollows) {
requestedFollows.forEach(
(request: Account, index: number) => {
if (requestedFollows && request.id === acct.id) {
requestedFollows.splice(index, 1);
}
}
);
}
this.setState({ requestedFollows });
let verb: string = type;
verb === "authorize"
? (verb = "authorized")
: (verb = "rejected");
this.props.enqueueSnackbar(`You have ${verb} this request.`);
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't ${type} this request: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
}
showFollowRequests() {
const { classes } = this.props;
return (
<div>
<ListSubheader>Follow requests</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
{this.state.requestedFollows
? this.state.requestedFollows.map(
(request: Account) => {
return (
<ListItem key={request.id}>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${request.id}`}
alt={request.username}
src={
request.avatar_static
}
/>
</ListItemAvatar>
<ListItemText
primary={
request.display_name ||
request.acct
}
secondary={request.acct}
/>
<ListItemSecondaryAction>
<Tooltip title="Accept request">
<IconButton
onClick={() =>
this.handleFollowRequest(
request,
"authorize"
)
}
>
<CheckIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reject request">
<IconButton
onClick={() =>
this.handleFollowRequest(
request,
"reject"
)
}
>
<CloseIcon />
</IconButton>
</Tooltip>
<Tooltip title="View profile">
<LinkableIconButton
to={`/profile/${request.id}`}
>
<AccountCircleIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
)
: null}
</List>
</Paper>
<br />
</div>
);
}
showFollowSuggestions() {
const { classes } = this.props;
return (
@ -172,6 +295,23 @@ class RecommendationsPage extends Component<
<div className={classes.pageLayoutConstraints}>
{this.state.viewDidLoad ? (
<div>
{this.state.requestedFollows &&
this.state.requestedFollows.length > 0 ? (
this.showFollowRequests()
) : (
<div
className={
classes.pageLayoutEmptyTextConstraints
}
>
<Typography variant="h6">
You don't have any follow requests.
</Typography>
<br />
</div>
)}
<Divider />
<br />
{this.state.followSuggestions &&
this.state.followSuggestions.length > 0 ? (
this.showFollowSuggestions()
@ -180,35 +320,23 @@ class RecommendationsPage extends Component<
className={
classes.pageLayoutEmptyTextConstraints
}
style={{ textAlign: "center" }}
>
<GroupIcon
color="action"
style={{ fontSize: 48 }}
/>
<Typography variant="h6">
<Typography variant="h5">
We don't have any suggestions for you.
</Typography>
<Typography paragraph>
Take a look around the fediverse or check
out the Activity page for more.
Why not interact with the fediverse a bit by
creating a new post?
</Typography>
<br />
</div>
)}
<br />
<Typography variant="caption" paragraph>
Looking for follow requests? You can find them in
Settings or in the account menu. You can also{" "}
<Link href="/#/requests">click here</Link>.
</Typography>
</div>
) : null}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading recommendations.
Something went wrong when loading this timeline.
</Typography>
<Typography>
{this.state.viewDidErrorCode

View File

@ -1,215 +0,0 @@
import React, { Component } from "react";
import {
CircularProgress,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
Paper,
Tooltip,
Typography,
withStyles
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import { Account } from "../types/Account";
import Mastodon from "megalodon";
import { LinkableAvatar, LinkableIconButton } from "../interfaces/overrides";
import CheckIcon from "@material-ui/icons/Check";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import CloseIcon from "@material-ui/icons/Close";
import CheckCircleIcon from "@material-ui/icons/CheckCircle";
import { withSnackbar } from "notistack";
interface IRequestsPageState {
viewLoading: boolean;
viewLoaded?: boolean;
viewErrored?: boolean;
requestedAccounts?: [Account];
}
class RequestsPage extends Component<any, IRequestsPageState> {
client: Mastodon;
constructor(props: any) {
super(props);
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
localStorage.getItem("baseurl") + "/api/v1"
);
this.state = {
viewLoading: true
};
}
componentDidMount() {
this.client
.get("/follow_requests")
.then((resp: any) => {
let requestedAccounts: [Account] = resp.data;
this.setState({
requestedAccounts,
viewLoading: false,
viewLoaded: true
});
})
.catch((err: Error) => {
this.setState({
viewLoading: false,
viewErrored: true
});
console.error(err.message);
});
}
handleFollowRequest(acct: Account, type: "authorize" | "reject") {
this.client
.post(`/follow_requests/${acct.id}/${type}`)
.then((resp: any) => {
let requestedAccounts = this.state.requestedAccounts;
if (requestedAccounts) {
requestedAccounts.forEach(
(request: Account, index: number) => {
if (requestedAccounts && request.id === acct.id) {
requestedAccounts.splice(index, 1);
}
}
);
}
this.setState({ requestedAccounts });
let verb: string = type;
verb === "authorize"
? (verb = "authorized")
: (verb = "rejected");
this.props.enqueueSnackbar(`You have ${verb} this request.`);
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't ${type} this request: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
}
showFollowRequests() {
const { classes } = this.props;
return (
<div>
<ListSubheader>Follow requests</ListSubheader>
<Paper className={classes.pageListConstraints}>
<List>
{this.state.requestedAccounts
? this.state.requestedAccounts.map(
(request: Account) => {
return (
<ListItem key={request.id}>
<ListItemAvatar>
<LinkableAvatar
to={`/profile/${request.id}`}
alt={request.username}
src={
request.avatar_static
}
/>
</ListItemAvatar>
<ListItemText
primary={
request.display_name ||
request.acct
}
secondary={request.acct}
/>
<ListItemSecondaryAction>
<Tooltip title="Accept request">
<IconButton
onClick={() =>
this.handleFollowRequest(
request,
"authorize"
)
}
>
<CheckIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reject request">
<IconButton
onClick={() =>
this.handleFollowRequest(
request,
"reject"
)
}
>
<CloseIcon />
</IconButton>
</Tooltip>
<Tooltip title="View profile">
<LinkableIconButton
to={`/profile/${request.id}`}
>
<AccountCircleIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
)
: null}
</List>
</Paper>
<br />
</div>
);
}
render() {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
{this.state.viewLoaded ? (
<div>
{this.state.requestedAccounts &&
this.state.requestedAccounts.length > 0 ? (
this.showFollowRequests()
) : (
<div
className={
classes.pageLayoutEmptyTextConstraints
}
style={{ textAlign: "center" }}
>
<CheckCircleIcon
color="action"
style={{ fontSize: 48 }}
/>
<Typography variant="h6">
You don't have any follow requests.
</Typography>
<br />
</div>
)}
</div>
) : null}
{this.state.viewLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
</div>
);
}
}
export default withStyles(styles)(withSnackbar(RequestsPage));

View File

@ -26,8 +26,6 @@ import { withSnackbar } from "notistack";
import Post from "../components/Post";
import { Status } from "../types/Status";
import { Account } from "../types/Account";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
interface ISearchPageState {
query: string[] | string;
@ -38,7 +36,6 @@ interface ISearchPageState {
viewDidLoad?: boolean;
viewDidError?: boolean;
viewDidErrorCode?: string;
isMasonryLayout: boolean;
}
class SearchPage extends Component<any, ISearchPageState> {
@ -57,8 +54,7 @@ class SearchPage extends Component<any, ISearchPageState> {
this.state = {
viewIsLoading: true,
query: searchParams.query,
type: searchParams.type,
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
type: searchParams.type
};
if (searchParams.type === "tag") {
@ -199,7 +195,7 @@ class SearchPage extends Component<any, ISearchPageState> {
showAllAccountsFromQuery() {
const { classes } = this.props;
return (
<div className={classes.pageLayoutConstraints}>
<div>
<ListSubheader>Accounts</ListSubheader>
{this.state.results &&
@ -264,44 +260,22 @@ class SearchPage extends Component<any, ISearchPageState> {
);
}
renderPosts(posts: Status[]) {
const { classes } = this.props;
const postComponents = posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client} />;
});
if (this.state.isMasonryLayout) {
return (
<Masonry
className={classes.masonryGrid}
columnClassName={classes["my-masonry-grid_column"]}
breakpointCols={{
default: 4,
2000: 3,
1400: 2,
1050: 1
}}
>
{postComponents}
</Masonry>
);
} else {
return <div>{postComponents}</div>;
}
}
showAllPostsFromQuery() {
const { classes } = this.props;
const containerClasses = `${classes.pageLayoutConstraints} ${
this.state.isMasonryLayout
? classes.pageLayoutMasonry + " " + classes.noTopPaddingMargin
: ""
}`;
return (
<div className={containerClasses}>
<div>
<ListSubheader>Posts</ListSubheader>
{this.state.results ? (
this.state.results.statuses.length > 0 ? (
this.renderPosts(this.state.results.statuses)
this.state.results.statuses.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})
) : (
<Typography
variant="caption"
@ -317,15 +291,20 @@ class SearchPage extends Component<any, ISearchPageState> {
showAllPostsWithTag() {
const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints} ${
this.state.isMasonryLayout ? classes.pageLayoutMasonry : ""
}`;
return (
<div className={containerClasses}>
<div>
<ListSubheader>Tagged posts</ListSubheader>
{this.state.tagResults ? (
this.state.tagResults.length > 0 ? (
this.renderPosts(this.state.tagResults)
this.state.tagResults.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})
) : (
<Typography
variant="caption"
@ -342,7 +321,7 @@ class SearchPage extends Component<any, ISearchPageState> {
render() {
const { classes } = this.props;
return (
<div>
<div className={classes.pageLayoutConstraints}>
{this.state.type && this.state.type === "tag" ? (
this.showAllPostsWithTag()
) : (

View File

@ -62,11 +62,6 @@ import BellAlertIcon from "mdi-material-ui/BellAlert";
import RefreshIcon from "@material-ui/icons/Refresh";
import UndoIcon from "@material-ui/icons/Undo";
import DomainDisabledIcon from "@material-ui/icons/DomainDisabled";
import AccountSettingsIcon from "mdi-material-ui/AccountSettings";
import AlphabeticalVariantOffIcon from "mdi-material-ui/AlphabeticalVariantOff";
import DashboardIcon from "@material-ui/icons/Dashboard";
import InfiniteIcon from "@material-ui/icons/AllInclusive";
import { Config } from "../types/Config";
import { Account } from "../types/Account";
import Mastodon from "megalodon";
@ -87,9 +82,6 @@ interface ISettingsState {
brandName: string;
federated: boolean;
currentUser?: Account;
imposeCharacterLimit: boolean;
masonryLayout?: boolean;
infiniteScroll?: boolean;
}
class SettingsPage extends Component<any, ISettingsState> {
@ -120,10 +112,7 @@ class SettingsPage extends Component<any, ISettingsState> {
setHyperspaceTheme(defaultTheme),
defaultVisibility: getUserDefaultVisibility() || "public",
brandName: "Hyperspace",
federated: true,
imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit"),
masonryLayout: getUserDefaultBool("isMasonryLayout"),
infiniteScroll: getUserDefaultBool("isInfiniteScroll")
federated: true
};
this.toggleDarkMode = this.toggleDarkMode.bind(this);
@ -132,8 +121,6 @@ class SettingsPage extends Component<any, ISettingsState> {
this.toggleBadgeCount = this.toggleBadgeCount.bind(this);
this.toggleThemeDialog = this.toggleThemeDialog.bind(this);
this.toggleVisibilityDialog = this.toggleVisibilityDialog.bind(this);
this.toggleMasonryLayout = this.toggleMasonryLayout.bind(this);
this.toggleInfiniteScroll = this.toggleInfiniteScroll.bind(this);
this.changeThemeName = this.changeThemeName.bind(this);
this.changeTheme = this.changeTheme.bind(this);
this.setVisibility = this.setVisibility.bind(this);
@ -174,6 +161,7 @@ class SettingsPage extends Component<any, ISettingsState> {
getConfig().then((result: any) => {
if (result !== undefined) {
let config: Config = result;
console.log(!config.federation.allowPublicPosts);
this.setState({
federated: config.federation.allowPublicPosts
});
@ -228,16 +216,6 @@ class SettingsPage extends Component<any, ISettingsState> {
});
}
toggleCharacterLimit() {
this.setState({
imposeCharacterLimit: !this.state.imposeCharacterLimit
});
setUserDefaultBool(
"imposeCharacterLimit",
!this.state.imposeCharacterLimit
);
}
toggleResetDialog() {
this.setState({
resetHyperspaceDialog: !this.state.resetHyperspaceDialog
@ -248,16 +226,6 @@ class SettingsPage extends Component<any, ISettingsState> {
this.setState({ resetSettingsDialog: !this.state.resetSettingsDialog });
}
toggleMasonryLayout() {
this.setState({ masonryLayout: !this.state.masonryLayout });
setUserDefaultBool("isMasonryLayout", !this.state.masonryLayout);
}
toggleInfiniteScroll() {
this.setState({ infiniteScroll: !this.state.infiniteScroll });
setUserDefaultBool("isInfiniteScroll", !this.state.infiniteScroll);
}
changeTheme() {
setUserDefaultTheme(this.state.selectThemeName);
window.location.reload();
@ -534,7 +502,7 @@ class SettingsPage extends Component<any, ISettingsState> {
</div>
<div className={classes.pageGrow} />
<Toolbar>
<Tooltip title="Edit profile">
<Tooltip title="Edit Profile">
<LinkableIconButton
to={"/you"}
color="inherit"
@ -550,14 +518,6 @@ class SettingsPage extends Component<any, ISettingsState> {
<DomainDisabledIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="Manage follow requests">
<LinkableIconButton
to={"/requests"}
color="inherit"
>
<AccountSettingsIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="Configure on Mastodon">
<IconButton
href={
@ -576,36 +536,7 @@ class SettingsPage extends Component<any, ISettingsState> {
</Toolbar>
</div>
</div>
) : (
<div className={classes.pageHeroBackground}>
<div className={classes.pageHeroBackgroundImage} />
<div className={classes.profileContent}>
<br />
<Avatar className={classes.settingsAvatar} />
<div
className={classes.profileUserBox}
style={{ margin: "auto" }}
>
<Typography
className={classes.settingsHeaderText}
color="inherit"
component="h1"
>
{"Loading..."}
</Typography>
<Typography
color="inherit"
className={classes.settingsDetailText}
component="p"
>
@{"..."}
</Typography>
</div>
<div className={classes.pageGrow} />
<Toolbar />
</div>
</div>
)}
) : null}
<div className={classes.pageContentLayoutConstraints}>
<ListSubheader>Appearance</ListSubheader>
<Paper className={classes.pageListConstraints}>
@ -667,38 +598,6 @@ class SettingsPage extends Component<any, ISettingsState> {
</Button>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<DashboardIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Show more posts"
secondary="Shows additional columns of posts on wider screens"
/>
<ListItemSecondaryAction>
<Switch
checked={this.state.masonryLayout}
onChange={this.toggleMasonryLayout}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<InfiniteIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Enable infinite scroll"
secondary="Automatically load more posts when scrolling"
/>
<ListItemSecondaryAction>
<Switch
checked={this.state.infiniteScroll}
onChange={this.toggleInfiniteScroll}
/>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br />
@ -723,25 +622,6 @@ class SettingsPage extends Component<any, ISettingsState> {
</Button>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemAvatar>
<AlphabeticalVariantOffIcon color="action" />
</ListItemAvatar>
<ListItemText
primary="Impose character limit"
secondary="Impose a character limit when creating posts"
/>
<ListItemSecondaryAction>
<Switch
checked={
this.state.imposeCharacterLimit
}
onChange={() =>
this.toggleCharacterLimit()
}
/>
</ListItemSecondaryAction>
</ListItem>
</List>
</Paper>
<br />

View File

@ -1,440 +0,0 @@
import React, { Component } from "react";
import {
withStyles,
CircularProgress,
Typography,
Paper,
Button,
Chip,
Avatar,
Slide,
StyledComponentProps
} from "@material-ui/core";
import { styles } from "./PageLayout.styles";
import Post from "../components/Post";
import { Status } from "../types/Status";
import Mastodon, { StreamListener } from "megalodon";
import { withSnackbar, withSnackbarProps } from "notistack";
import Masonry from "react-masonry-css";
import { getUserDefaultBool } from "../utilities/settings";
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
/**
* The basic interface for a timeline page's properties.
*/
interface ITimelinePageProps extends withSnackbarProps, StyledComponentProps {
/**
* The API endpoint for the timeline to fetch after starting
* a stream.
*/
timeline: string;
/**
* The API endpoint for the timeline to stream.
*/
stream: string;
classes?: any;
}
/**
* The base interface for the timeline page's state.
*/
interface ITimelinePageState {
/**
* The list of posts from the timeline.
*/
posts?: [Status];
/**
* The list of posts stored temporarily while viewing the timeline.
*
* Can be cleared when user pushes "Show x posts" button.
*/
backlogPosts?: [Status] | null;
/**
* Whether the view is currently loading.
*/
viewIsLoading: boolean;
/**
* Whether the view loaded successfully.
*/
viewDidLoad?: boolean;
/**
* Whether the view errored.
*/
viewDidError?: boolean;
/**
* The view's error code, if it errored.
*/
viewDidErrorCode?: any;
/**
* Whether or not to use the masonry layout as defined in
* the user settings.
*/
isMasonryLayout?: boolean;
/**
* Whether posts should automatically load when scrolling.
*/
isInfiniteScroll?: boolean;
}
/**
* The base class for a timeline page.
*
* The timeline page streams a specific timeline. When the stream is connected,
* the page will fetch a particular timeline list of posts. The timeline page will
* also off-load incoming posts from the stream into a backlog that the user can
* then insert by clicking a button.
*/
class TimelinePage extends Component<ITimelinePageProps, ITimelinePageState> {
/**
* The client to use.
*/
client: Mastodon;
/**
* The page's stream listener.
*/
streamListener: StreamListener;
/**
* Construct the timeline page.
* @param props The timeline page's properties
*/
constructor(props: ITimelinePageProps) {
super(props);
// Initialize the state.
this.state = {
viewIsLoading: true,
backlogPosts: null,
isMasonryLayout: getUserDefaultBool("isMasonryLayout"),
isInfiniteScroll: getUserDefaultBool("isInfiniteScroll"),
};
// Generate the client.
this.client = new Mastodon(
localStorage.getItem("access_token") as string,
(localStorage.getItem("baseurl") as string) + "/api/v1"
);
// Create the stream listener from the properties.
this.streamListener = this.client.stream(this.props.stream);
this.loadMoreTimelinePieces = this.loadMoreTimelinePieces.bind(this);
this.shouldLoadMorePosts = this.shouldLoadMorePosts.bind(this);
}
/**
* Connect the stream listener and listen for new posts.
*/
componentWillMount() {
this.streamListener.on("connect", () => {
// Get the latest posts from this timeline.
this.client
.get(this.props.timeline, { limit: 50 })
// If we succeeded, update the state and turn off loading.
.then((resp: any) => {
let statuses: [Status] = resp.data;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
});
})
// Otherwise, update the state in error.
.catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
});
// Notify the user with a snackbar.
this.props.enqueueSnackbar("Failed to get posts.", {
variant: "error"
});
});
});
// Store incoming posts into a backlog if possible.
this.streamListener.on("update", (status: Status) => {
let queue = this.state.backlogPosts;
if (queue !== null && queue !== undefined) {
queue.unshift(status);
} else {
queue = [status];
}
this.setState({ backlogPosts: queue });
});
// When a post is deleted in the backend, find the post in the list
// and remove it from the list.
this.streamListener.on("delete", (id: number) => {
let posts = this.state.posts;
if (posts) {
posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1);
}
});
this.setState({ posts });
}
});
// Display an error if the stream encounters and error.
this.streamListener.on("error", (err: Error) => {
this.setState({
viewDidError: true,
viewDidErrorCode: err.message
});
this.props.enqueueSnackbar("An error occured.", {
variant: "error"
});
});
this.streamListener.on("heartbeat", () => {});
}
/**
* Insert a delay between repeated function calls
* codeburst.io/throttling-and-debouncing-in-javascript-646d076d0a44
* @param delay How long to wait before calling function (ms)
* @param fn The function to call
*/
debounced(delay: number, fn: Function) {
let lastCall = 0
return function(...args: any) {
const now = (new Date).getTime();
if (now - lastCall < delay) {
return
}
lastCall = now;
return fn(...args)
}
}
/**
* Listen for when scroll position changes
*/
componentDidMount() {
if (this.state.isInfiniteScroll) {
window.addEventListener(
"scroll",
this.debounced(200, this.shouldLoadMorePosts),
);
}
}
/**
* Halt the stream and scroll listeners when unmounting the component.
*/
componentWillUnmount() {
this.streamListener.stop();
if (this.state.isInfiniteScroll) {
window.removeEventListener(
"scroll",
this.shouldLoadMorePosts,
);
}
}
/**
* Insert the posts from the backlog into the current list of posts
* and clear the backlog.
*/
insertBacklog() {
window.scrollTo(0, 0);
let posts = this.state.posts;
let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null });
}
}
/**
* Load the next set of posts, if it exists.
*/
loadMoreTimelinePieces() {
// Reinstate the loading status.
this.setState({ viewDidLoad: false, viewIsLoading: true });
// If there are any posts, get the next set.
if (this.state.posts) {
this.client
.get(this.props.timeline, {
max_id: this.state.posts[this.state.posts.length - 1].id,
limit: 50
})
// If we succeeded, append them to the end of the list of posts.
.then((resp: any) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
});
})
// If we errored, display the error and don't do anything.
.catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
});
this.props.enqueueSnackbar("Failed to get posts", {
variant: "error"
});
});
}
}
/**
* Load more posts when scroll is near the end of the page
*/
shouldLoadMorePosts(e: Event) {
let difference =
document.body.clientHeight - window.scrollY - window.innerHeight;
if (difference < 10000 && this.state.viewIsLoading === false) {
this.loadMoreTimelinePieces();
}
}
/**
* Render the timeline page.
*/
render() {
const { classes } = this.props;
const containerClasses = `${classes.pageLayoutMaxConstraints}${
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
}`;
return (
<div className={containerClasses}>
{this.state.backlogPosts ? (
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
<Slide direction="down" in={true}>
<Chip
avatar={
<Avatar>
<ArrowUpwardIcon />
</Avatar>
}
label={`View ${
this.state.backlogPosts.length
} new post${
this.state.backlogPosts.length > 1
? "s"
: ""
}`}
color="primary"
className={classes.pageTopChip}
onClick={() => this.insertBacklog()}
clickable
/>
</Slide>
</div>
</div>
) : null}
{this.state.posts ? (
<div>
{this.state.isMasonryLayout ? (
<Masonry
breakpointCols={{
default: 4,
2000: 3,
1400: 2,
1050: 1
}}
className={classes.masonryGrid}
columnClassName={
classes["my-masonry-grid_column"]
}
>
{this.state.posts.map((post: Status) => {
return (
<div
className={classes.masonryGrid_item}
key={post.id}
>
<Post
post={post}
client={this.client}
/>
</div>
);
})}
</Masonry>
) : (
<div>
{this.state.posts.map((post: Status) => {
return (
<Post
key={post.id}
post={post}
client={this.client}
/>
);
})}
</div>
)}
<br />
{this.state.viewDidLoad && !this.state.viewDidError ? (
<div
style={{ textAlign: "center" }}
onClick={() => this.loadMoreTimelinePieces()}
>
<Button variant="contained">Load more</Button>
</div>
) : null}
</div>
) : (
<span />
)}
{this.state.viewDidError ? (
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">
Something went wrong when loading this timeline.
</Typography>
<Typography>
{this.state.viewDidErrorCode
? this.state.viewDidErrorCode
: ""}
</Typography>
</Paper>
) : (
<span />
)}
{this.state.viewIsLoading ? (
<div style={{ textAlign: "center" }}>
<CircularProgress
className={classes.progress}
color="primary"
/>
</div>
) : (
<span />
)}
</div>
);
}
}
export default withStyles(styles)(withSnackbar(TimelinePage));

View File

@ -413,51 +413,42 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
let clientLoginSession: SaveClientSession = JSON.parse(
loginData
);
getConfig().then((resp: any) => {
if (resp == undefined) {
return;
}
let conf: Config = resp;
let redirectUrl: string | undefined =
this.state.emergencyMode ||
clientLoginSession.authUrl.includes(
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
)
? undefined
: getRedirectAddress(conf.location);
Mastodon.fetchAccessToken(
clientLoginSession.clientId,
clientLoginSession.clientSecret,
code,
localStorage.getItem("baseurl") as string,
redirectUrl
)
.then((tokenData: any) => {
localStorage.setItem(
"access_token",
tokenData.access_token
);
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: this.state.defaultRedirectAddress;
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't authorize ${
this.state.brandName
? this.state.brandName
: "Hyperspace"
}: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
});
Mastodon.fetchAccessToken(
clientLoginSession.clientId,
clientLoginSession.clientSecret,
code,
localStorage.getItem("baseurl") as string,
this.state.emergencyMode
? undefined
: clientLoginSession.authUrl.includes(
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
)
? undefined
: window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}`
)
.then((tokenData: any) => {
localStorage.setItem(
"access_token",
tokenData.access_token
);
window.location.href =
window.location.protocol === "hyperspace:"
? "hyperspace://hyperspace/app/"
: `https://${window.location.host}/#/`;
})
.catch((err: Error) => {
this.props.enqueueSnackbar(
`Couldn't authorize ${
this.state.brandName
? this.state.brandName
: "Hyperspace"
}: ${err.name}`,
{ variant: "error" }
);
console.error(err.message);
});
}
}
}

View File

@ -14,7 +14,6 @@ export type Account = {
followers_count: number;
following_count: number;
statuses_count: number;
last_status_at: string;
note: string;
url: string;
avatar: string;

View File

@ -3,7 +3,7 @@
*/
export type Attachment = {
id: string;
type: "unknown" | "image" | "gifv" | "audio" | "video";
type: "unknown" | "image" | "gifv" | "video";
url: string;
remote_url: string | null;
preview_url: string;

View File

@ -1,13 +0,0 @@
/**
* Base draft type for a cached draft.
*/
export type Draft = {
/**
* The contents of the draft (i.e, its post text).
*/
contents: string;
/**
* The ID of the post it replies to, if applicable. If there isn't one, it should be set to -999.
*/
replyId: number;
};

View File

@ -1,5 +0,0 @@
export type History = {
day: string;
uses: number;
accounts: number;
};

View File

@ -1,7 +1,4 @@
import { History } from "./History";
export type Tag = {
name: string;
url: string;
history?: [History];
};

View File

@ -1,14 +1,5 @@
import { isDarwinApp } from "./desktop";
/**
* A list containing the types of child views.
*
* This list is used to help determine if a back button is necessary, usually because there
* is no defined way of returning to the parent view without using the menu bar or keyboard
* shortcut in desktop apps.
*/
export const childViews = ["#/profile", "#/conversation"];
/**
* Determine whether the title bar is being displayed.
* This might be useful in cases where styles are dependent on the title bar's visibility, such as heights.
@ -18,20 +9,3 @@ export const childViews = ["#/profile", "#/conversation"];
export function isAppbarExpanded(): boolean {
return isDarwinApp() || process.env.NODE_ENV === "development";
}
/**
* Determine whether a path is considered a "child view".
*
* This is often used to determine whether a back button should be rendered or not.
* @param path The path of the page, usually its hash
* @returns Boolean distating if the view is a child view.
*/
export function isChildView(path: string): boolean {
let protocolMatched = false;
childViews.forEach((childViewProtocol: string) => {
if (path.startsWith(childViewProtocol)) {
protocolMatched = true;
}
});
return protocolMatched;
}

View File

@ -1,43 +0,0 @@
import { Draft } from "../types/Draft";
/**
* Check whether a cached draft exists.
*/
export function draftExists(): boolean {
return sessionStorage.getItem("cachedDraft") !== null;
}
/**
* Write a draft to session storage.
* @param draft The text of the post.
* @param replyId The post's reply ID, if available.
*/
export function writeDraft(draft: string, replyId?: number) {
let cachedDraft = {
contents: draft,
replyId: replyId ? replyId : -999
};
sessionStorage.setItem("cachedDraft", JSON.stringify(cachedDraft));
}
/**
* Return the cached draft and remove it from session storage.
* @returns A Draft object with the draft's contents and reply ID (or -999).
*/
export function loadDraft(): Draft {
let contents = "";
let replyId = -999;
if (draftExists()) {
let draft = sessionStorage.getItem("cachedDraft");
sessionStorage.removeItem("cachedDraft");
if (draft != null) {
const draftObject = JSON.parse(draft);
contents = draftObject.contents;
replyId = draftObject.replyId;
}
}
return {
contents: contents,
replyId: replyId
};
}

View File

@ -44,18 +44,18 @@ export function createHyperspaceApp(
/**
* Gets the appropriate redirect address.
* @param url The address or configuration to use
* @param type The address or configuration to use
*/
export function getRedirectAddress(
url: "desktop" | "dynamic" | string
type: "desktop" | "dynamic" | string
): string {
switch (url) {
switch (type) {
case "desktop":
return "hyperspace://hyperspace/app/";
case "dynamic":
return `https://${window.location.host}`;
default:
return url;
return type;
}
}

View File

@ -12,7 +12,6 @@ type SettingsTemplate = {
clearNotificationsOnRead: boolean;
displayAllOnNotificationBadge: boolean;
defaultVisibility: string;
imposeCharacterLimit: boolean;
};
/**
@ -100,9 +99,7 @@ export function createUserDefaults() {
enablePushNotifications: true,
clearNotificationsOnRead: false,
displayAllOnNotificationBadge: false,
defaultVisibility: "public",
imposeCharacterLimit: true,
isMasonryLayout: false
defaultVisibility: "public"
};
let settings = [
@ -110,9 +107,7 @@ export function createUserDefaults() {
"systemDecidesDarkMode",
"clearNotificationsOnRead",
"displayAllOnNotificationBadge",
"defaultVisibility",
"imposeCharacterLimit",
"isMasonryLayout"
"defaultVisibility"
];
migrateExistingSettings();