Compare commits
202 Commits
v1.1.0-bet
...
master
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 |
|
@ -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,6 +24,7 @@ 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]
|
||||
|
|
|
@ -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 @@
|
|||
**Changes Overview**
|
||||
This PR makes the following changes:
|
||||
|
||||
<!-- 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
|
||||
-->
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
- [] This is a release check.
|
||||
<!-- If the following is a release check, uncomment the following line. -->
|
||||
<!-- - [x] This is a release check. -->
|
||||
|
||||
**Pending for review**
|
||||
@hyperspacedev/desktop
|
|
@ -12,8 +12,27 @@ jobs:
|
|||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Change desktop field
|
||||
run: |
|
||||
from json import load, dump
|
||||
|
||||
json_dict = {}
|
||||
with open('public/config.json', 'r') as file:
|
||||
json_dict = load(file)
|
||||
|
||||
json_dict["location"] = "desktop"
|
||||
|
||||
with open('public/config.json', 'w+') as out:
|
||||
dump(json_dict, out)
|
||||
shell: python
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm install
|
||||
npm run build --if-present
|
||||
npm run build-desktop-linux
|
||||
npm run build:linux
|
||||
- name: Upload Linux executables
|
||||
uses: actions/upload-artifact@v1
|
||||
if: success()
|
||||
with:
|
||||
name: "Linux executables (output dir)"
|
||||
path: dist
|
||||
|
|
|
@ -12,7 +12,8 @@ jobs:
|
|||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Run pre-build setup
|
||||
- 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
|
||||
|
@ -40,8 +41,27 @@ jobs:
|
|||
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-desktop-darwin-nosign
|
||||
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
|
||||
|
|
|
@ -7,7 +7,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [8.x, 10.x, 12.x]
|
||||
node-version: [10.x, 12.x]
|
||||
steps:
|
||||
- name: Clone source code
|
||||
uses: actions/checkout@v1
|
||||
|
@ -20,4 +20,4 @@ jobs:
|
|||
npm install
|
||||
npm run build --if-present
|
||||
env:
|
||||
CI: true
|
||||
CI: false
|
||||
|
|
|
@ -12,8 +12,27 @@ jobs:
|
|||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 10.x
|
||||
- name: Change desktop field
|
||||
run: |
|
||||
from json import load, dump
|
||||
|
||||
json_dict = {}
|
||||
with open('public/config.json', 'r') as file:
|
||||
json_dict = load(file)
|
||||
|
||||
json_dict["location"] = "desktop"
|
||||
|
||||
with open('public/config.json', 'w+') as out:
|
||||
dump(json_dict, out)
|
||||
shell: python
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm install
|
||||
npm run build --if-present
|
||||
npm run build-desktop-win
|
||||
npm run build:win
|
||||
- name: Upload Windows executable
|
||||
uses: actions/upload-artifact@v1
|
||||
if: success()
|
||||
with:
|
||||
name: "Windows executable (output dir)"
|
||||
path: dist
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
name: Create issue on Jira
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
jira:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Jira Login
|
||||
id: login
|
||||
uses: atlassian/gajira-login@v2.0.0
|
||||
env:
|
||||
JIRA_BASE_URL: "https://hyperspacedev.atlassian.net"
|
||||
JIRA_USER_EMAIL: software@marquiskurt.net
|
||||
JIRA_API_TOKEN: ${{ secrets.JIRA_TOKEN }}
|
||||
- name: Jira Create issue
|
||||
id: create
|
||||
uses: atlassian/gajira-create@v2.0.0
|
||||
with:
|
||||
project: HD
|
||||
issuetype: Unsorted
|
||||
summary: ${{ github.event.issue.title }}
|
||||
description: ${{ github.event.issue.body }}
|
|
@ -15,6 +15,6 @@ jobs:
|
|||
- name: Install dependencies and run Prettier
|
||||
run: |
|
||||
npm install
|
||||
npm run check-prettier
|
||||
npm run test:prettier
|
||||
env:
|
||||
CI: true
|
|
@ -73,3 +73,6 @@ desktop/*.provisionprofile
|
|||
|
||||
# JetBrains IDEA directory
|
||||
.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
|
||||
|
@ -178,7 +202,6 @@ AND CONDITIONS OF THIS LICENSE.
|
|||
Section 8(g), all rights not expressly granted by Licensor are
|
||||
hereby reserved.
|
||||
|
||||
|
||||
4. RESTRICTIONS
|
||||
|
||||
The license granted in Section 3 above is expressly made subject to and
|
||||
|
@ -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.
|
||||
|
267
README.md
|
@ -1,138 +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)
|
||||
![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 Mac App Store](https://hyperspace.marquiskurt.net/assets/images/mas.svg)](https://apps.apple.com/us/app/hyperspace-desktop/id1454139710?mt=12)
|
||||
|
||||
[![Get on the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/hyperspace)
|
||||
**via [WinGet](https://github.com/microsoft/winget-cli)**:
|
||||
|
||||
Looking for the Mac App Store version? [Read more ›](https://hyperspace.marquiskurt.net/2019/11/08/post.html)
|
||||
```
|
||||
winget install HyperspaceDesktop
|
||||
```
|
||||
|
||||
## Build instructions
|
||||
## Build from source
|
||||
|
||||
### Prerequisites
|
||||
To build Hyperspace Desktop, you'll need the following tools and packages:
|
||||
|
||||
To develop Hyperspace, you'll need the following tools and packages:
|
||||
|
||||
- Node.js 8 or later
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
For the child ones (inherited `entitlements.mas.inherit.plist`):
|
||||
For certificates, make sure your Mac has the following certificates installed:
|
||||
|
||||
- `com.apple.security.app-sandbox`
|
||||
- `com.apple.security.inherit`
|
||||
- `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`
|
||||
- 3rd Party Mac Developer Application
|
||||
- 3rd Party Mac Developer Installer
|
||||
- Developer ID Application
|
||||
- Developer ID Installer
|
||||
- Mac Developer
|
||||
|
||||
> ⚠️ Note that the inherited permissions are the same as that of the parent. This is due to an issue where the hardened runtime fails to pass down the inherited properties (see [electron/electron#20560](https://github.com/electron/electron/issues/20560#issuecomment-546110018)). This might change in future versions of macOS.
|
||||
The easiest way to handle this is by opening Xcode and going to **Preferences › Accounts** and create the certificates from "Manage Certificates".
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
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.
|
||||
##### Create your entitlements files
|
||||
|
||||
> ⚠️ **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_.
|
||||
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/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
|
114
package.json
|
@ -1,64 +1,66 @@
|
|||
{
|
||||
"name": "hyperspace",
|
||||
"productName": "Hyperspace Desktop",
|
||||
"version": "1.1.0-beta1",
|
||||
"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.11",
|
||||
"@material-ui/core": "^3.9.3",
|
||||
"@material-ui/icons": "^4.5.1",
|
||||
"@types/emoji-mart": "^2.11.0",
|
||||
"@types/jest": "^24.0.18",
|
||||
"@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.19.0",
|
||||
"electron": "^6.0.11",
|
||||
"electron-builder": "^21.2.0",
|
||||
"emoji-mart": "^2.11.1",
|
||||
"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.18.0",
|
||||
"mdi-material-ui": "^5.27.0",
|
||||
"megalodon": "^0.6.4",
|
||||
"moment": "^2.24.0",
|
||||
"moment": "^2.27.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",
|
||||
"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.7.2"
|
||||
"typescript": "^3.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-notarize": "^0.1.1",
|
||||
"electron-updater": "^4.1.2",
|
||||
"electron-window-state": "^5.0.3"
|
||||
"electron-updater": "^4.3.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"react-masonry-css": "^1.0.14"
|
||||
},
|
||||
"main": "public/electron.js",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"electrify": "npm run build; electron .",
|
||||
"electrify-nobuild": "electron .",
|
||||
"build": "react-scripts build",
|
||||
"create-mac-icon": "cd desktop; iconutil -c icns app.iconset; cd ..",
|
||||
"build-desktop": "npm run build; npm run create-mac-icon; electron-builder -p 'never' -mwl deb AppImage snap",
|
||||
"build-desktop-win": "electron-builder -p 'never' -w",
|
||||
"build-desktop-darwin": "npm run create-mac-icon; electron-builder -p 'never' -m",
|
||||
"build-desktop-darwin-nosign": "npm run create-mac-icon; electron-builder -p 'never' -m dmg -c.mac.identity=null -c.afterSign=\"desktop/donothing.js\"",
|
||||
"build-desktop-linux": "electron-builder -p 'never' -l deb AppImage snap",
|
||||
"build-desktop-linux-select": "electron-builder -p 'never' -l ",
|
||||
"check-prettier": "prettier --check src/**/**.tsx",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"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 "
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
|
@ -68,47 +70,5 @@
|
|||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all"
|
||||
],
|
||||
"build": {
|
||||
"appId": "net.marquiskurt.hyperspace",
|
||||
"afterSign": "desktop/notarize.js",
|
||||
"directories": {
|
||||
"buildResources": "desktop"
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.social-networking",
|
||||
"icon": "desktop/app.icns",
|
||||
"target": [
|
||||
"dmg",
|
||||
"mas"
|
||||
],
|
||||
"darkModeSupport": true,
|
||||
"hardenedRuntime": true
|
||||
},
|
||||
"mas": {
|
||||
"entitlements": "desktop/entitlements.mas.plist",
|
||||
"entitlementsInherit": "desktop/entitlements.mas.inherit.plist",
|
||||
"provisioningProfile": "desktop/embedded.provisionprofile"
|
||||
},
|
||||
"dmg": {
|
||||
"sign": false
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "desktop/app.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"${@:1}"
|
||||
],
|
||||
"icon": "linux",
|
||||
"category": "Network"
|
||||
},
|
||||
"snap": {
|
||||
"confinement": "strict",
|
||||
"summary": "A beautiful, fluffy client for the fediverse"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
|
||||
## Previous Contributors
|
||||
|
||||
- LucasAzazer
|
||||
|
|
|
@ -1,26 +1,26 @@
|
|||
{
|
||||
"version": "1.1.0",
|
||||
"location": "https://hyperspaceapp-next.herokuapp.com",
|
||||
"version": "1.1.4",
|
||||
"location": "https://hyperspaceapp.herokuapp.com",
|
||||
"branding": {
|
||||
"name": "Hyperspace",
|
||||
"logo": "logo.svg",
|
||||
"background": "background.png"
|
||||
},
|
||||
"developer": true,
|
||||
"developer": false,
|
||||
"federation": {
|
||||
"universalLogin": true,
|
||||
"allowPublicPosts": true,
|
||||
"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,8 +40,9 @@ function isDarwin() {
|
|||
* Register the protocol for Hyperspace
|
||||
*/
|
||||
function registerProtocol() {
|
||||
protocol.registerFileProtocol('hyperspace', (request, callback) => {
|
||||
|
||||
protocol.registerFileProtocol(
|
||||
"hyperspace",
|
||||
(request, callback) => {
|
||||
// Check to make sure we're doing a GET request
|
||||
if (request.method !== "GET") {
|
||||
callback({ error: -322 });
|
||||
|
@ -79,6 +87,7 @@ function registerProtocol() {
|
|||
} else {
|
||||
// If it doesn't match above, throw a "FILE_NOT_FOUND" error.
|
||||
callback({ error: -6 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a normalized version of the string.
|
||||
|
@ -87,7 +96,7 @@ function registerProtocol() {
|
|||
// 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('..')) {
|
||||
if (relTarget.startsWith("..")) {
|
||||
callback({ error: -6 });
|
||||
return;
|
||||
}
|
||||
|
@ -95,18 +104,17 @@ function registerProtocol() {
|
|||
// 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');
|
||||
});
|
||||
|
||||
},
|
||||
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,8 +123,7 @@ function createWindow() {
|
|||
});
|
||||
|
||||
// Create a browser window with some settings
|
||||
mainWindow = new BrowserWindow(
|
||||
{
|
||||
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
|
||||
|
@ -133,15 +140,14 @@ function createWindow() {
|
|||
webPreferences: { nodeIntegration: true },
|
||||
|
||||
// Set some preferences that are specific to macOS.
|
||||
titleBarStyle: 'hiddenInset',
|
||||
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);
|
||||
|
@ -151,32 +157,38 @@ function createWindow() {
|
|||
|
||||
// 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', () => {
|
||||
systemPreferences.subscribeNotification(
|
||||
"AppleInterfaceThemeChangedNotification",
|
||||
() => {
|
||||
if (mainWindow != null) {
|
||||
mainWindow.webContents.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
systemPreferences.subscribeNotification('AppleColorPreferencesChangedNotification', () => {
|
||||
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,106 +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',
|
||||
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' },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: 'Activity',
|
||||
accelerator: 'Alt+CmdOrCtrl+A',
|
||||
label: "Activity",
|
||||
accelerator: "Alt+CmdOrCtrl+A",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/activity")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/activity");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -327,127 +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: 'Follow Requests',
|
||||
label: "Follow Requests",
|
||||
accelerator: "Alt+CmdOrCtrl+E",
|
||||
click() {
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/requests")
|
||||
safelyGoTo("hyperspace://hyperspace/app/#/requests");
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Blocked Servers',
|
||||
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 Desktop 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 ${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
|
||||
|
@ -456,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();
|
||||
|
|
73
src/App.tsx
|
@ -8,17 +8,15 @@ 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";
|
||||
|
@ -32,6 +30,7 @@ let theme = setHyperspaceTheme(getUserDefaultTheme());
|
|||
interface IAppState {
|
||||
theme: any;
|
||||
showLayout: boolean;
|
||||
avatarURL?: string;
|
||||
}
|
||||
|
||||
class App extends Component<any, IAppState> {
|
||||
|
@ -46,6 +45,7 @@ class App extends Component<any, IAppState> {
|
|||
showLayout:
|
||||
userLoggedIn() && !window.location.hash.includes("#/welcome")
|
||||
};
|
||||
this.setAvatarURL = this.setAvatarURL.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
|
@ -87,9 +87,13 @@ class App extends Component<any, IAppState> {
|
|||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
setAvatarURL(avatarURL: string) {
|
||||
this.setState({
|
||||
avatarURL
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
this.removeBodyBackground();
|
||||
|
||||
return (
|
||||
|
@ -97,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}
|
||||
|
@ -118,7 +165,9 @@ 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
|
||||
|
|
|
@ -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")]: {
|
||||
|
|
|
@ -32,6 +32,7 @@ import {
|
|||
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";
|
||||
|
@ -44,6 +45,7 @@ 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";
|
||||
|
@ -60,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,
|
||||
|
@ -98,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;
|
||||
|
@ -119,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
|
||||
);
|
||||
|
@ -140,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;
|
||||
|
@ -150,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 = "";
|
||||
|
@ -216,36 +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) {
|
||||
what = what.replace(/^#/g, "tag:");
|
||||
console.log(what);
|
||||
window.location.href = isDesktopApp()
|
||||
? "hyperspace://hyperspace/app/index.html#/search?query=" + what
|
||||
: "/#/search?query=" + what;
|
||||
// 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) => {
|
||||
|
@ -257,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 });
|
||||
|
@ -276,6 +375,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the title bar.
|
||||
*/
|
||||
titlebar() {
|
||||
const { classes } = this.props;
|
||||
if (isDarwinApp()) {
|
||||
|
@ -307,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 (
|
||||
|
@ -409,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"
|
||||
|
@ -476,6 +591,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the entire layout.
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -484,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"
|
||||
|
@ -524,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"
|
||||
|
@ -562,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
|
||||
: ""
|
||||
|
@ -585,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
|
||||
|
@ -596,7 +737,10 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
<Avatar
|
||||
alt="You"
|
||||
src={
|
||||
this.state
|
||||
this.props.avatarURL
|
||||
? this.props
|
||||
.avatarURL
|
||||
: this.state
|
||||
.currentUser
|
||||
? this.state
|
||||
.currentUser
|
||||
|
@ -629,6 +773,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
<Divider />
|
||||
<LinkableListItem
|
||||
button={true}
|
||||
onClick={this.toggleAcctMenu}
|
||||
to={"/you"}
|
||||
>
|
||||
<ListItemText>
|
||||
|
@ -637,6 +782,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
|
|||
</LinkableListItem>
|
||||
<LinkableListItem
|
||||
button={true}
|
||||
onClick={this.toggleAcctMenu}
|
||||
to={"/requests"}
|
||||
>
|
||||
<ListItemText>
|
||||
|
@ -675,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()}
|
||||
|
|
|
@ -81,15 +81,21 @@ class AttachmentComponent extends Component<
|
|||
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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
LinearProgress,
|
||||
Tooltip
|
||||
} from "@material-ui/core";
|
||||
import { LinkableIconButton } from "../../interfaces/overrides";
|
||||
|
||||
import FastRewindIcon from "@material-ui/icons/FastRewind";
|
||||
import FastForwardIcon from "@material-ui/icons/FastForward";
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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": {
|
||||
|
@ -81,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 author = post.reblog ? post.reblog.account : post.account;
|
||||
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 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"
|
||||
}
|
||||
/**
|
||||
* 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}`
|
||||
);
|
||||
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"
|
||||
// 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;
|
||||
}
|
||||
);
|
||||
console.log(err.message);
|
||||
});
|
||||
}
|
||||
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"
|
||||
}
|
||||
/**
|
||||
* 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}`
|
||||
);
|
||||
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"
|
||||
// compensate for slow server update
|
||||
if (action === "unreblog") {
|
||||
resp.data.reblogs_count -= 1;
|
||||
}
|
||||
);
|
||||
console.log(err.message);
|
||||
});
|
||||
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,13 +642,16 @@ 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
|
||||
classes={{
|
||||
content: classes.postHeaderContent,
|
||||
title: classes.postHeaderTitle
|
||||
}}
|
||||
avatar={
|
||||
<LinkableAvatar
|
||||
to={`/profile/${
|
||||
|
@ -656,13 +677,7 @@ export class Post extends React.Component<any, IPostState> {
|
|||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
title={
|
||||
<Typography
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: this.getReblogAuthors(post)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={this.getReblogAuthors(post)}
|
||||
subheader={moment(post.created_at).format(
|
||||
"MMMM Do YYYY [at] h:mm A"
|
||||
)}
|
||||
|
@ -699,9 +714,7 @@ export class Post extends React.Component<any, IPostState> {
|
|||
: post.replies_count}
|
||||
</Typography>
|
||||
<Tooltip title="Favorite">
|
||||
<IconButton
|
||||
onClick={() => this.toggleFavorited(post)}
|
||||
>
|
||||
<IconButton onClick={() => this.toggleFavorited(post)}>
|
||||
<FavoriteIcon
|
||||
className={
|
||||
post.reblog
|
||||
|
@ -721,9 +734,7 @@ export class Post extends React.Component<any, IPostState> {
|
|||
: post.favourites_count}
|
||||
</Typography>
|
||||
<Tooltip title="Boost">
|
||||
<IconButton
|
||||
onClick={() => this.toggleReblogged(post)}
|
||||
>
|
||||
<IconButton onClick={() => this.toggleReblogged(post)}>
|
||||
<AutorenewIcon
|
||||
className={
|
||||
post.reblog
|
||||
|
@ -789,7 +800,7 @@ export class Post extends React.Component<any, IPostState> {
|
|||
variant: "success"
|
||||
}),
|
||||
onShareError: (error: Error) => {
|
||||
if (error.name != "AbortError")
|
||||
if (error.name !== "AbortError")
|
||||
this.props.enqueueSnackbar(
|
||||
`Couldn't share post: ${error.name}`,
|
||||
{ variant: "error" }
|
||||
|
@ -811,9 +822,7 @@ export class Post extends React.Component<any, IPostState> {
|
|||
</LinkableMenuItem>
|
||||
</div>
|
||||
) : (
|
||||
<LinkableMenuItem
|
||||
to={`/profile/${post.account.id}`}
|
||||
>
|
||||
<LinkableMenuItem to={`/profile/${post.account.id}`}>
|
||||
View profile
|
||||
</LinkableMenuItem>
|
||||
)}
|
||||
|
@ -840,9 +849,7 @@ export class Post extends React.Component<any, IPostState> {
|
|||
<div>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
this.togglePostDeleteDialog()
|
||||
}
|
||||
onClick={() => this.togglePostDeleteDialog()}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
|
@ -851,7 +858,6 @@ export class Post extends React.Component<any, IPostState> {
|
|||
{this.showDeleteDialog()}
|
||||
</Menu>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -28,6 +28,8 @@ import CodeIcon from "@material-ui/icons/Code";
|
|||
import TicketAccountIcon from "mdi-material-ui/TicketAccount";
|
||||
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";
|
||||
|
@ -84,13 +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 : 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,
|
||||
|
@ -98,19 +110,12 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
},
|
||||
repository: config.repository
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error(err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
shouldRenderInstanceContact(): boolean {
|
||||
if (this.state.instance != null) {
|
||||
return this.state.instance.version.match(/Pleroma/) == null;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return this.state.instance?.version?.match(/Pleroma/) == null ?? false;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -121,9 +126,8 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
<div
|
||||
className={classes.instanceHeaderPaper}
|
||||
style={{
|
||||
backgroundImage: `url("${
|
||||
this.state.brandBg ? this.state.brandBg : ""
|
||||
}")`
|
||||
backgroundImage: `url("${this.state.brandBg ??
|
||||
""}")`
|
||||
}}
|
||||
>
|
||||
<div className={classes.instanceToolbar}>
|
||||
|
@ -139,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 Desktop"}
|
||||
{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)"
|
||||
|
@ -164,21 +186,24 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
<List className={classes.pageListConstraints}>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
{this.state.hyperspaceAdmin ? (
|
||||
<LinkableAvatar
|
||||
to={`/profile/${
|
||||
this.state.hyperspaceAdmin
|
||||
? this.state.hyperspaceAdmin.id
|
||||
: 0
|
||||
}`}
|
||||
to={`/profile/${this.state
|
||||
.hyperspaceAdmin?.id ?? 0}`}
|
||||
src={
|
||||
this.state.hyperspaceAdmin
|
||||
? this.state.hyperspaceAdmin
|
||||
.avatar_static
|
||||
: ""
|
||||
?.avatar_static ?? ""
|
||||
}
|
||||
>
|
||||
<PersonIcon />
|
||||
</LinkableAvatar>
|
||||
) : (
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary="App provider"
|
||||
|
@ -189,9 +214,11 @@ 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"
|
||||
}
|
||||
/>
|
||||
{this.state.hyperspaceAdmin ? (
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Send a post or message">
|
||||
<LinkableIconButton
|
||||
|
@ -199,28 +226,22 @@ class AboutPage extends Component<any, IAboutPageState> {
|
|||
this.state.federated
|
||||
? "public"
|
||||
: "private"
|
||||
}&acct=${
|
||||
this.state.hyperspaceAdmin
|
||||
? this.state.hyperspaceAdmin
|
||||
.acct
|
||||
: ""
|
||||
}`}
|
||||
}&acct=${this.state.hyperspaceAdmin
|
||||
?.acct ?? ""}`}
|
||||
>
|
||||
<ChatIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="View profile">
|
||||
<LinkableIconButton
|
||||
to={`/profile/${
|
||||
this.state.hyperspaceAdmin
|
||||
? this.state.hyperspaceAdmin.id
|
||||
: 0
|
||||
}`}
|
||||
to={`/profile/${this.state
|
||||
.hyperspaceAdmin?.id ?? 0}`}
|
||||
>
|
||||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
) : null}
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
|
@ -270,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
|
||||
|
@ -289,15 +306,11 @@ 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>
|
||||
|
@ -345,12 +358,8 @@ 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>
|
||||
|
@ -428,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>
|
||||
|
@ -444,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>
|
||||
|
@ -460,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>
|
||||
|
@ -471,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"}{" "}
|
||||
Desktop is made possible by the{" "}
|
||||
{this.state.brandName ?? "Hyperspace"} Desktop is made
|
||||
possible by the{" "}
|
||||
<Link
|
||||
href={"https://material-ui.com"}
|
||||
target="_blank"
|
||||
|
|
|
@ -83,7 +83,7 @@ class ActivityPage extends Component<any, IActivityPageState> {
|
|||
viewLoading: false,
|
||||
viewErrored: true
|
||||
});
|
||||
console.log(err.message);
|
||||
console.error(err.message);
|
||||
});
|
||||
|
||||
this.client
|
||||
|
@ -101,7 +101,7 @@ class ActivityPage extends Component<any, IActivityPageState> {
|
|||
viewLoading: false,
|
||||
viewErrored: true
|
||||
});
|
||||
console.log(err.message);
|
||||
console.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
@ -44,35 +44,106 @@ import {
|
|||
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(),
|
||||
|
@ -87,13 +158,21 @@ class Composer extends Component<any, IComposerState> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
@ -107,26 +186,37 @@ class Composer extends Component<any, IComposerState> {
|
|||
});
|
||||
});
|
||||
|
||||
// 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) {
|
||||
if (clipitems !== undefined) {
|
||||
for (let i = 0; i < clipitems.length; i++) {
|
||||
if (clipitems[i].type.indexOf("image") != -1) {
|
||||
if (clipitems[i].type.indexOf("image") !== -1) {
|
||||
let clipfile = clipitems[i].getAsFile();
|
||||
if (clipfile != null) {
|
||||
fileList.push(clipfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.actuallyUploadMedia(fileList);
|
||||
|
||||
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}: ` : "";
|
||||
|
@ -141,6 +231,36 @@ class Composer extends Component<any, IComposerState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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") {
|
||||
|
@ -151,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 = "";
|
||||
|
@ -173,6 +298,10 @@ 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,
|
||||
|
@ -182,20 +311,31 @@ class Composer extends Component<any, IComposerState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: ".jpeg,.jpg,.png,.gif,.webm,.mp4,.mov,.ogg,.wav,.mp3,.flac"
|
||||
})
|
||||
.then((media: FileList) => this.actuallyUploadMedia(media))
|
||||
.then((media: FileList) => this.uploadMedia(media))
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar("Couldn't get media: " + err.name, {
|
||||
variant: "error"
|
||||
|
@ -204,15 +344,27 @@ class Composer extends Component<any, IComposerState> {
|
|||
});
|
||||
}
|
||||
|
||||
actuallyUploadMedia(media: FileList | File[]) {
|
||||
/**
|
||||
* 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;
|
||||
|
@ -225,6 +377,8 @@ class Composer extends Component<any, IComposerState> {
|
|||
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(
|
||||
|
@ -234,16 +388,24 @@ class Composer extends Component<any, IComposerState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
@ -256,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) {
|
||||
|
@ -269,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;
|
||||
|
@ -285,6 +455,9 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty poll.
|
||||
*/
|
||||
createPoll() {
|
||||
if (this.state.poll === undefined) {
|
||||
let expiration = new Date();
|
||||
|
@ -304,6 +477,9 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new poll item into the poll.
|
||||
*/
|
||||
addPollItem() {
|
||||
if (
|
||||
this.state.poll !== undefined &&
|
||||
|
@ -318,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" }
|
||||
|
@ -326,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;
|
||||
|
@ -343,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 &&
|
||||
|
@ -369,6 +554,10 @@ 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);
|
||||
|
@ -388,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,
|
||||
|
@ -423,28 +625,50 @@ class Composer extends Component<any, IComposerState> {
|
|||
}
|
||||
: null
|
||||
})
|
||||
|
||||
// If we succeed, send a success message, clear the status
|
||||
// text field, and go back.
|
||||
.then(() => {
|
||||
this.props.enqueueSnackbar("Posted!");
|
||||
|
||||
// This is necessary to prevent session drafts from saving
|
||||
// posts that were already posted.
|
||||
this.setState({ text: "" });
|
||||
|
||||
window.history.back();
|
||||
})
|
||||
|
||||
// Otherwise, show an error message and don't do anything.
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar("Couldn't post: " + err.name);
|
||||
console.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
|
@ -652,7 +876,7 @@ class Composer extends Component<any, IComposerState> {
|
|||
<Tooltip title="Add photos, videos, or audio">
|
||||
<IconButton
|
||||
disabled={this.state.poll !== undefined}
|
||||
onClick={() => this.uploadMedia()}
|
||||
onClick={() => this.promptMediaDialog()}
|
||||
id="compose-media"
|
||||
>
|
||||
<AttachFileIcon />
|
||||
|
@ -738,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}>
|
||||
|
@ -184,6 +195,35 @@ class HomePage extends Component<any, IHomePageState> {
|
|||
</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}
|
||||
>
|
||||
<Post
|
||||
key={post.id}
|
||||
post={post}
|
||||
client={this.client}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Masonry>
|
||||
) : (
|
||||
<div>
|
||||
{this.state.posts.map((post: Status) => {
|
||||
return (
|
||||
|
@ -194,6 +234,8 @@ class HomePage extends Component<any, IHomePageState> {
|
|||
/>
|
||||
);
|
||||
})}
|
||||
</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.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,7 +8,6 @@ import {
|
|||
ListItemText,
|
||||
CircularProgress,
|
||||
ListItemAvatar,
|
||||
Avatar,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip,
|
||||
Typography
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
|
@ -17,75 +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) => {
|
||||
/**
|
||||
* 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
|
||||
viewDidLoad: true,
|
||||
mobileMenuOpen: notifMenus
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
viewDidLoad: true,
|
||||
viewIsLoading: false,
|
||||
viewDidError: true,
|
||||
viewDidErrorCode: err.message
|
||||
});
|
||||
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");
|
||||
|
||||
|
@ -98,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;
|
||||
|
@ -111,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`)
|
||||
|
@ -142,6 +263,9 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all notifications from the server.
|
||||
*/
|
||||
removeAllNotifications() {
|
||||
this.client
|
||||
.post("/notifications/clear")
|
||||
|
@ -159,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 = "";
|
||||
|
@ -231,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">
|
||||
|
@ -240,15 +470,28 @@ class NotificationsPage extends Component<any, INotificationsPageState> {
|
|||
<AssignmentIndIcon />
|
||||
</LinkableIconButton>
|
||||
</Tooltip>
|
||||
{!this.state.relationships[notif.account.id]
|
||||
.following ? (
|
||||
<Tooltip title="Follow account">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
this.followMember(notif.account)
|
||||
this.toggleFollow(notif.account)
|
||||
}
|
||||
>
|
||||
<PersonAddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Unfollow account">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
this.toggleFollow(notif.account)
|
||||
}
|
||||
>
|
||||
<PersonRemoveIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
) : notif.status ? (
|
||||
<span>
|
||||
|
@ -288,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 (
|
||||
|
@ -383,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.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,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";
|
||||
|
@ -64,11 +60,13 @@ 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;
|
||||
|
@ -86,6 +84,8 @@ interface ISettingsState {
|
|||
federated: boolean;
|
||||
currentUser?: Account;
|
||||
imposeCharacterLimit: boolean;
|
||||
masonryLayout?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
}
|
||||
|
||||
class SettingsPage extends Component<any, ISettingsState> {
|
||||
|
@ -117,7 +117,9 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
defaultVisibility: getUserDefaultVisibility() || "public",
|
||||
brandName: "Hyperspace",
|
||||
federated: true,
|
||||
imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit")
|
||||
imposeCharacterLimit: getUserDefaultBool("imposeCharacterLimit"),
|
||||
masonryLayout: getUserDefaultBool("isMasonryLayout"),
|
||||
infiniteScroll: getUserDefaultBool("isInfiniteScroll")
|
||||
};
|
||||
|
||||
this.toggleDarkMode = this.toggleDarkMode.bind(this);
|
||||
|
@ -126,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) => {
|
||||
|
@ -160,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
|
||||
});
|
||||
|
@ -191,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
|
||||
});
|
||||
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() {
|
||||
|
@ -241,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();
|
||||
|
@ -280,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 (
|
||||
|
@ -313,7 +597,6 @@ class SettingsPage extends Component<any, ISettingsState> {
|
|||
label={theme.name}
|
||||
/>
|
||||
))}
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Grid>
|
||||
<Grid
|
||||
|
@ -559,215 +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>
|
||||
<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>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()}
|
||||
|
@ -779,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));
|
|
@ -18,7 +18,8 @@ import {
|
|||
ListItemText,
|
||||
ListItemAvatar,
|
||||
ListItemSecondaryAction,
|
||||
IconButton
|
||||
IconButton,
|
||||
InputAdornment
|
||||
} from "@material-ui/core";
|
||||
import { styles } from "./WelcomePage.styles";
|
||||
import Mastodon from "megalodon";
|
||||
|
@ -36,51 +37,166 @@ import axios from "axios";
|
|||
import { withSnackbar, withSnackbarProps } from "notistack";
|
||||
import { Config } from "../types/Config";
|
||||
import {
|
||||
addAccountToRegistry,
|
||||
getAccountRegistry,
|
||||
loginWithAccount,
|
||||
removeAccountFromRegistry
|
||||
} from "../utilities/accounts";
|
||||
import { Account, MultiAccount } from "../types/Account";
|
||||
import { MultiAccount } from "../types/Account";
|
||||
|
||||
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
|
||||
import CloseIcon from "@material-ui/icons/Close";
|
||||
|
||||
/**
|
||||
* Basic props for Welcome page
|
||||
*/
|
||||
interface IWelcomeProps extends withSnackbarProps {
|
||||
classes: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic state for welcome page
|
||||
*/
|
||||
interface IWelcomeState {
|
||||
/**
|
||||
* The custom-defined URL to the logo to display
|
||||
*/
|
||||
logoUrl?: string;
|
||||
|
||||
/**
|
||||
* The custom-defined URL to the background image to display
|
||||
*/
|
||||
backgroundUrl?: string;
|
||||
|
||||
/**
|
||||
* The custom-defined brand name of this app
|
||||
*/
|
||||
brandName?: string;
|
||||
|
||||
/**
|
||||
* The custom-defined server address to register to
|
||||
*/
|
||||
registerBase?: string;
|
||||
|
||||
/**
|
||||
* Whether this version of Hyperspace has federation
|
||||
*/
|
||||
federates?: boolean;
|
||||
|
||||
/**
|
||||
* Whether Hyperspace is ready to get the auth code
|
||||
*/
|
||||
proceedToGetCode: boolean;
|
||||
|
||||
/**
|
||||
* The currently "logged-in" user after the first step
|
||||
*/
|
||||
user: string;
|
||||
|
||||
/**
|
||||
* Whether the user's input errors
|
||||
*/
|
||||
userInputError: boolean;
|
||||
|
||||
/**
|
||||
* The user input error message, if any
|
||||
*/
|
||||
userInputErrorMessage: string;
|
||||
|
||||
/**
|
||||
* The app's client ID, if registered
|
||||
*/
|
||||
clientId?: string;
|
||||
|
||||
/**
|
||||
* The app's client secret, if registered
|
||||
*/
|
||||
clientSecret?: string;
|
||||
|
||||
/**
|
||||
* The authorization URL provided by Mastodon from the
|
||||
* client ID and secret
|
||||
*/
|
||||
authUrl?: string;
|
||||
|
||||
/**
|
||||
* Whether a previous login attempt is present
|
||||
*/
|
||||
foundSavedLogin: boolean;
|
||||
|
||||
/**
|
||||
* Whether Hyperspace is in the process of authorizing
|
||||
*/
|
||||
authorizing: boolean;
|
||||
|
||||
/**
|
||||
* The custom-defined license for the Hyperspace source code
|
||||
*/
|
||||
license?: string;
|
||||
|
||||
/**
|
||||
* The custom-defined URL to the source code of Hyperspace
|
||||
*/
|
||||
repo?: string;
|
||||
|
||||
/**
|
||||
* The default address to redirect to. Used in login inits and
|
||||
* when the authorization code completes.
|
||||
*/
|
||||
defaultRedirectAddress: string;
|
||||
|
||||
/**
|
||||
* Whether the redirect address is set to 'dynamic'.
|
||||
*/
|
||||
redirectAddressIsDynamic: boolean;
|
||||
|
||||
/**
|
||||
* Whether the authorization dialog for the emergency login is
|
||||
* open.
|
||||
*/
|
||||
openAuthDialog: boolean;
|
||||
|
||||
/**
|
||||
* The authorization code to fetch an access token with
|
||||
*/
|
||||
authCode: string;
|
||||
|
||||
/**
|
||||
* Whether the Emergency Mode has been initiated
|
||||
*/
|
||||
emergencyMode: boolean;
|
||||
|
||||
/**
|
||||
* The current app version
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* Whether we are in the process of adding a new account or not
|
||||
*/
|
||||
willAddAccount: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base class for the Welcome page.
|
||||
*
|
||||
* The Welcome page is responsible for handling the registration,
|
||||
* login, and authorization of accounts into the Hyperspace app.
|
||||
*/
|
||||
class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
||||
/**
|
||||
* The associated Mastodon client to handle logins/authorizations
|
||||
* with
|
||||
*/
|
||||
client: any;
|
||||
|
||||
/**
|
||||
* Construct the state and other components of the Welcome page
|
||||
* @param props The properties passed onto the page
|
||||
*/
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
// Set up our state
|
||||
this.state = {
|
||||
proceedToGetCode: false,
|
||||
user: "",
|
||||
|
@ -89,6 +205,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
authorizing: false,
|
||||
userInputErrorMessage: "",
|
||||
defaultRedirectAddress: "",
|
||||
redirectAddressIsDynamic: false,
|
||||
openAuthDialog: false,
|
||||
authCode: "",
|
||||
emergencyMode: false,
|
||||
|
@ -96,47 +213,52 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
willAddAccount: false
|
||||
};
|
||||
|
||||
// Read the configuration data and update the state
|
||||
getConfig()
|
||||
.then((result: any) => {
|
||||
if (result !== undefined) {
|
||||
let config: Config = result;
|
||||
if (result.location === "dynamic") {
|
||||
|
||||
// Warn if the location is dynamic (unexpected behavior)
|
||||
if (config.location === "dynamic") {
|
||||
console.warn(
|
||||
"Redirect URI is set to dynamic, which may affect how sign-in works for some users. Careful!"
|
||||
);
|
||||
}
|
||||
|
||||
// Reset to mastodon.online if the location is a disallowed
|
||||
// domain.
|
||||
if (
|
||||
inDisallowedDomains(result.registration.defaultInstance)
|
||||
) {
|
||||
console.warn(
|
||||
`The default instance field in config.json contains an unsupported domain (${result.registration.defaultInstance}), so it's been reset to mastodon.social.`
|
||||
`The default instance field in config.json contains an unsupported domain (${result.registration.defaultInstance}), so it's been reset to mastodon.online.`
|
||||
);
|
||||
result.registration.defaultInstance = "mastodon.social";
|
||||
result.registration.defaultInstance = "mastodon.online";
|
||||
}
|
||||
|
||||
// Update the state as per the configuration
|
||||
this.setState({
|
||||
logoUrl: config.branding
|
||||
? result.branding.logo
|
||||
: "logo.png",
|
||||
backgroundUrl: config.branding
|
||||
? result.branding.background
|
||||
: "background.png",
|
||||
brandName: config.branding
|
||||
? result.branding.name
|
||||
: "Hyperspace",
|
||||
registerBase: config.registration
|
||||
? result.registration.defaultInstance
|
||||
: "",
|
||||
logoUrl: config.branding?.logo ?? "logo.png",
|
||||
backgroundUrl:
|
||||
config.branding?.background ?? "background.png",
|
||||
brandName: config.branding?.name ?? "Hyperspace",
|
||||
registerBase:
|
||||
result.registration?.defaultInstance ?? "",
|
||||
federates: config.federation.universalLogin,
|
||||
license: config.license.url,
|
||||
repo: config.repository,
|
||||
defaultRedirectAddress:
|
||||
config.location != "dynamic"
|
||||
config.location !== "dynamic"
|
||||
? config.location
|
||||
: `https://${window.location.host}`,
|
||||
redirectAddressIsDynamic: config.location === "dynamic",
|
||||
version: config.version
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
// Print an error if the config wasn't found.
|
||||
.catch(() => {
|
||||
console.error(
|
||||
"config.json is missing. If you want to customize Hyperspace, please include config.json"
|
||||
|
@ -144,6 +266,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for any existing logins and tokens before presenting
|
||||
* the login page
|
||||
*/
|
||||
componentDidMount() {
|
||||
if (localStorage.getItem("login")) {
|
||||
this.getSavedSession();
|
||||
|
@ -154,34 +280,55 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user field in the state
|
||||
* @param user The string to update the state to
|
||||
*/
|
||||
updateUserInfo(user: string) {
|
||||
this.checkForErrors(user);
|
||||
this.setState({ user });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the auth code in the state
|
||||
* @param code The authorization code to update the state to
|
||||
*/
|
||||
updateAuthCode(code: string) {
|
||||
this.setState({ authCode: code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of the authorization dialog
|
||||
*/
|
||||
toggleAuthDialog() {
|
||||
this.setState({ openAuthDialog: !this.state.openAuthDialog });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the app is ready to open the authorization
|
||||
* process.
|
||||
*/
|
||||
readyForAuth() {
|
||||
if (localStorage.getItem("baseurl")) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return localStorage.getItem("baseurl") !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current access token and base URL
|
||||
*/
|
||||
clear() {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("baseurl");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current saved session from the previous login
|
||||
* attempt and update the state
|
||||
*/
|
||||
getSavedSession() {
|
||||
let loginData = localStorage.getItem("login");
|
||||
if (loginData) {
|
||||
if (localStorage.getItem("login") === null) {
|
||||
return;
|
||||
}
|
||||
let loginData = localStorage.getItem("login") as string;
|
||||
let session: SaveClientSession = JSON.parse(loginData);
|
||||
this.setState({
|
||||
clientId: session.clientId,
|
||||
|
@ -190,8 +337,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
emergencyMode: session.emergency
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the emergency login mode.
|
||||
*/
|
||||
startEmergencyLogin() {
|
||||
if (!this.state.emergencyMode) {
|
||||
this.createEmergencyLogin();
|
||||
|
@ -199,70 +348,94 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
this.toggleAuthDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the registration process.
|
||||
* @returns A URL pointing to the signup page of the base as defined
|
||||
* in the config's `registerBase` field
|
||||
*/
|
||||
startRegistration() {
|
||||
if (this.state.registerBase) {
|
||||
return "https://" + this.state.registerBase + "/auth/sign_up";
|
||||
} else {
|
||||
return "https://joinmastodon.org/#getting-started";
|
||||
}
|
||||
return this.state.registerBase
|
||||
? "https://" + this.state.registerBase + "/auth/sign_up"
|
||||
: "https://joinmastodon.org/#getting-started";
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the keyboard and start the login procedure if the user
|
||||
* presses the ENTER/RETURN key
|
||||
* @param event The keyboard event
|
||||
*/
|
||||
watchUsernameField(event: any) {
|
||||
if (event.keyCode === 13) this.startLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the keyboard and start the emergency login auth procedure
|
||||
* if the user presses the ENTER/RETURN key
|
||||
* @param event The keyboard event
|
||||
*/
|
||||
watchAuthField(event: any) {
|
||||
if (event.keyCode === 13) this.authorizeEmergencyLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "logged-in" user by reading the username string
|
||||
* from the first field on the login page.
|
||||
* @param user The user string to parse
|
||||
* @returns The base URL of the user
|
||||
*/
|
||||
getLoginUser(user: string) {
|
||||
// Did the user include "@"? They probably are not from the
|
||||
// server defined in config
|
||||
if (user.includes("@")) {
|
||||
if (this.state.federates) {
|
||||
let newUser = user;
|
||||
this.setState({ user: newUser });
|
||||
return "https://" + newUser.split("@")[1];
|
||||
} else {
|
||||
let newUser = `${user}@${
|
||||
this.state.registerBase
|
||||
? this.state.registerBase
|
||||
: "mastodon.social"
|
||||
}`;
|
||||
let newUser = `${user}@${this.state.registerBase ??
|
||||
"mastodon.online"}`;
|
||||
this.setState({ user: newUser });
|
||||
return (
|
||||
"https://" +
|
||||
(this.state.registerBase
|
||||
? this.state.registerBase
|
||||
: "mastodon.social")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let newUser = `${user}@${
|
||||
this.state.registerBase
|
||||
? this.state.registerBase
|
||||
: "mastodon.social"
|
||||
}`;
|
||||
this.setState({ user: newUser });
|
||||
return (
|
||||
"https://" +
|
||||
(this.state.registerBase
|
||||
? this.state.registerBase
|
||||
: "mastodon.social")
|
||||
"https://" + (this.state.registerBase ?? "mastodon.online")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
startLogin() {
|
||||
let error = this.checkForErrors();
|
||||
// Otherwise, treat them as if they're from the server
|
||||
else {
|
||||
let newUser = `${user}@${this.state.registerBase ??
|
||||
"mastodon.online"}`;
|
||||
this.setState({ user: newUser });
|
||||
return "https://" + (this.state.registerBase ?? "mastodon.online");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the user string for any errors and then create a client with an
|
||||
* ID and secret to start the authorization process.
|
||||
* @param bypassChecks Whether to bypass the checks in place.
|
||||
*/
|
||||
startLogin(bypassChecks: boolean = false) {
|
||||
// Check if we have errored
|
||||
let error = this.checkForErrors(this.state.user, bypassChecks);
|
||||
|
||||
// If we didn't, create the Hyperspace app to register onto that Mastodon
|
||||
// server.
|
||||
if (!error) {
|
||||
// Define the app's scopes and base URL
|
||||
const scopes = "read write follow";
|
||||
const baseurl = this.getLoginUser(this.state.user);
|
||||
localStorage.setItem("baseurl", baseurl);
|
||||
|
||||
// Create the Hyperspace app
|
||||
createHyperspaceApp(
|
||||
this.state.brandName ? this.state.brandName : "Hyperspace",
|
||||
this.state.brandName ?? "Hyperspace",
|
||||
scopes,
|
||||
baseurl,
|
||||
getRedirectAddress(this.state.defaultRedirectAddress)
|
||||
).then((resp: any) => {
|
||||
)
|
||||
// If we succeeded, create a login attempt for later reference
|
||||
.then((resp: any) => {
|
||||
let saveSessionForCrashing: SaveClientSession = {
|
||||
clientId: resp.clientId,
|
||||
clientSecret: resp.clientSecret,
|
||||
|
@ -273,30 +446,51 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
"login",
|
||||
JSON.stringify(saveSessionForCrashing)
|
||||
);
|
||||
|
||||
// Finally, update the state
|
||||
this.setState({
|
||||
clientId: resp.clientId,
|
||||
clientSecret: resp.clientSecret,
|
||||
authUrl: resp.url,
|
||||
proceedToGetCode: true
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar(
|
||||
`Failed to register app at ${baseurl.replace(
|
||||
"https://",
|
||||
""
|
||||
)}`
|
||||
);
|
||||
console.error(err);
|
||||
});
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an emergency mode login. This is usually initiated when the
|
||||
* "click-to-authorize" method fails and the user needs to copy and paste
|
||||
* an authorization code manually.
|
||||
*/
|
||||
createEmergencyLogin() {
|
||||
console.log("Creating an emergency login...");
|
||||
|
||||
// Set up the scopes and base URL
|
||||
const scopes = "read write follow";
|
||||
const baseurl =
|
||||
localStorage.getItem("baseurl") ||
|
||||
this.getLoginUser(this.state.user);
|
||||
|
||||
// Register the Mastodon app with the Mastodon server
|
||||
Mastodon.registerApp(
|
||||
this.state.brandName ? this.state.brandName : "Hyperspace",
|
||||
this.state.brandName ?? "Hyperspace",
|
||||
{
|
||||
scopes: scopes
|
||||
},
|
||||
baseurl
|
||||
).then((appData: any) => {
|
||||
)
|
||||
// If we succeed, create a login attempt for later reference
|
||||
.then((appData: any) => {
|
||||
let saveSessionForCrashing: SaveClientSession = {
|
||||
clientId: appData.clientId,
|
||||
clientSecret: appData.clientSecret,
|
||||
|
@ -307,6 +501,8 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
"login",
|
||||
JSON.stringify(saveSessionForCrashing)
|
||||
);
|
||||
|
||||
// Finally, update the state
|
||||
this.setState({
|
||||
clientId: appData.clientId,
|
||||
clientSecret: appData.clientSecret,
|
||||
|
@ -315,14 +511,24 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the URL to redirect to an authorization sequence from an emergency
|
||||
* login.
|
||||
*
|
||||
* Since Hyperspace reads the auth code from the URL, we need to redirect to
|
||||
* a URL with the code inside to trigger an auth
|
||||
*/
|
||||
authorizeEmergencyLogin() {
|
||||
let redirAddress =
|
||||
this.state.defaultRedirectAddress === "desktop"
|
||||
? "hyperspace://hyperspace/app"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: this.state.defaultRedirectAddress;
|
||||
window.location.href = `${redirAddress}/?code=${this.state.authCode}#/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a login attempt from a session
|
||||
*/
|
||||
resumeLogin() {
|
||||
let loginData = localStorage.getItem("login");
|
||||
if (loginData) {
|
||||
|
@ -337,19 +543,31 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
checkForErrors(): boolean {
|
||||
/**
|
||||
* Check the user input string for any possible errors
|
||||
* @param username The username to read and check for errors
|
||||
* @param bypassesInstanceNameCheck Whether to bypass the instance name validation process. Defaults to false.
|
||||
* @return Whether an error has occured in the validation.
|
||||
*/
|
||||
checkForErrors(
|
||||
username: string,
|
||||
bypassesInstanceNameCheck: boolean = false
|
||||
): boolean {
|
||||
let userInputError = false;
|
||||
let userInputErrorMessage = "";
|
||||
|
||||
if (this.state.user === "") {
|
||||
// Is the user string blank?
|
||||
if (username === "") {
|
||||
userInputError = true;
|
||||
userInputErrorMessage = "Username cannot be blank.";
|
||||
this.setState({ userInputError, userInputErrorMessage });
|
||||
return true;
|
||||
} else {
|
||||
if (this.state.user.includes("@")) {
|
||||
if (username.includes("@")) {
|
||||
if (this.state.federates && this.state.federates === true) {
|
||||
let baseUrl = this.state.user.split("@")[1];
|
||||
let baseUrl = username.split("@")[1];
|
||||
|
||||
// Is the user's domain in the disallowed list?
|
||||
if (inDisallowedDomains(baseUrl)) {
|
||||
this.setState({
|
||||
userInputError: true,
|
||||
|
@ -357,6 +575,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
});
|
||||
return true;
|
||||
} else {
|
||||
if (bypassesInstanceNameCheck) {
|
||||
return false;
|
||||
}
|
||||
// Are we unable to ping the server?
|
||||
axios
|
||||
.get(
|
||||
"https://instances.social/api/1.0/instances/show?name=" +
|
||||
|
@ -370,7 +592,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
.catch((err: Error) => {
|
||||
let userInputError = true;
|
||||
let userInputErrorMessage =
|
||||
"Instance name is invalid.";
|
||||
"We couldn't recognize this instance.";
|
||||
this.setState({
|
||||
userInputError,
|
||||
userInputErrorMessage
|
||||
|
@ -379,10 +601,8 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
});
|
||||
}
|
||||
} else if (
|
||||
this.state.user.includes(
|
||||
this.state.registerBase
|
||||
? this.state.registerBase
|
||||
: "mastodon.social"
|
||||
username.includes(
|
||||
this.state.registerBase ?? "mastodon.online"
|
||||
)
|
||||
) {
|
||||
this.setState({ userInputError, userInputErrorMessage });
|
||||
|
@ -403,30 +623,47 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the URL and determine whether or not there's an auth code
|
||||
* in the URL. If there is, try to authorize and get the access
|
||||
* token for storage.
|
||||
*/
|
||||
checkForToken() {
|
||||
let location = window.location.href;
|
||||
|
||||
// Is there an auth code?
|
||||
if (location.includes("?code=")) {
|
||||
let code = parseUrl(location).query.code as string;
|
||||
this.setState({ authorizing: true });
|
||||
let loginData = localStorage.getItem("login");
|
||||
|
||||
// If there's login data, try to fetch an access token
|
||||
if (loginData) {
|
||||
let clientLoginSession: SaveClientSession = JSON.parse(
|
||||
loginData
|
||||
);
|
||||
|
||||
getConfig().then((resp: any) => {
|
||||
if (resp === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let conf: Config = resp;
|
||||
|
||||
let redirectUrl: string | undefined =
|
||||
this.state.emergencyMode ||
|
||||
clientLoginSession.authUrl.includes(
|
||||
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
|
||||
)
|
||||
? undefined
|
||||
: getRedirectAddress(conf.location);
|
||||
|
||||
Mastodon.fetchAccessToken(
|
||||
clientLoginSession.clientId,
|
||||
clientLoginSession.clientSecret,
|
||||
code,
|
||||
localStorage.getItem("baseurl") as string,
|
||||
this.state.emergencyMode
|
||||
? undefined
|
||||
: clientLoginSession.authUrl.includes(
|
||||
"urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob"
|
||||
)
|
||||
? undefined
|
||||
: window.location.protocol === "hyperspace:"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: `https://${window.location.host}`
|
||||
redirectUrl
|
||||
)
|
||||
.then((tokenData: any) => {
|
||||
localStorage.setItem(
|
||||
|
@ -436,38 +673,52 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
window.location.href =
|
||||
window.location.protocol === "hyperspace:"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: `https://${window.location.host}/#/`;
|
||||
: this.state.defaultRedirectAddress;
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.props.enqueueSnackbar(
|
||||
`Couldn't authorize ${
|
||||
this.state.brandName
|
||||
? this.state.brandName
|
||||
: "Hyperspace"
|
||||
}: ${err.name}`,
|
||||
`Couldn't authorize ${this.state.brandName ??
|
||||
"Hyperspace"}: ${err.name}`,
|
||||
{ variant: "error" }
|
||||
);
|
||||
console.error(err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the app's main view after a login.
|
||||
*/
|
||||
redirectToApp() {
|
||||
window.location.href =
|
||||
window.location.protocol === "hyperspace:"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: this.state.redirectAddressIsDynamic
|
||||
? `https://${window.location.host}/#/`
|
||||
: this.state.defaultRedirectAddress + "/#/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the title bar for macOS
|
||||
*/
|
||||
titlebar() {
|
||||
const { classes } = this.props;
|
||||
if (isDarwinApp()) {
|
||||
return (
|
||||
<div className={classes.titleBarRoot}>
|
||||
<Typography className={classes.titleBarText}>
|
||||
{this.state.brandName
|
||||
? this.state.brandName
|
||||
: "Hyperspace"}
|
||||
{this.state.brandName ?? "Hyperspace"}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the multi-user account panel
|
||||
*/
|
||||
showMultiAccount() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -481,11 +732,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
<ListItem
|
||||
onClick={() => {
|
||||
loginWithAccount(account);
|
||||
window.location.href =
|
||||
window.location.protocol ===
|
||||
"hyperspace:"
|
||||
? "hyperspace://hyperspace/app/"
|
||||
: `https://${window.location.host}/#/`;
|
||||
this.redirectToApp();
|
||||
}}
|
||||
button={true}
|
||||
>
|
||||
|
@ -527,12 +774,15 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the main landing panel
|
||||
*/
|
||||
showLanding() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<Typography variant="h5">Sign in</Typography>
|
||||
<Typography>with your Mastodon account</Typography>
|
||||
<Typography>with your fediverse account</Typography>
|
||||
<div className={classes.middlePadding} />
|
||||
<TextField
|
||||
variant="outlined"
|
||||
|
@ -542,25 +792,37 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
onChange={event => this.updateUserInfo(event.target.value)}
|
||||
onKeyDown={event => this.watchUsernameField(event)}
|
||||
error={this.state.userInputError}
|
||||
onBlur={() => this.checkForErrors()}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">@</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{this.state.userInputError ? (
|
||||
<Typography color="error">
|
||||
{this.state.userInputErrorMessage}
|
||||
{this.state.userInputErrorMessage ===
|
||||
"We couldn't recognize this instance." ? (
|
||||
<span>
|
||||
<br />
|
||||
<Link
|
||||
// className={classes.welcomeLink}
|
||||
onClick={() => this.startLogin(true)}
|
||||
>
|
||||
Try anyway
|
||||
</Link>
|
||||
</span>
|
||||
) : null}
|
||||
</Typography>
|
||||
) : null}
|
||||
<br />
|
||||
{this.state.registerBase && this.state.federates ? (
|
||||
<Typography variant="caption">
|
||||
Not from{" "}
|
||||
<b>
|
||||
{this.state.registerBase
|
||||
? this.state.registerBase
|
||||
: "noinstance"}
|
||||
</b>
|
||||
? Sign in with your{" "}
|
||||
<b>{this.state.registerBase ?? "noinstance"}</b>? Sign
|
||||
in with your{" "}
|
||||
<Link
|
||||
href="https://docs.joinmastodon.org/usage/decentralization/#addressing-people"
|
||||
href="https://docs.joinmastodon.org/user/signup/#address"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="secondary"
|
||||
|
@ -610,19 +872,19 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the login auth panel
|
||||
*/
|
||||
showLoginAuth() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<Typography variant="h5">
|
||||
Howdy,{" "}
|
||||
{this.state.user ? this.state.user.split("@")[0] : "user"}
|
||||
Howdy, {this.state.user?.split("@")[0] ?? "user"}
|
||||
</Typography>
|
||||
<Typography>
|
||||
To continue, finish signing in on your instance's website
|
||||
and authorize{" "}
|
||||
{this.state.brandName ? this.state.brandName : "Hyperspace"}
|
||||
.
|
||||
and authorize {this.state.brandName ?? "Hyperspace"}.
|
||||
</Typography>
|
||||
<div className={classes.middlePadding} />
|
||||
<div style={{ display: "flex" }}>
|
||||
|
@ -631,7 +893,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
href={this.state.authUrl ? this.state.authUrl : ""}
|
||||
href={this.state.authUrl ?? ""}
|
||||
>
|
||||
Authorize
|
||||
</Button>
|
||||
|
@ -651,8 +913,10 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the emergency login panel
|
||||
*/
|
||||
showAuthDialog() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.openAuthDialog}
|
||||
|
@ -672,7 +936,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
href={this.state.authUrl ? this.state.authUrl : ""}
|
||||
href={this.state.authUrl ?? ""}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
@ -705,14 +969,17 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the authorizing panel
|
||||
*/
|
||||
showAuthorizationLoader() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<Typography variant="h5">Authorizing</Typography>
|
||||
<Typography>
|
||||
Please wait while Hyperspace authorizes with Mastodon. This
|
||||
shouldn't take long...
|
||||
Please wait while Hyperspace authorizes with your instance.
|
||||
This shouldn't take long...
|
||||
</Typography>
|
||||
<div className={classes.middlePadding} />
|
||||
<div style={{ display: "flex" }}>
|
||||
|
@ -725,6 +992,9 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page
|
||||
*/
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
|
@ -733,20 +1003,15 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
<div
|
||||
className={classes.root}
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
this.state !== null
|
||||
? this.state.backgroundUrl
|
||||
: "background.png"
|
||||
})`
|
||||
backgroundImage: `url(${this.state.backgroundUrl ??
|
||||
"background.png"})`
|
||||
}}
|
||||
>
|
||||
<Paper className={classes.paper}>
|
||||
<img
|
||||
className={classes.logo}
|
||||
alt={
|
||||
this.state ? this.state.brandName : "Hyperspace"
|
||||
}
|
||||
src={this.state ? this.state.logoUrl : "logo.png"}
|
||||
alt={this.state.brandName ?? "Hyperspace"}
|
||||
src={this.state.logoUrl ?? "logo.png"}
|
||||
/>
|
||||
<br />
|
||||
<Fade in={true}>
|
||||
|
@ -782,9 +1047,8 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
<Link
|
||||
className={classes.welcomeLink}
|
||||
href={
|
||||
this.state.repo
|
||||
? this.state.repo
|
||||
: "https://github.com/hyperspacedev"
|
||||
this.state.repo ??
|
||||
"https://github.com/hyperspacedev"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
|
@ -797,16 +1061,15 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
<Link
|
||||
className={classes.welcomeLink}
|
||||
href={
|
||||
this.state.license
|
||||
? this.state.license
|
||||
: "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
this.state.license ??
|
||||
"https://thufie.lain.haus/NPL.html"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
License
|
||||
</Link>{" "}
|
||||
|
|
||||
|{" "}
|
||||
<Link
|
||||
className={classes.welcomeLink}
|
||||
href="https://github.com/hyperspacedev/hyperspace/issues/new"
|
||||
|
@ -817,10 +1080,7 @@ class WelcomePage extends Component<IWelcomeProps, IWelcomeState> {
|
|||
</Link>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{this.state.brandName
|
||||
? this.state.brandName
|
||||
: "Hypersapce"}{" "}
|
||||
v.
|
||||
{this.state.brandName ?? "Hypersapce"} v.
|
||||
{this.state.version}{" "}
|
||||
{this.state.brandName &&
|
||||
this.state.brandName !== "Hyperspace"
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -42,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];
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -44,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";
|
||||
|
@ -13,6 +12,7 @@ type SettingsTemplate = {
|
|||
displayAllOnNotificationBadge: boolean;
|
||||
defaultVisibility: string;
|
||||
imposeCharacterLimit: boolean;
|
||||
canSendNotifications: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -101,7 +101,9 @@ export function createUserDefaults() {
|
|||
clearNotificationsOnRead: false,
|
||||
displayAllOnNotificationBadge: false,
|
||||
defaultVisibility: "public",
|
||||
imposeCharacterLimit: true
|
||||
imposeCharacterLimit: true,
|
||||
isMasonryLayout: false,
|
||||
canSendNotifications: false
|
||||
};
|
||||
|
||||
let settings = [
|
||||
|
@ -110,7 +112,9 @@ export function createUserDefaults() {
|
|||
"clearNotificationsOnRead",
|
||||
"displayAllOnNotificationBadge",
|
||||
"defaultVisibility",
|
||||
"imposeCharacterLimit"
|
||||
"imposeCharacterLimit",
|
||||
"isMasonryLayout",
|
||||
"canSendNotifications"
|
||||
];
|
||||
|
||||
migrateExistingSettings();
|
||||
|
@ -124,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(
|
||||
|
|