Compare commits
289 Commits
Author | SHA1 | Date |
---|---|---|
Marquis Kurt | d2f1f23006 | |
Marquis Kurt | 6f6fe991f5 | |
dependabot[bot] | a74bd76b10 | |
Marquis Kurt | 01ece3cb7b | |
Marquis Kurt | e8ec0a47f9 | |
Marquis Kurt | 119066fa91 | |
Marquis Kurt | 5620b9d505 | |
Marquis Kurt | e0f0f3f2d8 | |
Marquis Kurt | 27acb9a151 | |
Marquis Kurt | 07be3af687 | |
Marquis Kurt | a94180b15c | |
Marquis Kurt | d3729ee76f | |
Marquis Kurt | 4e8a372234 | |
Marquis Kurt | ded85d38a1 | |
Marquis Kurt | dd7b3d7b78 | |
Marquis Kurt | f640cd2963 | |
Travis Kohlbeck | 914ee67d95 | |
Marquis Kurt | 54388e806a | |
Travis Kohlbeck | b7e27b703c | |
Travis Kohlbeck | 214262fdb3 | |
Marquis Kurt | 3d201eddf9 | |
Marquis Kurt | 993f5718fe | |
Marquis Kurt | 50b758c24b | |
Marquis Kurt | ac8d58c247 | |
Marquis Kurt | 43a83c1c84 | |
Marquis Kurt | 78e4fef3aa | |
Marquis Kurt | d1a70faf09 | |
Marquis Kurt | cd3147b2ce | |
Marquis Kurt | f51e00f54a | |
Marquis Kurt | a34ab59272 | |
Marquis Kurt | c854d406a7 | |
Marquis Kurt | a1aa9c09dc | |
Marquis Kurt | 1a132bdaf4 | |
Marquis Kurt | a98a8f04fe | |
Marquis Kurt | 63fc2f6b13 | |
Marquis Kurt | b16d4e09ad | |
Marquis Kurt | 81ed4bb8ad | |
Marquis Kurt | 3723795b7f | |
Marquis Kurt | 6656a3749e | |
Marquis Kurt | 33b9f9d76d | |
Marquis Kurt | e58208ba8d | |
Marquis Kurt | f591abfba8 | |
Marquis Kurt | 9c345e741c | |
Marquis Kurt | 6b006e17cc | |
Marquis Kurt | e6826e08d8 | |
Marquis Kurt | 5bf6fce1da | |
Marquis Kurt | f52daad91c | |
Marquis Kurt | fc9f1ebf21 | |
Marquis Kurt | 8897035fe5 | |
Marquis Kurt | 29a272a6f8 | |
Marquis Kurt | 93994ab9d7 | |
Travis Kohlbeck | 908f7ebb93 | |
Travis Kohlbeck | 3b1a5ff937 | |
Marquis Kurt | c3b3b2c1de | |
Marquis Kurt | f2da91468b | |
Marquis Kurt | a70ccd013f | |
Marquis Kurt | f960f4a0bc | |
Marquis Kurt | 95b950ab70 | |
Marquis Kurt | a86b97c46c | |
Marquis Kurt | 1ba63086f1 | |
Travis Kohlbeck | a501ed0744 | |
Marquis Kurt | 8432ad5a24 | |
Travis Kohlbeck | 1ec52d9d86 | |
Marquis Kurt | 564a9d26f9 | |
Marquis Kurt | 9238f8851a | |
Marquis Kurt | d6f9225b3b | |
Marquis Kurt | 5797296f38 | |
Marquis Kurt | 02c1b5ba89 | |
Marquis Kurt | 2e75118986 | |
Marquis Kurt | 3aa094db7c | |
Marquis Kurt | 4c2aceb3f1 | |
Marquis Kurt | cad678c3b1 | |
Marquis Kurt | c1616c83ad | |
Marquis Kurt | 3a640ab51f | |
Marquis Kurt | 0afdfc647a | |
Marquis Kurt | 436004250e | |
Marquis Kurt | 9ef922ccfe | |
Marquis Kurt | 07ec71f438 | |
Marquis Kurt | edf1f01b34 | |
Marquis Kurt | ffe9a97d93 | |
Marquis Kurt | eed62306ca | |
Marquis Kurt | 7cb5b1c692 | |
Marquis Kurt | 009ed5044e | |
Marquis Kurt | 0c675969c5 | |
Marquis Kurt | d73e6ce678 | |
Marquis Kurt | 67e94d8126 | |
Marquis Kurt | 8e3512a95e | |
Marquis Kurt | 6590e947c9 | |
Marquis Kurt | cae5cb301d | |
Marquis Kurt | 2f7c7d0a91 | |
Marquis Kurt | 4e64a1d4f9 | |
Marquis Kurt | afd3a7f31c | |
Marquis Kurt | 1e54b21672 | |
Travis Kohlbeck | b4b32efd7d | |
Travis Kohlbeck | f908e8656a | |
Travis Kohlbeck | 78d7b02085 | |
Travis Kohlbeck | a168b614b2 | |
Travis Kohlbeck | 37c01fc150 | |
Marquis Kurt | 5838039fef | |
Marquis Kurt | 16219c7e51 | |
Travis Kohlbeck | d4663e9bf1 | |
Travis Kohlbeck | 3542a88d98 | |
Travis Kohlbeck | 8459102c74 | |
Travis Kohlbeck | 3200086a6d | |
Marquis Kurt | cc43d90607 | |
Marquis Kurt | 5f50a6d6dc | |
Travis Kohlbeck | 166736e0bc | |
Travis Kohlbeck | 2620bb8282 | |
Marquis Kurt | 33d42991f3 | |
Marquis Kurt | 931244cb5a | |
Marquis Kurt | 5bcb31ee87 | |
Marquis Kurt | 334812fb06 | |
Travis Kohlbeck | cd57edbb32 | |
Travis Kohlbeck | f8ec25a050 | |
Marquis Kurt | 163d7e693e | |
Marquis Kurt | 9a2f7f6ef4 | |
Marquis Kurt | af5bd4a12a | |
Marquis Kurt | 20d9730a8e | |
Marquis Kurt | 8c6df6f1fd | |
Marquis Kurt | 8696251824 | |
Marquis Kurt | a922d58d20 | |
Marquis Kurt | c94b483b47 | |
Travis Kohlbeck | 073efe137b | |
Marquis Kurt | f2fe5cbb31 | |
Travis Kohlbeck | 4042661d90 | |
Travis Kohlbeck | 97b74ac5cd | |
Travis Kohlbeck | bbf9dafb07 | |
Travis Kohlbeck | 8e3dd1cd02 | |
Marquis Kurt | 0941927295 | |
Marquis Kurt | 030597593a | |
Marquis Kurt | e2b9513145 | |
Marquis Kurt | c429583225 | |
Travis Kohlbeck | cf2e299c19 | |
Marquis Kurt | a46d9c6c0f | |
Marquis Kurt | 8d14ff78df | |
Marquis Kurt | c00bca93bc | |
Marquis Kurt | e7d797595a | |
Marquis Kurt | 7a0827780e | |
Marquis Kurt | 5d26434855 | |
Marquis Kurt | 66eb312fe7 | |
Marquis Kurt | 77c32f4ec2 | |
Marquis Kurt | a12b14d10d | |
Marquis Kurt | 6e8185a090 | |
Marquis Kurt | 32c2a19369 | |
Marquis Kurt | 338b9118a6 | |
Marquis Kurt | a1f8c209b7 | |
Marquis Kurt | a95d06664d | |
Marquis Kurt | 3de4499494 | |
Marquis Kurt | bf4cdd6d8c | |
Marquis Kurt | 877d2512fc | |
Marquis Kurt | f35f3ef9dc | |
Marquis Kurt | 1abf50f3a0 | |
Marquis Kurt | ba8183aad3 | |
Marquis Kurt | 5e89034702 | |
Marquis Kurt | 760ac9ddd0 | |
Marquis Kurt | c0a6f3a5f9 | |
Marquis Kurt | 3528057e2e | |
Marquis Kurt | d67f89d6de | |
Marquis Kurt | 0b995218ea | |
Marquis Kurt | 860375d6fc | |
Travis Kohlbeck | 313ff79116 | |
Marquis Kurt | 68ff7e8663 | |
Marquis Kurt | d07d099c2f | |
Marquis Kurt | 89c4339c85 | |
Marquis Kurt | f33e4e6562 | |
Marquis Kurt | 2d7af3f717 | |
Marquis Kurt | ca23a4927c | |
Marquis Kurt | 40f1c8abd4 | |
Travis Kohlbeck | 6ef1daaec9 | |
Travis Kohlbeck | 95e5849be8 | |
Travis Kohlbeck | af4f6e1d53 | |
Travis Kohlbeck | e178a01fff | |
Marquis Kurt | 4cdc67b94f | |
Marquis Kurt | 1e3505bc7e | |
Travis Kohlbeck | fc8afb9000 | |
Travis Kohlbeck | e5cfaf0a44 | |
Marquis Kurt | 1bf92dd91d | |
Marquis Kurt | c25b612fdb | |
Marquis Kurt | 9966aec312 | |
Marquis Kurt | 58b4a12cca | |
Marquis Kurt | 7901e0f900 | |
Marquis Kurt | d49449c2bb | |
Marquis Kurt | c9fb045e4f | |
Marquis Kurt | 9a5473d842 | |
Marquis Kurt | 9bfe5f7e26 | |
Marquis Kurt | 2a6b1274b7 | |
Travis Kohlbeck | add9590e0b | |
Marquis Kurt | 8a39f36039 | |
Marquis Kurt | b18ab2be25 | |
Travis Kohlbeck | 240c1e98d8 | |
Travis Kohlbeck | 1a464339e3 | |
Marquis Kurt | 96d448bbd4 | |
Marquis Kurt | 4185d6bad8 | |
Marquis Kurt | bd15fb3e63 | |
Marquis Kurt | 774e2eb817 | |
Marquis Kurt | 2ffa744e52 | |
Marquis Kurt | e761dc4f97 | |
Marquis Kurt | 93f135e93b | |
Marquis Kurt | 6c8035465a | |
Marquis Kurt | b4496720a3 | |
Marquis Kurt | e768a24e94 | |
Marquis Kurt | 927c9c06a1 | |
Marquis Kurt | 7c0488f36d | |
Marquis Kurt | 88b6d9a452 | |
Marquis Kurt | 44e8c3f0b4 | |
Marquis Kurt | 8a428b1bba | |
Marquis Kurt | 14a06dd113 | |
Marquis Kurt | e835307dc2 | |
audmaxwell | 8b1142fd7d | |
audmaxwell | 8c1c0573f8 | |
Marquis Kurt | ee865cd494 | |
Marquis Kurt | 83e97c343b | |
Marquis Kurt | d8d5b52609 | |
Marquis Kurt | 78a9ff6c2a | |
Marquis Kurt | 15d654b662 | |
Marquis Kurt | 2e2ae7e48b | |
Marquis Kurt | a47631cbb1 | |
Marquis Kurt | ae295992df | |
Marquis Kurt | 33a2c2468c | |
Marquis Kurt | e98372d785 | |
Marquis Kurt | fe001a716a | |
Marquis Kurt | a61e31a0b5 | |
Marquis Kurt | 86047e649c | |
Marquis Kurt | 3a0f75b97f | |
Marquis Kurt | 7a977aeddd | |
Marquis Kurt | 0d27cabe53 | |
Marquis Kurt | f36000a939 | |
Marquis Kurt | dbe2319ea4 | |
Marquis Kurt | 9d6cb2525f | |
Marquis Kurt | e6ab97be66 | |
Marquis Kurt | 2ad8c8fb45 | |
audmaxwell | 86c0b151b8 | |
Marquis Kurt | aedb332f68 | |
Marquis Kurt | a41c543311 | |
Marquis Kurt | d8f3bf8a26 | |
Marquis Kurt | 0e81ae527c | |
Marquis Kurt | 034458ed4c | |
Marquis Kurt | 3c8c54dc96 | |
Marquis Kurt | a2a9edcb5b | |
Marquis Kurt | f9dd8a5801 | |
Marquis Kurt | 72cc633d18 | |
Marquis Kurt | 3bc3f59dda | |
Marquis Kurt | d67d900e9c | |
Marquis Kurt | eeb3353a9c | |
audmaxwell | 448a10ef6f | |
Marquis Kurt | 2720d5e774 | |
Marquis Kurt | 0293ff0d60 | |
Marquis Kurt | 761946c2b5 | |
Marquis Kurt | d254de3fbf | |
Marquis Kurt | a13e561f5b | |
Marquis Kurt | b31b9cce9d | |
Marquis Kurt | f9945250ed | |
Marquis Kurt | 923e8dd944 | |
Marquis Kurt | ae09fc57dd | |
Marquis Kurt | c8298dd9bc | |
Marquis Kurt | 52e703c790 | |
Marquis Kurt | b16d4b6969 | |
Marquis Kurt | 94380d8100 | |
Marquis Kurt | 48eabbed87 | |
Marquis Kurt | 3da74be981 | |
Travis Kohlbeck | c1d1f727b8 | |
Marquis Kurt | 19139ae732 | |
Marquis Kurt | 21a3e5e595 | |
Marquis Kurt | ea12f53fb2 | |
Marquis Kurt | 78b97b1b82 | |
Marquis Kurt | a57e240500 | |
Marquis Kurt | 1c4232bdd7 | |
Marquis Kurt | f554ff92a9 | |
Marquis Kurt | 16ea8d5cdb | |
Marquis Kurt | 1c6a7a6827 | |
Marquis Kurt | cf38153034 | |
Marquis Kurt | bf43f1b201 | |
Marquis Kurt | 4437bd4c7f | |
Travis Kohlbeck | 5b4f1bdbc8 | |
Marquis Kurt | 08a43ad245 | |
Marquis Kurt | 56ebbeffae | |
Marquis Kurt | 41145837ad | |
Marquis Kurt | f7ceba8b14 | |
Marquis Kurt | d60211dc7c | |
Marquis Kurt | 14fef7d5ce | |
Marquis Kurt | 41ce237896 | |
Marquis Kurt | 301226340e | |
Marquis Kurt | b237c39e41 | |
Marquis Kurt | 0a22ee6ba2 | |
Marquis Kurt | 0a51bd09f1 | |
Marquis Kurt | baa4ec907a | |
Travis Kohlbeck | cdd434d043 | |
Marquis Kurt | a44fb9865a | |
Marquis Kurt | e0794b197d |
|
@ -1 +1 @@
|
|||
* @alicerunsonfedora @Nomad1556 @Bio-ico
|
||||
* @hyperspacedev/administration @hyperspacedev/desktop
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
github: [alicerunsonfedora]
|
||||
patreon: hyperspacedev
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[Bug] Issue title"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
title: "Issue title"
|
||||
labels: "bug"
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
|
|||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
|
@ -24,9 +24,10 @@ A clear and concise description of what you expected to happen.
|
|||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**App Information (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari] (if applicable)
|
||||
- Version [e.g. 22]
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari] (if applicable)
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Request a feature
|
||||
url: "https://github.com/hyperspacedev/hyperspace/discussions/new?category=ideas"
|
||||
about: Suggest a new idea here.
|
||||
- name: Discord
|
||||
url: "https://discord.gg/c69AXwk"
|
||||
about: Chat with us here.
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: '[Request] Request title'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
|
@ -1,10 +1,28 @@
|
|||
---
|
||||
name: Pull request
|
||||
about: Create a pull request to help us improve the code
|
||||
title: "Request title"
|
||||
|
||||
---
|
||||
|
||||
**Changes Overview**
|
||||
This PR makes the following changes:
|
||||
|
||||
<!-- List your changes here as a bullet list. Read the contribution guidelines for more details.-->
|
||||
<!-- List your changes here as a bullet list. Read the contribution guidelines for more details.-->
|
||||
-
|
||||
-
|
||||
|
||||
**Does this PR fix, close, or implement any issues?**
|
||||
- [ ] This PR closes, fixes, or implements the following issues.
|
||||
|
||||
<!-- List any issues that this pull request may close or contribute to. Make sure you follow the proper syntax for referencing an issue.
|
||||
|
||||
Examples:
|
||||
|
||||
- Implements #0
|
||||
- Closes UnscriptedVN/issues#0
|
||||
- Contributes to #0
|
||||
-->
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
<!-- If the following is a release check, uncomment the following line. -->
|
||||
<!-- - [x] This is a release check. -->
|
||||
|
||||
**Pending for review**
|
||||
@hyperspacedev/desktop
|
|
@ -3,17 +3,36 @@ name: Build Linux Client
|
|||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build_linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm install
|
||||
npm run build --if-present
|
||||
npm run build-desktop-linux
|
||||
build_linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Use Node.js
|
||||
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:linux
|
||||
- name: Upload Linux executables
|
||||
uses: actions/upload-artifact@v1
|
||||
if: success()
|
||||
with:
|
||||
name: "Linux executables (output dir)"
|
||||
path: dist
|
||||
|
|
|
@ -3,45 +3,65 @@ name: Build macOS Client
|
|||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build_darwin:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Run pre-build setup
|
||||
run: |
|
||||
echo "Downloading certificates and profiles..."
|
||||
echo "$ascCertificates" > certs.b64
|
||||
echo "$ascMasProfile" > mas.b64
|
||||
echo "$ascMacProfile" > mac.b64
|
||||
echo "$ascEntitlementsMas" > entmas.b64
|
||||
echo "$ascEntitlementsMac" > entmac.b64
|
||||
echo "$ascInfoPlist" > info.b64
|
||||
build_darwin:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Install certificates and entitlements
|
||||
if: github.actor == 'alicerunsonfedora' || github.actor == 'Nomad1556' || github.actor == 'audmaxwell'
|
||||
run: |
|
||||
echo "Downloading certificates and profiles..."
|
||||
echo "$ascCertificates" > certs.b64
|
||||
echo "$ascMasProfile" > mas.b64
|
||||
echo "$ascMacProfile" > mac.b64
|
||||
echo "$ascEntitlementsMas" > entmas.b64
|
||||
echo "$ascEntitlementsMac" > entmac.b64
|
||||
echo "$ascInfoPlist" > info.b64
|
||||
|
||||
echo "Installing certificates and profiles..."
|
||||
base64 --decode certs.b64 > Certificates.p12
|
||||
base64 --decode mas.b64 > desktop/embedded.provisionprofile
|
||||
base64 --decode mac.b64 > desktop/nonmas.provisionprofile
|
||||
base64 --decode entmas.b64 > desktop/entitlements.mas.plist
|
||||
base64 --decode entmac.b64 > desktop/entitlements.mac.plist
|
||||
base64 --decode info.b64 > desktop/info.plist
|
||||
security add-generic-password -a "appleseed@marquiskurt.net" -w "$ascPassword" -s "AC_PASSWORD"
|
||||
sudo security import Certificates.p12 -P "$ascCertsPassword" -k /Library/Keychains/System.keychain
|
||||
env:
|
||||
ascPassword: ${{ secrets.ASC_PASSWORD }}
|
||||
ascCertificates: ${{ secrets.ASC_CERTS }}
|
||||
ascCertsPassword: ${{ secrets.ASC_CERTS_PASSWORD }}
|
||||
ascMacProfile: ${{ secrets.ASC_NONMAS_PROFILE }}
|
||||
ascMasProfile: ${{ secrets.ASC_EMBEDDED_PROFILE }}
|
||||
ascEntitlementsMas: ${{ secrets.ASC_MAS_ENTITLEMENTS }}
|
||||
ascEntitlementsMac: ${{ secrets.ASC_MAC_ENTITLEMENTS }}
|
||||
ascInfoPlist: ${{ secrets.ASC_INFO_PLIST }}
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm install
|
||||
npm run build --if-present
|
||||
npm run build-desktop-darwin-nosign
|
||||
echo "Installing certificates and profiles..."
|
||||
base64 --decode certs.b64 > Certificates.p12
|
||||
base64 --decode mas.b64 > desktop/embedded.provisionprofile
|
||||
base64 --decode mac.b64 > desktop/nonmas.provisionprofile
|
||||
base64 --decode entmas.b64 > desktop/entitlements.mas.plist
|
||||
base64 --decode entmac.b64 > desktop/entitlements.mac.plist
|
||||
base64 --decode info.b64 > desktop/info.plist
|
||||
security add-generic-password -a "appleseed@marquiskurt.net" -w "$ascPassword" -s "AC_PASSWORD"
|
||||
sudo security import Certificates.p12 -P "$ascCertsPassword" -k /Library/Keychains/System.keychain
|
||||
env:
|
||||
ascPassword: ${{ secrets.ASC_PASSWORD }}
|
||||
ascCertificates: ${{ secrets.ASC_CERTS }}
|
||||
ascCertsPassword: ${{ secrets.ASC_CERTS_PASSWORD }}
|
||||
ascMacProfile: ${{ secrets.ASC_NONMAS_PROFILE }}
|
||||
ascMasProfile: ${{ secrets.ASC_EMBEDDED_PROFILE }}
|
||||
ascEntitlementsMas: ${{ secrets.ASC_MAS_ENTITLEMENTS }}
|
||||
ascEntitlementsMac: ${{ secrets.ASC_MAC_ENTITLEMENTS }}
|
||||
ascInfoPlist: ${{ secrets.ASC_INFO_PLIST }}
|
||||
- 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:mac-unsigned
|
||||
- name: Upload macOS (unsigned) bundle
|
||||
uses: actions/upload-artifact@v1
|
||||
if: success()
|
||||
with:
|
||||
name: "macOS bundle (output dir)"
|
||||
path: dist
|
||||
|
|
|
@ -3,21 +3,21 @@ name: Node CI
|
|||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [8.x, 10.x, 12.x]
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm install
|
||||
npm run build --if-present
|
||||
env:
|
||||
CI: true
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x, 12.x]
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm install
|
||||
npm run build --if-present
|
||||
env:
|
||||
CI: false
|
||||
|
|
|
@ -3,17 +3,36 @@ name: Build Windows Client
|
|||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build_win:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm install
|
||||
npm run build --if-present
|
||||
npm run build-desktop-win
|
||||
build_win:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Use Node.js
|
||||
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:win
|
||||
- name: Upload Windows executable
|
||||
uses: actions/upload-artifact@v1
|
||||
if: success()
|
||||
with:
|
||||
name: "Windows executable (output dir)"
|
||||
path: dist
|
||||
|
|
|
@ -3,18 +3,18 @@ name: Prettier
|
|||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Install dependencies and run Prettier
|
||||
run: |
|
||||
npm install
|
||||
npm run check-prettier
|
||||
env:
|
||||
CI: true
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Install dependencies and run Prettier
|
||||
run: |
|
||||
npm install
|
||||
npm run test:prettier
|
||||
env:
|
||||
CI: true
|
||||
|
|
|
@ -72,4 +72,7 @@ desktop/*.plist
|
|||
desktop/*.provisionprofile
|
||||
|
||||
# JetBrains IDEA directory
|
||||
.idea/
|
||||
.idea/
|
||||
|
||||
# Pesky macOS files
|
||||
**/**.DS_Store
|
||||
|
|
|
@ -1,10 +1,24 @@
|
|||
Hyperspace
|
||||
Copyright Hyperspace developers 2019
|
||||
Hyperspace Desktop
|
||||
Copyright Hyperspace Developers 2020
|
||||
|
||||
NON-VIOLENT PUBLIC LICENSE v1
|
||||
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
|
||||
|
||||
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
|
||||
NON-VIOLENT PUBLIC LICENSE v1 ("LICENSE"). THE WORK IS PROTECTED BY
|
||||
NON-VIOLENT PUBLIC LICENSE v4 ("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
|
||||
|
@ -38,8 +52,9 @@ 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 action of one person towards another
|
||||
in an intentional manner.
|
||||
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.
|
||||
|
||||
d. "Collection" means a collection of literary or artistic
|
||||
works, such as encyclopedias and anthologies, or performances,
|
||||
|
@ -61,8 +76,7 @@ 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 by physical
|
||||
means.
|
||||
either their will or the will of their legal guardians.
|
||||
|
||||
g. "Licensor" means the individual, individuals, entity or
|
||||
entities that offer(s) the Work under the terms of this License.
|
||||
|
@ -134,13 +148,23 @@ 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
|
||||
overtly or covertly observe persons or their activities.
|
||||
o. "Surveilling" means the use of the Work to either
|
||||
overtly or covertly observe and record persons and 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
|
||||
|
@ -177,7 +201,6 @@ 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
|
||||
|
||||
|
@ -232,15 +255,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
|
||||
an Act of War.
|
||||
or profiting from 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.
|
||||
|
||||
|
@ -419,4 +442,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.
|
268
README.md
|
@ -1,123 +1,275 @@
|
|||
<p align="center">
|
||||
<img src="desktop/app.iconset/icon_512@2x.png" width="128" max-width="25%" alt=“Hyperspace” />
|
||||
</p>
|
||||
<h1 align="center">Hyperspace</h1>
|
||||
<div align="center">
|
||||
|
||||
<p align="center">The new beautiful, fluffy client for the fediverse written in TypeScript and React</p>
|
||||
<img src="desktop/app.iconset/icon_512x512@2x.png" width="128" max-width="25%" alt="Hyperspace Desktop icon" />
|
||||
|
||||
![Hyperspace 1.0 on a MacBook Pro](screenshot.png)
|
||||
# Hyperspace Desktop
|
||||
|
||||
The new beautiful, fluffy client for the fediverse written in TypeScript and React
|
||||
|
||||
</div>
|
||||
|
||||
![Hyperspace Desktop on a MacBook Pro](screenshot.png)
|
||||
|
||||
[![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) [![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)
|
||||
[![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)
|
||||
|
||||
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.
|
||||
Socialize and communicate with your friends in the fediverse (ActivityPub-powered social networks like Mastodon and Pleroma) with Hyperspace Desktop. Browse your timelines, check in with friends, and share your experiences across the fediverse in a beautiful, clean, and customizable way.
|
||||
|
||||
## Features
|
||||
What Hyperspace Desktop offers:
|
||||
|
||||
- **Responsive by design**: Hyperspace is beautifully designed to put your content front and center and bring a familiar experience to Mastodon. View threads and profiles with ease and compose anywhere with the compose button. And, of course, Hyperspace scales across devices beautifully, providing the same experience anywhere.
|
||||
- **Customizable**: Hyperspace allows customization and configuration at every level, from the server level with branding and instance setup, down to the user level with dark mode, custom themes, and multi-user account support. And, if the default configuration settings aren't enough, anyone can make their own version of Hyperspace with custom additions.
|
||||
- **Open-source**: Hyperspace is free (libre) and open-source software. Licensed under the Non-Violent Public License, anyone can modify, redistribute, or contribute to the Hyperspace project without restriction. Hyperspace is written in TypeScript and takes advantage of multiple open-source libraries and projects such as React, Megalodon, and Material-UI, so web and Node.js developers will feel right at home.
|
||||
- A clean, responsive, and streamlined design that fits in with your Mac
|
||||
- Support for switching between accounts to access the accounts you use the most
|
||||
- Customization support, ranging from several beautiful themes to masonry layout and infinite scrolling
|
||||
- Powerful toot composer with media uploads, emojis, and polls
|
||||
- Activity and recommended views that give you insight on the community/instance you reside in
|
||||
|
||||
> If you've used Hyperspace 0.x, you'll note many changes with the 1.x and later series. You can learn more about these changes in the [migration article](MIGRATING.md).
|
||||
## Get started
|
||||
|
||||
## Downloads
|
||||
Hyperspace Desktop is available for the major desktop platforms via our downloads page, GitHub, and other store platforms where applicable.
|
||||
|
||||
Hyperspace is available for download on GitHub as well as other platforms.
|
||||
[**Download from our website ›**](https://hyperspace.marquiskurt.net/download)
|
||||
|
||||
[**Get latest release ›**](https://github.com/hyperspacedev/hyperspace/releases/latest)
|
||||
### Download from a store
|
||||
|
||||
[![Get on the Mac App Store](https://hyperspace.marquiskurt.net/images/mas.svg)](https://itunes.apple.com/us/app/hyperspace/id1454139710?mt=12) [![Get on the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/hyperspace)
|
||||
[![Get on the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/hyperspace) [![Get on the Mac App Store](https://hyperspace.marquiskurt.net/assets/images/mas.svg)](https://apps.apple.com/us/app/hyperspace-desktop/id1454139710?mt=12)
|
||||
|
||||
## Build instructions
|
||||
**via [WinGet](https://github.com/microsoft/winget-cli)**:
|
||||
|
||||
### Prerequisites
|
||||
```
|
||||
winget install HyperspaceDesktop
|
||||
```
|
||||
|
||||
To develop Hyperspace, you'll need the following tools and packages:
|
||||
## Build from source
|
||||
|
||||
- Node.js 8 or later
|
||||
To build Hyperspace Desktop, you'll need the following tools and packages:
|
||||
|
||||
- Node.js v10 or later
|
||||
- (macOS-only) Xcode 10 or higher
|
||||
|
||||
### Installing dependencies
|
||||
|
||||
First, clone the repository from GitHub:
|
||||
|
||||
```bash
|
||||
```
|
||||
git clone https://github.com/hyperspacedev/hyperspace
|
||||
```
|
||||
|
||||
Then, in the app directory, run the following command to install all of the package dependencies:
|
||||
|
||||
```npm
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Testing changes
|
||||
|
||||
Before testing Hyperspace, you'll need to modify the `location` key in `public/config.json`. For example:
|
||||
Run any of the following scripts to test:
|
||||
|
||||
```json
|
||||
"location": "https://localhost:3000"
|
||||
```
|
||||
- `npm start` - Starts a local server hosted at https://localhost:3000.
|
||||
- `npm run electron:build` - Builds a copy of the source code and then runs the app through Electron. Ensure that the `location` key in `config.json` points to `"desktop"` before running this.
|
||||
- `npm run electron:prebuilt` - Similar to `electron:build` but doesn't build the project before running.
|
||||
|
||||
The `location` key can take the following values during testing:
|
||||
The `location` key in `config.json` can take the following values during testing:
|
||||
|
||||
- **https://localhost:3000**: Most suitable for running `npm start` or running via `react-scripts`.
|
||||
- **desktop**: Most suitable for when testing the desktop application.
|
||||
- **https://localhost:3000**: Most suitable for running `npm start` or running via `react-scripts`.
|
||||
- **desktop**: Most suitable for when testing the desktop application.
|
||||
|
||||
After changing this setting, run any of the following scripts to test:
|
||||
|
||||
- `npm start` - Starts a local server hosted at https://localhost:3000.
|
||||
- `npm run electrify` - Builds a copy of the source code and then runs the app through Electron. Ensure that the `location` key in `config.json` points to `"desktop"` before running this.
|
||||
- `npm run electrify-nobuild` - Similar to `electrify` but doesn't build the project before running.
|
||||
> Note: Hyperspace Desktop v1.1.0-beta3 and older versions require the location field to be changed to `"https://localhost:3000"` before running.
|
||||
|
||||
### Building a release
|
||||
|
||||
To build a release, run the following command:
|
||||
|
||||
```npm
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built files will be available under `build` as static files that can be hosted on a web server. If you plan to release these files alongside the desktop apps, compress these files in a ZIP.
|
||||
|
||||
#### Building desktop releases
|
||||
#### Building desktop apps
|
||||
|
||||
You can run any of the following commands to build a release for the desktop:
|
||||
|
||||
- `npm run build-desktop`: Builds the desktop apps for all platforms (eg. Windows, macOS, Linux). Will run `npm run build` before building.
|
||||
- `npm run build-desktop-win`: Builds the desktop app for Windows without running `npm run build`.
|
||||
- `npm run build-desktop-darwin`: Builds the desktop apps for macOS (eg. disk image, Mac App Store) without running `npm run build`. See the details below for more information on building for macOS.
|
||||
- `npm run build-desktop-linux`: Builds the desktop apps for Linux (eg. Debian package, AppImage, and Snap) without running `npm run build`.
|
||||
- `npm run build-desktop-linux-select`: Builds the desktop app for Linux without running `npm run build`. _Target is required as a parameter._
|
||||
- `npm run build:desktop-all`: Builds the desktop apps for all platforms (eg. Windows, macOS, Linux). Will run `npm run build` before building.
|
||||
- `npm run build:win`: Builds the desktop app for Windows without running `npm run build`.
|
||||
- `npm run build:mac`: Builds the desktop apps for macOS without running `npm run build`. See the details below for more information on building for macOS.
|
||||
- `npm run build:mas`: Builds the desktop apps for the Mac App Store without running `npm run build`. See the details below for more information on building for macOS.
|
||||
- `npm run build:linux`: Builds the desktop apps for Linux (eg. Debian package, AppImage, and Snap) without running `npm run build`.
|
||||
- `npm run build:linux-select-targets`: Builds the desktop app for Linux without running `npm run build`. _Targets are required as parameters._
|
||||
|
||||
The built files will be available under `dist` that can be uploaded to your app distributor or website.
|
||||
|
||||
#### Building for macOS
|
||||
#### Extra steps for macOS
|
||||
|
||||
More recent version of macOS require that the Hyperspace desktop app be both digitally code-signed and notarized (uploaded to Apple to check for malware). Hyperspace includes the tools necessary to automate this process when building the macOS version either by `npm run build-desktop` or by `npm run build-desktop-darwin`.
|
||||
The macOS builds of Hyperspace Desktop require a bit more effort and resources to build and distribute accordingly. The following is a quick guide to building Hyperspace Desktop for macOS and for the Mac App Store.
|
||||
|
||||
Make sure you have your provisioning profiles for the Mac App Store (`embedded.provisionprofile`) and standard distribution (`nonmas.provisionprofile`) in the `desktop` directory. These provision profiles can be obtained through Apple Developer. You'll also need to create entitlements files in the `desktop` directory that list the following entitlements for your app:
|
||||
##### Gather your tools
|
||||
|
||||
- `com.apple.security.app-sandbox`
|
||||
- `com.apple.security.files.downloads.read-write`
|
||||
- `com.apple.security.files.user-selected.read-write`
|
||||
- `com.apple.security.allow-unsigned-executable-memory`
|
||||
- `com.apple.security.network.client`
|
||||
To create a code-signed and notarized version of Hyperspace Desktop, you'll need to acquire some provisioning profiles and certificates from a valid Apple Developer account.
|
||||
|
||||
It is also recommended to add the `com.apple.security.applications-groups` entry with your bundle's identifier. You'll also need to create an `info.plist` in the `desktop` directory containing the team identifier and application identifier and install the developer certificates on the Mac you plan to build from.
|
||||
For certificates, make sure your Mac has the following certificates installed:
|
||||
|
||||
You'll also want to modify the `notarize.js` file to change the details from the default to your App Store Connect account details and app identifier.
|
||||
- 3rd Party Mac Developer Application
|
||||
- 3rd Party Mac Developer Installer
|
||||
- Developer ID Application
|
||||
- Developer ID Installer
|
||||
- Mac Developer
|
||||
|
||||
> ⚠️ **Warning**: The package.json file also includes the `build-desktop-darwin-nosign` script. This script is specifically intended for automated systems that cannot run notarization (Azure Pipelines, GitHub Actions, etc.). _Do not use this command to build production-ready versions of Hyperspace_.
|
||||
The easiest way to handle this is by opening Xcode and going to **Preferences › Accounts** and create the certificates from "Manage Certificates".
|
||||
|
||||
You'll also need to [create a provisioning profile for **Mac App Store** distribution](https://developer.apple.com/account/resources/profiles/add) and save it to the `desktop` folder as `embedded.provisonprofile`.
|
||||
|
||||
##### Create your entitlements files
|
||||
|
||||
You'll also need to create the entitlements files in the `desktop` directory that declares the permissions for Hyperspace Desktop. Replace `TEAM_ID` with the appropriate Apple Developer information and `BUNDLE_ID` with the bundle ID of your app.
|
||||
|
||||
###### entitlements.mac.plist
|
||||
|
||||
```plist
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
###### entitlements.mas.plist
|
||||
|
||||
```plist
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>TEAM_ID.BUNDLE_ID</string>
|
||||
</array>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
###### entitlements.mas.inherit.plist
|
||||
|
||||
```plist
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
###### entitlements.mas.loginhelper.plist
|
||||
|
||||
```plist
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
###### info.plist
|
||||
|
||||
```plist
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ElectronTeamID</key>
|
||||
<string>TEAM_ID</string>
|
||||
<key>com.apple.developer.team-identifier</key>
|
||||
<string>TEAM_ID</string>
|
||||
<key>com.apple.application-identifier</key>
|
||||
<string>TEAM_ID.BUNDLE_ID</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
##### Edit `notarize.js`
|
||||
|
||||
You'll also need to edit `notarize.js` in the `desktop` directory. Replace `<TEAM_ID>`, `<BUNDLE_ID>`, and `<APPLE_DEVELOPER_EMAIL>` with the appropriate information from the app and your account from Apple Developer.
|
||||
|
||||
```js
|
||||
// notarize.js
|
||||
// Script to notarize Hyperspace for macOS
|
||||
// © 2019 Hyperspace developers. Licensed under Apache 2.0.
|
||||
|
||||
const { notarize } = require("electron-notarize");
|
||||
|
||||
// This is pulled from the Apple Keychain. To set this up,
|
||||
// follow the instructions provided here:
|
||||
// https://github.com/electron/electron-notarize#safety-when-using-appleidpassword
|
||||
const password = `@keychain:AC_PASSWORD`;
|
||||
|
||||
exports.default = async function notarizing(context) {
|
||||
const { electronPlatformName, appOutDir } = context;
|
||||
if (electronPlatformName !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Notarizing Hyperspace...");
|
||||
|
||||
const appName = context.packager.appInfo.productFilename;
|
||||
|
||||
return await notarize({
|
||||
appBundleId: "<BUNDLE_ID>",
|
||||
appPath: `${appOutDir}/${appName}.app`,
|
||||
appleId: "<APPLE_DEVELOPER_EMAIL>",
|
||||
appleIdPassword: password,
|
||||
ascProvider: "<TEAM_ID>"
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Note that the password is pulled from your keychain. You'll need to create an app password and store it in your keychain as `AC_PASSWORD`.
|
||||
|
||||
##### Build the apps
|
||||
|
||||
Run any of the following commands to build Hyperspace Desktop for the Mac:
|
||||
|
||||
- `npm run build:mac` - Builds the macOS app in a DMG container.
|
||||
- `npm run build:mac-unsigned` - Similar to `build:mac`, but skips code signing and notarization. **Use only for CI or in situations where code signing and notarization is not available.**
|
||||
- `npm run build:mas` - Builds the Mac App Store package.
|
||||
|
||||
## Licensing and Credits
|
||||
|
||||
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 Desktop 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 Desktop 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.
|
||||
Hyperspace Desktop 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
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
If you want to aid the project in other ways, consider supporting the project on [Patreon](https://patreon.com/marquiskurt).
|
||||
If you want to aid the project in other ways, consider supporting the project on [Patreon](https://patreon.com/hyperspacedev).
|
||||
|
|
BIN
desktop/app.icns
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 38 KiB |
After Width: | Height: | Size: 113 KiB |
Before Width: | Height: | Size: 770 B |
Before Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 2.0 KiB |
After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 83 KiB |
After Width: | Height: | Size: 113 KiB |
After Width: | Height: | Size: 353 KiB |
Before Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 5.1 KiB |
After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 185 KiB |
After Width: | Height: | Size: 351 KiB |
After Width: | Height: | Size: 1.1 MiB |
|
@ -0,0 +1,44 @@
|
|||
#
|
||||
# Hyperspace Desktop MAS Build Config
|
||||
# (C) 2020 Hyperspace Developers. Licensed under NPLv4.
|
||||
#
|
||||
# The following configuration file is used to configure the Mac App Store builds of Hyperspace
|
||||
# Desktop. For building cross-platform apps without submitting to the Mac App Store, modify the
|
||||
# standard.yml config file.
|
||||
#
|
||||
|
||||
appId: net.marquiskurt.hyperspace
|
||||
afterSign: desktop/notarize.js
|
||||
directories:
|
||||
buildResources: desktop
|
||||
|
||||
# The bundleVersion and bundleShortVersion keys in this config correspond to builds in the
|
||||
# Mac App Store. If you are attempting to upload a new build of the same app version to the
|
||||
# Mac App Store, change the bundle version. The bundle short version should be the same as
|
||||
# the app version seen in config.json and package.json.
|
||||
#
|
||||
# If you are submitting a new app version entirely, make sure the bundle version and short
|
||||
# version match accordingly, except in cases where the app version is the same version as an
|
||||
# older Mac App Store build.
|
||||
mac:
|
||||
# Bundle version will reflect the build number (i.e., the release number).
|
||||
bundleVersion: "28"
|
||||
bundleShortVersion: "1.1.4"
|
||||
category: public.app-category.social-networking
|
||||
icon: desktop/app.icns
|
||||
target: [dmg]
|
||||
darkModeSupport: true
|
||||
hardenedRuntime: false
|
||||
gatekeeperAssess: false
|
||||
|
||||
# Note that you will need the proper entitlements files for the following keys below. Refer to
|
||||
# the Hyperspace Desktop documentation regarding what keys will need to be inserted into the
|
||||
# entitlements files:
|
||||
# https://hyperspace.marquiskurt.net/docs/desktop-build-desktop.html
|
||||
mas:
|
||||
entitlements: desktop/entitlements.mas.plist
|
||||
entitlementsInherit: desktop/entitlements.mas.inherit.plist
|
||||
provisioningProfile: desktop/embedded.provisionprofile
|
||||
|
||||
dmg:
|
||||
sign: false
|
|
@ -0,0 +1,35 @@
|
|||
#
|
||||
# Hyperspace Desktop Build Config
|
||||
# (C) 2020 Hyperspace Developers. Licensed under NPLv4.
|
||||
#
|
||||
# The following configuration file is used to configure and build the cross-platforms apps,
|
||||
# excluding the Mac App Store build. For the Mac App Store build, modify the mas.yml file.
|
||||
#
|
||||
|
||||
appId: net.marquiskurt.hyperspace
|
||||
afterSign: desktop/notarize.js
|
||||
directories:
|
||||
buildResources: desktop
|
||||
|
||||
mac:
|
||||
category: public.app-category.social-networking
|
||||
icon: desktop/app.icns
|
||||
target: [dmg]
|
||||
darkModeSupport: true
|
||||
hardenedRuntime: true
|
||||
|
||||
dmg:
|
||||
sign: false
|
||||
|
||||
win:
|
||||
target: [nsis]
|
||||
icon: desktop/app.ico
|
||||
|
||||
linux:
|
||||
target: ["${@:1}"]
|
||||
icon: linux
|
||||
category: Network
|
||||
|
||||
snap:
|
||||
confinement: strict
|
||||
summary: The fluffiest client for the fediverse
|
176
package.json
|
@ -1,114 +1,74 @@
|
|||
{
|
||||
"name": "hyperspace",
|
||||
"productName": "Hyperspace",
|
||||
"version": "1.0.0",
|
||||
"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"
|
||||
"name": "hyperspace",
|
||||
"productName": "Hyperspace Desktop",
|
||||
"version": "1.1.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.13",
|
||||
"@material-ui/core": "^3.9.4",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@types/emoji-mart": "^2.11.3",
|
||||
"@types/jest": "^24.9.1",
|
||||
"@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.21.1",
|
||||
"electron": "^9.0.5",
|
||||
"electron-builder": "^22.7.0",
|
||||
"emoji-mart": "^2.11.2",
|
||||
"file-dialog": "^0.0.7",
|
||||
"material-ui-pickers": "^2.2.4",
|
||||
"mdi-material-ui": "^5.27.0",
|
||||
"megalodon": "^0.6.4",
|
||||
"moment": "^2.27.0",
|
||||
"notistack": "^0.5.1",
|
||||
"prettier": "^1.19.1",
|
||||
"query-string": "^6.13.1",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "^3.4.1",
|
||||
"react-swipeable-views": "^0.13.9",
|
||||
"react-web-share-api": "^0.0.2",
|
||||
"typescript": "^3.9.5"
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.social-networking",
|
||||
"icon": "desktop/app.icns",
|
||||
"target": [
|
||||
"dmg",
|
||||
"mas"
|
||||
],
|
||||
"darkModeSupport": true,
|
||||
"hardenedRuntime": true
|
||||
"dependencies": {
|
||||
"electron-notarize": "^0.1.1",
|
||||
"electron-updater": "^4.3.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"react-masonry-css": "^1.0.14"
|
||||
},
|
||||
"mas": {
|
||||
"entitlements": "desktop/entitlements.mas.plist",
|
||||
"entitlementsInherit": "desktop/entitlements.mas.inherit.plist",
|
||||
"provisioningProfile": "desktop/embedded.provisionprofile"
|
||||
"main": "public/electron.js",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"test": "react-scripts test",
|
||||
"test:prettier": "prettier --check src/**/**.tsx",
|
||||
"eject": "react-scripts eject",
|
||||
"electron:build": "npm run build; electron .",
|
||||
"electron:prebuilt": "electron .",
|
||||
"build": "react-scripts build",
|
||||
"build:icns": "iconutil -c icns desktop/app.iconset -o desktop/app.icns",
|
||||
"build:desktop-all": "npm run build; npm run build:icns; electron-builder -p 'never' -mwl deb AppImage snap -c ebuild/standard.yml",
|
||||
"build:win": "electron-builder -p 'never' -w -c ebuild/standard.yml",
|
||||
"build:mac": "npm run build:icns; electron-builder -p 'never' -m -c ebuild/standard.yml",
|
||||
"build:mac-unsigned": "npm run build:icns; electron-builder -p 'never' -m dmg -c.mac.identity=null -c.afterSign=\"desktop/donothing.js\" -c ebuild/standard.yml",
|
||||
"build:mas": "npm run build:icns; electron-builder -p 'never' -m mas -c ebuild/mas.yml",
|
||||
"build:linux": "electron-builder -p 'never' -l deb AppImage snap -c ebuild/standard.yml",
|
||||
"build:linux-select-targets": "electron-builder -p 'never' -c ebuild/standard.yml -l "
|
||||
},
|
||||
"dmg": {
|
||||
"sign": false
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
Hyperspace has been made possible by the efforts of the Hyperspace development team and these amazing contributors on Patreon:
|
||||
|
||||
- LucasAzazer
|
||||
<!-- (Add contributors here) -->
|
||||
|
||||
Thanks for your continued support in helping us create the fluffiest client for the fediverse!
|
||||
Thanks for your continued support in helping us create the fluffiest client for the fediverse!
|
||||
|
||||
## Previous Contributors
|
||||
|
||||
- LucasAzazer
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.4",
|
||||
"location": "https://hyperspaceapp.herokuapp.com",
|
||||
"branding": {
|
||||
"name": "Hyperspace",
|
||||
|
@ -13,15 +13,15 @@
|
|||
"enablePublicTimeline": true
|
||||
},
|
||||
"registration": {
|
||||
"defaultInstance": "mastodon.social"
|
||||
"defaultInstance": "mastodon.online"
|
||||
},
|
||||
"admin": {
|
||||
"name": "Hyperspace Developers",
|
||||
"account": "774314"
|
||||
},
|
||||
"license": {
|
||||
"name": "Non-violent Public License",
|
||||
"name": "Non-violent Public License v4+",
|
||||
"url": "https://thufie.lain.haus/NPL.html"
|
||||
},
|
||||
"repository": "https://github.com/hyperspacedev/hyperspace"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,17 @@
|
|||
// Electron script to run Hyperspace as an app
|
||||
// © 2018 Hyperspace developers. Licensed under NPL v1.
|
||||
|
||||
const { app, Menu, protocol, BrowserWindow, shell, systemPreferences } = require('electron');
|
||||
const windowStateKeeper = require('electron-window-state');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const path = require('path');
|
||||
const {
|
||||
app,
|
||||
Menu,
|
||||
protocol,
|
||||
BrowserWindow,
|
||||
shell,
|
||||
systemPreferences
|
||||
} = require("electron");
|
||||
const windowStateKeeper = require("electron-window-state");
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
const path = require("path");
|
||||
|
||||
// Check for any updates to the app
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
|
@ -18,7 +25,7 @@ let mainWindow;
|
|||
// file:// protocol, which is necessary for Mastodon to redirect
|
||||
// to when authorizing Hyperspace.
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'hyperspace', privileges: { standard: true, secure: true } }
|
||||
{ scheme: "hyperspace", privileges: { standard: true, secure: true } }
|
||||
]);
|
||||
|
||||
/**
|
||||
|
@ -33,80 +40,81 @@ function isDarwin() {
|
|||
* Register the protocol for Hyperspace
|
||||
*/
|
||||
function registerProtocol() {
|
||||
protocol.registerFileProtocol('hyperspace', (request, callback) => {
|
||||
|
||||
// Check to make sure we're doing a GET request
|
||||
if (request.method !== "GET") {
|
||||
callback({error: -322});
|
||||
return null;
|
||||
protocol.registerFileProtocol(
|
||||
"hyperspace",
|
||||
(request, callback) => {
|
||||
// Check to make sure we're doing a GET request
|
||||
if (request.method !== "GET") {
|
||||
callback({ error: -322 });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check to make sure we're actually working with a hyperspace
|
||||
// protocol and that the host is 'hyperspace'
|
||||
const parsedUrl = new URL(request.url);
|
||||
if (parsedUrl.protocol !== "hyperspace:") {
|
||||
callback({ error: -302 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedUrl.host !== "hyperspace") {
|
||||
callback({ error: -105 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the parsed URL to a list of strings.
|
||||
const target = parsedUrl.pathname.split("/");
|
||||
|
||||
// Check that the target isn't trying to go somewhere
|
||||
// else. If it is, throw a "FILE_NOT_FOUND" error
|
||||
if (target[0] !== "") {
|
||||
callback({ error: -6 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the last target item in the list is empty.
|
||||
// If so, replace it with "index.html" so that it can
|
||||
// load a page.
|
||||
if (target[target.length - 1] === "") {
|
||||
target[target.length - 1] = "index.html";
|
||||
}
|
||||
|
||||
// Check the middle target and redirect to the appropriate
|
||||
// build files of the desktop app when running.
|
||||
let baseDirectory;
|
||||
if (target[1] === "app" || target[1] === "oauth") {
|
||||
baseDirectory = __dirname + "/../build/";
|
||||
} else {
|
||||
// If it doesn't match above, throw a "FILE_NOT_FOUND" error.
|
||||
callback({ error: -6 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a normalized version of the string.
|
||||
baseDirectory = path.normalize(baseDirectory);
|
||||
|
||||
// Check to make sure the target isn't trying to go out of bounds.
|
||||
// If it is, throw a "FILE_NOT_FOUND" error.
|
||||
const relTarget = path.normalize(path.join(...target.slice(2)));
|
||||
if (relTarget.startsWith("..")) {
|
||||
callback({ error: -6 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the absolute target path and return it.
|
||||
const absTarget = path.join(baseDirectory, relTarget);
|
||||
callback({ path: absTarget });
|
||||
},
|
||||
error => {
|
||||
if (error) console.error("Failed to register protocol");
|
||||
}
|
||||
|
||||
// Check to make sure we're actually working with a hyperspace
|
||||
// protocol and that the host is 'hyperspace'
|
||||
const parsedUrl = new URL(request.url);
|
||||
if (parsedUrl.protocol !== "hyperspace:") {
|
||||
callback({error: -302});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedUrl.host !== "hyperspace") {
|
||||
callback({error: -105});
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the parsed URL to a list of strings.
|
||||
const target = parsedUrl.pathname.split("/");
|
||||
|
||||
// Check that the target isn't trying to go somewhere
|
||||
// else. If it is, throw a "FILE_NOT_FOUND" error
|
||||
if (target[0] !== "") {
|
||||
callback({error: -6});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the last target item in the list is empty.
|
||||
// If so, replace it with "index.html" so that it can
|
||||
// load a page.
|
||||
if (target[target.length -1] === "") {
|
||||
target[target.length -1] = "index.html";
|
||||
}
|
||||
|
||||
// Check the middle target and redirect to the appropriate
|
||||
// build files of the desktop app when running.
|
||||
let baseDirectory;
|
||||
if (target[1] === "app" || target[1] === "oauth") {
|
||||
baseDirectory = __dirname + "/../build/";
|
||||
} else {
|
||||
// If it doesn't match above, throw a "FILE_NOT_FOUND" error.
|
||||
callback({error: -6});
|
||||
}
|
||||
|
||||
// Create a normalized version of the string.
|
||||
baseDirectory = path.normalize(baseDirectory);
|
||||
|
||||
// Check to make sure the target isn't trying to go out of bounds.
|
||||
// If it is, throw a "FILE_NOT_FOUND" error.
|
||||
const relTarget = path.normalize(path.join(...target.slice(2)));
|
||||
if (relTarget.startsWith('..')) {
|
||||
callback({error: -6});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the absolute target path and return it.
|
||||
const absTarget = path.join(baseDirectory, relTarget);
|
||||
callback({ path: absTarget });
|
||||
|
||||
}, (error) => {
|
||||
if (error) console.error('Failed to register protocol');
|
||||
});
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the window and all of its properties
|
||||
*/
|
||||
function createWindow() {
|
||||
|
||||
// Create a window state manager that keeps track of the width
|
||||
// and height of the main window.
|
||||
let mainWindowState = windowStateKeeper({
|
||||
|
@ -115,68 +123,72 @@ function createWindow() {
|
|||
});
|
||||
|
||||
// Create a browser window with some settings
|
||||
mainWindow = new BrowserWindow(
|
||||
{
|
||||
// Use the values from the window state keeper
|
||||
// to draw the window exactly as it was left.
|
||||
// If not possible, derive it from the default
|
||||
// values defined earlier.
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
height: mainWindowState.height,
|
||||
|
||||
// Set a minimum width to prevent element collisions.
|
||||
minWidth: 300,
|
||||
mainWindow = new BrowserWindow({
|
||||
// Use the values from the window state keeper
|
||||
// to draw the window exactly as it was left.
|
||||
// If not possible, derive it from the default
|
||||
// values defined earlier.
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
height: mainWindowState.height,
|
||||
|
||||
// Set important web preferences.
|
||||
webPreferences: {nodeIntegration: true},
|
||||
// Set a minimum width to prevent element collisions.
|
||||
minWidth: 300,
|
||||
|
||||
// Set some preferences that are specific to macOS.
|
||||
titleBarStyle: 'hiddenInset',
|
||||
vibrancy: "sidebar",
|
||||
transparent: isDarwin(),
|
||||
backgroundColor: isDarwin()? "#80000000": "#FFF",
|
||||
// Set important web preferences.
|
||||
webPreferences: { nodeIntegration: true },
|
||||
|
||||
// Hide the window until the contents load
|
||||
show: false
|
||||
}
|
||||
);
|
||||
// Set some preferences that are specific to macOS.
|
||||
titleBarStyle: "hiddenInset",
|
||||
vibrancy: "sidebar",
|
||||
transparent: isDarwin(),
|
||||
backgroundColor: isDarwin() ? "#80000000" : "#FFF",
|
||||
|
||||
// Hide the window until the contents load
|
||||
show: false
|
||||
});
|
||||
|
||||
// Set up event listeners to track changes in the window state.
|
||||
mainWindowState.manage(mainWindow);
|
||||
|
||||
|
||||
// Load the main app and open the index page.
|
||||
mainWindow.loadURL("hyperspace://hyperspace/app/");
|
||||
|
||||
|
||||
// Watch for a change in macOS's dark mode and reload the window to apply changes, as well as accent color
|
||||
if (isDarwin()) {
|
||||
systemPreferences.subscribeNotification('AppleInterfaceThemeChangedNotification', () => {
|
||||
if (mainWindow != null) {
|
||||
mainWindow.webContents.reload();
|
||||
systemPreferences.subscribeNotification(
|
||||
"AppleInterfaceThemeChangedNotification",
|
||||
() => {
|
||||
if (mainWindow != null) {
|
||||
mainWindow.webContents.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
systemPreferences.subscribeNotification('AppleColorPreferencesChangedNotification', () => {
|
||||
if (mainWindow != null) {
|
||||
mainWindow.webContents.reload();
|
||||
systemPreferences.subscribeNotification(
|
||||
"AppleColorPreferencesChangedNotification",
|
||||
() => {
|
||||
if (mainWindow != null) {
|
||||
mainWindow.webContents.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
// Only show the window when ready
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// Delete the window when closed
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Hijack any links with a blank target and open them in the default
|
||||
// browser instead of a new Electron window
|
||||
mainWindow.webContents.on('new-window', (event, url) => {
|
||||
mainWindow.webContents.on("new-window", (event, url) => {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
@ -199,18 +211,17 @@ function safelyGoTo(url) {
|
|||
* Create the menu bar and attach it to a window
|
||||
*/
|
||||
function createMenubar() {
|
||||
|
||||
// Create an instance of the Menu class
|
||||
let menu = Menu;
|
||||
|
||||
|
||||
// Create a menu bar template
|
||||
const menuBar = [
|
||||
{
|
||||
label: 'File',
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Window',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
label: "New Window",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click() {
|
||||
if (mainWindow == null) {
|
||||
registerProtocol();
|
||||
|
@ -219,98 +230,110 @@ function createMenubar() {
|
|||
}
|
||||
},
|
||||
{
|
||||
label: 'New Post',
|
||||
accelerator: 'Shift+CmdOrCtrl+N',
|
||||
label: "New Post",
|
||||
accelerator: "Shift+CmdOrCtrl+N",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#compose")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#compose");
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'pasteandmatchstyle' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectall' }
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "pasteandmatchstyle" },
|
||||
{ role: "delete" },
|
||||
{ role: "selectall" }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: 'Back',
|
||||
accelerator: 'CmdOrCtrl+[',
|
||||
label: "Back",
|
||||
accelerator: "CmdOrCtrl+[",
|
||||
click() {
|
||||
if (mainWindow != null && mainWindow.webContents.canGoBack()) {
|
||||
mainWindow.webContents.goBack()
|
||||
if (
|
||||
mainWindow != null &&
|
||||
mainWindow.webContents.canGoBack()
|
||||
) {
|
||||
mainWindow.webContents.goBack();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Forward',
|
||||
accelerator: 'CmdOrCtrl+]',
|
||||
label: "Forward",
|
||||
accelerator: "CmdOrCtrl+]",
|
||||
click() {
|
||||
if (mainWindow != null && mainWindow.webContents.canGoForward()) {
|
||||
mainWindow.webContents.goForward()
|
||||
if (
|
||||
mainWindow != null &&
|
||||
mainWindow.webContents.canGoForward()
|
||||
) {
|
||||
mainWindow.webContents.goForward();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ role: 'reload' },
|
||||
{ role: 'forcereload' },
|
||||
{ type: 'separator' },
|
||||
{ role: "reload" },
|
||||
{ role: "forcereload" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: 'Open Dev Tools',
|
||||
click () {
|
||||
label: "Open Dev Tools",
|
||||
click() {
|
||||
try {
|
||||
mainWindow.webContents.openDevTools();
|
||||
} catch (err) {
|
||||
console.error("Couldn't open dev tools: " + err);
|
||||
}
|
||||
|
||||
},
|
||||
accelerator: 'Shift+CmdOrCtrl+I'
|
||||
accelerator: "Shift+CmdOrCtrl+I"
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' }
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "Timelines",
|
||||
submenu: [
|
||||
{
|
||||
label: 'Home',
|
||||
label: "Home",
|
||||
accelerator: "CmdOrCtrl+0",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/home")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/home");
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Local',
|
||||
label: "Local",
|
||||
accelerator: "CmdOrCtrl+1",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/local")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/local");
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Public',
|
||||
label: "Public",
|
||||
accelerator: "CmdOrCtrl+2",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/public")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/public");
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Messages',
|
||||
label: "Messages",
|
||||
accelerator: "CmdOrCtrl+3",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/messages")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/messages");
|
||||
}
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Activity",
|
||||
accelerator: "Alt+CmdOrCtrl+A",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/activity");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -319,120 +342,138 @@ function createMenubar() {
|
|||
label: "Account",
|
||||
submenu: [
|
||||
{
|
||||
label: 'Notifications',
|
||||
label: "Notifications",
|
||||
accelerator: "Alt+CmdOrCtrl+N",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/notifications")
|
||||
safelyGoTo(
|
||||
"hyperspace://hyperspace/app/#/notifications"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Recommendations...',
|
||||
label: "Recommendations",
|
||||
accelerator: "Alt+CmdOrCtrl+R",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/recommended")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/recommended");
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: 'Edit Profile',
|
||||
label: "Edit Profile",
|
||||
accelerator: "Shift+CmdOrCtrl+P",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/you")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/you");
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Blocked Servers',
|
||||
label: "Follow Requests",
|
||||
accelerator: "Alt+CmdOrCtrl+E",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/requests");
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Blocked Servers",
|
||||
accelerator: "Shift+CmdOrCtrl+B",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/blocked")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/blocked");
|
||||
}
|
||||
},
|
||||
{ type: 'separator'},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: 'Switch Accounts...',
|
||||
label: "Switch Accounts...",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/welcome")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/welcome");
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
role: 'window',
|
||||
role: "window",
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'close' },
|
||||
{ type: 'separator' },
|
||||
{ role: "minimize" },
|
||||
{ role: "close" },
|
||||
{ type: "separator" }
|
||||
]
|
||||
},
|
||||
{
|
||||
role: 'help',
|
||||
role: "help",
|
||||
submenu: [
|
||||
{
|
||||
label: 'Hyperspace Docs',
|
||||
click () { require('electron').shell.openExternal('https://hyperspace.marquiskurt.net/docs/') }
|
||||
label: "Hyperspace Desktop Docs",
|
||||
click() {
|
||||
require("electron").shell.openExternal(
|
||||
"https://hyperspace.marquiskurt.net/docs/"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Report a Bug',
|
||||
click () { require('electron').shell.openExternal('https://github.com/hyperspacedev/hyperspace/issues') }
|
||||
label: "Report a Bug",
|
||||
click() {
|
||||
require("electron").shell.openExternal(
|
||||
"https://github.com/hyperspacedev/hyperspace/issues"
|
||||
);
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: 'Acknowledgements',
|
||||
click () { require('electron').shell.openExternal('https://github.com/hyperspacedev/hyperspace/blob/master/patreon.md') }
|
||||
label: "Acknowledgements",
|
||||
click() {
|
||||
require("electron").shell.openExternal(
|
||||
"https://github.com/hyperspacedev/hyperspace/blob/master/patreon.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (process.platform === "darwin") {
|
||||
menuBar.unshift({
|
||||
label: app.getName(),
|
||||
submenu: [
|
||||
{
|
||||
label: 'About Hyperspace',
|
||||
label: `About ${app.getName()}`,
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/about")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/about");
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Preferences...",
|
||||
accelerator: 'Cmd+,',
|
||||
accelerator: "Cmd+,",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/settings");
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideothers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' }
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideothers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" }
|
||||
]
|
||||
});
|
||||
|
||||
// Edit menu
|
||||
menuBar[2].submenu.push(
|
||||
{ type: 'separator' },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [
|
||||
{ role: 'startspeaking' },
|
||||
{ role: 'stopspeaking' }
|
||||
]
|
||||
label: "Speech",
|
||||
submenu: [{ role: "startspeaking" }, { role: "stopspeaking" }]
|
||||
}
|
||||
);
|
||||
|
||||
// Window menu
|
||||
menuBar[6].submenu = [
|
||||
{ role: 'close' },
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'front' }
|
||||
]
|
||||
{ role: "close" },
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
{ type: "separator" },
|
||||
{ role: "front" }
|
||||
];
|
||||
}
|
||||
|
||||
// Create the template for the menu and attach it to the application
|
||||
|
@ -441,21 +482,21 @@ function createMenubar() {
|
|||
}
|
||||
|
||||
// When the app is ready, create the window and menu bar
|
||||
app.on('ready', () => {
|
||||
app.on("ready", () => {
|
||||
registerProtocol();
|
||||
createWindow();
|
||||
createMenubar();
|
||||
});
|
||||
|
||||
// Standard quit behavior changes for macOS
|
||||
app.on('window-all-closed', () => {
|
||||
app.on("window-all-closed", () => {
|
||||
if (!isDarwin()) {
|
||||
app.quit()
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// When the app is activated, create the window and menu bar
|
||||
app.on('activate', () => {
|
||||
app.on("activate", () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
createMenubar();
|
||||
|
|
77
src/App.tsx
|
@ -8,19 +8,19 @@ import AboutPage from "./pages/About";
|
|||
import Settings from "./pages/Settings";
|
||||
import { getUserDefaultBool, getUserDefaultTheme } from "./utilities/settings";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import HomePage from "./pages/Home";
|
||||
import LocalPage from "./pages/Local";
|
||||
import PublicPage from "./pages/Public";
|
||||
import TimelinePage from "./pages/Timeline";
|
||||
import Conversation from "./pages/Conversation";
|
||||
import NotificationsPage from "./pages/Notifications";
|
||||
import AnnouncementsPage from "./pages/Announcements";
|
||||
import SearchPage from "./pages/Search";
|
||||
import Composer from "./pages/Compose";
|
||||
import WelcomePage from "./pages/Welcome";
|
||||
import MessagesPage from "./pages/Messages";
|
||||
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";
|
||||
|
@ -30,6 +30,7 @@ let theme = setHyperspaceTheme(getUserDefaultTheme());
|
|||
interface IAppState {
|
||||
theme: any;
|
||||
showLayout: boolean;
|
||||
avatarURL?: string;
|
||||
}
|
||||
|
||||
class App extends Component<any, IAppState> {
|
||||
|
@ -44,6 +45,7 @@ class App extends Component<any, IAppState> {
|
|||
showLayout:
|
||||
userLoggedIn() && !window.location.hash.includes("#/welcome")
|
||||
};
|
||||
this.setAvatarURL = this.setAvatarURL.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
|
@ -85,9 +87,13 @@ class App extends Component<any, IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
setAvatarURL(avatarURL: string) {
|
||||
this.setState({
|
||||
avatarURL
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
this.removeBodyBackground();
|
||||
|
||||
return (
|
||||
|
@ -95,12 +101,55 @@ class App extends Component<any, IAppState> {
|
|||
<CssBaseline />
|
||||
<Route path="/welcome" component={WelcomePage} />
|
||||
<div>
|
||||
{this.state.showLayout ? <AppLayout /> : null}
|
||||
<PrivateRoute exact path="/" component={HomePage} />
|
||||
<PrivateRoute path="/home" component={HomePage} />
|
||||
<PrivateRoute path="/local" component={LocalPage} />
|
||||
<PrivateRoute path="/public" component={PublicPage} />
|
||||
{this.state.showLayout ? (
|
||||
<AppLayout avatarURL={this.state.avatarURL} />
|
||||
) : 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 path="/messages" component={MessagesPage} />
|
||||
<PrivateRoute
|
||||
path="/announcements"
|
||||
component={AnnouncementsPage}
|
||||
/>
|
||||
<PrivateRoute
|
||||
path="/notifications"
|
||||
component={NotificationsPage}
|
||||
|
@ -116,13 +165,17 @@ class App extends Component<any, IAppState> {
|
|||
<PrivateRoute path="/search" component={SearchPage} />
|
||||
<PrivateRoute path="/settings" component={Settings} />
|
||||
<PrivateRoute path="/blocked" component={Blocked} />
|
||||
<PrivateRoute path="/you" component={You} />
|
||||
<PrivateRoute path="/you">
|
||||
<You onAvatarUpdate={this.setAvatarURL} />
|
||||
</PrivateRoute>
|
||||
<PrivateRoute path="/about" component={AboutPage} />
|
||||
<PrivateRoute path="/compose" component={Composer} />
|
||||
<PrivateRoute
|
||||
path="/recommended"
|
||||
component={RecommendationsPage}
|
||||
/>
|
||||
<PrivateRoute path="/requests" component={RequestsPage} />
|
||||
<PrivateRoute path="/activity" component={ActivityPage} />
|
||||
</div>
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
|
|
|
@ -35,7 +35,8 @@ export const styles = (theme: Theme) =>
|
|||
titleBarText: {
|
||||
fontSize: 12,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1
|
||||
paddingBottom: 1,
|
||||
color: theme.palette.getContrastText(theme.palette.primary.main)
|
||||
},
|
||||
appBar: {
|
||||
zIndex: 1000,
|
||||
|
@ -57,6 +58,10 @@ export const styles = (theme: Theme) =>
|
|||
display: "none"
|
||||
}
|
||||
},
|
||||
appBarBackButton: {
|
||||
marginLeft: -12,
|
||||
marginRight: 20
|
||||
},
|
||||
appBarTitle: {
|
||||
display: "none",
|
||||
[theme.breakpoints.up("md")]: {
|
||||
|
|
|
@ -28,9 +28,11 @@ 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";
|
||||
import AnnouncementIcon from "@material-ui/icons/Announcement";
|
||||
import MailIcon from "@material-ui/icons/Mail";
|
||||
import HomeIcon from "@material-ui/icons/Home";
|
||||
import DomainIcon from "@material-ui/icons/Domain";
|
||||
|
@ -41,6 +43,10 @@ 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 {
|
||||
|
@ -56,37 +62,89 @@ import { getConfig, getUserDefaultBool } from "../../utilities/settings";
|
|||
import {
|
||||
isDesktopApp,
|
||||
isDarwinApp,
|
||||
getElectronApp
|
||||
getElectronApp,
|
||||
linkablePath
|
||||
} from "../../utilities/desktop";
|
||||
import { Config } from "../../types/Config";
|
||||
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,
|
||||
|
@ -94,14 +152,20 @@ 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;
|
||||
|
@ -115,18 +179,25 @@ 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
|
||||
);
|
||||
|
@ -136,9 +207,14 @@ 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;
|
||||
|
@ -146,14 +222,17 @@ 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 = "";
|
||||
|
@ -212,34 +291,55 @@ 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) {
|
||||
window.location.href = isDesktopApp()
|
||||
? "hyperspace://hyperspace/app/index.html#/search?query=" + what
|
||||
: "/#/search?query=" + what;
|
||||
what = what.replace(/^#/g, "tag:");
|
||||
// console.log(what);
|
||||
window.location.href = linkablePath("/#/search?query=" + 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) => {
|
||||
|
@ -251,15 +351,20 @@ 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 });
|
||||
|
@ -270,6 +375,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the title bar.
|
||||
*/
|
||||
titlebar() {
|
||||
const { classes } = this.props;
|
||||
if (isDarwinApp()) {
|
||||
|
@ -282,7 +390,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
{this.state.brandName
|
||||
? this.state.brandName
|
||||
: "Hyperspace"}{" "}
|
||||
{this.state.developerMode ? "(Beta)" : null}
|
||||
Desktop {this.state.developerMode ? "(Beta)" : null}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
|
@ -290,6 +398,10 @@ 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.
|
||||
</Typography>
|
||||
</div>
|
||||
|
@ -297,6 +409,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the app drawer. On the desktop, this appears as a sidebar in larger layouts.
|
||||
*/
|
||||
appDrawer() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -399,6 +514,16 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
<Divider />
|
||||
<div className={classes.drawerDisplayMobile}>
|
||||
<ListSubheader>Account</ListSubheader>
|
||||
<LinkableListItem
|
||||
button
|
||||
key="announcements-mobile"
|
||||
to="/announcements"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<AnnouncementIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Announcements" />
|
||||
</LinkableListItem>
|
||||
<LinkableListItem
|
||||
button
|
||||
key="notifications-mobile"
|
||||
|
@ -430,7 +555,13 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
</LinkableListItem>
|
||||
<Divider />
|
||||
</div>
|
||||
<ListSubheader>More</ListSubheader>
|
||||
<ListSubheader>Community</ListSubheader>
|
||||
<LinkableListItem button key="activity" to="/activity">
|
||||
<ListItemIcon>
|
||||
<TrendingUpIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Activity" />
|
||||
</LinkableListItem>
|
||||
<LinkableListItem
|
||||
button
|
||||
key="recommended"
|
||||
|
@ -439,8 +570,10 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
<ListItemIcon>
|
||||
<GroupIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Who to follow" />
|
||||
<ListItemText primary="Recommended" />
|
||||
</LinkableListItem>
|
||||
<Divider />
|
||||
<ListSubheader>More</ListSubheader>
|
||||
<LinkableListItem button key="settings" to="/settings">
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
|
@ -458,6 +591,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the entire layout.
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -466,6 +602,18 @@ 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"
|
||||
|
@ -506,6 +654,14 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
</div>
|
||||
<div className={classes.appBarFlexGrow} />
|
||||
<div className={classes.appBarActionButtons}>
|
||||
<Tooltip title="Announcements">
|
||||
<LinkableIconButton
|
||||
to="/announcements"
|
||||
color="inherit"
|
||||
>
|
||||
<AnnouncementIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Notifications">
|
||||
<LinkableIconButton
|
||||
color="inherit"
|
||||
|
@ -544,7 +700,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
}
|
||||
alt="You"
|
||||
src={
|
||||
this.state.currentUser
|
||||
this.props.avatarURL
|
||||
? this.props.avatarURL
|
||||
: this.state.currentUser
|
||||
? this.state.currentUser
|
||||
.avatar_static
|
||||
: ""
|
||||
|
@ -567,6 +725,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
<div>
|
||||
<LinkableListItem
|
||||
button={true}
|
||||
onClick={this.toggleAcctMenu}
|
||||
to={`/profile/${
|
||||
this.state.currentUser
|
||||
? this.state.currentUser
|
||||
|
@ -578,8 +737,11 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
<Avatar
|
||||
alt="You"
|
||||
src={
|
||||
this.state
|
||||
.currentUser
|
||||
this.props.avatarURL
|
||||
? this.props
|
||||
.avatarURL
|
||||
: this.state
|
||||
.currentUser
|
||||
? this.state
|
||||
.currentUser
|
||||
.avatar_static
|
||||
|
@ -611,12 +773,23 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
<Divider />
|
||||
<LinkableListItem
|
||||
button={true}
|
||||
onClick={this.toggleAcctMenu}
|
||||
to={"/you"}
|
||||
>
|
||||
<ListItemText>
|
||||
Edit profile
|
||||
</ListItemText>
|
||||
</LinkableListItem>
|
||||
<LinkableListItem
|
||||
button={true}
|
||||
onClick={this.toggleAcctMenu}
|
||||
to={"/requests"}
|
||||
>
|
||||
<ListItemText>
|
||||
Manage follow requests
|
||||
</ListItemText>
|
||||
</LinkableListItem>
|
||||
<Divider />
|
||||
<LinkableListItem
|
||||
to={"/welcome"}
|
||||
button={true}
|
||||
|
@ -648,7 +821,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
variant="temporary"
|
||||
anchor={"left"}
|
||||
open={this.state.drawerOpenOnMobile}
|
||||
onClose={this.toggleDrawerOnMobile}
|
||||
onClick={this.toggleDrawerOnMobile}
|
||||
classes={{ paper: classes.drawerPaper }}
|
||||
>
|
||||
{this.appDrawer()}
|
||||
|
|
|
@ -7,6 +7,7 @@ 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 {
|
||||
|
@ -76,17 +77,25 @@ class AttachmentComponent extends Component<
|
|||
className={classes.mediaObject}
|
||||
/>
|
||||
);
|
||||
case "audio":
|
||||
return <AudioPlayer src={slide.url} id={slide.id} />;
|
||||
case "gifv":
|
||||
return (
|
||||
<img
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
src={slide.url}
|
||||
alt={slide.description ? slide.description : ""}
|
||||
title={slide.description ? slide.description : ""}
|
||||
className={classes.mediaObject}
|
||||
/>
|
||||
);
|
||||
case "unknown":
|
||||
return (
|
||||
<object data={slide.url} className={classes.mediaObject} />
|
||||
<object
|
||||
data={slide.url}
|
||||
className={classes.mediaObject}
|
||||
aria-label={`Slide: ${slide.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -106,33 +115,36 @@ class AttachmentComponent extends Component<
|
|||
);
|
||||
})}
|
||||
</SwipeableViews>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
{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 />
|
||||
<Typography variant="caption">
|
||||
{mediaItem.description
|
||||
? mediaItem.description
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
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`
|
||||
}
|
||||
});
|
|
@ -0,0 +1,140 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
Toolbar,
|
||||
IconButton,
|
||||
withStyles,
|
||||
LinearProgress,
|
||||
Tooltip
|
||||
} from "@material-ui/core";
|
||||
|
||||
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);
|
|
@ -0,0 +1,3 @@
|
|||
import AudioPlayer from "./AudioPlayer";
|
||||
|
||||
export default AudioPlayer;
|
|
@ -68,7 +68,10 @@ class ComposeMediaAttachment extends Component<
|
|||
) : attachment.type === "video" ? (
|
||||
<video autoPlay={false} src={attachment.url} />
|
||||
) : (
|
||||
<object data={attachment.url} />
|
||||
<object
|
||||
data={attachment.url}
|
||||
aria-label={`Attachment: ${attachment.id}`}
|
||||
/>
|
||||
)}
|
||||
<GridListTileBar
|
||||
classes={{ title: classes.attachmentBar }}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { Component } from "react";
|
||||
import { Picker, PickerProps, CustomEmoji } from "emoji-mart";
|
||||
import { Picker, PickerProps } from "emoji-mart";
|
||||
import "emoji-mart/css/emoji-mart.css";
|
||||
|
||||
interface IEmojiPickerProps extends PickerProps {
|
||||
|
@ -8,7 +8,12 @@ interface IEmojiPickerProps extends PickerProps {
|
|||
|
||||
export class EmojiPicker extends Component<IEmojiPickerProps, any> {
|
||||
retrieveFromLocal() {
|
||||
return JSON.parse(localStorage.getItem("emojis") as string);
|
||||
let emojiStorage = localStorage.getItem("emojis");
|
||||
if (emojiStorage != null) {
|
||||
return JSON.parse(emojiStorage as string);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -6,6 +6,28 @@ export const styles = (theme: Theme) =>
|
|||
marginTop: theme.spacing.unit,
|
||||
marginBottom: theme.spacing.unit
|
||||
},
|
||||
postHeaderContent: {
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
postHeaderTitle: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
color: theme.palette.text.secondary
|
||||
},
|
||||
postAuthorNameAndAccount: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
},
|
||||
postAuthorName: {
|
||||
whiteSpace: "nowrap",
|
||||
color: theme.palette.text.primary
|
||||
},
|
||||
postAuthorAccount: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
marginLeft: theme.spacing.unit * 0.5
|
||||
},
|
||||
postReblogChip: {
|
||||
color: theme.palette.common.white,
|
||||
"&:hover": {
|
||||
|
@ -14,6 +36,9 @@ export const styles = (theme: Theme) =>
|
|||
backgroundColor: theme.palette.secondary.main,
|
||||
marginBottom: theme.spacing.unit
|
||||
},
|
||||
postReblogMenu: {
|
||||
outline: "none"
|
||||
},
|
||||
postContent: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
|
@ -78,6 +103,12 @@ export const styles = (theme: Theme) =>
|
|||
paddingTop: theme.spacing.unit,
|
||||
paddingBottom: theme.spacing.unit
|
||||
},
|
||||
postReblogIcon: {
|
||||
marginBottom: theme.spacing.unit * -0.5,
|
||||
marginLeft: theme.spacing.unit * 0.5,
|
||||
marginRight: theme.spacing.unit * 0.5,
|
||||
color: theme.palette.text.primary
|
||||
},
|
||||
postAuthorEmoji: {
|
||||
height: theme.typography.fontSize,
|
||||
verticalAlign: "middle"
|
||||
|
|
|
@ -25,8 +25,7 @@ import {
|
|||
RadioGroup,
|
||||
Tooltip,
|
||||
Typography,
|
||||
withStyles,
|
||||
Zoom
|
||||
withStyles
|
||||
} from "@material-ui/core";
|
||||
import MoreVertIcon from "@material-ui/icons/MoreVert";
|
||||
import ReplyIcon from "@material-ui/icons/Reply";
|
||||
|
@ -101,6 +100,11 @@ 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 });
|
||||
}
|
||||
|
@ -119,7 +123,7 @@ export class Post extends React.Component<any, IPostState> {
|
|||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar("Couldn't delete post: " + err.name);
|
||||
console.log(err.message);
|
||||
console.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -396,24 +400,61 @@ export class Post extends React.Component<any, IPostState> {
|
|||
|
||||
getReblogAuthors(post: Status) {
|
||||
const { classes } = this.props;
|
||||
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
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={classes.postAuthorNameAndAccount}>
|
||||
<span
|
||||
className={classes.postAuthorName}
|
||||
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>
|
||||
</span>
|
||||
{reblogger ? (
|
||||
<div>
|
||||
<AutorenewIcon
|
||||
fontSize="small"
|
||||
className={classes.postReblogIcon}
|
||||
/>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyString(
|
||||
reblogger.display_name ||
|
||||
reblogger.username,
|
||||
emojis,
|
||||
classes.postAuthorEmoji
|
||||
)
|
||||
}}
|
||||
></span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
getMentions(mention: [Mention]) {
|
||||
|
@ -500,86 +541,63 @@ 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) {
|
||||
let url = "";
|
||||
if (post.reblog) {
|
||||
url = post.reblog.uri;
|
||||
} else {
|
||||
url = post.uri;
|
||||
}
|
||||
return url;
|
||||
return post.reblog ? post.reblog.uri : post.uri;
|
||||
}
|
||||
|
||||
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/favorited and update post state
|
||||
* @param post The post to un/favorite
|
||||
*/
|
||||
async toggleFavorited(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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
/**
|
||||
* Tell server a post has been un/reblogged and update post state
|
||||
* @param post The post to un/reblog
|
||||
*/
|
||||
async toggleReblogged(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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -624,234 +642,222 @@ export class Post extends React.Component<any, IPostState> {
|
|||
const { classes } = this.props;
|
||||
const post = this.state.post;
|
||||
return (
|
||||
<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
|
||||
}
|
||||
/>
|
||||
}
|
||||
action={
|
||||
<Tooltip title="More">
|
||||
<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.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
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography>
|
||||
{post.reblog
|
||||
? post.reblog.favourites_count
|
||||
: post.favourites_count}
|
||||
</Typography>
|
||||
<Tooltip title="Boost">
|
||||
<IconButton
|
||||
onClick={() => this.toggleReblogged(post)}
|
||||
>
|
||||
<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" }
|
||||
);
|
||||
}
|
||||
}}
|
||||
<Card
|
||||
className={classes.post}
|
||||
id={`post_${post.id}`}
|
||||
elevation={this.props.threadHeader ? 0 : 1}
|
||||
>
|
||||
<CardHeader
|
||||
classes={{
|
||||
content: classes.postHeaderContent,
|
||||
title: classes.postHeaderTitle
|
||||
}}
|
||||
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
|
||||
}
|
||||
/>
|
||||
{post.reblog ? (
|
||||
<div>
|
||||
<LinkableMenuItem
|
||||
to={`/profile/${post.reblog.account.id}`}
|
||||
>
|
||||
View author profile
|
||||
</LinkableMenuItem>
|
||||
<LinkableMenuItem
|
||||
to={`/profile/${post.account.id}`}
|
||||
>
|
||||
View reblogger profile
|
||||
</LinkableMenuItem>
|
||||
</div>
|
||||
) : (
|
||||
}
|
||||
action={
|
||||
<Tooltip title="More" placement="left">
|
||||
<IconButton
|
||||
key={`${post.id}_submenu`}
|
||||
id={`${post.id}_submenu`}
|
||||
onClick={() => this.togglePostMenu()}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
title={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.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
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography>
|
||||
{post.reblog
|
||||
? post.reblog.favourites_count
|
||||
: post.favourites_count}
|
||||
</Typography>
|
||||
<Tooltip title="Boost">
|
||||
<IconButton onClick={() => this.toggleReblogged(post)}>
|
||||
<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 profile
|
||||
View reblogger profile
|
||||
</LinkableMenuItem>
|
||||
)}
|
||||
<div className={classes.mobileOnly}>
|
||||
</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>
|
||||
<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"
|
||||
onClick={() => this.togglePostDeleteDialog()}
|
||||
>
|
||||
Open in Web
|
||||
Delete
|
||||
</MenuItem>
|
||||
</div>
|
||||
{this.state.myAccount &&
|
||||
post.account.id === this.state.myAccount ? (
|
||||
<div>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
this.togglePostDeleteDialog()
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</div>
|
||||
) : null}
|
||||
{this.showDeleteDialog()}
|
||||
</Menu>
|
||||
</Card>
|
||||
</Zoom>
|
||||
) : null}
|
||||
{this.showDeleteDialog()}
|
||||
</Menu>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,12 +22,6 @@ if (userLoggedIn()) {
|
|||
refreshUserAccountData();
|
||||
}
|
||||
|
||||
window.onstorage = (event: any) => {
|
||||
if (event.key == "account") {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
<HashRouter>
|
||||
<SnackbarProvider
|
||||
|
|
|
@ -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) =>
|
||||
userLoggedIn() ? (
|
||||
React.createElement(component, compProps)
|
||||
) : (
|
||||
<Redirect to="/welcome" />
|
||||
redir(
|
||||
React.createElement(render ? render : component, compProps)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
@ -93,5 +93,6 @@ export const PrivateRoute = (props: IPrivateRouteProps) => {
|
|||
};
|
||||
|
||||
interface IPrivateRouteProps extends RouteProps {
|
||||
component: any;
|
||||
component?: any;
|
||||
render?: any;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* A Generic dictionary with the value of a specific type.
|
||||
*
|
||||
* Keys _must_ be strings.
|
||||
*/
|
||||
export interface Dictionary<T> {
|
||||
[Key: string]: T;
|
||||
}
|
|
@ -23,13 +23,13 @@ import AssignmentIcon from "@material-ui/icons/Assignment";
|
|||
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
|
||||
import NetworkCheckIcon from "@material-ui/icons/NetworkCheck";
|
||||
import UpdateIcon from "@material-ui/icons/Update";
|
||||
import InfoIcon from "@material-ui/icons/Info";
|
||||
import NotesIcon from "@material-ui/icons/Notes";
|
||||
import CodeIcon from "@material-ui/icons/Code";
|
||||
import TicketAccountIcon from "mdi-material-ui/TicketAccount";
|
||||
import MastodonIcon from "mdi-material-ui/Mastodon";
|
||||
import EditIcon from "@material-ui/icons/Edit";
|
||||
import VpnKeyIcon from "@material-ui/icons/VpnKey";
|
||||
import BugReportIcon from "@material-ui/icons/BugReport";
|
||||
import ForumIcon from "@material-ui/icons/Forum";
|
||||
|
||||
import { styles } from "./PageLayout.styles";
|
||||
import { Instance } from "../types/Instance";
|
||||
|
@ -86,15 +86,23 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
let account = resp.data;
|
||||
this.setState({
|
||||
hyperspaceAdmin: account,
|
||||
hyperspaceAdminName: config.admin.name,
|
||||
hyperspaceAdminName: config.admin.name
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error(err.message);
|
||||
if (true) {
|
||||
this.setState({
|
||||
hyperspaceAdminName: `Could not find ${config.admin.name} on ${config.registration.defaultInstance}`
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({
|
||||
federation: config.federation,
|
||||
developer: config.developer
|
||||
? config.developer === "true"
|
||||
: false,
|
||||
developer: config.developer ?? false,
|
||||
versionNumber: config.version,
|
||||
brandName: config.branding
|
||||
? config.branding.name
|
||||
: "Hyperspace",
|
||||
brandName: config.branding.name ?? "Hyperspace",
|
||||
brandBg: config.branding.background,
|
||||
license: {
|
||||
name: config.license.name,
|
||||
|
@ -102,25 +110,24 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
},
|
||||
repository: config.repository
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error(err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
shouldRenderInstanceContact(): boolean {
|
||||
return this.state.instance?.version?.match(/Pleroma/) == null ?? false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
|
||||
return (
|
||||
<div className={classes.pageLayoutConstraints}>
|
||||
<Paper>
|
||||
<div
|
||||
className={classes.instanceHeaderPaper}
|
||||
style={{
|
||||
backgroundImage: `url("${
|
||||
this.state.brandBg ? this.state.brandBg : ""
|
||||
}")`
|
||||
backgroundImage: `url("${this.state.brandBg ??
|
||||
""}")`
|
||||
}}
|
||||
>
|
||||
<div className={classes.instanceToolbar}>
|
||||
|
@ -136,20 +143,38 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
</IconButton>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip title="Submit a bug report">
|
||||
<IconButton
|
||||
href={
|
||||
"https://github.com/hyperspacedev/hyperspace/issues/new?assignees=&labels=&template=bug_report.md&title=%5BBug%5D+Issue+title"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="inherit"
|
||||
>
|
||||
<BugReportIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Request a feature">
|
||||
<IconButton
|
||||
href={
|
||||
"https://github.com/hyperspacedev/hyperspace/issues/new?assignees=&labels=&template=feature_request.md&title=%5BRequest%5D+Request+title"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="inherit"
|
||||
>
|
||||
<ForumIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={classes.instanceHeaderText}>
|
||||
<Typography variant="h4" component="p">
|
||||
{this.state.brandName
|
||||
? this.state.brandName
|
||||
: "Hyperspace"}
|
||||
{this.state.brandName ?? "Hyperspace Desktop"}
|
||||
</Typography>
|
||||
<Typography>
|
||||
Version{" "}
|
||||
{`${
|
||||
this.state
|
||||
? this.state.versionNumber
|
||||
: "1.0.x"
|
||||
} ${
|
||||
{`${this.state.versionNumber ?? "1.1.x"} ${
|
||||
this.state &&
|
||||
this.state.brandName !== "Hyperspace"
|
||||
? "(Hyperspace-like)"
|
||||
|
@ -161,21 +186,24 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
<List className={classes.pageListConstraints}>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<LinkableAvatar
|
||||
to={`/profile/${
|
||||
this.state.hyperspaceAdmin
|
||||
? this.state.hyperspaceAdmin.id
|
||||
: 0
|
||||
}`}
|
||||
src={
|
||||
this.state.hyperspaceAdmin
|
||||
? this.state.hyperspaceAdmin
|
||||
.avatar_static
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<PersonIcon />
|
||||
</LinkableAvatar>
|
||||
{this.state.hyperspaceAdmin ? (
|
||||
<LinkableAvatar
|
||||
to={`/profile/${this.state
|
||||
.hyperspaceAdmin?.id ?? 0}`}
|
||||
src={
|
||||
this.state.hyperspaceAdmin
|
||||
?.avatar_static ?? ""
|
||||
}
|
||||
>
|
||||
<PersonIcon />
|
||||
</LinkableAvatar>
|
||||
) : (
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="App provider"
|
||||
|
@ -186,38 +214,34 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
this.state.hyperspaceAdmin
|
||||
.display_name ||
|
||||
"@" + this.state.hyperspaceAdmin.acct
|
||||
: "No provider set in config"
|
||||
: this.state.hyperspaceAdminName ??
|
||||
"No provider set in config"
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Send a post or message">
|
||||
<LinkableIconButton
|
||||
to={`/compose?visibility=${
|
||||
this.state.federated
|
||||
? "public"
|
||||
: "private"
|
||||
}&acct=${
|
||||
this.state.hyperspaceAdmin
|
||||
? this.state.hyperspaceAdmin
|
||||
.acct
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ChatIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="View profile">
|
||||
<LinkableIconButton
|
||||
to={`/profile/${
|
||||
this.state.hyperspaceAdmin
|
||||
? this.state.hyperspaceAdmin.id
|
||||
: 0
|
||||
}`}
|
||||
>
|
||||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
{this.state.hyperspaceAdmin ? (
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Send a post or message">
|
||||
<LinkableIconButton
|
||||
to={`/compose?visibility=${
|
||||
this.state.federated
|
||||
? "public"
|
||||
: "private"
|
||||
}&acct=${this.state.hyperspaceAdmin
|
||||
?.acct ?? ""}`}
|
||||
>
|
||||
<ChatIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="View profile">
|
||||
<LinkableIconButton
|
||||
to={`/profile/${this.state
|
||||
.hyperspaceAdmin?.id ?? 0}`}
|
||||
>
|
||||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
) : null}
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
|
@ -267,12 +291,8 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
<div
|
||||
className={classes.instanceHeaderPaper}
|
||||
style={{
|
||||
backgroundImage: `url("${
|
||||
this.state.instance &&
|
||||
this.state.instance.thumbnail
|
||||
? this.state.instance.thumbnail
|
||||
: ""
|
||||
}")`
|
||||
backgroundImage: `url("${this.state.instance
|
||||
?.thumbnail ?? ""}")`
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
|
@ -286,20 +306,16 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
</IconButton>
|
||||
<div className={classes.instanceHeaderText}>
|
||||
<Typography variant="h4" component="p">
|
||||
{this.state.instance
|
||||
? this.state.instance.uri
|
||||
: "Loading..."}
|
||||
{this.state.instance?.uri ?? "Loading..."}
|
||||
</Typography>
|
||||
<Typography>
|
||||
Server version{" "}
|
||||
{this.state.instance
|
||||
? this.state.instance.version
|
||||
: "x.x.x"}
|
||||
{this.state.instance?.version ?? "x.x.x"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<List className={classes.pageListConstraints}>
|
||||
{localStorage["isPleroma"] == "false" && (
|
||||
{this.shouldRenderInstanceContact() ? (
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<LinkableAvatar
|
||||
|
@ -342,19 +358,15 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
</Tooltip>
|
||||
<Tooltip title="View profile">
|
||||
<LinkableIconButton
|
||||
to={`/profile/${
|
||||
this.state.instance
|
||||
? this.state.instance
|
||||
.contact_account.id
|
||||
: 0
|
||||
}`}
|
||||
to={`/profile/${this.state.instance
|
||||
?.contact_account.id ?? 0}`}
|
||||
>
|
||||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
)}
|
||||
) : null}
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
|
@ -425,8 +437,8 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
secondary={
|
||||
this.state.federation &&
|
||||
this.state.federation.enablePublicTimeline
|
||||
? "This instance is federated."
|
||||
: "This instance is not federated."
|
||||
? "This copy of Hyperspace is federated."
|
||||
: "This copy of Hyperspace is not federated."
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
@ -441,8 +453,8 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
secondary={
|
||||
this.state.federation &&
|
||||
this.state.federation.universalLogin
|
||||
? "This instance supports universal login."
|
||||
: "This instance does not support universal login."
|
||||
? "This copy of Hyperspace supports universal login."
|
||||
: "This copy of Hyperspace does not support universal login."
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
@ -457,8 +469,8 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
secondary={
|
||||
this.state.federation &&
|
||||
this.state.federation.allowPublicPosts
|
||||
? "This instance allows posting publicly."
|
||||
: "This instance does not allow posting publicly."
|
||||
? "This copy of Hyperspace allows posting publicly."
|
||||
: "This copy of Hyperspace does not allow posting publicly."
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
@ -468,12 +480,12 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
<div className={classes.pageLayoutFooter}>
|
||||
<Typography variant="caption">
|
||||
(C) {new Date().getFullYear()}{" "}
|
||||
{this.state ? this.state.brandName : "Hyperspace"}{" "}
|
||||
developers. All rights reserved.
|
||||
{this.state.brandName ?? "Hyperspace"} developers. All
|
||||
rights reserved.
|
||||
</Typography>
|
||||
<Typography variant="caption" paragraph>
|
||||
{this.state ? this.state.brandName : "Hyperspace"} is
|
||||
made possible by the{" "}
|
||||
{this.state.brandName ?? "Hyperspace"} Desktop is made
|
||||
possible by the{" "}
|
||||
<Link
|
||||
href={"https://material-ui.com"}
|
||||
target="_blank"
|
||||
|
|
|
@ -0,0 +1,344 @@
|
|||
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.error(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.error(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);
|
|
@ -0,0 +1,211 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
ListSubheader,
|
||||
withStyles,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Paper,
|
||||
CardHeader,
|
||||
Avatar
|
||||
} from "@material-ui/core";
|
||||
|
||||
import { styles } from "./PageLayout.styles";
|
||||
import AnnouncementIcon from "@material-ui/icons/Announcement";
|
||||
|
||||
import Mastodon from "megalodon";
|
||||
import { Announcement } from "../types/Announcement";
|
||||
import { withSnackbar } from "notistack";
|
||||
import moment from "moment";
|
||||
|
||||
/**
|
||||
* The state interface for the notifications page.
|
||||
*/
|
||||
interface IAnnouncementsPageState {
|
||||
/**
|
||||
* The list of notifications, if it exists.
|
||||
*/
|
||||
announcements?: [Announcement];
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notifications page.
|
||||
*/
|
||||
class AnnouncementsPage extends Component<any, IAnnouncementsPageState> {
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform pre-mount tasks
|
||||
*/
|
||||
async componentWillMount() {
|
||||
try {
|
||||
// Get the list of notifications
|
||||
let resp: any = await this.client.get("/announcements");
|
||||
let announcements: [Announcement] = resp.data;
|
||||
|
||||
this.setState({
|
||||
announcements,
|
||||
viewIsLoading: false,
|
||||
viewDidLoad: true
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
viewDidLoad: true,
|
||||
viewIsLoading: false,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the announcements page.
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.pageLayoutConstraints}>
|
||||
{this.state.viewDidLoad ? (
|
||||
this.state.announcements &&
|
||||
this.state.announcements.length > 0 ? (
|
||||
<div>
|
||||
<ListSubheader>Current announcements</ListSubheader>
|
||||
{this.state.announcements.map(
|
||||
(announcement: Announcement) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar>
|
||||
<AnnouncementIcon />
|
||||
</Avatar>
|
||||
}
|
||||
title={`Published on ${moment(
|
||||
announcement.published_at
|
||||
).format(
|
||||
"MMMM Do, YYYY [at] hh:mmA"
|
||||
)}`}
|
||||
subheader={
|
||||
announcement.ends_at
|
||||
? `Expires ${moment(
|
||||
announcement.ends_at
|
||||
).format(
|
||||
"MMMM Do, YYYY"
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
></CardHeader>
|
||||
<CardContent>
|
||||
<Typography
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
announcement.content
|
||||
}}
|
||||
></Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={classes.pageLayoutEmptyTextConstraints}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<AnnouncementIcon
|
||||
color="action"
|
||||
style={{ fontSize: 48 }}
|
||||
/>
|
||||
<Typography variant="h6">
|
||||
No server announcements
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
There aren't any announcements in your
|
||||
community. Announcements that use the
|
||||
announcement feature on Mastodon will appear
|
||||
here.
|
||||
</Typography>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{this.state.viewDidError ? (
|
||||
<Paper className={classes.errorCard}>
|
||||
<Typography variant="h4">Bummer.</Typography>
|
||||
<Typography variant="h6">
|
||||
Something went wrong when loading announcements.
|
||||
</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(AnnouncementsPage));
|
|
@ -139,7 +139,7 @@ class Blocked extends Component<any, IBlockedState> {
|
|||
variant="outlined"
|
||||
fullWidth
|
||||
value={this.state.blockTextField}
|
||||
placeholder="mastodon.social"
|
||||
placeholder="mastodon.online"
|
||||
onChange={e => this.updateTextField(e.target.value)}
|
||||
></TextField>
|
||||
</DialogContent>
|
||||
|
|
|
@ -45,5 +45,25 @@ 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
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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 CameraAltIcon from "@material-ui/icons/CameraAlt";
|
||||
import AttachFileIcon from "@material-ui/icons/AttachFile";
|
||||
import TagFacesIcon from "@material-ui/icons/TagFaces";
|
||||
import HowToVoteIcon from "@material-ui/icons/HowToVote";
|
||||
import VisibilityIcon from "@material-ui/icons/Visibility";
|
||||
|
@ -39,55 +39,140 @@ 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 } from "../utilities/settings";
|
||||
import {
|
||||
getUserDefaultVisibility,
|
||||
getConfig,
|
||||
getUserDefaultBool
|
||||
} from "../utilities/settings";
|
||||
import { draftExists, writeDraft, loadDraft } from "../utilities/compose";
|
||||
|
||||
/**
|
||||
* 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: 500,
|
||||
remainingChars: getUserDefaultBool("imposeCharacterLimit")
|
||||
? 500
|
||||
: 9999999999999,
|
||||
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,
|
||||
|
@ -95,11 +180,43 @@ class Composer extends Component<any, IComposerState> {
|
|||
acct: state.acct,
|
||||
visibility: state.visibility,
|
||||
text,
|
||||
remainingChars: 500 - text.length
|
||||
remainingChars: getUserDefaultBool("imposeCharacterLimit")
|
||||
? 500 - text.length
|
||||
: 99999999
|
||||
});
|
||||
});
|
||||
|
||||
// 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}: ` : "";
|
||||
|
@ -108,10 +225,42 @@ class Composer extends Component<any, IComposerState> {
|
|||
acct: state.acct,
|
||||
visibility: state.visibility,
|
||||
text,
|
||||
remainingChars: 500 - text.length
|
||||
remainingChars: getUserDefaultBool("imposeCharacterLimit")
|
||||
? 500 - text.length
|
||||
: 99999999
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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") {
|
||||
|
@ -122,6 +271,11 @@ 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 = "";
|
||||
|
@ -144,52 +298,44 @@ 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: 500 - text.length });
|
||||
this.setState({
|
||||
text,
|
||||
remainingChars: getUserDefaultBool("imposeCharacterLimit")
|
||||
? 500 - text.length
|
||||
: 99999999
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
|
||||
uploadMedia() {
|
||||
/**
|
||||
* Open a file dialog to let the user choose files to upload to the server and then upload them.
|
||||
*/
|
||||
promptMediaDialog() {
|
||||
filedialog({
|
||||
multiple: false,
|
||||
accept: "image/*, video/*"
|
||||
accept: ".jpeg,.jpg,.png,.gif,.webm,.mp4,.mov,.ogg,.wav,.mp3,.flac"
|
||||
})
|
||||
.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" }
|
||||
);
|
||||
});
|
||||
})
|
||||
.then((media: FileList) => this.uploadMedia(media))
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
|
||||
variant: "error"
|
||||
|
@ -198,16 +344,68 @@ 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) {
|
||||
this.state.attachments.map((attachment: Attachment) => {
|
||||
ids.push(attachment.id);
|
||||
});
|
||||
return this.state.attachments.map(
|
||||
(attachment: Attachment) => attachment.id
|
||||
);
|
||||
}
|
||||
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) {
|
||||
|
@ -220,6 +418,10 @@ 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) {
|
||||
|
@ -233,6 +435,10 @@ 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;
|
||||
|
@ -249,6 +455,9 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty poll.
|
||||
*/
|
||||
createPoll() {
|
||||
if (this.state.poll === undefined) {
|
||||
let expiration = new Date();
|
||||
|
@ -268,6 +477,9 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new poll item into the poll.
|
||||
*/
|
||||
addPollItem() {
|
||||
if (
|
||||
this.state.poll !== undefined &&
|
||||
|
@ -282,7 +494,7 @@ class Composer extends Component<any, IComposerState> {
|
|||
this.setState({
|
||||
poll: poll
|
||||
});
|
||||
} else if (this.state.poll && this.state.poll.options.length == 4) {
|
||||
} else if (this.state.poll && this.state.poll.options.length === 4) {
|
||||
this.props.enqueueSnackbar(
|
||||
"You've reached the options limit in your poll.",
|
||||
{ variant: "error" }
|
||||
|
@ -290,6 +502,11 @@ 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;
|
||||
|
@ -307,6 +524,10 @@ 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 &&
|
||||
|
@ -333,13 +554,16 @@ 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 });
|
||||
|
@ -353,25 +577,38 @@ 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,
|
||||
|
@ -388,31 +625,52 @@ 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.log(err.message);
|
||||
console.error(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
|
||||
|
@ -469,18 +727,28 @@ class Composer extends Component<any, IComposerState> {
|
|||
}}
|
||||
value={this.state.text}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={
|
||||
this.state.remainingChars <= 100
|
||||
? classes.charsReachingLimit
|
||||
: null
|
||||
}
|
||||
>
|
||||
{`${this.state.remainingChars} character${
|
||||
this.state.remainingChars === 1 ? "" : "s"
|
||||
} remaining`}
|
||||
</Typography>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{this.state.attachments &&
|
||||
this.state.attachments.length > 0 ? (
|
||||
<div className={classes.composeAttachmentArea}>
|
||||
|
@ -605,13 +873,13 @@ class Composer extends Component<any, IComposerState> {
|
|||
) : null}
|
||||
</DialogContent>
|
||||
<Toolbar className={classes.dialogActions}>
|
||||
<Tooltip title="Add photos or videos">
|
||||
<Tooltip title="Add photos, videos, or audio">
|
||||
<IconButton
|
||||
disabled={this.state.poll !== undefined}
|
||||
onClick={() => this.uploadMedia()}
|
||||
onClick={() => this.promptMediaDialog()}
|
||||
id="compose-media"
|
||||
>
|
||||
<CameraAltIcon />
|
||||
<AttachFileIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Insert emoji">
|
||||
|
@ -694,6 +962,21 @@ 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
|
||||
|
|
|
@ -14,6 +14,8 @@ 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 {
|
||||
|
@ -23,8 +25,14 @@ 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;
|
||||
|
@ -34,7 +42,8 @@ class HomePage extends Component<any, IHomePageState> {
|
|||
|
||||
this.state = {
|
||||
viewIsLoading: true,
|
||||
backlogPosts: null
|
||||
backlogPosts: null,
|
||||
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||
};
|
||||
|
||||
this.client = new Mastodon(
|
||||
|
@ -154,9 +163,11 @@ class HomePage extends Component<any, IHomePageState> {
|
|||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
|
||||
const containerClasses = `${classes.pageLayoutMaxConstraints}${
|
||||
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
|
||||
}`;
|
||||
return (
|
||||
<div className={classes.pageLayoutMaxConstraints}>
|
||||
<div className={containerClasses}>
|
||||
{this.state.backlogPosts ? (
|
||||
<div className={classes.pageTopChipContainer}>
|
||||
<div className={classes.pageTopChips}>
|
||||
|
@ -185,15 +196,46 @@ class HomePage extends Component<any, IHomePageState> {
|
|||
) : null}
|
||||
{this.state.posts ? (
|
||||
<div>
|
||||
{this.state.posts.map((post: Status) => {
|
||||
return (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
)}
|
||||
<br />
|
||||
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
||||
<div
|
||||
|
|
|
@ -14,6 +14,8 @@ 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 {
|
||||
|
@ -23,8 +25,14 @@ 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;
|
||||
|
@ -34,7 +42,8 @@ class LocalPage extends Component<any, ILocalPageState> {
|
|||
|
||||
this.state = {
|
||||
viewIsLoading: true,
|
||||
backlogPosts: null
|
||||
backlogPosts: null,
|
||||
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||
};
|
||||
|
||||
this.client = new Mastodon(
|
||||
|
@ -155,9 +164,12 @@ class LocalPage extends Component<any, ILocalPageState> {
|
|||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
const containerClasses = `${classes.pageLayoutMaxConstraints}${
|
||||
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={classes.pageLayoutMaxConstraints}>
|
||||
<div className={containerClasses}>
|
||||
{this.state.backlogPosts ? (
|
||||
<div className={classes.pageTopChipContainer}>
|
||||
<div className={classes.pageTopChips}>
|
||||
|
@ -186,15 +198,50 @@ class LocalPage extends Component<any, ILocalPageState> {
|
|||
) : null}
|
||||
{this.state.posts ? (
|
||||
<div>
|
||||
{this.state.posts.map((post: Status) => {
|
||||
return (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
)}
|
||||
<br />
|
||||
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
||||
<div
|
||||
|
|
|
@ -8,12 +8,13 @@ import {
|
|||
ListItemText,
|
||||
CircularProgress,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Typography
|
||||
} 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";
|
||||
|
@ -70,67 +71,82 @@ 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}>
|
||||
<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 />
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{this.state.viewIsLoading ? (
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
|
@ -17,72 +18,176 @@ import {
|
|||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Menu,
|
||||
MenuItem
|
||||
} 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 PersonRemoveIcon from "mdi-material-ui/AccountMinus";
|
||||
import DeleteIcon from "@material-ui/icons/Delete";
|
||||
import { styles } from "./PageLayout.styles";
|
||||
import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
|
||||
import {
|
||||
LinkableIconButton,
|
||||
LinkableAvatar,
|
||||
LinkableMenuItem
|
||||
} 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";
|
||||
import { linkablePath } from "../utilities/desktop";
|
||||
|
||||
/**
|
||||
* The state interface for the notifications page.
|
||||
*/
|
||||
interface INotificationsPageState {
|
||||
/**
|
||||
* The list of notifications, if it exists.
|
||||
*/
|
||||
notifications?: [Notification];
|
||||
|
||||
/**
|
||||
* The relationships with all notification accounts
|
||||
*/
|
||||
relationships: { [id: string]: Relationship };
|
||||
|
||||
/**
|
||||
* 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
|
||||
deleteDialogOpen: false,
|
||||
mobileMenuOpen: {},
|
||||
relationships: {}
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.client
|
||||
.get("/notifications")
|
||||
.then((resp: any) => {
|
||||
let notifications: [Notification] = resp.data;
|
||||
this.setState({
|
||||
notifications,
|
||||
viewIsLoading: false,
|
||||
viewDidLoad: true
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.setState({
|
||||
viewDidLoad: true,
|
||||
viewIsLoading: false,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: err.message
|
||||
});
|
||||
/**
|
||||
* Perform pre-mount tasks
|
||||
*/
|
||||
async componentWillMount() {
|
||||
try {
|
||||
// Get the list of notifications
|
||||
let resp: any = await this.client.get("/notifications");
|
||||
let notifications: [Notification] = resp.data;
|
||||
|
||||
// initialize all menus as closed
|
||||
let notifMenus: Dictionary<boolean> = {};
|
||||
notifications.forEach(
|
||||
(n: Notification) => (notifMenus[n.id] = false)
|
||||
);
|
||||
|
||||
// compile list of all notification account ids
|
||||
let accountIds: string[] = [];
|
||||
notifications.forEach(notif => {
|
||||
if (!accountIds.includes(notif.account.id)) {
|
||||
accountIds.push(notif.account.id);
|
||||
}
|
||||
});
|
||||
|
||||
// store relationships in id-relationship pairs
|
||||
resp = await this.client.get(`/accounts/relationships`, {
|
||||
id: accountIds
|
||||
});
|
||||
let relationships: Dictionary<Relationship> = {};
|
||||
resp.data.forEach((relation: Relationship) => {
|
||||
relationships[relation.id] = relation;
|
||||
});
|
||||
|
||||
this.setState({
|
||||
notifications,
|
||||
relationships,
|
||||
viewIsLoading: false,
|
||||
viewDidLoad: true,
|
||||
mobileMenuOpen: notifMenus
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
viewDidLoad: true,
|
||||
viewIsLoading: false,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
|
||||
|
@ -95,10 +200,25 @@ 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;
|
||||
|
@ -108,6 +228,10 @@ 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`)
|
||||
|
@ -139,6 +263,9 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all notifications from the server.
|
||||
*/
|
||||
removeAllNotifications() {
|
||||
this.client
|
||||
.post("/notifications/clear")
|
||||
|
@ -156,6 +283,10 @@ 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 = "";
|
||||
|
@ -228,6 +359,108 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
{this.getActions(notif)}
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un/follow an account and update relationships state.
|
||||
* @param acct The account to un/follow, if possible
|
||||
*/
|
||||
async toggleFollow(acct: Account) {
|
||||
let relationships = this.state.relationships;
|
||||
if (!relationships[acct.id].following) {
|
||||
try {
|
||||
let resp: any = await this.client.post(
|
||||
`/accounts/${acct.id}/follow`
|
||||
);
|
||||
relationships[acct.id] = resp.data;
|
||||
this.setState({ relationships });
|
||||
this.props.enqueueSnackbar(
|
||||
"You are now following this account."
|
||||
);
|
||||
} catch (e) {
|
||||
this.props.enqueueSnackbar(
|
||||
"Couldn't follow acccount: " + e.name
|
||||
);
|
||||
console.error(e.message);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
let resp: any = await this.client.post(
|
||||
`/accounts/${acct.id}/unfollow`
|
||||
);
|
||||
relationships[acct.id] = resp.data;
|
||||
this.setState({ relationships });
|
||||
this.props.enqueueSnackbar(
|
||||
"You are no longer following this account."
|
||||
);
|
||||
} catch (e) {
|
||||
this.props.enqueueSnackbar(
|
||||
"Couldn't unfollow acccount: " + e.name
|
||||
);
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.toggleFollow(notif.account)}
|
||||
>
|
||||
{this.state.relationships[notif.account.id]
|
||||
.following
|
||||
? "Unfollow"
|
||||
: "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">
|
||||
|
@ -237,15 +470,28 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Follow account">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
this.followMember(notif.account)
|
||||
}
|
||||
>
|
||||
<PersonAddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!this.state.relationships[notif.account.id]
|
||||
.following ? (
|
||||
<Tooltip title="Follow account">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
this.toggleFollow(notif.account)
|
||||
}
|
||||
>
|
||||
<PersonAddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Unfollow account">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
this.toggleFollow(notif.account)
|
||||
}
|
||||
>
|
||||
<PersonRemoveIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
) : notif.status ? (
|
||||
<span>
|
||||
|
@ -285,28 +531,14 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
|
@ -337,12 +569,20 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
</Paper>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.pageLayoutEmptyTextConstraints}>
|
||||
<Typography variant="h4">All clear!</Typography>
|
||||
<div
|
||||
className={classes.pageLayoutEmptyTextConstraints}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<NotificationsIcon
|
||||
color="action"
|
||||
style={{ fontSize: 48 }}
|
||||
/>
|
||||
<Typography variant="h6">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}
|
||||
|
@ -372,6 +612,19 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
<span />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classes.pageLayoutEmptyTextConstraints}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<Typography>
|
||||
<Link
|
||||
href={linkablePath("/#/settings#sp-notifications")}
|
||||
>
|
||||
Manage notification settings
|
||||
</Link>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={this.state.deleteDialogOpen}
|
||||
onClose={() => this.toggleDeleteDialog()}
|
||||
|
|
|
@ -323,5 +323,21 @@ 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
|
||||
}
|
||||
});
|
||||
|
|
|
@ -3,7 +3,6 @@ import {
|
|||
withStyles,
|
||||
Typography,
|
||||
Avatar,
|
||||
Divider,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
|
@ -25,6 +24,8 @@ 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";
|
||||
|
@ -44,6 +45,7 @@ interface IProfilePageState {
|
|||
viewDidError?: boolean;
|
||||
viewDidErrorCode?: string;
|
||||
blockDialogOpen: boolean;
|
||||
isMasonryLayout?: boolean;
|
||||
}
|
||||
|
||||
class ProfilePage extends Component<any, IProfilePageState> {
|
||||
|
@ -59,7 +61,8 @@ class ProfilePage extends Component<any, IProfilePageState> {
|
|||
|
||||
this.state = {
|
||||
viewIsLoading: true,
|
||||
blockDialogOpen: false
|
||||
blockDialogOpen: false,
|
||||
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -305,8 +308,36 @@ 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}>
|
||||
|
@ -464,7 +495,7 @@ class ProfilePage extends Component<any, IProfilePageState> {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.pageContentLayoutConstraints}>
|
||||
<div className={containerClasses}>
|
||||
{this.state.viewDidError ? (
|
||||
<Paper className={classes.errorCard}>
|
||||
<Typography variant="h4">Bummer.</Typography>
|
||||
|
@ -482,15 +513,7 @@ class ProfilePage extends Component<any, IProfilePageState> {
|
|||
)}
|
||||
{this.state.posts ? (
|
||||
<div>
|
||||
{this.state.posts.map((post: Status) => {
|
||||
return (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{this.renderPosts(this.state.posts)}
|
||||
<br />
|
||||
{this.state.viewDidLoad &&
|
||||
!this.state.viewDidError ? (
|
||||
|
|
|
@ -14,6 +14,8 @@ 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 {
|
||||
|
@ -23,8 +25,14 @@ 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;
|
||||
|
@ -34,7 +42,8 @@ class PublicPage extends Component<any, IPublicPageState> {
|
|||
|
||||
this.state = {
|
||||
viewIsLoading: true,
|
||||
backlogPosts: null
|
||||
backlogPosts: null,
|
||||
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||
};
|
||||
|
||||
this.client = new Mastodon(
|
||||
|
@ -154,9 +163,12 @@ class PublicPage extends Component<any, IPublicPageState> {
|
|||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
const containerClasses = `${classes.pageLayoutMaxConstraints}${
|
||||
this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={classes.pageLayoutMaxConstraints}>
|
||||
<div className={containerClasses}>
|
||||
{this.state.backlogPosts ? (
|
||||
<div className={classes.pageTopChipContainer}>
|
||||
<div className={classes.pageTopChips}>
|
||||
|
@ -185,15 +197,50 @@ class PublicPage extends Component<any, IPublicPageState> {
|
|||
) : null}
|
||||
{this.state.posts ? (
|
||||
<div>
|
||||
{this.state.posts.map((post: Status) => {
|
||||
return (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
)}
|
||||
<br />
|
||||
{this.state.viewDidLoad && !this.state.viewDidError ? (
|
||||
<div
|
||||
|
|
|
@ -6,25 +6,22 @@ import {
|
|||
ListItem,
|
||||
Paper,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
ListItemSecondaryAction,
|
||||
ListItemAvatar,
|
||||
ListSubheader,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Divider,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Link
|
||||
} 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;
|
||||
|
@ -35,7 +32,6 @@ interface IRecommendationsPageState {
|
|||
viewDidLoad?: boolean;
|
||||
viewDidError?: Boolean;
|
||||
viewDidErrorCode?: string;
|
||||
requestedFollows?: [Account];
|
||||
followSuggestions?: [Account];
|
||||
}
|
||||
|
||||
|
@ -57,21 +53,6 @@ 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) => {
|
||||
|
@ -125,110 +106,6 @@ 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 (
|
||||
|
@ -295,23 +172,6 @@ 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()
|
||||
|
@ -320,23 +180,35 @@ class RecommendationsPage extends Component<
|
|||
className={
|
||||
classes.pageLayoutEmptyTextConstraints
|
||||
}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<Typography variant="h5">
|
||||
<GroupIcon
|
||||
color="action"
|
||||
style={{ fontSize: 48 }}
|
||||
/>
|
||||
<Typography variant="h6">
|
||||
We don't have any suggestions for you.
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
Why not interact with the fediverse a bit by
|
||||
creating a new post?
|
||||
Take a look around the fediverse or check
|
||||
out the Activity page for more.
|
||||
</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 this timeline.
|
||||
Something went wrong when loading recommendations.
|
||||
</Typography>
|
||||
<Typography>
|
||||
{this.state.viewDidErrorCode
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
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));
|
|
@ -6,7 +6,6 @@ import {
|
|||
ListSubheader,
|
||||
ListItemSecondaryAction,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
Paper,
|
||||
withStyles,
|
||||
Typography,
|
||||
|
@ -14,18 +13,20 @@ import {
|
|||
Tooltip,
|
||||
IconButton
|
||||
} from "@material-ui/core";
|
||||
import PersonIcon from "@material-ui/icons/Person";
|
||||
|
||||
import AssignmentIndIcon from "@material-ui/icons/AssignmentInd";
|
||||
import PersonAddIcon from "@material-ui/icons/PersonAdd";
|
||||
import { styles } from "./PageLayout.styles";
|
||||
import { LinkableIconButton, LinkableAvatar } from "../interfaces/overrides";
|
||||
import Mastodon from "megalodon";
|
||||
import { parse as parseParams, ParsedQuery } from "query-string";
|
||||
import { parse as parseParams } from "query-string";
|
||||
import { Results } from "../types/Search";
|
||||
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;
|
||||
|
@ -36,6 +37,7 @@ interface ISearchPageState {
|
|||
viewDidLoad?: boolean;
|
||||
viewDidError?: boolean;
|
||||
viewDidErrorCode?: string;
|
||||
isMasonryLayout: boolean;
|
||||
}
|
||||
|
||||
class SearchPage extends Component<any, ISearchPageState> {
|
||||
|
@ -54,7 +56,8 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
this.state = {
|
||||
viewIsLoading: true,
|
||||
query: searchParams.query,
|
||||
type: searchParams.type
|
||||
type: searchParams.type,
|
||||
isMasonryLayout: getUserDefaultBool("isMasonryLayout")
|
||||
};
|
||||
|
||||
if (searchParams.type === "tag") {
|
||||
|
@ -81,35 +84,23 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
}
|
||||
}
|
||||
|
||||
runQueryCheck(newLocation?: string): ParsedQuery {
|
||||
let searchParams = "";
|
||||
if (newLocation !== undefined && typeof newLocation === "string") {
|
||||
searchParams = newLocation.replace("#/search", "");
|
||||
} else {
|
||||
searchParams = location.hash.replace("#/search", "");
|
||||
}
|
||||
return parseParams(searchParams);
|
||||
}
|
||||
|
||||
getQueryAndType(props: any) {
|
||||
let newSearch = this.runQueryCheck(props.location);
|
||||
let query: string | string[];
|
||||
const { search }: { search: string } = props.location;
|
||||
let newSearch = parseParams(search);
|
||||
let query: string | string[] = "";
|
||||
let type;
|
||||
|
||||
if (newSearch.query) {
|
||||
if (newSearch.query.toString().startsWith("tag:")) {
|
||||
if (search.includes("tag:")) {
|
||||
type = "tag";
|
||||
query = newSearch.query.toString().replace("tag:", "");
|
||||
} else {
|
||||
query = newSearch.query;
|
||||
}
|
||||
} else {
|
||||
query = "";
|
||||
query = newSearch.query.toString().replace("tag:", "");
|
||||
}
|
||||
|
||||
if (newSearch.type && newSearch.type !== undefined) {
|
||||
type = newSearch.type;
|
||||
}
|
||||
|
||||
return {
|
||||
query: query,
|
||||
type: type
|
||||
|
@ -152,14 +143,11 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
let tagResults: [Status] = resp.data;
|
||||
this.setState({
|
||||
tagResults,
|
||||
viewDidLoad: true,
|
||||
viewIsLoading: false
|
||||
viewDidLoad: true
|
||||
});
|
||||
console.log(this.state.tagResults);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.setState({
|
||||
viewIsLoading: false,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: err.message
|
||||
});
|
||||
|
@ -168,6 +156,9 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
`Couldn't search for posts with tag ${this.state.query}: ${err.name}`,
|
||||
{ variant: "error" }
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ viewIsLoading: false });
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -195,7 +186,7 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
showAllAccountsFromQuery() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div className={classes.pageLayoutConstraints}>
|
||||
<ListSubheader>Accounts</ListSubheader>
|
||||
|
||||
{this.state.results &&
|
||||
|
@ -260,22 +251,44 @@ 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>
|
||||
<div className={containerClasses}>
|
||||
<ListSubheader>Posts</ListSubheader>
|
||||
{this.state.results ? (
|
||||
this.state.results.statuses.length > 0 ? (
|
||||
this.state.results.statuses.map((post: Status) => {
|
||||
return (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
);
|
||||
})
|
||||
this.renderPosts(this.state.results.statuses)
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
|
@ -291,20 +304,15 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
|
||||
showAllPostsWithTag() {
|
||||
const { classes } = this.props;
|
||||
const containerClasses = `${classes.pageLayoutMaxConstraints} ${
|
||||
this.state.isMasonryLayout ? classes.pageLayoutMasonry : ""
|
||||
}`;
|
||||
return (
|
||||
<div>
|
||||
<div className={containerClasses}>
|
||||
<ListSubheader>Tagged posts</ListSubheader>
|
||||
{this.state.tagResults ? (
|
||||
this.state.tagResults.length > 0 ? (
|
||||
this.state.tagResults.map((post: Status) => {
|
||||
return (
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
);
|
||||
})
|
||||
this.renderPosts(this.state.tagResults)
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
|
@ -321,7 +329,7 @@ class SearchPage extends Component<any, ISearchPageState> {
|
|||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.pageLayoutConstraints}>
|
||||
<div>
|
||||
{this.state.type && this.state.type === "tag" ? (
|
||||
this.showAllPostsWithTag()
|
||||
) : (
|
||||
|
|
|
@ -38,19 +38,15 @@ import {
|
|||
} from "../utilities/settings";
|
||||
import {
|
||||
canSendNotifications,
|
||||
browserSupportsNotificationRequests
|
||||
browserSupportsNotificationRequests,
|
||||
getNotificationRequestPermission
|
||||
} from "../utilities/notifications";
|
||||
import { themes, defaultTheme } from "../types/HyperspaceTheme";
|
||||
import ThemePreview from "../components/ThemePreview";
|
||||
import {
|
||||
setHyperspaceTheme,
|
||||
getHyperspaceTheme,
|
||||
getDarkModeFromSystem
|
||||
} from "../utilities/themes";
|
||||
import { setHyperspaceTheme, getHyperspaceTheme } from "../utilities/themes";
|
||||
import { Visibility } from "../types/Visibility";
|
||||
import { LinkableButton, LinkableIconButton } from "../interfaces/overrides";
|
||||
import { LinkableIconButton } from "../interfaces/overrides";
|
||||
|
||||
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
|
||||
import DevicesIcon from "@material-ui/icons/Devices";
|
||||
import Brightness3Icon from "@material-ui/icons/Brightness3";
|
||||
import PaletteIcon from "@material-ui/icons/Palette";
|
||||
|
@ -62,10 +58,15 @@ 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";
|
||||
import { isDarwinApp } from "../utilities/desktop";
|
||||
import { withSnackbar } from "notistack";
|
||||
|
||||
interface ISettingsState {
|
||||
darkModeEnabled: boolean;
|
||||
|
@ -82,6 +83,9 @@ interface ISettingsState {
|
|||
brandName: string;
|
||||
federated: boolean;
|
||||
currentUser?: Account;
|
||||
imposeCharacterLimit: boolean;
|
||||
masonryLayout?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
}
|
||||
|
||||
class SettingsPage extends Component<any, ISettingsState> {
|
||||
|
@ -112,7 +116,10 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
setHyperspaceTheme(defaultTheme),
|
||||
defaultVisibility: getUserDefaultVisibility() || "public",
|
||||
brandName: "Hyperspace",
|
||||
federated: true
|
||||
federated: true,
|
||||
imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit"),
|
||||
masonryLayout: getUserDefaultBool("isMasonryLayout"),
|
||||
infiniteScroll: getUserDefaultBool("isInfiniteScroll")
|
||||
};
|
||||
|
||||
this.toggleDarkMode = this.toggleDarkMode.bind(this);
|
||||
|
@ -121,11 +128,22 @@ 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);
|
||||
}
|
||||
|
||||
componentWillReceiveProps() {
|
||||
const path = window.location.hash.split("#");
|
||||
const lastPath = document.getElementById(path[path.length - 1]);
|
||||
if (lastPath !== null) {
|
||||
lastPath.scrollIntoView();
|
||||
window.scrollBy(0, -64);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
getConfig()
|
||||
.then((config: any) => {
|
||||
|
@ -155,13 +173,20 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
console.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const path = window.location.hash.split("#");
|
||||
const lastPath = document.getElementById(path[path.length - 1]);
|
||||
if (lastPath !== null) {
|
||||
lastPath.scrollIntoView();
|
||||
window.scrollBy(0, -64);
|
||||
}
|
||||
}
|
||||
|
||||
getFederatedStatus() {
|
||||
getConfig().then((result: any) => {
|
||||
if (result !== undefined) {
|
||||
let config: Config = result;
|
||||
console.log(!config.federation.allowPublicPosts);
|
||||
// console.log(!config.federation.allowPublicPosts);
|
||||
this.setState({
|
||||
federated: config.federation.allowPublicPosts
|
||||
});
|
||||
|
@ -186,14 +211,47 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the setting for enabling/disabling push notifications.
|
||||
*
|
||||
* If the notification permission wasn't set yet (i.e., `Notification.permission`)
|
||||
* is in `"default"` state, get the permission request first.
|
||||
*/
|
||||
togglePushNotifications() {
|
||||
this.setState({
|
||||
pushNotificationsEnabled: !this.state.pushNotificationsEnabled
|
||||
});
|
||||
setUserDefaultBool(
|
||||
"enablePushNotifications",
|
||||
!this.state.pushNotificationsEnabled
|
||||
);
|
||||
if (!browserSupportsNotificationRequests()) {
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === "default") {
|
||||
getNotificationRequestPermission()
|
||||
.then(permission => {
|
||||
if (permission === "granted") {
|
||||
setUserDefaultBool(
|
||||
"enablePushNotifications",
|
||||
!this.state.pushNotificationsEnabled
|
||||
);
|
||||
this.setState({
|
||||
pushNotificationsEnabled: !this.state
|
||||
.pushNotificationsEnabled
|
||||
});
|
||||
} else if (permission === "denied") {
|
||||
this.props.enqueueSnackbar(
|
||||
"Permission request was denied.",
|
||||
{ variant: "error" }
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(reason =>
|
||||
this.props.enqueueSnackbar(reason, { variant: "error" })
|
||||
);
|
||||
} else {
|
||||
setUserDefaultBool(
|
||||
"enablePushNotifications",
|
||||
!this.state.pushNotificationsEnabled
|
||||
);
|
||||
this.setState({
|
||||
pushNotificationsEnabled: !this.state.pushNotificationsEnabled
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleBadgeCount() {
|
||||
|
@ -216,6 +274,16 @@ 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
|
||||
|
@ -226,6 +294,16 @@ 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();
|
||||
|
@ -265,6 +343,227 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
window.location.reload();
|
||||
}
|
||||
|
||||
settingsList = () => {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<>
|
||||
<ListSubheader id="sp-appearance">Appearance</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<DevicesIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Match system appearance"
|
||||
secondary="Follows your device's preferences to toggle dark mode"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={this.state.systemDecidesDarkMode}
|
||||
onChange={this.toggleSystemDarkMode}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{!this.state.systemDecidesDarkMode ? (
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Brightness3Icon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Dark mode"
|
||||
secondary="Toggles light or dark theme"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
disabled={
|
||||
this.state.systemDecidesDarkMode
|
||||
}
|
||||
checked={this.state.darkModeEnabled}
|
||||
onChange={this.toggleDarkMode}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<PaletteIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Interface theme"
|
||||
secondary="Defines the color palette used for the interface"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button onClick={this.toggleThemeDialog}>
|
||||
Set theme
|
||||
</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 />
|
||||
<ListSubheader id="sp-composer">Composer</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<VisibilityIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Default post visibility"
|
||||
secondary="Creating posts in the composer will use this visiblity"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button onClick={this.toggleVisibilityDialog}>
|
||||
Change
|
||||
</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 />
|
||||
<ListSubheader id="sp-notifications">
|
||||
Notifications
|
||||
</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<NotificationsIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Enable push notifications"
|
||||
secondary={
|
||||
getUserDefaultBool("userDeniedNotification")
|
||||
? "Check your browser's notification permissions."
|
||||
: browserSupportsNotificationRequests()
|
||||
? "Sends a push notification when not focused."
|
||||
: "Notifications aren't supported."
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={
|
||||
this.state.pushNotificationsEnabled
|
||||
}
|
||||
onChange={this.togglePushNotifications}
|
||||
disabled={
|
||||
!browserSupportsNotificationRequests()
|
||||
}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<BellAlertIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Notification badge counts all notifications"
|
||||
secondary={
|
||||
"Counts all notifications, read or unread."
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={this.state.badgeDisplaysAllNotifs}
|
||||
onChange={this.toggleBadgeCount}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
<br />
|
||||
<ListSubheader id="sp-advanced">Advanced</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<RefreshIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Refresh settings"
|
||||
secondary="Resets the settings to defaults."
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button
|
||||
onClick={() =>
|
||||
this.toggleResetSettingsDialog()
|
||||
}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<UndoIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={`Reset ${this.state.brandName}`}
|
||||
secondary="Deletes all data and resets the app"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button
|
||||
onClick={() => this.toggleResetDialog()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
showThemeDialog() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -298,7 +597,6 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
label={theme.name}
|
||||
/>
|
||||
))}
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Grid>
|
||||
<Grid
|
||||
|
@ -502,7 +800,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"
|
||||
|
@ -518,6 +816,14 @@ 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={
|
||||
|
@ -536,196 +842,38 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
</Toolbar>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
<div className={classes.pageContentLayoutConstraints}>
|
||||
<ListSubheader>Appearance</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<DevicesIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Match system appearance"
|
||||
secondary="Follows your device's preferences to toggle dark mode"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={
|
||||
this.state.systemDecidesDarkMode
|
||||
}
|
||||
onChange={this.toggleSystemDarkMode}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{!this.state.systemDecidesDarkMode ? (
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Brightness3Icon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Dark mode"
|
||||
secondary="Toggles light or dark theme"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
disabled={
|
||||
this.state
|
||||
.systemDecidesDarkMode
|
||||
}
|
||||
checked={
|
||||
this.state.darkModeEnabled
|
||||
}
|
||||
onChange={this.toggleDarkMode}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<PaletteIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Interface theme"
|
||||
secondary="Defines the color palette used for the interface"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button
|
||||
onClick={this.toggleThemeDialog}
|
||||
>
|
||||
Set theme
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
<br />
|
||||
<ListSubheader>Composer</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<VisibilityIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Default post visibility"
|
||||
secondary="Creating posts in the composer will use this visiblity"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button
|
||||
onClick={
|
||||
this.toggleVisibilityDialog
|
||||
}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
<br />
|
||||
<ListSubheader>Notifications</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<NotificationsIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Enable push notifications"
|
||||
secondary={
|
||||
getUserDefaultBool(
|
||||
"userDeniedNotification"
|
||||
)
|
||||
? "Check your browser's notification permissions."
|
||||
: browserSupportsNotificationRequests()
|
||||
? "Sends a push notification when not focused."
|
||||
: "Notifications aren't supported."
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={
|
||||
this.state
|
||||
.pushNotificationsEnabled
|
||||
}
|
||||
onChange={
|
||||
this.togglePushNotifications
|
||||
}
|
||||
disabled={
|
||||
!browserSupportsNotificationRequests() ||
|
||||
getUserDefaultBool(
|
||||
"userDeniedNotification"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<BellAlertIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Notification badge counts all notifications"
|
||||
secondary={
|
||||
"Counts all notifications, read or unread."
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
checked={
|
||||
this.state
|
||||
.badgeDisplaysAllNotifs
|
||||
}
|
||||
onChange={this.toggleBadgeCount}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
<br />
|
||||
<ListSubheader>Advanced</ListSubheader>
|
||||
<Paper className={classes.pageListConstraints}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<RefreshIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="Refresh settings"
|
||||
secondary="Resets the settings to defaults."
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button
|
||||
onClick={() =>
|
||||
this.toggleResetSettingsDialog()
|
||||
}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<UndoIcon color="action" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={`Reset ${this.state.brandName}`}
|
||||
secondary="Deletes all data and resets the app"
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button
|
||||
onClick={() =>
|
||||
this.toggleResetDialog()
|
||||
}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Paper>
|
||||
{this.settingsList()}
|
||||
{this.showThemeDialog()}
|
||||
{this.showVisibilityDialog()}
|
||||
{this.showResetDialog()}
|
||||
|
@ -737,4 +885,4 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(SettingsPage);
|
||||
export default withStyles(styles)(withSnackbar(SettingsPage));
|
||||
|
|
|
@ -0,0 +1,437 @@
|
|||
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));
|
|
@ -16,6 +16,7 @@ import filedialog from "file-dialog";
|
|||
|
||||
interface IYouProps extends withSnackbarProps {
|
||||
classes: any;
|
||||
onAvatarUpdate: Function;
|
||||
}
|
||||
|
||||
interface IYouState {
|
||||
|
@ -74,7 +75,7 @@ class You extends Component<IYouProps, IYouState> {
|
|||
|
||||
getAccount() {
|
||||
let acct = localStorage.getItem("account");
|
||||
console.log(acct);
|
||||
// console.log(acct);
|
||||
if (acct) {
|
||||
return JSON.parse(acct);
|
||||
}
|
||||
|
@ -106,6 +107,9 @@ class You extends Component<IYouProps, IYouState> {
|
|||
this.props.enqueueSnackbar(
|
||||
"Avatar updated successfully."
|
||||
);
|
||||
this.props.onAvatarUpdate(
|
||||
currentAccount.avatar_static
|
||||
);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.closeSnackbar("persistAvatar");
|
||||
|
|
|
@ -14,6 +14,7 @@ export type Account = {
|
|||
followers_count: number;
|
||||
following_count: number;
|
||||
statuses_count: number;
|
||||
last_status_at: string;
|
||||
note: string;
|
||||
url: string;
|
||||
avatar: string;
|
||||
|
@ -41,7 +42,7 @@ export type UAccount = {
|
|||
*/
|
||||
export type MultiAccount = {
|
||||
/**
|
||||
* The host name of the account (ex.: mastodon.social)
|
||||
* The host name of the account (ex.: mastodon.online)
|
||||
*/
|
||||
host: string;
|
||||
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { Account } from "./Account";
|
||||
import { Tag } from "./Tag";
|
||||
import { MastodonEmoji } from "./Emojis";
|
||||
|
||||
export type Announcement = {
|
||||
id: string;
|
||||
content: string;
|
||||
starts_at?: string;
|
||||
ends_at?: string;
|
||||
all_day: boolean;
|
||||
published_at: string;
|
||||
updated_at: string;
|
||||
read: boolean;
|
||||
mentions: [Account];
|
||||
tags: [Tag];
|
||||
emojis: [MastodonEmoji];
|
||||
};
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
type: "unknown" | "image" | "gifv" | "video";
|
||||
type: "unknown" | "image" | "gifv" | "audio" | "video";
|
||||
url: string;
|
||||
remote_url: string | null;
|
||||
preview_url: string;
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* 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;
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
export type History = {
|
||||
day: string;
|
||||
uses: number;
|
||||
accounts: number;
|
||||
};
|
|
@ -1,4 +1,7 @@
|
|||
import { History } from "./History";
|
||||
|
||||
export type Tag = {
|
||||
name: string;
|
||||
url: string;
|
||||
history?: [History];
|
||||
};
|
||||
|
|
|
@ -25,12 +25,6 @@ export function refreshUserAccountData() {
|
|||
.catch((err: Error) => {
|
||||
console.error(err.message);
|
||||
});
|
||||
client.get("/instance").then((resp: any) => {
|
||||
localStorage.setItem(
|
||||
"isPleroma",
|
||||
resp.data.version.match(/Pleroma/) ? "true" : "false"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
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.
|
||||
|
@ -9,3 +18,20 @@ import { isDarwinApp } from "./desktop";
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
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
|
||||
};
|
||||
}
|
|
@ -27,7 +27,7 @@ export function isDarkMode() {
|
|||
// Lift window to an ElectronWindow and add use require()
|
||||
const eWin = window as ElectronWindow;
|
||||
const { remote } = eWin.require("electron");
|
||||
return remote.systemPreferences.isDarkMode();
|
||||
return remote.nativeTheme.shouldUseDarkColors;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -56,3 +56,11 @@ export function getElectronApp() {
|
|||
const { remote } = eWin.require("electron");
|
||||
return remote.app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the linkable version of a path for the web and desktop.
|
||||
* @param path The path to make a linkable version of
|
||||
*/
|
||||
export function linkablePath(path: string): string {
|
||||
return isDesktopApp() ? "/app" + path : path;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import Mastodon from "megalodon";
|
||||
|
||||
export const instancesBearerKey =
|
||||
"QBEULaOyF04OKjHsHktx5reOqDUklWMSWfUiKRdcen7iLDjta8kL2ZEgozAlBScizR0LKSMcxg2su9f3VLdJt5dZyfWkSXEPlxHBUlPzBF4Ju9lAiOhklh0TLNpFZBqA";
|
||||
|
||||
/**
|
||||
* Creates the Hyperspace app with the appropriate Redirect URI
|
||||
* @param name The name of the app (if not Hyperspace, will use `name (Hyperspace-like)`)
|
||||
|
@ -41,18 +44,18 @@ export function createHyperspaceApp(
|
|||
|
||||
/**
|
||||
* Gets the appropriate redirect address.
|
||||
* @param type The address or configuration to use
|
||||
* @param url The address or configuration to use
|
||||
*/
|
||||
export function getRedirectAddress(
|
||||
type: "desktop" | "dynamic" | string
|
||||
url: "desktop" | "dynamic" | string
|
||||
): string {
|
||||
switch (type) {
|
||||
switch (url) {
|
||||
case "desktop":
|
||||
return "hyperspace://hyperspace/app/";
|
||||
case "dynamic":
|
||||
return `https://${window.location.host}`;
|
||||
default:
|
||||
return type;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,23 +2,28 @@ import { getUserDefaultBool, setUserDefaultBool } from "./settings";
|
|||
|
||||
/**
|
||||
* Get the person's permission to send notification requests.
|
||||
*
|
||||
* @returns Promise containing the notification permission, or a rejection if
|
||||
* either the browser doesn't support notifications.
|
||||
*/
|
||||
export function getNotificationRequestPermission() {
|
||||
if ("Notification" in window) {
|
||||
Notification.requestPermission();
|
||||
let request = Notification.permission;
|
||||
if (request === "granted") {
|
||||
setUserDefaultBool("enablePushNotifications", true);
|
||||
setUserDefaultBool("userDeniedNotification", false);
|
||||
} else {
|
||||
setUserDefaultBool("enablePushNotifications", false);
|
||||
setUserDefaultBool("userDeniedNotification", true);
|
||||
}
|
||||
Notification.requestPermission().then(request => {
|
||||
setUserDefaultBool(
|
||||
"enablePushNotifications",
|
||||
request === "granted"
|
||||
);
|
||||
setUserDefaultBool("userDeniedNotification", request === "denied");
|
||||
});
|
||||
return Promise.resolve(Notification.permission);
|
||||
} else {
|
||||
console.warn(
|
||||
"Notifications aren't supported in this browser. The setting will be disabled."
|
||||
);
|
||||
setUserDefaultBool("enablePushNotifications", false);
|
||||
return Promise.reject(
|
||||
"Notifications are not supported in this browser."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,7 +40,10 @@ export function browserSupportsNotificationRequests(): boolean {
|
|||
* @returns Boolean value of `enablePushNotifications`
|
||||
*/
|
||||
export function canSendNotifications() {
|
||||
return getUserDefaultBool("enablePushNotifications");
|
||||
return (
|
||||
getUserDefaultBool("enablePushNotifications") &&
|
||||
Notification.permission === "granted"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { defaultTheme, themes } from "../types/HyperspaceTheme";
|
||||
import { getNotificationRequestPermission } from "./notifications";
|
||||
import axios from "axios";
|
||||
import { Config } from "../types/Config";
|
||||
import { Visibility } from "../types/Visibility";
|
||||
|
@ -12,6 +11,8 @@ type SettingsTemplate = {
|
|||
clearNotificationsOnRead: boolean;
|
||||
displayAllOnNotificationBadge: boolean;
|
||||
defaultVisibility: string;
|
||||
imposeCharacterLimit: boolean;
|
||||
canSendNotifications: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -99,7 +100,10 @@ export function createUserDefaults() {
|
|||
enablePushNotifications: true,
|
||||
clearNotificationsOnRead: false,
|
||||
displayAllOnNotificationBadge: false,
|
||||
defaultVisibility: "public"
|
||||
defaultVisibility: "public",
|
||||
imposeCharacterLimit: true,
|
||||
isMasonryLayout: false,
|
||||
canSendNotifications: false
|
||||
};
|
||||
|
||||
let settings = [
|
||||
|
@ -107,7 +111,10 @@ export function createUserDefaults() {
|
|||
"systemDecidesDarkMode",
|
||||
"clearNotificationsOnRead",
|
||||
"displayAllOnNotificationBadge",
|
||||
"defaultVisibility"
|
||||
"defaultVisibility",
|
||||
"imposeCharacterLimit",
|
||||
"isMasonryLayout",
|
||||
"canSendNotifications"
|
||||
];
|
||||
|
||||
migrateExistingSettings();
|
||||
|
@ -121,16 +128,40 @@ export function createUserDefaults() {
|
|||
}
|
||||
}
|
||||
});
|
||||
getNotificationRequestPermission();
|
||||
|
||||
setUserDefaultBool("userDeniedNotications", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the configuration data from `config.json`
|
||||
* Gets the configuration data from `config.json`.
|
||||
*
|
||||
* In scenarios where the app is being run from the desktop or from a local React server
|
||||
* started by react-scripts, the location field is adjusted accordingly.
|
||||
*
|
||||
* @returns The Promise data from getting the config.
|
||||
*/
|
||||
export async function getConfig(): Promise<Config | undefined> {
|
||||
try {
|
||||
const resp = await axios.get("config.json");
|
||||
|
||||
let { location }: { location: string } = resp.data;
|
||||
|
||||
if (
|
||||
!location.endsWith("/") &&
|
||||
location !== "desktop" &&
|
||||
location !== "dynamic"
|
||||
) {
|
||||
console.info(
|
||||
"Location does not have a forward slash, so Hyperspace has added it automatically."
|
||||
);
|
||||
resp.data.location = location + "/";
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
resp.data.location = "http://localhost:3000/";
|
||||
console.info("Location field has been updated to localhost:3000.");
|
||||
}
|
||||
|
||||
return resp.data as Config;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
|
|