272 Commits

Author SHA1 Message Date
Declan Chidlow
87dfca26d7 Add AutoComplete for role completion 2025-06-02 10:47:08 +08:00
Paul Makles
9e293e0a30 merge: pull request #1108 from Zomatree/feat/reset-bot-password 2025-05-17 13:27:04 +01:00
Paul Makles
d957fbd5dc merge: pull request #1103 from Docteh/automation 2025-05-17 13:26:33 +01:00
Zomatree
0a8b70c8ed chore: update submodule 2025-05-17 13:10:11 +01:00
Zomatree
07d74a4ef4 feat: reset bot token button 2025-05-17 12:59:39 +01:00
Kyle Kienapfel
38ab8a7801 chore: update link to contribution guide in PR template
[skip ci]
2025-05-12 16:14:26 -07:00
Paul Makles
bccf1eebae merge: pull request #1102 from Docteh/docky 2025-05-12 11:16:25 +01:00
Kyle Kienapfel
135dbe2da4 fix: Remove caching from GitHub actions to allow compilation of docker container
The GitHub action `actions/cache@v2` is no longer available. Last successful docker build was 2024-11-15

I've removed the caching, as merely upgrading the version of the cache script did not fix the problem. Since the revoltchat/revite is deprecated I believe that the lack of caching in builds shouldn't be an undue burden on GitHub. Only 9 commits have been made here since builds stopped working

The logic for publishing Docker containers has been shuffled so that the build is executed only once when publishing.

I have tested building to GitHub's container registry, but not to dockerhub, I do not forsee any issues there.

Main reason for doing this, is that a fix for attachment downloads (https://github.com/revoltchat/revite/pull/1067) is not easily available to anyone running self-hosted.
2025-05-11 13:21:08 -07:00
izzy
f95d3d27d8 chore: remove the extra string 2025-05-09 08:48:41 +01:00
izzy
3d41a016cf fix: login error messages not rendering properly 2025-05-09 08:42:58 +01:00
Paul Makles
f9c2fcec56 chore: bump language submodule 2025-01-29 13:42:20 +00:00
Paul Makles
18243f0207 chore: always target blank for download button 2025-01-29 13:41:54 +00:00
Declan Chidlow
7750276554 fix: remove /download from attachments (#1067) 2025-01-29 13:39:17 +00:00
Declan Chidlow
97a6c7d399 fix: remove ability for owners to leave server from ServerInfo modal (#1070)
Co-authored-by: Declan Chidlow <git.rh92c@8shield.net>
2025-01-29 13:39:03 +00:00
Paul Makles
ac2beaf549 chore: update URL to FAQ 2024-12-17 17:35:55 +00:00
Amy
bf951e59ee fix: appending mention to draft content (#1065) 2024-12-17 17:29:57 +00:00
Paul Makles
03f9f7673d merge: pull request #1050 from JackDotJS/fix-blank-category-name 2024-12-17 17:29:16 +00:00
Paul Makles
478d375125 fix: add try-catch block around md renderer
fix: update recursive match to block numbered lists
2024-11-15 13:09:40 +00:00
Paul Makles
cb6296a96e chore: update languages submodule 2024-11-06 09:51:44 +00:00
Paul Makles
48a9d7d370 chore: update donation links 2024-11-06 09:51:35 +00:00
Paul Makles
5de18192b2 chore: delete .env.production 2024-10-27 22:31:42 +00:00
Paul Makles
ef4218d12b merge branch: 'insert/member-subscriptions' 2024-10-27 22:00:22 +00:00
Paul Makles
df4f6578f7 feat: add session token to file uploads 2024-10-27 21:59:16 +00:00
JackDotJS
28c897ac3d fix(settings): fixed inability to rename categories when they have blank names
Signed-off-by: JackDotJS <jackdotjs@proton.me>
2024-10-27 11:14:04 -07:00
Paul Makles
86e8424e46 merge: pull request #1035 from DeclanChidlow/server-leave-mobile 2024-10-16 12:53:00 +01:00
Declan Chidlow
5e66bc8dc4 fix: Add ability to leave server through server info screen 2024-10-15 20:23:36 +08:00
Paul Makles
00e6ead9bd chore: bump revolt.js submodule 2024-07-27 15:18:23 +02:00
Paul Makles
0f0808aa56 feat: add report button to user profiles 2024-06-23 12:23:42 +01:00
Paul Makles
c972e6813f fix: should always call subscribe 2024-06-19 18:16:30 +01:00
Paul Makles
61304f18c2 feat: implement support for Subscribe event 2024-06-19 17:40:59 +01:00
Paul Makles
2722d0a854 chore: remove this so people don't click it 2024-06-09 13:57:36 +01:00
Paul Makles
dfd96c449a chore: synchronise locales 2024-06-09 13:55:52 +01:00
Paul Makles
9eca58dda1 merge: pull request #967 from revoltchat/rexo/fix/remove-deprecated-emoji 2024-06-09 13:51:11 +01:00
Sophie L
6cf0ef95ad fix: remove (broken/deprecated) built-in custom emoji
these have been broken for a while due to the data loss when insert's server failed, and were also deprecated upon the addition of custom server emoji.

they were meant to be temporary anyway, and other clients (including the new web app) don't implement them, so let's remove them to avoid confusion.
2024-05-16 15:29:53 +01:00
insertish
d3661170a4 ci: synced local '.github/workflows/triage_pr.yml' with remote 'workflows/triage_pr.yml'
[skip ci]
2024-03-28 13:37:37 +00:00
Paul Makles
5b6546b761 fix: allow retrying sending files 2024-03-15 19:50:17 +00:00
Sophie L
c25ecc12b2 chore: rm mirroring script 2024-03-13 23:51:56 +00:00
Paul Makles
60fdb5c091 docs: legacy release procedure 2024-03-13 23:41:54 +00:00
Bob Bobs
472e6e07b5 fix: only delete session if error is Unauthorized 2024-03-13 22:55:12 +00:00
Paul Makles
15e8e10151 merge: pull request #864
fix(home): inconsistent discover button height
2023-11-11 19:16:59 +00:00
Paul Makles
782a9b54ba chore: update dest directory 2023-10-04 19:22:54 +01:00
Paul Makles
f52ede1c21 chore: bump languages submodule 2023-10-04 19:18:57 +01:00
Lea
7fef354fe6 fix: appeal to monkey brain 2023-10-04 19:13:07 +01:00
Lea
d4e0f19f95 feat: admin panel link for system message user 2023-10-04 19:13:07 +01:00
wangdejiang
fe63c6633f chore: resolve ts-to-JSX type hints 2023-08-25 18:44:57 +01:00
Sophie L
be3565871c feat: translate account management options 2023-08-25 18:44:34 +01:00
Paul Makles
bc604c0157 chore: bump language submodule 2023-08-01 23:10:06 +01:00
Paul Makles
8c183b530b feat: add admin shortcuts for convenience 2023-08-01 23:09:50 +01:00
Paul Makles
9bbc22b091 chore: update lang submodule; disable typechecking for docker build 2023-07-06 19:01:29 +01:00
Paul Makles
0d5ffb4df8 chore: update alerts to use new health service 2023-07-06 19:00:30 +01:00
Paul Makles
9d83d7cc1c chore: bump language submodule 2023-07-02 08:46:57 +01:00
FluffyCookie000
534b9227de fix: usernames are evolving blog post link (#895) 2023-07-02 08:46:34 +01:00
Paul Makles
5363ff2572 feat: raise katex limit to 512, upgrade dependencies 2023-06-18 10:22:01 +02:00
Paul Makles
3f1c2be709 chore: update language submodule 2023-06-17 16:11:32 +01:00
Paul Makles
04027658e6 feat: add more validation to KaTeX in markdown 2023-06-17 16:11:22 +01:00
Paul Makles
f1594a7b36 chore: show error on modify account 2023-06-15 18:43:36 +01:00
Paul Makles
fa0fcedce5 chore: we cooking now frfr 2023-06-13 22:04:14 +01:00
Paul Makles
70f5e6fc7e chore: monkey patch ulid parse error 2023-06-12 09:58:13 +01:00
Paul Makles
9b9ec867da chore: update typing indicator to at least use display name 2023-06-12 09:52:00 +01:00
Paul Makles
fead4ca879 feat: always show link warning for masked links 2023-06-12 09:51:17 +01:00
Paul Makles
449eee006d fix: copy full username from user profile 2023-06-11 22:26:56 +01:00
Paul Makles
cf691a8462 fix: use display name where available for friends 2023-06-11 22:22:52 +01:00
Paul Makles
c5ca579c95 chore: add helpful hint for new username format 2023-06-11 19:00:34 +01:00
Paul Makles
636367faea chore: strip emoji picker experiment
chore: use discrim
2023-06-11 18:36:07 +01:00
Paul Makles
f20ada7c49 feat: add changelog entry in preparation for update 2023-06-11 17:31:22 +01:00
Paul Makles
75d07ffe0f chore: move badges to top half of user profile 2023-06-11 13:02:38 +01:00
Paul Makles
5b600bec20 feat: back port discriminators and display names 2023-06-11 12:44:05 +01:00
temptrash
86218a7272 feat: swap pomelo usernames for discriminators 2023-06-10 15:01:12 +02:00
Paul Makles
9bc052ea87 chore: don't update the default .env file 2023-06-03 19:29:50 +01:00
Paul Makles
1862db89a3 feat: add support for webhooks
fix: removing reactions crashes client
2023-06-03 19:29:14 +01:00
Paul Makles
00708bb8f4 feat: include message context (if any) when reporting user 2023-05-31 12:06:31 +01:00
Paul Makles
7e87265ccb chore: update lang submodule 2023-05-31 11:50:35 +01:00
kate
d55d86aa3c fix: comment out link for nightly desktop build (#882) 2023-05-06 19:19:45 +01:00
Paul Makles
c426b6c184 chore: bump components and lang submodules 2023-04-21 21:12:20 +01:00
Paul Makles
4d58ff06f3 fix: weird typing errors that cropped up 2023-04-21 21:05:29 +01:00
Paul Makles
9f9902ffb1 fix: reset member sidebar fetch status when we become ready 2023-04-21 20:52:23 +01:00
Ashley
e8989fcffa add a blank space to re run CI 2023-03-22 18:40:24 +03:00
Ashley
06dea9300c fix inconsonant height in buttons 2023-03-20 18:11:10 +03:00
Paul Makles
c13896244c chore: update components submodule 2023-03-18 22:22:02 +00:00
Paul Makles
e17e556ee6 chore: add notice to emoji menu 2023-03-14 21:57:47 +00:00
Paul Makles
7441569cfa chore: enable emoji picker by default 2023-03-14 21:48:19 +00:00
Paul Makles
eff1de7c78 chore: add notices throughout app about development 2023-03-14 21:48:13 +00:00
Paul Makles
b733e13ec2 merge: branch 'master' of https://github.com/revoltchat/revite 2023-03-14 21:16:16 +00:00
Declan Chidlow
e68ceac4f6 fix: overflow management in voice menus (#849) 2023-03-14 21:16:05 +00:00
Paul Makles
3f536589e5 chore: update languages submodule 2023-03-14 21:15:04 +00:00
Paul Makles
21175dffda fix: check if content actually exists
chore: update lang submodule
2023-02-24 13:53:56 +01:00
Paul Makles
5b31ce494e merge: remote-tracking branch 'origin/production' 2023-02-23 20:08:51 +01:00
Paul Makles
056ec623cb chore: move date forwards 2023-02-23 19:12:26 +01:00
Paul Makles
ff0330ec1b feat(modal): add report success modal (also allows blocking user) 2023-02-23 17:52:47 +01:00
Paul Makles
58f35a2813 feat: add changelog entry for in-app reporting 2023-02-22 18:34:35 +01:00
Paul Makles
a00d554f80 feat: render shadows and markdown in changelogs 2023-02-22 18:33:38 +01:00
Paul Makles
7f911f5d2c chore: always build highmem 2023-02-22 17:59:38 +01:00
Paul Makles
122f047c85 fix(modal): trigger on keydown instead of keyup to prevent interference 2023-02-22 17:59:26 +01:00
Paul Makles
7d544c82ab feat: add reporting UI 2023-02-22 17:13:43 +01:00
Paul Makles
46eec5a132 fix: do not trigger modal submission on <select> 2023-02-22 17:00:47 +01:00
Paul Makles
de85527cd8 chore: bump submodules 2023-02-22 15:01:17 +01:00
kate
6062a361f1 fix: allow dash char in autocorrect content validation (#838) 2023-02-05 13:45:32 +00:00
Paul Makles
95d4f61d7f feat: support Streamable embeds 2023-01-29 17:34:07 +00:00
Paul Makles
34ce1d1a86 chore: bump submodule dependencies 2023-01-29 17:33:42 +00:00
insertish
a9f23fe0e3 ci: synced local '.github/workflows/triage_pr.yml' with remote 'workflows/triage_pr.yml'
[skip ci]
2023-01-24 19:43:32 +00:00
insertish
6c08ccff52 ci: synced local '.github/workflows/triage_issue.yml' with remote 'workflows/triage_issue.yml'
[skip ci]
2023-01-24 19:43:32 +00:00
Leda
2f43a2c32d fix: category title input reverts to span on empty string (#699)
Fixes https://github.com/revoltchat/revite/issues/693
2023-01-24 17:59:31 +00:00
cheneyni-451
e34c5c99fe fix(ui): add margin to delete role button (#802)
Co-authored-by: Cheney Ni <cheneyni@umich.edu>
2023-01-24 17:56:50 +00:00
kate
89b3c9c098 fix: dash ("-" char) in emoji names (#816) 2023-01-24 17:55:17 +00:00
Sophie L
dadf0b6329 fix: update draft check (#830) 2023-01-24 17:55:04 +00:00
Lea
fcf6812151 feat: allow rolt.chat for relative navigation (#814) 2022-12-05 14:47:04 +00:00
Lea
09be8c9e4f feat: list custom emojis in autocomplete (#809)
* feat: list custom emojis in autocomplete

* fix: properly align emoji name in autocompletion
2022-12-05 14:44:47 +00:00
kate
6767ea1853 fix(ci): typing issues; broken submodules (#826)
* Remove broken submodules

* Fix yarn typecheck

* Add build:deps before typecheck to fix missing dependencies

* fix: minor linting nitpick

Co-authored-by: Sophie L <beartechtalks@gmail.com>
2022-12-05 14:42:43 +00:00
Paul Makles
9be4afe241 fix: allow setting custom remote for publish 2022-11-06 13:26:41 +00:00
Paul Makles
7e89dcfb13 chore: add notice for iCloud users 2022-11-06 13:21:06 +00:00
Paul Makles
3836156f3d merge: branch 'master' into production 2022-10-23 21:09:49 +01: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
40d3356c1b merge: branch 'master' into production 2022-09-20 19:12:25 +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
df20ab7407 merge: branch 'master' into production 2022-09-20 18:44:51 +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
1621e3b17d merge: branch 'master' into production 2022-09-18 12:32: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
7ba2388de4 merge: branch 'master' into production 2022-09-18 10:24:23 +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
1a9a2786bb merge: branch 'master' into production 2022-09-17 13:02: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
8fd6963f38 merge: branch 'master' into production 2022-09-16 18:47:09 +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
1016d80e56 merge: branch 'master' into production 2022-09-08 21:28:25 +01:00
Paul Makles
eab5ed033f merge: branch 'master' of https://github.com/revoltchat/revite 2022-09-08 21:24:29 +01:00
Paul Makles
263d97853a merge: branch 'master' into production 2022-09-08 17:22:00 +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
1c69756383 merge: branch 'master' into production 2022-09-03 09:19:04 +01:00
Paul Makles
02eb7d83f6 feat: use wxp logo for Windows 7 2022-09-03 09:18:56 +01:00
Paul Makles
14037ef0c7 merge: branch 'master' into production 2022-09-02 16:29:27 +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
8608257066 merge: branch 'master' into production 2022-09-02 14:42:47 +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
88bc8f93b6 merge: branch 'master' into production 2022-09-01 14:04:27 +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
Paul Makles
ff41219cf4 Merge branch 'master' into production 2022-08-08 16:08:12 +01: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
f76e53649b Merge branch 'master' into production 2022-08-08 15:30:21 +01:00
Paul Makles
a4b8fb5fc2 chore: update strings and permission lists 2022-08-08 15:30:10 +01:00
Paul Makles
c9264e1a49 Merge branch 'master' into production 2022-08-08 15:22:17 +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
b2e0b5a035 merge: branch 'master' into production 2022-07-31 12:20:41 +02: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
11e8cd652d merge: branch 'master' into production 2022-07-30 12:30:07 +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
9a70bb0eff merge: branch 'master' into production 2022-07-18 13:07:04 +01:00
Paul Makles
791f4dfd89 chore: bump lang submodule 2022-07-18 13:04:03 +01:00
Paul Makles
3fbf315587 fix: add LoadSuspense around verification page 2022-07-18 13:02:40 +01:00
Paul Makles
9807ef9c9a fix: match optional whitespace in quote regex 2022-07-18 12:59:17 +01:00
Paul Makles
96017c5f33 merge: branch 'master' into production 2022-07-16 15:18:06 +01: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
176c7883c8 merge: branch 'master' into production 2022-07-15 21:51:24 +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
70bda88383 merge: branch 'master' into production 2022-07-14 17:14:12 +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
c9066aba2d merge: branch 'master' into production 2022-07-13 13:06:47 +01:00
Paul Makles
fc8cfa5419 fix: actually log out invalidate sessions 2022-07-13 13:05:43 +01:00
Paul Makles
3204d176de merge: branch 'master' into production 2022-07-13 12:57:07 +01:00
Paul Makles
e3a526e2d7 fix: type errors with markdown content 2022-07-13 12:57:01 +01:00
Paul Makles
57887dc86f merge: branch 'master' into production 2022-07-13 12:53:58 +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
4541a34cef Merge branch 'master' into production 2022-05-19 13:42:15 +01:00
Paul Makles
83a3585940 chore: add notice 2022-05-07 10:45:18 +01:00
250 changed files with 9110 additions and 5257 deletions

5
.env
View File

@@ -1,2 +1,5 @@
VITE_API_URL=https://api.revolt.chat # VITE_API_URL=https://api.revolt.chat
# VITE_API_URL=https://app.revolt.chat/api
# VITE_API_URL=http://local.revolt.chat:8000
VITE_API_URL=https://app.revolt.chat/api
VITE_THEMES_URL=https://themes.revolt.chat VITE_THEMES_URL=https://themes.revolt.chat

View File

@@ -1,6 +1,6 @@
## Please make sure to check the following tasks before opening and submitting a PR ## Please make sure to check the following tasks before opening and submitting a PR
* [ ] I understand and have followed the [contribution guide](https://github.com/revoltchat/revolt/discussions/282) * [ ] I understand and have followed the [contribution guide](https://developers.revolt.chat/contrib.html)
* [ ] I have tested my changes locally and they are working as intended * [ ] I have tested my changes locally and they are working as intended
* [ ] These changes do not have any notable side effects on other Revolt projects * [ ] These changes do not have any notable side effects on other Revolt projects
* [ ] (optional) I have opened a pull request on [the translation repository](https://github.com/revoltchat/translations) * [ ] (optional) I have opened a pull request on [the translation repository](https://github.com/revoltchat/translations)

View File

@@ -30,55 +30,18 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
architecture: [linux/amd64]
steps:
- name: Checkout
uses: actions/checkout@v2
with:
submodules: "recursive"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache/${{ matrix.architecture }}
key: ${{ runner.os }}-buildx-${{ matrix.architecture }}-${{ github.sha }}
- name: Build
uses: docker/build-push-action@v2
with:
context: .
platforms: ${{ matrix.architecture }}
cache-from: type=local,src=/tmp/.buildx-cache/${{ matrix.architecture }}
cache-to: type=local,dest=/tmp/.buildx-cache-new/${{ matrix.architecture }},mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache/${{ matrix.architecture }}
mv /tmp/.buildx-cache-new/${{ matrix.architecture }} /tmp/.buildx-cache/${{ matrix.architecture }}
publish: publish:
needs: [test]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
with: with:
submodules: "recursive" submodules: "recursive"
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v3
- name: Cache amd64 Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache/linux/amd64
key: ${{ runner.os }}-buildx-linux/amd64-${{ github.sha }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v3
@@ -86,26 +49,22 @@ jobs:
images: revoltchat/client, ghcr.io/revoltchat/client images: revoltchat/client, ghcr.io/revoltchat/client
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
if: github.event_name != 'pull_request'
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Github Container Registry - name: Login to Github Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish - name: Build and publish
uses: docker/build-push-action@v2 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64 platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache/linux/amd64
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

View File

@@ -1,16 +0,0 @@
name: Mirroring
on:
push:
branches:
- "master"
jobs:
to_gitlab:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v1
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url: git@gitlab.com:insert/revolt-vite.git
ssh_private_key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }}

View File

@@ -15,22 +15,27 @@ jobs:
gh api graphql -f query=' gh api graphql -f query='
query { query {
organization(login: "revoltchat"){ organization(login: "revoltchat"){
projectNext(number: 3) { projectV2(number: 3) {
id id
fields(first:20) { fields(first:20) {
nodes { nodes {
id ... on ProjectV2SingleSelectField {
name id
settings name
options {
id
name
}
}
} }
} }
} }
} }
}' > project_data.json }' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'TODO_OPTION_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") |.settings | fromjson.options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV echo 'TODO_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
- name: Add issue to project - name: Add issue to project
env: env:
@@ -39,11 +44,11 @@ jobs:
run: | run: |
item_id="$( gh api graphql -f query=' item_id="$( gh api graphql -f query='
mutation($project:ID!, $issue:ID!) { mutation($project:ID!, $issue:ID!) {
addProjectNextItem(input: {projectId: $project, contentId: $issue}) { addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
projectNextItem { item {
id id
} }
} }
}' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectNextItem.projectNextItem.id')" }' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV echo 'ITEM_ID='$item_id >> $GITHUB_ENV

View File

@@ -15,22 +15,27 @@ jobs:
gh api graphql -f query=' gh api graphql -f query='
query { query {
organization(login: "revoltchat"){ organization(login: "revoltchat"){
projectNext(number: 3) { projectV2(number: 5) {
id id
fields(first:20) { fields(first:20) {
nodes { nodes {
id ... on ProjectV2SingleSelectField {
name id
settings name
options {
id
name
}
}
} }
} }
} }
} }
}' > project_data.json }' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectNext.fields.nodes[] | select(.name== "Status") |.settings | fromjson.options[] | select(.name=="Incoming PRs") |.id' project_data.json) >> $GITHUB_ENV echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="🆕 Untriaged") |.id' project_data.json) >> $GITHUB_ENV
- name: Add PR to project - name: Add PR to project
env: env:
@@ -39,13 +44,13 @@ jobs:
run: | run: |
item_id="$( gh api graphql -f query=' item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) { mutation($project:ID!, $pr:ID!) {
addProjectNextItem(input: {projectId: $project, contentId: $pr}) { addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
projectNextItem { item {
id id
} }
} }
}' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectNextItem.projectNextItem.id')" }' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields - name: Set fields
@@ -59,14 +64,16 @@ jobs:
$status_field: ID! $status_field: ID!
$status_value: String! $status_value: String!
) { ) {
set_status: updateProjectNextItemField(input: { set_status: updateProjectV2ItemFieldValue(input: {
projectId: $project projectId: $project
itemId: $item itemId: $item
fieldId: $status_field fieldId: $status_field
value: $status_value value: {
singleSelectOptionId: $status_value
}
}) { }) {
projectNextItem { projectV2Item {
id id
} }
} }
}' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent }' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent

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

@@ -5,7 +5,8 @@ COPY . .
COPY .env.build .env COPY .env.build .env
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
RUN yarn typecheck RUN yarn build:deps
# RUN yarn typecheck # lol no
RUN yarn build:highmem RUN yarn build:highmem
RUN yarn workspaces focus --production --all RUN yarn workspaces focus --production --all

View File

@@ -1,3 +1,30 @@
# Deprecation Notice
This project is deprecated, however it still may receive maintenance updates.
PRs for small fixes are more than welcome.
## Deploying a new release
Ensure `.env.local` points to `https://app.revolt.chat/api`.
```bash
cd ~/deployments/revite
git pull
git submodule update
# check:
git status
export REVOLT_SAAS_BRANCH=revite/main
export REMOTE=root@production
scripts/publish.sh
# SSH in and restart revite:
ssh $REMOTE
tmux a -t 4
```
# Revite # Revite
## Description ## Description
@@ -10,8 +37,6 @@ 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/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/styles`: needs to be migrated to [revoltchat/components](https://github.com/revoltchat/components)
- `src/context/intermediate`: modal system is being rewritten from scratch
- `src/context/revoltjs`: client state management needs to be rewritten and include support for concurrent clients
- `src/lib`: this needs to be organised - `src/lib`: this needs to be organised
## Stack ## Stack
@@ -45,6 +70,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
``` ```
@@ -57,6 +83,7 @@ You can now access the client at http://local.revolt.chat:3000.
| `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 build:deps` | Build external dependencies. |
| `yarn preview` | Start a local server with the built client. | | `yarn preview` | Start a local server with the built client. |
| `yarn lint` | Run ESLint on the client. | | `yarn lint` | Run ESLint on the client. |
| `yarn fmt` | Run Prettier on the client. | | `yarn fmt` | Run Prettier on the client. |

1
external/components vendored Submodule

Submodule external/components added at 4be02430c7

2
external/lang vendored

1
external/revolt.js vendored Submodule

Submodule external/revolt.js added at cd9e84a337

View File

@@ -3,7 +3,8 @@
"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}",
@@ -43,13 +44,17 @@
} }
}, },
"dependencies": { "dependencies": {
"@revoltchat/rehype-katex": "6.0.3-patch.1",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"klaw": "^3.0.0", "klaw": "^3.0.0",
"lottie-react": "^2.4.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/bitter": "^4.5.7",
"@fontsource/comic-neue": "^4.4.5", "@fontsource/comic-neue": "^4.4.5",
@@ -70,25 +75,25 @@
"@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", "@insertish/vite-plugin-babel-macros": "^1.0.5",
"@preact/preset-vite": "^2.0.0", "@preact/preset-vite": "^2.0.0",
"@revoltchat/ui": "1.0.46", "@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.6", "@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", "@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",
@@ -106,15 +111,16 @@
"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-jsdoc": "^39.3.2",
"eslint-plugin-mobx": "^0.0.8",
"eventemitter3": "^4.0.7", "eventemitter3": "^4.0.7",
"history": "4", "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.4.0", "mobx-react-lite": "3.4.0",
@@ -122,7 +128,7 @@
"preact-context-menu": "0.4.1", "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",
"qrcode.react": "^3.0.2", "qrcode.react": "^3.0.2",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-device-detect": "2.2.2", "react-device-detect": "2.2.2",
@@ -132,17 +138,29 @@
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scroll": "^1.8.2", "react-scroll": "^1.8.2",
"react-virtuoso": "^2.12.0", "react-virtuoso": "^2.12.0",
"revolt.js": "6.0.3", "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", "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",
@@ -150,5 +168,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"
}
} }

View File

@@ -2,21 +2,26 @@
# Build and publish release to production server # Build and publish release to production server
# Remote Server # Remote Server
REMOTE=revolt-de-nrb-1 if [ -z "$REMOTE" ]; then
echo "Please set REMOTE!"
exit
fi
# Remote Directory # Remote Directory
REMOTE_DIR=/root/revite REMOTE_DIR=/root/deployments/revite
# Post-install script # Post-install script
POST_INSTALL="pm2 restart revite" POST_INSTALL=""
# Assets # Assets
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:highmem
yarn build
# 2. Archive built files # 2. Archive built files
tar -czvf build.tar.gz dist tar -czvf build.tar.gz dist

View File

@@ -1,38 +0,0 @@
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;

86
src/assets/changelogs.tsx Normal file
View File

@@ -0,0 +1,86 @@
import Lottie, { LottieRefCurrentProps } from "lottie-react";
import { JSX } from "preact";
import usernameAnim from "../controllers/modals/components/legacy/usernameUpdateLottie.json";
type Element =
| string
| {
type: "image";
src: string;
shadow?: boolean;
}
| { type: "element"; element: JSX.Element };
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!",
],
},
2: {
date: new Date("2023-02-23T20:00:00.000Z"),
title: "In-App Reporting Is Here",
content: [
"You can now report any user, server, or message directly from the app.",
{
type: "image",
src: "https://autumn.revolt.chat/attachments/ZuDVIjGiCl61Pk9XGk5qfc8-idN9EnFAk55DUQp713/the.png",
shadow: true,
},
"If you want to learn more about how we're making Revolt safer for you, check out our new blog post :point_right: [https://revolt.chat/posts/improving-user-safety](https://revolt.chat/posts/improving-user-safety)",
],
},
3: {
date: new Date("2023-06-11T15:00:00.000Z"),
title: "Usernames are Changing",
content: [
{
type: "element",
element: (
<Lottie
animationData={usernameAnim}
style={{
background: "var(--secondary-background)",
borderRadius: "6px",
}}
/>
),
},
"Revolt has undergone a significant change to its username system, transitioning from unique username handles to a new system of display names and usernames with four-digit number tags called discriminators. The four-digit number tags serve as identifiers to differentiate users with the same username, allowing individuals to select desired usernames that reflect their identity.",
{
type: "element",
element: (
<a href="https://revolt.chat/posts/evolving-usernames">
Read more on our blog!
</a>
),
},
],
},
};
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: "💭",
@@ -1849,108 +1848,109 @@ export const emojiDictionary = {
england: "🏴󠁧󠁢󠁥󠁮󠁧󠁿", england: "🏴󠁧󠁢󠁥󠁮󠁧󠁿",
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",
boohoo: "custom:boohoo.png", // boohoo: "custom:boohoo.png",
boohoo_goes_hard: "custom:boohoo_goes_hard.png", // boohoo_goes_hard: "custom:boohoo_goes_hard.png",
boohoo_shaken: "custom:boohoo_shaken.png", // boohoo_shaken: "custom:boohoo_shaken.png",
cat_arrival: "custom:cat_arrival.gif", // cat_arrival: "custom:cat_arrival.gif",
cat_awson: "custom:cat_awson.png", // cat_awson: "custom:cat_awson.png",
cat_blob: "custom:cat_blob.png", // cat_blob: "custom:cat_blob.png",
cat_bonk: "custom:cat_bonk.png", // cat_bonk: "custom:cat_bonk.png",
cat_concern: "custom:cat_concern.png", // cat_concern: "custom:cat_concern.png",
cat_fast: "custom:cat_fast.gif", // cat_fast: "custom:cat_fast.gif",
cat_kitty: "custom:cat_kitty.png", // cat_kitty: "custom:cat_kitty.png",
cat_lick: "custom:cat_lick.gif", // cat_lick: "custom:cat_lick.gif",
cat_not_like: "custom:cat_not_like.png", // cat_not_like: "custom:cat_not_like.png",
cat_put: "custom:cat_put.gif", // cat_put: "custom:cat_put.gif",
cat_pwease: "custom:cat_pwease.png", // cat_pwease: "custom:cat_pwease.png",
cat_rage: "custom:cat_rage.png", // cat_rage: "custom:cat_rage.png",
cat_sad: "custom:cat_sad.png", // cat_sad: "custom:cat_sad.png",
cat_snuff: "custom:cat_snuff.gif", // cat_snuff: "custom:cat_snuff.gif",
cat_spin: "custom:cat_spin.gif", // cat_spin: "custom:cat_spin.gif",
cat_squish: "custom:cat_squish.gif", // cat_squish: "custom:cat_squish.gif",
cat_stare: "custom:cat_stare.gif", // cat_stare: "custom:cat_stare.gif",
cat_steal: "custom:cat_steal.gif", // cat_steal: "custom:cat_steal.gif",
cat_sussy: "custom:cat_sussy.gif", // cat_sussy: "custom:cat_sussy.gif",
clueless: "custom:clueless.png", // clueless: "custom:clueless.png",
death: "custom:death.gif", // death: "custom:death.gif",
developers: "custom:developers.gif", // developers: "custom:developers.gif",
fastwawa: "custom:fastwawa.gif", // fastwawa: "custom:fastwawa.gif",
ferris: "custom:ferris.png", // ferris: "custom:ferris.png",
ferris_bongo: "custom:ferris_bongo.gif", // ferris_bongo: "custom:ferris_bongo.gif",
ferris_nom: "custom:ferris_nom.png", // ferris_nom: "custom:ferris_nom.png",
ferris_pensive: "custom:ferris_pensive.png", // ferris_pensive: "custom:ferris_pensive.png",
ferris_unsafe: "custom:ferris_unsafe.png", // ferris_unsafe: "custom:ferris_unsafe.png",
flesh: "custom:flesh.png", // flesh: "custom:flesh.png",
flooshed: "custom:flooshed.png", // flooshed: "custom:flooshed.png",
flosh: "custom:flosh.png", // flosh: "custom:flosh.png",
flushee: "custom:flushee.png", // flushee: "custom:flushee.png",
forgor: "custom:forgor.png", // forgor: "custom:forgor.png",
hollow: "custom:hollow.png", // hollow: "custom:hollow.png",
john: "custom:john.png", // john: "custom:john.png",
lightspeed: "custom:lightspeed.png", // lightspeed: "custom:lightspeed.png",
little_guy: "custom:little_guy.png", // little_guy: "custom:little_guy.png",
lmaoooo: "custom:lmaoooo.gif", // lmaoooo: "custom:lmaoooo.gif",
lol: "custom:lol.png", // lol: "custom:lol.png",
looking: "custom:looking.gif", // looking: "custom:looking.gif",
marie: "custom:marie.png", // marie: "custom:marie.png",
marie_furret: "custom:marie_furret.gif", // marie_furret: "custom:marie_furret.gif",
marie_smug: "custom:marie_smug.png", // marie_smug: "custom:marie_smug.png",
megumin: "custom:megumin.png", // megumin: "custom:megumin.png",
michi_above: "custom:michi_above.png", // michi_above: "custom:michi_above.png",
michi_awww: "custom:michi_awww.gif", // michi_awww: "custom:michi_awww.gif",
michi_drag: "custom:michi_drag.gif", // michi_drag: "custom:michi_drag.gif",
michi_flustered: "custom:michi_flustered.png", // michi_flustered: "custom:michi_flustered.png",
michi_glare: "custom:michi_glare.png", // michi_glare: "custom:michi_glare.png",
michi_sus: "custom:michi_sus.png", // michi_sus: "custom:michi_sus.png",
monkaS: "custom:monkaS.png", // monkaS: "custom:monkaS.png",
monkaStare: "custom:monkaStare.png", // monkaStare: "custom:monkaStare.png",
monkey_grr: "custom:monkey_grr.png", // monkey_grr: "custom:monkey_grr.png",
monkey_pensive: "custom:monkey_pensive.png", // monkey_pensive: "custom:monkey_pensive.png",
monkey_zany: "custom:monkey_zany.png", // monkey_zany: "custom:monkey_zany.png",
nazu_sit: "custom:nazu_sit.png", // nazu_sit: "custom:nazu_sit.png",
nazu_sus: "custom:nazu_sus.png", // nazu_sus: "custom:nazu_sus.png",
ok_and: "custom:ok_and.gif", // ok_and: "custom:ok_and.gif",
owo: "custom:owo.png", // owo: "custom:owo.png",
pat: "custom:pat.png", // pat: "custom:pat.png",
pointThink: "custom:pointThink.png", // pointThink: "custom:pointThink.png",
rainbowHype: "custom:rainbowHype.gif", // rainbowHype: "custom:rainbowHype.gif",
rawr: "custom:rawr.png", // rawr: "custom:rawr.png",
rember: "custom:rember.png", // rember: "custom:rember.png",
revolt: "custom:revolt.png", // revolt: "custom:revolt.png",
sickly: "custom:sickly.png", // sickly: "custom:sickly.png",
stare: "custom:stare.png", // stare: "custom:stare.png",
tfyoulookingat: "custom:tfyoulookingat.png", // tfyoulookingat: "custom:tfyoulookingat.png",
thanks: "custom:thanks.png", // thanks: "custom:thanks.png",
thonk: "custom:thonk.png", // thonk: "custom:thonk.png",
trol: "custom:trol.png", // trol: "custom:trol.png",
troll_smile: "custom:troll_smile.gif", // troll_smile: "custom:troll_smile.gif",
uber: "custom:uber.png", // uber: "custom:uber.png",
ubertroll: "custom:ubertroll.png", // ubertroll: "custom:ubertroll.png",
verycool: "custom:verycool.png", // verycool: "custom:verycool.png",
verygood: "custom:verygood.png", // verygood: "custom:verygood.png",
wawafast: "custom:wawafast.gif", // wawafast: "custom:wawafast.gif",
wawastance: "custom:wawastance.png", // wawastance: "custom:wawastance.png",
yeahokayyy: "custom:yeahokayyy.png", // yeahokayyy: "custom:yeahokayyy.png",
yed: "custom:yed.png", // yed: "custom:yed.png",
yems: "custom:yems.png", // yems: "custom:yems.png",
michael: "custom:michael.gif", // michael: "custom:michael.gif",
charle: "custom:charle.gif", // charle: "custom:charle.gif",
sadge: "custom:sadge.webp", // sadge: "custom:sadge.webp",
sus: "custom:sus.webp", // sus: "custom:sus.webp",
chade: "custom:chade.gif", // chade: "custom:chade.gif",
gigachad: "custom:gigachad.webp", // gigachad: "custom:gigachad.webp",
sippy: "custom:sippy.webp", // sippy: "custom:sippy.webp",
ayame_heart: "custom:ayame_heart.png", // ayame_heart: "custom:ayame_heart.png",
catgirl_peek: "custom:catgirl_peek.png", // catgirl_peek: "custom:catgirl_peek.png",
girl_happy: "custom:girl_happy.png", // girl_happy: "custom:girl_happy.png",
hug_plushie: "custom:hug_plushie.png", // hug_plushie: "custom:hug_plushie.png",
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",
// },
}; };

View File

@@ -1,13 +1,17 @@
import { Channel, User } from "revolt.js"; /* eslint-disable @typescript-eslint/ban-ts-comment */
import { Link } from "react-router-dom";
import { Channel, User, Role } 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";
@@ -25,11 +29,16 @@ export type AutoCompleteState =
type: "channel"; type: "channel";
matches: Channel[]; matches: Channel[];
} }
| {
type: "role";
matches: Role[];
}
)); ));
export type SearchClues = { export type SearchClues = {
users?: { type: "channel"; id: string } | { type: "all" }; users?: { type: "channel"; id: string } | { type: "all" };
channels?: { server: string }; channels?: { server: string };
roles?: { server: string };
}; };
export type AutoCompleteProps = { export type AutoCompleteProps = {
@@ -55,18 +64,20 @@ export function useAutoComplete(
function findSearchString( function findSearchString(
el: HTMLTextAreaElement, el: HTMLTextAreaElement,
): ["emoji" | "user" | "channel", string, number] | undefined { ): ["emoji" | "user" | "channel" | "role", string, number] | undefined {
if (el.selectionStart === el.selectionEnd) { if (el.selectionStart === el.selectionEnd) {
const cursor = el.selectionStart; const cursor = el.selectionStart;
const content = el.value.slice(0, cursor); const content = el.value.slice(0, cursor);
const valid = /\w/; const valid = /[\w\-]/;
let j = content.length - 1; let j = content.length - 1;
if (content[j] === "@") { if (content[j] === "@") {
return ["user", "", j]; return ["user", "", j];
} else if (content[j] === "#") { } else if (content[j] === "#") {
return ["channel", "", j]; return ["channel", "", j];
} else if (content[j] === "%") {
return ["role", "", j];
} }
while (j >= 0 && valid.test(content[j])) { while (j >= 0 && valid.test(content[j])) {
@@ -76,7 +87,12 @@ export function useAutoComplete(
if (j === -1) return; if (j === -1) return;
const current = content[j]; const current = content[j];
if (current === ":" || current === "@" || current === "#") { if (
current === ":" ||
current === "@" ||
current === "#" ||
current === "%"
) {
const search = content.slice(j + 1, content.length); const search = content.slice(j + 1, content.length);
const minLen = current === ":" ? 2 : 1; const minLen = current === ":" ? 2 : 1;
@@ -86,6 +102,8 @@ export function useAutoComplete(
? "channel" ? "channel"
: current === ":" : current === ":"
? "emoji" ? "emoji"
: current === "%"
? "role"
: "user", : "user",
search.toLowerCase(), search.toLowerCase(),
current === ":" ? j + 1 : j, current === ":" ? j + 1 : j,
@@ -105,16 +123,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,
@@ -219,6 +244,42 @@ export function useAutoComplete(
return; return;
} }
} }
if (type === "role" && searchClues?.roles) {
const server = client.servers.get(searchClues.roles.server);
let roles: (Role & { id: string })[] = [];
if (server?.roles) {
roles = Object.entries(server.roles).map(([id, role]) => ({
...role,
id,
}));
}
const matches = (
search.length > 0
? roles.filter((role) =>
role.name.toLowerCase().match(regex),
)
: roles
)
.splice(0, 5)
.filter((x) => typeof x !== "undefined");
if (matches.length > 0) {
const currentPosition =
state.type !== "none" ? state.selected : 0;
setState({
type: "role",
matches,
selected: Math.min(currentPosition, matches.length - 1),
within: false,
});
return;
}
}
} }
if (state.type !== "none") { if (state.type !== "none") {
@@ -234,10 +295,13 @@ 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") {
@@ -248,6 +312,14 @@ export function useAutoComplete(
state.matches[state.selected]._id, state.matches[state.selected]._id,
"> ", "> ",
); );
} else if (state.type === "role") {
content.splice(
index,
search.length + 1,
"<%",
state.matches[state.selected].id,
"> ",
);
} else { } else {
content.splice( content.splice(
index, index,
@@ -389,12 +461,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,21 +490,67 @@ 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" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match} key={match._id}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
@@ -452,7 +575,7 @@ export default function AutoComplete({
{state.type === "channel" && {state.type === "channel" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match} key={match._id}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
@@ -474,6 +597,40 @@ export default function AutoComplete({
{match.name} {match.name}
</button> </button>
))} ))}
{state.type === "role" &&
state.matches.map((match, i) => (
<button
key={match._id}
className={i === state.selected ? "active" : ""}
onMouseEnter={() =>
(i !== state.selected || !state.within) &&
setState({
...state,
selected: i,
within: true,
})
}
onMouseLeave={() =>
state.within &&
setState({
...state,
within: false,
})
}
onClick={onClick}>
<div
style={{
width: "16px",
height: "16px",
borderRadius: "50%",
backgroundColor: match.colour || "#7c7c7c",
marginRight: "8px",
flexShrink: 0,
}}
/>
{match.name}
</button>
))}
</div> </div>
</Base> </Base>
); );

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,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,12 +42,12 @@ 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,
)}`; // )}`;
} // }
const codepoint = toCodePoint(emoji); const codepoint = toCodePoint(emoji);
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`; return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;

View File

@@ -9,6 +9,7 @@ import { Text } from "preact-i18n";
import { IconButton } from "@revoltchat/ui"; 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

@@ -13,9 +13,8 @@ import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { QueuedMessage } from "../../../mobx/stores/MessageQueue"; import { QueuedMessage } from "../../../mobx/stores/MessageQueue";
import { I18nError } from "../../../context/Locale"; import { I18nError } from "../../../context/Locale";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { modalController } from "../../../controllers/modals/ModalController";
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";
@@ -33,7 +33,7 @@ import InviteList from "./embed/EmbedInvite";
interface Props { interface Props {
attachContext?: boolean; attachContext?: boolean;
queued?: QueuedMessage; queued?: QueuedMessage;
message: MessageObject; message: MessageObject & { webhook: { name: string; avatar?: string } };
highlight?: boolean; highlight?: boolean;
contrast?: boolean; contrast?: boolean;
content?: Children; content?: Children;
@@ -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);
@@ -65,12 +63,16 @@ const Message = observer(
? useTriggerEvents("Menu", { ? useTriggerEvents("Menu", {
user: message.author_id, user: message.author_id,
contextualChannel: message.channel_id, contextualChannel: message.channel_id,
contextualMessage: message._id,
// eslint-disable-next-line // eslint-disable-next-line
}) })
: 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 +89,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 +118,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", {
@@ -131,6 +138,11 @@ const Message = observer(
<UserIcon <UserIcon
className="avatar" className="avatar"
url={message.generateMasqAvatarURL()} url={message.generateMasqAvatarURL()}
override={
message.webhook?.avatar
? `https://autumn.revolt.chat/avatars/${message.webhook.avatar}`
: undefined
}
target={user} target={user}
size={36} size={36}
onClick={handleUserClick} onClick={handleUserClick}
@@ -151,6 +163,7 @@ const Message = observer(
showServerIdentity showServerIdentity
onClick={handleUserClick} onClick={handleUserClick}
masquerade={message.masquerade!} masquerade={message.masquerade!}
override={message.webhook?.name}
{...userContext} {...userContext}
/> />
<MessageDetail <MessageDetail
@@ -159,7 +172,8 @@ 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 && (
<Category> <Category>
@@ -179,10 +193,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,4 +1,4 @@
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 { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js"; import { Channel } from "revolt.js";
@@ -6,9 +6,10 @@ 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 } from "@revoltchat/ui"; 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";
@@ -21,18 +22,25 @@ 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 { DraftObject } from "../../../mobx/stores/Draft";
import { Reply } from "../../../mobx/stores/MessageQueue"; import { Reply } from "../../../mobx/stores/MessageQueue";
import { modalController } from "../../../context/modals"; 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 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";
@@ -127,6 +135,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;
`; `;
@@ -137,6 +149,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;
@@ -148,11 +221,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 client = useContext(AppContext); const [picker, setPicker] = useState(false);
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>
@@ -174,7 +278,12 @@ export default observer(({ channel }: Props) => {
// Push message content to draft. // Push message content to draft.
const setMessage = useCallback( const setMessage = useCallback(
(content?: string) => state.draft.set(channel._id, content), (content?: string) => {
const dobj: DraftObject = {
content,
};
state.draft.set(channel._id, dobj);
},
[state.draft, channel._id], [state.draft, channel._id],
); );
@@ -196,7 +305,7 @@ export default observer(({ channel }: Props) => {
if (!state.draft.has(channel._id)) { if (!state.draft.has(channel._id)) {
setMessage(text); setMessage(text);
} else { } else {
setMessage(`${state.draft.get(channel._id)}\n${text}`); setMessage(`${state.draft.get(channel._id)?.content}\n${text}`);
} }
} }
@@ -214,8 +323,8 @@ export default observer(({ channel }: Props) => {
if (uploadState.type === "uploading" || uploadState.type === "sending") if (uploadState.type === "uploading" || uploadState.type === "sending")
return; return;
const content = state.draft.get(channel._id)?.trim() ?? ""; const content = state.draft.get(channel._id)?.content?.trim() ?? "";
if (uploadState.type === "attached") return sendFile(content); if (uploadState.type !== "none") return sendFile(content);
if (content.length === 0) return; if (content.length === 0) return;
internalEmit("NewMessages", "hide"); internalEmit("NewMessages", "hide");
@@ -297,8 +406,11 @@ export default observer(({ channel }: Props) => {
* @returns * @returns
*/ */
async function sendFile(content: string) { async function sendFile(content: string) {
if (uploadState.type !== "attached") return; if (uploadState.type !== "attached" && uploadState.type !== "failed")
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;
@@ -422,7 +534,7 @@ export default observer(({ channel }: Props) => {
} }
function isInCodeBlock(cursor: number): boolean { function isInCodeBlock(cursor: number): boolean {
const content = state.draft.get(channel._id) || ""; const content = state.draft.get(channel._id)?.content || "";
const contentBeforeCursor = content.substring(0, cursor); const contentBeforeCursor = content.substring(0, cursor);
let delimiterCount = 0; let delimiterCount = 0;
@@ -455,6 +567,7 @@ export default observer(({ channel }: Props) => {
channel.channel_type === "TextChannel" channel.channel_type === "TextChannel"
? { server: channel.server_id! } ? { server: channel.server_id! }
: undefined, : undefined,
roles: { server: channel.server_id! },
}); });
return ( return (
@@ -498,6 +611,22 @@ 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);
const cnt: DraftObject = {
content:
(v?.content ? `${v.content} ` : "") +
`:${emoji}:`,
};
state.draft.set(channel._id, cnt);
}}
onClose={closePicker}
/>
)}
</FloatingLayer>
<Base> <Base>
{channel.havePermission("UploadFiles") ? ( {channel.havePermission("UploadFiles") ? (
<FileAction> <FileAction>
@@ -546,7 +675,7 @@ export default observer(({ channel }: Props) => {
id="message" id="message"
maxLength={2000} maxLength={2000}
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
value={state.draft.get(channel._id) ?? ""} value={state.draft.get(channel._id)?.content ?? ""}
padding="var(--message-box-padding)" padding="var(--message-box-padding)"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") { if (e.ctrlKey && e.key === "Enter") {
@@ -618,16 +747,11 @@ export default observer(({ channel }: Props) => {
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
/> />
{/*<Action>
<IconButton>
<Box size={24} />
</IconButton>
</Action>
<Action> <Action>
<IconButton> <IconButton onClick={() => setPicker(!picker)}>
<HappyBeaming size={24} /> <HappyBeaming size={24} />
</IconButton> </IconButton>
</Action>*/} </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

@@ -15,19 +15,19 @@ 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";
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);
const open_url = `${url}/${filename}`; const open_url = `${url}/${filename}`;
const download_url = url?.replace("attachments", "attachments/download"); const download_url = url;
const filesize = determineFileSize(size); const filesize = determineFileSize(size);
@@ -49,10 +49,11 @@ export default function AttachmentActions({ attachment }: Props) {
</IconButton> </IconButton>
</a> </a>
<a <a
target="_blank"
href={download_url} href={download_url}
className={styles.downloadIcon} className={styles.downloadIcon}
download download
target={isFirefox || window.native ? "_blank" : "_self"} // target={isFirefox || window.native ? "_blank" : "_self"}
rel="noreferrer"> rel="noreferrer">
<IconButton> <IconButton>
<Download size={24} /> <Download size={24} />

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,15 +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 { Button, Preloader } from "@revoltchat/ui"; import { Button, Preloader } from "@revoltchat/ui";
import RequiresOnline from "../../../../context/revoltjs/RequiresOnline"; import { useClient } from "../../../../controllers/client/ClientController";
import { import RequiresOnline from "../../../../controllers/client/jsx/RequiresOnline";
AppContext,
StatusContext,
} from "../../../../context/revoltjs/RevoltClient";
interface Props { interface Props {
attachment: API.File; attachment: API.File;
@@ -23,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(() => {
@@ -56,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

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

@@ -17,6 +17,7 @@ import { Bar } from "./JumpToBottom";
export default observer( export default observer(
({ channel, last_id }: { channel: Channel; last_id?: string }) => { ({ channel, last_id }: { channel: Channel; last_id?: string }) => {
const [hidden, setHidden] = useState(false); const [hidden, setHidden] = useState(false);
const [timeAgo, setTimeAgo] = useState("");
const hide = () => setHidden(true); const hide = () => setHidden(true);
useEffect(() => setHidden(false), [last_id]); useEffect(() => setHidden(false), [last_id]);
@@ -29,6 +30,14 @@ export default observer(
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, []); }, []);
useEffect(() => {
if (last_id) {
try {
setTimeAgo(dayjs(decodeTime(last_id)).fromNow());
} catch (err) {}
}
}, [last_id]);
const renderer = getRenderer(channel); const renderer = getRenderer(channel);
const history = useHistory(); const history = useHistory();
if (renderer.state !== "RENDER") return null; if (renderer.state !== "RENDER") return null;
@@ -52,7 +61,7 @@ export default observer(
<Text <Text
id="app.main.channel.misc.new_messages" id="app.main.channel.misc.new_messages"
fields={{ fields={{
time_ago: dayjs(decodeTime(last_id)).fromNow(), time_ago: timeAgo,
}} }}
/> />
</div> </div>

View File

@@ -185,13 +185,15 @@ 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>

View File

@@ -76,7 +76,9 @@ export default observer(({ channel }: Props) => {
if (users.length >= 5) { if (users.length >= 5) {
text = <Text id="app.main.channel.typing.several" />; text = <Text id="app.main.channel.typing.several" />;
} else if (users.length > 1) { } else if (users.length > 1) {
const userlist = [...users].map((x) => x!.username); const userlist = [...users].map(
(x) => x!.display_name ?? x!.username,
);
const user = userlist.pop(); const user = userlist.pop();
text = ( text = (
@@ -92,7 +94,9 @@ export default observer(({ channel }: Props) => {
text = ( text = (
<Text <Text
id="app.main.channel.typing.single" id="app.main.channel.typing.single"
fields={{ user: users[0]!.username }} fields={{
user: users[0]!.display_name ?? users[0]!.username,
}}
/> />
); );
} }

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,
@@ -69,7 +67,8 @@ export default function Embed({ embed }: Props) {
break; break;
} }
case "Twitch": case "Twitch":
case "Lightspeed": { case "Lightspeed":
case "Streamable": {
mw = 1280; mw = 1280;
mh = 720; mh = 720;
break; break;
@@ -143,7 +142,11 @@ 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!,
undefined,
true,
)
} }
className={styles.title}> className={styles.title}>
{embed.title} {embed.title}
@@ -191,8 +194,13 @@ 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, undefined, true)
}
/> />
); );
} }

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";
@@ -12,14 +11,13 @@ import { Button, Category, Preloader } from "@revoltchat/ui";
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
import { I18nError } from "../../../../context/Locale"; import { I18nError } from "../../../../context/Locale";
import {
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 {
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;
@@ -78,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);
@@ -90,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)
@@ -99,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 ? (

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"
@@ -93,6 +92,16 @@ export default function EmbedMedia({ embed, width, height }: Props) {
/> />
); );
} }
case "Streamable": {
return (
<iframe
src={`https://streamable.com/e/${embed.special.id}?loop=0`}
seamless
loading="lazy"
style={{ height }}
/>
);
}
default: { default: {
if (embed.video) { if (embed.video) {
const url = embed.video.url; const url = embed.video.url;
@@ -115,10 +124,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

@@ -16,17 +16,17 @@ enum Badges {
Paw = 128, Paw = 128,
EarlyAdopter = 256, EarlyAdopter = 256,
ReservedRelevantJokeBadge1 = 512, ReservedRelevantJokeBadge1 = 512,
ReservedRelevantJokeBadge2 = 1024,
} }
const BadgesBase = styled.div` const BadgesBase = styled.div`
gap: 8px; gap: 8px;
display: flex; display: flex;
margin-top: 4px;
flex-direction: row; flex-direction: row;
img { img {
width: 32px; width: 24px;
height: 32px; height: 24px;
} }
`; `;
@@ -102,7 +102,7 @@ export default function UserBadges({ badges, uid }: Props) {
content={ content={
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" /> <Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
}> }>
<Shield size={32} color="gray" /> <Shield size={24} color="gray" />
</Tooltip> </Tooltip>
) : ( ) : (
<></> <></>
@@ -119,7 +119,7 @@ export default function UserBadges({ badges, uid }: Props) {
}} }}
onClick={() => { onClick={() => {
window.open( window.open(
"https://insrt.uk/donate", "https://wiki.revolt.chat/notes/project/financial-support/",
"_blank", "_blank",
); );
}} }}
@@ -135,6 +135,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

@@ -11,8 +11,7 @@ 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 Tooltip from "../Tooltip"; import Tooltip from "../Tooltip";
import UserStatus from "./UserStatus"; import UserStatus from "./UserStatus";
@@ -30,9 +29,14 @@ const HeaderBase = styled.div`
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.new-name {
font-size: 16px;
font-weight: 600;
}
.username { .username {
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 13px;
font-weight: 600; font-weight: 600;
} }
@@ -48,17 +52,22 @@ interface Props {
} }
export default observer(({ user }: Props) => { export default observer(({ user }: Props) => {
const { writeClipboard } = useIntermediate();
return ( return (
<Header topBorder palette="secondary"> <Header topBorder palette="secondary">
<HeaderBase> <HeaderBase>
<div className="new-name">
{user.display_name ?? user.username}
</div>
<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={() =>
@{user.username} modalController.writeText(user.username)
}>
{user.username}
{"#"}
{user.discriminator}
</span> </span>
</Tooltip> </Tooltip>
</Localizer> </Localizer>

View File

@@ -6,15 +6,15 @@ 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";
interface Props extends IconBaseProps<User> { interface Props extends IconBaseProps<User> {
status?: boolean; status?: boolean;
override?: string;
voice?: VoiceStatus; voice?: VoiceStatus;
masquerade?: API.Masquerade; masquerade?: API.Masquerade;
showServerIdentity?: boolean; showServerIdentity?: boolean;
@@ -26,6 +26,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")
@@ -56,7 +58,7 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
const client = useApplicationState().client!; const client = useClient();
const { const {
target, target,
@@ -69,12 +71,15 @@ export default observer(
showServerIdentity, showServerIdentity,
masquerade, masquerade,
innerRef, innerRef,
override,
...svgProps ...svgProps
} = props; } = props;
let { url } = props; let { url } = props;
if (masquerade?.avatar) { if (masquerade?.avatar) {
url = client.proxyFile(masquerade.avatar); url = client.proxyFile(masquerade.avatar);
} else if (override) {
url = override;
} else if (!url) { } else if (!url) {
let override; let override;
if (target && showServerIdentity) { if (target && showServerIdentity) {

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,15 +30,34 @@ 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;
showServerIdentity?: boolean | "both"; showServerIdentity?: boolean | "both";
override?: string;
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,
@@ -43,12 +65,18 @@ export const Username = observer(
masquerade, masquerade,
showServerIdentity, showServerIdentity,
innerRef, innerRef,
override,
...otherProps ...otherProps
}: UsernameProps) => { }: UsernameProps) => {
let username = user?.username; let username =
let color; (user as unknown as { display_name: string })?.display_name ??
user?.username;
let color = masquerade?.colour;
let timed_out: Date | undefined;
if (user && showServerIdentity) { if (override) {
username = override;
} else if (user && showServerIdentity) {
const { server } = useParams<{ server?: string }>(); const { server } = useParams<{ server?: string }>();
if (server) { if (server) {
const client = useClient(); const client = useClient();
@@ -66,15 +94,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 +109,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 +152,18 @@ export const Username = observer(
); );
} }
return ( if (override) {
<span {...otherProps} ref={innerRef} style={{ color }}> return (
{prefixAt ? "@" : undefined} <>
{masquerade?.name ?? username ?? ( {el}
<Text id="app.main.channel.unknown_user" /> <BotBadge>
)} <Text id="app.main.channel.bot" />
</span> </BotBadge>
); </>
);
}
return el;
}, },
); );
@@ -125,9 +180,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,266 @@
import "katex/dist/katex.min.css";
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";
// @ts-expect-error no typings
import rehypeKatex from "@revoltchat/rehype-katex";
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,
maxLength: 512,
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 =
/(^(?:(?:[>*+-]|\d+\.)[^\S\r\n]*){5})(?:(?:[>*+-]|\d+\.)[^\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(() => {
try {
render
.process(sanitisedContent)
.then((file) => setContent(file.result));
} catch (err) {
setContent("Message failed to render." as never);
}
}, [sanitisedContent]);
const largeEmoji = useMemo(
() => !disallowBigEmoji && isOnlyEmoji(content!),
[content, disallowBigEmoji],
);
return <Container largeEmoji={largeEmoji}>{Content}</Container>;
});

View File

@@ -1,294 +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 { emojiDictionary } from "../../assets/emojis";
import { generateEmoji } from "../common/Emoji";
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" ||
channel?.channel_type === "VoiceChannel"
) {
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,38 @@
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,
undefined,
ev.currentTarget.innerText !== 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

@@ -9,8 +9,7 @@ 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";
const Base = styled.div` const Base = styled.div`

View File

@@ -13,8 +13,7 @@ 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";
@@ -50,7 +49,6 @@ export const UserButton = observer((props: UserProps) => {
channel, channel,
...divProps ...divProps
} = props; } = props;
const { openScreen } = useIntermediate();
return ( return (
<div <div
@@ -111,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,
}) })
@@ -149,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 (
@@ -159,7 +155,9 @@ 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,
@@ -179,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>
@@ -195,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 { Banner } from "@revoltchat/ui"; import { Banner, Button, Column } from "@revoltchat/ui";
import { import { useSession } from "../../../controllers/client/ClientController";
ClientStatus,
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
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

@@ -20,11 +20,10 @@ 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 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";
@@ -46,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) =>
@@ -131,8 +129,7 @@ export default observer(() => {
<Text id="app.main.categories.conversations" /> <Text id="app.main.categories.conversations" />
<IconButton <IconButton
onClick={() => onClick={() =>
openScreen({ modalController.push({
id: "special_input",
type: "create_group", type: "create_group",
}) })
}> }>

View File

@@ -7,8 +7,9 @@ import { ServerList } from "@revoltchat/ui";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
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";
/** /**
* Server list sidebar shim component * Server list sidebar shim component
@@ -16,13 +17,11 @@ import { useClient } from "../../../context/revoltjs/RevoltClient";
export default observer(() => { export default observer(() => {
const client = useClient(); const client = useClient();
const state = useApplicationState(); const state = useApplicationState();
const { openScreen } = useIntermediate();
const { server: server_id } = useParams<{ server?: string }>(); const { server: server_id } = useParams<{ server?: string }>();
const createServer = useCallback( const createServer = useCallback(
() => () =>
openScreen({ modalController.push({
id: "special_input",
type: "create_server", type: "create_server",
}), }),
[], [],
@@ -37,6 +36,7 @@ export default observer(() => {
home={state.layout.getLastHomePath} home={state.layout.getLastHomePath}
servers={state.ordering.orderedServers} servers={state.ordering.orderedServers}
reorder={state.ordering.reorderServer} reorder={state.ordering.reorderServer}
showDiscovery={IS_REVOLT}
/> />
); );
}); });

View File

@@ -14,8 +14,7 @@ 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 { ChannelButton } from "../items/ButtonItem"; import { ChannelButton } from "../items/ButtonItem";

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" rel="noreferrer"> 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";
@@ -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

@@ -7,8 +7,7 @@ import { useEffect, useState } from "preact/hooks";
import { Button, Category, Error, InputBox, Preloader } 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 { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";

View File

@@ -2,18 +2,16 @@ import { Block } from "@styled-icons/boxicons-regular";
import { Trash } from "@styled-icons/boxicons-solid"; import { Trash } from "@styled-icons/boxicons-solid";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { CategoryButton } from "@revoltchat/ui"; import { CategoryButton } from "@revoltchat/ui";
import { modalController } from "../../../context/modals";
import { import {
LogOutContext, clientController,
useClient, useClient,
} from "../../../context/revoltjs/RevoltClient"; } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
export default function AccountManagement() { export default function AccountManagement() {
const logOut = useContext(LogOutContext);
const client = useClient(); const client = useClient();
const callback = (route: "disable" | "delete") => () => const callback = (route: "disable" | "delete") => () =>
@@ -26,7 +24,7 @@ export default function AccountManagement() {
"X-MFA-Ticket": ticket.token, "X-MFA-Ticket": ticket.token,
}, },
}) })
.then(() => logOut(true)), .then(clientController.logoutCurrent),
); );
return ( return (
@@ -41,7 +39,7 @@ export default function AccountManagement() {
<CategoryButton <CategoryButton
icon={<Block size={24} color="var(--error)" />} icon={<Block size={24} color="var(--error)" />}
description={ description={
"Disable your account. You won't be able to access it unless you contact support." <Text id="app.settings.pages.account.manage.disable_description" />
} }
action="chevron" action="chevron"
onClick={callback("disable")}> onClick={callback("disable")}>
@@ -51,7 +49,7 @@ export default function AccountManagement() {
<CategoryButton <CategoryButton
icon={<Trash size={24} color="var(--error)" />} icon={<Trash size={24} color="var(--error)" />}
description={ description={
"Your account will be queued for deletion, a confirmation email will be sent." <Text id="app.settings.pages.account.manage.delete_description" />
} }
action="chevron" action="chevron"
onClick={callback("delete")}> onClick={callback("delete")}>

View File

@@ -3,7 +3,7 @@ import { Envelope, Key, Pencil } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { import {
AccountDetail, AccountDetail,
@@ -12,26 +12,22 @@ import {
HiddenValue, HiddenValue,
} from "@revoltchat/ui"; } from "@revoltchat/ui";
import { modalController } from "../../../context/modals"; import { useSession } from "../../../controllers/client/ClientController";
import { import { modalController } from "../../../controllers/modals/ModalController";
ClientStatus,
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
export default observer(() => { export default observer(() => {
const client = useClient(); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
const [email, setEmail] = useState("..."); const [email, setEmail] = useState("...");
useEffect(() => { useEffect(() => {
if (email === "..." && status === ClientStatus.ONLINE) { if (email === "..." && session.state === "Online") {
client.api client.api
.get("/auth/account/") .get("/auth/account/")
.then((account) => setEmail(account.email)); .then((account) => setEmail(account.email));
} }
}, [client, email, status]); }, [client, email, session.state]);
return ( return (
<> <>
@@ -41,7 +37,13 @@ export default observer(() => {
{( {(
[ [
["username", client.user!.username, At], [
"username",
client.user!.username +
"#" +
client.user!.discriminator,
At,
],
["email", email, Envelope], ["email", email, Envelope],
["password", "•••••••••", Key], ["password", "•••••••••", Key],
] as const ] as const

View File

@@ -3,17 +3,13 @@ import { Lock } from "@styled-icons/boxicons-solid";
import { API } from "revolt.js"; import { API } from "revolt.js";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks"; import { useCallback, useEffect, useState } from "preact/hooks";
import { Category, CategoryButton, Error, Tip } from "@revoltchat/ui"; import { Category, CategoryButton, Error, Tip } from "@revoltchat/ui";
import { modalController } from "../../../context/modals"; import { useSession } from "../../../controllers/client/ClientController";
import { import { takeError } from "../../../controllers/client/jsx/error";
ClientStatus, import { modalController } from "../../../controllers/modals/ModalController";
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util";
/** /**
* Temporary helper function for Axios config * Temporary helper function for Axios config
@@ -33,8 +29,8 @@ export function toConfig(token: string) {
*/ */
export default function MultiFactorAuthentication() { export default function MultiFactorAuthentication() {
// Pull in prerequisites // Pull in prerequisites
const client = useClient(); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
// Keep track of MFA state // Keep track of MFA state
const [mfa, setMFA] = useState<API.MultiFactorStatus>(); const [mfa, setMFA] = useState<API.MultiFactorStatus>();
@@ -42,13 +38,13 @@ export default function MultiFactorAuthentication() {
// Fetch the current MFA status on account // Fetch the current MFA status on account
useEffect(() => { useEffect(() => {
if (!mfa && status === ClientStatus.ONLINE) { if (!mfa && session.state === "Online") {
client.api client!.api
.get("/auth/mfa/") .get("/auth/mfa/")
.then(setMFA) .then(setMFA)
.catch((err) => setError(takeError(err))); .catch((err) => setError(takeError(err)));
} }
}, [client, mfa, status]); }, [mfa, client, session.state]);
// Action called when recovery code button is pressed // Action called when recovery code button is pressed
const recoveryAction = useCallback(async () => { const recoveryAction = useCallback(async () => {
@@ -104,7 +100,11 @@ export default function MultiFactorAuthentication() {
// Decide whether to disable or enable. // Decide whether to disable or enable.
if (mfa!.totp_mfa) { if (mfa!.totp_mfa) {
// Disable TOTP authentication // Disable TOTP authentication
await client.api.delete("/auth/mfa/totp", toConfig(ticket.token)); await client.api.delete(
"/auth/mfa/totp",
{},
toConfig(ticket.token),
);
setMFA({ setMFA({
...mfa!, ...mfa!,

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,290 +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 {
CategoryButton,
Checkbox,
ColourSwatches,
ComboBox,
Radio,
} from "@revoltchat/ui";
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 { EmojiSelector } from "./EmojiSelector";
import { ThemeBaseSelector } from "./ThemeBaseSelector";
/**
* 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();
}}
/>
);
});
/**
* Component providing a link to the theme shop.
* Only appears if experiment is enabled.
*/
export const ShimThemeShop = () => {
return (
<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>
);
};
/**
* Component providing a way to change current accent colour.
*/
export const ShimThemeAccent = 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 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)}
/>
</>
);
});
/**
* Component providing a way to switch between compact and normal message view.
*/
export const ShimDisplayCompact = () => {
// TODO: WIP feature
return (
<>
<h3>
<Text id="app.settings.pages.appearance.message_display" />
</h3>
<div /* className={styles.display} */>
<Radio
title={
<Text id="app.settings.pages.appearance.display.default" />
}
description={
<Text id="app.settings.pages.appearance.display.default_description" />
}
value={true}
/>
<Radio
title={
<Text id="app.settings.pages.appearance.display.compact" />
}
description={
<Text id="app.settings.pages.appearance.display.compact_description" />
}
value={false}
disabled
/>
</div>
</>
);
};
/**
* Component providing a way to change primary text font.
*/
export const ShimDisplayFont = 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 ShimDisplayMonospaceFont = 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 ShimDisplayLigatures = observer(() => {
const settings = useApplicationState().settings;
if (settings.theme.getFont() !== "Inter") return null;
return (
<>
<Checkbox
value={settings.get("appearance:ligatures") ?? false}
onChange={(v) => settings.set("appearance:ligatures", v)}
title={<Text id="app.settings.pages.appearance.ligatures" />}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}
/>
</>
);
});
/**
* Component providing a way to toggle showing the send button on desktop.
*/
export const ShimShowSendButton = observer(() => {
const settings = useApplicationState().settings;
return (
<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" />
}
/>
);
});
/**
* Component providing a way to toggle seasonal themes.
*/
export const ShimDisplaySeasonal = observer(() => {
const settings = useApplicationState().settings;
return (
<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" />
}
/>
);
});
/**
* Component providing a way to toggle transparency effects.
*/
export const ShimDisplayTransparency = observer(() => {
const settings = useApplicationState().settings;
return (
<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" />
}
/>
);
});
/**
* 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)}
/>
);
});

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 { 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,
);
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
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

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

@@ -7,7 +7,7 @@ import notoSVG from "./assets/noto_emoji.svg";
import openmojiSVG from "./assets/openmoji_emoji.svg"; import openmojiSVG from "./assets/openmoji_emoji.svg";
import twemojiSVG from "./assets/twemoji_emoji.svg"; import twemojiSVG from "./assets/twemoji_emoji.svg";
import { EmojiPack } from "../../common/Emoji"; import { EmojiPack } from "../../../common/Emoji";
const Container = styled.div` const Container = styled.div`
gap: 12px; gap: 12px;

View File

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

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 (
@@ -56,7 +54,9 @@ export default function ThemeTools() {
</Tooltip> </Tooltip>
<div <div
className="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

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

View File

@@ -31,6 +31,7 @@ export type Variables =
| "tooltip" | "tooltip"
| "status-online" | "status-online"
| "status-away" | "status-away"
| "status-focus"
| "status-busy" | "status-busy"
| "status-streaming" | "status-streaming"
| "status-invisible"; | "status-invisible";
@@ -283,6 +284,7 @@ export const PRESETS: Record<string, Theme> = {
"tertiary-foreground": "#3a3a3a", "tertiary-foreground": "#3a3a3a",
"status-online": "#3ABF7E", "status-online": "#3ABF7E",
"status-away": "#F39F00", "status-away": "#F39F00",
"status-focus": "#4799F0",
"status-busy": "#F84848", "status-busy": "#F84848",
"status-streaming": "#977EFF", "status-streaming": "#977EFF",
"status-invisible": "#A5A5A5", "status-invisible": "#A5A5A5",
@@ -310,6 +312,7 @@ export const PRESETS: Record<string, Theme> = {
"tertiary-foreground": "#848484", "tertiary-foreground": "#848484",
"status-online": "#3ABF7E", "status-online": "#3ABF7E",
"status-away": "#F39F00", "status-away": "#F39F00",
"status-focus": "#4799F0",
"status-busy": "#F84848", "status-busy": "#F84848",
"status-streaming": "#977EFF", "status-streaming": "#977EFF",
"status-invisible": "#A5A5A5", "status-invisible": "#A5A5A5",
@@ -336,9 +339,8 @@ export const generateVariables = (theme: Theme) => {
if (colour) { if (colour) {
const [r, g, b] = colour; const [r, g, b] = colour;
return `--${key}: ${theme[key]}; --${key}-rgb: ${r}, ${g}, ${b};`; return `--${key}: ${theme[key]}; --${key}-rgb: ${r}, ${g}, ${b};`;
} }
return `--${key}: ${theme[key]};`; return `--${key}: ${theme[key]};`;
}); });
}; };

View File

@@ -3,3 +3,14 @@ import { createBrowserHistory } from "history";
export const history = createBrowserHistory({ export const history = createBrowserHistory({
basename: import.meta.env.BASE_URL, basename: import.meta.env.BASE_URL,
}); });
export const routeInformation = {
getServer: () =>
/server\/([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26})/.exec(
history.location.pathname,
)?.[1],
getChannel: () =>
/channel\/([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26})/.exec(
history.location.pathname,
)?.[1],
};

View File

@@ -6,21 +6,19 @@ import { useEffect, useState } from "preact/hooks";
import { Preloader, UIProvider } from "@revoltchat/ui"; import { Preloader, UIProvider } from "@revoltchat/ui";
import { hydrateState } from "../mobx/State"; import { state } from "../mobx/State";
import Binder from "../controllers/client/jsx/Binder";
import ModalRenderer from "../controllers/modals/ModalRenderer";
import Locale from "./Locale"; import Locale from "./Locale";
import Theme from "./Theme"; import Theme from "./Theme";
import { history } from "./history"; import { history } from "./history";
import Intermediate from "./intermediate/Intermediate";
import ModalRenderer from "./modals/ModalRenderer";
import Client from "./revoltjs/RevoltClient";
import SyncManager from "./revoltjs/SyncManager";
const uiContext = { const uiContext = {
Link, Link,
Text: Text as any, Text: Text as any,
Trigger: ContextMenuTrigger, Trigger: ContextMenuTrigger,
emitAction: () => {}, emitAction: () => void {},
}; };
/** /**
@@ -31,7 +29,7 @@ export default function Context({ children }: { children: Children }) {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
hydrateState().then(() => setReady(true)); state.hydrate().then(() => setReady(true));
}, []); }, []);
if (!ready) return <Preloader type="spinner" />; if (!ready) return <Preloader type="spinner" />;
@@ -40,12 +38,8 @@ export default function Context({ children }: { children: Children }) {
<Router history={history}> <Router history={history}>
<UIProvider value={uiContext}> <UIProvider value={uiContext}>
<Locale> <Locale>
<Intermediate> <>{children}</>
<Client> <Binder />
{children}
<SyncManager />
</Client>
</Intermediate>
<ModalRenderer /> <ModalRenderer />
</Locale> </Locale>
</UIProvider> </UIProvider>

View File

@@ -1,219 +0,0 @@
import { Prompt } from "react-router";
import { useHistory } from "react-router-dom";
import { API, Channel, Message, Server, User } from "revolt.js";
import { createContext } from "preact";
import {
StateUpdater,
useContext,
useEffect,
useMemo,
useState,
} from "preact/hooks";
import type { Action } from "@revoltchat/ui/esm/components/design/atoms/display/Modal";
import { internalSubscribe } from "../../lib/eventEmitter";
import { determineLink } from "../../lib/links";
import { useApplicationState } from "../../mobx/State";
import { modalController } from "../modals";
import Modals from "./Modals";
export type Screen =
| { id: "none" }
// Modals
| { id: "signed_out" }
| { id: "error"; error: string }
| { id: "clipboard"; text: string }
| { id: "token_reveal"; token: string; username: string }
| { id: "external_link_prompt"; link: string }
| { id: "sessions"; confirm: () => void }
| {
id: "_prompt";
question: Children;
content?: Children;
actions: Action[];
}
| ({ id: "special_prompt" } & (
| { type: "leave_group"; target: Channel }
| { type: "close_dm"; target: Channel }
| { type: "leave_server"; target: Server }
| { type: "delete_server"; target: Server }
| { type: "delete_channel"; target: Channel }
| {
type: "delete_bot";
target: string;
name: string;
cb?: () => void;
}
| { type: "delete_message"; target: Message }
| {
type: "create_invite";
target: Channel;
}
| { type: "kick_member"; target: Server; user: User }
| { type: "ban_member"; target: Server; user: User }
| { type: "unfriend_user"; target: User }
| { type: "block_user"; target: User }
| {
type: "create_channel";
target: Server;
cb?: (
channel: Channel & {
channel_type: "TextChannel" | "VoiceChannel";
},
) => void;
}
| { type: "create_category"; target: Server }
))
| ({ id: "special_input" } & (
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| {
type: "create_role";
server: Server;
callback: (id: string) => void;
}
))
| {
id: "_input";
question: Children;
field: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
| {
id: "onboarding";
callback: (
username: string,
loginAfterSuccess?: true,
) => Promise<void>;
}
// Pop-overs
| { id: "profile"; user_id: string }
| {
id: "user_picker";
omit?: string[];
callback: (users: string[]) => Promise<void>;
}
| { id: "image_viewer"; attachment?: API.File; embed?: API.Image }
| { id: "channel_info"; channel: Channel }
| { id: "pending_requests"; users: User[] }
| { id: "modify_account"; field: "username" | "email" | "password" }
| { id: "create_bot"; onCreate: (bot: API.Bot) => void }
| {
id: "server_identity";
server: Server;
};
export const IntermediateContext = createContext({
screen: { id: "none" },
focusTaken: false,
});
export const IntermediateActionsContext = createContext<{
openLink: (href?: string, trusted?: boolean) => boolean;
openScreen: (screen: Screen) => void;
writeClipboard: (text: string) => void;
}>({
openLink: null!,
openScreen: null!,
writeClipboard: null!,
});
interface Props {
children: Children;
}
export let __thisIsAHack: StateUpdater<Screen>;
export default function Intermediate(props: Props) {
const [screen, openScreen] = useState<Screen>({ id: "none" });
__thisIsAHack = openScreen;
const history = useHistory();
const value = {
screen,
focusTaken: screen.id !== "none",
};
const actions = useMemo(() => {
return {
openLink: (href?: string, trusted?: boolean) => {
return modalController.openLink(href, trusted);
},
openScreen: (screen: Screen) => openScreen(screen),
writeClipboard: (a: string) => modalController.writeText(a),
};
// eslint-disable-next-line
}, []);
useEffect(() => {
const openProfile = (user_id: string) =>
openScreen({ id: "profile", user_id });
const navigate = (path: string) => history.push(path);
const subs = [
internalSubscribe(
"Intermediate",
"openProfile",
openProfile as (...args: unknown[]) => void,
),
internalSubscribe(
"Intermediate",
"navigate",
navigate as (...args: unknown[]) => void,
),
];
return () => subs.map((unsub) => unsub());
}, [history]);
return (
<IntermediateContext.Provider value={value}>
<IntermediateActionsContext.Provider value={actions}>
{screen.id !== "onboarding" && props.children}
<Modals
{...value}
{...actions}
key={
screen.id
} /** By specifying a key, we reset state whenever switching screen. */
/>
<Prompt
when={[
"modify_account",
"special_prompt",
"special_input",
"image_viewer",
"profile",
"channel_info",
"pending_requests",
"user_picker",
].includes(screen.id)}
message={(_, action) => {
if (action === "POP") {
openScreen({ id: "none" });
setTimeout(() => history.push(history.location), 0);
return false;
}
return true;
}}
/>
</IntermediateActionsContext.Provider>
</IntermediateContext.Provider>
);
}
export const useIntermediate = () => useContext(IntermediateActionsContext);

View File

@@ -1,28 +0,0 @@
//import { isModalClosing } from "../../components/ui/Modal";
import { Screen } from "./Intermediate";
import { InputModal } from "./modals/Input";
import { OnboardingModal } from "./modals/Onboarding";
import { PromptModal } from "./modals/Prompt";
export interface Props {
screen: Screen;
openScreen: (screen: Screen) => void;
}
export default function Modals({ screen, openScreen }: Props) {
const onClose = () =>
//isModalClosing || screen.id === "onboarding"
openScreen({ id: "none" });
// : internalEmit("Modal", "close");
switch (screen.id) {
case "_prompt":
return <PromptModal onClose={onClose} {...screen} />;
case "_input":
return <InputModal onClose={onClose} {...screen} />;
case "onboarding":
return <OnboardingModal onClose={onClose} {...screen} />;
}
return null;
}

View File

@@ -1,45 +0,0 @@
import { useContext } from "preact/hooks";
import { IntermediateContext, useIntermediate } from "./Intermediate";
import { SpecialInputModal } from "./modals/Input";
import { SpecialPromptModal } from "./modals/Prompt";
import { ChannelInfo } from "./popovers/ChannelInfo";
import { CreateBotModal } from "./popovers/CreateBot";
import { ImageViewer } from "./popovers/ImageViewer";
import { UserPicker } from "./popovers/UserPicker";
import { UserProfile } from "./popovers/UserProfile";
export default function Popovers() {
const { screen } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const onClose = () =>
//isModalClosing
openScreen({ id: "none" });
//: internalEmit("Modal", "close");
switch (screen.id) {
case "profile":
// @ts-expect-error someone figure this out :)
return <UserProfile {...screen} onClose={onClose} />;
case "user_picker":
// @ts-expect-error someone figure this out :)
return <UserPicker {...screen} onClose={onClose} />;
case "image_viewer":
return <ImageViewer {...screen} onClose={onClose} />;
case "channel_info":
// @ts-expect-error someone figure this out :)
return <ChannelInfo {...screen} onClose={onClose} />;
case "create_bot":
// @ts-expect-error someone figure this out :)
return <CreateBotModal onClose={onClose} {...screen} />;
case "special_prompt":
// @ts-expect-error someone figure this out :)
return <SpecialPromptModal onClose={onClose} {...screen} />;
case "special_input":
// @ts-expect-error someone figure this out :)
return <SpecialInputModal onClose={onClose} {...screen} />;
}
return null;
}

View File

@@ -1,189 +0,0 @@
import { useHistory } from "react-router";
import { Server } from "revolt.js";
import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks";
import { Category, InputBox, Modal } from "@revoltchat/ui";
import { I18nError } from "../../Locale";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
question: Children;
field?: Children;
description?: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
export function InputModal({
onClose,
question,
field,
description,
defaultValue,
callback,
}: Props) {
const [processing, setProcessing] = useState(false);
const [value, setValue] = useState(defaultValue ?? "");
const [error, setError] = useState<undefined | string>(undefined);
return (
<Modal
title={question}
description={description}
disabled={processing}
actions={[
{
confirmation: true,
children: <Text id="app.special.modals.actions.ok" />,
onClick: () => {
setProcessing(true);
callback(value)
.then(onClose)
.catch((err) => {
setError(takeError(err));
setProcessing(false);
});
},
},
{
children: <Text id="app.special.modals.actions.cancel" />,
onClick: onClose,
},
]}
onClose={onClose}>
{field ? (
<Category>
<I18nError error={error}>{field}</I18nError>
</Category>
) : (
error && (
<Category>
<I18nError error={error} />
</Category>
)
)}
<InputBox
value={value}
style={{ width: "100%" }}
onChange={(e) => setValue(e.currentTarget.value)}
/>
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| { type: "create_role"; server: Server; callback: (id: string) => void }
);
export function SpecialInputModal(props: SpecialProps) {
const history = useHistory();
const client = useContext(AppContext);
const { onClose } = props;
switch (props.type) {
case "create_group": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.groups.create" />}
field={<Text id="app.main.groups.name" />}
callback={async (name) => {
const group = await client.channels.createGroup({
name,
users: [],
});
history.push(`/channel/${group._id}`);
}}
/>
);
}
case "create_server": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.servers.create" />}
field={<Text id="app.main.servers.name" />}
description={
<div>
By creating this server, you agree to the{" "}
<a
href="https://revolt.chat/aup"
target="_blank"
rel="noreferrer">
Acceptable Use Policy.
</a>
</div>
}
callback={async (name) => {
const server = await client.servers.createServer({
name,
});
history.push(`/server/${server._id}`);
}}
/>
);
}
case "create_role": {
return (
<InputModal
onClose={onClose}
question={
<Text id="app.settings.permissions.create_role" />
}
field={<Text id="app.settings.permissions.role_name" />}
callback={async (name) => {
const role = await props.server.createRole(name);
props.callback(role.id);
}}
/>
);
}
case "set_custom_status": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.context_menu.set_custom_status" />}
field={<Text id="app.context_menu.custom_status" />}
defaultValue={client.user?.status?.text ?? undefined}
callback={(text) =>
client.users.edit({
status: {
...client.user?.status,
text: text.trim().length > 0 ? text : undefined,
},
})
}
/>
);
}
case "add_friend": {
return (
<InputModal
onClose={onClose}
question={"Add Friend"}
callback={(username) =>
client.api
.post(`/users/friend`, { username })
.then(undefined)
}
/>
);
}
default:
return null;
}
}

View File

@@ -1,79 +0,0 @@
import { SubmitHandler, useForm } from "react-hook-form";
import styles from "./Onboarding.module.scss";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { Button, Preloader } from "@revoltchat/ui";
import wideSVG from "/assets/wide.svg";
import FormField from "../../../pages/login/FormField";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
}
interface FormInputs {
username: string;
}
export function OnboardingModal({ onClose, callback }: Props) {
const { handleSubmit, register } = useForm<FormInputs>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
setLoading(true);
callback(username, true)
.then(() => onClose())
.catch((err: unknown) => {
setError(takeError(err));
setLoading(false);
});
};
return (
<div className={styles.onboarding}>
<div className={styles.header}>
<h1>
<Text id="app.special.modals.onboarding.welcome" />
<br />
<img src={wideSVG} loading="eager" />
</h1>
</div>
<div className={styles.form}>
{loading ? (
<Preloader type="spinner" />
) : (
<>
<p>
<Text id="app.special.modals.onboarding.pick" />
</p>
<form
onSubmit={
handleSubmit(
onSubmit,
) as unknown as JSX.GenericEventHandler<HTMLFormElement>
}>
<div>
<FormField
type="username"
register={register}
showOverline
error={error}
/>
</div>
<Button type="submit">
<Text id="app.special.modals.actions.continue" />
</Button>
</form>
</>
)}
</div>
<div />
</div>
);
}

View File

@@ -1,18 +0,0 @@
.invite {
display: flex;
flex-direction: column;
code {
padding: 1em;
user-select: all;
font-size: 1.4em;
text-align: center;
font-family: var(--monospace-font);
}
}
.column {
display: flex;
align-items: center;
flex-direction: column;
}

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