187 Commits

Author SHA1 Message Date
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
233 changed files with 7789 additions and 4849 deletions

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: 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 '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=="Incoming PRs") |.id' project_data.json) >> $GITHUB_ENV
- name: Add PR to project - name: Add PR to project
env: env:
@@ -39,12 +44,12 @@ 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
@@ -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,6 +5,7 @@ COPY . .
COPY .env.build .env COPY .env.build .env
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
RUN yarn build:deps
RUN yarn typecheck RUN yarn typecheck
RUN yarn build:highmem RUN yarn build:highmem
RUN yarn workspaces focus --production --all RUN yarn workspaces focus --production --all

View File

@@ -10,8 +10,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 +43,7 @@ Get revite up and running locally.
git clone --recursive https://github.com/revoltchat/revite git clone --recursive https://github.com/revoltchat/revite
cd revite cd revite
yarn yarn
yarn build:deps
yarn dev yarn dev
``` ```
@@ -57,6 +56,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 e79862b597

2
external/lang vendored

1
external/revolt.js vendored Submodule

Submodule external/revolt.js added at 29df60f782

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}",
@@ -46,10 +47,12 @@
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"klaw": "^3.0.0", "klaw": "^3.0.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 +73,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 +109,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 +126,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 +136,30 @@
"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-katex": "^6.0.2",
"rehype-prism": "^2.1.3",
"rehype-react": "^7.1.1",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"revolt.js": "6.0.17",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "^1.35.1", "sass": "^1.35.1",
"semver": "^7.3.7", "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 +167,9 @@
"repository": "https://github.com/revoltchat/revite.git", "repository": "https://github.com/revoltchat/revite.git",
"author": "Paul <paulmakles@gmail.com>", "author": "Paul <paulmakles@gmail.com>",
"license": "MIT", "license": "MIT",
"packageManager": "yarn@3.2.0" "packageManager": "yarn@3.2.0",
"resolutions": {
"@revoltchat/ui": "portal:external/components",
"revolt.js": "portal:external/revolt.js"
}
} }

View File

@@ -2,7 +2,10 @@
# 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/revite
@@ -14,9 +17,11 @@ POST_INSTALL="pm2 restart revite"
export REVOLT_SAAS=https://github.com/revoltchat/assets export REVOLT_SAAS=https://github.com/revoltchat/assets
# Exit when any command fails
set -e
# 1. Build Revite # 1. Build Revite
yarn yarn build: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

@@ -3,6 +3,7 @@ type Element =
| { | {
type: "image"; type: "image";
src: string; src: string;
shadow?: boolean;
}; };
export interface ChangelogPost { export interface ChangelogPost {
@@ -29,6 +30,19 @@ export const changelogEntries: Record<number, ChangelogPost> = {
"Other authentication methods coming later, stay tuned!", "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)",
],
},
}; };
export const changelogEntryArray = Object.keys(changelogEntries).map( export const changelogEntryArray = Object.keys(changelogEntries).map(

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: "💭",
@@ -1952,5 +1951,6 @@ export const emojiDictionary = {
huggies: "custom:huggies.png", huggies: "custom:huggies.png",
noted: "custom:noted.gif", noted: "custom:noted.gif",
waving: "custom:waving.png", waving: "custom:waving.png",
mogusvented: "custom:mogusvented.png",
}, },
}; };

View File

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

View File

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

View File

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

View File

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

@@ -5,7 +5,7 @@ import { useTriggerEvents } from "preact-context-menu";
import { memo } from "preact/compat"; import { memo } from "preact/compat";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Category } from "@revoltchat/ui"; import { Category, Button } from "@revoltchat/ui";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
@@ -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";
@@ -52,11 +52,9 @@ const Message = observer(
queued, queued,
hideReply, hideReply,
}: Props) => { }: Props) => {
const client = useClient(); const client = message.client;
const user = message.author; const user = message.author;
const { openScreen } = useIntermediate();
const content = message.content; const content = message.content;
const head = const head =
preferHead || (message.reply_ids && message.reply_ids.length > 0); preferHead || (message.reply_ids && message.reply_ids.length > 0);
@@ -70,7 +68,10 @@ const Message = observer(
: undefined; : undefined;
const openProfile = () => const openProfile = () =>
openScreen({ id: "profile", user_id: message.author_id }); modalController.push({
type: "user_profile",
user_id: message.author_id,
});
const handleUserClick = (e: MouseEvent) => { const handleUserClick = (e: MouseEvent) => {
if (e.shiftKey && user?._id) { if (e.shiftKey && user?._id) {
@@ -87,6 +88,7 @@ const Message = observer(
// ! FIXME(?): animate on hover // ! FIXME(?): animate on hover
const [mouseHovering, setAnimate] = useState(false); const [mouseHovering, setAnimate] = useState(false);
const [reactionsOpen, setReactionsOpen] = useState(false);
useEffect(() => setAnimate(false), [replacement]); useEffect(() => setAnimate(false), [replacement]);
return ( return (
@@ -115,7 +117,11 @@ const Message = observer(
} }
contrast={contrast} contrast={contrast}
sending={typeof queued !== "undefined"} sending={typeof queued !== "undefined"}
mention={message.mention_ids?.includes(client.user!._id)} mention={
message.mention_ids && client.user
? message.mention_ids.includes(client.user._id)
: undefined
}
failed={typeof queued?.error !== "undefined"} failed={typeof queued?.error !== "undefined"}
{...(attachContext {...(attachContext
? useTriggerEvents("Menu", { ? useTriggerEvents("Menu", {
@@ -159,7 +165,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 +186,13 @@ const Message = observer(
{message.embeds?.map((embed, index) => ( {message.embeds?.map((embed, index) => (
<Embed key={index} embed={embed} /> <Embed key={index} embed={embed} />
))} ))}
{mouseHovering && <Reactions message={message} />
{(mouseHovering || reactionsOpen) &&
!replacement && !replacement &&
!isTouchscreenDevice && ( !isTouchscreenDevice && (
<MessageOverlayBar <MessageOverlayBar
reactionsOpen={reactionsOpen}
setReactionsOpen={setReactionsOpen}
message={message} message={message}
queued={queued} queued={queued}
/> />

View File

@@ -1,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],
); );
@@ -214,7 +323,7 @@ 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 === "attached") return sendFile(content);
if (content.length === 0) return; if (content.length === 0) return;
@@ -299,6 +408,7 @@ export default observer(({ channel }: Props) => {
async function sendFile(content: string) { async function sendFile(content: string) {
if (uploadState.type !== "attached") return; if (uploadState.type !== "attached") return;
const attachments: string[] = []; const attachments: string[] = [];
setMessage;
const cancel = Axios.CancelToken.source(); const cancel = Axios.CancelToken.source();
const files = uploadState.files; const files = uploadState.files;
@@ -422,7 +532,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;
@@ -498,6 +608,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 +672,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 +744,13 @@ export default observer(({ channel }: Props) => {
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
/> />
{/*<Action> {state.experiments.isEnabled("picker") && (
<IconButton> <Action>
<Box size={24} /> <IconButton onClick={() => setPicker(!picker)}>
</IconButton> <HappyBeaming size={24} />
</Action> </IconButton>
<Action> </Action>
<IconButton> )}
<HappyBeaming size={24} />
</IconButton>
</Action>*/}
<Action> <Action>
<IconButton <IconButton
className={ className={

View File

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

View File

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

View File

@@ -15,14 +15,14 @@ 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);

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,152 @@ const Divider = styled.div`
background: var(--tertiary-background); background: var(--tertiary-background);
`; `;
export const MessageOverlayBar = observer(({ message, queued }: Props) => { export const MessageOverlayBar = observer(
const client = useClient(); ({ reactionsOpen, setReactionsOpen, message, queued }: Props) => {
const { openScreen, writeClipboard } = useIntermediate(); const client = message.client;
const isAuthor = message.author_id === client.user!._id; const isAuthor = message.author_id === client.user!._id;
const [copied, setCopied] = useState<"link" | "id">(null!); const [copied, setCopied] = useState<"link" | "id">(null!);
const [extraActions, setExtra] = useState(shiftKeyPressed); const [extraActions, setExtra] = useState(shiftKeyPressed);
useEffect(() => { useEffect(() => {
const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey); const handler = (ev: KeyboardEvent) => setExtra(ev.shiftKey);
document.addEventListener("keyup", handler); document.addEventListener("keyup", handler);
document.addEventListener("keydown", handler); document.addEventListener("keydown", handler);
return () => { return () => {
document.removeEventListener("keyup", handler); document.removeEventListener("keyup", handler);
document.removeEventListener("keydown", handler); document.removeEventListener("keydown", handler);
}; };
}); });
return ( return (
<OverlayBar> <OverlayBar>
<Tooltip content="Reply"> {message.channel?.havePermission("SendMessage") && (
<Entry onClick={() => internalEmit("ReplyBar", "add", message)}> <Tooltip content="Reply">
<Share size={18} /> <Entry
</Entry> onClick={() =>
</Tooltip> internalEmit("ReplyBar", "add", message)
}>
<Share size={18} />
</Entry>
</Tooltip>
)}
{isAuthor && ( {message.channel?.havePermission("React") &&
<Tooltip content="Edit"> state.experiments.isEnabled("picker") && (
<ReactionWrapper
open={reactionsOpen}
setOpen={setReactionsOpen}
message={message}>
<Tooltip content="React">
<Entry>
<HappyBeaming size={18} />
</Entry>
</Tooltip>
</ReactionWrapper>
)}
{isAuthor && (
<Tooltip content="Edit">
<Entry
onClick={() =>
internalEmit(
"MessageRenderer",
"edit_message",
message._id,
)
}>
<Pencil size={18} />
</Entry>
</Tooltip>
)}
{isAuthor ||
(message.channel &&
message.channel.havePermission("ManageMessages")) ? (
<Tooltip content="Delete">
<Entry
onClick={(e) =>
e.shiftKey
? message.delete()
: modalController.push({
type: "delete_message",
target: message,
})
}>
<Trash size={18} color={"var(--error)"} />
</Entry>
</Tooltip>
) : undefined}
<Tooltip content="More">
<Entry <Entry
onClick={() => onClick={() =>
internalEmit( openContextMenu("Menu", {
"MessageRenderer", message,
"edit_message", contextualChannel: message.channel_id,
message._id, queued,
) })
}> }>
<Pencil size={18} /> <DotsVerticalRounded size={18} />
</Entry> </Entry>
</Tooltip> </Tooltip>
)} {extraActions && (
{isAuthor || <>
(message.channel && <Divider />
message.channel.havePermission("ManageMessages")) ? ( <Tooltip content="Mark as Unread">
<Tooltip content="Delete"> <Entry
<Entry onClick={() => {
onClick={(e) => // ! FIXME: deduplicate this code with ctx menu
e.shiftKey const messages = getRenderer(
? message.delete() message.channel!,
: openScreen({ ).messages;
id: "special_prompt", const index = messages.findIndex(
type: "delete_message", (x) => x._id === message._id,
target: message, );
} as unknown as Screen)
}>
<Trash size={18} color={"var(--error)"} />
</Entry>
</Tooltip>
) : undefined}
<Tooltip content="More">
<Entry
onClick={() =>
openContextMenu("Menu", {
message,
contextualChannel: message.channel_id,
queued,
})
}>
<DotsVerticalRounded size={18} />
</Entry>
</Tooltip>
{extraActions && (
<>
<Divider />
<Tooltip content="Mark as Unread">
<Entry
onClick={() => {
// ! FIXME: deduplicate this code with ctx menu
const messages = getRenderer(
message.channel!,
).messages;
const index = messages.findIndex(
(x) => x._id === message._id,
);
let unread_id = message._id; let unread_id = message._id;
if (index > 0) { if (index > 0) {
unread_id = messages[index - 1]._id; unread_id = messages[index - 1]._id;
} }
internalEmit("NewMessages", "mark", unread_id); internalEmit(
message.channel?.ack(unread_id, true); "NewMessages",
}}> "mark",
<Notification size={18} /> unread_id,
</Entry> );
</Tooltip> message.channel?.ack(unread_id, true);
<Tooltip }}>
content={copied === "link" ? "Copied!" : "Copy Link"} <Notification size={18} />
hideOnClick={false}> </Entry>
<Entry </Tooltip>
onClick={() => { <Tooltip
setCopied("link"); content={
writeClipboard(message.url); copied === "link" ? "Copied!" : "Copy Link"
}}> }
<LinkAlt size={18} /> hideOnClick={false}>
</Entry> <Entry
</Tooltip> onClick={() => {
<Tooltip setCopied("link");
content={copied === "id" ? "Copied!" : "Copy ID"} modalController.writeText(message.url);
hideOnClick={false}> }}>
<Entry <LinkAlt size={18} />
onClick={() => { </Entry>
setCopied("id"); </Tooltip>
writeClipboard(message._id); <Tooltip
}}> content={copied === "id" ? "Copied!" : "Copy ID"}
<InfoSquare size={18} /> hideOnClick={false}>
</Entry> <Entry
</Tooltip> onClick={() => {
</> setCopied("id");
)} modalController.writeText(message._id);
</OverlayBar> }}>
); <InfoSquare size={18} />
}); </Entry>
</Tooltip>
</>
)}
</OverlayBar>
);
},
);

View File

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

@@ -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,7 @@ export default function Embed({ embed }: Props) {
<a <a
onMouseDown={(ev) => onMouseDown={(ev) =>
(ev.button === 0 || ev.button === 1) && (ev.button === 0 || ev.button === 1) &&
openLink(embed.url!) modalController.openLink(embed.url!)
} }
className={styles.title}> className={styles.title}>
{embed.title} {embed.title}
@@ -191,8 +190,12 @@ export default function Embed({ embed }: Props) {
type="text/html" type="text/html"
frameBorder="0" frameBorder="0"
loading="lazy" loading="lazy"
onClick={() => openScreen({ id: "image_viewer", embed })} onClick={() =>
onMouseDown={(ev) => ev.button === 1 && openLink(embed.url)} modalController.push({ type: "image_viewer", embed })
}
onMouseDown={(ev) =>
ev.button === 1 && modalController.openLink(embed.url)
}
/> />
); );
} }

View File

@@ -1,5 +1,4 @@
import { Group } from "@styled-icons/boxicons-solid"; import { Group } from "@styled-icons/boxicons-solid";
import { reaction } from "mobx";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Message, API } from "revolt.js"; import { Message, API } from "revolt.js";
@@ -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,6 +16,7 @@ enum Badges {
Paw = 128, Paw = 128,
EarlyAdopter = 256, EarlyAdopter = 256,
ReservedRelevantJokeBadge1 = 512, ReservedRelevantJokeBadge1 = 512,
ReservedRelevantJokeBadge2 = 1024,
} }
const BadgesBase = styled.div` const BadgesBase = styled.div`
@@ -135,6 +136,13 @@ export default function UserBadges({ badges, uid }: Props) {
) : ( ) : (
<></> <></>
)} )}
{badges & Badges.ReservedRelevantJokeBadge2 ? (
<Tooltip content="It's Morbin Time">
<img src="/assets/badges/amorbus.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Paw ? ( {badges & Badges.Paw ? (
<Tooltip content="🦊"> <Tooltip content="🦊">
<img src="/assets/badges/paw.svg" /> <img src="/assets/badges/paw.svg" />

View File

@@ -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";
@@ -48,8 +47,6 @@ 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>
@@ -57,7 +54,9 @@ export default observer(({ user }: Props) => {
<Tooltip content={<Text id="app.special.copy_username" />}> <Tooltip content={<Text id="app.special.copy_username" />}>
<span <span
className="username" className="username"
onClick={() => writeClipboard(user.username)}> onClick={() =>
modalController.writeText(user.username)
}>
@{user.username} @{user.username}
</span> </span>
</Tooltip> </Tooltip>

View File

@@ -6,10 +6,9 @@ import styled, { css } from "styled-components/macro";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import fallback from "../assets/user.png"; import fallback from "../assets/user.png";
import { useClient } from "../../../controllers/client/ClientController";
import IconBase, { IconBaseProps } from "../IconBase"; import IconBase, { IconBaseProps } from "../IconBase";
type VoiceStatus = "muted" | "deaf"; type VoiceStatus = "muted" | "deaf";
@@ -26,6 +25,8 @@ export function useStatusColour(user?: User) {
return user?.online && user?.status?.presence !== "Invisible" return user?.online && user?.status?.presence !== "Invisible"
? user?.status?.presence === "Idle" ? user?.status?.presence === "Idle"
? theme.getVariable("status-away") ? theme.getVariable("status-away")
: user?.status?.presence === "Focus"
? theme.getVariable("status-focus")
: user?.status?.presence === "Busy" : user?.status?.presence === "Busy"
? theme.getVariable("status-busy") ? theme.getVariable("status-busy")
: theme.getVariable("status-online") : theme.getVariable("status-online")
@@ -56,7 +57,7 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
const client = useApplicationState().client!; const client = useClient();
const { const {
target, target,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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",
@@ -337,8 +340,7 @@ export const generateVariables = (theme: Theme) => {
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;
}

View File

@@ -1,557 +0,0 @@
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { Channel, Message as MessageI, Server, User } from "revolt.js";
import { ulid } from "ulid";
import styles from "./Prompt.module.scss";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import { Category, Modal, InputBox, Radio } from "@revoltchat/ui";
import type { Action } from "@revoltchat/ui/esm/components/design/atoms/display/Modal";
import { TextReact } from "../../../lib/i18n";
import Message from "../../../components/common/messaging/Message";
import UserIcon from "../../../components/common/user/UserIcon";
import { I18nError } from "../../Locale";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
import { useIntermediate } from "../Intermediate";
interface Props {
onClose: () => void;
question: Children;
content?: Children;
disabled?: boolean;
actions: Action[];
error?: string;
}
export function PromptModal({
onClose,
question,
content,
actions,
disabled,
error,
}: Props) {
return (
<Modal
title={question}
actions={actions}
onClose={onClose}
disabled={disabled}>
{error && (
<Category>
<I18nError error={error} />
</Category>
)}
{content}
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
| { 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: MessageI }
| {
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 }
);
export const SpecialPromptModal = observer((props: SpecialProps) => {
const client = useContext(AppContext);
const history = useHistory();
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<undefined | string>(undefined);
const { onClose } = props;
switch (props.type) {
case "leave_group":
case "close_dm":
case "leave_server":
case "delete_server":
case "delete_channel":
case "delete_bot":
case "unfriend_user":
case "block_user": {
const EVENTS = {
close_dm: ["confirm_close_dm", "close"],
delete_server: ["confirm_delete", "delete"],
delete_channel: ["confirm_delete", "delete"],
delete_bot: ["confirm_delete", "delete"],
leave_group: ["confirm_leave", "leave"],
leave_server: ["confirm_leave", "leave"],
unfriend_user: ["unfriend_user", "remove"],
block_user: ["block_user", "block"],
};
const event = EVENTS[props.type];
let name;
switch (props.type) {
case "unfriend_user":
case "block_user":
name = props.target.username;
break;
case "close_dm":
name = props.target.recipient?.username;
break;
case "delete_bot":
name = props.name;
break;
default:
name = props.target.name;
}
return (
<PromptModal
onClose={onClose}
question={
<Text
id={`app.special.modals.prompt.${event[0]}`}
fields={{ name }}
/>
}
actions={[
{
confirmation: true,
palette: "error",
children: (
<Text
id={`app.special.modals.actions.${event[1]}`}
/>
),
onClick: async () => {
setProcessing(true);
try {
switch (props.type) {
case "unfriend_user":
await props.target.removeFriend();
break;
case "block_user":
await props.target.blockUser();
break;
case "leave_group":
case "close_dm":
case "delete_channel":
case "leave_server":
case "delete_server":
if (props.type != "delete_channel")
history.push("/");
props.target.delete();
break;
case "delete_bot":
client.bots.delete(props.target);
props.cb?.();
break;
}
return true;
} catch (err) {
setError(takeError(err));
setProcessing(false);
return false;
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<TextReact
id={`app.special.modals.prompt.${event[0]}_long`}
fields={{ name: <b>{name}</b> }}
/>
}
disabled={processing}
error={error}
/>
);
}
case "delete_message": {
return (
<PromptModal
onClose={onClose}
question={<Text id={"app.context_menu.delete_message"} />}
actions={[
{
confirmation: true,
palette: "error",
children: (
<Text id="app.special.modals.actions.delete" />
),
onClick: async () => {
setProcessing(true);
try {
props.target.delete();
return true;
} catch (err) {
setError(takeError(err));
setProcessing(false);
return false;
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
palette: "plain",
},
]}
content={
<>
<h5>
<Text
id={`app.special.modals.prompt.confirm_delete_message_long`}
/>
</h5>
<Message
message={props.target}
head={true}
contrast
/>
</>
}
disabled={processing}
error={error}
/>
);
}
case "create_invite": {
const [code, setCode] = useState("abcdef");
const { writeClipboard } = useIntermediate();
useEffect(() => {
setProcessing(true);
props.target
.createInvite()
.then(({ _id }) => setCode(_id))
.catch((err) => setError(takeError(err)))
.finally(() => setProcessing(false));
}, [props.target]);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.create_invite`} />}
actions={[
{
children: (
<Text id="app.special.modals.actions.ok" />
),
confirmation: true,
onClick: onClose,
},
{
children: <Text id="app.context_menu.copy_link" />,
onClick: () =>
writeClipboard(
`${window.location.protocol}//${window.location.host}/invite/${code}`,
),
},
]}
content={
processing ? (
<Text id="app.special.modals.prompt.create_invite_generate" />
) : (
<div className={styles.invite}>
<Text id="app.special.modals.prompt.create_invite_created" />
<code>{code}</code>
</div>
)
}
disabled={processing}
error={error}
/>
);
}
case "kick_member": {
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.kick_member`} />}
actions={[
{
children: (
<Text id="app.special.modals.actions.kick" />
),
palette: "error",
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
client.members
.getKey({
server: props.target._id,
user: props.user._id,
})
?.kick();
return true;
} catch (err) {
setError(takeError(err));
setProcessing(false);
return false;
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<div className={styles.column}>
<UserIcon target={props.user} size={64} />
<Text
id="app.special.modals.prompt.confirm_kick"
fields={{ name: props.user?.username }}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "ban_member": {
const [reason, setReason] = useState<string | undefined>(undefined);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.ban_member`} />}
actions={[
{
children: (
<Text id="app.special.modals.actions.ban" />
),
palette: "error",
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await props.target.banUser(props.user._id, {
reason,
});
return true;
} catch (err) {
setError(takeError(err));
setProcessing(false);
return false;
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<div className={styles.column}>
<UserIcon target={props.user} size={64} />
<Text
id="app.special.modals.prompt.confirm_ban"
fields={{ name: props.user?.username }}
/>
<Category>
<Text id="app.special.modals.prompt.confirm_ban_reason" />
</Category>
<InputBox
value={reason ?? ""}
onChange={(e) =>
setReason(e.currentTarget.value)
}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "create_channel": {
const [name, setName] = useState("");
const [type, setType] = useState<"Text" | "Voice">("Text");
const history = useHistory();
return (
<PromptModal
onClose={onClose}
question={<Text id="app.context_menu.create_channel" />}
actions={[
{
confirmation: true,
palette: "secondary",
children: (
<Text id="app.special.modals.actions.create" />
),
onClick: async () => {
setProcessing(true);
try {
const channel =
await props.target.createChannel({
type,
name,
});
if (props.cb) {
props.cb(channel as any);
} else {
history.push(
`/server/${props.target._id}/channel/${channel._id}`,
);
}
return true;
} catch (err) {
setError(takeError(err));
setProcessing(false);
return false;
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<>
<Category>
<Text id="app.main.servers.channel_type" />
</Category>
<Radio
title={
<Text id="app.main.servers.text_channel" />
}
value={type === "Text"}
onSelect={() => setType("Text")}
/>
<Radio
title={
<Text id="app.main.servers.voice_channel" />
}
value={type === "Voice"}
onSelect={() => setType("Voice")}
/>
<Category>
<Text id="app.main.servers.channel_name" />
</Category>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</>
}
disabled={processing}
error={error}
/>
);
}
case "create_category": {
const [name, setName] = useState("");
return (
<PromptModal
onClose={onClose}
question={<Text id="app.context_menu.create_category" />}
actions={[
{
confirmation: true,
palette: "secondary",
children: (
<Text id="app.special.modals.actions.create" />
),
onClick: async () => {
setProcessing(true);
try {
props.target.edit({
categories: [
...(props.target.categories ?? []),
{
id: ulid(),
title: name,
channels: [],
},
],
});
setProcessing(false);
return true;
} catch (err) {
setError(takeError(err));
setProcessing(false);
return false;
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<>
<Category>
<Text id="app.main.servers.category_name" />
</Category>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</>
}
disabled={processing}
error={error}
/>
);
}
default:
return null;
}
});

View File

@@ -1,16 +0,0 @@
.info {
.header {
display: flex;
align-items: center;
flex-direction: row;
h1 {
margin: 0;
flex-grow: 1;
}
div {
cursor: pointer;
}
}
}

View File

@@ -1,41 +0,0 @@
import { X } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js";
import styles from "./ChannelInfo.module.scss";
import { Modal } from "@revoltchat/ui";
import Markdown from "../../../components/markdown/Markdown";
import { getChannelName } from "../../revoltjs/util";
interface Props {
channel: Channel;
onClose: () => void;
}
export const ChannelInfo = observer(({ channel, onClose }: Props) => {
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "SavedMessages"
) {
onClose();
return null;
}
return (
<Modal onClose={onClose}>
<div className={styles.info}>
<div className={styles.header}>
<h1>{getChannelName(channel, true)}</h1>
<div onClick={onClose}>
<X size={36} />
</div>
</div>
<p>
<Markdown content={channel.description!} />
</p>
</div>
</Modal>
);
});

View File

@@ -1,83 +0,0 @@
import { SubmitHandler, useForm } from "react-hook-form";
import { API } from "revolt.js";
import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks";
import { Category, Modal } from "@revoltchat/ui";
import FormField from "../../../pages/login/FormField";
import { I18nError } from "../../Locale";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
onCreate: (bot: API.Bot) => void;
}
interface FormInputs {
name: string;
}
export function CreateBotModal({ onClose, onCreate }: Props) {
const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = async ({ name }) => {
try {
const { bot } = await client.bots.create({ name });
onCreate(bot);
onClose();
} catch (err) {
setError(takeError(err));
}
};
return (
<Modal
onClose={onClose}
title={<Text id="app.special.popovers.create_bot.title" />}
actions={[
{
confirmation: true,
palette: "accent",
onClick: async () => {
await handleSubmit(onSubmit)();
return true;
},
children: <Text id="app.special.modals.actions.create" />,
},
{
palette: "plain",
onClick: onClose,
children: <Text id="app.special.modals.actions.cancel" />,
},
]}>
{/* Preact / React typing incompatabilities */}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(
onSubmit,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)(e as any);
}}>
<FormField
type="username"
name="name"
register={register}
showOverline
error={errors.name?.message}
/>
{error && (
<Category>
<Text id="app.special.popovers.create_bot.failed" />{" "}
&middot; <I18nError error={error} />
</Category>
)}
</form>
</Modal>
);
}

View File

@@ -1,20 +0,0 @@
.viewer {
display: flex;
overflow: hidden;
flex-direction: column;
border-end-end-radius: 4px;
border-end-start-radius: 4px;
max-width: 100vw;
img {
width: auto;
height: auto;
max-width: 90vw;
max-height: 75vh;
object-fit: contain;
border-bottom: thin solid var(--tertiary-foreground);
-webkit-touch-callout: default;
}
}

View File

@@ -1,5 +0,0 @@
.list {
max-width: 100%;
max-height: 360px;
overflow-y: scroll;
}

View File

@@ -1,22 +0,0 @@
import { observer } from "mobx-react-lite";
import { useEffect } from "preact/hooks";
import { modalController } from ".";
export default observer(() => {
useEffect(() => {
function keyUp(event: KeyboardEvent) {
if (event.key === "Escape") {
modalController.pop("close");
} else if (event.key === "Enter") {
modalController.pop("confirm");
}
}
document.addEventListener("keyup", keyUp);
return () => document.removeEventListener("keyup", keyUp);
}, []);
return modalController.rendered;
});

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