Compare commits

...

202 Commits

Author SHA1 Message Date
Marquis Kurt d2f1f23006
Update config.yml 2021-01-07 13:31:47 -05:00
Marquis Kurt 6f6fe991f5
Merge pull request #236 from hyperspacedev/dependabot/npm_and_yarn/axios-0.21.1
Bump axios from 0.19.2 to 0.21.1
2021-01-05 13:15:32 -05:00
dependabot[bot] a74bd76b10
Bump axios from 0.19.2 to 0.21.1
Bumps [axios](https://github.com/axios/axios) from 0.19.2 to 0.21.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.21.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.19.2...v0.21.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-05 07:44:38 +00:00
Marquis Kurt 01ece3cb7b
Delete feature_request.md 2020-12-25 22:03:36 -05:00
Marquis Kurt e8ec0a47f9
Update config.yml 2020-12-25 22:03:23 -05:00
Marquis Kurt 119066fa91
Increment build number 2020-10-03 18:43:41 -04:00
Marquis Kurt 5620b9d505
Update MAS script 2020-10-03 13:52:09 -04:00
Marquis Kurt e0f0f3f2d8
Update README 2020-10-03 13:49:55 -04:00
Marquis Kurt 27acb9a151
Merge pull request #231 from hyperspacedev/develop/1.1.4
v1.1.4
2020-10-03 13:45:24 -04:00
Marquis Kurt 07be3af687
Merge pull request #230 from hyperspacedev/feat/check-bypass
Added instance name validation bypass
2020-10-03 13:31:54 -04:00
Marquis Kurt a94180b15c
Added instance name validation bypass
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-10-03 12:47:45 -04:00
Marquis Kurt d3729ee76f
Add slashes to end of app links in Welcome (fixes #224)
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-10-03 11:33:00 -04:00
Marquis Kurt 4e8a372234
Migrate from mastodon.social -> mastodon.online 2020-10-03 11:25:43 -04:00
Marquis Kurt ded85d38a1
Merge pull request #228 from hyperspacedev/chore/icons-and-settings
Update macOS icon for Big Sur, change default instance
2020-10-03 10:39:12 -04:00
Marquis Kurt dd7b3d7b78
Merge branch 'develop/1.1.4' into chore/icons-and-settings 2020-10-01 13:21:19 -04:00
Marquis Kurt f640cd2963
Update macOS icon, version string, and default instance
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-10-01 13:19:33 -04:00
Travis Kohlbeck 914ee67d95
Merge pull request #223 from hyperspacedev/GH-208-input-error-state
Don't show input error state when not empty
2020-07-08 18:39:26 -04:00
Marquis Kurt 54388e806a
Merge branch 'develop/1.1.4' into GH-208-input-error-state 2020-07-08 18:29:53 -04:00
Travis Kohlbeck b7e27b703c Update package.json and config.json versions 2020-07-08 18:26:35 -04:00
Travis Kohlbeck 214262fdb3 Don't show input error state when not empty 2020-07-06 12:47:40 -04:00
Marquis Kurt 3d201eddf9
Merge pull request #221 from hyperspacedev/chore/electron-builder-updates
Update Electron Builder scripts
2020-07-02 20:26:10 -04:00
Marquis Kurt 993f5718fe
Add Mac App Store link 🎉 2020-07-02 20:07:06 -04:00
Marquis Kurt 50b758c24b
Update README 2020-07-02 13:55:15 -04:00
Marquis Kurt ac8d58c247
Fix Mac CI alignment 2020-07-02 12:29:59 -04:00
Marquis Kurt 43a83c1c84
Attempt to fix Mac CI workflow 2020-07-02 12:28:55 -04:00
Marquis Kurt 78e4fef3aa
Update workflows 2020-07-02 12:25:08 -04:00
Marquis Kurt d1a70faf09
Reorganize NPM scripts, update workflows 2020-07-02 12:20:45 -04:00
Marquis Kurt cd3147b2ce
Update electron-builder configs 2020-07-02 11:25:39 -04:00
Marquis Kurt f51e00f54a
Merge pull request #214 from hyperspacedev/develop-1.1.3-fix-search
v1.1.3 - Fix search page results
2020-06-22 21:43:13 -04:00
Marquis Kurt a34ab59272
Update dependencies and config 2020-06-22 18:21:44 -04:00
Marquis Kurt c854d406a7
Remove unused ParsedQuery import 2020-06-19 10:11:58 -04:00
Marquis Kurt a1aa9c09dc
Fix search page results 2020-06-19 09:51:51 -04:00
Marquis Kurt 1a132bdaf4
Update package.json 2020-06-19 09:50:50 -04:00
Marquis Kurt a98a8f04fe
Update GitHub content, disable Jira issue creation 2020-06-17 22:50:52 -04:00
Marquis Kurt 63fc2f6b13
Update package lock 2020-06-17 21:41:34 -04:00
Marquis Kurt b16d4e09ad
Merge pull request #209 from hyperspacedev/develop-1.1.2
v1.1.2 (Release Check)
2020-06-17 21:39:23 -04:00
Marquis Kurt 81ed4bb8ad
Update electron-builder 2020-06-17 21:29:50 -04:00
Marquis Kurt 3723795b7f
Merge pull request #212 from hyperspacedev/develop-1.1.2-electron-update
Update to Electron v9
2020-06-17 20:59:32 -04:00
Marquis Kurt 6656a3749e
Make likable paths across desktop app work 2020-06-17 20:45:09 -04:00
Marquis Kurt 33b9f9d76d
Update isDarkMode to point to nativeTheme 2020-06-17 20:25:00 -04:00
Marquis Kurt e58208ba8d
Remove extraneous parens and brackets
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-06-17 19:41:55 -04:00
Marquis Kurt f591abfba8
Update electron to 9.0.4, run updates
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-06-17 19:17:07 -04:00
Marquis Kurt 9c345e741c
Merge pull request #207 from hyperspacedev/GH-206-notification-reset
Return out of notification toggle when browser is unsupported
2020-05-28 10:49:28 -04:00
Marquis Kurt 6b006e17cc
Merge pull request #205 from hyperspacedev/develop-1.1.2-clarify-welcome
Add '@' symbol adornment, refactor Welcome
2020-05-28 10:21:38 -04:00
Marquis Kurt e6826e08d8
Run Prettier for the umptillionth time
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-05-27 11:14:22 -04:00
Marquis Kurt 5bf6fce1da
Run Prettier on files that missed Prettier check
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-05-27 10:57:03 -04:00
Marquis Kurt f52daad91c
Return out of notif toggle when browser is unsupported
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-05-27 10:46:24 -04:00
Marquis Kurt fc9f1ebf21
Fix Prettier on Settings utilities... again...
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-05-27 08:49:12 -04:00
Marquis Kurt 8897035fe5
Merge pull request #204 from hyperspacedev/HD-61-update-avatar
Update header avatar when user changes avatar
2020-05-27 08:41:19 -04:00
Marquis Kurt 29a272a6f8
Run prettier on settings utility 2020-05-18 11:20:14 -04:00
Marquis Kurt 93994ab9d7
Add '@' symbol adornment, refactor to use nullish coalescing where appropriate 2020-05-18 11:14:21 -04:00
Travis Kohlbeck 908f7ebb93 Update correct avatar 2020-05-05 13:37:25 -04:00
Travis Kohlbeck 3b1a5ff937 Hook together avatar update and header (broken) 2020-05-05 13:10:25 -04:00
Marquis Kurt c3b3b2c1de
Merge pull request #203 from hyperspacedev/HD-68-about-page
Update about page configurations
2020-05-04 10:45:09 -04:00
Marquis Kurt f2da91468b
Add bug report and feature request buttons 2020-04-22 10:28:29 -04:00
Marquis Kurt a70ccd013f
Update Prettier, refactor expressions with nullish coaslescing 2020-04-22 10:13:54 -04:00
Marquis Kurt f960f4a0bc
Move config setting to state in finally 2020-04-22 09:58:50 -04:00
Marquis Kurt 95b950ab70
Bump version strings 2020-04-22 09:35:37 -04:00
Marquis Kurt a86b97c46c
Merge pull request #201 from hyperspacedev/develop-1.1.1
HD-64 #done
2020-04-17 16:43:33 -04:00
Marquis Kurt 1ba63086f1
Merge pull request #199 from hyperspacedev:HD-65-close-header-menu
HD-65 #done
2020-04-17 15:38:57 -04:00
Travis Kohlbeck a501ed0744 Close header menu on click menu item 2020-04-14 13:56:30 -04:00
Marquis Kurt 8432ad5a24
Merge pull request #196 from hyperspacedev/HD-63-location-slash-fix
HD-63 #done
2020-04-14 13:32:04 -04:00
Travis Kohlbeck 1ec52d9d86 Modify console message slightly
- Prettify if statement?
2020-04-14 12:26:42 -04:00
Marquis Kurt 564a9d26f9
Filter out "desktop" and "dynamic" in location (#193, HD-63 #in-review) 2020-04-13 09:55:11 -04:00
Marquis Kurt 9238f8851a
Bump version to v1.1.1 2020-04-13 09:48:38 -04:00
Marquis Kurt d6f9225b3b
Update package lock 2020-04-11 19:28:52 -04:00
Marquis Kurt 5797296f38
Change config to release mode 2020-04-11 19:22:57 -04:00
Marquis Kurt 02c1b5ba89
Update config 2020-04-11 19:16:27 -04:00
Marquis Kurt 2e75118986
Update config 2020-04-10 10:08:16 -04:00
Marquis Kurt 3aa094db7c
Change URL and version strings 2020-04-10 10:06:30 -04:00
Marquis Kurt 4c2aceb3f1
Merge pull request #190 from hyperspacedev/develop-1.1.0-beta5
HD-51 #done
2020-03-30 19:27:20 -04:00
Marquis Kurt cad678c3b1
Merge pull request #186 from hyperspacedev/HD-56-notifications-rework
HD-56 #done
2020-03-30 19:12:39 -04:00
Marquis Kurt c1616c83ad
Remove optional chaining (boo) 2020-03-30 19:04:04 -04:00
Marquis Kurt 3a640ab51f
Merge branch 'develop-1.1.0-beta5' into HD-56-notifications-rework 2020-03-30 19:00:29 -04:00
Marquis Kurt 0afdfc647a
Merge pull request #184 from hyperspacedev/HD-50-announcements
HD-50 #done
2020-03-30 18:50:41 -04:00
Marquis Kurt 436004250e
Merge pull request #189 from hyperspacedev/HD-57-welcome-repoint
HD-57 #done
2020-03-30 18:49:58 -04:00
Marquis Kurt 9ef922ccfe
Merge pull request #185 from hyperspacedev/develop-1.1.0-beta5-deptree
Update project dependencies and scripts
2020-03-28 23:08:06 -04:00
Marquis Kurt 07ec71f438
Implement HD-57 2020-03-28 20:43:43 -04:00
Marquis Kurt edf1f01b34
Squash warnings 2020-03-24 13:20:59 -04:00
Marquis Kurt ffe9a97d93
More work on HD-56 2020-03-24 13:06:40 -04:00
Marquis Kurt eed62306ca
Set a default canSendNotifications in settings generation 2020-03-24 11:02:15 -04:00
Marquis Kurt 7cb5b1c692
Update Patreon/funding page 2020-03-18 12:48:52 -04:00
Marquis Kurt 009ed5044e
Diable CI flag in standard CI script 2020-03-18 12:40:00 -04:00
Marquis Kurt 0c675969c5
Update README, CI scripts, and fix Search location issue 2020-03-18 12:33:32 -04:00
Marquis Kurt d73e6ce678
Update dependencies 2020-03-18 12:26:49 -04:00
Marquis Kurt 67e94d8126
Create announcement type and page 2020-03-13 16:41:45 -04:00
Marquis Kurt 8e3512a95e
Add announcement links to app bar and drawer 2020-03-13 16:02:50 -04:00
Marquis Kurt 6590e947c9
Update README with localhost changes 2020-03-02 14:56:15 -05:00
Marquis Kurt cae5cb301d
Update package version 2020-03-02 14:52:33 -05:00
Marquis Kurt 2f7c7d0a91
Merge pull request #179 from hyperspacedev/develop-1.1.0-beta4
HD-47 #done
2020-02-28 17:43:25 -05:00
Marquis Kurt 4e64a1d4f9
Merge pull request #178 from hyperspacedev/develop-1.1.0-mod-and-localtest
Auto-set localhost:3000 URL when testing
2020-02-25 22:24:29 -05:00
Marquis Kurt afd3a7f31c
Update getConfig to change location field if running in dev mode 2020-02-25 17:17:04 -05:00
Marquis Kurt 1e54b21672
Merge pull request #177 from hyperspacedev/HD-49-post-name-overflow
HD-49 #done
2020-02-20 18:00:57 -05:00
Travis Kohlbeck b4b32efd7d prettifies post styles 2020-02-19 09:11:19 -05:00
Travis Kohlbeck f908e8656a keeps reblogger on same line as author when there is enough room 2020-02-19 09:10:09 -05:00
Travis Kohlbeck 78d7b02085 prettifies files, I need to format on save... 2020-02-18 17:34:20 -05:00
Travis Kohlbeck a168b614b2 hides account name with ellipsis instead of line break, fixes colors 2020-02-18 17:23:42 -05:00
Travis Kohlbeck 37c01fc150 prevents line breaks for author names, reblog-icon-reblogger 2020-02-18 17:15:47 -05:00
Marquis Kurt 5838039fef
Merge pull request #172 from hyperspacedev/HD-44-uneven-masonry-columns
HD-44 #done
2020-02-17 17:27:16 -05:00
Marquis Kurt 16219c7e51
Merge pull request #174 from hyperspacedev/HD-45-notif-follow-btn
HD-45 #done
2020-02-17 17:26:48 -05:00
Travis Kohlbeck d4663e9bf1 updates menu label based on un/following 2020-02-17 16:53:10 -05:00
Travis Kohlbeck 3542a88d98 replaces following fetch with relationships fetch, enables unfollowing 2020-02-17 16:50:30 -05:00
Travis Kohlbeck 8459102c74 removes follow account button on click 2020-02-17 14:53:35 -05:00
Travis Kohlbeck 3200086a6d prettifies Timeline.tsx 2020-02-17 12:54:04 -05:00
Marquis Kurt cc43d90607
Merge branch 'develop-1.1.0-beta4' into HD-44-uneven-masonry-columns 2020-02-17 08:19:51 -05:00
Marquis Kurt 5f50a6d6dc
Empty commit for PR re-check
Signed-off-by: Marquis Kurt <software@marquiskurt.net>
2020-02-17 08:14:40 -05:00
Travis Kohlbeck 166736e0bc forgot to prettify Post 2020-02-16 16:56:29 -05:00
Travis Kohlbeck 2620bb8282 adds infinite scroll setting, prevents post rerendering on list updates, adds debounce to scroll event listener 2020-02-16 16:43:49 -05:00
Marquis Kurt 33d42991f3
Comment out unnecessary console logs and update getConfig 2020-02-16 14:49:55 -05:00
Marquis Kurt 931244cb5a
Pull changes from b4 2020-02-16 14:20:50 -05:00
Marquis Kurt 5bcb31ee87
Merge pull request #171 from hyperspacedev/HD-31-favorite-reblog-state-issue
HD-31 #done
2020-02-16 14:17:57 -05:00
Marquis Kurt 334812fb06
Merge pull request #169 from hyperspacedev/HD-46-notify-overlap
HD-46 #done
2020-02-16 14:17:35 -05:00
Travis Kohlbeck cd57edbb32 fixes duplicate posts bug 2020-02-16 11:13:57 -05:00
Travis Kohlbeck f8ec25a050 fixes like/retoot bugs 2020-02-16 01:39:18 -05:00
Marquis Kurt 163d7e693e
Add docs to new Dictionary type 2020-02-07 12:45:25 -05:00
Marquis Kurt 9a2f7f6ef4
Show a menu in mobile on notifications 2020-02-07 12:44:01 -05:00
Marquis Kurt af5bd4a12a
Fix login issues due to null redirectAddress on reload 2020-02-07 10:09:47 -05:00
Marquis Kurt 20d9730a8e
Bump package version to 1.1.0-beta4 2020-01-25 15:46:36 -05:00
Marquis Kurt 8c6df6f1fd
Merge pull request #168 from hyperspacedev/develop-1.1.0-beta3
HD-34 #done
2020-01-25 15:30:44 -05:00
Marquis Kurt 8696251824
Merge pull request #167 from hyperspacedev/HD-48-stop-draft-after-post
HD-48 #done
2020-01-25 15:06:07 -05:00
Marquis Kurt a922d58d20
Clear text field in state after posting in Compose 2020-01-24 11:11:26 -05:00
Marquis Kurt c94b483b47
Call userLoggedIn in PrivateRoute 2020-01-24 10:49:10 -05:00
Travis Kohlbeck 073efe137b loads more posts at scroll position, disables post zoom effect 2020-01-23 22:36:25 -05:00
Marquis Kurt f2fe5cbb31
Merge pull request #165 from hyperspacedev/HD-36-profile-search-masonry
[HD-36] Enable masonry layout for Search and Profile pages
2020-01-23 13:19:02 -05:00
Travis Kohlbeck 4042661d90 maximizes masonry layout on profile page 2020-01-23 12:29:00 -05:00
Travis Kohlbeck 97b74ac5cd prettifies files 2020-01-23 11:02:46 -05:00
Travis Kohlbeck bbf9dafb07 adds masonry layout to search page, adds forgotten semi-colons to ProfilePage.tsx 2020-01-23 10:48:23 -05:00
Travis Kohlbeck 8e3dd1cd02 adds masonry layout to profile page 2020-01-23 10:21:58 -05:00
Marquis Kurt 0941927295
Merge pull request #164 from hyperspacedev/HD-41-gifv
HD-41 #done
2020-01-23 09:11:38 -05:00
Marquis Kurt 030597593a
Merge pull request #153 from hyperspacedev/HD-37-upgrade-licensing
HD-37 #done
2020-01-23 08:52:15 -05:00
Marquis Kurt e2b9513145
Merge pull request #151 from hyperspacedev/HD-35-skinny-timeline
HD-35 #done
2020-01-23 08:51:00 -05:00
Marquis Kurt c429583225
Merge pull request #155 from hyperspacedev/HD-39-strip-account-html
HD-39 #done
2020-01-23 08:50:04 -05:00
Travis Kohlbeck cf2e299c19 loads and loops gif/mp4 "images" 2020-01-22 21:35:22 -05:00
Marquis Kurt a46d9c6c0f
Read the config in checkForToken 2020-01-21 10:41:45 -05:00
Marquis Kurt 8d14ff78df
Finish docs for Welcome page 2020-01-21 10:05:05 -05:00
Marquis Kurt c00bca93bc
Add redirectToApp in Welcome, start Welcome docs 2020-01-21 09:40:47 -05:00
Marquis Kurt e7d797595a
Rewrite getReblogAuthors 2020-01-16 10:41:00 -05:00
Marquis Kurt 7a0827780e
Upgrade license to NPLv4 2020-01-13 20:48:51 -05:00
Marquis Kurt 5d26434855
Deprecate Home, Local, Public pages 2020-01-07 11:25:02 -05:00
Marquis Kurt 66eb312fe7
Replace Home, Local, Public pages with TimelinePage 2020-01-07 11:24:53 -05:00
Marquis Kurt 77c32f4ec2
Extend PrivateRoute to allow render functions 2020-01-07 11:24:34 -05:00
Marquis Kurt a12b14d10d
Make classes optional on TimelinePage 2020-01-07 11:24:08 -05:00
Marquis Kurt 6e8185a090
Create timeline component 2020-01-07 11:04:13 -05:00
Marquis Kurt 32c2a19369
Bump package.json to 1.1.0-beta3 2020-01-07 10:41:45 -05:00
Marquis Kurt 338b9118a6
Merge branch 'develop-1.1.0-beta2' into staging 2019-12-23 15:49:04 -05:00
Marquis Kurt a1f8c209b7
Remove change verfication 2019-12-23 15:02:45 -05:00
Marquis Kurt a95d06664d
Do cat-and-grp instead 2019-12-23 14:59:08 -05:00
Marquis Kurt 3de4499494
Fix uses and use shell 2019-12-23 14:57:42 -05:00
Marquis Kurt bf4cdd6d8c
Try verifying changes 2019-12-23 14:56:45 -05:00
Marquis Kurt 877d2512fc
Try using Python instead 2019-12-23 14:03:56 -05:00
Marquis Kurt f35f3ef9dc
Fix file path issue 2019-12-23 12:27:45 -05:00
Marquis Kurt 1abf50f3a0
Try Powershell instead of sed? 2019-12-23 12:26:26 -05:00
Marquis Kurt ba8183aad3
Change sed delimiter 2019-12-23 11:58:38 -05:00
Marquis Kurt 5e89034702
Right, not a bash script 2019-12-23 11:53:08 -05:00
Marquis Kurt 760ac9ddd0
Try modifying config.json and uploading dir 2019-12-23 11:50:59 -05:00
Marquis Kurt c0a6f3a5f9
Test Windows exe upload 2019-12-23 11:39:20 -05:00
Marquis Kurt 3528057e2e
Apply homepage fix to local and public 2019-12-22 16:38:37 -05:00
Marquis Kurt d67f89d6de
Prettify failed files 2019-12-22 16:28:58 -05:00
Marquis Kurt 0b995218ea
Update package-lock.json 2019-12-22 16:19:09 -05:00
Marquis Kurt 860375d6fc
Merge pull request #148 from hyperspacedev/HD-5-masonry-layout
HD-5 #done
2019-12-22 16:18:09 -05:00
Travis Kohlbeck 313ff79116 adds back original padding when masonry disabled 2019-12-22 16:05:24 -05:00
Marquis Kurt 68ff7e8663
Merge pull request #134 from hyperspacedev/HD-25-clipboard-fire-fix
HD-25 #done
2019-12-22 14:57:12 -05:00
Marquis Kurt d07d099c2f
Merge branch 'develop-1.1.0-beta2' into HD-25-clipboard-fire-fix 2019-12-22 14:56:52 -05:00
Marquis Kurt 89c4339c85
Merge pull request #137 from hyperspacedev/HD-28-back-button
HD-28 #done
2019-12-22 14:55:17 -05:00
Marquis Kurt f33e4e6562
Merge pull request #138 from hyperspacedev/HD-7-cached-drafts
HD-7 #done
2019-12-22 14:54:36 -05:00
Marquis Kurt 2d7af3f717
Merge pull request #141 from hyperspacedev/HD-3-follow-once
HD-3 #done
2019-12-22 14:54:13 -05:00
Marquis Kurt ca23a4927c
Merge pull request #147 from hyperspacedev/develop-1.1.0-beta2-post-refactor
Refactor toggle and Mastodon URL methods
2019-12-22 13:55:34 -05:00
Marquis Kurt 40f1c8abd4
Refactor toggle and Mastodon URL methods 2019-12-22 13:36:52 -05:00
Travis Kohlbeck 6ef1daaec9 changes settings title for masonry layout 2019-12-21 21:28:23 -05:00
Travis Kohlbeck 95e5849be8 adds masonry layout to local and public feeds 2019-12-21 18:10:24 -05:00
Travis Kohlbeck af4f6e1d53 adds settings option for masonry layout 2019-12-21 17:57:26 -05:00
Travis Kohlbeck e178a01fff implements masonry layout with breakpoints 2019-12-21 17:14:50 -05:00
Marquis Kurt 4cdc67b94f
Merge pull request #144 from travisk-codes/master
Updates appearance of post/retoot authors
2019-12-20 11:40:08 -05:00
Marquis Kurt 1e3505bc7e
Merge branch 'travisk-codes-master' into develop-1.1.0-beta2 2019-12-20 11:39:23 -05:00
Travis Kohlbeck fc8afb9000
updates appearance of post/retoot authors 2019-12-20 11:34:20 -05:00
Travis Kohlbeck e5cfaf0a44 logs post state at various points, logs post state on click post header 2019-12-19 15:18:26 -05:00
Marquis Kurt 1bf92dd91d
Merge branch 'master' into develop-1.1.0-beta2 2019-12-18 14:22:13 -05:00
Marquis Kurt c25b612fdb
Merge branch 'master' into staging 2019-12-18 14:21:33 -05:00
Marquis Kurt 9966aec312
Merge pull request #145 from hyperspacedev/github-actions-apple-sign
Run certificate installs on CI only if Desktop initiates it
2019-12-18 14:20:05 -05:00
Marquis Kurt 58b4a12cca
Remove show GitHub.actor 2019-12-18 14:11:10 -05:00
Marquis Kurt 7901e0f900
Limit to main desktop contributors 2019-12-18 14:09:05 -05:00
Marquis Kurt d49449c2bb
Figure out my actor 2019-12-18 13:55:32 -05:00
Marquis Kurt c9fb045e4f
Ditto pre.v commit 2019-12-18 13:53:25 -05:00
Marquis Kurt 9a5473d842
Ditto prev. 2 commits
Run pre-build setup based on who ran it
2019-12-18 13:52:48 -05:00
Marquis Kurt 9bfe5f7e26
Ditto prev. commit 2019-12-18 13:49:20 -05:00
Marquis Kurt 2a6b1274b7
Run event only if software@marquiskurt.net initiated it 2019-12-18 13:48:34 -05:00
Travis Kohlbeck add9590e0b updates appearance of post/retoot authors 2019-12-18 13:03:16 -05:00
Marquis Kurt 8a39f36039
Manually apply #140 (HD-29 #done) 2019-12-18 11:08:51 -05:00
Marquis Kurt b18ab2be25
Merge pull request #143 from travisk-codes/master
HD-30 #done
2019-12-18 11:05:00 -05:00
Travis Kohlbeck 240c1e98d8 replaces null with dummy header 2019-12-17 21:04:16 -05:00
Travis Kohlbeck 1a464339e3 forced prettier formatting 2019-12-17 21:00:58 -05:00
Marquis Kurt 96d448bbd4
Add docs for Notifications page (HD-24) 2019-12-12 11:52:23 -05:00
Marquis Kurt 4185d6bad8
Get relationship and evaluate before following (HD-3) 2019-12-12 11:44:14 -05:00
Marquis Kurt bd15fb3e63
Add drafts utilities, draft segment, and draft type 2019-12-11 14:32:07 -05:00
Marquis Kurt 774e2eb817
Document AppLayout and pass in window location hash 2019-12-11 13:37:42 -05:00
Marquis Kurt 2ffa744e52
Re-instate original idea 2019-12-11 13:21:28 -05:00
Marquis Kurt e761dc4f97
Rely on just window history 2019-12-11 12:55:38 -05:00
Marquis Kurt 93f135e93b
Create basic back button (HD-28) 2019-12-11 12:27:42 -05:00
Marquis Kurt 6c8035465a
Add documentation to the Compose page (HD-24) 2019-12-03 16:19:38 -05:00
Marquis Kurt b4496720a3
Add getContrastText to titleBarText class 2019-12-03 15:53:11 -05:00
Marquis Kurt e768a24e94
Rename methods, run uplaod event only if media list is not empty 2019-12-03 15:46:17 -05:00
Marquis Kurt 927c9c06a1
Bump Hyperspace package version 2019-12-03 15:33:10 -05:00
80 changed files with 11926 additions and 12130 deletions

2
.github/FUNDING.yml vendored
View File

@ -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

View File

@ -1,10 +1,9 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug] Issue title"
labels: ''
assignees: ''
title: "Issue title"
labels: "bug"
assignees: ""
---
**Describe the bug**
@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
@ -24,9 +24,10 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**App Information (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari] (if applicable)
- Version [e.g. 22]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari] (if applicable)
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -3,17 +3,36 @@ name: Build Linux Client
on: [push, pull_request]
jobs:
build_linux:
runs-on: ubuntu-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build-desktop-linux
build_linux:
runs-on: ubuntu-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Change desktop field
run: |
from json import load, dump
json_dict = {}
with open('public/config.json', 'r') as file:
json_dict = load(file)
json_dict["location"] = "desktop"
with open('public/config.json', 'w+') as out:
dump(json_dict, out)
shell: python
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build:linux
- name: Upload Linux executables
uses: actions/upload-artifact@v1
if: success()
with:
name: "Linux executables (output dir)"
path: dist

View File

@ -3,45 +3,65 @@ name: Build macOS Client
on: [push, pull_request]
jobs:
build_darwin:
runs-on: macos-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Run pre-build setup
run: |
echo "Downloading certificates and profiles..."
echo "$ascCertificates" > certs.b64
echo "$ascMasProfile" > mas.b64
echo "$ascMacProfile" > mac.b64
echo "$ascEntitlementsMas" > entmas.b64
echo "$ascEntitlementsMac" > entmac.b64
echo "$ascInfoPlist" > info.b64
build_darwin:
runs-on: macos-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Install certificates and entitlements
if: github.actor == 'alicerunsonfedora' || github.actor == 'Nomad1556' || github.actor == 'audmaxwell'
run: |
echo "Downloading certificates and profiles..."
echo "$ascCertificates" > certs.b64
echo "$ascMasProfile" > mas.b64
echo "$ascMacProfile" > mac.b64
echo "$ascEntitlementsMas" > entmas.b64
echo "$ascEntitlementsMac" > entmac.b64
echo "$ascInfoPlist" > info.b64
echo "Installing certificates and profiles..."
base64 --decode certs.b64 > Certificates.p12
base64 --decode mas.b64 > desktop/embedded.provisionprofile
base64 --decode mac.b64 > desktop/nonmas.provisionprofile
base64 --decode entmas.b64 > desktop/entitlements.mas.plist
base64 --decode entmac.b64 > desktop/entitlements.mac.plist
base64 --decode info.b64 > desktop/info.plist
security add-generic-password -a "appleseed@marquiskurt.net" -w "$ascPassword" -s "AC_PASSWORD"
sudo security import Certificates.p12 -P "$ascCertsPassword" -k /Library/Keychains/System.keychain
env:
ascPassword: ${{ secrets.ASC_PASSWORD }}
ascCertificates: ${{ secrets.ASC_CERTS }}
ascCertsPassword: ${{ secrets.ASC_CERTS_PASSWORD }}
ascMacProfile: ${{ secrets.ASC_NONMAS_PROFILE }}
ascMasProfile: ${{ secrets.ASC_EMBEDDED_PROFILE }}
ascEntitlementsMas: ${{ secrets.ASC_MAS_ENTITLEMENTS }}
ascEntitlementsMac: ${{ secrets.ASC_MAC_ENTITLEMENTS }}
ascInfoPlist: ${{ secrets.ASC_INFO_PLIST }}
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build-desktop-darwin-nosign
echo "Installing certificates and profiles..."
base64 --decode certs.b64 > Certificates.p12
base64 --decode mas.b64 > desktop/embedded.provisionprofile
base64 --decode mac.b64 > desktop/nonmas.provisionprofile
base64 --decode entmas.b64 > desktop/entitlements.mas.plist
base64 --decode entmac.b64 > desktop/entitlements.mac.plist
base64 --decode info.b64 > desktop/info.plist
security add-generic-password -a "appleseed@marquiskurt.net" -w "$ascPassword" -s "AC_PASSWORD"
sudo security import Certificates.p12 -P "$ascCertsPassword" -k /Library/Keychains/System.keychain
env:
ascPassword: ${{ secrets.ASC_PASSWORD }}
ascCertificates: ${{ secrets.ASC_CERTS }}
ascCertsPassword: ${{ secrets.ASC_CERTS_PASSWORD }}
ascMacProfile: ${{ secrets.ASC_NONMAS_PROFILE }}
ascMasProfile: ${{ secrets.ASC_EMBEDDED_PROFILE }}
ascEntitlementsMas: ${{ secrets.ASC_MAS_ENTITLEMENTS }}
ascEntitlementsMac: ${{ secrets.ASC_MAC_ENTITLEMENTS }}
ascInfoPlist: ${{ secrets.ASC_INFO_PLIST }}
- name: Change desktop field
run: |
from json import load, dump
json_dict = {}
with open('public/config.json', 'r') as file:
json_dict = load(file)
json_dict["location"] = "desktop"
with open('public/config.json', 'w+') as out:
dump(json_dict, out)
shell: python
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build:mac-unsigned
- name: Upload macOS (unsigned) bundle
uses: actions/upload-artifact@v1
if: success()
with:
name: "macOS bundle (output dir)"
path: dist

View File

@ -3,21 +3,21 @@ name: Node CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [8.x, 10.x, 12.x]
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
env:
CI: true
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [10.x, 12.x]
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
env:
CI: false

View File

@ -3,17 +3,36 @@ name: Build Windows Client
on: [push, pull_request]
jobs:
build_win:
runs-on: windows-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build-desktop-win
build_win:
runs-on: windows-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Change desktop field
run: |
from json import load, dump
json_dict = {}
with open('public/config.json', 'r') as file:
json_dict = load(file)
json_dict["location"] = "desktop"
with open('public/config.json', 'w+') as out:
dump(json_dict, out)
shell: python
- name: Install dependencies and build
run: |
npm install
npm run build --if-present
npm run build:win
- name: Upload Windows executable
uses: actions/upload-artifact@v1
if: success()
with:
name: "Windows executable (output dir)"
path: dist

View File

@ -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 }}

View File

@ -3,18 +3,18 @@ name: Prettier
on: [pull_request]
jobs:
prettier:
runs-on: ubuntu-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Install dependencies and run Prettier
run: |
npm install
npm run check-prettier
env:
CI: true
prettier:
runs-on: ubuntu-latest
steps:
- name: Clone source code
uses: actions/checkout@v1
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Install dependencies and run Prettier
run: |
npm install
npm run test:prettier
env:
CI: true

5
.gitignore vendored
View File

@ -72,4 +72,7 @@ desktop/*.plist
desktop/*.provisionprofile
# JetBrains IDEA directory
.idea/
.idea/
# Pesky macOS files
**/**.DS_Store

View File

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

267
README.md
View File

@ -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 &rsaquo;**](https://hyperspace.marquiskurt.net/download)
[**Get latest release &rsaquo;**](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 &rsaquo;](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 &rsaquo; 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).

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

44
ebuild/mas.yml Normal file
View File

@ -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

35
ebuild/standard.yml Normal file
View File

@ -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

18513
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}
]
}

View File

@ -2,6 +2,10 @@
Hyperspace has been made possible by the efforts of the Hyperspace development team and these amazing contributors on Patreon:
- LucasAzazer
<!-- (Add contributors here) -->
Thanks for your continued support in helping us create the fluffiest client for the fediverse!
Thanks for your continued support in helping us create the fluffiest client for the fediverse!
## Previous Contributors
- LucasAzazer

View File

@ -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"

View File

@ -2,10 +2,17 @@
// Electron script to run Hyperspace as an app
// © 2018 Hyperspace developers. Licensed under NPL v1.
const { app, Menu, protocol, BrowserWindow, shell, systemPreferences } = require('electron');
const windowStateKeeper = require('electron-window-state');
const { autoUpdater } = require('electron-updater');
const path = require('path');
const {
app,
Menu,
protocol,
BrowserWindow,
shell,
systemPreferences
} = require("electron");
const windowStateKeeper = require("electron-window-state");
const { autoUpdater } = require("electron-updater");
const path = require("path");
// Check for any updates to the app
autoUpdater.checkForUpdatesAndNotify();
@ -18,7 +25,7 @@ let mainWindow;
// file:// protocol, which is necessary for Mastodon to redirect
// to when authorizing Hyperspace.
protocol.registerSchemesAsPrivileged([
{ scheme: 'hyperspace', privileges: { standard: true, secure: true } }
{ scheme: "hyperspace", privileges: { standard: true, secure: true } }
]);
/**
@ -33,80 +40,81 @@ function isDarwin() {
* Register the protocol for Hyperspace
*/
function registerProtocol() {
protocol.registerFileProtocol('hyperspace', (request, callback) => {
// Check to make sure we're doing a GET request
if (request.method !== "GET") {
callback({error: -322});
return null;
protocol.registerFileProtocol(
"hyperspace",
(request, callback) => {
// Check to make sure we're doing a GET request
if (request.method !== "GET") {
callback({ error: -322 });
return null;
}
// Check to make sure we're actually working with a hyperspace
// protocol and that the host is 'hyperspace'
const parsedUrl = new URL(request.url);
if (parsedUrl.protocol !== "hyperspace:") {
callback({ error: -302 });
return;
}
if (parsedUrl.host !== "hyperspace") {
callback({ error: -105 });
return;
}
// Convert the parsed URL to a list of strings.
const target = parsedUrl.pathname.split("/");
// Check that the target isn't trying to go somewhere
// else. If it is, throw a "FILE_NOT_FOUND" error
if (target[0] !== "") {
callback({ error: -6 });
return;
}
// Check if the last target item in the list is empty.
// If so, replace it with "index.html" so that it can
// load a page.
if (target[target.length - 1] === "") {
target[target.length - 1] = "index.html";
}
// Check the middle target and redirect to the appropriate
// build files of the desktop app when running.
let baseDirectory;
if (target[1] === "app" || target[1] === "oauth") {
baseDirectory = __dirname + "/../build/";
} else {
// If it doesn't match above, throw a "FILE_NOT_FOUND" error.
callback({ error: -6 });
return;
}
// Create a normalized version of the string.
baseDirectory = path.normalize(baseDirectory);
// Check to make sure the target isn't trying to go out of bounds.
// If it is, throw a "FILE_NOT_FOUND" error.
const relTarget = path.normalize(path.join(...target.slice(2)));
if (relTarget.startsWith("..")) {
callback({ error: -6 });
return;
}
// Create the absolute target path and return it.
const absTarget = path.join(baseDirectory, relTarget);
callback({ path: absTarget });
},
error => {
if (error) console.error("Failed to register protocol");
}
// Check to make sure we're actually working with a hyperspace
// protocol and that the host is 'hyperspace'
const parsedUrl = new URL(request.url);
if (parsedUrl.protocol !== "hyperspace:") {
callback({error: -302});
return;
}
if (parsedUrl.host !== "hyperspace") {
callback({error: -105});
return;
}
// Convert the parsed URL to a list of strings.
const target = parsedUrl.pathname.split("/");
// Check that the target isn't trying to go somewhere
// else. If it is, throw a "FILE_NOT_FOUND" error
if (target[0] !== "") {
callback({error: -6});
return;
}
// Check if the last target item in the list is empty.
// If so, replace it with "index.html" so that it can
// load a page.
if (target[target.length -1] === "") {
target[target.length -1] = "index.html";
}
// Check the middle target and redirect to the appropriate
// build files of the desktop app when running.
let baseDirectory;
if (target[1] === "app" || target[1] === "oauth") {
baseDirectory = __dirname + "/../build/";
} else {
// If it doesn't match above, throw a "FILE_NOT_FOUND" error.
callback({error: -6});
}
// Create a normalized version of the string.
baseDirectory = path.normalize(baseDirectory);
// Check to make sure the target isn't trying to go out of bounds.
// If it is, throw a "FILE_NOT_FOUND" error.
const relTarget = path.normalize(path.join(...target.slice(2)));
if (relTarget.startsWith('..')) {
callback({error: -6});
return;
}
// Create the absolute target path and return it.
const absTarget = path.join(baseDirectory, relTarget);
callback({ path: absTarget });
}, (error) => {
if (error) console.error('Failed to register protocol');
});
);
}
/**
* Create the window and all of its properties
*/
function createWindow() {
// Create a window state manager that keeps track of the width
// and height of the main window.
let mainWindowState = windowStateKeeper({
@ -115,68 +123,72 @@ function createWindow() {
});
// Create a browser window with some settings
mainWindow = new BrowserWindow(
{
// Use the values from the window state keeper
// to draw the window exactly as it was left.
// If not possible, derive it from the default
// values defined earlier.
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
// Set a minimum width to prevent element collisions.
minWidth: 300,
mainWindow = new BrowserWindow({
// Use the values from the window state keeper
// to draw the window exactly as it was left.
// If not possible, derive it from the default
// values defined earlier.
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
// Set important web preferences.
webPreferences: {nodeIntegration: true},
// Set a minimum width to prevent element collisions.
minWidth: 300,
// Set some preferences that are specific to macOS.
titleBarStyle: 'hiddenInset',
vibrancy: "sidebar",
transparent: isDarwin(),
backgroundColor: isDarwin()? "#80000000": "#FFF",
// Set important web preferences.
webPreferences: { nodeIntegration: true },
// Hide the window until the contents load
show: false
}
);
// Set some preferences that are specific to macOS.
titleBarStyle: "hiddenInset",
vibrancy: "sidebar",
transparent: isDarwin(),
backgroundColor: isDarwin() ? "#80000000" : "#FFF",
// Hide the window until the contents load
show: false
});
// Set up event listeners to track changes in the window state.
mainWindowState.manage(mainWindow);
// Load the main app and open the index page.
mainWindow.loadURL("hyperspace://hyperspace/app/");
// Watch for a change in macOS's dark mode and reload the window to apply changes, as well as accent color
if (isDarwin()) {
systemPreferences.subscribeNotification('AppleInterfaceThemeChangedNotification', () => {
if (mainWindow != null) {
mainWindow.webContents.reload();
systemPreferences.subscribeNotification(
"AppleInterfaceThemeChangedNotification",
() => {
if (mainWindow != null) {
mainWindow.webContents.reload();
}
}
});
);
systemPreferences.subscribeNotification('AppleColorPreferencesChangedNotification', () => {
if (mainWindow != null) {
mainWindow.webContents.reload();
systemPreferences.subscribeNotification(
"AppleColorPreferencesChangedNotification",
() => {
if (mainWindow != null) {
mainWindow.webContents.reload();
}
}
});
);
}
// Only show the window when ready
mainWindow.once('ready-to-show', () => {
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});
// Delete the window when closed
mainWindow.on('closed', () => {
mainWindow = null
mainWindow.on("closed", () => {
mainWindow = null;
});
// Hijack any links with a blank target and open them in the default
// browser instead of a new Electron window
mainWindow.webContents.on('new-window', (event, url) => {
mainWindow.webContents.on("new-window", (event, url) => {
event.preventDefault();
shell.openExternal(url);
});
@ -199,18 +211,17 @@ function safelyGoTo(url) {
* Create the menu bar and attach it to a window
*/
function createMenubar() {
// Create an instance of the Menu class
let menu = Menu;
// Create a menu bar template
const menuBar = [
{
label: 'File',
label: "File",
submenu: [
{
label: 'New Window',
accelerator: 'CmdOrCtrl+N',
label: "New Window",
accelerator: "CmdOrCtrl+N",
click() {
if (mainWindow == null) {
registerProtocol();
@ -219,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',
click () {
label: "Open Dev Tools",
click() {
try {
mainWindow.webContents.openDevTools();
} catch (err) {
console.error("Couldn't open dev tools: " + err);
}
},
accelerator: 'Shift+CmdOrCtrl+I'
accelerator: "Shift+CmdOrCtrl+I"
},
{ type: 'separator' },
{ role: 'togglefullscreen' }
{ type: "separator" },
{ role: "togglefullscreen" }
]
},
{
label: "Timelines",
submenu: [
{
label: 'Home',
label: "Home",
accelerator: "CmdOrCtrl+0",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/home")
safelyGoTo("hyperspace://hyperspace/app/#/home");
}
},
{
label: 'Local',
label: "Local",
accelerator: "CmdOrCtrl+1",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/local")
safelyGoTo("hyperspace://hyperspace/app/#/local");
}
},
{
label: 'Public',
label: "Public",
accelerator: "CmdOrCtrl+2",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/public")
safelyGoTo("hyperspace://hyperspace/app/#/public");
}
},
{
label: 'Messages',
label: "Messages",
accelerator: "CmdOrCtrl+3",
click() {
safelyGoTo("hyperspace://hyperspace/app/#/messages")
safelyGoTo("hyperspace://hyperspace/app/#/messages");
}
},
{ type: 'separator' },
{ 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();

View File

@ -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

View File

@ -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")]: {

View File

@ -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,8 +737,11 @@ export class AppLayout extends Component<any, IAppLayoutState> {
<Avatar
alt="You"
src={
this.state
.currentUser
this.props.avatarURL
? this.props
.avatarURL
: this.state
.currentUser
? this.state
.currentUser
.avatar_static
@ -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()}

View File

@ -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}`}
/>
);
}
}

View File

@ -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";

View File

@ -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 }}

View File

@ -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 {

View File

@ -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"

View File

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

View File

@ -78,14 +78,14 @@ export const ProfileRoute = (rest: any, component: Component) => (
export const PrivateRoute = (props: IPrivateRouteProps) => {
const { component, render, ...rest } = props;
const redir = (comp: any) =>
userLoggedIn() ? comp : <Redirect to="/welcome" />;
return (
<Route
{...rest}
render={(compProps: any) =>
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;
}

8
src/interfaces/utils.tsx Normal file
View File

@ -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;
}

View File

@ -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>
<LinkableAvatar
to={`/profile/${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.id
: 0
}`}
src={
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin
.avatar_static
: ""
}
>
<PersonIcon />
</LinkableAvatar>
{this.state.hyperspaceAdmin ? (
<LinkableAvatar
to={`/profile/${this.state
.hyperspaceAdmin?.id ?? 0}`}
src={
this.state.hyperspaceAdmin
?.avatar_static ?? ""
}
>
<PersonIcon />
</LinkableAvatar>
) : (
<ListItemAvatar>
<Avatar>
<PersonIcon />
</Avatar>
</ListItemAvatar>
)}
</ListItemAvatar>
<ListItemText
primary="App provider"
@ -189,38 +214,34 @@ class AboutPage extends Component<any, IAboutPageState> {
this.state.hyperspaceAdmin
.display_name ||
"@" + this.state.hyperspaceAdmin.acct
: "No provider set in config"
: this.state.hyperspaceAdminName ??
"No provider set in config"
}
/>
<ListItemSecondaryAction>
<Tooltip title="Send a post or message">
<LinkableIconButton
to={`/compose?visibility=${
this.state.federated
? "public"
: "private"
}&acct=${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin
.acct
: ""
}`}
>
<ChatIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="View profile">
<LinkableIconButton
to={`/profile/${
this.state.hyperspaceAdmin
? this.state.hyperspaceAdmin.id
: 0
}`}
>
<AssignmentIndIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
{this.state.hyperspaceAdmin ? (
<ListItemSecondaryAction>
<Tooltip title="Send a post or message">
<LinkableIconButton
to={`/compose?visibility=${
this.state.federated
? "public"
: "private"
}&acct=${this.state.hyperspaceAdmin
?.acct ?? ""}`}
>
<ChatIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="View profile">
<LinkableIconButton
to={`/profile/${this.state
.hyperspaceAdmin?.id ?? 0}`}
>
<AssignmentIndIcon />
</LinkableIconButton>
</Tooltip>
</ListItemSecondaryAction>
) : null}
</ListItem>
<ListItem>
<ListItemAvatar>
@ -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"

View File

@ -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);
});
}

211
src/pages/Announcements.tsx Normal file
View File

@ -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));

View File

@ -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>

View File

@ -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
}
});

View File

@ -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

View File

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

View File

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

View File

@ -8,7 +8,6 @@ import {
ListItemText,
CircularProgress,
ListItemAvatar,
Avatar,
ListItemSecondaryAction,
Tooltip,
Typography

View File

@ -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) => {
let notifications: [Notification] = resp.data;
this.setState({
notifications,
viewIsLoading: false,
viewDidLoad: true
});
})
.catch((err: Error) => {
this.setState({
viewDidLoad: true,
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
});
/**
* Perform pre-mount tasks
*/
async componentWillMount() {
try {
// Get the list of notifications
let resp: any = await this.client.get("/notifications");
let notifications: [Notification] = resp.data;
// initialize all menus as closed
let notifMenus: Dictionary<boolean> = {};
notifications.forEach(
(n: Notification) => (notifMenus[n.id] = false)
);
// compile list of all notification account ids
let accountIds: string[] = [];
notifications.forEach(notif => {
if (!accountIds.includes(notif.account.id)) {
accountIds.push(notif.account.id);
}
});
// store relationships in id-relationship pairs
resp = await this.client.get(`/accounts/relationships`, {
id: accountIds
});
let relationships: Dictionary<Relationship> = {};
resp.data.forEach((relation: Relationship) => {
relationships[relation.id] = relation;
});
this.setState({
notifications,
relationships,
viewIsLoading: false,
viewDidLoad: true,
mobileMenuOpen: notifMenus
});
} catch (e) {
this.setState({
viewDidLoad: true,
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: e.message
});
}
}
/**
* Perform post-mount tasks.
*/
componentDidMount() {
// Start listening for new notifications after fetching.
this.streamNotifications();
}
/**
* Set up a stream listener and keep updating notifications.
*/
streamNotifications() {
this.streamListener = this.client.stream("/streaming/user");
@ -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>
<Tooltip title="Follow account">
<IconButton
onClick={() =>
this.followMember(notif.account)
}
>
<PersonAddIcon />
</IconButton>
</Tooltip>
{!this.state.relationships[notif.account.id]
.following ? (
<Tooltip title="Follow account">
<IconButton
onClick={() =>
this.toggleFollow(notif.account)
}
>
<PersonAddIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Unfollow account">
<IconButton
onClick={() =>
this.toggleFollow(notif.account)
}
>
<PersonRemoveIcon />
</IconButton>
</Tooltip>
)}
</span>
) : notif.status ? (
<span>
@ -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()}

View File

@ -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
}
});

View File

@ -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 ? (

View File

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

View File

@ -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()
) : (

View File

@ -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
});
setUserDefaultBool(
"enablePushNotifications",
!this.state.pushNotificationsEnabled
);
if (!browserSupportsNotificationRequests()) {
return;
}
if (Notification.permission === "default") {
getNotificationRequestPermission()
.then(permission => {
if (permission === "granted") {
setUserDefaultBool(
"enablePushNotifications",
!this.state.pushNotificationsEnabled
);
this.setState({
pushNotificationsEnabled: !this.state
.pushNotificationsEnabled
});
} else if (permission === "denied") {
this.props.enqueueSnackbar(
"Permission request was denied.",
{ variant: "error" }
);
}
})
.catch(reason =>
this.props.enqueueSnackbar(reason, { variant: "error" })
);
} else {
setUserDefaultBool(
"enablePushNotifications",
!this.state.pushNotificationsEnabled
);
this.setState({
pushNotificationsEnabled: !this.state.pushNotificationsEnabled
});
}
}
toggleBadgeCount() {
@ -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));

437
src/pages/Timeline.tsx Normal file
View File

@ -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));

File diff suppressed because it is too large Load Diff

View File

@ -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");

View File

@ -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;

View File

@ -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];
};

13
src/types/Draft.tsx Normal file
View File

@ -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;
};

View File

@ -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;
}

43
src/utilities/compose.tsx Normal file
View File

@ -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
};
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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"
);
}
/**

View File

@ -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(