217 Commits

Author SHA1 Message Date
Jan
0f0d41615b fix: properly align emoji name in autocompletion 2022-10-26 09:13:14 +02:00
Jan
35fb6b4381 feat: list custom emojis in autocomplete 2022-10-26 08:22:39 +02:00
Paul Makles
9f3e47d327 chore: remove captcha from login form 2022-10-23 21:08:36 +01:00
Paul Makles
e29678a6a3 fix: add load suspense on master branch 2022-10-23 21:06:53 +01:00
Paul Makles
eb4670ec43 revert: "fix: display server identity (if present) in typing indicator" (#789) 2022-09-20 19:11:45 +01:00
Paul Makles
22b2bc0a5e fix: use new lightspeed embed URL 2022-09-20 18:31:12 +01:00
Paul Makles
2a12b79ef7 chore: bump lang submodule 2022-09-20 18:31:05 +01:00
4444dogs
9b6abe374a feat: add seasonal halloween theme (#784)
Co-authored-by: Sophie L <beartechtalks@gmail.com>
2022-09-20 18:26:20 +01:00
Compey
b649b2a923 Use rvlt.gg instead of app.revolt.chat/invite for invites (#783) 2022-09-20 18:25:14 +01:00
Ed L
b64c316dc9 chore: lay groundwork for masquerades 2022-09-20 18:24:59 +01:00
Ed L
bfeefb4c73 feat: use Button component for dev page, linting 2022-09-20 18:24:59 +01:00
Ed L
734fa06425 chore: use ios icon for sessions, update links 2022-09-20 18:24:59 +01:00
Ed L
5a738b7c68 fix: grammar, update command for dev mode 2022-09-20 18:24:59 +01:00
Ed L
f03a88bd78 fix: move leave/delete server option to the bottom 2022-09-20 18:24:59 +01:00
Ed L
e53059ee08 fix: let users message/hide mutuals tab for bots 2022-09-20 18:24:59 +01:00
Ed L
099f7a3116 feat: show role id 2022-09-20 18:24:59 +01:00
Ed L
d2264a2a43 fix: use --monospace-font 2022-09-20 18:24:59 +01:00
Ed L
ef26f67c8e chore: deduplicate imports 2022-09-20 18:24:59 +01:00
Ed L
5b7ec655eb chore: bump deps, remove old login background 2022-09-20 18:24:59 +01:00
Ed L
a381ba6320 feat: revamp onboarding 2022-09-20 18:24:59 +01:00
Jan
4f2fc267f9 feat: display account age next to join messages (#750) 2022-09-20 18:23:06 +01:00
Leda
2687f98952 fix: display server identity (if present) in typing indicator (#703) 2022-09-20 18:22:25 +01:00
Paul Makles
0a27632f05 chore: bump components dep 2022-09-19 10:53:36 +01:00
Paul Makles
6b9106c975 fix: correctly match protocols 2022-09-18 12:32:29 +01:00
Paul Makles
e6ad6a552e feat: add focus mode 2022-09-18 12:05:48 +01:00
Paul Makles
47bfaad508 fix: sanitise links passed to react-router
fix: flip protocol sanitisation to use a whitelist
2022-09-18 10:24:15 +01:00
Paul Makles
61a06c3f1a fix: re-write blockquote regex to include lists 2022-09-17 13:01:02 +01:00
Paul Makles
b42c24295f fix: null assertion has a chance of throwing error here 2022-09-17 10:50:26 +01:00
Paul Makles
71e689ece3 chore: update language defns 2022-09-16 18:46:54 +01:00
Paul Makles
7bc806ec63 feat: add indication if language is not fully translated
chore: update language definitions
2022-09-16 14:55:55 +01:00
Paul Makles
eab5ed033f merge: branch 'master' of https://github.com/revoltchat/revite 2022-09-08 21:24:29 +01:00
Paul Makles
ff57dbddba fix: correctly specify headers when removing MFA 2022-09-08 11:19:26 +01:00
Paul Makles
de207f0fa7 chore: add revolt.js as a submodule 2022-09-03 14:06:29 +01:00
Paul Makles
02eb7d83f6 feat: use wxp logo for Windows 7 2022-09-03 09:18:56 +01:00
Paul Makles
2c50d9be6b chore: disable list behaviour if line starts with plus 2022-09-02 16:29:20 +01:00
Paul Makles
594ef00d09 feat: add ability to leave groups / servers silently 2022-09-02 16:20:25 +01:00
Paul Makles
7626a1f461 fix: remove stray text 2022-09-02 14:40:17 +01:00
Paul Makles
83ca6f489e feat: add new reaction button to list 2022-09-02 14:35:16 +01:00
Paul Makles
b7a10bb9ab fix: ensure blockquotes are broken 2022-09-02 14:17:56 +01:00
Paul Makles
f6be6d7cf8 feat: change emojis page from grid to list 2022-09-02 14:17:42 +01:00
Paul Makles
dfbba41be4 feat: redesign emoji uploader 2022-09-02 13:42:34 +01:00
Paul Makles
024fc6853b chore: bump language definitions 2022-09-01 14:04:09 +01:00
Paul Makles
5bd2d24c56 feat: display timeout status on client 2022-09-01 14:00:43 +01:00
Paul Makles
5c50bed33d chore: update language definitions 2022-08-18 12:56:26 +02:00
Paul Makles
07fd598bf9 fix: don't collapse whitespace / newlines 2022-08-18 12:56:21 +02:00
Paul Makles
a234e3a582 chore: update form components to be more reliable 2022-08-15 20:20:25 +02:00
Paul Makles
a77f2f9b4d chore: remove stray log 2022-08-15 19:37:29 +02:00
Paul Makles
787d5840d2 chore: hide discovery button if not pointing to Revolt 2022-08-15 19:32:02 +02:00
Paul Makles
353507e17a fix: build components during build 2022-08-09 12:52:20 +02:00
Paul Makles
1b41ca03d9 chore: revert back to yarn portals 2022-08-09 12:40:03 +02:00
Paul Makles
3ca2b12dfc chore: add @revoltchat/ui as a submodule 2022-08-09 12:36:16 +02:00
Sophie L
7cf7402cea fix: styling fixes (#721) 2022-08-08 16:06:21 +01:00
Leda
c2a729a5e0 fix: render markdown inside/after HTML tags
fixes #733
2022-08-08 16:02:57 +01:00
Alice Harris
e9977b2a76 fix: large images in website previews escape embed box 2022-08-08 16:02:04 +01:00
Paul Makles
a4b8fb5fc2 chore: update strings and permission lists 2022-08-08 15:30:10 +01:00
Paul Makles
9a5653bc02 chore: update lang submodule 2022-08-08 15:22:03 +01:00
Paul Makles
50fd3b2d11 chore: clean up repository 2022-08-08 15:21:46 +01:00
Paul Makles
58f294b790 feat: add reaction button to overlay 2022-08-08 15:15:20 +01:00
Paul Makles
e1d3ad1675 chore: update language submodule 2022-08-08 14:36:34 +01:00
Paul Makles
11a17feaae chore: handle all updates to messages 2022-07-31 12:20:25 +02:00
Paul Makles
ca69a0b4c5 fix: remove eye_speech_bubble (cursed) 2022-07-31 11:38:22 +02:00
Paul Makles
6e70b55d02 chore: update lang 2022-07-30 12:29:50 +02:00
Paul Makles
dedc1e0666 feat: add corresponding UI for interactions reactions 2022-07-30 12:23:56 +02:00
Paul Makles
084c90613f feat: add reactions 2022-07-16 15:17:02 +01:00
Paul Makles
dbe8a64ffc fix: pass-through to color / unset if no gradient 2022-07-16 12:59:59 +01:00
Paul Makles
73fd35bf46 feat: add change group ownership / text system msg 2022-07-15 21:47:32 +01:00
Paul Makles
1a0b4b5703 feat: render masquerade colour 2022-07-15 21:38:11 +01:00
Paul Makles
62a427b7a7 feat: add separator to recovery codes when copying 2022-07-15 16:41:02 +01:00
Paul Makles
64e7038532 chore: bump revolt.js dependency 2022-07-15 16:25:51 +01:00
Paul Makles
7e88e733d5 feat: change colour rendering 2022-07-15 16:17:15 +01:00
Paul Makles
f3822b625d feat: make emoji picker close on select / interact elsewhere 2022-07-14 17:13:51 +01:00
Paul Makles
df1b39256d feat: show message that user can't message another 2022-07-14 15:06:37 +01:00
Paul Makles
fc8cfa5419 fix: actually log out invalidate sessions 2022-07-13 13:05:43 +01:00
Paul Makles
e3a526e2d7 fix: type errors with markdown content 2022-07-13 12:57:01 +01:00
Paul Makles
4f3f6e26cf feat: convert html AST nodes to text 2022-07-13 12:32:39 +01:00
Paul Makles
2214efe1bc chore: hide emoji settings if no perm 2022-07-12 14:21:44 +01:00
Paul Makles
030c211230 fix: internal links would not redirect properly 2022-07-12 14:15:53 +01:00
Paul Makles
7f6db77c4f chore: change quote depth limit to 5 from 3 2022-07-11 15:33:57 +01:00
Paul Makles
924448dc2c fix: remove html entities using AST plugin 2022-07-11 15:33:34 +01:00
Paul Makles
77c3f8d1bc chore: bump @revoltchat/ui to 1.0.76 2022-07-10 15:30:34 +01:00
Paul Makles
80943afcf6 fix: don't hide settings button behind bottom nav
fixes #691
2022-07-10 14:37:11 +01:00
Paul Makles
f792888268 chore: bump UI library 2022-07-10 14:27:00 +01:00
Paul Makles
aad9a30266 fix: correctly pass-through preview URLs 2022-07-10 13:57:47 +01:00
Paul Makles
0ec7e5e116 feat: try to load any 'valid' emoji 2022-07-10 13:53:19 +01:00
Paul Makles
2b65e98cd3 merge: pull request #730 from revoltchat/feat/emojis 2022-07-09 17:56:49 +01:00
Paul Makles
448722225e fix: change quote matching Regex 2022-07-09 17:55:13 +01:00
Paul Makles
cb6d5a3828 feat: update emoji picker; move settings bhnd expr 2022-07-09 17:53:40 +01:00
Paul Makles
354c22108e feat: it's morbing time 2022-07-08 18:38:39 +01:00
Paul Makles
ec96dde694 feat: render custom emoji 2022-07-08 17:14:15 +01:00
Paul Makles
445e9537d4 merge: remote-tracking branch 'origin/master' into feat/emojis 2022-07-08 17:02:39 +01:00
Paul Makles
c12d40d0da fix: correct mention styling 2022-07-08 16:58:21 +01:00
Paul Makles
7e20d5029e fix: underline anchor; prevent jitter on render 2022-07-08 15:45:16 +01:00
Paul Makles
262b931810 fix: actually render HTML out instead of obliterating it 2022-07-08 15:36:18 +01:00
Paul Makles
4a85dd69cf fix: limit maximum quote depth to 3 2022-07-08 15:19:16 +01:00
Paul Makles
34bb2bbc13 feat: switch to remark from markdown-it (big)
* replaces old mentions with avatar and display name
* renders things directly through React
* replaces most of the markdown hacks with custom AST components
* adds a tooltip to codeblock "copy to clipboard"
2022-07-08 14:24:48 +01:00
Paul Makles
b541301cb1 feat: add test emoji page 2022-07-07 17:33:33 +01:00
Paul Makles
a766183f01 feat: port CreateBot to modal form
fixes #723
2022-07-06 13:15:33 +01:00
Paul Makles
47e3d0bdb5 chore: update README to reflect project changes [skip ci] 2022-07-06 13:09:17 +01:00
Paul Makles
c51b024329 chore(refactor): delete context/revoltjs folder 2022-07-06 13:08:03 +01:00
Paul Makles
e37140dcd0 chore: minor styling change for clipboard modal 2022-07-06 12:50:38 +01:00
Paul Makles
705dcd001b feat: add correct submit buttons to form modals 2022-07-06 12:49:24 +01:00
Paul Makles
f9c6f5cd9d chore: delete intermediate 2022-07-05 21:13:42 +01:00
Paul Makles
f7ff7d0dfe feat: port CreateCategory / fix Channel 2022-07-05 20:55:24 +01:00
Paul Makles
29fb8e0064 feat: port CreateChannel modal 2022-07-05 20:37:40 +01:00
Paul Makles
4009b19f9c feat: port BanMember, KickMember modals 2022-07-05 20:25:00 +01:00
Paul Makles
ec347f585d feat: port CreateInvite 2022-07-05 20:11:32 +01:00
Paul Makles
160d71684f feat: port DeleteMessage and Confirmation 2022-07-05 18:46:13 +01:00
Paul Makles
79c90c1b00 feat: port input modals to new system 2022-07-05 17:53:41 +01:00
Paul Makles
23dec32476 feat(refactor): partially rewrite appearance settings 2022-07-05 15:49:08 +01:00
Paul Makles
a24e027a48 chore: clean up picker / prism imports 2022-07-04 19:04:27 +01:00
Paul Makles
6e70937825 chore: turn emoji picker into an experiment 2022-07-01 19:32:27 +01:00
Paul Makles
e4b61819d3 feat: mogus vented 2022-07-01 19:23:50 +01:00
Paul Makles
6bf58e8379 feat: test emoji picker design 2022-07-01 18:09:53 +01:00
Paul Makles
5dfe72c093 merge: pull request #717 from revoltchat/chore/client-fsm 2022-07-01 15:12:04 +01:00
Paul Makles
c4ac7a1b6d Merge branch 'master' into chore/client-fsm 2022-07-01 15:11:39 +01:00
Paul Makles
401b2d4990 feat: port UserProfile, Onboarding, CreateBot to legacy 2022-06-30 20:39:00 +01:00
Paul Makles
0d3f29515e feat: port ImageViewer 2022-06-30 19:34:04 +01:00
Paul Makles
1664aaee15 feat: add ServerInfo, port ChannelInfo 2022-06-30 19:06:49 +01:00
Paul Makles
8501e33103 chore(refactor): move RequiresOnline into controllers 2022-06-29 17:33:23 +01:00
Paul Makles
a2a52e237d chore(refactor): remove Notifications component 2022-06-29 17:31:59 +01:00
Paul Makles
05516c5823 fix: hide push notifications on electron app 2022-06-29 16:46:25 +01:00
Paul Makles
45692999bf chore(refactor): remove SyncManager 2022-06-29 16:41:26 +01:00
Paul Makles
1fcb3cedc1 feat: consistent authentication flow
fix: missing suspense on login
feat: re-prompt MFA if fail on login
2022-06-29 16:27:57 +01:00
Paul Makles
0261fec676 chore: deprecate RevoltClient context 2022-06-29 16:02:35 +01:00
Paul Makles
0e86f19da2 chore(doc): document client controller 2022-06-29 14:49:48 +01:00
Paul Makles
31220db8fe feat: fully working onboarding on login 2022-06-29 11:48:48 +01:00
Paul Makles
66ae518e51 feat: make login functional again 2022-06-29 10:52:42 +01:00
Paul Makles
8d505c9564 chore: clean up FSM code 2022-06-29 10:28:24 +01:00
Paul Makles
5f2311b09c feat: implement useClient from client controller 2022-06-28 19:59:58 +01:00
Paul Makles
ce88fab714 feat: get fsm to a working testing state 2022-06-28 13:49:50 +01:00
Paul Makles
b53d3bce13 fix: don't display date on ("new") message divider 2022-06-28 13:23:45 +01:00
Paul Makles
c997286340 patch: temporarily fix issue with public invites 2022-06-28 13:22:10 +01:00
Paul Makles
80f4bb3d98 feat: build finite state machine for sessions 2022-06-28 13:20:08 +01:00
Paul Makles
1cfcb20d4d chore(refactor): rename context/modals to controllers/modals 2022-06-27 17:56:06 +01:00
Paul Makles
95ebd935ed fix: duct-tape fix the bot edit issues
fixes #629
2022-06-21 11:14:51 +01:00
Paul Makles
3b7c1cbe20 chore(ci): allow any tag [skip ci] 2022-06-21 11:14:04 +01:00
Paul Makles
cb0a521473 fix: no client context on ModifyAccount
fix: no reactivity on account settings

closes #706
fixes #683
fixes #702
2022-06-21 10:57:58 +01:00
Paul Makles
aa9974149c fix: bump @revoltchat/ui 2022-06-18 17:13:00 +01:00
Paul Makles
569864167e fix: consider whether alert is present in height calc 2022-06-18 17:05:05 +01:00
Paul Makles
f185dec461 feat: handle system alerts and poll rate changes 2022-06-18 17:03:04 +01:00
Paul Makles
03e177f865 feat(modal): implement new server identity modal
closes #172
2022-06-18 15:54:17 +01:00
Paul Makles
f685352963 fix: temporarily hack in mention to profile flow 2022-06-18 15:06:24 +01:00
Paul Makles
6755217ad2 feat(modal): port ModifyAccount and PendingRequest 2022-06-18 15:02:59 +01:00
Paul Makles
211ff2058a chore: remove legacy Redux migration 2022-06-18 14:49:59 +01:00
Paul Makles
b7be9f8c03 feat(modal): port LinkWarning 2022-06-18 14:19:31 +01:00
Paul Makles
241b9cd27b feat(modal): port Clipboard 2022-06-18 12:33:22 +01:00
Paul Makles
d10bd96900 feat(modal): port Error and ShowToken 2022-06-18 12:25:56 +01:00
Paul Makles
374be319c4 feat(modals): port SignedOut and SignOutSessions 2022-06-18 11:56:05 +01:00
Paul Makles
0ee7b73d61 feat: re-work modal behaviour to be more natural 2022-06-18 11:22:37 +01:00
Paul Makles
63d5f6bb7d fix: show update button on native if frame off 2022-06-17 16:57:13 +01:00
Leda
34e6995d86 fix: modify account, and create bot forms not being submitted (#697) 2022-06-17 16:51:10 +01:00
Paul Makles
a1ef1dce5e fix: remove status request
closes #685
closes #643
2022-06-17 16:50:48 +01:00
Paul Makles
a190a51d0b fix(qr): render the QR code consistently 2022-06-14 18:01:19 +01:00
Paul Makles
c9127d6cf3 fix(css): please let the torture stop 2022-06-14 17:21:52 +01:00
div2005
f0d2e31b17 fix: bottom nav alignment is wrong (#684)
Co-authored-by: div2005 <div2005@tuta.io>
Co-authored-by: Paul Makles <paulmakles@gmail.com>
2022-06-14 17:19:18 +01:00
Paul Makles
220a28a151 fix: bottom nav button alignment
fixes #679
2022-06-14 17:16:18 +01:00
Paul Makles
b44779c89a fix: user picker checkbox + width
fixes #657
fixes #681
2022-06-14 17:10:59 +01:00
Paul Makles
5835064219 chore: fix merge conflicts 2022-06-14 16:30:42 +01:00
Paul Makles
b9da79bc11 chore: bump preact-context-menu 2022-06-14 16:27:46 +01:00
Leda
ba99cbaf2a fix: bug in user/channel mention when query text is empty (#659) 2022-06-14 15:24:13 +01:00
Leda
fc0c7611d4 fix: bug where channel icon scales with channel name (#661)
Co-authored-by: Paul Makles <paulmakles@gmail.com>
2022-06-14 15:23:58 +01:00
Leda
2e9c013ed8 fix: display voice channel as link in messages (#658)
* fix: display voice channel as link in messages

* chore: format
2022-06-14 15:17:00 +01:00
Leda
3e40a61624 fix: bug in join server button when in light theme (#660) 2022-06-14 15:13:31 +01:00
Paul Makles
4e22ccb2f7 chore: duct-tape fix for semver library breaking 2022-06-12 22:24:29 +01:00
Paul Makles
00718245f9 fix: make the changelog button in settings work 2022-06-12 22:21:23 +01:00
Paul Makles
cd8ab6739b feat: add changelog modal 2022-06-12 22:19:41 +01:00
Paul Makles
5eabd2861f chore: unlink components 2022-06-12 21:19:27 +01:00
Paul Makles
a404ff7fe0 feat: add auto-update and out-of-date indicator 2022-06-12 21:16:42 +01:00
Paul Makles
7680931f5f chore: i18n 2022-06-12 21:11:23 +01:00
Paul Makles
c1324108e3 fix(eslint): rules included deprecated plugin 2022-06-12 19:38:29 +01:00
Paul Makles
56770d40df chore: bump language submodule 2022-06-12 19:29:17 +01:00
Paul Makles
64f19ec2c0 chore: display error on load if present 2022-06-12 19:27:18 +01:00
Paul Makles
dbb1c1e8fa feat: finalise 2FA login 2022-06-12 19:24:59 +01:00
Paul Makles
c686e85d37 feat: add MFA recovery codes 2022-06-12 16:30:37 +01:00
Paul Makles
8eefc87b05 chore(refactor): clean up component folder structure 2022-06-12 15:24:00 +01:00
Paul Makles
645e1af6db chore: refactor account UI 2022-06-12 15:07:30 +01:00
Paul Makles
8103cc03cf chore: move "resend verification" button 2022-06-12 12:16:15 +01:00
Paul Makles
bdc527ebbe chore(ci): re-enable mirroring 2022-06-10 18:50:14 +01:00
Paul Makles
8a2826da91 fix: use class in markdown rendering 2022-06-10 17:36:59 +01:00
Paul Makles
bd50378234 feat: add account deletion confirmation route 2022-06-10 17:20:31 +01:00
Paul Makles
71f8fc86a4 chore: fix build errors 2022-06-10 17:00:37 +01:00
Paul Makles
277eaa685d chore(ci): stop mirroring to GitLab [skip ci] 2022-06-10 16:53:02 +01:00
Paul Makles
e81b8ed472 feat: new modal renderer + mfa flow modal 2022-06-10 16:52:12 +01:00
Paul Makles
6be0807433 feat: add disable / delete funct; bump revolt-api 2022-06-10 14:32:21 +01:00
Paul Makles
e0ca1681bd chore(refactor): move and re-organise types folder 2022-06-10 14:11:38 +01:00
Paul Makles
ebcbe4bd4b chore: read version from package.json 2022-06-10 12:18:02 +01:00
Paul Makles
ec8b51f559 chore: repository clean-up 2022-06-09 14:58:39 +01:00
Paul Makles
72e60b7528 chore: clean up build errors 2022-06-09 14:50:05 +01:00
Paul Makles
3399991824 merge: pull request #650 from revoltchat/chore/component-migration 2022-06-09 14:49:17 +01:00
Paul Makles
6497e11fb0 Merge branch 'master' into chore/component-migration 2022-06-09 14:48:45 +01:00
Paul Makles
7544c78360 chore: prepare account management buttons 2022-06-09 14:47:53 +01:00
Paul Makles
040c4367f7 chore: clean up code 2022-06-09 14:47:33 +01:00
Paul Makles
ee80dfd3c8 fix: pull Client into state early
(should fix empty server list)
2022-06-09 14:40:54 +01:00
Paul Makles
67c8418c31 fix: actually resolve the error from requests 2022-06-07 17:00:11 +01:00
Paul Makles
2056232759 fix: build errors 2022-05-30 16:46:07 +01:00
Paul Makles
81bf325990 feat: add column element 2022-05-30 16:15:52 +01:00
Paul Makles
906f15f103 fix: use shape="circle" for friend component 2022-05-30 15:54:55 +01:00
Paul Makles
ea106a3902 chore: bump @revoltchat/ui 2022-05-30 15:48:33 +01:00
Paul Makles
41e533ab59 feat(@ui): port Modal component 2022-05-30 15:45:14 +01:00
Paul Makles
68b9d5ea79 feat(@ui): migrate category / overline and header 2022-05-30 14:42:09 +01:00
Paul Makles
673efc0586 fix: make context menu line divider compact 2022-05-30 12:57:30 +01:00
Paul Makles
74f8c552ed feat(@ui): port category button 2022-05-30 12:56:47 +01:00
Paul Makles
f3bdbe52d9 feat(@ui): migrate textarea and tip 2022-05-30 12:47:13 +01:00
Paul Makles
b4777e9816 feat(@ui): migrate line divider, preloader and save status 2022-05-30 12:40:01 +01:00
Paul Makles
ab77d4a812 feat(@ui): migrate radio 2022-05-30 12:29:56 +01:00
Paul Makles
1d243d4762 feat(@ui): migrate input box 2022-05-30 12:26:16 +01:00
Paul Makles
2f9bfbf83f chore: remove Masks component 2022-05-30 12:19:32 +01:00
Paul Makles
c2547b3ead feat(@ui): migrate icon button 2022-05-30 12:01:47 +01:00
Paul Makles
a64fe61199 feat(@ui): migrate date divider and details 2022-05-29 16:40:02 +01:00
Paul Makles
1bd138d6ef feat(@ui): migrate colour swatches and combo box 2022-05-29 16:38:09 +01:00
Paul Makles
4bcfa601a5 feat(@ui): migrate checkbox component 2022-05-29 16:34:54 +01:00
Paul Makles
20d31babce feat(@ui): migrate Banner component 2022-05-29 15:43:36 +01:00
Paul Makles
12b9716043 fix: open last opened server channel instead of first 2022-05-27 22:32:06 +01:00
Paul Makles
e2d9e41a58 chore: bump revolt.js and @revoltchat/ui 2022-05-27 22:31:12 +01:00
Paul Makles
bdf741e0ee feat: add "ordering" data store 2022-05-27 21:21:42 +01:00
Paul Makles
588cb7c019 feat: finish reimplementation of server list 2022-05-27 21:21:42 +01:00
Paul Makles
94dd4b464a chore: server list integration test 2022-05-27 21:21:42 +01:00
305 changed files with 10588 additions and 8987 deletions

View File

@@ -5,7 +5,7 @@ on:
branches: branches:
- "master" - "master"
tags: tags:
- "v*" - "*"
paths-ignore: paths-ignore:
- ".github/**" - ".github/**"
- "!.github/workflows/docker.yml" - "!.github/workflows/docker.yml"

View File

@@ -4,7 +4,6 @@ on:
push: push:
branches: branches:
- "master" - "master"
- "production"
jobs: jobs:
to_gitlab: to_gitlab:

View File

@@ -1,40 +0,0 @@
image: node:16-buster
variables:
GIT_SUBMODULE_STRATEGY: recursive
cache:
paths:
- node_modules
# Fetch dependencies and setup project for compilation.
install:
stage: prepare
script:
- yarn
# Type check the project
typecheck:
stage: test
needs:
- install
dependencies:
- install
script:
- yarn typecheck
# Lint the project and check prettier output.
lint:
stage: test
allow_failure: true
needs:
- install
dependencies:
- install
script:
- yarn lint
- yarn --check 'src/**/*.{js,jsx,ts,tsx}'
stages:
- prepare
- test

6
.gitmodules vendored
View File

@@ -1,3 +1,9 @@
[submodule "external/lang"] [submodule "external/lang"]
path = external/lang path = external/lang
url = https://github.com/revoltchat/translations url = https://github.com/revoltchat/translations
[submodule "external/components"]
path = external/components
url = https://github.com/revoltchat/components
[submodule "external/revolt.js"]
path = external/revolt.js
url = https://github.com/revoltchat/revolt.js

View File

@@ -4,6 +4,14 @@
This is the web client for Revolt, which is also available live at [app.revolt.chat](https://app.revolt.chat). This is the web client for Revolt, which is also available live at [app.revolt.chat](https://app.revolt.chat).
## Pending Rewrite
The following code is pending a partial or full rewrite:
- `src/components`: components are being migrated to [revoltchat/components](https://github.com/revoltchat/components)
- `src/styles`: needs to be migrated to [revoltchat/components](https://github.com/revoltchat/components)
- `src/lib`: this needs to be organised
## Stack ## Stack
- [Preact](https://preactjs.com/) - [Preact](https://preactjs.com/)
@@ -35,6 +43,7 @@ Get revite up and running locally.
git clone --recursive https://github.com/revoltchat/revite git clone --recursive https://github.com/revoltchat/revite
cd revite cd revite
yarn yarn
yarn build:deps
yarn dev yarn dev
``` ```
@@ -42,17 +51,19 @@ You can now access the client at http://local.revolt.chat:3000.
## CLI Commands ## CLI Commands
| Command | Description | | Command | Description |
| ------------------- | -------------------------------------------- | | --------------------------------------- | -------------------------------------------- |
| `yarn pull` | Setup assets required for Revite. | | `yarn pull` | Setup assets required for Revite. |
| `yarn dev` | Start the Revolt client in development mode. | | `yarn dev` | Start the Revolt client in development mode. |
| `yarn build` | Build the Revolt client. | | `yarn build` | Build the Revolt client. |
| `yarn preview` | Start a local server with the built client. | | `yarn build:deps` | Build external dependencies. |
| `yarn lint` | Run ESLint on the client. | | `yarn preview` | Start a local server with the built client. |
| `yarn fmt` | Run Prettier on the client. | | `yarn lint` | Run ESLint on the client. |
| `yarn typecheck` | Run TypeScript type checking on the client. | | `yarn fmt` | Run Prettier on the client. |
| `yarn start` | Start a local sirv server with built client. | | `yarn typecheck` | Run TypeScript type checking on the client. |
| `yarn start:inject` | Inject a given API URL and start server. | | `yarn start` | Start a local sirv server with built client. |
| `yarn start:inject` | Inject a given API URL and start server. |
| `yarn lint \| egrep "no-literals" -B 1` | Scan for untranslated strings. |
## License ## License

View File

@@ -1 +0,0 @@
0.5.3-1

1
external/components vendored Submodule

Submodule external/components added at e79862b597

2
external/lang vendored

1
external/revolt.js vendored Submodule

Submodule external/revolt.js added at ab064f41a4

View File

@@ -1,9 +1,10 @@
{ {
"version": "0.0.0", "version": "1.0.1",
"scripts": { "scripts": {
"dev": "node scripts/setup_assets.js --check && vite", "dev": "node scripts/setup_assets.js --check && vite",
"pull": "node scripts/setup_assets.js", "pull": "node scripts/setup_assets.js",
"build": "rimraf build && node scripts/setup_assets.js --check && vite build", "build:deps": "cd external && cd components && yarn && yarn build:esm && cd .. && cd revolt.js && yarn && yarn build",
"build": "yarn && rimraf build && node scripts/setup_assets.js --check && yarn build:deps && vite build",
"build:highmem": "NODE_OPTIONS='--max-old-space-size=4096' yarn build", "build:highmem": "NODE_OPTIONS='--max-old-space-size=4096' yarn build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src/**/*.{js,jsx,ts,tsx}", "lint": "eslint src/**/*.{js,jsx,ts,tsx}",
@@ -39,39 +40,21 @@
"varsIgnorePattern": "^_" "varsIgnorePattern": "^_"
} }
], ],
"require-jsdoc": [ "react/jsx-no-literals": "warn"
"error",
{
"require": {
"FunctionDeclaration": true,
"MethodDefinition": true,
"ClassDeclaration": true,
"ArrowFunctionExpression": false,
"FunctionExpression": false
},
"ignore": {
"MethodDefinition": [
"toJSON",
"hydrate"
]
}
}
]
} }
}, },
"dependencies": { "dependencies": {
"@fontsource/bitter": "^4.5.0",
"@insertish/vite-plugin-babel-macros": "^1.0.5",
"@revoltchat/ui": "^1.0.31",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"klaw": "^3.0.0", "klaw": "^3.0.0",
"react-beautiful-dnd": "^13.1.0",
"sirv-cli": "^1.0.14", "sirv-cli": "^1.0.14",
"vite": "^2.6.14" "vite": "^3.0.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-decorators": "^7.17.9", "@babel/plugin-proposal-decorators": "^7.17.9",
"@floating-ui/react-dom": "^1.0.0",
"@floating-ui/react-dom-interactions": "^0.9.1",
"@fontsource/atkinson-hyperlegible": "^4.4.5", "@fontsource/atkinson-hyperlegible": "^4.4.5",
"@fontsource/bitter": "^4.5.7",
"@fontsource/comic-neue": "^4.4.5", "@fontsource/comic-neue": "^4.4.5",
"@fontsource/fira-code": "^4.4.5", "@fontsource/fira-code": "^4.4.5",
"@fontsource/inter": "^4.4.5", "@fontsource/inter": "^4.4.5",
@@ -90,27 +73,30 @@
"@fontsource/space-mono": "^4.4.5", "@fontsource/space-mono": "^4.4.5",
"@fontsource/ubuntu": "^4.4.5", "@fontsource/ubuntu": "^4.4.5",
"@fontsource/ubuntu-mono": "^4.4.5", "@fontsource/ubuntu-mono": "^4.4.5",
"@hcaptcha/react-hcaptcha": "^0.3.6", "@hcaptcha/react-hcaptcha": "^1.4.4",
"@insertish/vite-plugin-babel-macros": "^1.0.5",
"@preact/preset-vite": "^2.0.0", "@preact/preset-vite": "^2.0.0",
"@revoltchat/ui": "^1.0.77",
"@rollup/plugin-replace": "^2.4.2", "@rollup/plugin-replace": "^2.4.2",
"@styled-icons/boxicons-logos": "^10.38.0", "@styled-icons/boxicons-logos": "^10.38.0",
"@styled-icons/boxicons-regular": "^10.38.0", "@styled-icons/boxicons-regular": "^10.38.0",
"@styled-icons/boxicons-solid": "^10.38.0", "@styled-icons/boxicons-solid": "^10.38.0",
"@styled-icons/simple-icons": "^10.33.0", "@styled-icons/simple-icons": "^10.45.0",
"@tippyjs/react": "^4.2.5", "@tippyjs/react": "4.2.6",
"@traptitech/markdown-it-katex": "^3.4.3", "@traptitech/markdown-it-katex": "^3.4.3",
"@traptitech/markdown-it-spoiler": "^1.1.6", "@traptitech/markdown-it-spoiler": "^1.1.6",
"@trivago/prettier-plugin-sort-imports": "^2.0.2", "@trivago/prettier-plugin-sort-imports": "^2.0.2",
"@types/lodash": "^4",
"@types/lodash.defaultsdeep": "^4.6.6", "@types/lodash.defaultsdeep": "^4.6.6",
"@types/lodash.isequal": "^4.5.5", "@types/lodash.isequal": "^4.5.5",
"@types/markdown-it": "^12.0.2", "@types/node": "^15.14.9",
"@types/node": "^15.12.4",
"@types/preact-i18n": "^2.3.0", "@types/preact-i18n": "^2.3.0",
"@types/prismjs": "^1.16.5", "@types/prismjs": "^1.26.0",
"@types/react-beautiful-dnd": "^13.1.2", "@types/react-beautiful-dnd": "^13",
"@types/react-helmet": "^6.1.1", "@types/react-helmet": "^6.1.1",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/react-scroll": "^1.8.2", "@types/react-scroll": "^1.8.2",
"@types/semver": "^7",
"@types/styled-components": "^5.1.10", "@types/styled-components": "^5.1.10",
"@types/twemoji": "^12.1.1", "@types/twemoji": "^12.1.1",
"@typescript-eslint/eslint-plugin": "^4.27.0", "@typescript-eslint/eslint-plugin": "^4.27.0",
@@ -122,39 +108,58 @@
"detect-browser": "^5.2.0", "detect-browser": "^5.2.0",
"eslint": "^7.28.0", "eslint": "^7.28.0",
"eslint-config-preact": "^1.1.4", "eslint-config-preact": "^1.1.4",
"eslint-plugin-jsdoc": "^39.3.2",
"eslint-plugin-mobx": "^0.0.8",
"eventemitter3": "^4.0.7", "eventemitter3": "^4.0.7",
"history": "4",
"json-stringify-deterministic": "^1.0.2", "json-stringify-deterministic": "^1.0.2",
"localforage": "^1.9.0", "localforage": "^1.9.0",
"lodash": "^4.17.21",
"lodash.defaultsdeep": "^4.6.1", "lodash.defaultsdeep": "^4.6.1",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"long": "^5.2.0", "long": "^5.2.0",
"markdown-it": "^12.0.6", "mdast-util-to-hast": "^12.1.2",
"markdown-it-emoji": "^2.0.0",
"mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext", "mediasoup-client": "npm:@insertish/mediasoup-client@3.6.36-esnext",
"mobx": "^6.6.0", "mobx": "^6.6.0",
"mobx-react-lite": "^3.3.0", "mobx-react-lite": "3.4.0",
"preact": "^10.5.14", "preact": "^10.5.14",
"preact-context-menu": "0.4.0-patch.0", "preact-context-menu": "0.4.1",
"preact-i18n": "^2.4.0-preactx", "preact-i18n": "^2.4.0-preactx",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"prismjs": "^1.23.0", "prismjs": "^1.28.0",
"react-device-detect": "^1.17.0", "qrcode.react": "^3.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-device-detect": "2.2.2",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-hook-form": "6.3.0", "react-hook-form": "6.3.0",
"react-overlapping-panels": "1.2.2", "react-overlapping-panels": "1.2.2",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scroll": "^1.8.2", "react-scroll": "^1.8.2",
"react-virtuoso": "^1.10.4", "react-virtuoso": "^2.12.0",
"revolt.js": "6.0.0-2", "rehype-katex": "^6.0.2",
"rehype-prism": "^2.1.3",
"rehype-react": "^7.1.1",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"revolt.js": "6.0.17",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "^1.35.1", "sass": "^1.35.1",
"semver": "^7.3.7",
"shade-blend-color": "^1.0.0", "shade-blend-color": "^1.0.0",
"slate": "^0.81.1",
"slate-history": "^0.66.0",
"slate-react": "^0.81.0",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"styled-components": "^5.3.0", "styled-components": "^5.3.0",
"typescript": "^4.4.2", "typescript": "^4.4.2",
"ulid": "^2.3.0", "ulid": "^2.3.0",
"unified": "^10.1.2",
"unist-util-visit": "^4.1.0",
"use-resize-observer": "^7.0.0", "use-resize-observer": "^7.0.0",
"vite-plugin-pwa": "^0.11.13", "vite-plugin-pwa": "^0.12.3",
"workbox-precaching": "^6.1.5" "workbox-precaching": "^6.1.5"
}, },
"name": "client", "name": "client",
@@ -162,5 +167,9 @@
"repository": "https://github.com/revoltchat/revite.git", "repository": "https://github.com/revoltchat/revite.git",
"author": "Paul <paulmakles@gmail.com>", "author": "Paul <paulmakles@gmail.com>",
"license": "MIT", "license": "MIT",
"packageManager": "yarn@3.2.0" "packageManager": "yarn@3.2.0",
"resolutions": {
"@revoltchat/ui": "portal:external/components",
"revolt.js": "portal:external/revolt.js"
}
} }

1
packages/components Submodule

Submodule packages/components added at d314b2d191

Submodule packages/hast-util-table-cell-style added at 7803fa5441

1
packages/revolt.js Submodule

Submodule packages/revolt.js added at 39d1f596e2

View File

@@ -14,8 +14,10 @@ POST_INSTALL="pm2 restart revite"
export REVOLT_SAAS=https://github.com/revoltchat/assets export REVOLT_SAAS=https://github.com/revoltchat/assets
# Exit when any command fails
set -e
# 1. Build Revite # 1. Build Revite
yarn
yarn build yarn build
# 2. Archive built files # 2. Archive built files

38
src/assets/changelogs.ts Normal file
View File

@@ -0,0 +1,38 @@
type Element =
| string
| {
type: "image";
src: string;
};
export interface ChangelogPost {
date: Date;
title: string;
content: Element[];
}
export const changelogEntries: Record<number, ChangelogPost> = {
1: {
date: new Date("2022-06-12T20:39:16.674Z"),
title: "Secure your account with 2FA",
content: [
"Two-factor authentication is now available to all users, you can now head over to settings to enable recovery codes and an authenticator app.",
{
type: "image",
src: "https://autumn.revolt.chat/attachments/E21kwmuJGcASgkVLiSIW0wV3ggcaOWjW0TQF7cdFNY/image.png",
},
"Once enabled, you will be prompted on login.",
{
type: "image",
src: "https://autumn.revolt.chat/attachments/LWRYoKR2tE1ggW_Lzm547P1pnrkNgmBaoCAfWvHE74/image.png",
},
"Other authentication methods coming later, stay tuned!",
],
},
};
export const changelogEntryArray = Object.keys(changelogEntries).map(
(index) => changelogEntries[index as unknown as number],
);
export const latestChangelog = changelogEntryArray.length;

View File

@@ -151,7 +151,6 @@ export const emojiDictionary = {
hole: "🕳️", hole: "🕳️",
bomb: "💣", bomb: "💣",
speech_balloon: "💬", speech_balloon: "💬",
eye_speech_bubble: "👁️‍🗨️",
left_speech_bubble: "🗨️", left_speech_bubble: "🗨️",
right_anger_bubble: "🗯️", right_anger_bubble: "🗯️",
thought_balloon: "💭", thought_balloon: "💭",
@@ -1850,7 +1849,7 @@ export const emojiDictionary = {
scotland: "🏴󠁧󠁢󠁳󠁣󠁴󠁿", scotland: "🏴󠁧󠁢󠁳󠁣󠁴󠁿",
wales: "🏴󠁧󠁢󠁷󠁬󠁳󠁿", wales: "🏴󠁧󠁢󠁷󠁬󠁳󠁿",
...{ ...{
"1984": "custom:1984.gif", 1984: "custom:1984.gif",
KekW: "custom:KekW.png", KekW: "custom:KekW.png",
amogus: "custom:amogus.gif", amogus: "custom:amogus.gif",
awaa: "custom:awaa.png", awaa: "custom:awaa.png",
@@ -1952,5 +1951,6 @@ export const emojiDictionary = {
huggies: "custom:huggies.png", huggies: "custom:huggies.png",
noted: "custom:noted.gif", noted: "custom:noted.gif",
waving: "custom:waving.png", waving: "custom:waving.png",
mogusvented: "custom:mogusvented.png",
}, },
}; };

14
src/components/README.md Normal file
View File

@@ -0,0 +1,14 @@
The following folders should not be added to or modified:
- `common`
- `markdown`
- `native`
- `ui`
The following are part-legacy, will remain in place and will be rewritten to some degree still:
- `navigation`
The following are mostly good to go:
- `settings`

View File

@@ -6,15 +6,11 @@ import styled from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Button } from "@revoltchat/ui"; import { Button, Checkbox } from "@revoltchat/ui";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import { SECTION_NSFW } from "../../mobx/stores/Layout"; import { SECTION_NSFW } from "../../mobx/stores/Layout";
import Checkbox from "../ui/Checkbox";
import { Children } from "../../types/Preact";
const Base = styled.div` const Base = styled.div`
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
@@ -81,10 +77,10 @@ export default observer((props: Props) => {
</span> </span>
<Checkbox <Checkbox
checked={layout.getSectionState(SECTION_NSFW, false)} title={<Text id="app.main.channel.nsfw.confirm" />}
onChange={() => layout.toggleSectionState(SECTION_NSFW, false)}> value={layout.getSectionState(SECTION_NSFW, false)}
<Text id="app.main.channel.nsfw.confirm" /> onChange={() => layout.toggleSectionState(SECTION_NSFW, false)}
</Checkbox> />
<div className="actions"> <div className="actions">
<Button palette="secondary" onClick={() => history.goBack()}> <Button palette="secondary" onClick={() => history.goBack()}>
<Text id="app.special.modals.actions.back" /> <Text id="app.special.modals.actions.back" />

View File

@@ -1,13 +1,17 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Link } from "react-router-dom";
import { Channel, User } from "revolt.js"; import { Channel, User } from "revolt.js";
import { Emoji as CustomEmoji } from "revolt.js/esm/maps/Emojis";
import styled, { css } from "styled-components/macro"; import styled, { css } from "styled-components/macro";
import { StateUpdater, useState } from "preact/hooks"; import { StateUpdater, useState } from "preact/hooks";
import { useClient } from "../../context/revoltjs/RevoltClient";
import { emojiDictionary } from "../../assets/emojis"; import { emojiDictionary } from "../../assets/emojis";
import { useClient } from "../../controllers/client/ClientController";
import ChannelIcon from "./ChannelIcon"; import ChannelIcon from "./ChannelIcon";
import Emoji from "./Emoji"; import Emoji from "./Emoji";
import ServerIcon from "./ServerIcon";
import Tooltip from "./Tooltip";
import UserIcon from "./user/UserIcon"; import UserIcon from "./user/UserIcon";
export type AutoCompleteState = export type AutoCompleteState =
@@ -15,7 +19,7 @@ export type AutoCompleteState =
| ({ selected: number; within: boolean } & ( | ({ selected: number; within: boolean } & (
| { | {
type: "emoji"; type: "emoji";
matches: string[]; matches: (string | CustomEmoji)[];
} }
| { | {
type: "user"; type: "user";
@@ -88,7 +92,7 @@ export function useAutoComplete(
? "emoji" ? "emoji"
: "user", : "user",
search.toLowerCase(), search.toLowerCase(),
j + 1, current === ":" ? j + 1 : j,
]; ];
} }
} }
@@ -105,16 +109,23 @@ export function useAutoComplete(
if (type === "emoji") { if (type === "emoji") {
// ! TODO: we should convert it to a Binary Search Tree and use that // ! TODO: we should convert it to a Binary Search Tree and use that
const matches = Object.keys(emojiDictionary) const matches = [
.filter((emoji: string) => emoji.match(regex)) ...Object.keys(emojiDictionary).filter((emoji: string) =>
.splice(0, 5); emoji.match(regex),
),
...Array.from(client.emojis.values()).filter((emoji) =>
emoji.name.match(regex),
),
].splice(0, 5);
if (matches.length > 0) { if (matches.length > 0) {
const currentPosition = const currentPosition =
state.type !== "none" ? state.selected : 0; state.type !== "none" ? state.selected : 0;
setState({ setState({
// @ts-ignore-next-line are you high
type: "emoji", type: "emoji",
// @ts-ignore-next-line
matches, matches,
selected: Math.min(currentPosition, matches.length - 1), selected: Math.min(currentPosition, matches.length - 1),
within: false, within: false,
@@ -234,15 +245,18 @@ export function useAutoComplete(
const content = el.value.split(""); const content = el.value.split("");
if (state.type === "emoji") { if (state.type === "emoji") {
const selected = state.matches[state.selected];
content.splice( content.splice(
index, index,
search.length, search.length,
state.matches[state.selected], selected instanceof CustomEmoji
? selected._id
: selected,
": ", ": ",
); );
} else if (state.type === "user") { } else if (state.type === "user") {
content.splice( content.splice(
index - 1, index,
search.length + 1, search.length + 1,
"<@", "<@",
state.matches[state.selected]._id, state.matches[state.selected]._id,
@@ -250,7 +264,7 @@ export function useAutoComplete(
); );
} else { } else {
content.splice( content.splice(
index - 1, index,
search.length + 1, search.length + 1,
"<#", "<#",
state.matches[state.selected]._id, state.matches[state.selected]._id,
@@ -389,12 +403,17 @@ export default function AutoComplete({
setState, setState,
onClick, onClick,
}: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) { }: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) {
const client = useClient();
return ( return (
<Base detached={detached}> <Base detached={detached}>
<div> <div>
{state.type === "emoji" && {state.type === "emoji" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
style={{
display: "flex",
justifyContent: "space-between",
}}
key={match} key={match}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
@@ -413,15 +432,61 @@ export default function AutoComplete({
}) })
} }
onClick={onClick}> onClick={onClick}>
<Emoji <div
emoji={ style={{
(emojiDictionary as Record<string, string>)[ display: "flex",
match flexDirection: "row",
] justifyContent: "center",
} }}>
size={20} {match instanceof CustomEmoji ? (
/> <img
:{match}: loading="lazy"
src={match.imageURL}
style={{
width: `20px`,
height: `20px`,
}}
/>
) : (
<Emoji
emoji={
(
emojiDictionary as Record<
string,
string
>
)[match]
}
size={20}
/>
)}
<span style={{ paddingLeft: "4px" }}>{`:${
match instanceof CustomEmoji
? match.name
: match
}:`}</span>
</div>
{match instanceof CustomEmoji &&
match.parent.type == "Server" && (
<>
<Tooltip
content={
client.servers.get(
match.parent.id,
)?.name
}>
<Link
to={`/server/${match.parent.id}`}>
<ServerIcon
target={client.servers.get(
match.parent.id,
)}
size={20}
/>
</Link>
</Tooltip>
</>
)}
</button> </button>
))} ))}
{state.type === "user" && {state.type === "user" &&

View File

@@ -2,12 +2,9 @@ import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js"; import { Channel } from "revolt.js";
import { useContext } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import fallback from "./assets/group.png"; import fallback from "./assets/group.png";
import { useClient } from "../../controllers/client/ClientController";
import { ImageIconBase, IconBaseProps } from "./IconBase"; import { ImageIconBase, IconBaseProps } from "./IconBase";
interface Props extends IconBaseProps<Channel> { interface Props extends IconBaseProps<Channel> {
@@ -22,7 +19,7 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
const client = useContext(AppContext); const client = useClient();
const { const {
size, size,

View File

@@ -1,11 +1,9 @@
import { ChevronDown } from "@styled-icons/boxicons-regular"; import { ChevronDown } from "@styled-icons/boxicons-regular";
import { Details } from "@revoltchat/ui";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import Details from "../ui/Details";
import { Children } from "../../types/Preact";
interface Props { interface Props {
id: string; id: string;
defaultValue: boolean; defaultValue: boolean;
@@ -34,7 +32,7 @@ export default function CollapsibleSection({
} }
{...detailsProps}> {...detailsProps}>
<summary> <summary>
<div class="padding"> <div className="padding">
<ChevronDown size={20} /> <ChevronDown size={20} />
{summary} {summary}
</div> </div>

View File

@@ -1,3 +1,5 @@
import { emojiDictionary } from "../../assets/emojis";
export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji"; export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji";
let EMOJI_PACK: EmojiPack = "mutant"; let EMOJI_PACK: EmojiPack = "mutant";
@@ -40,7 +42,7 @@ function toCodePoint(rune: string) {
.join("-"); .join("-");
} }
function parseEmoji(emoji: string) { export function parseEmoji(emoji: string) {
if (emoji.startsWith("custom:")) { if (emoji.startsWith("custom:")) {
return `https://dl.insrt.uk/projects/revolt/emotes/${emoji.substring( return `https://dl.insrt.uk/projects/revolt/emotes/${emoji.substring(
7, 7,

View File

@@ -1,6 +1,6 @@
import { useApplicationState } from "../../mobx/State"; import { ComboBox } from "@revoltchat/ui";
import ComboBox from "../ui/ComboBox"; import { useApplicationState } from "../../mobx/State";
import { Language, Languages } from "../../../external/lang/Languages"; import { Language, Languages } from "../../../external/lang/Languages";

View File

@@ -7,8 +7,9 @@ import styled, { css } from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import IconButton from "../ui/IconButton"; import { IconButton } from "@revoltchat/ui";
import { modalController } from "../../controllers/modals/ModalController";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
interface Props { interface Props {
@@ -60,6 +61,9 @@ const ServerBanner = styled.div<Omit<Props, "server">>`
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex-grow: 1; flex-grow: 1;
cursor: pointer;
color: var(--foreground);
} }
} }
`; `;
@@ -121,7 +125,13 @@ export default observer(({ server }: Props) => {
</svg> </svg>
</Tooltip> </Tooltip>
) : undefined} ) : undefined}
<div className="title">{server.name}</div> <a
className="title"
onClick={() =>
modalController.push({ type: "server_info", server })
}>
{server.name}
</a>
{server.havePermission("ManageServer") && ( {server.havePermission("ManageServer") && (
<Link to={`/server/${server._id}/settings`}> <Link to={`/server/${server._id}/settings`}>
<IconButton> <IconButton>

View File

@@ -4,8 +4,7 @@ import styled from "styled-components/macro";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { useClient } from "../../controllers/client/ClientController";
import { IconBaseProps, ImageIconBase } from "./IconBase"; import { IconBaseProps, ImageIconBase } from "./IconBase";
interface Props extends IconBaseProps<Server> { interface Props extends IconBaseProps<Server> {
@@ -34,7 +33,7 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
const client = useContext(AppContext); const client = useClient();
const { target, attachment, size, animate, server_name, ...imgProps } = const { target, attachment, size, animate, server_name, ...imgProps } =
props; props;

View File

@@ -3,8 +3,6 @@ import styled from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { Children } from "../../types/Preact";
type Props = Omit<TippyProps, "children"> & { type Props = Omit<TippyProps, "children"> & {
children: Children; children: Children;
content: Children; content: Children;

View File

@@ -3,13 +3,13 @@ import { Download, CloudDownload } from "@styled-icons/boxicons-regular";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { IconButton } from "@revoltchat/ui";
import { internalSubscribe } from "../../lib/eventEmitter"; import { internalSubscribe } from "../../lib/eventEmitter";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import IconButton from "../ui/IconButton"; import { updateSW } from "../../updateWorker";
import { updateSW } from "../../main";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
let pendingUpdate = false; let pendingUpdate = false;
@@ -31,7 +31,7 @@ export default function UpdateIndicator({ style }: Props) {
if (style === "titlebar") { if (style === "titlebar") {
return ( return (
<div class="actions"> <div className="actions">
<Tooltip <Tooltip
content="A new update is available!" content="A new update is available!"
placement="bottom"> placement="bottom">
@@ -46,7 +46,7 @@ export default function UpdateIndicator({ style }: Props) {
); );
} }
if (window.isNative) return null; if (window.isNative && window.native.getConfig().frame) return null;
return ( return (
<IconButton onClick={() => updateSW(true)}> <IconButton onClick={() => updateSW(true)}>

View File

@@ -5,17 +5,16 @@ import { useTriggerEvents } from "preact-context-menu";
import { memo } from "preact/compat"; import { memo } from "preact/compat";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Category, Button } from "@revoltchat/ui";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { QueuedMessage } from "../../../mobx/stores/MessageQueue"; import { QueuedMessage } from "../../../mobx/stores/MessageQueue";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { I18nError } from "../../../context/Locale";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import Overline from "../../ui/Overline"; import { modalController } from "../../../controllers/modals/ModalController";
import { Children } from "../../../types/Preact";
import Markdown from "../../markdown/Markdown"; import Markdown from "../../markdown/Markdown";
import UserIcon from "../user/UserIcon"; import UserIcon from "../user/UserIcon";
import { Username } from "../user/UserShort"; import { Username } from "../user/UserShort";
@@ -26,6 +25,7 @@ import MessageBase, {
} from "./MessageBase"; } from "./MessageBase";
import Attachment from "./attachments/Attachment"; import Attachment from "./attachments/Attachment";
import { MessageReply } from "./attachments/MessageReply"; import { MessageReply } from "./attachments/MessageReply";
import { Reactions } from "./attachments/Reactions";
import { MessageOverlayBar } from "./bars/MessageOverlayBar"; import { MessageOverlayBar } from "./bars/MessageOverlayBar";
import Embed from "./embed/Embed"; import Embed from "./embed/Embed";
import InviteList from "./embed/EmbedInvite"; import InviteList from "./embed/EmbedInvite";
@@ -52,11 +52,9 @@ const Message = observer(
queued, queued,
hideReply, hideReply,
}: Props) => { }: Props) => {
const client = useClient(); const client = message.client;
const user = message.author; const user = message.author;
const { openScreen } = useIntermediate();
const content = message.content; const content = message.content;
const head = const head =
preferHead || (message.reply_ids && message.reply_ids.length > 0); preferHead || (message.reply_ids && message.reply_ids.length > 0);
@@ -70,7 +68,10 @@ const Message = observer(
: undefined; : undefined;
const openProfile = () => const openProfile = () =>
openScreen({ id: "profile", user_id: message.author_id }); modalController.push({
type: "user_profile",
user_id: message.author_id,
});
const handleUserClick = (e: MouseEvent) => { const handleUserClick = (e: MouseEvent) => {
if (e.shiftKey && user?._id) { if (e.shiftKey && user?._id) {
@@ -87,6 +88,7 @@ const Message = observer(
// ! FIXME(?): animate on hover // ! FIXME(?): animate on hover
const [mouseHovering, setAnimate] = useState(false); const [mouseHovering, setAnimate] = useState(false);
const [reactionsOpen, setReactionsOpen] = useState(false);
useEffect(() => setAnimate(false), [replacement]); useEffect(() => setAnimate(false), [replacement]);
return ( return (
@@ -115,7 +117,11 @@ const Message = observer(
} }
contrast={contrast} contrast={contrast}
sending={typeof queued !== "undefined"} sending={typeof queued !== "undefined"}
mention={message.mention_ids?.includes(client.user!._id)} mention={
message.mention_ids && client.user
? message.mention_ids.includes(client.user._id)
: undefined
}
failed={typeof queued?.error !== "undefined"} failed={typeof queued?.error !== "undefined"}
{...(attachContext {...(attachContext
? useTriggerEvents("Menu", { ? useTriggerEvents("Menu", {
@@ -159,10 +165,13 @@ const Message = observer(
/> />
</span> </span>
)} )}
{replacement ?? <Markdown content={content} />} {replacement ??
(content && <Markdown content={content} />)}
{!queued && <InviteList message={message} />} {!queued && <InviteList message={message} />}
{queued?.error && ( {queued?.error && (
<Overline type="error" error={queued.error} /> <Category>
<I18nError error={queued.error} />
</Category>
)} )}
{message.attachments?.map((attachment, index) => ( {message.attachments?.map((attachment, index) => (
<Attachment <Attachment
@@ -177,10 +186,13 @@ const Message = observer(
{message.embeds?.map((embed, index) => ( {message.embeds?.map((embed, index) => (
<Embed key={index} embed={embed} /> <Embed key={index} embed={embed} />
))} ))}
{mouseHovering && <Reactions message={message} />
{(mouseHovering || reactionsOpen) &&
!replacement && !replacement &&
!isTouchscreenDevice && ( !isTouchscreenDevice && (
<MessageOverlayBar <MessageOverlayBar
reactionsOpen={reactionsOpen}
setReactionsOpen={setReactionsOpen}
message={message} message={message}
queued={queued} queued={queued}
/> />

View File

@@ -1,21 +1,15 @@
import { Send, ShieldX } from "@styled-icons/boxicons-solid"; import { HappyBeaming, Send, ShieldX } from "@styled-icons/boxicons-solid";
import Axios, { CancelTokenSource } from "axios"; import Axios, { CancelTokenSource } from "axios";
import Long from "long";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { import { Channel } from "revolt.js";
Channel,
DEFAULT_PERMISSION_DIRECT_MESSAGE,
DEFAULT_PERMISSION_VIEW_ONLY,
Permission,
Server,
U32_MAX,
UserPermission,
} from "revolt.js";
import styled, { css } from "styled-components/macro"; import styled, { css } from "styled-components/macro";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks"; import { memo } from "preact/compat";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { IconButton, Picker } from "@revoltchat/ui";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { debounce } from "../../../lib/debounce"; import { debounce } from "../../../lib/debounce";
@@ -28,20 +22,24 @@ import {
SMOOTH_SCROLL_ON_RECEIVE, SMOOTH_SCROLL_ON_RECEIVE,
} from "../../../lib/renderer/Singleton"; } from "../../../lib/renderer/Singleton";
import { useApplicationState } from "../../../mobx/State"; import { state, useApplicationState } from "../../../mobx/State";
import { Reply } from "../../../mobx/stores/MessageQueue"; import { Reply } from "../../../mobx/stores/MessageQueue";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { dayjs } from "../../../context/Locale";
import { emojiDictionary } from "../../../assets/emojis";
import {
clientController,
useClient,
} from "../../../controllers/client/ClientController";
import { takeError } from "../../../controllers/client/jsx/error";
import { import {
FileUploader, FileUploader,
grabFiles, grabFiles,
uploadFile, uploadFile,
} from "../../../context/revoltjs/FileUploads"; } from "../../../controllers/client/jsx/legacy/FileUploads";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { modalController } from "../../../controllers/modals/ModalController";
import { takeError } from "../../../context/revoltjs/util"; import { RenderEmoji } from "../../markdown/plugins/emoji";
import IconButton from "../../ui/IconButton";
import AutoComplete, { useAutoComplete } from "../AutoComplete"; import AutoComplete, { useAutoComplete } from "../AutoComplete";
import { PermissionTooltip } from "../Tooltip"; import { PermissionTooltip } from "../Tooltip";
import FilePreview from "./bars/FilePreview"; import FilePreview from "./bars/FilePreview";
@@ -104,7 +102,7 @@ const Blocked = styled.div`
`; `;
const Action = styled.div` const Action = styled.div`
> div { > a {
height: 48px; height: 48px;
width: 48px; width: 48px;
display: flex; display: flex;
@@ -127,7 +125,7 @@ const Action = styled.div`
`; `;
const FileAction = styled.div` const FileAction = styled.div`
> div { > a {
height: 48px; height: 48px;
width: 62px; width: 62px;
display: flex; display: flex;
@@ -136,6 +134,10 @@ const FileAction = styled.div`
} }
`; `;
const FloatingLayer = styled.div`
position: relative;
`;
const ThisCodeWillBeReplacedAnywaysSoIMightAsWellJustDoItThisWay__Padding = styled.div` const ThisCodeWillBeReplacedAnywaysSoIMightAsWellJustDoItThisWay__Padding = styled.div`
width: 16px; width: 16px;
`; `;
@@ -146,6 +148,67 @@ const RE_SED = new RegExp("^s/([^])*/([^])*$");
// Tests for code block delimiters (``` at start of line) // Tests for code block delimiters (``` at start of line)
const RE_CODE_DELIMITER = new RegExp("^```", "gm"); const RE_CODE_DELIMITER = new RegExp("^```", "gm");
export const HackAlertThisFileWillBeReplaced = observer(
({
onSelect,
onClose,
}: {
onSelect: (emoji: string) => void;
onClose: () => void;
}) => {
const renderEmoji = useMemo(
() =>
memo(({ emoji }: { emoji: string }) => (
<RenderEmoji match={emoji} {...({} as any)} />
)),
[],
);
const emojis: Record<string, any> = {
default: Object.keys(emojiDictionary).map((id) => ({ id })),
};
// ! FIXME: also expose typing from component
const categories: any[] = [];
for (const server of state.ordering.orderedServers) {
// ! FIXME: add a separate map on each server for emoji
const list = [...clientController.getReadyClient()!.emojis.values()]
.filter(
(emoji) =>
emoji.parent.type !== "Detached" &&
emoji.parent.id === server._id,
)
.map(({ _id, name }) => ({ id: _id, name }));
if (list.length > 0) {
emojis[server._id] = list;
categories.push({
id: server._id,
name: server.name,
iconURL: server.generateIconURL({ max_side: 256 }),
});
}
}
categories.push({
id: "default",
name: "Default",
emoji: "smiley",
});
return (
<Picker
emojis={emojis}
categories={categories}
renderEmoji={renderEmoji}
onSelect={onSelect}
onClose={onClose}
/>
);
},
);
// ! FIXME: add to app config and load from app config // ! FIXME: add to app config and load from app config
export const CAN_UPLOAD_AT_ONCE = 5; export const CAN_UPLOAD_AT_ONCE = 5;
@@ -157,12 +220,42 @@ export default observer(({ channel }: Props) => {
}); });
const [typing, setTyping] = useState<boolean | number>(false); const [typing, setTyping] = useState<boolean | number>(false);
const [replies, setReplies] = useState<Reply[]>([]); const [replies, setReplies] = useState<Reply[]>([]);
const { openScreen } = useIntermediate(); const [picker, setPicker] = useState(false);
const client = useContext(AppContext); const client = useClient();
const translate = useTranslation(); const translate = useTranslation();
const closePicker = useCallback(() => setPicker(false), []);
const renderer = getRenderer(channel); const renderer = getRenderer(channel);
if (channel.server?.member?.timeout) {
return (
<Base>
<Blocked>
<Action>
<PermissionTooltip
permission="SendMessages"
placement="top">
<ShieldX size={22} />
</PermissionTooltip>
</Action>
<div className="text">
<Text
id="app.main.channel.misc.timed_out"
fields={{
// TODO: make this reactive
time: dayjs().to(
channel.server.member.timeout,
true,
),
}}
/>
</div>
</Blocked>
</Base>
);
}
if (!channel.havePermission("SendMessage")) { if (!channel.havePermission("SendMessage")) {
return ( return (
<Base> <Base>
@@ -309,6 +402,7 @@ export default observer(({ channel }: Props) => {
async function sendFile(content: string) { async function sendFile(content: string) {
if (uploadState.type !== "attached") return; if (uploadState.type !== "attached") return;
const attachments: string[] = []; const attachments: string[] = [];
setMessage;
const cancel = Axios.CancelToken.source(); const cancel = Axios.CancelToken.source();
const files = uploadState.files; const files = uploadState.files;
@@ -482,7 +576,10 @@ export default observer(({ channel }: Props) => {
files: [...uploadState.files, ...files], files: [...uploadState.files, ...files],
}), }),
() => () =>
openScreen({ id: "error", error: "FileTooLarge" }), modalController.push({
type: "error",
error: "FileTooLarge",
}),
true, true,
) )
} }
@@ -505,6 +602,20 @@ export default observer(({ channel }: Props) => {
replies={replies} replies={replies}
setReplies={setReplies} setReplies={setReplies}
/> />
<FloatingLayer>
{picker && (
<HackAlertThisFileWillBeReplaced
onSelect={(emoji) => {
const v = state.draft.get(channel._id);
state.draft.set(
channel._id,
`${v ? `${v} ` : ""}:${emoji}:`,
);
}}
onClose={closePicker}
/>
)}
</FloatingLayer>
<Base> <Base>
{channel.havePermission("UploadFiles") ? ( {channel.havePermission("UploadFiles") ? (
<FileAction> <FileAction>
@@ -625,16 +736,13 @@ export default observer(({ channel }: Props) => {
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
/> />
{/*<Action> {state.experiments.isEnabled("picker") && (
<IconButton> <Action>
<Box size={24} /> <IconButton onClick={() => setPicker(!picker)}>
</IconButton> <HappyBeaming size={24} />
</Action> </IconButton>
<Action> </Action>
<IconButton> )}
<HappyBeaming size={24} />
</IconButton>
</Action>*/}
<Action> <Action>
<IconButton <IconButton
className={ className={

View File

@@ -9,15 +9,26 @@ import {
EditAlt, EditAlt,
Edit, Edit,
MessageSquareEdit, MessageSquareEdit,
Key,
} from "@styled-icons/boxicons-solid"; } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Message, API } from "revolt.js"; import { Message, API } from "revolt.js";
import styled from "styled-components/macro"; import styled from "styled-components/macro";
import { decodeTime } from "ulid";
import { useTriggerEvents } from "preact-context-menu"; import { useTriggerEvents } from "preact-context-menu";
import { Text } from "preact-i18n";
import { Row } from "@revoltchat/ui";
import { TextReact } from "../../../lib/i18n"; import { TextReact } from "../../../lib/i18n";
import { useApplicationState } from "../../../mobx/State";
import { dayjs } from "../../../context/Locale";
import Markdown from "../../markdown/Markdown";
import Tooltip from "../Tooltip";
import UserShort from "../user/UserShort"; import UserShort from "../user/UserShort";
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase"; import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
@@ -67,12 +78,17 @@ const iconDictionary = {
channel_renamed: EditAlt, channel_renamed: EditAlt,
channel_description_changed: Edit, channel_description_changed: Edit,
channel_icon_changed: MessageSquareEdit, channel_icon_changed: MessageSquareEdit,
channel_ownership_changed: Key,
text: InfoCircle, text: InfoCircle,
}; };
export const SystemMessage = observer( export const SystemMessage = observer(
({ attachContext, message, highlight, hideInfo }: Props) => { ({ attachContext, message, highlight, hideInfo }: Props) => {
const data = message.asSystemMessage; const data = message.asSystemMessage;
if (!data) return null;
const settings = useApplicationState().settings;
const SystemMessageIcon = const SystemMessageIcon =
iconDictionary[data.type as API.SystemMessage["type"]] ?? iconDictionary[data.type as API.SystemMessage["type"]] ??
InfoCircle; InfoCircle;
@@ -98,16 +114,39 @@ export const SystemMessage = observer(
case "user_joined": case "user_joined":
case "user_left": case "user_left":
case "user_kicked": case "user_kicked":
case "user_banned": case "user_banned": {
const createdAt = data.user ? decodeTime(data.user._id) : null;
children = ( children = (
<TextReact <Row centred>
id={`app.main.channel.system.${data.type}`} <TextReact
fields={{ id={`app.main.channel.system.${data.type}`}
user: <UserShort user={data.user} />, fields={{
}} user: <UserShort user={data.user} />,
/> }}
/>
{data.type == "user_joined" &&
createdAt &&
(settings.get("appearance:show_account_age") ||
Date.now() - createdAt <
1000 * 60 * 60 * 24 * 7) && (
<Tooltip
content={
<Text
id="app.main.channel.system.registered_at"
fields={{
time: dayjs(
createdAt,
).fromNow(),
}}
/>
}>
<InfoCircle size={16} />
</Tooltip>
)}
</Row>
); );
break; break;
}
case "channel_renamed": case "channel_renamed":
children = ( children = (
<TextReact <TextReact
@@ -130,6 +169,22 @@ export const SystemMessage = observer(
/> />
); );
break; break;
case "channel_ownership_changed":
children = (
<TextReact
id={`app.main.channel.system.channel_ownership_changed`}
fields={{
from: <UserShort user={data.from} />,
to: <UserShort user={data.to} />,
}}
/>
);
break;
case "text":
if (message.system?.type === "text") {
children = <Markdown content={message.system?.content} />;
}
break;
} }
return ( return (

View File

@@ -3,10 +3,9 @@ import { API } from "revolt.js";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useTriggerEvents } from "preact-context-menu"; import { useTriggerEvents } from "preact-context-menu";
import { useContext, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
import { useClient } from "../../../../controllers/client/ClientController";
import AttachmentActions from "./AttachmentActions"; import AttachmentActions from "./AttachmentActions";
import { SizedGrid } from "./Grid"; import { SizedGrid } from "./Grid";
import ImageFile from "./ImageFile"; import ImageFile from "./ImageFile";
@@ -21,7 +20,7 @@ interface Props {
const MAX_ATTACHMENT_WIDTH = 480; const MAX_ATTACHMENT_WIDTH = 480;
export default function Attachment({ attachment, hasContent }: Props) { export default function Attachment({ attachment, hasContent }: Props) {
const client = useContext(AppContext); const client = useClient();
const { filename, metadata } = attachment; const { filename, metadata } = attachment;
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_")); const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));

View File

@@ -11,18 +11,18 @@ import styles from "./AttachmentActions.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { IconButton } from "@revoltchat/ui";
import { determineFileSize } from "../../../../lib/fileSize"; import { determineFileSize } from "../../../../lib/fileSize";
import { AppContext } from "../../../../context/revoltjs/RevoltClient"; import { useClient } from "../../../../controllers/client/ClientController";
import IconButton from "../../../ui/IconButton";
interface Props { interface Props {
attachment: API.File; attachment: API.File;
} }
export default function AttachmentActions({ attachment }: Props) { export default function AttachmentActions({ attachment }: Props) {
const client = useContext(AppContext); const client = useClient();
const { filename, metadata, size } = attachment; const { filename, metadata, size } = attachment;
const url = client.generateFileURL(attachment); const url = client.generateFileURL(attachment);

View File

@@ -2,8 +2,6 @@ import styled from "styled-components/macro";
import { Ref } from "preact"; import { Ref } from "preact";
import { Children } from "../../../../types/Preact";
const Grid = styled.div<{ width: number; height: number }>` const Grid = styled.div<{ width: number; height: number }>`
--width: ${(props) => props.width}px; --width: ${(props) => props.width}px;
--height: ${(props) => props.height}px; --height: ${(props) => props.height}px;

View File

@@ -2,10 +2,10 @@ import { API } from "revolt.js";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useContext, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { useIntermediate } from "../../../../context/intermediate/Intermediate"; import { useClient } from "../../../../controllers/client/ClientController";
import { AppContext } from "../../../../context/revoltjs/RevoltClient"; import { modalController } from "../../../../controllers/modals/ModalController";
enum ImageLoadingState { enum ImageLoadingState {
Loading, Loading,
@@ -19,8 +19,7 @@ type Props = JSX.HTMLAttributes<HTMLImageElement> & {
export default function ImageFile({ attachment, ...props }: Props) { export default function ImageFile({ attachment, ...props }: Props) {
const [loading, setLoading] = useState(ImageLoadingState.Loading); const [loading, setLoading] = useState(ImageLoadingState.Loading);
const client = useContext(AppContext); const client = useClient();
const { openScreen } = useIntermediate();
const url = client.generateFileURL(attachment)!; const url = client.generateFileURL(attachment)!;
return ( return (
@@ -32,7 +31,9 @@ export default function ImageFile({ attachment, ...props }: Props) {
className={classNames(styles.image, { className={classNames(styles.image, {
[styles.loading]: loading !== ImageLoadingState.Loaded, [styles.loading]: loading !== ImageLoadingState.Loaded,
})} })}
onClick={() => openScreen({ id: "image_viewer", attachment })} onClick={() =>
modalController.push({ type: "image_viewer", attachment })
}
onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")} onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")}
onLoad={() => setLoading(ImageLoadingState.Loaded)} onLoad={() => setLoading(ImageLoadingState.Loaded)}
onError={() => setLoading(ImageLoadingState.Error)} onError={() => setLoading(ImageLoadingState.Error)}

View File

@@ -221,13 +221,15 @@ export const MessageReply = observer(
</em> </em>
</> </>
)} )}
<Markdown {message.content && (
disallowBigEmoji <Markdown
content={message.content?.replace( disallowBigEmoji
/\n/g, content={message.content.replace(
" ", /\n/g,
)} " ",
/> )}
/>
)}
</div> </div>
</> </>
)} )}

View File

@@ -0,0 +1,239 @@
import {
autoPlacement,
offset,
shift,
useFloating,
} from "@floating-ui/react-dom-interactions";
import { Plus } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Message } from "revolt.js";
import styled, { css } from "styled-components";
import { createPortal } from "preact/compat";
import { useCallback, useRef, useState } from "preact/hooks";
import { IconButton } from "@revoltchat/ui";
import { emojiDictionary } from "../../../../assets/emojis";
import { useClient } from "../../../../controllers/client/ClientController";
import { RenderEmoji } from "../../../markdown/plugins/emoji";
import { HackAlertThisFileWillBeReplaced } from "../MessageBox";
interface Props {
message: Message;
}
/**
* Reaction list element
*/
const List = styled.div`
gap: 0.4em;
display: flex;
flex-wrap: wrap;
margin-top: 0.2em;
align-items: center;
.add {
display: none;
}
&:hover .add {
display: grid;
}
`;
/**
* List divider
*/
const Divider = styled.div`
width: 1px;
height: 14px;
background: var(--tertiary-foreground);
`;
/**
* Reaction styling
*/
const Reaction = styled.div<{ active: boolean }>`
padding: 0.4em;
cursor: pointer;
user-select: none;
vertical-align: middle;
border: 1px solid transparent;
color: var(--secondary-foreground);
border-radius: var(--border-radius);
background: var(--secondary-background);
img {
width: 1.2em;
height: 1.2em;
object-fit: contain;
}
&:hover {
filter: brightness(0.9);
}
&:active {
filter: brightness(0.75);
}
${(props) =>
props.active &&
css`
border-color: var(--accent);
`}
`;
/**
* Render reactions on a message
*/
export const Reactions = observer(({ message }: Props) => {
const client = useClient();
const [showPicker, setPicker] = useState(false);
/**
* Render individual reaction entries
*/
const Entry = useCallback(
observer(({ id, user_ids }: { id: string; user_ids?: Set<string> }) => {
const active = user_ids?.has(client.user!._id) || false;
return (
<Reaction
active={active}
onClick={() =>
active ? message.unreact(id) : message.react(id)
}>
<RenderEmoji match={id} /> {user_ids?.size || 0}
</Reaction>
);
}),
[],
);
/**
* Determine two lists of 'required' and 'optional' reactions
*/
const { required, optional } = (() => {
const required = new Set<string>();
const optional = new Set<string>();
if (message.interactions?.reactions) {
for (const reaction of message.interactions.reactions) {
required.add(reaction);
}
}
for (const key of message.reactions.keys()) {
if (!required.has(key)) {
optional.add(key);
}
}
return {
required,
optional,
};
})();
// Don't render list if nothing is going to show anyways
if (required.size === 0 && optional.size === 0) return null;
return (
<List>
{Array.from(required, (id) => (
<Entry key={id} id={id} user_ids={message.reactions.get(id)} />
))}
{required.size !== 0 && optional.size !== 0 && <Divider />}
{Array.from(optional, (id) => (
<Entry key={id} id={id} user_ids={message.reactions.get(id)} />
))}
{message.channel?.havePermission("React") && (
<ReactionWrapper
message={message}
open={showPicker}
setOpen={setPicker}>
<IconButton className={showPicker ? "" : "add"}>
<Plus size={20} />
</IconButton>
</ReactionWrapper>
)}
</List>
);
});
const Base = styled.div`
> div {
position: unset;
}
`;
/**
* ! FIXME: rewrite
*/
export const ReactionWrapper: React.FC<{
message: Message;
open: boolean;
setOpen: (v: boolean) => void;
}> = ({ open, setOpen, message, children }) => {
const { x, y, reference, floating, strategy } = useFloating({
open,
middleware: [
offset(4),
shift({ mainAxis: true, crossAxis: true, padding: 4 }),
autoPlacement(),
],
});
const skip = useRef();
const toggle = () => {
if (skip.current) {
skip.current = null;
return;
}
setOpen(!open);
if (!open) {
skip.current = true;
}
};
return (
<>
<div
ref={reference}
onClick={toggle}
style={{ width: "fit-content" }}>
{children}
</div>
{createPortal(
<div id="reaction">
{open && (
<Base
ref={floating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}>
<HackAlertThisFileWillBeReplaced
onSelect={(emoji) =>
message.react(
emojiDictionary[
emoji as keyof typeof emojiDictionary
] ?? emoji,
)
}
onClose={toggle}
/>
</Base>
)}
</div>,
document.body,
)}
</>
);
};

View File

@@ -3,16 +3,12 @@ import { API } from "revolt.js";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import RequiresOnline from "../../../../context/revoltjs/RequiresOnline"; import { Button, Preloader } from "@revoltchat/ui";
import {
AppContext,
StatusContext,
} from "../../../../context/revoltjs/RevoltClient";
import Preloader from "../../../ui/Preloader"; import { useClient } from "../../../../controllers/client/ClientController";
import { Button } from "@revoltchat/ui"; import RequiresOnline from "../../../../controllers/client/jsx/RequiresOnline";
interface Props { interface Props {
attachment: API.File; attachment: API.File;
@@ -24,9 +20,8 @@ export default function TextFile({ attachment }: Props) {
const [gated, setGated] = useState(attachment.size > 100_000); const [gated, setGated] = useState(attachment.size > 100_000);
const [content, setContent] = useState<undefined | string>(undefined); const [content, setContent] = useState<undefined | string>(undefined);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const status = useContext(StatusContext);
const client = useContext(AppContext);
const client = useClient();
const url = client.generateFileURL(attachment)!; const url = client.generateFileURL(attachment)!;
useEffect(() => { useEffect(() => {
@@ -57,7 +52,7 @@ export default function TextFile({ attachment }: Props) {
setLoading(false); setLoading(false);
}); });
} }
}, [content, loading, gated, status, attachment._id, attachment.size, url]); }, [content, loading, gated, attachment._id, attachment.size, url]);
return ( return (
<div <div

View File

@@ -149,12 +149,12 @@ function FileEntry({
<EmptyEntry className="icon"> <EmptyEntry className="icon">
<File size={36} /> <File size={36} />
</EmptyEntry> </EmptyEntry>
<div class="overlay"> <div className="overlay">
<XCircle size={36} /> <XCircle size={36} />
</div> </div>
</PreviewBox> </PreviewBox>
<span class="fn">{file.name}</span> <span className="fn">{file.name}</span>
<span class="size">{determineFileSize(file.size)}</span> <span className="size">{determineFileSize(file.size)}</span>
</Entry> </Entry>
); );
@@ -169,13 +169,18 @@ function FileEntry({
return ( return (
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}> <Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
<PreviewBox onClick={remove}> <PreviewBox onClick={remove}>
<img class="icon" src={url} alt={file.name} loading="eager" /> <img
<div class="overlay"> className="icon"
src={url}
alt={file.name}
loading="eager"
/>
<div className="overlay">
<XCircle size={36} /> <XCircle size={36} />
</div> </div>
</PreviewBox> </PreviewBox>
<span class="fn">{file.name}</span> <span className="fn">{file.name}</span>
<span class="size">{determineFileSize(file.size)}</span> <span className="size">{determineFileSize(file.size)}</span>
</Entry> </Entry>
); );
} }

View File

@@ -5,9 +5,9 @@ import {
Share, Share,
InfoSquare, InfoSquare,
Notification, Notification,
HappyBeaming,
} from "@styled-icons/boxicons-solid"; } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Permission } from "revolt.js";
import { Message as MessageObject } from "revolt.js"; import { Message as MessageObject } from "revolt.js";
import styled from "styled-components"; import styled from "styled-components";
@@ -18,17 +18,16 @@ import { internalEmit } from "../../../../lib/eventEmitter";
import { shiftKeyPressed } from "../../../../lib/modifiers"; import { shiftKeyPressed } from "../../../../lib/modifiers";
import { getRenderer } from "../../../../lib/renderer/Singleton"; import { getRenderer } from "../../../../lib/renderer/Singleton";
import { state } from "../../../../mobx/State";
import { QueuedMessage } from "../../../../mobx/stores/MessageQueue"; import { QueuedMessage } from "../../../../mobx/stores/MessageQueue";
import { import { modalController } from "../../../../controllers/modals/ModalController";
Screen,
useIntermediate,
} from "../../../../context/intermediate/Intermediate";
import { useClient } from "../../../../context/revoltjs/RevoltClient";
import Tooltip from "../../../common/Tooltip"; import Tooltip from "../../../common/Tooltip";
import { ReactionWrapper } from "../attachments/Reactions";
interface Props { interface Props {
reactionsOpen: boolean;
setReactionsOpen: (v: boolean) => void;
message: MessageObject; message: MessageObject;
queued?: QueuedMessage; queued?: QueuedMessage;
} }
@@ -87,127 +86,152 @@ const Divider = styled.div`
background: var(--tertiary-background); background: var(--tertiary-background);
`; `;
export const MessageOverlayBar = observer(({ message, queued }: Props) => { export const MessageOverlayBar = observer(
const client = useClient(); ({ reactionsOpen, setReactionsOpen, message, queued }: Props) => {
const { openScreen, writeClipboard } = useIntermediate(); const client = message.client;
const isAuthor = message.author_id === client.user!._id; const isAuthor = message.author_id === client.user!._id;
const [copied, setCopied] = useState<"link" | "id">(null!); const [copied, setCopied] = useState<"link" | "id">(null!);
const [extraActions, setExtra] = useState(shiftKeyPressed); const [extraActions, setExtra] = useState(shiftKeyPressed);
useEffect(() => { useEffect(() => {
const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey); const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey);
document.addEventListener("keyup", handler); document.addEventListener("keyup", handler);
document.addEventListener("keydown", handler); document.addEventListener("keydown", handler);
return () => { return () => {
document.removeEventListener("keyup", handler); document.removeEventListener("keyup", handler);
document.removeEventListener("keydown", handler); document.removeEventListener("keydown", handler);
}; };
}); });
return ( return (
<OverlayBar> <OverlayBar>
<Tooltip content="Reply"> {message.channel?.havePermission("SendMessage") && (
<Entry onClick={() => internalEmit("ReplyBar", "add", message)}> <Tooltip content="Reply">
<Share size={18} /> <Entry
</Entry> onClick={() =>
</Tooltip> internalEmit("ReplyBar", "add", message)
}>
<Share size={18} />
</Entry>
</Tooltip>
)}
{isAuthor && ( {message.channel?.havePermission("React") &&
<Tooltip content="Edit"> state.experiments.isEnabled("picker") && (
<ReactionWrapper
open={reactionsOpen}
setOpen={setReactionsOpen}
message={message}>
<Tooltip content="React">
<Entry>
<HappyBeaming size={18} />
</Entry>
</Tooltip>
</ReactionWrapper>
)}
{isAuthor && (
<Tooltip content="Edit">
<Entry
onClick={() =>
internalEmit(
"MessageRenderer",
"edit_message",
message._id,
)
}>
<Pencil size={18} />
</Entry>
</Tooltip>
)}
{isAuthor ||
(message.channel &&
message.channel.havePermission("ManageMessages")) ? (
<Tooltip content="Delete">
<Entry
onClick={(e) =>
e.shiftKey
? message.delete()
: modalController.push({
type: "delete_message",
target: message,
})
}>
<Trash size={18} color={"var(--error)"} />
</Entry>
</Tooltip>
) : undefined}
<Tooltip content="More">
<Entry <Entry
onClick={() => onClick={() =>
internalEmit( openContextMenu("Menu", {
"MessageRenderer", message,
"edit_message", contextualChannel: message.channel_id,
message._id, queued,
) })
}> }>
<Pencil size={18} /> <DotsVerticalRounded size={18} />
</Entry> </Entry>
</Tooltip> </Tooltip>
)} {extraActions && (
{isAuthor || <>
(message.channel && <Divider />
message.channel.havePermission("ManageMessages")) ? ( <Tooltip content="Mark as Unread">
<Tooltip content="Delete"> <Entry
<Entry onClick={() => {
onClick={(e) => // ! FIXME: deduplicate this code with ctx menu
e.shiftKey const messages = getRenderer(
? message.delete() message.channel!,
: openScreen({ ).messages;
id: "special_prompt", const index = messages.findIndex(
type: "delete_message", (x) => x._id === message._id,
target: message, );
} as unknown as Screen)
}>
<Trash size={18} color={"var(--error)"} />
</Entry>
</Tooltip>
) : undefined}
<Tooltip content="More">
<Entry
onClick={() =>
openContextMenu("Menu", {
message,
contextualChannel: message.channel_id,
queued,
})
}>
<DotsVerticalRounded size={18} />
</Entry>
</Tooltip>
{extraActions && (
<>
<Divider />
<Tooltip content="Mark as Unread">
<Entry
onClick={() => {
// ! FIXME: deduplicate this code with ctx menu
const messages = getRenderer(
message.channel!,
).messages;
const index = messages.findIndex(
(x) => x._id === message._id,
);
let unread_id = message._id; let unread_id = message._id;
if (index > 0) { if (index > 0) {
unread_id = messages[index - 1]._id; unread_id = messages[index - 1]._id;
} }
internalEmit("NewMessages", "mark", unread_id); internalEmit(
message.channel?.ack(unread_id, true); "NewMessages",
}}> "mark",
<Notification size={18} /> unread_id,
</Entry> );
</Tooltip> message.channel?.ack(unread_id, true);
<Tooltip }}>
content={copied === "link" ? "Copied!" : "Copy Link"} <Notification size={18} />
hideOnClick={false}> </Entry>
<Entry </Tooltip>
onClick={() => { <Tooltip
setCopied("link"); content={
writeClipboard(message.url); copied === "link" ? "Copied!" : "Copy Link"
}}> }
<LinkAlt size={18} /> hideOnClick={false}>
</Entry> <Entry
</Tooltip> onClick={() => {
<Tooltip setCopied("link");
content={copied === "id" ? "Copied!" : "Copy ID"} modalController.writeText(message.url);
hideOnClick={false}> }}>
<Entry <LinkAlt size={18} />
onClick={() => { </Entry>
setCopied("id"); </Tooltip>
writeClipboard(message._id); <Tooltip
}}> content={copied === "id" ? "Copied!" : "Copy ID"}
<InfoSquare size={18} /> hideOnClick={false}>
</Entry> <Entry
</Tooltip> onClick={() => {
</> setCopied("id");
)} modalController.writeText(message._id);
</OverlayBar> }}>
); <InfoSquare size={18} />
}); </Entry>
</Tooltip>
</>
)}
</OverlayBar>
);
},
);

View File

@@ -7,6 +7,8 @@ import styled from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { StateUpdater, useEffect } from "preact/hooks"; import { StateUpdater, useEffect } from "preact/hooks";
import { IconButton } from "@revoltchat/ui";
import { internalSubscribe } from "../../../../lib/eventEmitter"; import { internalSubscribe } from "../../../../lib/eventEmitter";
import { useApplicationState } from "../../../../mobx/State"; import { useApplicationState } from "../../../../mobx/State";
@@ -14,8 +16,6 @@ import { SECTION_MENTION } from "../../../../mobx/stores/Layout";
import { Reply } from "../../../../mobx/stores/MessageQueue"; import { Reply } from "../../../../mobx/stores/MessageQueue";
import Tooltip from "../../../common/Tooltip"; import Tooltip from "../../../common/Tooltip";
import IconButton from "../../../ui/IconButton";
import Markdown from "../../../markdown/Markdown"; import Markdown from "../../../markdown/Markdown";
import UserShort from "../../user/UserShort"; import UserShort from "../../user/UserShort";
import { SystemMessage } from "../SystemMessage"; import { SystemMessage } from "../SystemMessage";
@@ -152,11 +152,11 @@ export default observer(({ channel, replies, setReplies }: Props) => {
return ( return (
<Base key={reply.id}> <Base key={reply.id}>
<ReplyBase preview> <ReplyBase preview>
<div class="replyto"> <div className="replyto">
<Text id="app.main.channel.reply.replying" /> <Text id="app.main.channel.reply.replying" />
</div> </div>
<div class="content"> <div className="content">
<div class="username"> <div className="username">
<UserShort <UserShort
size={16} size={16}
showServerIdentity showServerIdentity
@@ -164,7 +164,7 @@ export default observer(({ channel, replies, setReplies }: Props) => {
masquerade={message.masquerade!} masquerade={message.masquerade!}
/> />
</div> </div>
<div class="message"> <div className="message">
{message.attachments && ( {message.attachments && (
<> <>
<File size={16} /> <File size={16} />
@@ -185,18 +185,20 @@ export default observer(({ channel, replies, setReplies }: Props) => {
hideInfo hideInfo
/> />
) : ( ) : (
<Markdown message.content && (
disallowBigEmoji <Markdown
content={message.content?.replace( disallowBigEmoji
/\n/g, content={message.content.replace(
" ", /\n/g,
)} " ",
/> )}
/>
)
)} )}
</div> </div>
</div> </div>
</ReplyBase> </ReplyBase>
<span class="actions"> <span className="actions">
{message.author_id !== client.user!._id && ( {message.author_id !== client.user!._id && (
<IconButton <IconButton
onClick={() => { onClick={() => {
@@ -225,7 +227,7 @@ export default observer(({ channel, replies, setReplies }: Props) => {
content={ content={
<Text id="app.main.channel.reply.toggle" /> <Text id="app.main.channel.reply.toggle" />
}> }>
<span class="toggle"> <span className="toggle">
<At size={15} /> <At size={15} />
<Text <Text
id={ id={

View File

@@ -13,7 +13,6 @@
&.website { &.website {
gap: 6px; gap: 6px;
display: flex;
flex-direction: row; flex-direction: row;
> div:nth-child(1) { > div:nth-child(1) {

View File

@@ -4,9 +4,8 @@ import styles from "./Embed.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { useIntermediate } from "../../../../context/intermediate/Intermediate"; import { useClient } from "../../../../controllers/client/ClientController";
import { useClient } from "../../../../context/revoltjs/RevoltClient"; import { modalController } from "../../../../controllers/modals/ModalController";
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea"; import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
import Markdown from "../../../markdown/Markdown"; import Markdown from "../../../markdown/Markdown";
import Attachment from "../attachments/Attachment"; import Attachment from "../attachments/Attachment";
@@ -24,7 +23,6 @@ const MAX_PREVIEW_SIZE = 150;
export default function Embed({ embed }: Props) { export default function Embed({ embed }: Props) {
const client = useClient(); const client = useClient();
const { openScreen, openLink } = useIntermediate();
const maxWidth = Math.min( const maxWidth = Math.min(
useContext(MessageAreaWidthContext) - CONTAINER_PADDING, useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
MAX_EMBED_WIDTH, MAX_EMBED_WIDTH,
@@ -143,7 +141,7 @@ export default function Embed({ embed }: Props) {
<a <a
onMouseDown={(ev) => onMouseDown={(ev) =>
(ev.button === 0 || ev.button === 1) && (ev.button === 0 || ev.button === 1) &&
openLink(embed.url!) modalController.openLink(embed.url!)
} }
className={styles.title}> className={styles.title}>
{embed.title} {embed.title}
@@ -191,8 +189,12 @@ export default function Embed({ embed }: Props) {
type="text/html" type="text/html"
frameBorder="0" frameBorder="0"
loading="lazy" loading="lazy"
onClick={() => openScreen({ id: "image_viewer", embed })} onClick={() =>
onMouseDown={(ev) => ev.button === 1 && openLink(embed.url)} modalController.push({ type: "image_viewer", embed })
}
onMouseDown={(ev) =>
ev.button === 1 && modalController.openLink(embed.url)
}
/> />
); );
} }

View File

@@ -1,5 +1,4 @@
import { Group } from "@styled-icons/boxicons-solid"; import { Group } from "@styled-icons/boxicons-solid";
import { reaction } from "mobx";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Message, API } from "revolt.js"; import { Message, API } from "revolt.js";
@@ -7,20 +6,18 @@ import styled, { css } from "styled-components/macro";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { Button } from "@revoltchat/ui"; import { Button, Category, Preloader } from "@revoltchat/ui";
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
import { import { I18nError } from "../../../../context/Locale";
AppContext,
ClientStatus,
StatusContext,
} from "../../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../../context/revoltjs/util";
import ServerIcon from "../../../../components/common/ServerIcon"; import ServerIcon from "../../../../components/common/ServerIcon";
import Overline from "../../../ui/Overline"; import {
import Preloader from "../../../ui/Preloader"; useClient,
useSession,
} from "../../../../controllers/client/ClientController";
import { takeError } from "../../../../controllers/client/jsx/error";
const EmbedInviteBase = styled.div` const EmbedInviteBase = styled.div`
width: 400px; width: 400px;
@@ -79,8 +76,8 @@ type Props = {
export function EmbedInvite({ code }: Props) { export function EmbedInvite({ code }: Props) {
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const [joinError, setJoinError] = useState<string | undefined>(undefined); const [joinError, setJoinError] = useState<string | undefined>(undefined);
@@ -91,7 +88,7 @@ export function EmbedInvite({ code }: Props) {
useEffect(() => { useEffect(() => {
if ( if (
typeof invite === "undefined" && typeof invite === "undefined" &&
(status === ClientStatus.ONLINE || status === ClientStatus.READY) (session.state === "Online" || session.state === "Ready")
) { ) {
client client
.fetchInvite(code) .fetchInvite(code)
@@ -100,7 +97,7 @@ export function EmbedInvite({ code }: Props) {
) )
.catch((err) => setError(takeError(err))); .catch((err) => setError(takeError(err)));
} }
}, [client, code, invite, status]); }, [client, code, invite, session.state]);
if (typeof invite === "undefined") { if (typeof invite === "undefined") {
return error ? ( return error ? (
@@ -160,7 +157,11 @@ export function EmbedInvite({ code }: Props) {
</Button> </Button>
)} )}
</EmbedInviteBase> </EmbedInviteBase>
{joinError && <Overline type="error" error={joinError} />} {joinError && (
<Category>
<I18nError error={joinError} />
</Category>
)}
</> </>
); );
} }

View File

@@ -3,8 +3,8 @@ import { API } from "revolt.js";
import styles from "./Embed.module.scss"; import styles from "./Embed.module.scss";
import { useIntermediate } from "../../../../context/intermediate/Intermediate"; import { useClient } from "../../../../controllers/client/ClientController";
import { useClient } from "../../../../context/revoltjs/RevoltClient"; import { modalController } from "../../../../controllers/modals/ModalController";
interface Props { interface Props {
embed: API.Embed; embed: API.Embed;
@@ -14,7 +14,6 @@ interface Props {
export default function EmbedMedia({ embed, width, height }: Props) { export default function EmbedMedia({ embed, width, height }: Props) {
if (embed.type !== "Website") return null; if (embed.type !== "Website") return null;
const { openScreen } = useIntermediate();
const client = useClient(); const client = useClient();
switch (embed.special?.type) { switch (embed.special?.type) {
@@ -50,7 +49,7 @@ export default function EmbedMedia({ embed, width, height }: Props) {
case "Lightspeed": case "Lightspeed":
return ( return (
<iframe <iframe
src={`https://next.lightspeed.tv/embed/${embed.special.id}`} src={`https://new.lightspeed.tv/embed/${embed.special.id}/stream`}
frameBorder="0" frameBorder="0"
allowFullScreen allowFullScreen
scrolling="no" scrolling="no"
@@ -115,10 +114,10 @@ export default function EmbedMedia({ embed, width, height }: Props) {
className={styles.image} className={styles.image}
src={client.proxyFile(url)} src={client.proxyFile(url)}
loading="lazy" loading="lazy"
style={{ width, height }} style={{ width: "100%", height: "100%" }}
onClick={() => onClick={() =>
openScreen({ modalController.push({
id: "image_viewer", type: "image_viewer",
embed: embed.image!, embed: embed.image!,
}) })
} }

View File

@@ -3,7 +3,7 @@ import { API } from "revolt.js";
import styles from "./Embed.module.scss"; import styles from "./Embed.module.scss";
import IconButton from "../../../ui/IconButton"; import { IconButton } from "@revoltchat/ui";
interface Props { interface Props {
embed: API.Image; embed: API.Image;
@@ -20,7 +20,7 @@ export default function EmbedMediaActions({ embed }: Props) {
</span> </span>
<a <a
href={embed.url} href={embed.url}
class={styles.openIcon} className={styles.openIcon}
target="_blank" target="_blank"
rel="noreferrer"> rel="noreferrer">
<IconButton> <IconButton>

View File

@@ -16,6 +16,7 @@ enum Badges {
Paw = 128, Paw = 128,
EarlyAdopter = 256, EarlyAdopter = 256,
ReservedRelevantJokeBadge1 = 512, ReservedRelevantJokeBadge1 = 512,
ReservedRelevantJokeBadge2 = 1024,
} }
const BadgesBase = styled.div` const BadgesBase = styled.div`
@@ -135,6 +136,13 @@ export default function UserBadges({ badges, uid }: Props) {
) : ( ) : (
<></> <></>
)} )}
{badges & Badges.ReservedRelevantJokeBadge2 ? (
<Tooltip content="It's Morbin Time">
<img src="/assets/badges/amorbus.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Paw ? ( {badges & Badges.Paw ? (
<Tooltip content="🦊"> <Tooltip content="🦊">
<img src="/assets/badges/paw.svg" /> <img src="/assets/badges/paw.svg" />

View File

@@ -1,17 +1,24 @@
import { User } from "revolt.js"; import { User } from "revolt.js";
import Checkbox, { CheckboxProps } from "../../ui/Checkbox"; import { Checkbox, Row, Column } from "@revoltchat/ui";
import UserIcon from "./UserIcon"; import UserIcon from "./UserIcon";
import { Username } from "./UserShort"; import { Username } from "./UserShort";
type UserProps = Omit<CheckboxProps, "children"> & { user: User }; type UserProps = { value: boolean; onChange: (v: boolean) => void; user: User };
export default function UserCheckbox({ user, ...props }: UserProps) { export default function UserCheckbox({ user, ...props }: UserProps) {
return ( return (
<Checkbox {...props}> <Checkbox
<UserIcon target={user} size={32} /> {...props}
<Username user={user} /> title={
</Checkbox> <Row centred>
<UserIcon target={user} size={32} />
<Column centred>
<Username user={user} />
</Column>
</Row>
}
/>
); );
} }

View File

@@ -7,13 +7,11 @@ import styled from "styled-components/macro";
import { openContextMenu } from "preact-context-menu"; import { openContextMenu } from "preact-context-menu";
import { Text, Localizer } from "preact-i18n"; import { Text, Localizer } from "preact-i18n";
import { Header, IconButton } from "@revoltchat/ui";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { modalController } from "../../../controllers/modals/ModalController";
import Header from "../../ui/Header";
import IconButton from "../../ui/IconButton";
import Tooltip from "../Tooltip"; import Tooltip from "../Tooltip";
import UserStatus from "./UserStatus"; import UserStatus from "./UserStatus";
@@ -49,16 +47,16 @@ interface Props {
} }
export default observer(({ user }: Props) => { export default observer(({ user }: Props) => {
const { writeClipboard } = useIntermediate();
return ( return (
<Header topBorder placement="secondary"> <Header topBorder palette="secondary">
<HeaderBase> <HeaderBase>
<Localizer> <Localizer>
<Tooltip content={<Text id="app.special.copy_username" />}> <Tooltip content={<Text id="app.special.copy_username" />}>
<span <span
className="username" className="username"
onClick={() => writeClipboard(user.username)}> onClick={() =>
modalController.writeText(user.username)
}>
@{user.username} @{user.username}
</span> </span>
</Tooltip> </Tooltip>

View File

@@ -1,7 +1,6 @@
import { User } from "revolt.js"; import { User } from "revolt.js";
import styled from "styled-components/macro"; import styled from "styled-components/macro";
import { Children } from "../../../types/Preact";
import Tooltip from "../Tooltip"; import Tooltip from "../Tooltip";
import { Username } from "./UserShort"; import { Username } from "./UserShort";
import UserStatus from "./UserStatus"; import UserStatus from "./UserStatus";

View File

@@ -6,10 +6,9 @@ import styled, { css } from "styled-components/macro";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import fallback from "../assets/user.png"; import fallback from "../assets/user.png";
import { useClient } from "../../../controllers/client/ClientController";
import IconBase, { IconBaseProps } from "../IconBase"; import IconBase, { IconBaseProps } from "../IconBase";
type VoiceStatus = "muted" | "deaf"; type VoiceStatus = "muted" | "deaf";
@@ -26,6 +25,8 @@ export function useStatusColour(user?: User) {
return user?.online && user?.status?.presence !== "Invisible" return user?.online && user?.status?.presence !== "Invisible"
? user?.status?.presence === "Idle" ? user?.status?.presence === "Idle"
? theme.getVariable("status-away") ? theme.getVariable("status-away")
: user?.status?.presence === "Focus"
? theme.getVariable("status-focus")
: user?.status?.presence === "Busy" : user?.status?.presence === "Busy"
? theme.getVariable("status-busy") ? theme.getVariable("status-busy")
: theme.getVariable("status-online") : theme.getVariable("status-online")
@@ -114,7 +115,7 @@ export default observer(
y="0" y="0"
width="32" width="32"
height="32" height="32"
class="icon" className="icon"
mask={mask ?? (status ? "url(#user)" : undefined)}> mask={mask ?? (status ? "url(#user)" : undefined)}>
{<img src={url} draggable={false} loading="lazy" />} {<img src={url} draggable={false} loading="lazy" />}
</foreignObject> </foreignObject>

View File

@@ -1,16 +1,19 @@
import { TimeFive } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { User, API } from "revolt.js"; import { User, API } from "revolt.js";
import styled from "styled-components/macro"; import styled, { css } from "styled-components/macro";
import { Ref } from "preact"; import { Ref } from "preact";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { dayjs } from "../../../context/Locale";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { useClient } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
import Tooltip from "../Tooltip";
import UserIcon from "./UserIcon"; import UserIcon from "./UserIcon";
const BotBadge = styled.div` const BotBadge = styled.div`
@@ -27,7 +30,10 @@ const BotBadge = styled.div`
border-radius: calc(var(--border-radius) / 2); border-radius: calc(var(--border-radius) / 2);
`; `;
type UsernameProps = JSX.HTMLAttributes<HTMLElement> & { type UsernameProps = Omit<
JSX.HTMLAttributes<HTMLElement>,
"children" | "as"
> & {
user?: User; user?: User;
prefixAt?: boolean; prefixAt?: boolean;
masquerade?: API.Masquerade; masquerade?: API.Masquerade;
@@ -36,6 +42,21 @@ type UsernameProps = JSX.HTMLAttributes<HTMLElement> & {
innerRef?: Ref<any>; innerRef?: Ref<any>;
}; };
const Name = styled.span<{ colour?: string | null }>`
${(props) =>
props.colour &&
(props.colour.includes("gradient")
? css`
background: ${props.colour};
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
`
: css`
color: ${props.colour};
`)}
`;
export const Username = observer( export const Username = observer(
({ ({
user, user,
@@ -46,7 +67,8 @@ export const Username = observer(
...otherProps ...otherProps
}: UsernameProps) => { }: UsernameProps) => {
let username = user?.username; let username = user?.username;
let color; let color = masquerade?.colour;
let timed_out: Date | undefined;
if (user && showServerIdentity) { if (user && showServerIdentity) {
const { server } = useParams<{ server?: string }>(); const { server } = useParams<{ server?: string }>();
@@ -66,15 +88,14 @@ export const Username = observer(
} }
} }
if (member.roles && member.roles.length > 0) { if (member.timeout) {
const srv = client.servers.get(member._id.server); timed_out = member.timeout;
if (srv?.roles) { }
for (const role of member.roles) {
const c = srv.roles[role]?.colour; if (!color) {
if (c) { for (const [_, { colour }] of member.orderedRoles) {
color = c; if (colour) {
continue; color = colour;
}
} }
} }
} }
@@ -82,14 +103,38 @@ export const Username = observer(
} }
} }
const el = (
<>
<Name {...otherProps} ref={innerRef} colour={color}>
{prefixAt ? "@" : undefined}
{masquerade?.name ?? username ?? (
<Text id="app.main.channel.unknown_user" />
)}
</Name>
{timed_out && (
<Tooltip
content={
<Text
id="app.main.channel.user_timed_out"
fields={{
time: dayjs(timed_out).fromNow(true),
}}
/>
}>
<TimeFive
size={16}
color="var(--secondary-foreground)"
/>
</Tooltip>
)}
</>
);
if (user?.bot) { if (user?.bot) {
return ( return (
<> <>
<span {...otherProps} ref={innerRef} style={{ color }}> {el}
{masquerade?.name ?? username ?? (
<Text id="app.main.channel.unknown_user" />
)}
</span>
<BotBadge> <BotBadge>
{masquerade ? ( {masquerade ? (
<Text id="app.main.channel.bridge" /> <Text id="app.main.channel.bridge" />
@@ -101,14 +146,7 @@ export const Username = observer(
); );
} }
return ( return el;
<span {...otherProps} ref={innerRef} style={{ color }}>
{prefixAt ? "@" : undefined}
{masquerade?.name ?? username ?? (
<Text id="app.main.channel.unknown_user" />
)}
</span>
);
}, },
); );
@@ -125,9 +163,9 @@ export default function UserShort({
masquerade?: API.Masquerade; masquerade?: API.Masquerade;
showServerIdentity?: boolean; showServerIdentity?: boolean;
}) { }) {
const { openScreen } = useIntermediate();
const openProfile = () => const openProfile = () =>
user && openScreen({ id: "profile", user_id: user._id }); user &&
modalController.push({ type: "user_profile", user_id: user._id });
const handleUserClick = (e: MouseEvent) => { const handleUserClick = (e: MouseEvent) => {
if (e.shiftKey && user?._id) { if (e.shiftKey && user?._id) {

View File

@@ -32,6 +32,10 @@ export default observer(({ user, tooltip }: Props) => {
return <Text id="app.status.idle" />; return <Text id="app.status.idle" />;
} }
if (user.status?.presence === "Focus") {
return <Text id="app.status.focus" />;
}
if (user.status?.presence === "Invisible") { if (user.status?.presence === "Invisible") {
return <Text id="app.status.offline" />; return <Text id="app.status.offline" />;
} }

View File

@@ -1,218 +0,0 @@
.markdown {
:global(.emoji) {
object-fit: contain;
height: 1.25em;
width: 1.25em;
margin: 0 0.05em 0 0.1em;
vertical-align: -0.2em;
}
&[data-large-emojis="true"] :global(.emoji) {
width: 3rem;
height: 3rem;
margin-bottom: 0;
margin-top: 1px;
margin-right: 2px;
vertical-align: -0.3em;
}
p,
pre {
margin: 0;
}
a {
text-decoration: none;
&[data-type="mention"] {
padding: 0 6px;
flex-shrink: 0;
font-weight: 600;
display: inline-block;
background: var(--secondary-background);
border-radius: calc(var(--border-radius) * 2);
&:hover {
text-decoration: none;
}
}
&:hover {
text-decoration: underline;
}
}
h1,
h2,
h3,
h4,
h5,
h6,
ul,
ol,
blockquote {
margin: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
&:not(:first-child) {
margin-top: 12px;
}
}
ul,
ol {
list-style-position: inside;
padding-left: 10px;
}
blockquote {
margin: 2px 0;
padding: 2px 0;
background: var(--hover);
border-radius: var(--border-radius);
border-inline-start: 4px solid var(--tertiary-background);
> * {
margin: 0 8px;
}
}
pre {
padding: 1em;
overflow-x: scroll;
border-radius: var(--border-radius);
background: var(--block) !important;
}
p > code {
padding: 1px 4px;
flex-shrink: 0;
}
code {
color: white;
font-size: 90%;
background: var(--block);
border-radius: var(--border-radius);
font-family: var(--monospace-font), monospace;
border-radius: 3px;
-webkit-box-decoration-break: clone;
}
input[type="checkbox"] {
margin-right: 4px;
pointer-events: none;
}
table {
border-collapse: collapse;
th,
td {
padding: 6px;
border: 1px solid var(--tertiary-foreground);
}
}
:global(.katex-block) {
overflow-x: auto;
}
:global(.spoiler) {
padding: 0 2px;
cursor: pointer;
user-select: none;
color: transparent;
background: #151515;
border-radius: var(--border-radius);
> * {
opacity: 0;
pointer-events: none;
}
&:global(.shown) {
cursor: auto;
user-select: all;
color: var(--foreground);
background: var(--secondary-background);
> * {
opacity: 1;
pointer-events: unset;
}
}
}
:global(.code) {
font-family: var(--monospace-font), monospace;
:global(.lang) {
width: fit-content;
padding-bottom: 8px;
div {
color: #111;
cursor: pointer;
padding: 2px 6px;
font-weight: 600;
user-select: none;
display: inline-block;
background: var(--accent);
font-size: 10px;
text-transform: uppercase;
box-shadow: 0 2px #787676;
border-radius: calc(var(--border-radius) / 3);
&:active {
transform: translateY(1px);
box-shadow: 0 1px #787676;
}
}
}
}
input[type="checkbox"] {
width: 0;
opacity: 0;
pointer-events: none;
}
label {
pointer-events: none;
}
input[type="checkbox"] + label:before {
width: 12px;
height: 12px;
content: "a";
font-size: 10px;
margin-right: 6px;
line-height: 12px;
background: white;
position: relative;
display: inline-block;
border-radius: var(--border-radius);
}
input[type="checkbox"][checked="true"] + label:before {
content: "";
align-items: center;
display: inline-flex;
justify-content: center;
background: var(--accent);
}
input[type="checkbox"] + label {
line-height: 12px;
position: relative;
}
}

View File

@@ -1,13 +1,15 @@
import { Suspense, lazy } from "preact/compat"; import { Suspense, lazy } from "preact/compat";
const Renderer = lazy(() => import("./Renderer")); const Renderer = lazy(() => import("./RemarkRenderer"));
export interface MarkdownProps { export interface MarkdownProps {
content?: string | null; content: string;
disallowBigEmoji?: boolean; disallowBigEmoji?: boolean;
} }
export default function Markdown(props: MarkdownProps) { export default function Markdown(props: MarkdownProps) {
if (!props.content) return null;
return ( return (
// @ts-expect-error Typings mis-match. // @ts-expect-error Typings mis-match.
<Suspense fallback={props.content}> <Suspense fallback={props.content}>

View File

@@ -0,0 +1,258 @@
import "katex/dist/katex.min.css";
import rehypeKatex from "rehype-katex";
import rehypePrism from "rehype-prism";
import rehypeReact from "rehype-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import styled, { css } from "styled-components";
import { unified } from "unified";
import { createElement } from "preact";
import { memo } from "preact/compat";
import { useLayoutEffect, useMemo, useState } from "preact/hooks";
import { MarkdownProps } from "./Markdown";
import { handlers } from "./hast";
import { RenderCodeblock } from "./plugins/Codeblock";
import { RenderAnchor } from "./plugins/anchors";
import { remarkChannels, RenderChannel } from "./plugins/channels";
import { isOnlyEmoji, remarkEmoji, RenderEmoji } from "./plugins/emoji";
import { remarkHtmlToText } from "./plugins/htmlToText";
import { remarkMention, RenderMention } from "./plugins/mentions";
import { remarkSpoiler, RenderSpoiler } from "./plugins/spoiler";
import { remarkTimestamps } from "./plugins/timestamps";
import "./prism";
/**
* Null element
*/
const Null: React.FC = () => null;
/**
* Custom Markdown components
*/
const components = {
emoji: RenderEmoji,
mention: RenderMention,
spoiler: RenderSpoiler,
channel: RenderChannel,
a: RenderAnchor,
p: styled.p`
margin: 0;
> code {
padding: 1px 4px;
flex-shrink: 0;
}
`,
h1: styled.h1`
margin: 0.2em 0;
`,
h2: styled.h2`
margin: 0.2em 0;
`,
h3: styled.h3`
margin: 0.2em 0;
`,
h4: styled.h4`
margin: 0.2em 0;
`,
h5: styled.h5`
margin: 0.2em 0;
`,
h6: styled.h6`
margin: 0.2em 0;
`,
pre: RenderCodeblock,
code: styled.code`
color: white;
background: var(--block);
font-size: 90%;
font-family: var(--monospace-font), monospace;
border-radius: 3px;
box-decoration-break: clone;
`,
table: styled.table`
border-collapse: collapse;
th,
td {
padding: 6px;
border: 1px solid var(--tertiary-foreground);
}
`,
ul: styled.ul`
list-style-position: inside;
padding-left: 10px;
margin: 0.2em 0;
`,
ol: styled.ol`
list-style-position: inside;
padding-left: 10px;
margin: 0.2em 0;
`,
li: styled.li`
${(props) =>
props.class === "task-list-item" &&
css`
list-style-type: none;
`}
`,
blockquote: styled.blockquote`
margin: 2px 0;
padding: 2px 0;
background: var(--hover);
border-radius: var(--border-radius);
border-inline-start: 4px solid var(--tertiary-background);
> * {
margin: 0 8px;
}
`,
// Block image elements
img: Null,
// Catch literally everything else just in case
video: Null,
figure: Null,
picture: Null,
source: Null,
audio: Null,
script: Null,
style: Null,
};
/**
* Unified Markdown renderer
*/
const render = unified()
.use(remarkParse)
.use(remarkBreaks)
.use(remarkGfm)
.use(remarkMath)
.use(remarkSpoiler)
.use(remarkChannels)
.use(remarkTimestamps)
.use(remarkEmoji)
.use(remarkMention)
.use(remarkHtmlToText)
.use(remarkRehype, {
handlers,
})
.use(rehypeKatex, {
maxSize: 10,
maxExpand: 0,
trust: false,
strict: false,
output: "html",
throwOnError: false,
errorColor: "var(--error)",
})
.use(rehypePrism)
// @ts-expect-error typings do not
// match between Preact and React
.use(rehypeReact, {
createElement,
Fragment,
components,
});
/**
* Markdown parent container
*/
const Container = styled.div<{ largeEmoji: boolean }>`
// Allow scrolling block math
.math-display {
overflow-x: auto;
}
// Set emoji size
--emoji-size: ${(props) => (props.largeEmoji ? "3em" : "1.25em")};
// Underline link hover
a:hover {
text-decoration: underline;
}
`;
/**
* Regex for matching execessive recursion of blockquotes and lists
*/
const RE_RECURSIVE = /(^(?:[>*+-][^\S\r\n]*){5})(?:[>*+-][^\S\r\n]*)+(.*$)/gm;
/**
* Regex for matching multi-line blockquotes
*/
const RE_BLOCKQUOTE = /^([^\S\r\n]*>[^\n]+\n?)+/gm;
/**
* Regex for matching HTML tags
*/
const RE_HTML_TAGS = /^(<\/?[a-zA-Z0-9]+>)(.*$)/gm;
/**
* Regex for matching empty lines
*/
const RE_EMPTY_LINE = /^\s*?$/gm;
/**
* Regex for matching line starting with plus
*/
const RE_PLUS = /^\s*\+(?:$|[^+])/gm;
/**
* Sanitise Markdown input before rendering
* @param content Input string
* @returns Sanitised string
*/
function sanitise(content: string) {
return (
content
// Strip excessive blockquote or list indentation
.replace(RE_RECURSIVE, (_, m0, m1) => m0 + m1)
// Append empty character if string starts with html tag
// This is to avoid inconsistencies in rendering Markdown inside/after HTML tags
// https://github.com/revoltchat/revite/issues/733
.replace(RE_HTML_TAGS, (match) => `\u200E${match}`)
// Append empty character if line starts with a plus
// which would usually open a new list but we want
// to avoid that behaviour in our case.
.replace(RE_PLUS, (match) => `\u200E${match}`)
// Replace empty lines with non-breaking space
// because remark renderer is collapsing empty
// or otherwise whitespace-only lines of text
.replace(RE_EMPTY_LINE, "")
// Ensure empty line after blockquotes for correct rendering
.replace(RE_BLOCKQUOTE, (match) => `${match}\n`)
);
}
/**
* Remark renderer component
*/
export default memo(({ content, disallowBigEmoji }: MarkdownProps) => {
const sanitisedContent = useMemo(() => sanitise(content), [content]);
const [Content, setContent] = useState<React.ReactElement>(null!);
useLayoutEffect(() => {
render
.process(sanitisedContent)
.then((file) => setContent(file.result));
}, [sanitisedContent]);
const largeEmoji = useMemo(
() => !disallowBigEmoji && isOnlyEmoji(content!),
[content, disallowBigEmoji],
);
return <Container largeEmoji={largeEmoji}>{Content}</Container>;
});

View File

@@ -1,292 +0,0 @@
/* eslint-disable react-hooks/rules-of-hooks */
import MarkdownKatex from "@traptitech/markdown-it-katex";
import MarkdownSpoilers from "@traptitech/markdown-it-spoiler";
import "katex/dist/katex.min.css";
import MarkdownIt from "markdown-it";
// @ts-expect-error No typings.
import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare";
import { RE_MENTIONS } from "revolt.js";
import styles from "./Markdown.module.scss";
import { useCallback, useContext } from "preact/hooks";
import { internalEmit } from "../../lib/eventEmitter";
import { determineLink } from "../../lib/links";
import { dayjs } from "../../context/Locale";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { generateEmoji } from "../common/Emoji";
import { emojiDictionary } from "../../assets/emojis";
import { MarkdownProps } from "./Markdown";
import Prism from "./prism";
// TODO: global.d.ts file for defining globals
declare global {
interface Window {
copycode: (element: HTMLDivElement) => void;
}
}
// Handler for code block copy.
if (typeof window !== "undefined") {
window.copycode = function (element: HTMLDivElement) {
try {
const code = element.parentElement?.parentElement?.children[1];
if (code) {
navigator.clipboard.writeText(code.textContent?.trim() ?? "");
}
} catch (e) {}
};
}
export const md: MarkdownIt = MarkdownIt({
breaks: true,
linkify: true,
highlight: (str, lang) => {
const v = Prism.languages[lang];
if (v) {
const out = Prism.highlight(str, v, lang);
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
}
return `<pre class="code"><code>${md.utils.escapeHtml(
str,
)}</code></pre>`;
},
})
.disable("image")
.use(MarkdownEmoji, { defs: emojiDictionary })
.use(MarkdownSpoilers)
.use(MarkdownKatex, {
throwOnError: false,
maxExpand: 0,
maxSize: 10,
strict: false,
errorColor: "var(--error)",
});
md.linkify.set({ fuzzyLink: false });
// TODO: global.d.ts file for defining globals
declare global {
interface Window {
internalHandleURL: (element: HTMLAnchorElement) => void;
}
}
// Include emojis.
md.renderer.rules.emoji = function (token, idx) {
return generateEmoji(token[idx].content);
};
// Force line breaks.
// https://github.com/markdown-it/markdown-it/issues/211#issuecomment-508380611
const defaultParagraphRenderer =
md.renderer.rules.paragraph_open ||
((tokens, idx, options, env, self) =>
self.renderToken(tokens, idx, options));
md.renderer.rules.paragraph_open = function (tokens, idx, options, env, self) {
let result = "";
if (idx > 1) {
const inline = tokens[idx - 2];
const paragraph = tokens[idx];
if (
inline.type === "inline" &&
inline.map &&
inline.map[1] &&
paragraph.map &&
paragraph.map[0]
) {
const diff = paragraph.map[0] - inline.map[1];
if (diff > 0) {
result = "<br>".repeat(diff);
}
}
}
return result + defaultParagraphRenderer(tokens, idx, options, env, self);
};
const RE_TWEMOJI = /:(\w+):/g;
// ! FIXME: Move to library
const RE_CHANNELS = /<#([A-z0-9]{26})>/g;
const RE_TIME = /<t:([0-9]+):(\w)>/g;
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
const client = useContext(AppContext);
const { openLink } = useIntermediate();
if (typeof content === "undefined") return null;
if (!content || content.length === 0) return null;
// We replace the message with the mention at the time of render.
// We don't care if the mention changes.
const newContent = content
.replace(RE_TIME, (sub: string, ...args: unknown[]) => {
if (isNaN(args[0] as number)) return sub;
const date = dayjs.unix(args[0] as number);
const format = args[1] as string;
let final = "";
switch (format) {
case "t":
final = date.format("hh:mm");
break;
case "T":
final = date.format("hh:mm:ss");
break;
case "R":
final = date.fromNow();
break;
case "D":
final = date.format("DD MMMM YYYY");
break;
case "F":
final = date.format("dddd, DD MMMM YYYY hh:mm");
break;
default:
final = date.format("DD MMMM YYYY hh:mm");
break;
}
return `\`${final}\``;
})
.replace(RE_MENTIONS, (sub: string, ...args: unknown[]) => {
const id = args[0] as string,
user = client.users.get(id);
if (user) {
return `[@${user.username}](/@${id})`;
}
return sub;
})
.replace(RE_CHANNELS, (sub: string, ...args: unknown[]) => {
const id = args[0] as string,
channel = client.channels.get(id);
if (channel?.channel_type === "TextChannel") {
return `[#${channel.name}](/server/${channel.server_id}/channel/${id})`;
}
return sub;
});
const useLargeEmojis = disallowBigEmoji
? false
: content.replace(RE_TWEMOJI, "").trim().length === 0;
const toggle = useCallback((ev: MouseEvent) => {
if (ev.currentTarget) {
const element = ev.currentTarget as HTMLDivElement;
if (element.classList.contains("spoiler")) {
element.classList.add("shown");
}
}
}, []);
const handleLink = useCallback(
(ev: MouseEvent) => {
if (ev.currentTarget) {
const element = ev.currentTarget as HTMLAnchorElement;
if (ev.shiftKey) {
switch (element.dataset.type) {
case "mention": {
internalEmit(
"MessageBox",
"append",
`<@${element.dataset.mentionId}>`,
"mention",
);
ev.preventDefault();
return;
}
case "channel_mention": {
internalEmit(
"MessageBox",
"append",
`<#${element.dataset.mentionId}>`,
"channel_mention",
);
ev.preventDefault();
return;
}
}
}
if (openLink(element.href)) {
ev.preventDefault();
}
}
},
[openLink],
);
return (
<span
ref={(el) => {
if (el) {
el.querySelectorAll<HTMLDivElement>(".spoiler").forEach(
(element) => {
element.removeEventListener("click", toggle);
element.addEventListener("click", toggle);
},
);
el.querySelectorAll<HTMLAnchorElement>("a").forEach(
(element) => {
element.removeEventListener("click", handleLink);
element.addEventListener("click", handleLink);
element.removeAttribute("data-type");
element.removeAttribute("data-mention-id");
element.removeAttribute("target");
const link = determineLink(element.href);
switch (link.type) {
case "profile": {
element.setAttribute(
"data-type",
"mention",
);
element.setAttribute(
"data-mention-id",
link.id,
);
break;
}
case "navigate": {
if (link.navigation_type === "channel") {
element.setAttribute(
"data-type",
"channel_mention",
);
element.setAttribute(
"data-mention-id",
link.channel_id,
);
}
break;
}
case "external": {
element.setAttribute("target", "_blank");
element.setAttribute("rel", "noreferrer");
break;
}
}
},
);
}
}}
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: md.render(newContent),
}}
data-large-emojis={useLargeEmojis}
/>
);
}

View File

@@ -0,0 +1,7 @@
import { passThroughComponents } from "./plugins/remarkRegexComponent";
import { timestampHandler } from "./plugins/timestamps";
export const handlers = {
...passThroughComponents("emoji", "spoiler", "mention", "channel"),
timestamp: timestampHandler,
};

View File

@@ -0,0 +1,79 @@
import styled from "styled-components";
import { useCallback, useRef } from "preact/hooks";
import { Tooltip } from "@revoltchat/ui";
import { modalController } from "../../../controllers/modals/ModalController";
/**
* Base codeblock styles
*/
const Base = styled.pre`
padding: 1em;
overflow-x: scroll;
background: var(--block);
border-radius: var(--border-radius);
`;
/**
* Copy codeblock contents button styles
*/
const Lang = styled.div`
font-family: var(--monospace-font);
width: fit-content;
padding-bottom: 8px;
a {
color: #111;
cursor: pointer;
padding: 2px 6px;
font-weight: 600;
user-select: none;
display: inline-block;
background: var(--accent);
font-size: 10px;
text-transform: uppercase;
box-shadow: 0 2px #787676;
border-radius: calc(var(--border-radius) / 3);
&:active {
transform: translateY(1px);
box-shadow: 0 1px #787676;
}
}
`;
/**
* Render a codeblock with copy text button
*/
export const RenderCodeblock: React.FC<{ class: string }> = ({
children,
...props
}) => {
const ref = useRef<HTMLPreElement>(null);
let text = "text";
if (props.class) {
text = props.class.split("-")[1];
}
const onCopy = useCallback(() => {
const text = ref.current?.querySelector("code")?.innerText;
text && modalController.writeText(text);
}, [ref]);
return (
<Base ref={ref}>
<Lang>
<Tooltip content="Copy to Clipboard" placement="top">
{/**
// @ts-expect-error Preact-React */}
<a onClick={onCopy}>{text}</a>
</Tooltip>
</Lang>
{children}
</Base>
);
};

View File

@@ -0,0 +1,34 @@
import { Link } from "react-router-dom";
import { determineLink } from "../../../lib/links";
import { modalController } from "../../../controllers/modals/ModalController";
export function RenderAnchor({
href,
...props
}: JSX.HTMLAttributes<HTMLAnchorElement>) {
// Pass-through no href or if anchor
if (!href || href.startsWith("#")) return <a href={href} {...props} />;
// Determine type of link
const link = determineLink(href);
if (link.type === "none") return <a {...props} />;
// Render direct link if internal
if (link.type === "navigate") {
return <Link to={link.path} children={props.children} />;
}
return (
<a
{...props}
href={href}
target="_blank"
rel="noreferrer"
onClick={(ev) =>
modalController.openLink(href) && ev.preventDefault()
}
/>
);
}

View File

@@ -0,0 +1,21 @@
import { Link } from "react-router-dom";
import { clientController } from "../../../controllers/client/ClientController";
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
export function RenderChannel({ match }: CustomComponentProps) {
const channel = clientController.getAvailableClient().channels.get(match)!;
return (
<Link
to={`${
channel.server_id ? `/server/${channel.server_id}` : ""
}/channel/${match}`}>{`#${channel.name}`}</Link>
);
}
export const remarkChannels = createComponent(
"channel",
/<#([A-z0-9]{26})>/g,
(match) => clientController.getAvailableClient().channels.has(match),
);

View File

@@ -0,0 +1,66 @@
import styled from "styled-components";
import { useState } from "preact/hooks";
import { emojiDictionary } from "../../../assets/emojis";
import { clientController } from "../../../controllers/client/ClientController";
import { parseEmoji } from "../../common/Emoji";
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
const Emoji = styled.img`
object-fit: contain;
height: var(--emoji-size);
width: var(--emoji-size);
margin: 0 0.05em 0 0.1em;
vertical-align: -0.2em;
img:before {
content: " ";
display: block;
position: absolute;
height: 50px;
width: 50px;
background-image: url(ishere.jpg);
}
`;
const RE_EMOJI = /:([a-zA-Z0-9_+]+):/g;
const RE_ULID = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/;
export function RenderEmoji({ match }: CustomComponentProps) {
const [fail, setFail] = useState(false);
const url = RE_ULID.test(match)
? `${
clientController.getAvailableClient().configuration?.features
.autumn.url
}/emojis/${match}`
: parseEmoji(
match in emojiDictionary
? emojiDictionary[match as keyof typeof emojiDictionary]
: match,
);
if (fail) return <span>{`:${match}:`}</span>;
return (
<Emoji
alt={`:${match}:`}
loading="lazy"
className="emoji"
draggable={false}
src={url}
onError={() => setFail(true)}
/>
);
}
export const remarkEmoji = createComponent(
"emoji",
RE_EMOJI,
(match) => match in emojiDictionary || RE_ULID.test(match),
);
export function isOnlyEmoji(text: string) {
return text.replaceAll(RE_EMOJI, "").trim().length === 0;
}

View File

@@ -0,0 +1,10 @@
import { Plugin } from "unified";
import { visit } from "unist-util-visit";
export const remarkHtmlToText: Plugin = () => {
return (tree) => {
visit(tree, "html", (node: { type: string; value: string }) => {
node.type = "text";
});
};
};

View File

@@ -0,0 +1,53 @@
import { RE_MENTIONS } from "revolt.js";
import styled from "styled-components";
import { clientController } from "../../../controllers/client/ClientController";
import UserShort from "../../common/user/UserShort";
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
const Mention = styled.a`
gap: 4px;
flex-shrink: 0;
padding-left: 2px;
padding-right: 6px;
align-items: center;
display: inline-flex;
vertical-align: middle;
cursor: pointer;
font-weight: 600;
text-decoration: none !important;
background: var(--secondary-background);
border-radius: calc(var(--border-radius) * 2);
transition: 0.1s ease filter;
&:hover {
filter: brightness(0.75);
}
&:active {
filter: brightness(0.65);
}
svg {
width: 1em;
height: 1em;
}
`;
export function RenderMention({ match }: CustomComponentProps) {
return (
<Mention>
<UserShort
showServerIdentity
user={clientController.getAvailableClient().users.get(match)}
/>
</Mention>
);
}
export const remarkMention = createComponent("mention", RE_MENTIONS, (match) =>
clientController.getAvailableClient().users.has(match),
);

View File

@@ -0,0 +1,108 @@
import type { Handler } from "mdast-util-to-hast";
import type { Plugin } from "unified";
import { visit } from "unist-util-visit";
/**
* Props given to custom components
*/
export interface CustomComponentProps {
type?: string;
match: string;
arg1?: string;
}
/**
* Create a new custom component matched by a given RegExp
* @param type hast node type
* @param regex Regex to match (must have one capture group)
* @returns Unified Plugin
*/
export function createComponent(
type: string,
regex: RegExp,
validator?: (match: string) => boolean,
): Plugin {
/**
* Plugin which transforms a given RegExp into a custom component with given name.
*/
return () => {
return (tree) => {
visit(
tree,
"text",
(
node: { value: string },
index: number,
parent: { children: any[] },
) => {
const result = [];
let start = 0;
regex.lastIndex = 0;
let match = regex.exec(node.value);
while (match) {
if (!validator || validator(match[1])) {
const position = match.index;
if (start !== position) {
result.push({
type: "text",
value: node.value.slice(start, position),
});
}
result.push({
type,
match: match[1],
arg1: match[2],
});
start = position + match[0].length;
}
match = regex.exec(node.value);
}
if (
result.length > 0 &&
parent &&
typeof index === "number"
) {
if (start < node.value.length) {
result.push({
type: "text",
value: node.value.slice(start),
});
}
parent.children.splice(index, 1, ...result);
return index + result.length;
}
},
);
};
};
}
/**
* Pass-through a component as-is from remark to rehype
* @param name Tag name
* @returns Handler
*/
export const passThroughRehype: (name: string) => Handler =
(name: string) => (h, node) =>
h(node, name, node);
/**
* Pass-through multiple components at once
* @param keys Tags
* @returns Handlers
*/
export const passThroughComponents = (...keys: string[]) => {
const obj: Record<string, Handler> = {};
for (const key of keys) {
obj[key] = passThroughRehype(key);
}
return obj;
};

View File

@@ -0,0 +1,45 @@
import styled, { css } from "styled-components";
import { useState } from "preact/hooks";
import { createComponent, CustomComponentProps } from "./remarkRegexComponent";
const Spoiler = styled.span<{ shown: boolean }>`
padding: 0 2px;
cursor: pointer;
user-select: none;
color: transparent;
background: #151515;
border-radius: var(--border-radius);
> * {
opacity: 0;
pointer-events: none;
}
${(props) =>
props.shown &&
css`
cursor: auto;
user-select: all;
color: var(--foreground);
background: var(--secondary-background);
> * {
opacity: 1;
pointer-events: unset;
}
`}
`;
export function RenderSpoiler({ match }: CustomComponentProps) {
const [shown, setShown] = useState(false);
return (
<Spoiler shown={shown} onClick={() => setShown(true)}>
{match}
</Spoiler>
);
}
export const remarkSpoiler = createComponent("spoiler", /!!([^!]+)!!/g);

View File

@@ -0,0 +1,39 @@
import type { Handler } from "mdast-util-to-hast";
import { dayjs } from "../../../context/Locale";
import { createComponent } from "./remarkRegexComponent";
export const timestampHandler: Handler = (h, { match, arg1 }) => {
if (isNaN(match)) return { type: "text", value: match };
const date = dayjs.unix(match);
let value = "";
switch (arg1) {
case "t":
value = date.format("hh:mm");
break;
case "T":
value = date.format("hh:mm:ss");
break;
case "R":
value = date.fromNow();
break;
case "D":
value = date.format("DD MMMM YYYY");
break;
case "F":
value = date.format("dddd, DD MMMM YYYY hh:mm");
break;
default:
value = date.format("DD MMMM YYYY hh:mm");
break;
}
return h(null, "code", {}, [{ type: "text", value }]);
};
export const remarkTimestamps = createComponent(
"timestamp",
/<t:([0-9]+)(?::(\w))?>/g,
);

View File

@@ -34,7 +34,6 @@ import "prismjs/components/prism-r";
import "prismjs/components/prism-sql"; import "prismjs/components/prism-sql";
import "prismjs/components/prism-graphql"; import "prismjs/components/prism-graphql";
import "prismjs/components/prism-shell-session"; import "prismjs/components/prism-shell-session";
import "prismjs/components/prism-java";
import "prismjs/components/prism-powershell"; import "prismjs/components/prism-powershell";
import "prismjs/components/prism-swift"; import "prismjs/components/prism-swift";
import "prismjs/components/prism-yaml"; import "prismjs/components/prism-yaml";
@@ -87,7 +86,6 @@ import "prismjs/components/prism-moonscript";
import "prismjs/components/prism-qml"; import "prismjs/components/prism-qml";
import "prismjs/components/prism-vim"; import "prismjs/components/prism-vim";
import "prismjs/components/prism-nim"; import "prismjs/components/prism-nim";
import "prismjs/components/prism-swift";
import "prismjs/components/prism-haml"; import "prismjs/components/prism-haml";
import "prismjs/components/prism-ada"; import "prismjs/components/prism-ada";
import "prismjs/components/prism-arduino"; import "prismjs/components/prism-arduino";

View File

@@ -98,7 +98,7 @@ const TitlebarBase = styled.div<Props>`
export function Titlebar(props: Props) { export function Titlebar(props: Props) {
return ( return (
<TitlebarBase {...props}> <TitlebarBase {...props}>
<div class="title"> <div className="title">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 193.733 37.438"> viewBox="0 0 193.733 37.438">
@@ -114,7 +114,7 @@ export function Titlebar(props: Props) {
<Wrench size="12.5" /> <Wrench size="12.5" />
)} )}
</div> </div>
{/*<div class="actions quick"> {/*<div className="actions quick">
<Tooltip <Tooltip
content="Mute" content="Mute"
placement="bottom"> placement="bottom">
@@ -130,9 +130,9 @@ export function Titlebar(props: Props) {
</div> </div>
</Tooltip> </Tooltip>
</div>*/} </div>*/}
<div class="drag" /> <div className="drag" />
<UpdateIndicator style="titlebar" /> <UpdateIndicator style="titlebar" />
<div class="actions"> <div className="actions">
<div onClick={window.native.min}> <div onClick={window.native.min}>
<svg <svg
aria-hidden="false" aria-hidden="false"
@@ -164,7 +164,7 @@ export function Titlebar(props: Props) {
/> />
</svg> </svg>
</div> </div>
<div onClick={window.native.close} class="error"> <div onClick={window.native.close} className="error">
<svg <svg
aria-hidden="false" aria-hidden="false"
width="12" width="12"

View File

@@ -3,14 +3,14 @@ import { observer } from "mobx-react-lite";
import { useHistory, useLocation } from "react-router"; import { useHistory, useLocation } from "react-router";
import styled, { css } from "styled-components/macro"; import styled, { css } from "styled-components/macro";
import { Centred, IconButton } from "@revoltchat/ui";
import ConditionalLink from "../../lib/ConditionalLink"; import ConditionalLink from "../../lib/ConditionalLink";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import { useClient } from "../../context/revoltjs/RevoltClient"; import { useClient } from "../../controllers/client/ClientController";
import UserIcon from "../common/user/UserIcon"; import UserIcon from "../common/user/UserIcon";
import IconButton from "../ui/IconButton";
const Base = styled.div` const Base = styled.div`
background: var(--secondary-background); background: var(--secondary-background);
@@ -24,8 +24,19 @@ const Navbar = styled.div`
height: var(--bottom-navigation-height); height: var(--bottom-navigation-height);
`; `;
/**
* I've decided that this whole component
* needs to be re-written 👍👍👍👍👍👍
*/
const Button = styled.a<{ active: boolean }>` const Button = styled.a<{ active: boolean }>`
flex: 1; flex: 1;
color: var(--foreground);
// ok
* {
color: var(--foreground) !important;
}
> a, > a,
> div, > div,
@@ -63,7 +74,7 @@ export default observer(() => {
<Base> <Base>
<Navbar> <Navbar>
<Button active={homeActive}> <Button active={homeActive}>
<IconButton <Centred
onClick={() => { onClick={() => {
if (settingsActive) { if (settingsActive) {
if (history.length > 0) { if (history.length > 0) {
@@ -80,14 +91,14 @@ export default observer(() => {
} }
}}> }}>
<Message size={24} /> <Message size={24} />
</IconButton> </Centred>
</Button> </Button>
<Button active={friendsActive}> <Button active={friendsActive}>
<ConditionalLink active={friendsActive} to="/friends"> <IconButton>
<IconButton> <ConditionalLink active={friendsActive} to="/friends">
<Group size={25} /> <Group size={25} />
</IconButton> </ConditionalLink>
</ConditionalLink> </IconButton>
</Button> </Button>
{/*<Button active={searchActive}> {/*<Button active={searchActive}>
<ConditionalLink active={searchActive} to="/search"> <ConditionalLink active={searchActive} to="/search">
@@ -104,20 +115,20 @@ export default observer(() => {
</ConditionalLink> </ConditionalLink>
</Button>*/} </Button>*/}
<Button active={discoverActive}> <Button active={discoverActive}>
<ConditionalLink <IconButton>
active={discoverActive} <ConditionalLink
to="/discover/servers"> active={discoverActive}
<IconButton> to="/discover/servers">
<Compass size={24} /> <Compass size={24} />
</IconButton> </ConditionalLink>
</ConditionalLink> </IconButton>
</Button> </Button>
<Button active={settingsActive}> <Button active={settingsActive}>
<ConditionalLink active={settingsActive} to="/settings"> <IconButton>
<IconButton> <ConditionalLink active={settingsActive} to="/settings">
<UserIcon target={user} size={26} status={true} /> <UserIcon target={user} size={26} status={true} />
</IconButton> </ConditionalLink>
</ConditionalLink> </IconButton>
</Button> </Button>
</Navbar> </Navbar>
</Base> </Base>

View File

@@ -5,23 +5,20 @@ import { User, Channel } from "revolt.js";
import styles from "./Item.module.scss"; import styles from "./Item.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { Ref } from "preact";
import { useTriggerEvents } from "preact-context-menu"; import { useTriggerEvents } from "preact-context-menu";
import { Localizer, Text } from "preact-i18n"; import { Localizer, Text } from "preact-i18n";
import { IconButton } from "@revoltchat/ui";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { stopPropagation } from "../../../lib/stopPropagation"; import { stopPropagation } from "../../../lib/stopPropagation";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { modalController } from "../../../controllers/modals/ModalController";
import ChannelIcon from "../../common/ChannelIcon"; import ChannelIcon from "../../common/ChannelIcon";
import Tooltip from "../../common/Tooltip"; import Tooltip from "../../common/Tooltip";
import UserIcon from "../../common/user/UserIcon"; import UserIcon from "../../common/user/UserIcon";
import { Username } from "../../common/user/UserShort"; import { Username } from "../../common/user/UserShort";
import UserStatus from "../../common/user/UserStatus"; import UserStatus from "../../common/user/UserStatus";
import IconButton from "../../ui/IconButton";
import { Children } from "../../../types/Preact";
type CommonProps = Omit< type CommonProps = Omit<
JSX.HTMLAttributes<HTMLDivElement>, JSX.HTMLAttributes<HTMLDivElement>,
@@ -52,7 +49,6 @@ export const UserButton = observer((props: UserProps) => {
channel, channel,
...divProps ...divProps
} = props; } = props;
const { openScreen } = useIntermediate();
return ( return (
<div <div
@@ -113,8 +109,7 @@ export const UserButton = observer((props: UserProps) => {
className={styles.icon} className={styles.icon}
onClick={(e) => onClick={(e) =>
stopPropagation(e) && stopPropagation(e) &&
openScreen({ modalController.push({
id: "special_prompt",
type: "close_dm", type: "close_dm",
target: channel, target: channel,
}) })
@@ -151,7 +146,6 @@ export const ChannelButton = observer((props: ChannelProps) => {
return <UserButton {...{ active, alert, channel, user }} />; return <UserButton {...{ active, alert, channel, user }} />;
} }
const { openScreen } = useIntermediate();
const alerting = alert && !muted && !active; const alerting = alert && !muted && !active;
return ( return (
@@ -161,16 +155,16 @@ export const ChannelButton = observer((props: ChannelProps) => {
data-alert={alerting} data-alert={alerting}
data-muted={muted} data-muted={muted}
aria-label={channel.name} aria-label={channel.name}
className={classNames(styles.item, { [styles.compact]: compact })} className={classNames(styles.item, {
[styles.compact]: compact,
})}
{...useTriggerEvents("Menu", { {...useTriggerEvents("Menu", {
channel: channel._id, channel: channel._id,
unread: !!alert, unread: !!alert,
})}> })}>
<ChannelIcon <div className={styles.avatar}>
className={styles.avatar} <ChannelIcon target={channel} size={compact ? 24 : 32} />
target={channel} </div>
size={compact ? 24 : 32}
/>
<div className={styles.name}> <div className={styles.name}>
<div>{channel.name}</div> <div>{channel.name}</div>
{channel.channel_type === "Group" && ( {channel.channel_type === "Group" && (
@@ -183,7 +177,9 @@ export const ChannelButton = observer((props: ChannelProps) => {
<Text <Text
id="quantities.members" id="quantities.members"
plural={channel.recipients!.length} plural={channel.recipients!.length}
fields={{ count: channel.recipients!.length }} fields={{
count: channel.recipients!.length,
}}
/> />
)} )}
</div> </div>
@@ -199,8 +195,7 @@ export const ChannelButton = observer((props: ChannelProps) => {
<IconButton <IconButton
className={styles.icon} className={styles.icon}
onClick={() => onClick={() =>
openScreen({ modalController.push({
id: "special_prompt",
type: "leave_group", type: "leave_group",
target: channel, target: channel,
}) })

View File

@@ -1,45 +1,47 @@
import { observer } from "mobx-react-lite";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { import { Banner, Button, Column } from "@revoltchat/ui";
ClientStatus,
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
import Banner from "../../ui/Banner"; import { useSession } from "../../../controllers/client/ClientController";
export default function ConnectionStatus() { function ConnectionStatus() {
const status = useContext(StatusContext); const session = useSession()!;
const client = useClient();
if (status === ClientStatus.OFFLINE) { if (session.state === "Offline") {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.offline" /> <Text id="app.special.status.offline" />
</Banner> </Banner>
); );
} else if (status === ClientStatus.DISCONNECTED) { } else if (session.state === "Disconnected") {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.disconnected" /> <br /> <Column centred>
<a onClick={() => client.websocket.connect()}> <Text id="app.special.status.disconnected" />
<Text id="app.special.status.reconnect" /> <Button
</a> compact
palette="secondary"
onClick={() =>
session.emit({
action: "RETRY",
})
}>
<Text id="app.status.reconnect" />
</Button>
</Column>
</Banner> </Banner>
); );
} else if (status === ClientStatus.CONNECTING) { } else if (session.state === "Connecting") {
return (
<Banner>
<Text id="app.special.status.connecting" />
</Banner>
);
} else if (status === ClientStatus.RECONNECTING) {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.reconnecting" /> <Text id="app.special.status.reconnecting" />
</Banner> </Banner>
); );
} }
return null; return null;
} }
export default observer(ConnectionStatus);

View File

@@ -1,3 +1,4 @@
import { Plus } from "@styled-icons/boxicons-regular";
import { import {
Home, Home,
UserDetail, UserDetail,
@@ -11,18 +12,18 @@ import styled, { css } from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import { Category, IconButton } from "@revoltchat/ui";
import ConditionalLink from "../../../lib/ConditionalLink"; import ConditionalLink from "../../../lib/ConditionalLink";
import PaintCounter from "../../../lib/PaintCounter"; import PaintCounter from "../../../lib/PaintCounter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Category from "../../ui/Category";
import placeholderSVG from "../items/placeholder.svg"; import placeholderSVG from "../items/placeholder.svg";
import { useClient } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import ButtonItem, { ChannelButton } from "../items/ButtonItem"; import ButtonItem, { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus"; import ConnectionStatus from "../items/ConnectionStatus";
@@ -44,10 +45,9 @@ const Navbar = styled.div`
export default observer(() => { export default observer(() => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const client = useContext(AppContext); const client = useClient();
const state = useApplicationState(); const state = useApplicationState();
const { channel: channel_id } = useParams<{ channel: string }>(); const { channel: channel_id } = useParams<{ channel: string }>();
const { openScreen } = useIntermediate();
const channels = [...client.channels.values()].filter( const channels = [...client.channels.values()].filter(
(x) => (x) =>
@@ -125,15 +125,17 @@ export default observer(() => {
</ButtonItem> </ButtonItem>
</Link> </Link>
)} )}
<Category <Category>
text={<Text id="app.main.categories.conversations" />} <Text id="app.main.categories.conversations" />
action={() => <IconButton
openScreen({ onClick={() =>
id: "special_input", modalController.push({
type: "create_group", type: "create_group",
}) })
} }>
/> <Plus size={16} />
</IconButton>
</Category>
{channels.length === 0 && ( {channels.length === 0 && (
<img src={placeholderSVG} loading="eager" /> <img src={placeholderSVG} loading="eager" />
)} )}

View File

@@ -1,448 +1,42 @@
import { Plus } from "@styled-icons/boxicons-regular";
import { Cog, Compass } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Link, useHistory, useLocation, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import styled, { css } from "styled-components/macro";
import { useTriggerEvents } from "preact-context-menu"; import { useCallback } from "preact/hooks";
import ConditionalLink from "../../../lib/ConditionalLink"; import { ServerList } from "@revoltchat/ui";
import PaintCounter from "../../../lib/PaintCounter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { SIDEBAR_CHANNELS } from "../../../mobx/stores/Layout";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useClient } from "../../../controllers/client/ClientController";
import { useClient } from "../../../context/revoltjs/RevoltClient"; import { modalController } from "../../../controllers/modals/ModalController";
import { IS_REVOLT } from "../../../version";
import ChannelIcon from "../../common/ChannelIcon";
import ServerIcon from "../../common/ServerIcon";
import Tooltip from "../../common/Tooltip";
import UserHover from "../../common/user/UserHover";
import UserIcon from "../../common/user/UserIcon";
import IconButton from "../../ui/IconButton";
import LineDivider from "../../ui/LineDivider";
import { Children } from "../../../types/Preact";
function Icon({
children,
unread,
count,
size,
}: {
children: Children;
unread?: "mention" | "unread";
count: number | 0;
size: number;
}) {
return (
<svg width={size} height={size} aria-hidden="true" viewBox="0 0 32 32">
<use href="#serverIndicator" />
<foreignObject
x="0"
y="0"
width="32"
height="32"
mask={unread ? "url(#server)" : undefined}>
{children}
</foreignObject>
{unread === "unread" && (
<circle cx="27" cy="5" r="5" fill={"white"} />
)}
{unread === "mention" && (
<>
<circle cx="27" cy="5" r="5" fill={"var(--error)"} />
<text
x="27"
y="5"
r="5"
fill={"white"}
fontSize={"7.5"}
fontWeight={600}
text-anchor="middle"
alignmentBaseline={"middle"}
dominant-baseline={"middle"}>
{count < 10 ? count : "9+"}
</text>
</>
)}
</svg>
);
}
const ServersBase = styled.div`
width: 58px;
height: 100%;
padding-inline-start: 2px;
display: flex;
flex-shrink: 0;
flex-direction: column;
${isTouchscreenDevice &&
css`
padding-bottom: 50px;
`}
`;
const ServerList = styled.div`
flex-grow: 1;
display: flex;
overflow-y: scroll;
padding-bottom: 20px;
flex-direction: column;
scrollbar-width: none;
> :first-child > svg {
margin: 6px 0 6px 4px;
}
&::-webkit-scrollbar {
width: 0px;
}
`;
const ServerEntry = styled.div<{ active: boolean; home?: boolean }>`
height: 54px;
display: flex;
align-items: center;
//transition: 0.2s ease height;
:focus {
outline: 3px solid blue;
}
> div {
height: 42px;
padding-inline-start: 6px;
display: grid;
place-items: center;
border-start-start-radius: 50%;
border-end-start-radius: 50%;
&:active {
transform: translateY(1px);
}
${(props) =>
props.active &&
css`
&:active {
transform: none;
}
`}
}
> span {
width: 0;
display: relative;
${(props) =>
!props.active &&
css`
display: none;
`}
svg {
margin-top: 5px;
pointer-events: none;
}
}
${(props) =>
(!props.active || props.home) &&
css`
cursor: pointer;
`}
`;
const ServerCircle = styled.div`
width: 54px;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.circle {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary-background);
border-radius: 50%;
height: 42px;
width: 42px;
transition: background-color 0.1s ease-in;
cursor: pointer;
> div svg {
color: var(--accent);
}
&:active {
transform: translateY(1px);
}
}
`;
const SettingsButton = styled.div`
width: 50px;
height: 56px;
display: grid;
place-items: center;
`;
function Swoosh() {
const sidebarOpen = useApplicationState().layout.getSectionState(
SIDEBAR_CHANNELS,
true,
);
const fill = sidebarOpen
? "var(--sidebar-active)"
: "var(--primary-background)";
return (
<span>
<svg
width="56"
height="106"
viewBox="0 0 56 106"
xmlns="http://www.w3.org/2000/svg">
<path
d="M54 53C54 67.9117 41.9117 80 27 80C12.0883 80 0 67.9117 0 53C0 38.0883 12.0883 26 27 26C41.9117 26 54 38.0883 54 53Z"
fill={fill}
/>
<path
d="M27.0002 80C4.50023 80 56.0002 53 56.0002 53V106C56.0002 106 49.5002 80 27.0002 80Z"
fill={fill}
/>
<path
d="M27.0003 26C4.50025 26 56 53 56 53L56.0003 0C56.0003 0 49.5003 26 27.0003 26Z"
fill={fill}
/>
<rect x="51" y="50" width="5" height="7" fill={fill} />
</svg>
</span>
);
}
/**
* Server list sidebar shim component
*/
export default observer(() => { export default observer(() => {
const client = useClient(); const client = useClient();
const state = useApplicationState(); const state = useApplicationState();
const { server: server_id } = useParams<{ server?: string }>(); const { server: server_id } = useParams<{ server?: string }>();
const server = server_id ? client.servers.get(server_id) : undefined;
const servers = [...client.servers.values()];
const channels = [...client.channels.values()];
const history = useHistory(); const createServer = useCallback(
const path = useLocation().pathname; () =>
const { openScreen } = useIntermediate(); modalController.push({
type: "create_server",
let alertCount = [...client.users.values()].filter( }),
(x) => x.relationship === "Incoming", [],
).length; );
const homeActive =
typeof server === "undefined" &&
!path.startsWith("/invite") &&
!path.startsWith("/discover");
return ( return (
<ServersBase> <ServerList
<ServerList> client={client}
<ConditionalLink active={server_id}
active={homeActive} createServer={createServer}
to={state.layout.getLastHomePath()}> permit={state.notifications}
<ServerEntry home active={homeActive}> home={state.layout.getLastHomePath}
<Swoosh /> servers={state.ordering.orderedServers}
<div reorder={state.ordering.reorderServer}
{...useTriggerEvents("Status")} showDiscovery={IS_REVOLT}
onClick={() => />
homeActive && history.push("/settings")
}>
<UserHover user={client.user ?? undefined}>
<Icon
size={42}
unread={
alertCount > 0 ? "mention" : undefined
}
count={alertCount}>
<UserIcon
target={client.user ?? undefined}
size={32}
status
hover
/>
</Icon>
</UserHover>
</div>
</ServerEntry>
</ConditionalLink>
{channels
.filter(
(x) =>
((x.channel_type === "DirectMessage" && x.active) ||
x.channel_type === "Group") &&
x.unread,
)
.map((x) => {
const unreadCount = x.mentions.length;
return (
<Link to={`/channel/${x._id}`}>
<ServerEntry
home
active={false}
{...useTriggerEvents("Menu", {
channel: x._id,
unread: true,
})}>
<div>
<Icon
size={42}
unread={
unreadCount > 0
? "mention"
: "unread"
}
count={unreadCount}>
{x.channel_type ===
"DirectMessage" ? (
<UserIcon
target={x.recipient}
size={32}
hover
/>
) : (
<ChannelIcon
target={x}
size={32}
hover
/>
)}
</Icon>
</div>
</ServerEntry>
</Link>
);
})}
<LineDivider />
{servers.map((server) => {
const active = server._id === server_id;
const isUnread = server.isUnread(state.notifications);
const mentionCount = server.getMentions(
state.notifications,
).length;
return (
<ConditionalLink
key={server._id}
active={active}
to={state.layout.getServerPath(server._id)}>
<ServerEntry
active={active}
{...useTriggerEvents("Menu", {
server: server._id,
unread: isUnread,
})}>
<Swoosh />
<Tooltip
content={server.name}
placement="right">
<Icon
size={42}
unread={
mentionCount > 0
? "mention"
: isUnread
? "unread"
: undefined
}
count={mentionCount}>
<ServerIcon size={32} target={server} />
</Icon>
</Tooltip>
</ServerEntry>
</ConditionalLink>
);
})}
{/*<LineDivider />*/}
<ServerCircle>
<Tooltip content="Add a Server" placement="right">
<div className="circle">
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "create_server",
})
}>
<Plus size={32} />
</IconButton>
</div>
</Tooltip>
</ServerCircle>
{!isTouchscreenDevice && (
<ServerCircle>
<Tooltip
content={
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
}}>
<div>Discover Revolt</div>
<div
style={{
padding: "1px 5px",
fontSize: "9px",
background: "var(--status-busy)",
borderRadius: "60px",
}}>
NEW
</div>
</div>
}
placement="right">
<div className="circle">
<IconButton>
<Link to="/discover">
<a>
<Compass size={32} />
</a>
</Link>
</IconButton>
</div>
</Tooltip>
</ServerCircle>
)}
</ServerList>
{!isTouchscreenDevice && (
<Tooltip content={"Settings"} placement="right">
<ServerCircle>
<Link to="/settings">
<div className="circle">
<IconButton>
<Cog
size={24}
fill="var(--secondary-foreground) !important"
/>
</IconButton>
</div>
</Link>
</ServerCircle>
</Tooltip>
)}
<PaintCounter small />
</ServersBase>
); );
}); });

View File

@@ -1,12 +1,12 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Redirect, useParams } from "react-router"; import { Redirect, useParams } from "react-router";
import { Server } from "revolt.js";
import styled, { css } from "styled-components/macro"; import styled, { css } from "styled-components/macro";
import { Ref } from "preact";
import { useTriggerEvents } from "preact-context-menu"; import { useTriggerEvents } from "preact-context-menu";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import { Category } from "@revoltchat/ui";
import ConditionalLink from "../../../lib/ConditionalLink"; import ConditionalLink from "../../../lib/ConditionalLink";
import PaintCounter from "../../../lib/PaintCounter"; import PaintCounter from "../../../lib/PaintCounter";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
@@ -14,12 +14,9 @@ import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useClient } from "../../../context/revoltjs/RevoltClient"; import { useClient } from "../../../controllers/client/ClientController";
import CollapsibleSection from "../../common/CollapsibleSection"; import CollapsibleSection from "../../common/CollapsibleSection";
import ServerHeader from "../../common/ServerHeader"; import ServerHeader from "../../common/ServerHeader";
import Category from "../../ui/Category";
import { ChannelButton } from "../items/ButtonItem"; import { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus"; import ConnectionStatus from "../items/ConnectionStatus";
@@ -126,7 +123,7 @@ export default observer(() => {
<CollapsibleSection <CollapsibleSection
id={`category_${category.id}`} id={`category_${category.id}`}
defaultValue defaultValue
summary={<Category text={category.title} />}> summary={<Category>{category.title}</Category>}>
{channels} {channels}
</CollapsibleSection>, </CollapsibleSection>,
); );

View File

@@ -8,11 +8,7 @@ import { memo } from "preact/compat";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { import { modalController } from "../../../controllers/modals/ModalController";
Screen,
useIntermediate,
} from "../../../context/intermediate/Intermediate";
import { UserButton } from "../items/ButtonItem"; import { UserButton } from "../items/ButtonItem";
export type MemberListGroup = { export type MemberListGroup = {
@@ -55,15 +51,7 @@ const NoOomfie = styled.div`
`; `;
const ItemContent = memo( const ItemContent = memo(
({ ({ item, context }: { item: User; context: Channel }) => (
item,
context,
openScreen,
}: {
item: User;
context: Channel;
openScreen: (screen: Screen) => void;
}) => (
<UserButton <UserButton
key={item._id} key={item._id}
user={item} user={item}
@@ -77,13 +65,12 @@ const ItemContent = memo(
`<@${item._id}>`, `<@${item._id}>`,
"mention", "mention",
); );
} else } else {
[ modalController.push({
openScreen({ type: "user_profile",
id: "profile", user_id: item._id,
user_id: item._id, });
}), }
];
}} }}
/> />
), ),
@@ -96,8 +83,6 @@ export default function MemberList({
entries: MemberListGroup[]; entries: MemberListGroup[];
context: Channel; context: Channel;
}) { }) {
const { openScreen } = useIntermediate();
return ( return (
<GroupedVirtuoso <GroupedVirtuoso
groupCounts={entries.map((x) => x.users.length)} groupCounts={entries.map((x) => x.users.length)}
@@ -114,7 +99,7 @@ export default function MemberList({
)} )}
{entry.type !== "no_offline" && ( {entry.type !== "no_offline" && (
<> <>
{" - "} {" "}
{entry.users.length} {entry.users.length}
</> </>
)} )}
@@ -133,19 +118,20 @@ export default function MemberList({
return ( return (
<NoOomfie> <NoOomfie>
<div> <div>
Offline users temporarily disabled for this Offline users have temporarily been disabled for
server, see issue{" "} larger servers - see{" "}
<a <a
href="https://github.com/revoltchat/delta/issues/128" href="https://github.com/revoltchat/backend/issues/178"
target="_blank"> target="_blank"
#128 rel="noreferrer">
issue #178
</a>{" "} </a>{" "}
for when this will be resolved. for when this will be resolved.
</div> </div>
<div> <div>
You may re-enable them in{" "} You may re-enable them{" "}
<Link to="/settings/experiments"> <Link to="/settings/experiments">
<a>experiments</a> <a>here</a>
</Link> </Link>
. .
</div> </div>
@@ -158,11 +144,7 @@ export default function MemberList({
return ( return (
<div> <div>
<ItemContent <ItemContent item={item} context={context} />
item={item}
context={context}
openScreen={openScreen}
/>
</div> </div>
); );
}} }}

View File

@@ -4,14 +4,12 @@ import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Channel, Server, User, API } from "revolt.js"; import { Channel, Server, User, API } from "revolt.js";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useLayoutEffect, useState } from "preact/hooks";
import { import {
ClientStatus, useSession,
StatusContext,
useClient, useClient,
} from "../../../context/revoltjs/RevoltClient"; } from "../../../controllers/client/ClientController";
import { GenericSidebarBase } from "../SidebarBase"; import { GenericSidebarBase } from "../SidebarBase";
import MemberList, { MemberListGroup } from "./MemberList"; import MemberList, { MemberListGroup } from "./MemberList";
@@ -182,7 +180,7 @@ export const GroupMemberSidebar = observer(
); );
// ! FIXME: this is temporary code until we get lazy guilds like subscriptions // ! FIXME: this is temporary code until we get lazy guilds like subscriptions
const FETCHED: Set<String> = new Set(); const FETCHED: Set<string> = new Set();
export function resetMemberSidebarFetched() { export function resetMemberSidebarFetched() {
FETCHED.clear(); FETCHED.clear();
@@ -205,18 +203,18 @@ function shouldSkipOffline(id: string) {
export const ServerMemberSidebar = observer( export const ServerMemberSidebar = observer(
({ channel }: { channel: Channel }) => { ({ channel }: { channel: Channel }) => {
const client = useClient(); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
useEffect(() => { useEffect(() => {
const server_id = channel.server_id!; const server_id = channel.server_id!;
if (status === ClientStatus.ONLINE && !FETCHED.has(server_id)) { if (session.state === "Online" && !FETCHED.has(server_id)) {
FETCHED.add(server_id); FETCHED.add(server_id);
channel channel
.server!.syncMembers(shouldSkipOffline(server_id)) .server!.syncMembers(shouldSkipOffline(server_id))
.catch(() => FETCHED.delete(server_id)); .catch(() => FETCHED.delete(server_id));
} }
}, [status, channel]); }, [session.state, channel]);
const entries = useEntries( const entries = useEntries(
channel, channel,

View File

@@ -5,15 +5,10 @@ import styled from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Button } from "@revoltchat/ui"; import { Button, Category, Error, InputBox, Preloader } from "@revoltchat/ui";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { useClient } from "../../../controllers/client/ClientController";
import Message from "../../common/messaging/Message"; import Message from "../../common/messaging/Message";
import InputBox from "../../ui/InputBox";
import Overline from "../../ui/Overline";
import Preloader from "../../ui/Preloader";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
type SearchState = type SearchState =
@@ -103,18 +98,20 @@ export function SearchSidebar({ close }: Props) {
<GenericSidebarBase data-scroll-offset="with-padding"> <GenericSidebarBase data-scroll-offset="with-padding">
<GenericSidebarList> <GenericSidebarList>
<SearchBase> <SearchBase>
<Overline type="accent" block hover> <Category>
<a onClick={close}>« back to members</a> <Error
</Overline> error={<a onClick={close}>« back to members</a>}
<Overline type="subtle" block> />
</Category>
<Category>
<Text id="app.main.channel.search.title" /> <Text id="app.main.channel.search.title" />
</Overline> </Category>
<InputBox <InputBox
value={query} value={query}
onKeyDown={(e) => e.key === "Enter" && search()} onKeyDown={(e) => e.key === "Enter" && search()}
onChange={(e) => setQuery(e.currentTarget.value)} onChange={(e) => setQuery(e.currentTarget.value)}
/> />
<div class="sort"> <div className="sort">
{["Latest", "Oldest", "Relevance"].map((key) => ( {["Latest", "Oldest", "Relevance"].map((key) => (
<Button <Button
key={key} key={key}
@@ -129,7 +126,7 @@ export function SearchSidebar({ close }: Props) {
</div> </div>
{state.type === "loading" && <Preloader type="ring" />} {state.type === "loading" && <Preloader type="ring" />}
{state.type === "results" && ( {state.type === "results" && (
<div class="list"> <div className="list">
{state.results.map((message) => { {state.results.map((message) => {
let href = ""; let href = "";
if (channel?.channel_type === "TextChannel") { if (channel?.channel_type === "TextChannel") {
@@ -140,7 +137,7 @@ export function SearchSidebar({ close }: Props) {
return ( return (
<Link to={href} key={message._id}> <Link to={href} key={message._id}>
<div class="message"> <div className="message">
<Message <Message
message={message} message={message}
head head

View File

@@ -1,279 +0,0 @@
import { Brush } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Link } from "react-router-dom";
// @ts-expect-error shade-blend-color does not have typings.
import pSBC from "shade-blend-color";
import { Text } from "preact-i18n";
import TextAreaAutoSize from "../../lib/TextAreaAutoSize";
import { useApplicationState } from "../../mobx/State";
import {
Fonts,
FONTS,
FONT_KEYS,
MonospaceFonts,
MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS,
} from "../../context/Theme";
import Checkbox from "../ui/Checkbox";
import ColourSwatches from "../ui/ColourSwatches";
import ComboBox from "../ui/ComboBox";
import Radio from "../ui/Radio";
import CategoryButton from "../ui/fluent/CategoryButton";
import { EmojiSelector } from "./appearance/EmojiSelector";
import { ThemeBaseSelector } from "./appearance/ThemeBaseSelector";
/**
* Component providing a way to switch the base theme being used.
*/
export const ThemeBaseSelectorShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<ThemeBaseSelector
value={theme.isModified() ? undefined : theme.getBase()}
setValue={(base) => {
theme.setBase(base);
theme.reset();
}}
/>
);
});
/**
* Component providing a link to the theme shop.
* Only appears if experiment is enabled.
* TODO: stabilise
*/
export const ThemeShopShim = () => {
return (
<Link to="/discover/themes" replace>
<CategoryButton
icon={<Brush size={24} />}
action="chevron"
description={
<Text id="app.settings.pages.appearance.discover.description" />
}
hover>
<Text id="app.settings.pages.appearance.discover.title" />
</CategoryButton>
</Link>
);
};
/**
* Component providing a way to change current accent colour.
*/
export const ThemeAccentShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.accent_selector" />
</h3>
<ColourSwatches
value={theme.getVariable("accent")}
onChange={(colour) => {
theme.setVariable("accent", colour as string);
theme.setVariable("scrollbar-thumb", pSBC(-0.2, colour));
}}
/>
</>
);
});
/**
* Component providing a way to edit custom CSS.
*/
export const ThemeCustomCSSShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.custom_css" />
</h3>
<TextAreaAutoSize
maxRows={20}
minHeight={480}
code
value={theme.getCSS() ?? ""}
onChange={(ev) => theme.setCSS(ev.currentTarget.value)}
/>
</>
);
});
/**
* Component providing a way to switch between compact and normal message view.
*/
export const DisplayCompactShim = () => {
// TODO: WIP feature
return (
<>
<h3>
<Text id="app.settings.pages.appearance.message_display" />
</h3>
<div /* className={styles.display} */>
<Radio
description={
<Text id="app.settings.pages.appearance.display.default_description" />
}
checked>
<Text id="app.settings.pages.appearance.display.default" />
</Radio>
<Radio
description={
<Text id="app.settings.pages.appearance.display.compact_description" />
}
disabled>
<Text id="app.settings.pages.appearance.display.compact" />
</Radio>
</div>
</>
);
};
/**
* Component providing a way to change primary text font.
*/
export const DisplayFontShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ComboBox
value={theme.getFont()}
onChange={(e) => theme.setFont(e.currentTarget.value as Fonts)}>
{FONT_KEYS.map((key) => (
<option value={key} key={key}>
{FONTS[key as keyof typeof FONTS].name}
</option>
))}
</ComboBox>
</>
);
});
/**
* Component providing a way to change secondary, monospace text font.
*/
export const DisplayMonospaceFontShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ComboBox
value={theme.getMonospaceFont()}
onChange={(e) =>
theme.setMonospaceFont(
e.currentTarget.value as MonospaceFonts,
)
}>
{MONOSPACE_FONT_KEYS.map((key) => (
<option value={key} key={key}>
{
MONOSPACE_FONTS[key as keyof typeof MONOSPACE_FONTS]
.name
}
</option>
))}
</ComboBox>
</>
);
});
/**
* Component providing a way to toggle font ligatures.
*/
export const DisplayLigaturesShim = observer(() => {
const settings = useApplicationState().settings;
if (settings.theme.getFont() !== "Inter") return null;
return (
<>
<Checkbox
checked={settings.get("appearance:ligatures") ?? false}
onChange={(v) => settings.set("appearance:ligatures", v)}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}>
<Text id="app.settings.pages.appearance.ligatures" />
</Checkbox>
</>
);
});
/**
* Component providing a way to toggle showing the send button on desktop.
*/
export const ShowSendButtonShim = observer(() => {
const settings = useApplicationState().settings;
return (
<Checkbox
checked={settings.get("appearance:show_send_button") ?? false}
onChange={(v) => settings.set("appearance:show_send_button", v)}
description={
<Text id="app.settings.pages.appearance.appearance_options.show_send_desc" />
}>
<Text id="app.settings.pages.appearance.appearance_options.show_send" />
</Checkbox>
);
});
/**
* Component providing a way to toggle seasonal themes.
*/
export const DisplaySeasonalShim = observer(() => {
const settings = useApplicationState().settings;
return (
<Checkbox
checked={settings.get("appearance:seasonal") ?? true}
onChange={(v) => settings.set("appearance:seasonal", v)}
description={
<Text id="app.settings.pages.appearance.theme_options.seasonal_desc" />
}>
<Text id="app.settings.pages.appearance.theme_options.seasonal" />
</Checkbox>
);
});
/**
* Component providing a way to toggle transparency effects.
*/
export const DisplayTransparencyShim = observer(() => {
const settings = useApplicationState().settings;
return (
<Checkbox
checked={settings.get("appearance:transparency") ?? true}
onChange={(v) => settings.set("appearance:transparency", v)}
description={
<Text id="app.settings.pages.appearance.theme_options.transparency_desc" />
}>
<Text id="app.settings.pages.appearance.theme_options.transparency" />
</Checkbox>
);
});
/**
* Component providing a way to change emoji pack.
*/
export const DisplayEmojiShim = observer(() => {
const settings = useApplicationState().settings;
return (
<EmojiSelector
value={settings.get("appearance:emoji")}
setValue={(v) => settings.set("appearance:emoji", v)}
/>
);
});

View File

@@ -0,0 +1,60 @@
import { Block } from "@styled-icons/boxicons-regular";
import { Trash } from "@styled-icons/boxicons-solid";
import { Text } from "preact-i18n";
import { CategoryButton } from "@revoltchat/ui";
import {
clientController,
useClient,
} from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
export default function AccountManagement() {
const client = useClient();
const callback = (route: "disable" | "delete") => () =>
modalController.mfaFlow(client).then(
(ticket) =>
ticket &&
client.api
.post(`/auth/account/${route}`, undefined, {
headers: {
"X-MFA-Ticket": ticket.token,
},
})
.then(clientController.logoutCurrent),
);
return (
<>
<h3>
<Text id="app.settings.pages.account.manage.title" />
</h3>
<h5>
<Text id="app.settings.pages.account.manage.description" />
</h5>
<CategoryButton
icon={<Block size={24} color="var(--error)" />}
description={
"Disable your account. You won't be able to access it unless you contact support."
}
action="chevron"
onClick={callback("disable")}>
<Text id="app.settings.pages.account.manage.disable" />
</CategoryButton>
<CategoryButton
icon={<Trash size={24} color="var(--error)" />}
description={
"Your account will be queued for deletion, a confirmation email will be sent."
}
action="chevron"
onClick={callback("delete")}>
<Text id="app.settings.pages.account.manage.delete" />
</CategoryButton>
</>
);
}

View File

@@ -0,0 +1,72 @@
import { At } from "@styled-icons/boxicons-regular";
import { Envelope, Key, Pencil } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import {
AccountDetail,
CategoryButton,
Column,
HiddenValue,
} from "@revoltchat/ui";
import { useSession } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
export default observer(() => {
const session = useSession()!;
const client = session.client!;
const [email, setEmail] = useState("...");
useEffect(() => {
if (email === "..." && session.state === "Online") {
client.api
.get("/auth/account/")
.then((account) => setEmail(account.email));
}
}, [client, email, session.state]);
return (
<>
<Column group>
<AccountDetail user={client.user!} />
</Column>
{(
[
["username", client.user!.username, At],
["email", email, Envelope],
["password", "•••••••••", Key],
] as const
).map(([field, value, Icon]) => (
<CategoryButton
key={field}
icon={<Icon size={24} />}
description={
field === "email" ? (
<HiddenValue
value={value}
placeholder={"•••••••••••@••••••.•••"}
/>
) : (
value
)
}
account
action={<Pencil size={20} />}
onClick={() =>
modalController.push({
type: "modify_account",
client,
field,
})
}>
<Text id={`login.${field}`} />
</CategoryButton>
))}
</>
);
});

View File

@@ -0,0 +1,222 @@
import { ListOl } from "@styled-icons/boxicons-regular";
import { Lock } from "@styled-icons/boxicons-solid";
import { API } from "revolt.js";
import { Text } from "preact-i18n";
import { useCallback, useEffect, useState } from "preact/hooks";
import { Category, CategoryButton, Error, Tip } from "@revoltchat/ui";
import { useSession } from "../../../controllers/client/ClientController";
import { takeError } from "../../../controllers/client/jsx/error";
import { modalController } from "../../../controllers/modals/ModalController";
/**
* Temporary helper function for Axios config
* @param token Token
* @returns Headers
*/
export function toConfig(token: string) {
return {
headers: {
"X-MFA-Ticket": token,
},
};
}
/**
* Component for configuring MFA on an account.
*/
export default function MultiFactorAuthentication() {
// Pull in prerequisites
const session = useSession()!;
const client = session.client!;
// Keep track of MFA state
const [mfa, setMFA] = useState<API.MultiFactorStatus>();
const [error, setError] = useState<string>();
// Fetch the current MFA status on account
useEffect(() => {
if (!mfa && session.state === "Online") {
client!.api
.get("/auth/mfa/")
.then(setMFA)
.catch((err) => setError(takeError(err)));
}
}, [mfa, client, session.state]);
// Action called when recovery code button is pressed
const recoveryAction = useCallback(async () => {
// Perform MFA flow first
const ticket = await modalController.mfaFlow(client);
// Check whether action was cancelled
if (typeof ticket === "undefined") {
return;
}
// Decide whether to generate or fetch.
let codes;
if (mfa!.recovery_active) {
// Fetch existing recovery codes
codes = await client.api.post(
"/auth/mfa/recovery",
undefined,
toConfig(ticket.token),
);
} else {
// Generate new recovery codes
codes = await client.api.patch(
"/auth/mfa/recovery",
undefined,
toConfig(ticket.token),
);
setMFA({
...mfa!,
recovery_active: true,
});
}
// Display the codes to the user
modalController.push({
type: "mfa_recovery",
client,
codes,
});
}, [mfa]);
// Action called when TOTP button is pressed
const totpAction = useCallback(async () => {
// Perform MFA flow first
const ticket = await modalController.mfaFlow(client);
// Check whether action was cancelled
if (typeof ticket === "undefined") {
return;
}
// Decide whether to disable or enable.
if (mfa!.totp_mfa) {
// Disable TOTP authentication
await client.api.delete(
"/auth/mfa/totp",
{},
toConfig(ticket.token),
);
setMFA({
...mfa!,
totp_mfa: false,
});
} else {
// Generate a TOTP secret
const { secret } = await client.api.post(
"/auth/mfa/totp",
undefined,
toConfig(ticket.token),
);
// Open secret modal
let success;
while (!success) {
try {
// Make the user generator a token
const totp_code = await modalController.mfaEnableTOTP(
secret,
client.user!.username,
);
if (totp_code) {
// Check whether it is valid
await client.api.put(
"/auth/mfa/totp",
{
totp_code,
},
toConfig(ticket.token),
);
// Mark as successful and activated
success = true;
setMFA({
...mfa!,
totp_mfa: true,
});
} else {
break;
}
} catch (err) {}
}
}
}, [mfa]);
const mfaActive = !!mfa?.totp_mfa;
return (
<>
<h3>
<Text id="app.settings.pages.account.2fa.title" />
</h3>
<h5>
<Text id="app.settings.pages.account.2fa.description" />
</h5>
{error && (
<Category compact>
<Error error={error} />
</Category>
)}
<CategoryButton
icon={<ListOl size={24} />}
description={
<Text
id={`app.settings.pages.account.2fa.${
mfa?.recovery_active
? "view_recovery"
: "generate_recovery"
}_long`}
/>
}
disabled={!mfa}
onClick={recoveryAction}>
<Text
id={`app.settings.pages.account.2fa.${
mfa?.recovery_active
? "view_recovery"
: "generate_recovery"
}`}
/>
</CategoryButton>
<CategoryButton
icon={
<Lock
size={24}
color={!mfa?.totp_mfa ? "var(--error)" : undefined}
/>
}
description={"Set up time-based one-time password."}
disabled={!mfa || (!mfa.recovery_active && !mfa.totp_mfa)}
onClick={totpAction}>
<Text
id={`app.settings.pages.account.2fa.${
mfa?.totp_mfa ? "remove" : "add"
}_auth`}
/>
</CategoryButton>
{mfa && (
<Tip palette={mfaActive ? "primary" : "error"}>
<Text
id={`app.settings.pages.account.2fa.two_factor_${
mfaActive ? "on" : "off"
}`}
/>
</Tip>
)}
</>
);
}

View File

@@ -0,0 +1,63 @@
import { observer } from "mobx-react-lite";
import { Text } from "preact-i18n";
import { ObservedInputElement } from "@revoltchat/ui";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { useApplicationState } from "../../../mobx/State";
import {
MonospaceFonts,
MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS,
} from "../../../context/Theme";
/**
* ! LEGACY
* Component providing a way to edit custom CSS.
*/
export const ShimThemeCustomCSS = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.custom_css" />
</h3>
<TextAreaAutoSize
maxRows={20}
minHeight={480}
code
value={theme.getCSS() ?? ""}
onChange={(ev) => theme.setCSS(ev.currentTarget.value)}
/>
</>
);
});
export default function AdvancedOptions() {
const settings = useApplicationState().settings;
return (
<>
{/** Combo box of available monospaced fonts */}
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ObservedInputElement
type="combo"
value={() => settings.theme.getMonospaceFont()}
onChange={(value) =>
settings.theme.setMonospaceFont(value as MonospaceFonts)
}
options={MONOSPACE_FONT_KEYS.map((value) => ({
value,
name: MONOSPACE_FONTS[value as keyof typeof MONOSPACE_FONTS]
.name,
}))}
/>
{/** Custom CSS */}
<ShimThemeCustomCSS />
</>
);
}

View File

@@ -0,0 +1,77 @@
import { Text } from "preact-i18n";
import { Column, ObservedInputElement } from "@revoltchat/ui";
import { useApplicationState } from "../../../mobx/State";
export default function AppearanceOptions() {
const settings = useApplicationState().settings;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.appearance_options.title" />
</h3>
{/* Option to toggle "send message" button on desktop. */}
<ObservedInputElement
type="checkbox"
value={() =>
settings.get("appearance:show_send_button") ?? false
}
onChange={(v) => settings.set("appearance:show_send_button", v)}
title={
<Text id="app.settings.pages.appearance.appearance_options.show_send" />
}
description={
<Text id="app.settings.pages.appearance.appearance_options.show_send_desc" />
}
/>
{/* Option to always show the account creation age next to join system messages. */}
<ObservedInputElement
type="checkbox"
value={() =>
settings.get("appearance:show_account_age") ?? false
}
onChange={(v) => settings.set("appearance:show_account_age", v)}
title={
<Text id="app.settings.pages.appearance.appearance_options.show_account_age" />
}
description={
<Text id="app.settings.pages.appearance.appearance_options.show_account_age_desc" />
}
/>
<hr />
<h3>
<Text id="app.settings.pages.appearance.theme_options.title" />
</h3>
<Column>
{/* Option to toggle transparency effects in-app. */}
<ObservedInputElement
type="checkbox"
value={() =>
settings.get("appearance:transparency") ?? true
}
onChange={(v) => settings.set("appearance:transparency", v)}
title={
<Text id="app.settings.pages.appearance.theme_options.transparency" />
}
description={
<Text id="app.settings.pages.appearance.theme_options.transparency_desc" />
}
/>
{/* Option to toggle seasonal effects. */}
<ObservedInputElement
type="checkbox"
value={() => settings.get("appearance:seasonal") ?? true}
onChange={(v) => settings.set("appearance:seasonal", v)}
title={
<Text id="app.settings.pages.appearance.theme_options.seasonal" />
}
description={
<Text id="app.settings.pages.appearance.theme_options.seasonal_desc" />
}
/>
</Column>
</>
);
}

View File

@@ -0,0 +1,70 @@
import { observer } from "mobx-react-lite";
import { Text } from "preact-i18n";
import { Column, ObservedInputElement } from "@revoltchat/ui";
import { useApplicationState } from "../../../mobx/State";
import { FONTS, Fonts, FONT_KEYS } from "../../../context/Theme";
import { EmojiSelector } from "./legacy/EmojiSelector";
/**
* ! LEGACY
* Component providing a way to change emoji pack.
*/
export const ShimDisplayEmoji = observer(() => {
const settings = useApplicationState().settings;
return (
<EmojiSelector
value={settings.get("appearance:emoji")}
setValue={(v) => settings.set("appearance:emoji", v)}
/>
);
});
export default observer(() => {
const settings = useApplicationState().settings;
return (
<>
<Column>
{/* Combo box of available fonts. */}
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ObservedInputElement
type="combo"
value={() => settings.theme.getFont()}
onChange={(value) => settings.theme.setFont(value as Fonts)}
options={FONT_KEYS.map((value) => ({
value,
name: FONTS[value as keyof typeof FONTS].name,
}))}
/>
{/* Option to toggle liagures for supported fonts. */}
{settings.theme.getFont() === "Inter" && (
<ObservedInputElement
type="checkbox"
value={() =>
settings.get("appearance:ligatures") ?? true
}
onChange={(v) =>
settings.set("appearance:ligatures", v)
}
title={
<Text id="app.settings.pages.appearance.ligatures" />
}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}
/>
)}
</Column>
<hr />
{/* Emoji pack selector */}
<ShimDisplayEmoji />
</>
);
});

View File

@@ -1,170 +1,11 @@
import { Pencil } from "@styled-icons/boxicons-regular"; import Overrides from "./legacy/ThemeOverrides";
import { observer } from "mobx-react-lite"; import ThemeTools from "./legacy/ThemeTools";
import styled from "styled-components/macro";
import { useDebounceCallback } from "../../../lib/debounce";
import { useApplicationState } from "../../../mobx/State";
import { Variables } from "../../../context/Theme";
import InputBox from "../../ui/InputBox";
const Container = styled.div`
row-gap: 8px;
display: grid;
column-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
margin-bottom: 20px;
.entry {
padding: 12px;
margin-top: 8px;
border: 1px solid black;
border-radius: var(--border-radius);
span {
flex: 1;
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 8px;
text-transform: capitalize;
background: inherit;
background-clip: text;
-webkit-background-clip: text;
}
.override {
gap: 8px;
display: flex;
.picker {
width: 38px;
height: 38px;
display: grid;
cursor: pointer;
place-items: center;
border-radius: var(--border-radius);
background: var(--primary-background);
}
input[type="text"] {
width: 0;
min-width: 0;
flex-grow: 1;
}
}
.input {
width: 0;
height: 0;
position: relative;
input {
opacity: 0;
border: none;
display: block;
cursor: pointer;
position: relative;
top: 48px;
}
}
}
`;
export default observer(() => {
const theme = useApplicationState().settings.theme;
const setVariable = useDebounceCallback(
(data) => {
const { key, value } = data as { key: Variables; value: string };
theme.setVariable(key, value);
},
[theme],
100,
);
export default function ThemeOverrides() {
return ( return (
<Container> <>
{( <ThemeTools />
[ <Overrides />
"accent", </>
"background",
"foreground",
"primary-background",
"primary-header",
"secondary-background",
"secondary-foreground",
"secondary-header",
"tertiary-background",
"tertiary-foreground",
"block",
"message-box",
"mention",
"scrollbar-thumb",
"scrollbar-track",
"status-online",
"status-away",
"status-busy",
"status-streaming",
"status-invisible",
"success",
"warning",
"error",
"hover",
] as const
).map((key) => (
<div
class="entry"
key={key}
style={{ backgroundColor: theme.getVariable(key) }}>
<div class="input">
<input
type="color"
value={theme.getVariable(key)}
onChange={(el) =>
setVariable({
key,
value: el.currentTarget.value,
})
}
/>
</div>
<span
style={{
color: theme.getContrastingVariable(
key,
theme.getVariable("primary-background"),
),
}}>
{key}
</span>
<div class="override">
<div
class="picker"
onClick={(e) =>
e.currentTarget.parentElement?.parentElement
?.querySelector("input")
?.click()
}>
<Pencil size={24} />
</div>
<InputBox
type="text"
class="text"
value={theme.getVariable(key)}
onChange={(el) =>
setVariable({
key,
value: el.currentTarget.value,
})
}
/>
</div>
</div>
))}
</Container>
); );
}); }

View File

@@ -0,0 +1,64 @@
import { Brush } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Link } from "react-router-dom";
// @ts-expect-error shade-blend-color does not have typings.
import pSBC from "shade-blend-color";
import { Text } from "preact-i18n";
import { CategoryButton, ObservedInputElement } from "@revoltchat/ui";
import { useApplicationState } from "../../../mobx/State";
import { ThemeBaseSelector } from "./legacy/ThemeBaseSelector";
/**
* ! LEGACY
* Component providing a way to switch the base theme being used.
*/
export const ShimThemeBaseSelector = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<ThemeBaseSelector
value={theme.isModified() ? undefined : theme.getBase()}
setValue={(base) => {
theme.setBase(base);
theme.reset();
}}
/>
);
});
export default function ThemeSelection() {
const theme = useApplicationState().settings.theme;
return (
<>
{/** Allow users to change base theme */}
<ShimThemeBaseSelector />
{/** Provide a link to the theme shop */}
<Link to="/discover/themes" replace>
<CategoryButton
icon={<Brush size={24} />}
action="chevron"
description={
<Text id="app.settings.pages.appearance.discover.description" />
}>
<Text id="app.settings.pages.appearance.discover.title" />
</CategoryButton>
</Link>
<hr />
<h3>
<Text id="app.settings.pages.appearance.accent_selector" />
</h3>
<ObservedInputElement
type="colour"
value={theme.getVariable("accent")}
onChange={(colour) => {
theme.setVariable("accent", colour as string);
theme.setVariable("scrollbar-thumb", pSBC(-0.2, colour));
}}
/>
</>
);
}

View File

@@ -2,11 +2,12 @@ import styled from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { EmojiPack } from "../../common/Emoji"; import mutantSVG from "./assets/mutant_emoji.svg";
import mutantSVG from "./mutant_emoji.svg"; import notoSVG from "./assets/noto_emoji.svg";
import notoSVG from "./noto_emoji.svg"; import openmojiSVG from "./assets/openmoji_emoji.svg";
import openmojiSVG from "./openmoji_emoji.svg"; import twemojiSVG from "./assets/twemoji_emoji.svg";
import twemojiSVG from "./twemoji_emoji.svg";
import { EmojiPack } from "../../../common/Emoji";
const Container = styled.div` const Container = styled.div`
gap: 12px; gap: 12px;
@@ -87,10 +88,10 @@ export function EmojiSelector({ value, setValue }: Props) {
<Text id="app.settings.pages.appearance.emoji_pack" /> <Text id="app.settings.pages.appearance.emoji_pack" />
</h3> </h3>
<Container> <Container>
<div class="row"> <div className="row">
<div> <div>
<div <div
class="button" className="button"
onClick={() => setValue("mutant")} onClick={() => setValue("mutant")}
data-active={!value || value === "mutant"}> data-active={!value || value === "mutant"}>
<img <img
@@ -112,7 +113,7 @@ export function EmojiSelector({ value, setValue }: Props) {
</div> </div>
<div> <div>
<div <div
class="button" className="button"
onClick={() => setValue("twemoji")} onClick={() => setValue("twemoji")}
data-active={value === "twemoji"}> data-active={value === "twemoji"}>
<img <img
@@ -125,10 +126,10 @@ export function EmojiSelector({ value, setValue }: Props) {
<h4>Twemoji</h4> <h4>Twemoji</h4>
</div> </div>
</div> </div>
<div class="row"> <div className="row">
<div> <div>
<div <div
class="button" className="button"
onClick={() => setValue("openmoji")} onClick={() => setValue("openmoji")}
data-active={value === "openmoji"}> data-active={value === "openmoji"}>
<img <img
@@ -142,7 +143,7 @@ export function EmojiSelector({ value, setValue }: Props) {
</div> </div>
<div> <div>
<div <div
class="button" className="button"
onClick={() => setValue("noto")} onClick={() => setValue("noto")}
data-active={value === "noto"}> data-active={value === "noto"}>
<img <img

View File

@@ -0,0 +1 @@
These components need to be ported to @revoltchat/ui.

View File

@@ -2,8 +2,8 @@ import styled from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import darkSVG from "./dark.svg"; import darkSVG from "./assets/dark.svg";
import lightSVG from "./light.svg"; import lightSVG from "./assets/light.svg";
const List = styled.div` const List = styled.div`
gap: 8px; gap: 8px;

View File

@@ -0,0 +1,170 @@
import { Pencil } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import styled from "styled-components/macro";
import { InputBox } from "@revoltchat/ui";
import { useDebounceCallback } from "../../../../lib/debounce";
import { useApplicationState } from "../../../../mobx/State";
import { Variables } from "../../../../context/Theme";
const Container = styled.div`
row-gap: 8px;
display: grid;
column-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
margin-bottom: 20px;
.entry {
padding: 12px;
margin-top: 8px;
border: 1px solid black;
border-radius: var(--border-radius);
span {
flex: 1;
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 8px;
text-transform: capitalize;
background: inherit;
background-clip: text;
-webkit-background-clip: text;
}
.override {
gap: 8px;
display: flex;
.picker {
width: 38px;
height: 38px;
display: grid;
cursor: pointer;
place-items: center;
border-radius: var(--border-radius);
background: var(--primary-background);
}
input[type="text"] {
width: 0;
min-width: 0;
flex-grow: 1;
}
}
.input {
width: 0;
height: 0;
position: relative;
input {
opacity: 0;
border: none;
display: block;
cursor: pointer;
position: relative;
top: 48px;
}
}
}
`;
export default observer(() => {
const theme = useApplicationState().settings.theme;
const setVariable = useDebounceCallback(
(data) => {
const { key, value } = data as { key: Variables; value: string };
theme.setVariable(key, value);
},
[theme],
100,
);
return (
<Container>
{(
[
"accent",
"background",
"foreground",
"primary-background",
"primary-header",
"secondary-background",
"secondary-foreground",
"secondary-header",
"tertiary-background",
"tertiary-foreground",
"block",
"message-box",
"mention",
"scrollbar-thumb",
"scrollbar-track",
"status-online",
"status-away",
"status-busy",
"status-streaming",
"status-invisible",
"success",
"warning",
"error",
"hover",
] as const
).map((key) => (
<div
className="entry"
key={key}
style={{ backgroundColor: theme.getVariable(key) }}>
<div className="input">
<input
type="color"
value={theme.getVariable(key)}
onChange={(el) =>
setVariable({
key,
value: el.currentTarget.value,
})
}
/>
</div>
<span
style={{
color: theme.getContrastingVariable(
key,
theme.getVariable("primary-background"),
),
}}>
{key}
</span>
<div className="override">
<div
className="picker"
onClick={(e) =>
e.currentTarget.parentElement?.parentElement
?.querySelector("input")
?.click()
}>
<Pencil size={24} />
</div>
<InputBox
type="text"
className="text"
value={theme.getVariable(key)}
onChange={(el) =>
setVariable({
key,
value: el.currentTarget.value,
})
}
/>
</div>
</div>
))}
</Container>
);
});

View File

@@ -5,11 +5,10 @@ import { Text } from "preact-i18n";
import { Button } from "@revoltchat/ui"; import { Button } from "@revoltchat/ui";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { modalController } from "../../../../controllers/modals/ModalController";
import Tooltip from "../../../common/Tooltip";
import Tooltip from "../../common/Tooltip";
const Actions = styled.div` const Actions = styled.div`
gap: 8px; gap: 8px;
@@ -38,7 +37,6 @@ const Actions = styled.div`
`; `;
export default function ThemeTools() { export default function ThemeTools() {
const { writeClipboard, openScreen } = useIntermediate();
const theme = useApplicationState().settings.theme; const theme = useApplicationState().settings.theme;
return ( return (
@@ -55,8 +53,10 @@ export default function ThemeTools() {
</Button> </Button>
</Tooltip> </Tooltip>
<div <div
class="code" className="code"
onClick={() => writeClipboard(JSON.stringify(theme))}> onClick={() =>
modalController.writeText(JSON.stringify(theme))
}>
<Tooltip content={<Text id="app.special.copy" />}> <Tooltip content={<Text id="app.special.copy" />}>
{" "} {" "}
{JSON.stringify(theme)} {JSON.stringify(theme)}
@@ -72,16 +72,8 @@ export default function ThemeTools() {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
theme.hydrate(JSON.parse(text)); theme.hydrate(JSON.parse(text));
} catch (err) { } catch (err) {
openScreen({ modalController.push({
id: "_input", type: "import_theme",
question: (
<Text id="app.settings.pages.appearance.import_theme" />
),
field: (
<Text id="app.settings.pages.appearance.theme_data" />
),
callback: async (text) =>
theme.hydrate(JSON.parse(text)),
}); });
} }
}}> }}>

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,71 @@
import { Server } from "revolt.js";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { Button, Column, Form, FormElement, Row } from "@revoltchat/ui";
import { FileUploader } from "../../../controllers/client/jsx/legacy/FileUploads";
interface Props {
server: Server;
}
export function EmojiUploader({ server }: Props) {
const [fileId, setFileId] = useState<string>();
return (
<>
<h3>
<Text id="app.settings.server_pages.emojis.upload" />
</h3>
<Form
schema={{
name: "text",
file: "custom",
}}
data={{
name: {
field: "Name",
palette: "secondary",
},
file: {
element: (
<FileUploader
style="icon"
width={100}
height={100}
fileType="emojis"
behaviour="upload"
previewAfterUpload
maxFileSize={500000}
remove={async () => void setFileId("")}
onUpload={async (id) => void setFileId(id)}
/>
),
},
}}
onSubmit={async ({ name }) => {
await server.client.api.put(`/custom/emoji/${fileId}`, {
name,
parent: { type: "Server", id: server._id },
});
setFileId("");
}}>
<Row>
<FormElement id="file" />
<Column>
<FormElement id="name" />
<Button
type="submit"
palette="secondary"
disabled={!fileId}>
<Text id="app.special.modals.actions.save" />
</Button>
</Column>
</Row>
</Form>
</>
);
}

View File

@@ -1,5 +1,4 @@
import { API, Channel, Member, Server } from "revolt.js"; import { API, Channel, Permission, Server } from "revolt.js";
import { Permission } from "revolt.js";
import { PermissionSelect } from "./PermissionSelect"; import { PermissionSelect } from "./PermissionSelect";
@@ -19,7 +18,6 @@ export function PermissionList({ value, onChange, filter, target }: Props) {
(key) => (key) =>
![ ![
"GrantAllSafe", "GrantAllSafe",
"TimeoutMembers",
"ReadMessageHistory", "ReadMessageHistory",
"Speak", "Speak",
"Video", "Video",

View File

@@ -7,8 +7,7 @@ import styled, { css } from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useMemo } from "preact/hooks"; import { useMemo } from "preact/hooks";
import Checkbox from "../../ui/Checkbox"; import { Checkbox, OverrideSwitch } from "@revoltchat/ui";
import { OverrideSwitch } from "@revoltchat/ui";
interface PermissionSelectProps { interface PermissionSelectProps {
id: keyof typeof Permission; id: keyof typeof Permission;
@@ -25,7 +24,6 @@ const PermissionEntry = styled.label<{ disabled?: boolean }>`
width: 100%; width: 100%;
margin: 8px 0; margin: 8px 0;
display: flex; display: flex;
font-size: 1.1em;
align-items: center; align-items: center;
.title { .title {
@@ -69,13 +67,13 @@ export function PermissionSelect({
} }
return "Neutral"; return "Neutral";
} else { }
if (Long.fromNumber(value).and(permission).eq(permission)) { if (Long.fromNumber(value).and(permission).eq(permission)) {
return "Allow"; return "Allow";
} }
return "Neutral"; return "Neutral";
}
}, [value]); }, [value]);
function onSwitch(state: State) { function onSwitch(state: State) {
@@ -118,12 +116,12 @@ export function PermissionSelect({
return ( return (
<PermissionEntry disabled={disabled}> <PermissionEntry disabled={disabled}>
<span class="title"> <span className="title">
<span> <span>
<Text id={`permissions.${id}.t`}>{id}</Text> <Text id={`permissions.${id}.t`}>{id}</Text>
{disabled && <Lock className="lock" size={14} />} {disabled && <Lock className="lock" size={14} />}
</span> </span>
<span class="description"> <span className="description">
<Text id={`permissions.${id}.d`} /> <Text id={`permissions.${id}.d`} />
</span> </span>
</span> </span>
@@ -136,7 +134,7 @@ export function PermissionSelect({
) : ( ) : (
<Checkbox <Checkbox
disabled={disabled} disabled={disabled}
checked={state === "Allow"} value={state === "Allow"}
onChange={() => onChange={() =>
onChange( onChange(
Long.fromNumber(value, false) Long.fromNumber(value, false)

Some files were not shown because too many files have changed in this diff Show More