1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-22 02:12:38 +01:00

Merge pull request #209 from spacebarchat/feat/modal-context-menu-refactor

Refactor Floating Systems
This commit is contained in:
Puyodead1 2023-12-19 12:27:38 -05:00 committed by GitHub
commit 126eec5b64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 2826 additions and 2494 deletions

View File

@ -6,9 +6,11 @@
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@floating-ui/react": "^0.26.4",
"@fontsource/roboto": "^4.5.8",
"@fontsource/roboto-mono": "^5.0.16",
"@hcaptcha/react-hcaptcha": "^1.9.2",
"@hookform/resolvers": "^3.3.2",
"@mattjennings/react-modal-stack": "^1.0.4",
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
@ -30,7 +32,9 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/react-measure": "^2.0.12",
"@types/react-portal": "^4.0.6",
"classnames": "^2.3.2",
"csstype": "^3.1.3",
"dayjs": "^1.11.9",
"framer-motion": "^10.16.16",
"marked-react": "^2.0.0",
@ -50,6 +54,7 @@
"react-loading-skeleton": "^3.3.1",
"react-markdown": "^8.0.7",
"react-measure": "^2.5.2",
"react-portal": "^4.2.2",
"react-router-dom": "^6.20.1",
"react-secure-storage": "^1.3.2",
"react-select-search": "^4.1.7",
@ -61,7 +66,8 @@
"remark-gfm": "^3.0.1",
"reoverlay": "^1.0.3",
"styled-components": "^5.3.11",
"use-resize-observer": "^9.1.0"
"use-resize-observer": "^9.1.0",
"yup": "^1.3.3"
},
"devDependencies": {
"@craco/craco": "^7.1.0",

View File

@ -11,6 +11,9 @@ dependencies:
'@emotion/styled':
specifier: ^11.11.0
version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.24)(react@18.2.0)
'@floating-ui/react':
specifier: ^0.26.4
version: 0.26.4(react-dom@18.2.0)(react@18.2.0)
'@fontsource/roboto':
specifier: ^4.5.8
version: 4.5.8
@ -20,6 +23,9 @@ dependencies:
'@hcaptcha/react-hcaptcha':
specifier: ^1.9.2
version: 1.9.2(react-dom@18.2.0)(react@18.2.0)
'@hookform/resolvers':
specifier: ^3.3.2
version: 3.3.2(react-hook-form@7.49.0)
'@mattjennings/react-modal-stack':
specifier: ^1.0.4
version: 1.0.4(react@18.2.0)
@ -83,9 +89,15 @@ dependencies:
'@types/react-measure':
specifier: ^2.0.12
version: 2.0.12
'@types/react-portal':
specifier: ^4.0.6
version: 4.0.6
classnames:
specifier: ^2.3.2
version: 2.3.2
csstype:
specifier: ^3.1.3
version: 3.1.3
dayjs:
specifier: ^1.11.9
version: 1.11.10
@ -143,6 +155,9 @@ dependencies:
react-measure:
specifier: ^2.5.2
version: 2.5.2(react-dom@18.2.0)(react@18.2.0)
react-portal:
specifier: ^4.2.2
version: 4.2.2(react-dom@18.2.0)(react@18.2.0)
react-router-dom:
specifier: ^6.20.1
version: 6.20.1(react-dom@18.2.0)(react@18.2.0)
@ -179,6 +194,9 @@ dependencies:
use-resize-observer:
specifier: ^9.1.0
version: 9.1.0(react-dom@18.2.0)(react@18.2.0)
yup:
specifier: ^1.3.3
version: 1.3.3
devDependencies:
'@craco/craco':
@ -2170,7 +2188,7 @@ packages:
'@emotion/memoize': 0.8.1
'@emotion/unitless': 0.8.1
'@emotion/utils': 1.2.1
csstype: 3.1.2
csstype: 3.1.3
dev: false
/@emotion/sheet@1.2.2:
@ -2498,6 +2516,19 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@floating-ui/react@0.26.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-pRiEz+SiPyfTcckAtLkEf3KJ/sUbB4X4fWMcDm27HT2kfAq+dH+hMc2VoOkNaGpDE35a2PKo688ugWeHaToL3g==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@floating-ui/react-dom': 2.0.4(react-dom@18.2.0)(react@18.2.0)
'@floating-ui/utils': 0.1.6
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tabbable: 6.2.0
dev: false
/@floating-ui/utils@0.1.6:
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
dev: false
@ -2526,6 +2557,14 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@hookform/resolvers@3.3.2(react-hook-form@7.49.0):
resolution: {integrity: sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==}
peerDependencies:
react-hook-form: ^7.0.0
dependencies:
react-hook-form: 7.49.0(react@18.2.0)
dev: false
/@humanwhocodes/config-array@0.11.11:
resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==}
engines: {node: '>=10.10.0'}
@ -2921,7 +2960,7 @@ packages:
'@types/react': 18.2.24
'@types/react-transition-group': 4.4.10
clsx: 2.0.0
csstype: 3.1.2
csstype: 3.1.3
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@ -2963,7 +3002,7 @@ packages:
'@emotion/cache': 11.11.0
'@emotion/react': 11.11.1(@types/react@18.2.24)(react@18.2.0)
'@emotion/styled': 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.24)(react@18.2.0)
csstype: 3.1.2
csstype: 3.1.3
prop-types: 15.8.1
react: 18.2.0
dev: false
@ -2993,7 +3032,7 @@ packages:
'@mui/utils': 5.14.20(@types/react@18.2.24)(react@18.2.0)
'@types/react': 18.2.24
clsx: 2.0.0
csstype: 3.1.2
csstype: 3.1.3
prop-types: 15.8.1
react: 18.2.0
dev: false
@ -4026,6 +4065,12 @@ packages:
'@types/react': 18.2.24
dev: false
/@types/react-portal@4.0.6:
resolution: {integrity: sha512-XUO/XRxovs50Hkfoant04x4AzDOE1ivJNKN/6QzNhH/37NpbWGFFGKFI1ZHOBU+wACPyj3V1A/XgEJkzoTQ7KQ==}
dependencies:
'@types/react': 18.2.24
dev: false
/@types/react-syntax-highlighter@15.5.7:
resolution: {integrity: sha512-bo5fEO5toQeyCp0zVHBeggclqf5SQ/Z5blfFmjwO5dkMVGPgmiwZsJh9nu/Bo5L7IHTuGWrja6LxJVE2uB5ZrQ==}
dependencies:
@ -4050,7 +4095,7 @@ packages:
dependencies:
'@types/prop-types': 15.7.8
'@types/scheduler': 0.16.4
csstype: 3.1.2
csstype: 3.1.3
/@types/resolve@1.17.1:
resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==}
@ -4109,7 +4154,7 @@ packages:
dependencies:
'@types/hoist-non-react-statics': 3.3.2
'@types/react': 18.2.24
csstype: 3.1.2
csstype: 3.1.3
dev: true
/@types/testing-library__jest-dom@5.14.9:
@ -5934,8 +5979,8 @@ packages:
cssom: 0.3.8
dev: true
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
/csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
/damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@ -6183,7 +6228,7 @@ packages:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dependencies:
'@babel/runtime': 7.23.1
csstype: 3.1.2
csstype: 3.1.3
dev: false
/dom-serializer@0.2.2:
@ -11171,6 +11216,10 @@ packages:
object-assign: 4.1.1
react-is: 16.13.1
/property-expr@2.0.6:
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
dev: false
/property-information@5.6.0:
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
dependencies:
@ -11447,6 +11496,17 @@ packages:
resize-observer-polyfill: 1.5.1
dev: false
/react-portal@4.2.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-vS18idTmevQxyQpnde0Td6ZcUlv+pD8GTyR42n3CHUQq9OHi1C4jDE4ZWEbEsrbrLRhSECYiao58cvocwMtP7Q==}
peerDependencies:
react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0
react-dom: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0
dependencies:
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-refresh@0.11.0:
resolution: {integrity: sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==}
engines: {node: '>=0.10.0'}
@ -12698,6 +12758,10 @@ packages:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: true
/tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
dev: false
/tailwindcss@3.3.6:
resolution: {integrity: sha512-AKjF7qbbLvLaPieoKeTjG1+FyNZT6KaJMJPFeQyLfIp7l82ggH1fbHJSsYIvnbTFQOlkh+gBYpyby5GT1LIdLw==}
engines: {node: '>=14.0.0'}
@ -12847,6 +12911,10 @@ packages:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
dev: true
/tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
dev: false
/tmpl@1.0.5:
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
dev: true
@ -12867,6 +12935,10 @@ packages:
engines: {node: '>=0.6'}
dev: true
/toposort@2.0.2:
resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
dev: false
/tough-cookie@4.1.3:
resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==}
engines: {node: '>=6'}
@ -13006,6 +13078,11 @@ packages:
engines: {node: '>=10'}
dev: true
/type-fest@2.19.0:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
dev: false
/type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@ -14025,6 +14102,15 @@ packages:
engines: {node: '>=10'}
dev: true
/yup@1.3.3:
resolution: {integrity: sha512-v8QwZSsHH2K3/G9WSkp6mZKO+hugKT1EmnMqLNUcfu51HU9MDyhlETT/JgtzprnrnQHPWsjc6MUDMBp/l9fNnw==}
dependencies:
property-expr: 2.0.6
tiny-case: 1.0.3
toposort: 2.0.2
type-fest: 2.19.0
dev: false
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false

View File

@ -1,11 +1,22 @@
<svg width="1442" height="256" viewBox="0 0 1442 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M235.497 138C238.806 153.394 234.982 169.46 225.097 181.709C215.211 193.958 200.321 201.077 184.589 201.077H52.0732C36.3408 201.077 21.451 193.958 11.5658 181.709C1.67958 169.46 -2.14373 153.394 1.1649 138L24.4985 29.4245C25.5849 24.3682 29.0541 20.1534 33.8052 18.1191C38.5563 16.0838 43.9981 16.4817 48.4028 19.1861L118.331 62.1159L188.259 19.1861C192.664 16.4817 198.106 16.0838 202.857 18.1191C207.608 20.1534 211.077 24.3682 212.163 29.4245L235.497 138ZM181.883 112.957C181.883 105.091 175.511 98.7142 167.652 98.7142H140.235C132.375 98.7142 126.005 105.091 126.005 112.957V140.398C126.005 148.264 132.375 154.64 140.235 154.64H167.652C175.511 154.64 181.883 148.264 181.883 140.398V112.957ZM110.657 112.957C110.657 105.091 104.287 98.7142 96.4272 98.7142H69.0101C61.1507 98.7142 54.7792 105.091 54.7792 112.957V140.398C54.7792 148.264 61.1507 154.64 69.0101 154.64H96.4272C104.287 154.64 110.657 148.264 110.657 140.398V112.957Z" fill="#0185FF"/>
<path d="M379.118 10.8465C335.763 10.8465 309.821 35.8002 309.821 68.8373C309.821 103.909 339.541 116.525 361.461 124.852C379.37 131.916 392.488 136.963 392.488 148.065C392.488 158.158 382.145 165.474 364.487 165.474C344.578 165.474 324.177 156.643 310.828 145.289V190.428C324.177 199.754 345.838 207.064 369.532 207.064C413.897 207.064 441.098 182.111 441.098 146.551C441.098 108.703 409.615 96.5916 383.659 87.0039C366.253 80.1914 356.919 76.1541 356.919 67.3229C356.919 58.2396 366.506 52.4358 382.397 52.4358C398.281 52.4358 417.675 57.9874 429.765 66.5664V23.7016C416.667 16.14 398.785 10.8465 379.118 10.8465Z" fill="#0185FF"/>
<path d="M448.725 256H495.572V183.874C503.893 197.233 520.538 206.812 538.443 206.812C571.203 206.812 599.916 177.825 599.916 132.682C599.916 87.5075 569.943 58.269 537.183 58.269C516.752 58.269 501.623 69.1075 492.801 81.9626L488.519 62.0529H448.725V256ZM495.067 132.667C495.067 113.486 506.919 100.11 523.56 100.11C540.46 100.11 552.561 113.991 552.561 132.414C552.561 150.585 540.46 164.97 523.312 164.97C506.919 164.97 495.067 151.342 495.067 132.667Z" fill="#0185FF"/>
<path d="M667.938 206.812C689.12 206.812 703.748 195.973 712.318 183.118H712.566L717.102 203.028H756.899V62.0529H717.102L712.566 81.9626H712.318C703.748 69.1075 689.12 58.269 667.938 58.269C636.438 58.269 605.708 88.2641 605.708 132.903C605.708 176.817 636.438 206.812 667.938 206.812ZM653.056 132.682C653.056 114.496 665.164 100.11 682.312 100.11C698.702 100.11 710.804 113.739 710.804 132.667C710.804 151.342 698.702 164.97 682.312 164.97C665.164 164.97 653.056 151.096 653.056 132.682Z" fill="#0185FF"/>
<path d="M811.607 132.667C811.607 112.729 827.233 100.11 846.893 100.11C858.995 100.11 870.085 103.139 881.677 110.458V68.0988C869.328 61.2937 853.701 58.269 839.583 58.269C797.508 58.269 764.259 88.7677 764.259 132.414C764.259 176.061 797.254 206.812 839.583 206.812C854.204 206.812 869.576 203.283 881.677 196.982V154.623C870.085 162.951 858.238 164.97 847.148 164.97C827.233 164.97 811.607 152.604 811.607 132.667Z" fill="#0185FF"/>
<path d="M967.641 206.812C985.532 206.812 1001.9 203.031 1016.26 195.219V158.144C1003.41 165.216 989.311 170.52 973.939 170.52C954.534 170.52 937.896 162.185 931.852 145.263H1024.32C1025.07 140.728 1025.58 135.689 1025.58 130.146C1025.58 83.22 995.603 58.269 958.816 58.269C918.242 58.269 886.99 88.5129 886.99 132.918C886.99 175.56 915.971 206.812 967.641 206.812ZM930.84 118.304C934.619 103.654 945.709 94.5615 958.568 94.5615C971.923 94.5615 980.748 104.159 981.25 118.304H930.84Z" fill="#0185FF"/>
<path d="M1080.04 0H1033.19V203.028H1072.99L1077.27 183.118C1086.09 195.973 1101.22 206.812 1121.39 206.812C1154.15 206.812 1184.38 177.825 1184.38 132.667C1184.38 87.2554 1156.17 58.2691 1123.67 58.2691C1105.76 58.2691 1088.36 67.8474 1080.04 81.2061V0ZM1079.53 132.667C1079.53 113.739 1091.39 100.111 1107.78 100.111C1124.93 100.111 1137.03 114.496 1137.03 132.919C1137.03 151.09 1124.93 164.97 1108.03 164.97C1091.39 164.97 1079.53 151.595 1079.53 132.667Z" fill="#0185FF"/>
<path d="M1252.4 206.812C1273.59 206.812 1288.21 195.973 1296.78 183.118H1297.03L1301.57 203.028H1341.36V62.0529H1301.57L1297.03 81.9626H1296.78C1288.21 69.1075 1273.59 58.269 1252.4 58.269C1220.9 58.269 1190.17 88.2641 1190.17 132.903C1190.17 176.817 1220.9 206.812 1252.4 206.812ZM1237.52 132.682C1237.52 114.496 1249.63 100.11 1266.78 100.11C1283.17 100.11 1295.27 113.739 1295.27 132.667C1295.27 151.342 1283.17 164.97 1266.78 164.97C1249.63 164.97 1237.52 151.096 1237.52 132.682Z" fill="#0185FF"/>
<path d="M1351.05 203.028H1397.9V138.214C1397.9 112.471 1413.27 103.894 1429.65 103.894C1433.68 103.894 1437.97 104.399 1442 105.408V61.8008C1438.72 60.7907 1435.45 60.5392 1431.16 60.5392C1418.31 60.5392 1402.69 66.0862 1393.36 82.7312H1392.86L1388.33 62.0529H1351.05V203.028Z" fill="#0185FF"/>
<svg width="1200" height="214" viewBox="0 0 1200 214" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Logo-Blue 2" clip-path="url(#clip0_648_668)">
<g id="Icon">
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M195.975 114.84C198.729 127.651 195.546 141.021 187.32 151.214C179.094 161.407 166.702 167.331 153.611 167.331H43.3341C30.242 167.331 17.851 161.407 9.62479 151.214C1.39771 141.021 -1.78396 127.651 0.969403 114.84L20.3871 24.4863C21.2912 20.2786 24.1781 16.7711 28.1319 15.0782C32.0856 13.3845 36.6142 13.7156 40.2797 15.9662L98.4723 51.6913L156.665 15.9662C160.33 13.7156 164.859 13.3845 168.813 15.0782C172.766 16.7711 175.653 20.2786 176.557 24.4863L195.975 114.84ZM151.359 94.0001C151.359 87.4542 146.056 82.1476 139.516 82.1476H116.7C110.159 82.1476 104.858 87.4542 104.858 94.0001V116.836C104.858 123.382 110.159 128.688 116.7 128.688H139.516C146.056 128.688 151.359 123.382 151.359 116.836V94.0001ZM92.0861 94.0001C92.0861 87.4542 86.7852 82.1476 80.2444 82.1476H57.4286C50.8882 82.1476 45.586 87.4542 45.586 94.0001V116.836C45.586 123.382 50.8882 128.688 57.4286 128.688H80.2444C86.7852 128.688 92.0861 123.382 92.0861 116.836V94.0001Z" fill="#0185FF"/>
</g>
<g id="Wordmark">
<path id="Vector_2" d="M315.493 9.02637C279.414 9.02637 257.826 29.7923 257.826 57.2851C257.826 86.471 282.558 96.9698 300.8 103.899C315.703 109.778 326.619 113.978 326.619 123.217C326.619 131.616 318.012 137.704 303.318 137.704C286.75 137.704 269.773 130.355 258.664 120.907V158.47C269.773 166.231 287.798 172.314 307.516 172.314C344.436 172.314 367.072 151.549 367.072 121.957C367.072 90.4605 340.872 80.3816 319.272 72.403C304.787 66.7337 297.02 63.374 297.02 56.0248C297.02 48.4659 304.998 43.6361 318.222 43.6361C331.44 43.6361 347.579 48.256 357.64 55.3953V19.7241C346.741 13.4315 331.86 9.02637 315.493 9.02637Z" fill="#0185FF"/>
<path id="Vector_3" d="M373.418 213.037H412.403V153.016C419.328 164.133 433.179 172.104 448.079 172.104C475.342 172.104 499.236 147.982 499.236 110.415C499.236 72.8219 474.293 48.4902 447.031 48.4902C430.029 48.4902 417.439 57.5098 410.097 68.2075L406.534 51.6391H373.418V213.037ZM411.983 110.403C411.983 94.4406 421.846 83.3094 435.694 83.3094C449.758 83.3094 459.828 94.8608 459.828 110.192C459.828 125.314 449.758 137.284 435.488 137.284C421.846 137.284 411.983 125.943 411.983 110.403Z" fill="#0185FF"/>
<path id="Vector_4" d="M555.844 172.104C573.471 172.104 585.644 163.084 592.776 152.387H592.982L596.757 168.955H629.875V51.6391H596.757L592.982 68.2075H592.776C585.644 57.5098 573.471 48.4902 555.844 48.4902C529.631 48.4902 504.058 73.4515 504.058 110.599C504.058 147.143 529.631 172.104 555.844 172.104ZM543.46 110.415C543.46 95.2811 553.536 83.3094 567.806 83.3094C581.445 83.3094 591.516 94.6512 591.516 110.403C591.516 125.944 581.445 137.284 567.806 137.284C553.536 137.284 543.46 125.739 543.46 110.415Z" fill="#0185FF"/>
<path id="Vector_5" d="M675.401 110.403C675.401 93.8107 688.405 83.3094 704.765 83.3094C714.836 83.3094 724.065 85.8301 733.711 91.9208V56.6704C723.435 51.0073 710.431 48.4902 698.682 48.4902C663.668 48.4902 635.999 73.8706 635.999 110.192C635.999 146.514 663.457 172.104 698.682 172.104C710.849 172.104 723.641 169.168 733.711 163.924V128.674C724.065 135.604 714.206 137.284 704.977 137.284C688.405 137.284 675.401 126.994 675.401 110.403Z" fill="#0185FF"/>
<path id="Vector_6" d="M805.247 172.104C820.135 172.104 833.756 168.958 845.706 162.457V131.604C835.013 137.489 823.28 141.903 810.488 141.903C794.339 141.903 780.494 134.967 775.464 120.885H852.414C853.038 117.111 853.462 112.917 853.462 108.305C853.462 69.2539 828.516 48.4902 797.903 48.4902C764.138 48.4902 738.131 73.6585 738.131 110.611C738.131 146.097 762.248 172.104 805.247 172.104ZM774.622 98.45C777.767 86.2586 786.995 78.692 797.696 78.692C808.81 78.692 816.154 86.6789 816.572 98.45H774.622Z" fill="#0185FF"/>
<path id="Vector_7" d="M898.785 0H859.797V168.955H892.918L896.479 152.387C903.819 163.084 916.41 172.104 933.195 172.104C960.457 172.104 985.614 147.982 985.614 110.402C985.614 72.612 962.138 48.4902 935.092 48.4902C920.188 48.4902 905.708 56.4611 898.785 67.5779V0ZM898.36 110.402C898.36 94.651 908.23 83.3101 921.869 83.3101C936.141 83.3101 946.21 95.281 946.21 110.612C946.21 125.734 936.141 137.284 922.077 137.284C908.23 137.284 898.36 126.154 898.36 110.402Z" fill="#0185FF"/>
<path id="Vector_8" d="M1042.22 172.104C1059.85 172.104 1072.02 163.084 1079.15 152.387H1079.36L1083.14 168.955H1116.25V51.6391H1083.14L1079.36 68.2075H1079.15C1072.02 57.5098 1059.85 48.4902 1042.22 48.4902C1016.01 48.4902 990.433 73.4515 990.433 110.599C990.433 147.143 1016.01 172.104 1042.22 172.104ZM1029.84 110.415C1029.84 95.2811 1039.91 83.3094 1054.19 83.3094C1067.83 83.3094 1077.89 94.6512 1077.89 110.403C1077.89 125.944 1067.83 137.284 1054.19 137.284C1039.91 137.284 1029.84 125.739 1029.84 110.415Z" fill="#0185FF"/>
<path id="Vector_9" d="M1124.31 168.955H1163.3V115.019C1163.3 93.5959 1176.09 86.4583 1189.72 86.4583C1193.08 86.4583 1196.65 86.8785 1200 87.7182V51.4293C1197.27 50.5887 1194.55 50.3794 1190.98 50.3794C1180.29 50.3794 1167.29 54.9955 1159.52 68.8471H1159.11L1155.34 51.6391H1124.31V168.955Z" fill="#0185FF"/>
</g>
</g>
<defs>
<clipPath id="clip0_648_668">
<rect width="1200" height="213.037" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -2,13 +2,13 @@ import { PresenceUpdateStatus } from "@spacebarchat/spacebar-api-types/v9";
import { observer } from "mobx-react-lite";
import React from "react";
import styled from "styled-components";
import { PopoutContext } from "../contexts/PopoutContext";
import AccountStore from "../stores/AccountStore";
import { useAppStore } from "../stores/AppStore";
import Presence from "../stores/objects/Presence";
import User from "../stores/objects/User";
import Container from "./Container";
import UserProfilePopout from "./UserProfilePopout";
import Floating from "./floating/Floating";
import FloatingTrigger from "./floating/FloatingTrigger";
const Wrapper = styled(Container)<{ size: number; hasClick?: boolean }>`
width: ${(props) => props.size}px;
@ -33,6 +33,12 @@ const StatusDot = styled.span<{ color: string; width?: number; height?: number }
height: ${(props) => props.height ?? 10}px;
`;
function Yes(onClick: React.MouseEventHandler<HTMLDivElement>) {
return ({ children }: { children: React.ReactNode }) => {
return <div onClick={onClick}>{children}</div>;
};
}
interface Props {
user?: User | AccountStore;
size?: number;
@ -44,50 +50,47 @@ interface Props {
width?: number;
height?: number;
};
showPresence?: boolean;
}
function Avatar(props: Props) {
const app = useAppStore();
const popoutContext = React.useContext(PopoutContext);
const ref = React.useRef<HTMLDivElement>(null);
const user = props.user ?? app.account;
if (!user) return null;
const openPopout = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
if (!rect) return;
popoutContext.open({
element: <UserProfilePopout user={user} presence={props.presence} />,
position: rect,
placement: props.popoutPlacement,
});
};
const clickProp = props.onClick === null ? {} : { onClick: props.onClick ?? openPopout };
const Base = props.onClick ? Yes(props.onClick) : FloatingTrigger;
return (
<Wrapper size={props.size ?? 32} style={props.style} ref={ref} hasClick={props.onClick !== null} {...clickProp}>
<img
style={{
borderRadius: "50%",
}}
src={user.avatarUrl}
width={props.size ?? 32}
height={props.size ?? 32}
loading="eager"
/>
{props.presence && props.presence.status !== PresenceUpdateStatus.Offline && (
<StatusDot color={app.theme.getStatusColor(props.presence.status)} {...props.statusDotStyle} />
)}
</Wrapper>
<Floating
placement="right-start"
type="userPopout"
props={{
user: user as unknown as User,
}}
>
<Base>
<Wrapper size={props.size ?? 32} style={props.style} ref={ref} hasClick={props.onClick !== null}>
<img
style={{
borderRadius: "50%",
}}
src={user.avatarUrl}
width={props.size ?? 32}
height={props.size ?? 32}
loading="eager"
/>
{props.showPresence && (
<StatusDot
color={app.theme.getStatusColor(props.presence?.status ?? PresenceUpdateStatus.Offline)}
{...props.statusDotStyle}
/>
)}
</Wrapper>
</Base>
</Floating>
);
}

View File

@ -1,94 +1,102 @@
import styled from "styled-components";
// Adapted from https://github.com/revoltchat/components/blob/master/src/components/design/atoms/inputs/Button.tsx
interface Props {
variant?: "primary" | "secondary" | "danger" | "success" | "warning";
outlined?: boolean;
import styled, { css } from "styled-components";
export interface Props {
readonly compact?: boolean | "icon";
palette?: "primary" | "secondary" | "success" | "warning" | "danger" | "accent" | "link";
size?: "small" | "medium" | "large";
grow?: boolean;
readonly disabled?: boolean;
}
export default styled.button<Props>`
background: ${(props) => {
if (props.outlined) return "transparent";
switch (props.variant) {
case "primary":
return "var(--primary)";
case "secondary":
return "var(--secondary)";
case "danger":
return "var(--danger)";
case "success":
return "var(--success)";
case "warning":
return "var(--warning)";
default:
return "var(--primary)";
}
}};
border: ${(props) => {
if (!props.outlined) return "none";
switch (props.variant) {
case "primary":
return "1px solid var(--primary)";
case "secondary":
return "1px solid var(--secondary)";
case "danger":
return "1px solid var(--danger)";
case "success":
return "1px solid var(--success)";
case "warning":
return "1px solid var(--warning)";
default:
return "1px solid var(--primary)";
}
}};
color: var(--text);
padding: 8px 16px;
padding: 2px 16px;
border-radius: 8px;
font-size: 13px;
font-size: 14px;
font-weight: var(--font-weight-medium);
cursor: pointer;
outline: none;
border: none;
transition: background 0.2s ease-in-out;
pointer-events: ${(props) => (props.disabled ? "none" : null)};
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
opacity: ${(props) => (props.disabled ? 0.5 : 1)}
font-weight: var(--font-weight-bold);
height: ${(props) => {
switch (props.size) {
default:
case "small":
return "32px;";
case "medium":
return "40px";
case "large":
return "45px";
}
}};
min-height: ${(props) => {
switch (props.size) {
default:
case "small":
return "32px;";
case "medium":
return "40px";
case "large":
return "45px";
}
}};
min-width: ${(props) => {
if (props.grow) return "auto";
switch (props.size) {
default:
case "small":
return "96px";
case "medium":
return "96px";
case "large":
return "130px";
}
}};
&:hover {
background: ${(props) => {
switch (props.variant) {
case "primary":
return "var(--primary-light)";
case "secondary":
return "var(--secondary-light)";
case "danger":
return "var(--danger-light)";
case "success":
return "var(--success-light)";
case "warning":
return "var(--warning-light)";
default:
return "var(--primary-light)";
}
}};
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
}
${(props) => {
if (!props.palette) props.palette = "primary";
switch (props.palette) {
case "primary":
case "secondary":
case "success":
case "warning":
case "danger":
case "accent":
return css`
background: var(--${props.palette});
&:active {
background: ${(props) => {
switch (props.variant) {
case "primary":
return "var(--primary-dark)";
case "secondary":
return "var(--secondary-dark)";
case "danger":
return "var(--danger-dark)";
case "success":
return "var(--success-dark)";
case "warning":
return "var(--warning-dark)";
default:
return "var(--primary-dark)";
}
}};
}
&:hover {
filter: brightness(1.2);
}
&:active {
filter: brightness(0.8);
}
&:disabled {
filter: brightness(0.7);
}
`;
case "link":
return css`
background: transparent;
&:hover {
text-decoration: underline;
}
&:active {
filter: brightness(0.8);
}
&:disabled {
filter: brightness(0.7);
}
`;
}
}}
`;

View File

@ -1,13 +1,11 @@
import { StackedModalProps, useModals } from "@mattjennings/react-modal-stack";
import { observer } from "mobx-react-lite";
import React, { ComponentType } from "react";
import React, { useEffect } from "react";
import styled from "styled-components";
import { ContextMenuContext } from "../contexts/ContextMenuContext";
import { useAppStore } from "../stores/AppStore";
import { IContextMenuItem } from "./ContextMenuItem";
import Icon, { IconProps } from "./Icon";
import { SectionHeader } from "./SectionHeader";
import LeaveServerModal from "./modals/LeaveServerModal";
import Floating from "./floating/Floating";
import FloatingTrigger from "./floating/FloatingTrigger";
const Wrapper = styled(SectionHeader)`
background-color: var(--background-secondary);
@ -29,64 +27,18 @@ const HeaderText = styled.header`
function ChannelHeader() {
const app = useAppStore();
const contextMenu = React.useContext(ContextMenuContext);
const { openModal } = useModals();
const [contextMenuItems, setContextMenuItems] = React.useState<IContextMenuItem[]>([]);
const [isOpen, setOpen] = React.useState(false);
const [icon, setIcon] = React.useState<IconProps["icon"]>("mdiChevronDown");
React.useEffect(() => {
if (app.activeGuild && app.activeGuild.ownerId !== app.account?.id) {
setContextMenuItems([
{
label: "Leave Server",
color: "var(--danger)",
onClick: async () => {
openModal(LeaveServerModal as ComponentType<StackedModalProps>, {
guild: app.activeGuild,
});
},
iconProps: {
icon: "mdiLocationExit",
color: "var(--danger)",
},
hover: {
color: "var(--text)",
backgroundColor: "var(--danger)",
},
},
]);
} else {
setContextMenuItems([]);
}
}, [app.activeGuild]);
const onOpenChange = (open: boolean) => {
setOpen(open);
};
function openMenu(e: React.MouseEvent<HTMLDivElement>) {
e.stopPropagation();
if (contextMenu.visible) {
// "toggles" the menu
contextMenu.close();
setIcon("mdiChevronDown");
return;
}
const horizontalPadding = 5;
const verticalPadding = 10;
contextMenu.open({
position: {
x: e.currentTarget.offsetLeft + horizontalPadding, // centers the menu under the header
y: e.currentTarget.offsetHeight + horizontalPadding, // add a slight gap between the header and the menu
},
items: contextMenuItems,
style: {
width: e.currentTarget.clientWidth - verticalPadding, // adds "margin" to the left and right of the menu
boxSizing: "border-box",
},
});
setIcon("mdiClose");
}
useEffect(() => {
if (isOpen) setIcon("mdiClose");
else setIcon("mdiChevronDown");
}, [isOpen]);
if (app.activeGuildId === "@me") {
return (
@ -106,10 +58,14 @@ function ChannelHeader() {
if (!app.activeGuild) return null;
return (
<Wrapper onClick={openMenu}>
<HeaderText>{app.activeGuild.name}</HeaderText>
<Icon icon={icon} size="20px" color="var(--text)" />
</Wrapper>
<Floating type="guild" open={isOpen} onOpenChange={onOpenChange} props={{ guild: app.activeGuild! }}>
<FloatingTrigger>
<Wrapper>
<HeaderText>{app.activeGuild.name}</HeaderText>
<Icon icon={icon} size="20px" color="var(--text)" />
</Wrapper>
</FloatingTrigger>
</Floating>
);
}

View File

@ -1,13 +1,11 @@
import { useModals } from "@mattjennings/react-modal-stack";
import React from "react";
import React, { useContext } from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import Channel from "../../stores/objects/Channel";
import { IContextMenuItem } from "../ContextMenuItem";
import Icon from "../Icon";
import Tooltip from "../Tooltip";
import CreateInviteModal from "../modals/CreateInviteModal";
import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger";
const ListItem = styled.div<{ isCategory?: boolean }>`
padding: ${(props) => (props.isCategory ? "16px 8px 0 0" : "1px 8px 0 0")};
@ -45,32 +43,8 @@ interface Props {
function ChannelListItem({ channel, isCategory, active }: Props) {
const navigate = useNavigate();
const contextMenu = useContext(ContextMenuContext);
const { openModal } = useModals();
const contextMenu = React.useContext(ContextMenuContext);
const [contextMenuItems, setContextMenuItems] = React.useState<IContextMenuItem[]>([
{
index: 1,
label: "Copy Channel ID",
onClick: () => {
navigator.clipboard.writeText(channel.id);
},
iconProps: {
icon: "mdiIdentifier",
},
},
{
index: 0,
label: "Create Channel Invite",
onClick: () => {
openModal(CreateInviteModal, { guild_id: channel.guildId!, channel_id: channel.id });
},
iconProps: {
icon: "mdiAccountPlus",
},
},
]);
const [hovered, setHovered] = React.useState(false);
return (
@ -83,7 +57,8 @@ function ChannelListItem({ channel, isCategory, active }: Props) {
navigate(`/channels/${channel.guildId}/${channel.id}`);
}}
onContextMenu={(e) => contextMenu.open2(e, contextMenuItems)}
ref={contextMenu.setReferenceElement}
onContextMenu={(e) => contextMenu.onContextMenu(e, { type: "channel", channel })}
>
<Wrapper
isCategory={isCategory}
@ -123,18 +98,27 @@ function ChannelListItem({ channel, isCategory, active }: Props) {
</Text>
</div>
{isCategory && (
<Tooltip title="Create Channel" placement="top">
<span>
<Icon
icon="mdiPlus"
size="18px"
style={{
marginLeft: "auto",
}}
color={hovered ? "var(--text)" : "var(--text-secondary)"}
/>
</span>
</Tooltip>
<Floating
placement="top"
type="tooltip"
offset={10}
props={{
content: <span>Create Channel</span>,
}}
>
<FloatingTrigger>
<span>
<Icon
icon="mdiPlus"
size="18px"
style={{
marginLeft: "auto",
}}
color={hovered ? "var(--text)" : "var(--text-secondary)"}
/>
</span>
</FloatingTrigger>
</Floating>
)}
</Wrapper>
</ListItem>

View File

@ -3,7 +3,8 @@
import React from "react";
import styled from "styled-components";
import Tooltip from "./Tooltip";
import Floating from "./floating/Floating";
import FloatingTrigger from "./floating/FloatingTrigger";
const Actions = styled.div`
position: absolute;
@ -57,9 +58,17 @@ function CodeBlock(props: Props) {
}}
>
<Actions>
<Tooltip title="Copy to Clipboard" placement="top">
<a onClick={onCopy}>{text}</a>
</Tooltip>
<Floating
placement="top"
type="tooltip"
props={{
content: <span>"Copy to Clipboard</span>,
}}
>
<FloatingTrigger>
<a onClick={onCopy}>{text}</a>
</FloatingTrigger>
</Floating>
</Actions>
{props.children}
</pre>

View File

@ -1,56 +0,0 @@
import React from "react";
import { ContextMenuOpenProps } from "../contexts/ContextMenuContext";
import Container from "./Container";
import ContextMenuItem, { IContextMenuItem } from "./ContextMenuItem";
interface Props {
open: (props: ContextMenuOpenProps) => void;
close: () => void;
visible: boolean;
position: {
x: number;
y: number;
};
items: IContextMenuItem[];
style?: React.CSSProperties;
}
function ContextMenu({ position, close, items, style }: Props) {
// Close the context menu when the user clicks outside of it
React.useEffect(() => {
const listener = () => {
close();
};
document.addEventListener("click", listener);
return () => {
document.removeEventListener("click", listener);
};
}, []);
return (
<Container
onBlur={close}
style={{
...style,
position: "absolute",
minWidth: "10vw",
// maxWidth: "20vw",
borderRadius: 4,
zIndex: 4,
padding: "6px 8px",
top: position.y,
left: position.x,
}}
>
{items
.filter((a) => a.visible !== false)
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
.map((item, index) => {
return <ContextMenuItem key={index} item={item} close={close} index={index} />;
})}
</Container>
);
}
export default ContextMenu;

View File

@ -1,84 +0,0 @@
import React from "react";
import styled from "styled-components";
import Container from "./Container";
import Icon, { IconProps } from "./Icon";
export interface IContextMenuItem {
index?: number;
label: string;
color?: string;
onClick: React.MouseEventHandler<HTMLDivElement>;
iconProps?: IconProps;
hover?: {
color?: string;
backgroundColor?: string;
};
visible?: boolean;
}
const ContextMenuContainer = styled(Container)`
border-radius: 4px;
min-height: 32px;
cursor: pointer;
`;
// we handle the hover state ourselves to prevent "lag" with the icon color
const Wrapper = styled(Container)<{ hover?: IContextMenuItem["hover"]; hovered?: boolean }>`
border-radius: 4px;
padding: 6px 8px;
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
justify-content: space-between;
align-items: center;
color: ${(props) => (props.hovered ? props.hover?.color ?? "var(--text)" : props.color ?? "var(--text)")};
background-color: ${(props) => (props.hovered ? props.hover?.backgroundColor ?? "var(--primary)" : "transparent")};
`;
interface Props {
item: IContextMenuItem;
index: number;
close: () => void;
}
function ContextMenuItem({ item, index, close }: Props) {
const [isHovered, setIsHovered] = React.useState(false);
return (
<ContextMenuContainer
key={index}
onClick={async (e) => {
await item.onClick(e);
close();
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Wrapper hover={item.hover} hovered={isHovered} color={item.color}>
<div
style={{
// color: item.color ?? "var(--text)",
fontWeight: 500,
fontSize: "14px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.label}
</div>
{item.iconProps && (
<Icon
{...item.iconProps}
size={item.iconProps.size ?? "20px"}
color={isHovered ? item.hover?.color ?? "var(--text)" : item.iconProps.color ?? "var(--text)"}
/>
)}
</Wrapper>
</ContextMenuContainer>
);
}
export default ContextMenuItem;

View File

@ -1,19 +1,18 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { CDNRoutes, ChannelType, ImageFormat } from "@spacebarchat/spacebar-api-types/v9";
import { observer } from "mobx-react-lite";
import React from "react";
import React, { useContext } from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { ContextMenuContext } from "../contexts/ContextMenuContext";
import useLogger from "../hooks/useLogger";
import { useAppStore } from "../stores/AppStore";
import Guild from "../stores/objects/Guild";
import { Permissions } from "../utils/Permissions";
import REST from "../utils/REST";
import Container from "./Container";
import { IContextMenuItem } from "./ContextMenuItem";
import SidebarPill, { PillType } from "./SidebarPill";
import Tooltip from "./Tooltip";
import CreateInviteModal from "./modals/CreateInviteModal";
import Floating from "./floating/Floating";
import FloatingTrigger from "./floating/FloatingTrigger";
export const GuildSidebarListItem = styled.div`
position: relative;
@ -48,37 +47,14 @@ interface Props {
* List item for use in the guild sidebar
*/
function GuildItem({ guild, active }: Props) {
const logger = useLogger("GuildItem");
const app = useAppStore();
const navigate = useNavigate();
const { openModal } = useModals();
const contextMenu = useContext(ContextMenuContext);
const [pillType, setPillType] = React.useState<PillType>("none");
const [isHovered, setHovered] = React.useState(false);
const contextMenu = React.useContext(ContextMenuContext);
const [contextMenuItems, setContextMenuItems] = React.useState<IContextMenuItem[]>([
{
index: 1,
label: "Copy Guild ID",
onClick: () => {
navigator.clipboard.writeText(guild.id);
},
iconProps: {
icon: "mdiIdentifier",
},
},
{
index: 0,
label: "Create Invite",
onClick: () => {
openModal(CreateInviteModal, { guild_id: guild.id });
},
iconProps: {
icon: "mdiAccountPlus",
},
},
]);
React.useEffect(() => {
if (app.activeChannelId && app.activeGuildId === guild.id) return setPillType("active");
else if (isHovered) return setPillType("hover");
@ -95,36 +71,48 @@ function GuildItem({ guild, active }: Props) {
};
return (
<GuildSidebarListItem onContextMenu={(e) => contextMenu.open2(e, contextMenuItems)}>
<GuildSidebarListItem
ref={contextMenu.setReferenceElement}
onContextMenu={(e) => contextMenu.onContextMenu(e, { type: "guild", guild })}
>
<SidebarPill type={pillType} />
<Tooltip title={guild.name} placement="right">
<Wrapper
onClick={doNavigate}
active={active}
hasImage={!!guild?.icon}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{guild.icon ? (
<img
src={REST.makeCDNUrl(CDNRoutes.guildIcon(guild.id, guild?.icon, ImageFormat.PNG))}
width={48}
height={48}
loading="lazy"
/>
) : (
<span
style={{
fontSize: "18px",
fontWeight: "bold",
cursor: "pointer",
}}
>
{guild?.acronym}
</span>
)}
</Wrapper>
</Tooltip>
<Floating
placement="right"
type="tooltip"
offset={20}
props={{
content: <span>{guild.name}</span>,
}}
>
<FloatingTrigger>
<Wrapper
onClick={doNavigate}
active={active}
hasImage={!!guild?.icon}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{guild.icon ? (
<img
src={REST.makeCDNUrl(CDNRoutes.guildIcon(guild.id, guild?.icon, ImageFormat.PNG))}
width={48}
height={48}
loading="lazy"
/>
) : (
<span
style={{
fontSize: "18px",
fontWeight: "bold",
cursor: "pointer",
}}
>
{guild?.acronym}
</span>
)}
</Wrapper>
</FloatingTrigger>
</Floating>
</GuildSidebarListItem>
);
}

View File

@ -1,13 +1,12 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { observer } from "mobx-react-lite";
import React from "react";
import { useNavigate } from "react-router-dom";
import { AutoSizer, List, ListRowProps } from "react-virtualized";
import styled from "styled-components";
import { modalController } from "../controllers/modals";
import { useAppStore } from "../stores/AppStore";
import GuildItem, { GuildSidebarListItem } from "./GuildItem";
import SidebarAction from "./SidebarAction";
import AddServerModal from "./modals/AddServerModal";
const Container = styled.div`
display: flex;
@ -39,7 +38,6 @@ const Divider = styled.div`
function GuildSidebar() {
const app = useAppStore();
const { openModal } = useModals();
const navigate = useNavigate();
const { all } = app.guilds;
const itemCount = all.length + 3; // add the home button, divider, and add server button
@ -80,7 +78,9 @@ function GuildSidebar() {
color: "var(--success)",
}}
action={() => {
openModal(AddServerModal);
modalController.push({
type: "add_server",
});
}}
margin={false}
disablePill

View File

@ -1,18 +1,17 @@
import { PresenceUpdateStatus } from "@spacebarchat/spacebar-api-types/v9";
import React from "react";
import { useContext } from "react";
import styled from "styled-components";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import { PopoutContext } from "../../contexts/PopoutContext";
import { useAppStore } from "../../stores/AppStore";
import GuildMember from "../../stores/objects/GuildMember";
import ContextMenus from "../../utils/ContextMenus";
import Avatar from "../Avatar";
import { IContextMenuItem } from "../ContextMenuItem";
import UserProfilePopout from "../UserProfilePopout";
import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger";
const ListItem = styled.div<{ isCategory?: boolean }>`
const ListItem = styled(FloatingTrigger)<{ isCategory?: boolean }>`
padding: ${(props) => (props.isCategory ? "16px 8px 0 0" : "1px 8px 0 0")};
cursor: pointer;
user-select: none;
`;
const Container = styled.div`
@ -64,41 +63,36 @@ interface Props {
function MemberListItem({ item }: Props) {
const app = useAppStore();
const popoutContext = React.useContext(PopoutContext);
const contextMenu = React.useContext(ContextMenuContext);
const [contextMenuItems, setContextMenuItems] = React.useState<IContextMenuItem[]>([
...ContextMenus.User(item.user!),
...ContextMenus.Member(app.account!, item, item.guild!),
]);
const presence = app.presences.get(item.guild.id)?.get(item.user!.id);
const presence = app.presences.get(item.user!.id);
const contextMenu = useContext(ContextMenuContext);
return (
<ListItem
key={item.user?.id}
onContextMenu={(e) => contextMenu.open2(e, contextMenuItems)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
popoutContext.open({
element: <UserProfilePopout user={item.user!} presence={presence} member={item} />,
position: e.currentTarget.getBoundingClientRect(),
placement: "right",
});
<Floating
placement="right-start"
type="userPopout"
offset={20}
props={{
user: item.user!,
member: item,
}}
>
<Container>
<Wrapper offline={presence?.status === PresenceUpdateStatus.Offline}>
<AvatarWrapper>
<Avatar user={item.user!} size={32} presence={presence} />
</AvatarWrapper>
<TextWrapper>
<Text color={item.roleColor}>{item.nick ?? item.user?.username}</Text>
</TextWrapper>
</Wrapper>
</Container>
</ListItem>
<ListItem
key={item.user?.id}
ref={contextMenu.setReferenceElement}
onContextMenu={(e) => contextMenu.onContextMenu(e, { type: "user", user: item.user!, member: item })}
>
<Container>
<Wrapper offline={presence?.status === PresenceUpdateStatus.Offline}>
<AvatarWrapper>
<Avatar user={item.user!} size={32} presence={presence} showPresence />
</AvatarWrapper>
<TextWrapper>
<Text color={item.roleColor}>{item.nick ?? item.user?.username}</Text>
</TextWrapper>
</Wrapper>
</Container>
</ListItem>
</Floating>
);
}

View File

@ -1,145 +0,0 @@
import React from "react";
import Measure, { BoundingRect, ContentRect } from "react-measure";
import { PopoutOpenProps } from "../contexts/PopoutContext";
const OFFSET = 10;
function isRectZero(rect: BoundingRect) {
return (
rect.bottom === 0 &&
rect.left === 0 &&
rect.right === 0 &&
rect.top === 0 &&
rect.width === 0 &&
rect.height === 0
);
}
interface Props {
open: (props: PopoutOpenProps) => void;
close: () => void;
position: DOMRect;
element: React.ReactNode;
isOpen: boolean;
placement?: "left" | "right" | "top" | "bottom";
}
function PopoutRenderer({ position, element, placement, close }: Props) {
const [rect, setRect] = React.useState<ContentRect>({});
const [positionStyle, setPositionStyle] = React.useState<React.CSSProperties>({
visibility: "hidden",
});
React.useEffect(() => {
const listener = () => {
close();
};
document.addEventListener("click", listener);
return () => {
document.removeEventListener("click", listener);
};
}, []);
React.useEffect(() => {
if (rect.bounds && !isRectZero(rect.bounds)) {
switch (placement) {
default:
case "right": {
let x = position.left + position.width + OFFSET;
let y = position.top;
if (x + rect.bounds.width > window.innerWidth) {
x = position.left - rect.bounds.width - OFFSET;
}
if (y + rect.bounds.height > window.innerHeight) {
y = window.innerHeight - rect.bounds.height - OFFSET;
}
setPositionStyle({
visibility: "visible",
top: y,
left: x,
});
break;
}
case "left": {
let x = position.left - rect.bounds.width - OFFSET;
let y = position.top;
if (x < 0) {
x = position.left + position.width + OFFSET;
}
if (y + rect.bounds.height > window.innerHeight) {
y = window.innerHeight - rect.bounds.height - OFFSET;
}
setPositionStyle({
visibility: "visible",
top: y,
left: x,
});
break;
}
case "top": {
// center x
let x = position.left - rect.bounds.width / 2 + position.width / 2;
let y = position.top - rect.bounds.height - OFFSET;
if (x + rect.bounds.width > window.innerWidth) {
x = window.innerWidth - rect.bounds.width - OFFSET;
}
if (y < 0) {
y = position.top + position.height + OFFSET;
}
setPositionStyle({
visibility: "visible",
top: y,
left: x,
});
break;
}
case "bottom": {
let x = position.left - position.width / 1;
let y = position.top + position.height + OFFSET;
if (x + rect.bounds.width > window.innerWidth) {
x = window.innerWidth - rect.bounds.width - OFFSET;
}
if (y + rect.bounds.height > window.innerHeight) {
y = window.innerHeight - rect.bounds.height - OFFSET;
}
setPositionStyle({
visibility: "visible",
top: y,
left: x,
});
break;
}
}
}
}, [rect, element]);
const handleResize = (contentRect: ContentRect) => setRect(contentRect);
return (
<div
onBlur={close}
style={{
position: "absolute",
zIndex: 100,
...positionStyle,
}}
>
<Measure bounds onResize={handleResize}>
{({ measureRef }) => (
<div
style={{
width: "fit-content",
height: "fit-content",
}}
ref={measureRef}
>
{element}
</div>
)}
</Measure>
</div>
);
}
export default PopoutRenderer;

View File

@ -4,7 +4,8 @@ import Container from "./Container";
import { GuildSidebarListItem } from "./GuildItem";
import Icon, { IconProps } from "./Icon";
import SidebarPill, { PillType } from "./SidebarPill";
import Tooltip from "./Tooltip";
import Floating from "./floating/Floating";
import FloatingTrigger from "./floating/FloatingTrigger";
const Wrapper = styled(Container)<{
margin?: boolean;
@ -60,25 +61,34 @@ function SidebarAction(props: Props) {
return (
<GuildSidebarListItem>
<SidebarPill type={pillType} />
<Tooltip title={props.tooltip} placement="right">
<Wrapper
onClick={props.action}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
margin={props.margin}
active={props.active}
useGreenColorScheme={props.useGreenColorScheme}
>
{props.image && <img {...props.image} loading="lazy" />}
{props.icon && (
<Icon
{...props.icon}
color={isHovered && props.useGreenColorScheme ? "var(--text)" : props.icon.color}
/>
)}
{props.label && <span>{props.label}</span>}
</Wrapper>
</Tooltip>
<Floating
placement="right"
type="tooltip"
offset={20}
props={{
content: <span>{props.tooltip}</span>,
}}
>
<FloatingTrigger>
<Wrapper
onClick={props.action}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
margin={props.margin}
active={props.active}
useGreenColorScheme={props.useGreenColorScheme}
>
{props.image && <img {...props.image} loading="lazy" />}
{props.icon && (
<Icon
{...props.icon}
color={isHovered && props.useGreenColorScheme ? "var(--text)" : props.icon.color}
/>
)}
{props.label && <span>{props.label}</span>}
</Wrapper>
</FloatingTrigger>
</Floating>
</GuildSidebarListItem>
);
}

View File

@ -1,21 +1,24 @@
import MuiTooltip, { TooltipProps as MuiTooltipProps, tooltipClasses } from "@mui/material/Tooltip";
import styled from "styled-components";
import { FloatingProps } from "./floating/Floating";
export default styled(({ className, ...props }: MuiTooltipProps) => (
<MuiTooltip {...props} arrow classes={{ popper: className }} />
))(() => ({
[`& .${tooltipClasses.popper}`]: {
maxWidth: 200,
borderRadius: 5,
},
[`& .${tooltipClasses.arrow}`]: {
color: "var(--background-tertiary)",
},
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: "var(--background-tertiary)",
fontSize: "14px",
padding: "8px 12px",
overflow: "hidden",
textOverflow: "ellipsis",
},
}));
const Container = styled.div`
background-color: var(--background-tertiary);
line-height: 16px;
box-sizing: border-box;
font-size: 14px;
padding: 8px 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
border-radius: 4px;
color: var(--text);
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5);
`;
function Tooltip(props: FloatingProps<"tooltip">) {
if (!props) return null;
return <Container aria-label={props.aria}>{props.content}</Container>;
}
export default Tooltip;

View File

@ -1,14 +1,11 @@
import { useModals } from "@mattjennings/react-modal-stack";
import React from "react";
import styled from "styled-components";
import { PopoutContext } from "../contexts/PopoutContext";
import { useAppStore } from "../stores/AppStore";
import User from "../stores/objects/User";
import Avatar from "./Avatar";
import Icon from "./Icon";
import IconButton from "./IconButton";
import Tooltip from "./Tooltip";
import UserProfilePopout from "./UserProfilePopout";
import SettingsModal from "./modals/SettingsModal";
import Floating from "./floating/Floating";
import FloatingTrigger from "./floating/FloatingTrigger";
const Section = styled.section`
flex: 0 0 auto;
@ -24,7 +21,7 @@ const Container = styled.div`
background-color: var(--background-secondary-alt);
`;
const AvatarWrapper = styled.div`
const AvatarWrapper = styled(FloatingTrigger)`
display: flex;
align-items: center;
min-width: 120px;
@ -72,49 +69,46 @@ const ActionsWrapper = styled.div`
function UserPanel() {
const app = useAppStore();
const popoutContext = React.useContext(PopoutContext);
const { openModal } = useModals();
const ref = React.useRef<HTMLDivElement>(null);
const openSettingsModal = () => {
openModal(SettingsModal);
};
const openPopout = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
if (!rect) return;
popoutContext.open({
element: <UserProfilePopout user={app.account!} />,
position: rect,
placement: "top",
});
};
const openSettingsModal = () => {};
return (
<Section ref={ref}>
<Container>
<AvatarWrapper onClick={openPopout}>
<Avatar popoutPlacement="top" onClick={null} />
<Name>
<Username>{app.account?.username}</Username>
<Subtext>#{app.account?.discriminator}</Subtext>
</Name>
</AvatarWrapper>
<Floating
placement="bottom"
type="userPopout"
props={{
user: app.account! as unknown as User,
}}
>
<Section>
<Container>
<AvatarWrapper>
<Avatar popoutPlacement="top" onClick={null} />
<Name>
<Username>{app.account?.username}</Username>
<Subtext>#{app.account?.discriminator}</Subtext>
</Name>
</AvatarWrapper>
<ActionsWrapper>
<Tooltip title="Settings">
<IconButton aria-label="settings" color="#fff" onClick={openSettingsModal}>
<Icon icon="mdiCog" size="20px" />
</IconButton>
</Tooltip>
</ActionsWrapper>
</Container>
</Section>
<ActionsWrapper>
<Floating
placement="top"
type="tooltip"
offset={10}
props={{
content: <span>Settings</span>,
}}
>
<FloatingTrigger>
<IconButton aria-label="settings" color="#fff" onClick={openSettingsModal}>
<Icon icon="mdiCog" size="20px" />
</IconButton>
</FloatingTrigger>
</Floating>
</ActionsWrapper>
</Container>
</Section>
</Floating>
);
}

View File

@ -0,0 +1,26 @@
// https://github.com/revoltchat/components/blob/master/src/components/common/animations.ts
import { keyframes } from "styled-components";
export const animationFadeIn = keyframes`
0% {opacity: 0;}
70% {opacity: 0;}
100% {opacity: 1;}
`;
export const animationFadeOut = keyframes`
0% {opacity: 1;}
70% {opacity: 0;}
100% {opacity: 0;}
`;
export const animationZoomIn = keyframes`
0% {transform: scale(0.5);}
98% {transform: scale(1.01);}
100% {transform: scale(1);}
`;
export const animationZoomOut = keyframes`
0% {transform: scale(1);}
100% {transform: scale(0.5);}
`;

View File

@ -0,0 +1,47 @@
// loosely based on https://github.com/revoltchat/frontend/blob/master/components/app/menus/UserContextMenu.tsx
import Channel from "../../stores/objects/Channel";
import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "./ContextMenu";
interface MenuProps {
channel: Channel;
}
function ChannelContextMenu({ channel }: MenuProps) {
/**
* Copy id to clipboard
*/
function copyId() {
navigator.clipboard.writeText(channel.id);
}
return (
<ContextMenu>
{channel.hasPermission("MANAGE_CHANNELS") && (
<>
<ContextMenuButton disabled>Edit Channel</ContextMenuButton>
<ContextMenuButton disabled destructive>
Delete Channel
</ContextMenuButton>
<ContextMenuDivider />
</>
)}
<ContextMenuButton
icon="mdiIdentifier"
iconProps={{
style: {
filter: "invert(100%)",
background: "black",
borderRadius: "4px",
},
}}
onClick={copyId}
>
Copy Channel ID
</ContextMenuButton>
</ContextMenu>
);
}
export default ChannelContextMenu;

View File

@ -0,0 +1,76 @@
// modified from https://github.com/revoltchat/frontend/blob/master/components/app/menus/ContextMenu.tsx
// changed some styling
import { ComponentProps } from "react";
import styled from "styled-components";
import Icon, { IconProps } from "../Icon";
export const ContextMenu = styled.div`
display: flex;
flex-direction: column;
padding: 6px 8px;
min-width: 200px;
max-width: 300px;
overflow: hidden;
border-radius: 4px;
background: var(--background-tertiary);
color: var(--text);
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5);
`;
export const ContextMenuDivider = styled.div`
height: 1px;
margin: 4px;
background: var(--text-disabled);
`;
export const ContextMenuItem = styled("button")`
display: block;
padding: 6px 8px;
border-radius: 4px;
font-size: 14px;
margin: 2px 0;
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
// remove default button styles
border: none;
background: none;
color: inherit;
outline: none;
`;
const ButtonBase = styled(ContextMenuItem)<{ destructive?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
> span {
margin-top: 1px;
}
&:hover {
background: ${(props) => (props.destructive ? "var(--danger)" : "var(--primary)")};
${(props) => (props.destructive ? `color: var(--text)` : "")}
}
${(props) => (props.destructive ? `fill: var(--danger); color: var(--danger)` : "")}
`;
type ButtonProps = ComponentProps<typeof ContextMenuItem> & {
icon?: IconProps["icon"];
iconProps?: Omit<IconProps, "icon" | "size">;
destructive?: boolean;
};
export function ContextMenuButton({ icon, children, iconProps, ...props }: ButtonProps) {
return (
<ButtonBase {...props}>
<span>{children}</span>
{icon && <Icon icon={icon} {...iconProps} size="18px" />}
</ButtonBase>
);
}

View File

@ -0,0 +1,59 @@
// loosely based on https://github.com/revoltchat/frontend/blob/master/components/app/menus/UserContextMenu.tsx
import { modalController } from "../../controllers/modals";
import { useAppStore } from "../../stores/AppStore";
import Guild from "../../stores/objects/Guild";
import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "./ContextMenu";
interface MenuProps {
guild: Guild;
}
function GuildContextMenu({ guild }: MenuProps) {
const app = useAppStore();
const isNotOwner = guild.ownerId !== app.account!.id;
/**
* Copy id to clipboard
*/
function copyId() {
navigator.clipboard.writeText(guild.id);
}
/**
* Leave guild
*/
function leaveGuild() {
modalController.push({
type: "leave_server",
target: guild,
});
}
return (
<ContextMenu>
{isNotOwner && (
<>
<ContextMenuButton destructive onClick={leaveGuild}>
Leave Guild
</ContextMenuButton>
<ContextMenuDivider />
</>
)}
<ContextMenuButton
icon="mdiIdentifier"
iconProps={{
style: {
filter: "invert(100%)",
background: "black",
borderRadius: "4px",
},
}}
onClick={copyId}
>
Copy Guild ID
</ContextMenuButton>
</ContextMenu>
);
}
export default GuildContextMenu;

View File

@ -0,0 +1,64 @@
import { modalController } from "../../controllers/modals";
import { useAppStore } from "../../stores/AppStore";
import Message from "../../stores/objects/Message";
import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "./ContextMenu";
interface MenuProps {
message: Message;
}
function MessageContextMenu({ message }: MenuProps) {
const app = useAppStore();
function copyRaw() {
navigator.clipboard.writeText(message.content);
}
async function deleteMessage(e: MouseEvent) {
if (e.shiftKey) {
await message.delete();
} else {
modalController.push({
type: "delete_message",
target: message as Message,
});
}
}
function copyId() {
navigator.clipboard.writeText(message.id);
}
return (
<ContextMenu>
<ContextMenuButton icon="mdiReply" disabled>
Reply
</ContextMenuButton>
<ContextMenuButton icon="mdiContentCopy" onClick={copyRaw}>
Copy Raw Text
</ContextMenuButton>
<ContextMenuDivider />
{message.channel.hasPermission("MANAGE_MESSAGES") && message instanceof Message && (
<ContextMenuButton icon="mdiDelete" destructive onClick={deleteMessage}>
Delete Message
</ContextMenuButton>
)}
<ContextMenuDivider />
<ContextMenuButton
icon="mdiIdentifier"
onClick={copyId}
iconProps={{
style: {
filter: "invert(100%)",
background: "black",
borderRadius: "4px",
},
}}
>
Copy Message ID
</ContextMenuButton>
</ContextMenu>
);
}
export default MessageContextMenu;

View File

@ -0,0 +1,86 @@
// loosely based on https://github.com/revoltchat/frontend/blob/master/components/app/menus/UserContextMenu.tsx
import { modalController } from "../../controllers/modals";
import GuildMember from "../../stores/objects/GuildMember";
import User from "../../stores/objects/User";
import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "./ContextMenu";
interface MenuProps {
user: User;
member?: GuildMember;
}
function UserContextMenu({ user, member }: MenuProps) {
/**
* Copy user id to clipboard
*/
function copyId() {
navigator.clipboard.writeText(user.id);
}
/**
* Open kick modal
*/
function kick() {
if (!member) return;
modalController.push({
type: "kick_member",
target: member,
});
}
/**
* Open ban modal
*/
function ban() {
if (!member) return;
modalController.push({
type: "ban_member",
target: member,
});
}
return (
<ContextMenu>
<ContextMenuButton disabled>Profile</ContextMenuButton>
<ContextMenuButton disabled>Mention</ContextMenuButton>
<ContextMenuButton disabled>Message</ContextMenuButton>
<ContextMenuDivider />
{member && <ContextMenuButton disabled>Change Nickname</ContextMenuButton>}
<ContextMenuButton disabled>Add Friend</ContextMenuButton>
<ContextMenuButton disabled>Block</ContextMenuButton>
<ContextMenuDivider />
{member && (
<>
<ContextMenuButton destructive onClick={kick}>
Kick {member?.nick ?? user.username}
</ContextMenuButton>
<ContextMenuButton destructive onClick={ban}>
Ban {member?.nick ?? user.username}
</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton disabled icon="mdiChevronRight">
Roles
</ContextMenuButton>
<ContextMenuDivider />
</>
)}
<ContextMenuButton
icon="mdiIdentifier"
iconProps={{
style: {
filter: "invert(100%)",
background: "black",
borderRadius: "4px",
},
}}
onClick={copyId}
>
Copy User ID
</ContextMenuButton>
</ContextMenu>
);
}
export default UserContextMenu;

View File

@ -0,0 +1,95 @@
import { FloatingArrow, FloatingPortal, Placement } from "@floating-ui/react";
import { motion } from "framer-motion";
import { FloatingContext } from "../../contexts/FloatingContext";
import useFloating from "../../hooks/useFloating";
import Guild from "../../stores/objects/Guild";
import GuildMember from "../../stores/objects/GuildMember";
import User from "../../stores/objects/User";
import Tooltip from "../Tooltip";
import GuildMenuPopout from "./GuildMenuPopout";
import UserProfilePopout from "./UserProfilePopout";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Components = Record<string, React.FC<any>>;
const components: Components = {
userPopout: UserProfilePopout,
tooltip: Tooltip,
guild: GuildMenuPopout,
};
export type FloatingOptions = {
initialOpen?: boolean;
placement?: Placement;
offset?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
} & (
| {
type: "userPopout";
props: {
user: User;
member?: GuildMember;
};
}
| {
type: "tooltip";
props: {
content: JSX.Element;
aria?: string;
};
}
| {
type: "guild";
props: {
guild: Guild;
};
}
);
export type FloatingProps<T extends FloatingOptions["type"]> = (FloatingOptions & {
type: T;
})["props"];
function Floating({
type,
children,
props,
...restOptions
}: {
children: React.ReactNode;
} & FloatingOptions) {
const floating = useFloating({ type, ...restOptions });
const Component = components[type];
return (
<FloatingContext.Provider value={floating}>
{children}
{Component && floating.open && (
<FloatingPortal>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, easing: [0.87, 0, 0.13, 1] }}
ref={floating.refs.setFloating}
style={{ ...floating.context.floatingStyles, zIndex: 1000, outline: "none" }}
{...floating.getFloatingProps()}
>
<Component {...props} />
{type === "tooltip" && (
<FloatingArrow
ref={floating.arrowRef}
context={floating.context}
fill="var(--background-tertiary)"
/>
)}
</motion.div>
</FloatingPortal>
)}
</FloatingContext.Provider>
);
}
export default Floating;

View File

@ -0,0 +1,35 @@
import { FloatingFocusManager, FloatingPortal, useMergeRefs } from "@floating-ui/react";
import { motion } from "framer-motion";
import React from "react";
import useFloatingContext from "../../hooks/useFloatingContext";
export default React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(function PopoverContent(
{ style, ...props },
propRef,
) {
const { context: floatingContext, ...context } = useFloatingContext();
const ref = useMergeRefs([context.refs.setFloating, propRef]);
if (!floatingContext.open) return null;
return (
<FloatingPortal>
<FloatingFocusManager context={floatingContext}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, easing: [0.87, 0, 0.13, 1] }}
>
<div
ref={ref}
style={{ ...context.floatingStyles, ...style, zIndex: 1000, outline: "none" }}
{...context.getFloatingProps(props)}
>
{props.children}
</div>
</motion.div>
</FloatingFocusManager>
</FloatingPortal>
);
});

View File

@ -0,0 +1,36 @@
import { useMergeRefs } from "@floating-ui/react";
import React from "react";
import useFloatingContext from "../../hooks/useFloatingContext";
interface PopoverTriggerProps {
children: React.ReactNode;
asChild?: boolean;
}
export default React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & PopoverTriggerProps>(
function FloatingTrigger({ children, asChild = false, ...props }, propRef) {
const context = useFloatingContext();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const childrenRef = (children as any).ref;
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]);
// `asChild` allows the user to pass any element as the anchor
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children,
context.getReferenceProps({
ref,
...props,
...children.props,
"data-state": context.open ? "open" : "closed",
}),
);
}
return (
<div ref={ref} data-state={context.open ? "open" : "closed"} {...context.getReferenceProps(props)}>
{children}
</div>
);
},
);

View File

@ -0,0 +1,53 @@
import styled from "styled-components";
import useLogger from "../../hooks/useLogger";
import { modalController } from "../../controllers/modals";
import { useAppStore } from "../../stores/AppStore";
import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "../contextMenus/ContextMenu";
const CustomContextMenu = styled(ContextMenu)`
width: 200px;
`;
function GuildMenuPopout() {
const { activeGuild } = useAppStore();
const logger = useLogger("GuildMenuPopout");
if (!activeGuild) {
logger.error("activeGuild is undefined");
return null;
}
function leaveGuild() {
modalController.push({
type: "leave_server",
target: activeGuild!,
});
}
return (
<CustomContextMenu>
<ContextMenuButton icon="mdiCog" disabled>
Server Settings
</ContextMenuButton>
<ContextMenuButton icon="mdiPlusCircle" disabled>
Create Channel
</ContextMenuButton>
<ContextMenuButton icon="mdiFolderPlus" disabled>
Create Channel
</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton icon="mdiBell" disabled>
Notification Settings
</ContextMenuButton>
<ContextMenuButton icon="mdiShieldLock" disabled>
Privacy Settings
</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton icon="mdiLocationExit" destructive onClick={leaveGuild}>
Leave Guild
</ContextMenuButton>
</CustomContextMenu>
);
}
export default GuildMenuPopout;

View File

@ -1,17 +1,18 @@
import styled from "styled-components";
import useLogger from "../../hooks/useLogger";
import GuildMember from "../../stores/objects/GuildMember";
import User from "../../stores/objects/User";
import Snowflake from "../../utils/Snowflake";
import Avatar from "../Avatar";
import { HorizontalDivider } from "../Divider";
import { CDNRoutes, ImageFormat } from "@spacebarchat/spacebar-api-types/v9";
import dayjs from "dayjs";
import styled from "styled-components";
import { ReactComponent as SpacebarLogoBlue } from "../assets/images/logo/Spacebar_Icon.svg";
import useLogger from "../hooks/useLogger";
import AccountStore from "../stores/AccountStore";
import GuildMember from "../stores/objects/GuildMember";
import Presence from "../stores/objects/Presence";
import User from "../stores/objects/User";
import REST from "../utils/REST";
import Snowflake from "../utils/Snowflake";
import Avatar from "./Avatar";
import { HorizontalDivider } from "./Divider";
import Tooltip from "./Tooltip";
import { ReactComponent as SpacebarLogoBlue } from "../../assets/images/logo/Spacebar_Icon.svg";
import { useAppStore } from "../../stores/AppStore";
import REST from "../../utils/REST";
import Floating from "./Floating";
import FloatingTrigger from "./FloatingTrigger";
const Container = styled.div`
background-color: #252525;
@ -22,6 +23,7 @@ const Container = styled.div`
max-height: 600px;
overflow: hidden;
box-shadow: 0 0 0 1px rgb(0 0 0 / 15%), 0 4px 8px rgb(0 0 0 / 15%);
color: var(--text);
`;
const Top = styled.div`
@ -36,10 +38,22 @@ const Bottom = styled.div`
border-radius: 4px;
margin: 0 16px 16px;
max-height: 340px;
gap: 8px;
& > :first-child {
padding: 12px 12px 0 12px;
}
& > :nth-child(n + 3) {
padding: 0 12px;
}
& > :last-child {
padding: 0 12px 12px 12px;
}
`;
const Section = styled.div`
padding: 12px;
display: flex;
justify-content: space-between;
display: flex;
@ -102,22 +116,57 @@ const AcronymText = styled.div`
white-space: nowrap;
`;
const RoleList = styled.div`
display: flex;
flex-wrap: wrap;
position: relative;
margin-top: 2px;
`;
const RolePillDot = styled.span<{ color?: string }>`
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: 50%;
padding: 0;
margin: 0 4px;
background-color: ${(props) => props.color ?? "var(--text-disabled)"};
`;
const RolePill = styled.div`
display: flex;
align-items: center;
font-size: 12px;
font-weight: var(--font-weight-medium);
background-color: var(--background-primary-alt);
border-radius: 12px;
box-sizing: border-box;
height: 22px;
margin: 0 4px 4px 0;
padding: 8px;
`;
const RoleName = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
`;
interface Props {
user?: User | AccountStore;
presence?: Presence;
user: User;
member?: GuildMember;
}
function UserProfilePopout({ user, presence, member }: Props) {
function UserProfilePopout({ user, member }: Props) {
const app = useAppStore();
const logger = useLogger("UserProfilePopout");
if (!user && !member?.user) {
logger.error("neither user, nor a valid member was provided");
return null;
}
user = user ?? member!.user!;
const id = user.id;
const { timestamp: createdAt } = Snowflake.deconstruct(id);
const presence = app.presences.get(user.id);
return (
<Container>
@ -126,9 +175,10 @@ function UserProfilePopout({ user, presence, member }: Props) {
style={{ margin: "22px 16px" }}
size={80}
onClick={(e) => {
// TODO: open profile modal
e.preventDefault();
e.stopPropagation();
// TODO: open profile modal
logger.debug("open profile modal");
}}
user={user}
presence={presence}
@ -136,6 +186,7 @@ function UserProfilePopout({ user, presence, member }: Props) {
width: 16,
height: 16,
}}
showPresence
/>
</Top>
<Bottom>
@ -159,11 +210,19 @@ function UserProfilePopout({ user, presence, member }: Props) {
<Section>
<Heading>Member Since</Heading>
<MemberSinceContainer>
<Tooltip title="Spacebar" placement="top">
<div>
<SpacebarLogoBlue width={16} height={16} style={{ borderRadius: "50%" }} />
</div>
</Tooltip>
<Floating
placement="top"
type="tooltip"
props={{
content: <span>Spacebar</span>,
}}
>
<FloatingTrigger>
<div>
<SpacebarLogoBlue width={16} height={16} style={{ borderRadius: "50%" }} />
</div>
</FloatingTrigger>
</Floating>
<MemberSinceText>{dayjs(createdAt).format("MMM D, YYYY")}</MemberSinceText>
{member && (
<>
@ -176,34 +235,56 @@ function UserProfilePopout({ user, presence, member }: Props) {
}}
/>
<Tooltip title={member.guild.name} placement="top">
{member.guild.icon ? (
<img
src={REST.makeCDNUrl(
CDNRoutes.guildIcon(
member.guild.id,
member.guild.icon,
ImageFormat.PNG,
),
)}
width={16}
height={16}
loading="lazy"
style={{
borderRadius: "50%",
}}
/>
) : (
<Acronym>
<AcronymText>{member.guild.acronym}</AcronymText>
</Acronym>
)}
</Tooltip>
<Floating
placement="top"
type="tooltip"
props={{
content: <span>{member.guild.name}</span>,
}}
>
<FloatingTrigger>
{member.guild.icon ? (
<img
src={REST.makeCDNUrl(
CDNRoutes.guildIcon(
member.guild.id,
member.guild.icon,
ImageFormat.PNG,
),
)}
width={16}
height={16}
loading="lazy"
style={{
borderRadius: "50%",
}}
/>
) : (
<Acronym>
<AcronymText>{member.guild.acronym}</AcronymText>
</Acronym>
)}
</FloatingTrigger>
</Floating>
<MemberSinceText>{dayjs(member.joined_at).format("MMM D, YYYY")}</MemberSinceText>
</>
)}
</MemberSinceContainer>
</Section>
{member && (
<Section>
<Heading>{member.roles.length ? "Roles" : "No Roles"}</Heading>
<RoleList>
{member.roles.map((x, i) => (
<RolePill key={i}>
<RolePillDot color={x.color} />
<RoleName>{x.name}</RoleName>
</RolePill>
))}
</RoleList>
</Section>
)}
</Bottom>
</Container>
);

View File

@ -1,13 +1,11 @@
import React, { memo } from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { PopoutContext } from "../../contexts/PopoutContext";
import { useAppStore } from "../../stores/AppStore";
import Channel from "../../stores/objects/Channel";
import Role from "../../stores/objects/Role";
import User from "../../stores/objects/User";
import { hexToRGB, rgbToHsl } from "../../utils/Utils";
import UserProfilePopout from "../UserProfilePopout";
const Container = styled.span<{ color?: string; withHover?: boolean }>`
padding: 0 2px;
@ -29,22 +27,12 @@ interface MentionProps {
}
function UserMention({ id }: MentionProps) {
const app = useAppStore();
const popoutContext = React.useContext(PopoutContext);
const [user, setUser] = React.useState<User | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
const click = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user || !ref.current) return;
const rect = ref.current.getBoundingClientRect();
popoutContext.open({
element: <UserProfilePopout user={user} />,
position: rect,
});
};
React.useEffect(() => {

View File

@ -1,7 +1,8 @@
import dayjs from "dayjs";
import { memo } from "react";
import styled from "styled-components";
import Tooltip from "../Tooltip";
import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger";
const Container = styled.div`
background-color: hsl(var(--background-tertiary-hsl) / 0.3);
@ -43,9 +44,17 @@ function Timestamp({ timestamp, style }: Props) {
return (
<Container>
<Tooltip title={date.format("dddd, MMMM MM, h:mm A")} placement="top">
<span>{value}</span>
</Tooltip>
<Floating
placement="top"
type="tooltip"
props={{
content: <span>{date.format("dddd, MMMM MM, h:mm A")}</span>,
}}
>
<FloatingTrigger>
<span>{value}</span>
</FloatingTrigger>
</Floating>
</Container>
);
}

View File

@ -80,16 +80,17 @@ const Content = observer((props: Props2) => {
function Chat() {
const app = useAppStore();
const logger = useLogger("Messages");
const { activeChannel, activeGuild, activeChannelId, activeGuildId } = app;
React.useEffect(() => {
if (!app.activeChannel || !app.activeGuild || app.activeChannelId === "@me") return;
if (!activeChannel || !activeGuild || activeChannelId === "@me") return;
runInAction(() => {
app.gateway.onChannelOpen(app.activeGuildId!, app.activeChannelId!);
app.gateway.onChannelOpen(activeGuildId!, activeChannelId!);
});
}, [app.activeChannel, app.activeGuild]);
}, [activeChannel, activeGuild]);
if (app.activeGuildId && app.activeGuildId === "@me") {
if (activeGuildId && activeGuildId === "@me") {
return (
<WrapperTwo>
<span>Home Section Placeholder</span>
@ -97,7 +98,7 @@ function Chat() {
);
}
if (!app.activeGuild || !app.activeChannel) {
if (!activeGuild || !activeChannel) {
return (
<WrapperTwo>
<span
@ -113,10 +114,26 @@ function Chat() {
);
}
if (!activeChannel.hasPermission("VIEW_CHANNEL")) {
return (
<WrapperTwo>
<span
style={{
color: "var(--text-secondary)",
fontSize: "1.5rem",
margin: "auto",
}}
>
You do not have permission to view this channel
</span>
</WrapperTwo>
);
}
return (
<WrapperTwo>
<ChatHeader channel={app.activeChannel} />
<Content channel={app.activeChannel} guild={app.activeGuild} />
<ChatHeader channel={activeChannel} />
<Content channel={activeChannel} guild={activeGuild} />
</WrapperTwo>
);
}

View File

@ -6,7 +6,8 @@ import { useAppStore } from "../../stores/AppStore";
import Channel from "../../stores/objects/Channel";
import Icon from "../Icon";
import { SectionHeader } from "../SectionHeader";
import Tooltip from "../Tooltip";
import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger";
const IconButton = styled.button`
margin: 0;
@ -120,13 +121,21 @@ function ActionItem({ icon, active, ariaLabel, tooltip, onClick }: ActionItemPro
const logger = useLogger("ChatHeader.tsx:ActionItem");
return (
<Tooltip title={tooltip}>
<IconWrapper>
<IconButton onClick={onClick}>
<CustomIcon $active={active} icon={icon} size="24px" aria-label={ariaLabel} />
</IconButton>
</IconWrapper>
</Tooltip>
<Floating
placement="bottom"
type="tooltip"
props={{
content: <span>{tooltip}</span>,
}}
>
<FloatingTrigger>
<IconWrapper>
<IconButton onClick={onClick}>
<CustomIcon $active={active} icon={icon} size="24px" aria-label={ariaLabel} />
</IconButton>
</IconWrapper>
</FloatingTrigger>
</Floating>
);
}

View File

@ -1,12 +1,10 @@
import { observer } from "mobx-react-lite";
import React, { memo } from "react";
import { memo, useContext } from "react";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import { useAppStore } from "../../stores/AppStore";
import { MessageLike } from "../../stores/objects/Message";
import { QueuedMessageStatus } from "../../stores/objects/QueuedMessage";
import ContextMenus from "../../utils/ContextMenus";
import Avatar from "../Avatar";
import { IContextMenuItem } from "../ContextMenuItem";
import Markdown from "../markdown/MarkdownRenderer";
import MessageAttachment from "./MessageAttachment";
import MessageAuthor from "./MessageAuthor";
@ -21,10 +19,7 @@ interface Props {
function Message({ message, header }: Props) {
const app = useAppStore();
const contextMenu = React.useContext(ContextMenuContext);
const [contextMenuItems, setContextMenuItems] = React.useState<IContextMenuItem[]>([
...ContextMenus.Message(app, message, app.account),
]);
const contextMenuContext = useContext(ContextMenuContext);
const guild = message.guild_id ? app.guilds.get(message.guild_id) : undefined;
const isEveryoneMentioned = "mention_everyone" in message && message.mention_everyone;
@ -37,8 +32,13 @@ function Message({ message, header }: Props) {
return (
<MessageBase
header={header}
onContextMenu={(e) => contextMenu.open2(e, contextMenuItems)}
mention={isEveryoneMentioned || isUserMentioned || isRoleMentioned}
onContextMenu={(e) =>
contextMenuContext.onContextMenu(e, {
type: "message",
message: message,
})
}
>
<MessageInfo>
{header ? (
@ -54,6 +54,7 @@ function Message({ message, header }: Props) {
<MessageDetails message={message} position="top" />
</span>
)}
<MessageContentText
sending={"status" in message && message.status === QueuedMessageStatus.SENDING}
failed={"status" in message && message.status === QueuedMessageStatus.FAILED}

View File

@ -1,17 +1,11 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { APIAttachment } from "@spacebarchat/spacebar-api-types/v9";
import React from "react";
import styled from "styled-components";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import useLogger from "../../hooks/useLogger";
import ContextMenus from "../../utils/ContextMenus";
import { calculateImageRatio, calculateScaledDimensions } from "../../utils/Message";
import { getFileDetails, zoomFit } from "../../utils/Utils";
import { IContextMenuItem } from "../ContextMenuItem";
import Audio from "../media/Audio";
import File from "../media/File";
import Video from "../media/Video";
import AttachmentPreviewModal from "../modals/AttachmentPreviewModal";
const Attachment = styled.div<{ withPointer?: boolean }>`
cursor: ${(props) => (props.withPointer ? "pointer" : "default")};
@ -25,17 +19,13 @@ const Image = styled.img`
interface AttachmentProps {
attachment: APIAttachment;
contextMenuItems?: IContextMenuItem[];
maxWidth?: number;
maxHeight?: number;
}
export default function MessageAttachment({ attachment, contextMenuItems, maxWidth, maxHeight }: AttachmentProps) {
export default function MessageAttachment({ attachment, maxWidth, maxHeight }: AttachmentProps) {
const logger = useLogger("MessageAttachment");
const { openModal } = useModals();
const contextMenu = React.useContext(ContextMenuContext);
const url = attachment.proxy_url && attachment.proxy_url.length > 0 ? attachment.proxy_url : attachment.url;
const details = getFileDetails(attachment);
@ -64,13 +54,10 @@ export default function MessageAttachment({ attachment, contextMenuItems, maxWid
<Attachment
withPointer={attachment.content_type?.startsWith("image")}
key={attachment.id}
onContextMenu={(e) =>
contextMenu.open2(e, [...(contextMenuItems ?? []), ...ContextMenus.MessageAttachment(attachment)])
}
onClick={() => {
if (!attachment.content_type?.startsWith("image")) return;
const { width, height } = zoomFit(attachment.width!, attachment.height!);
openModal(AttachmentPreviewModal, { attachment, width, height });
// TODO: preview modal
}}
>
{finalElement}

View File

@ -1,12 +1,11 @@
import { observer } from "mobx-react-lite";
import React from "react";
import React, { useContext } from "react";
import styled from "styled-components";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import { PopoutContext } from "../../contexts/PopoutContext";
import { useAppStore } from "../../stores/AppStore";
import { MessageLike } from "../../stores/objects/Message";
import ContextMenus from "../../utils/ContextMenus";
import UserProfilePopout from "../UserProfilePopout";
import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger";
const Container = styled.div`
font-size: 16px;
@ -25,14 +24,18 @@ interface Props {
function MessageAuthor({ message }: Props) {
const app = useAppStore();
const contextMenu = React.useContext(ContextMenuContext);
const popoutContext = React.useContext(PopoutContext);
const contextMenu = useContext(ContextMenuContext);
const [color, setColor] = React.useState<string | undefined>(undefined);
const ref = React.useRef<HTMLDivElement>(null);
const onContextMenu = async (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
const member = await app.guilds.get(message.guild_id!)?.members.fetch(message.author.id);
contextMenu.onContextMenu(e, { type: "user", user: message.author, member });
};
React.useEffect(() => {
if ("guild_id" in message && message.guild_id) {
const guild = app.guilds.get(message.guild_id);
const guild = app.guilds.get(message.guild_id!);
if (!guild) return;
const member = guild.members.get(message.author.id);
if (!member) return;
@ -40,40 +43,26 @@ function MessageAuthor({ message }: Props) {
}
}, [message]);
const openPopout = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
if (!rect) return;
popoutContext.open({
element: <UserProfilePopout user={message.author} />,
position: rect,
placement: "right",
});
};
return (
<Container
ref={ref}
style={{
color,
<Floating
placement="right-start"
type="userPopout"
props={{
user: message.author,
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
contextMenu.open2(e, [
...ContextMenus.User(message.author),
...(message.guild_id ? ContextMenus.Member2(app, message.author, message.guild_id) : []),
]);
}}
onClick={openPopout}
>
{message.author.username}
</Container>
<FloatingTrigger>
<Container
style={{
color,
}}
ref={contextMenu.setReferenceElement}
onContextMenu={onContextMenu}
>
{message.author.username}
</Container>
</FloatingTrigger>
</Floating>
);
}

View File

@ -3,7 +3,8 @@ import { observer } from "mobx-react-lite";
import styled from "styled-components";
import Message, { MessageLike } from "../../stores/objects/Message";
import { calendarStrings } from "../../utils/i18n";
import Tooltip from "../Tooltip";
import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger";
interface Props {
header?: boolean;
@ -96,18 +97,33 @@ export const MessageDetails = observer(({ message, position }: { message: Messag
if (message instanceof Message && message.edited_timestamp) {
return (
<div className="messageTimestampWrapper">
<Tooltip title={dayjs(message.timestamp).format("dddd, MMMM D, YYYY h:mm A")} placement="top">
<time className="copyTime" dateTime={message.edited_timestamp.toISOString()}>
{dayjs(message.edited_timestamp).format("h:mm A")}
</time>
</Tooltip>
<Floating
placement="top"
type="tooltip"
props={{
content: <span>{dayjs(message.timestamp).format("dddd, MMMM D, YYYY h:mm A")}</span>,
}}
>
<FloatingTrigger>
<time className="copyTime" dateTime={message.edited_timestamp.toISOString()}>
{dayjs(message.edited_timestamp).format("h:mm A")}
</time>
</FloatingTrigger>
</Floating>
<span className="edited">
<Tooltip
title={dayjs(message.edited_timestamp).format("dddd, MMMM D, YYYY h:mm A")}
<Floating
placement="top"
type="tooltip"
props={{
content: (
<span>{dayjs(message.edited_timestamp).format("dddd, MMMM D, YYYY h:mm A")}</span>
),
}}
>
<span>(edited)</span>
</Tooltip>
<FloatingTrigger>
<span>(edited)</span>
</FloatingTrigger>
</Floating>
</span>
</div>
);
@ -121,15 +137,31 @@ export const MessageDetails = observer(({ message, position }: { message: Messag
return (
<DetailBase>
<Tooltip title={dayjs(message.timestamp).format("dddd, MMMM D, YYYY h:mm A")} placement="top">
<time className="copyTime" dateTime={message.timestamp.toISOString()}>
{dayjs(message.timestamp).calendar(undefined, calendarStrings)}
</time>
</Tooltip>
<Floating
placement="top"
type="tooltip"
props={{
content: <span>{dayjs(message.timestamp).format("dddd, MMMM D, YYYY h:mm A")}</span>,
}}
>
<FloatingTrigger>
<time className="copyTime" dateTime={message.timestamp.toISOString()}>
{dayjs(message.timestamp).calendar(undefined, calendarStrings)}
</time>
</FloatingTrigger>
</Floating>
{message instanceof Message && message.edited_timestamp && (
<Tooltip title={dayjs(message.edited_timestamp).format("dddd, MMMM D, YYYY h:mm A")} placement="top">
<span className="edited">(edited)</span>
</Tooltip>
<Floating
placement="top"
type="tooltip"
props={{
content: <span>{dayjs(message.edited_timestamp).format("dddd, MMMM D, YYYY h:mm A")}</span>,
}}
>
<FloatingTrigger>
<span className="edited">(edited)</span>
</FloatingTrigger>
</Floating>
)}
</DetailBase>
);

View File

@ -1,10 +1,11 @@
import Channel from "../../stores/objects/Channel";
import { useModals } from "@mattjennings/react-modal-stack";
import { ChannelType, MessageType, RESTPostAPIChannelMessageJSONBody } from "@spacebarchat/spacebar-api-types/v9";
import { observer } from "mobx-react-lite";
import React from "react";
import styled from "styled-components";
import { modalController } from "../../controllers/modals";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import Guild from "../../stores/objects/Guild";
@ -12,7 +13,6 @@ import Snowflake from "../../utils/Snowflake";
import { MAX_ATTACHMENTS } from "../../utils/constants";
import { debounce } from "../../utils/debounce";
import { isTouchscreenDevice } from "../../utils/isTouchscreenDevice";
import ErrorModal from "../modals/ErrorModal";
import MessageTextArea from "./MessageTextArea";
import AttachmentUpload from "./attachments/AttachmentUpload";
import AttachmentUploadList from "./attachments/AttachmentUploadPreview";
@ -60,7 +60,6 @@ function MessageInput({ channel }: Props) {
const logger = useLogger("MessageInput");
const [content, setContent] = React.useState("");
const [attachments, setAttachments] = React.useState<File[]>([]);
const { openModal } = useModals();
/**
* Debounced stopTyping
@ -161,13 +160,10 @@ function MessageInput({ channel }: Props) {
const appendAttachment = (files: File[]) => {
if (files.length === 0) return;
if (files.length > MAX_ATTACHMENTS || attachments.length + files.length > MAX_ATTACHMENTS) {
openModal(ErrorModal, {
modalController.push({
type: "error",
title: "Too many attachments",
message: (
<div style={{ justifyContent: "center", display: "flex" }}>
You can only attach {MAX_ATTACHMENTS} files at once.
</div>
),
error: `You can only attach ${MAX_ATTACHMENTS} files at once.`,
});
return;
}

View File

@ -1,12 +1,11 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { observer } from "mobx-react-lite";
import React from "react";
import styled from "styled-components";
import { modalController } from "../../../controllers/modals/ModalController";
import useLogger from "../../../hooks/useLogger";
import { bytesToSize } from "../../../utils/Utils";
import { MAX_UPLOAD_SIZE } from "../../../utils/constants";
import Icon from "../../Icon";
import ErrorModal from "../../modals/ErrorModal";
const Container = styled.button`
height: 45px;
@ -64,16 +63,12 @@ interface Props {
function AttachmentUpload({ append }: Props) {
const logger = useLogger("AttachmentUpload");
const { openModal } = useModals();
const fileTooLarge = () => {
openModal(ErrorModal, {
modalController.push({
type: "error",
title: "File Too Large",
message: (
<div style={{ justifyContent: "center", display: "flex" }}>
Max file size is {bytesToSize(MAX_UPLOAD_SIZE)}.
</div>
),
error: `Max file size is ${bytesToSize(MAX_UPLOAD_SIZE)}.`,
});
return;
};

View File

@ -1,97 +1,41 @@
import { useModals } from "@mattjennings/react-modal-stack";
import styled from "styled-components";
import Icon from "../Icon";
import CreateServerModal from "./CreateServerModal";
import JoinServerModal from "./JoinServerModal";
import {
Modal,
ModalActionItem,
ModalCloseWrapper,
ModalHeaderText,
ModalSubHeaderText,
ModelContentContainer,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
import { ModalProps, modalController } from "../../controllers/modals";
import Button from "../Button";
import { Modal } from "./ModalComponents";
export const ModalHeader = styled.div`
padding: 16px;
const ActionWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const CreateButton = styled(ModalActionItem)`
transition: background-color 0.2s ease-in-out;
margin-bottom: 8px;
font-size: 16px;
font-weight: var(--font-weight-medium);
&:hover {
background-color: var(--primary-light);
}
`;
const JoinButton = styled(ModalActionItem)`
transition: background-color 0.2s ease-in-out;
margin-bottom: 8px;
font-size: 16px;
font-weight: var(--font-weight-medium);
&:hover {
background-color: var(--background-secondary-highlight);
}
`;
function AddServerModal(props: AnimatedModalProps) {
const { openModal, closeModal } = useModals();
export function AddServerModal({ ...props }: ModalProps<"add_server">) {
return (
<Modal {...props}>
<ModalCloseWrapper>
<button
onClick={closeModal}
style={{
background: "none",
border: "none",
outline: "none",
}}
>
<Icon
icon="mdiClose"
size={1}
style={{
cursor: "pointer",
color: "var(--text)",
}}
/>
</button>
</ModalCloseWrapper>
<ModalHeader>
<ModalHeaderText>Add a Guild</ModalHeaderText>
<ModalSubHeaderText>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</ModalSubHeaderText>
</ModalHeader>
<ModelContentContainer>
<CreateButton
variant="filled"
size="med"
<Modal {...props} title="Add a Guild" description="Lorem ipsum dolor sit amet, consectetur adipiscing elit.">
<ActionWrapper>
<Button
palette="primary"
grow
onClick={() => {
openModal(CreateServerModal);
modalController.push({
type: "create_server",
});
}}
>
Create a Guild
</CreateButton>
</Button>
<JoinButton
variant="outlined"
size="med"
<Button
palette="secondary"
grow
onClick={() => {
openModal(JoinServerModal);
modalController.push({
type: "join_server",
});
}}
>
Join a Guild
</JoinButton>
</ModelContentContainer>
</Button>
</ActionWrapper>
</Modal>
);
}
export default AddServerModal;

View File

@ -1,30 +1,3 @@
import { APIAttachment } from "@spacebarchat/spacebar-api-types/v9";
import { Modal } from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
interface Props extends AnimatedModalProps {
attachment: APIAttachment;
width?: number;
height?: number;
export function AttachmentPreviewModal() {
return null;
}
function AttachmentPreviewModal(props: Props) {
const width = props.width ?? props.attachment.width ?? 0;
const height = props.height ?? props.attachment.height ?? 0;
return (
<Modal
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
}}
{...props}
>
<img src={props.attachment.url} width={width} height={height} loading="eager" />
</Modal>
);
}
export default AttachmentPreviewModal;

View File

@ -0,0 +1,110 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { Routes } from "@spacebarchat/spacebar-api-types/v9";
import { useForm } from "react-hook-form";
import styled from "styled-components";
import * as yup from "yup";
import { ModalProps, modalController } from "../../controllers/modals";
import { useAppStore } from "../../stores/AppStore";
import { Modal } from "./ModalComponents";
const DescriptionText = styled.p`
font-size: 16px;
font-weight: var(--font-weight-regular);
color: var(--text-header-secondary);
margin-top: 8px;
`;
const TextArea = styled.textarea`
flex: 1;
padding: 8px;
border-radius: 4px;
background-color: var(--background-secondary-alt);
border: none;
color: var(--text);
font-size: 16px;
font-weight: var(--font-weight-regular);
resize: none;
outline: none;
`;
const schema = yup
.object({
reason: yup.string().max(512, "Reason must be less than 512 characters"),
})
.required();
export function BanMemberModal({ target, type, ...props }: ModalProps<"ban_member">) {
const app = useAppStore();
const {
register,
handleSubmit,
formState: { disabled, isLoading, isSubmitting },
} = useForm({
resolver: yupResolver(schema),
});
const isDisabled = disabled || isLoading || isSubmitting;
const onSubmit = handleSubmit((data) => {
app.rest
.put(
Routes.guildBan(target.guild.id, target.user!.id),
undefined,
undefined,
data.reason
? {
"X-Audit-Log-Reason": data.reason,
}
: undefined,
)
.then(() => {
modalController.pop("close");
})
.catch((e) => {
console.error(e);
});
});
return (
<Modal
{...props}
title={`Ban '${target.user?.username}'`}
description={
<DescriptionText>
Are you sure you want to ban <b>@{target.user?.username}</b>? They won't be able to rejoin unless
they are unbanned.
</DescriptionText>
}
actions={[
{
onClick: onSubmit,
children: <span>Ban</span>,
palette: "danger",
confirmation: true,
disabled: isDisabled,
size: "small",
},
{
onClick: () => modalController.pop("close"),
children: <span>Cancel</span>,
palette: "link",
disabled: isDisabled,
size: "small",
},
]}
>
<img
src="https://media1.tenor.com/m/TG5OF7UkLasAAAAd/thanos-infinity.gif"
loading="lazy"
alt="Thanos Snap GIF"
height={300}
style={{
marginBottom: 20,
borderRadius: 8,
}}
/>
<TextArea {...register("reason")} id="reason" name="reason" placeholder="Reason" maxLength={512} />
</Modal>
);
}

View File

@ -1,9 +1,9 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { APIInvite, Routes } from "@spacebarchat/spacebar-api-types/v9";
import dayjs from "dayjs";
import React from "react";
import { useForm } from "react-hook-form";
import styled from "styled-components";
import { ModalProps } from "../../controllers/modals/types";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import { messageFromFieldError } from "../../utils/messageFromFieldError";
@ -13,89 +13,58 @@ import { TextDivider } from "../Divider";
import { InputSelect, InputSelectOption } from "../FormComponents";
import Icon from "../Icon";
import IconButton from "../IconButton";
import { InputContainer } from "./CreateServerModal";
import {
Modal,
ModalCloseWrapper,
ModalHeaderText,
ModalSubHeaderText,
ModelContentContainer,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
import { InputContainer, Modal } from "./ModalComponents";
const EXPIRE_OPTIONS = [
{
label: "30 Minutes",
value: 1800,
},
{
label: "1 Hour",
value: 3600,
},
{
label: "6 Hours",
value: 21600,
},
{
label: "12 Hours",
value: 43200,
},
{
label: "1 Day",
value: 86400,
},
{
label: "7 Days",
value: 604800,
},
{
label: "30 Days",
value: 2592000,
},
{
label: "Never",
value: 0,
},
];
// TODO: refactor the layout of this modal when we have dms and friends, and move settings to a separate modal
const MAX_USES_OPTIONS = [
{
label: "No Limit",
value: 0,
},
{
label: "1 use",
value: 1,
},
{
label: "5 uses",
value: 5,
},
{
label: "10 uses",
value: 10,
},
{
label: "25 uses",
value: 25,
},
{
label: "50 uses",
value: 50,
},
{
label: "100 uses",
value: 100,
},
];
type Option = { label: string; value: number };
const Mention = styled.span`
padding: 0 2px;
`;
enum EExpiry {
MINUTES_30 = "MINUTES_30",
HOUR_1 = "HOUR_1",
HOURS_6 = "HOURS_6",
HOURS_12 = "HOURS_12",
DAY_1 = "DAY_1",
DAY_7 = "DAY_7",
DAYS_30 = "DAYS_30",
NEVER = "NEVER",
}
const ModalHeader = styled.div`
padding: 24px 24px 0;
`;
enum EMaxUses {
NO_LIMIT = "NO_LIMIT",
ONE = "USE_1",
FIVE = "USE_5",
TEN = "USE_10",
TWENTY_FIVE = "USE_25",
FIFTY = "USE_50",
ONE_HUNDRED = "USE_100",
}
const ExpiryOptions: Record<EExpiry, Option> = {
[EExpiry.MINUTES_30]: { label: "30 Minutes", value: 1800 },
[EExpiry.HOUR_1]: { label: "1 Hour", value: 3600 },
[EExpiry.HOURS_6]: { label: "6 Hours", value: 21600 },
[EExpiry.HOURS_12]: { label: "12 Hours", value: 43200 },
[EExpiry.DAY_1]: { label: "1 Day", value: 86400 },
[EExpiry.DAY_7]: { label: "7 Days", value: 604800 },
[EExpiry.DAYS_30]: { label: "30 Days", value: 2592000 },
[EExpiry.NEVER]: { label: "Never", value: 0 },
};
const MaxUsesOptions: Record<EMaxUses, Option> = {
[EMaxUses.NO_LIMIT]: { label: "No Limit", value: 0 },
[EMaxUses.ONE]: { label: "1 use", value: 1 },
[EMaxUses.FIVE]: { label: "5 uses", value: 5 },
[EMaxUses.TEN]: { label: "10 uses", value: 10 },
[EMaxUses.TWENTY_FIVE]: { label: "25 uses", value: 25 },
[EMaxUses.FIFTY]: { label: "50 uses", value: 50 },
[EMaxUses.ONE_HUNDRED]: { label: "100 uses", value: 100 },
};
function findOptionByValue(value: number, options: Record<string, Option>): Option | undefined {
const result = Object.values(options).find((option) => option.value === value);
return result;
}
const InputWrapper = styled.div`
width: 100%;
@ -103,11 +72,6 @@ const InputWrapper = styled.div`
align-items: center;
`;
interface InviteModalProps extends AnimatedModalProps {
channel_id?: string;
guild_id: string;
}
interface APICreateInvite {
flags: 0;
target_type: null;
@ -121,23 +85,14 @@ interface FormValues extends APICreateInvite {
code: string;
}
function CreateInviteModal(props: InviteModalProps) {
export function CreateInviteModal({ target, ...props }: ModalProps<"create_invite">) {
const logger = useLogger("CreateInviteModal");
const app = useAppStore();
const { openModal, closeModal } = useModals();
const [maxAge, setMaxAge] = React.useState(EXPIRE_OPTIONS[5]);
const [maxUses, setMaxUses] = React.useState(MAX_USES_OPTIONS[0]);
const [maxAge, setMaxAge] = React.useState(ExpiryOptions.DAY_7);
const [maxUses, setMaxUses] = React.useState(MaxUsesOptions.NO_LIMIT);
const [isEdited, setIsEdited] = React.useState(false);
const [inviteExpiresAt, setInviteExpiresAt] = React.useState<Date | null>(null);
const guild = app.guilds.get(props.guild_id);
const channel = props.channel_id ? guild?.channels.find((x) => x.id === props.channel_id) : guild?.channels[0];
if (!guild || !channel) {
closeModal();
return null;
}
const {
register,
handleSubmit,
@ -154,13 +109,13 @@ function CreateInviteModal(props: InviteModalProps) {
clearErrors();
app.rest
.post<APICreateInvite, APIInvite>(
Routes.channelInvites(channel.id),
Routes.channelInvites(target.id),
Object.assign(
{
flags: 0,
target_type: null,
target_user_id: null,
max_age: EXPIRE_OPTIONS[5].value,
max_age: ExpiryOptions.DAY_7.value,
max_uses: 0,
temporary: false,
},
@ -211,158 +166,127 @@ function CreateInviteModal(props: InviteModalProps) {
});
const handleAgeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMaxAge(EXPIRE_OPTIONS.find((x) => x.value === Number(e.target.value)) ?? EXPIRE_OPTIONS[5]);
setMaxAge(findOptionByValue(Number(e.target.value), ExpiryOptions) ?? ExpiryOptions.DAY_7);
setIsEdited(true);
};
const handleMaxUsesChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMaxUses(MAX_USES_OPTIONS.find((x) => x.value === Number(e.target.value)) ?? MAX_USES_OPTIONS[0]);
setMaxUses(findOptionByValue(Number(e.target.value), MaxUsesOptions) ?? MaxUsesOptions.NO_LIMIT);
setIsEdited(true);
};
React.useEffect(() => createCode(), []);
return (
<Modal {...props}>
<ModalCloseWrapper>
<button
onClick={closeModal}
<Modal {...props} title="Invite People" description={`to #${target.name} in ${target.guild?.name}`}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
>
<InputContainer>
<LabelWrapper error={false}>
<InputLabel>Expire after</InputLabel>
</LabelWrapper>
<InputWrapper>
<InputSelect
{...register("max_age", { value: ExpiryOptions.DAY_7.value })}
onChange={handleAgeChange}
value={maxAge.value}
>
{Object.entries(ExpiryOptions).map(([_, b]) => (
<InputSelectOption value={b.value}>{b.label}</InputSelectOption>
))}
</InputSelect>
</InputWrapper>
</InputContainer>
<InputContainer>
<LabelWrapper error={false}>
<InputLabel>Max Number of Uses</InputLabel>
</LabelWrapper>
<InputWrapper>
<InputSelect
{...register("max_uses", { value: 0 })}
onChange={handleMaxUsesChange}
value={maxUses.value}
>
{Object.entries(MaxUsesOptions).map(([_, b]) => (
<InputSelectOption value={b.value}>{b.label}</InputSelectOption>
))}
</InputSelect>
</InputWrapper>
</InputContainer>
<div style={{ display: "flex", justifyContent: "flex-end", margin: "24px 0 12px 0" }}>
<Button disabled={!isEdited} onClick={onSubmit}>
Generate New Link
</Button>
</div>
<InputContainer
style={{
background: "none",
border: "none",
outline: "none",
marginTop: "0",
}}
>
<Icon
icon="mdiClose"
size={1}
<LabelWrapper error={!!errors.code}>
<InputLabel>Invite Code</InputLabel>
{errors.code && (
<InputErrorText>
<>
<TextDivider>-</TextDivider>
{errors.code.message}
</>
</InputErrorText>
)}
</LabelWrapper>
<InputWrapper
style={{
cursor: "pointer",
color: "var(--text)",
}}
/>
</button>
</ModalCloseWrapper>
<ModalHeader>
<ModalHeaderText>Invite People</ModalHeaderText>
<ModalSubHeaderText>
to <Mention>#{channel.name}</Mention> in <Mention>{guild.name}</Mention>
</ModalSubHeaderText>
</ModalHeader>
<ModelContentContainer>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
>
<InputContainer>
<LabelWrapper error={false}>
<InputLabel>Expire after</InputLabel>
</LabelWrapper>
<InputWrapper>
<InputSelect
{...register("max_age", { value: EXPIRE_OPTIONS[5].value })}
onChange={handleAgeChange}
value={maxAge.value}
>
{EXPIRE_OPTIONS.map((option) => (
<InputSelectOption value={option.value}>{option.label}</InputSelectOption>
))}
</InputSelect>
</InputWrapper>
</InputContainer>
<InputContainer>
<LabelWrapper error={false}>
<InputLabel>Maximum Uses</InputLabel>
</LabelWrapper>
<InputWrapper>
<InputSelect
{...register("max_uses", { value: 0 })}
onChange={handleMaxUsesChange}
value={maxUses.value}
>
{MAX_USES_OPTIONS.map((option) => (
<InputSelectOption value={option.value}>{option.label}</InputSelectOption>
))}
</InputSelect>
</InputWrapper>
</InputContainer>
<div style={{ display: "flex", justifyContent: "flex-end", margin: "24px 0 12px 0" }}>
<Button disabled={!isEdited} onClick={onSubmit}>
Generate new Link
</Button>
</div>
<InputContainer
style={{
marginTop: "0",
background: "var(--background-secondary-alt)",
borderRadius: "12px",
}}
>
<LabelWrapper error={!!errors.code}>
<InputLabel>Invite Code</InputLabel>
{errors.code && (
<InputErrorText>
<>
<TextDivider>-</TextDivider>
{errors.code.message}
</>
</InputErrorText>
)}
</LabelWrapper>
<Input
autoFocus
{...register("code")}
readOnly={true}
placeholder={`${window.location.origin}/invite/`}
/>
<InputWrapper
<IconButton
style={{
background: "var(--background-secondary-alt)",
borderRadius: "12px",
marginRight: "8px",
}}
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(getValues("code"));
}}
>
<Input
autoFocus
{...register("code")}
readOnly={true}
placeholder={`${window.location.origin}/invite/`}
/>
<Icon icon="mdiContentCopy" size="20px" color="white" />
</IconButton>
</InputWrapper>
<IconButton
style={{
marginRight: "8px",
}}
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(getValues("code"));
}}
>
<Icon icon="mdiContentCopy" size="20px" color="white" />
</IconButton>
</InputWrapper>
<span
style={{
color: "var(--text-secondary)",
marginTop: "8px",
fontSize: "12px",
fontWeight: "var(--font-weight-regular)",
padding: "0 8px",
}}
>
{inviteExpiresAt ? (
<>This invite will expire {dayjs(inviteExpiresAt).calendar()}</>
) : (
"Invite will never expire."
)}
</span>
</InputContainer>
</form>
</ModelContentContainer>
<span
style={{
color: "var(--text-secondary)",
marginTop: "8px",
fontSize: "12px",
fontWeight: "var(--font-weight-regular)",
padding: "0 8px",
}}
>
{inviteExpiresAt ? (
<>This invite will expire {dayjs(inviteExpiresAt).fromNow()}</>
) : (
"Invite will never expire."
)}
</span>
</InputContainer>
</form>
</Modal>
);
}
export default CreateInviteModal;

View File

@ -1,30 +1,15 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { APIGuild, Routes } from "@spacebarchat/spacebar-api-types/v9";
import React from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { ModalProps, modalController } from "../../controllers/modals";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import { messageFromFieldError } from "../../utils/messageFromFieldError";
import { Input, InputErrorText, InputLabel, InputWrapper, LabelWrapper } from "../AuthComponents";
import { TextDivider } from "../Divider";
import Icon from "../Icon";
import {
Modal,
ModalActionItem,
ModalCloseWrapper,
ModalFooter,
ModalHeaderText,
ModalSubHeaderText,
ModelContentContainer,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
export const ModalHeader = styled.div`
margin-bottom: 30px;
padding: 24px 24px 0;
`;
import { InputContainer, Modal } from "./ModalComponents";
const UploadIcon = styled.div`
padding-top: 4;
@ -51,25 +36,17 @@ const FileInput = styled.div`
width: 100%;
height: 100%;
opacity: 0;
// cursor: pointer;
cursor: not-allowed;
cursor: pointer;
font-size: 0px;
`;
export const InputContainer = styled.div`
margin-top: 24px;
display: flex;
flex-direction: column;
`;
type FormValues = {
name: string;
};
function CreateServerModal(props: AnimatedModalProps) {
export function CreateServerModal({ ...props }: ModalProps<"create_server">) {
const app = useAppStore();
const logger = useLogger("CreateServerModal");
const { openModal, closeModal, closeAllModals } = useModals();
const [selectedFile, setSelectedFile] = React.useState<File>();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const navigate = useNavigate();
@ -98,7 +75,7 @@ function CreateServerModal(props: AnimatedModalProps) {
})
.then((r) => {
navigate(`/channels/${r.id}`);
closeAllModals();
modalController.closeAll();
})
.catch((r) => {
if ("message" in r) {
@ -135,39 +112,38 @@ function CreateServerModal(props: AnimatedModalProps) {
return (
<Modal
{...props}
// used for clicking outside of the modal to close it
onClose={closeAllModals}
onClose={() => modalController.closeAll()}
title="Customize your guild"
description="Give your new guild a personality with a name and an icon. You can always change it later."
actions={[
{
onClick: onSubmit,
children: <span>Create</span>,
palette: "primary",
confirmation: true,
disabled: isLoading,
},
{
onClick: () => modalController.pop("close"),
children: <span>Back</span>,
palette: "link",
disabled: isLoading,
},
]}
>
<ModalCloseWrapper>
<button
onClick={closeAllModals}
style={{
background: "none",
border: "none",
outline: "none",
}}
>
<Icon
icon="mdiClose"
size={1}
style={{
cursor: "pointer",
color: "var(--text)",
}}
/>
</button>
</ModalCloseWrapper>
<ModalHeader>
<ModalHeaderText>Customize your guild</ModalHeaderText>
<ModalSubHeaderText>
Give your new guild a personality with a name and an icon. You can always change it later.
</ModalSubHeaderText>
</ModalHeader>
<ModelContentContainer>
<UploadIcon>
<IconContainer>
<UploadIcon>
<IconContainer>
{selectedFile ? (
<img
src={URL.createObjectURL(selectedFile)}
alt="Guild Icon"
width="80px"
height="80px"
style={{
borderRadius: "50%",
}}
/>
) : (
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
<path
fillRule="evenodd"
@ -188,71 +164,44 @@ function CreateServerModal(props: AnimatedModalProps) {
fill="currentColor"
></path>
</svg>
<IconInput
ref={fileInputRef}
type="file"
name="icon"
accept="image/*"
onChange={onIconChange}
)}
<IconInput ref={fileInputRef} type="file" name="icon" accept="image/*" onChange={onIconChange} />
{/* FIXME: need to like, remove focus after the selector is closed */}
<FileInput role="button" onClick={() => fileInputRef.current?.click()} />
</IconContainer>
</UploadIcon>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
>
<InputContainer>
<LabelWrapper error={!!errors.name}>
<InputLabel>Guild Name</InputLabel>
{errors.name && (
<InputErrorText>
<>
<TextDivider>-</TextDivider>
{errors.name.message}
</>
</InputErrorText>
)}
</LabelWrapper>
<InputWrapper>
<Input
autoFocus
{...register("name", { required: true })}
placeholder="Guild Name"
error={!!errors.name}
disabled={isLoading}
/>
<FileInput
role="button"
// disabled until I get the motiviation to not make it shit, I don't really want to use an invisible input
onClick={() => fileInputRef.current?.click()}
></FileInput>
</IconContainer>
</UploadIcon>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
>
<InputContainer>
<LabelWrapper error={!!errors.name}>
<InputLabel>Guild Name</InputLabel>
{errors.name && (
<InputErrorText>
<>
<TextDivider>-</TextDivider>
{errors.name.message}
</>
</InputErrorText>
)}
</LabelWrapper>
<InputWrapper>
<Input
autoFocus
{...register("name", { required: true })}
placeholder="Guild Name"
error={!!errors.name}
disabled={isLoading}
/>
</InputWrapper>
</InputContainer>
</form>
</ModelContentContainer>
<ModalFooter>
<ModalActionItem variant="filled" size="med" onClick={onSubmit} disabled={isLoading}>
Create
</ModalActionItem>
<ModalActionItem
variant="link"
size="min"
onClick={() => {
closeModal();
}}
>
Back
</ModalActionItem>
</ModalFooter>
</InputWrapper>
</InputContainer>
</form>
</Modal>
);
}
export default CreateServerModal;

View File

@ -0,0 +1,34 @@
import { ModalProps, modalController } from "../../controllers/modals";
import { Modal } from "./ModalComponents";
export function DeleteMessageModal({ target, ...props }: ModalProps<"delete_message">) {
return (
<Modal
{...props}
title="Delete Message"
description="Are you sure you want to delete this message?"
actions={[
{
onClick: () => {
modalController.pop("close");
},
children: <span>Cancel</span>,
palette: "link",
size: "small",
confirmation: true,
},
{
onClick: async () => {
await target.delete();
modalController.pop("close");
},
children: <span>Delete</span>,
palette: "danger",
size: "small",
},
]}
>
<div>message preview</div>
</Modal>
);
}

View File

@ -1,73 +1,22 @@
import { useModals } from "@mattjennings/react-modal-stack";
import styled from "styled-components";
import Icon from "../Icon";
import {
Modal,
ModalActionItem,
ModalCloseWrapper,
ModalFooter,
ModalHeaderText,
ModalSubHeaderText,
ModelContentContainer,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
export const ModalHeader = styled.div`
padding: 16px;
`;
const CloseButton = styled(ModalActionItem)`
transition: background-color 0.2s ease-in-out;
margin-bottom: 8px;
font-size: 16px;
font-weight: var(--font-weight-medium);
`;
interface Props extends AnimatedModalProps {
title: string;
subtitle?: string;
message: React.ReactNode;
}
function ErrorModal(props: Props) {
const { closeModal } = useModals();
import { ModalProps } from "../../controllers/modals/types";
import { Modal } from "./ModalComponents";
export function ErrorModal({ error, ...props }: ModalProps<"error">) {
return (
<Modal {...props}>
<ModalCloseWrapper>
<button
onClick={closeModal}
style={{
background: "none",
border: "none",
outline: "none",
}}
>
<Icon
icon="mdiClose"
size={1}
style={{
cursor: "pointer",
color: "var(--text)",
}}
/>
</button>
</ModalCloseWrapper>
<ModalHeader>
<ModalHeaderText>{props.title}</ModalHeaderText>
{props.subtitle && <ModalSubHeaderText>{props.subtitle}</ModalSubHeaderText>}
</ModalHeader>
<ModelContentContainer>{props.message}</ModelContentContainer>
<ModalFooter>
<CloseButton variant="filled" size="med" onClick={() => closeModal()}>
Dismiss
</CloseButton>
</ModalFooter>
<Modal
{...props}
actions={[
{
onClick: () => true,
confirmation: true,
children: <span>Dismiss</span>,
palette: "primary",
disabled: !(props.recoverable ?? true),
},
]}
nonDismissable={!(props.recoverable ?? true)}
>
{error}
</Modal>
);
}
export default ErrorModal;

View File

@ -1,71 +1,3 @@
import { useModals } from "@mattjennings/react-modal-stack";
import styled from "styled-components";
import Icon from "../Icon";
import {
Modal,
ModalActionItem,
ModalCloseWrapper,
ModalFooter,
ModalHeaderText,
ModelContentContainer,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
export const ModalHeader = styled.div`
padding: 16px;
`;
const SubmitButton = styled(ModalActionItem)`
transition: background-color 0.2s ease-in-out;
font-size: 16px;
font-weight: var(--font-weight-medium);
&:hover {
background-color: var(--background-secondary-highlight);
}
`;
function ForgotPasswordModal(props: AnimatedModalProps) {
const { closeModal } = useModals();
return (
<Modal {...props}>
<ModalCloseWrapper>
<button
onClick={closeModal}
style={{
background: "none",
border: "none",
outline: "none",
}}
>
<Icon
icon="mdiClose"
size={1}
style={{
cursor: "pointer",
color: "var(--text)",
}}
/>
</button>
</ModalCloseWrapper>
<ModalHeader>
<ModalHeaderText>Instructions Sent</ModalHeaderText>
</ModalHeader>
<ModelContentContainer>
We sent instructions to change your password to user@example.com, please check both your inbox and spam
folder.
</ModelContentContainer>
<ModalFooter>
<SubmitButton variant="filled" size="med" onClick={closeModal}>
Okay
</SubmitButton>
</ModalFooter>
</Modal>
);
export function ForgotPasswordModal() {
return null;
}
export default ForgotPasswordModal;

View File

@ -1,29 +1,14 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { Routes } from "@spacebarchat/spacebar-api-types/v9";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { ModalProps, modalController } from "../../controllers/modals";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import { messageFromFieldError } from "../../utils/messageFromFieldError";
import { Input, InputErrorText, InputLabel, LabelWrapper } from "../AuthComponents";
import { TextDivider } from "../Divider";
import Icon from "../Icon";
import AddServerModal from "./AddServerModal";
import {
Modal,
ModalActionItem,
ModalCloseWrapper,
ModalFooter,
ModalHeaderText,
ModalSubHeaderText,
ModelContentContainer,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
export const ModalHeader = styled.div`
padding: 16px;
`;
import { Modal } from "./ModalComponents";
const InviteInputContainer = styled.div`
display: flex;
@ -34,9 +19,8 @@ type FormValues = {
code: string;
};
function JoinServerModal(props: AnimatedModalProps) {
export function JoinServerModal({ ...props }: ModalProps<"join_server">) {
const logger = useLogger("JoinServerModal");
const { openModal, closeAllModals } = useModals();
const app = useAppStore();
const navigate = useNavigate();
@ -55,7 +39,7 @@ function JoinServerModal(props: AnimatedModalProps) {
.post<never, { guild_id: string; channel_id: string }>(Routes.invite(code))
.then((r) => {
navigate(`/channels/${r.guild_id}/${r.channel_id}`);
closeAllModals();
modalController.closeAll();
})
.catch((r) => {
if ("message" in r) {
@ -92,88 +76,59 @@ function JoinServerModal(props: AnimatedModalProps) {
return (
<Modal
{...props}
// used for clicking outside of the modal to close it
onClose={closeAllModals}
onClose={() => modalController.closeAll()}
title="Join a Guild"
description="Enter an invite below to join an existing guild."
actions={[
{
onClick: onSubmit,
children: <span>Join</span>,
palette: "primary",
confirmation: true,
disabled: isLoading,
},
{
onClick: () => modalController.pop("close"),
children: <span>Back</span>,
palette: "link",
disabled: isLoading,
},
]}
>
<ModalCloseWrapper>
<button
onClick={closeAllModals}
style={{
background: "none",
border: "none",
outline: "none",
}}
>
<Icon
icon="mdiClose"
size={1}
style={{
cursor: "pointer",
color: "var(--text)",
}}
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
>
<InviteInputContainer>
<LabelWrapper error={!!errors.code}>
<InputLabel>Invite Link</InputLabel>
{errors.code && (
<InputErrorText>
<>
<TextDivider>-</TextDivider>
{errors.code.message}
</>
</InputErrorText>
)}
</LabelWrapper>
<Input
{...register("code", { required: true })}
placeholder={`${window.location.origin}/invite/`}
type="text"
maxLength={9999}
required
error={!!errors.code}
disabled={isLoading}
autoFocus
minLength={6}
/>
</button>
</ModalCloseWrapper>
<ModalHeader>
<ModalHeaderText>Join a Guild</ModalHeaderText>
<ModalSubHeaderText>Enter an invite below to join an existing guild.</ModalSubHeaderText>
</ModalHeader>
<ModelContentContainer>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit();
}
}}
>
<InviteInputContainer>
<LabelWrapper error={!!errors.code}>
<InputLabel>Invite Link</InputLabel>
{errors.code && (
<InputErrorText>
<>
<TextDivider>-</TextDivider>
{errors.code.message}
</>
</InputErrorText>
)}
</LabelWrapper>
<Input
{...register("code", { required: true })}
placeholder={`${window.location.origin}/invite/`}
type="text"
maxLength={9999}
required
error={!!errors.code}
disabled={isLoading}
autoFocus
minLength={6}
/>
</InviteInputContainer>
</form>
</ModelContentContainer>
<ModalFooter>
<ModalActionItem variant="filled" size="med" onClick={onSubmit}>
Join Guild
</ModalActionItem>
<ModalActionItem
variant="link"
size="min"
onClick={() => {
openModal(AddServerModal);
}}
>
Back
</ModalActionItem>
</ModalFooter>
</InviteInputContainer>
</form>
</Modal>
);
}
export default JoinServerModal;

View File

@ -0,0 +1,99 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { Routes } from "@spacebarchat/spacebar-api-types/v9";
import { useForm } from "react-hook-form";
import styled from "styled-components";
import * as yup from "yup";
import { ModalProps, modalController } from "../../controllers/modals";
import { useAppStore } from "../../stores/AppStore";
import { Modal } from "./ModalComponents";
const DescriptionText = styled.p`
font-size: 16px;
font-weight: var(--font-weight-regular);
color: var(--text-header-secondary);
margin-top: 8px;
`;
const TextArea = styled.textarea`
flex: 1;
padding: 8px;
border-radius: 4px;
background-color: var(--background-secondary-alt);
border: none;
color: var(--text);
font-size: 16px;
font-weight: var(--font-weight-regular);
resize: none;
outline: none;
`;
const schema = yup
.object({
reason: yup.string().max(512, "Reason must be less than 512 characters"),
})
.required();
export function KickMemberModal({ target, ...props }: ModalProps<"kick_member">) {
const app = useAppStore();
const {
register,
control,
handleSubmit,
formState: { disabled, isLoading, isSubmitting },
} = useForm({
resolver: yupResolver(schema),
});
const isDisabled = disabled || isLoading || isSubmitting;
const onSubmit = handleSubmit((data) => {
app.rest
.delete(
Routes.guildMember(target.guild.id, target.user!.id),
undefined,
data.reason
? {
"X-Audit-Log-Reason": data.reason,
}
: undefined,
)
.then(() => {
modalController.pop("close");
})
.catch((e) => {
console.error(e);
});
});
return (
<Modal
{...props}
title={`Kick ${target.user?.username} from Guild`}
description={
<DescriptionText>
Are you sure you want to kick <b>@{target.user?.username}</b> from the guild? They will be able to
rejoin again with a new invite.
</DescriptionText>
}
actions={[
{
onClick: onSubmit,
children: <span>Kick</span>,
palette: "danger",
confirmation: true,
disabled: isDisabled,
size: "small",
},
{
onClick: () => modalController.pop("close"),
children: <span>Cancel</span>,
palette: "link",
disabled: isDisabled,
size: "small",
},
]}
>
<TextArea {...register("reason")} id="reason" name="reason" placeholder="Reason" maxLength={512} />
</Modal>
);
}

View File

@ -1,74 +0,0 @@
import { useModals } from "@mattjennings/react-modal-stack";
import styled from "styled-components";
import GuildMember from "../../stores/objects/GuildMember";
import Icon from "../Icon";
import {
Modal,
ModalActionItem,
ModalCloseWrapper,
ModalFooter,
ModalHeaderText,
ModalSubHeaderText,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
export const ModalHeader = styled.div`
padding: 16px;
`;
const CloseButton = styled(ModalActionItem)`
transition: background-color 0.2s ease-in-out;
margin-bottom: 8px;
font-size: 16px;
font-weight: var(--font-weight-medium);
`;
interface Props extends AnimatedModalProps {
member: GuildMember;
}
function KickModal(props: Props) {
const { closeModal } = useModals();
return (
<Modal {...props}>
<ModalCloseWrapper>
<button
onClick={closeModal}
style={{
background: "none",
border: "none",
outline: "none",
}}
>
<Icon
icon="mdiClose"
size={1}
style={{
cursor: "pointer",
color: "var(--text)",
}}
/>
</button>
</ModalCloseWrapper>
<ModalHeader>
<ModalHeaderText>Kick {props.member.user!.username}</ModalHeaderText>
<ModalSubHeaderText>
Are you sure you want to kick @{props.member.user!.username} from the server? They will be able to
rejoin again with a new invite.
</ModalSubHeaderText>
</ModalHeader>
{/* <ModelContentContainer>{props.message}</ModelContentContainer> */}
<ModalFooter>
<CloseButton variant="filled" size="med" onClick={() => closeModal()}>
Dismiss
</CloseButton>
</ModalFooter>
</Modal>
);
}
export default KickModal;

View File

@ -1,131 +1,65 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { Routes } from "@spacebarchat/spacebar-api-types/v9";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { ModalProps, modalController } from "../../controllers/modals";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import Guild from "../../stores/objects/Guild";
import Icon from "../Icon";
import {
Modal,
ModalActionItem,
ModalCloseWrapper,
ModalFooter,
ModalHeaderText,
ModelContentContainer,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
import { Modal } from "./ModalComponents";
export const ModalHeader = styled.div`
padding: 16px;
`;
const CancelButton = styled(ModalActionItem)`
transition: background-color 0.2s ease-in-out;
margin-bottom: 8px;
font-size: 14px;
font-weight: var(--font-weight-medium);
&:hover {
text-decoration: underline;
}
`;
const LeaveButton = styled(ModalActionItem)`
transition: background-color 0.2s ease-in-out;
margin-bottom: 8px;
font-size: 14px;
font-weight: var(--font-weight-medium);
border-radius: 4px;
background-color: var(--danger);
&:hover {
background-color: var(--background-secondary-highlight);
}
`;
interface Props {
guild: Guild;
}
function LeaveServerModal(props: Props & AnimatedModalProps) {
export function LeaveServerModal({ target, ...props }: ModalProps<"leave_server">) {
const app = useAppStore();
const { closeModal } = useModals();
const logger = useLogger("LeaveServerModal");
const navigate = useNavigate();
const [isDisabled, setDisabled] = useState(false);
if (!open) {
return null;
async function leaveGuild() {
setDisabled(true);
await app.rest
.delete(Routes.userGuild(target.id))
.then(() => {
modalController.pop("close");
navigate("/channels/@me");
})
.catch((e) => {
logger.error(e);
modalController.pop("close");
modalController.push({
type: "error",
error: e,
title: "Failed to leave server",
description: "An error occurred while trying to leave the server.",
});
})
.finally(() => setDisabled(false));
}
const handleLeaveServer = () => {
app.rest.delete(Routes.userGuild(props.guild.id)).finally(() => {
closeModal();
// navigate to @me
navigate("/channels/@me");
});
};
return (
<Modal {...props}>
<ModalCloseWrapper>
<button
onClick={closeModal}
style={{
background: "none",
border: "none",
outline: "none",
}}
>
<Icon
icon="mdiClose"
size={1}
style={{
cursor: "pointer",
color: "var(--text)",
}}
/>
</button>
</ModalCloseWrapper>
<ModalHeader>
<ModalHeaderText>Leave {props.guild.name}</ModalHeaderText>
</ModalHeader>
<ModelContentContainer>
<Modal
{...props}
title={`Leave '${target.name}'`}
description={
<span>
Are you sure you want to leave <b>{props.guild.name}</b>? You won't be able to rejoin this server
unless you are re-invited.
Are you sure you want to leave <b>{target.name}</b>? You won't be able to rejoin this guild unless
you are re-invited.
</span>
</ModelContentContainer>
<ModalFooter
style={{
flexDirection: "row",
justifyContent: "flex-end",
}}
>
<CancelButton
variant="link"
size="med"
onClick={() => {
closeModal();
}}
>
Cancel
</CancelButton>
<LeaveButton
variant="outlined"
size="med"
onClick={handleLeaveServer}
style={{
backgroundColor: "var(--danger)",
}}
>
Leave
</LeaveButton>
</ModalFooter>
</Modal>
}
actions={[
{
onClick: leaveGuild,
children: <span>Leave Server</span>,
palette: "danger",
confirmation: true,
disabled: isDisabled,
size: "small",
},
{
onClick: () => modalController.pop("close"),
children: <span>Cancel</span>,
palette: "link",
disabled: isDisabled,
size: "small",
},
]}
/>
);
}
export default LeaveServerModal;

View File

@ -1,37 +1,162 @@
import { useModals, type StackedModalProps } from "@mattjennings/react-modal-stack";
import { AnimatePresence, motion } from "framer-motion";
import React from "react";
import styled from "styled-components";
import React, { useCallback, useEffect, useState } from "react";
import { Portal } from "react-portal";
import styled, { css } from "styled-components";
import Button, { Props as ButtonProps } from "../Button";
import Icon from "../Icon";
import { animationFadeIn, animationFadeOut, animationZoomIn, animationZoomOut } from "../common/animations";
export type ModalAction = Omit<React.HTMLAttributes<HTMLButtonElement>, "as"> &
Omit<ButtonProps, "onClick"> & {
confirmation?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick: () => any | Promise<any>;
};
interface ModalProps {
children?: React.ReactNode;
onClose?: (force: boolean) => void;
signal?: "close" | "confirm" | "cancel";
title?: string;
description?: React.ReactNode;
transparent?: boolean;
nonDismissable?: boolean;
maxWidth?: string;
maxHeight?: string;
padding?: string;
actions?: ModalAction[];
disabled?: boolean;
withEmptyActionBar?: boolean;
}
/**
* Main container for all modals, handles the background overlay and positioning
*/
export const ModalBase = styled(motion.div)`
z-index: 100;
position: fixed;
export const ModalBase = styled.div<{ closing?: boolean }>`
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
z-index: 9999;
position: fixed;
max-height: 100%;
user-select: none;
animation-duration: 0.2s;
animation-fill-mode: forwards;
display: grid;
overflow-y: auto;
place-items: center;
color: var(--text);
background: rgba(0, 0, 0, 0.8);
${(props) =>
props.closing
? css`
animation-name: ${animationFadeOut};
> div {
animation-name: ${animationZoomOut};
}
`
: css`
animation-name: ${animationFadeIn};
`}
`;
/**
* Wrapper for modal content, handles the sizing and positioning
*/
export const ModalWrapper = styled(motion.div)<{ full?: boolean }>`
width: ${(props) => (props.full ? "100%" : "440px")};
height: ${(props) => (props.full ? "100%" : "auto")};
border-radius: 4px;
background-color: var(--background-secondary);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0 1px 3px 1px rgba(0, 0, 0, 0.05);
position: relative;
export const ModalWrapper = styled.div<
Pick<ModalProps, "transparent" | "maxWidth" | "maxHeight"> & { actions: boolean }
>`
min-height: 200px;
max-width: min(calc(100vw - 20px), ${(props) => props.maxWidth ?? "450px"});
max-height: min(calc(100vh - 20px), ${(props) => props.maxHeight ?? "650px"});
margin: 20px;
display: flex;
justify-content: ${(props) => (props.full ? undefined : "center")};
flex-direction: ${(props) => (props.full ? "row" : "column")};
flex-direction: column;
animation-name: ${animationZoomIn};
animation-duration: 0.25s;
animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1);
${(props) =>
!props.maxWidth &&
css`
width: 100%;
`}
${(props) =>
!props.transparent &&
css`
overflow: hidden;
background: var(--background-secondary);
border-radius: 8px;
`}
`;
export const ModalHeader = styled.div`
padding: 16px;
flex-shrink: 0;
word-break: break-word;
gap: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
`;
export const ModalContentContainer = styled.div<Pick<ModalProps, "transparent" | "padding">>`
display: flex;
flex-direction: column;
flex-grow: 1;
padding-top: 0;
padding: ${(props) => props.padding ?? "0 16px 16px"};
overflow-y: auto;
font-size: 0.9375rem;
${(props) =>
!props.transparent &&
css`
background: var(--background-secondary);
`}
`;
const Actions = styled.div`
gap: 8px;
display: flex;
padding: 16px;
flex-direction: row-reverse;
background: var(--background-primary);
border-radius: 0 0 4px 4px;
`;
export const ModalSubHeaderText = styled.div`
font-size: 16px;
font-weight: var(--font-weight-regular);
color: var(--text-header-secondary);
margin-top: 8px;
`;
export const ModalHeaderText = styled.div`
font-size: 24px;
font-weight: var(--font-weight-bold);
color: var(--text);
margin: 0;
padding: 0;
`;
export const InputContainer = styled.div`
margin-top: 24px;
display: flex;
flex-direction: column;
`;
/**
@ -39,215 +164,83 @@ export const ModalWrapper = styled(motion.div)<{ full?: boolean }>`
*/
export const ModalCloseWrapper = styled.div`
position: absolute;
top: 10px;
right: 10px;
`;
export const ModalHeaderText = styled.h1`
font-size: 24px;
font-weight: var(--font-weight-bold);
color: var(--text-header);
text-align: center;
margin: 0;
padding: 0;
`;
export const ModalSubHeaderText = styled.div`
font-size: 16px;
font-weight: var(--font-weight-regular);
color: var(--text-header-secondary);
text-align: center;
margin-top: 8px;
`;
export const ModelContentContainer = styled.div`
display: flex;
flex-direction: column;
padding: 0 16px;
margin: 16px 0;
border-radius: 5px 5px 0 0;
`;
export const ModalActionItem = styled.button<{
variant?: "filled" | "blank" | "outlined" | "link";
size?: "med" | "min";
}>`
color: var(--text);
display: flex;
position: relative;
justify-content: center;
align-items: center;
background: none;
border: none;
outline: none;
border-radius: 3px;
font-size: 14px;
font-weight: var(--font-weight-medium);
padding: 2px 16px;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
${(props) => {
if (props.variant === "filled") {
return `
background-color: var(--primary);
&:hover {
background-color: var(--primary-light);
}
`;
} else if (props.variant === "blank") {
return `
background: transparent;
`;
} else if (props.variant === "link") {
return `
background: transparent;
&:hover {
text-decoration: underline;
}
`;
} else if (props.variant === "outlined") {
return `
background: transparent;
border: 1px solid var(--background-secondary-highlight);
`;
}
}}
${(props) => {
if (props.size === "med") {
return `
width: auto;
height: 38px;
min-width: 96px;
min-height: 38px;
`;
} else if (props.size === "min") {
return `
width: auto;
display: inline;
height: auto;
padding: 2px 4px;
`;
}
}}
// disabled styling
${(props) => {
if (props.disabled) {
return `
opacity: 0.5;
cursor: not-allowed;
`;
}
}}
`;
export const ModalFooter = styled.div`
border-radius: 0 0 5px 5px;
background-color: var(--background-primary-alt);
position: relative;
padding: 16px;
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
`;
export const ModalFullSidebar = styled.div`
display: flex;
justify-content: flex-end;
flex: 1 0 15%;
z-index: 1;
top: 0;
right: 0;
padding: 10px;
cursor: pointer;
color: var(--text-disabled);
&:hover {
color: var(--text);
}
`;
export const ModalFullSidebarContainer = styled.div`
overflow: hidden scroll;
flex: 1 0 auto;
flex-direction: row;
display: flex;
justify-content: flex-end;
background-color: var(--background-secondary);
`;
export function Modal({ title, description, ...props }: ModalProps) {
const [closing, setClosing] = useState(false);
export const ModalFullSidebarContent = styled.div`
width: 200px;
padding: 40px 0;
display: flex;
flex-direction: column;
`;
const closeModal = useCallback(() => {
setClosing(true);
if (!closing) setTimeout(() => props.onClose?.(true), 2e2);
}, [closing, props]);
export const ModalFullContentContainer = styled.div`
display: flex;
flex: 1 1 700px;
align-items: flex-start;
`;
const confirm = useCallback(async () => {
if (await props.actions?.find((x) => x.confirmation)?.onClick?.()) {
closeModal();
}
}, [props.actions]);
export const ModalFullContentWrapper = styled.div`
flex: 1;
height: 100%;
`;
useEffect(() => {
if (props.signal === "confirm") {
confirm();
} else if (props.signal) {
if (props.signal === "close" && props.nonDismissable) {
return;
}
export const ModalFullContentContainerContentWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: row;
background-color: var(--background-primary);
`;
export const ModalFullContent = styled.div`
padding: 50px;
flex: 1 1 auto;
max-width: 80%;
min-width: 50%;
min-height: 100%;
`;
interface ModalProps extends StackedModalProps {
children: React.ReactNode;
full?: boolean;
onClick?: () => void;
/**
* Custom callback for when a modal is closed by clicking the background overlay
*/
onClose?: () => void;
style?: React.CSSProperties;
}
export function Modal(props: ModalProps) {
const { closeModal } = useModals();
closeModal();
}
}, [props.signal]);
return (
<AnimatePresence>
{props.open && (
<ModalBase
variants={{
show: {
opacity: 1,
scale: 1,
},
hide: {
opacity: 0,
scale: 0,
},
}}
initial="hide"
animate="show"
exit="hide"
onClick={(e) => {
if (e.target !== e.currentTarget) return;
if (props.onClose) props.onClose();
else closeModal();
}}
{...props}
>
<ModalWrapper full={props.full} style={props.style}>
{props.children}
</ModalWrapper>
</ModalBase>
)}
</AnimatePresence>
<Portal>
<ModalBase closing={closing} onClick={() => !props.nonDismissable && closeModal()}>
<ModalWrapper {...props} onClick={(e) => e.stopPropagation()} actions={false}>
<div style={{ position: "relative" }}>
{!props.nonDismissable && (
<ModalCloseWrapper onClick={closeModal}>
<Icon icon="mdiClose" size={1} />
</ModalCloseWrapper>
)}
</div>
{(title || description) && (
<ModalHeader>
{title && typeof title === "string" ? <ModalHeaderText>{title}</ModalHeaderText> : title}
{description && typeof description === "string" ? (
<ModalSubHeaderText>{description}</ModalSubHeaderText>
) : (
description
)}
</ModalHeader>
)}
<ModalContentContainer {...props}>{props.children}</ModalContentContainer>
{props.actions && props.actions.length > 0 && (
<Actions>
{props.actions.map((x, index) => (
<Button
disabled={props.disabled}
key={index}
{...x}
onClick={async () => {
if (await x.onClick()) {
closeModal();
}
}}
/>
))}
</Actions>
)}
</ModalWrapper>
</ModalBase>
</Portal>
);
}

View File

@ -1,75 +0,0 @@
import { ModalStackValue } from "@mattjennings/react-modal-stack";
import { AnimatePresence, motion } from "framer-motion";
import React from "react";
export type AnimatedModalProps = {
open: boolean;
};
function ModalRenderer({ stack }: ModalStackValue) {
const [displayedStack, setDisplayedStack] = React.useState(stack);
const [isOpen, setOpen] = React.useState(false);
React.useEffect(() => {
// we're opening the first modal, so update the stack right away
if (stack.length === 1 && displayedStack.length === 0) {
setOpen(true);
setDisplayedStack(stack);
}
// stack updated, trigger a dismissal of the current modal
else {
setOpen(false);
}
}, [stack]);
return (
<>
<AnimatePresence>
{stack.length > 0 && (
<motion.div
style={{
zIndex: 90,
position: `fixed`,
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0,0,0,0.8)",
}}
variants={{
show: { opacity: 1 },
hide: { opacity: 0 },
}}
initial="hide"
animate="show"
exit="hide"
/>
)}
</AnimatePresence>
{displayedStack.map((modal, index) => (
<modal.component
key={index}
open={index === displayedStack.length - 1 && isOpen}
onAnimationComplete={() => {
// set open state for next modal
if (stack.length > 0) {
setOpen(true);
} else {
setOpen(false);
}
// update displayed stack
// setTimeout is a hack to prevent a warning about updating state
// in an unmounted component (I can't figure out why it happens, or why this fixes it)
setTimeout(() => setDisplayedStack(stack));
modal.props?.onAnimationComplete?.();
}}
{...modal.props}
/>
))}
</>
);
}
export default ModalRenderer;

View File

@ -1,102 +1,3 @@
import { useModals } from "@mattjennings/react-modal-stack";
import { FormControlLabel, FormGroup, Switch } from "@mui/material";
import { observer } from "mobx-react-lite";
import { useAppStore } from "../../stores/AppStore";
import { GIT_BRANCH, GIT_REVISION, REPO_URL } from "../../utils/revison";
import Button from "../Button";
import Icon from "../Icon";
import Link from "../Link";
import {
Modal,
ModalCloseWrapper,
ModalFullContent,
ModalFullContentContainer,
ModalFullContentContainerContentWrapper,
ModalFullContentWrapper,
ModalFullSidebar,
ModalFullSidebarContainer,
ModalFullSidebarContent,
} from "./ModalComponents";
import { AnimatedModalProps } from "./ModalRenderer";
function SettingsModal(props: AnimatedModalProps) {
const app = useAppStore();
const { closeModal, closeAllModals } = useModals();
const logout = () => {
app.logout();
closeAllModals();
};
return (
<Modal full {...props}>
<ModalFullSidebar>
<ModalFullSidebarContainer>
<ModalFullSidebarContent>
SIDEBAR
<Button variant="danger" onClick={logout}>
Logout
</Button>
</ModalFullSidebarContent>
</ModalFullSidebarContainer>
</ModalFullSidebar>
<ModalFullContentContainer>
<ModalFullContentWrapper>
<ModalFullContentContainerContentWrapper>
<ModalFullContent>
<ModalCloseWrapper>
<button
onClick={closeModal}
style={{
background: "none",
border: "none",
outline: "none",
}}
>
<Icon
icon="mdiClose"
size={1}
style={{
cursor: "pointer",
color: "var(--text)",
}}
/>
</button>
</ModalCloseWrapper>
<div>
<span>Client Version: </span>
<Link href={`${REPO_URL}/commit/${GIT_REVISION}`} target="_blank" rel="noreferrer">
{GIT_REVISION.substring(0, 7)}
</Link>
{` `}
<Link
href={GIT_BRANCH !== "DETACHED" ? `${REPO_URL}/tree/${GIT_BRANCH}` : undefined}
target="_blank"
rel="noreferrer"
>
({GIT_BRANCH})
</Link>
</div>
<FormGroup>
<FormControlLabel
control={
<Switch
checked={app.fpsShown}
onChange={(e) => app.setFpsShown(e.target.checked)}
/>
}
label="Show FPS Graph"
/>
</FormGroup>
</ModalFullContent>
</ModalFullContentContainerContentWrapper>
</ModalFullContentWrapper>
</ModalFullContentContainer>
</Modal>
);
export function SettingsModal() {
return null;
}
export default observer(SettingsModal);

View File

@ -0,0 +1,12 @@
export * from "./AddServerModal";
export * from "./AttachmentPreviewModal";
export * from "./BanMemberModal";
export * from "./CreateInviteModal";
export * from "./CreateServerModal";
export * from "./DeleteMessageModal";
export * from "./ErrorModal";
export * from "./ForgotPasswordModal";
export * from "./JoinServerModal";
export * from "./KickMemberModal";
export * from "./LeaveServerModal";
export * from "./SettingsModal";

View File

@ -1,47 +1,79 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FloatingPortal, useFloating } from "@floating-ui/react";
import React from "react";
import { IContextMenuItem } from "../components/ContextMenuItem";
import useContextMenu, { ContextMenuComponents } from "../hooks/useContextMenu";
import Channel from "../stores/objects/Channel";
import Guild from "../stores/objects/Guild";
import GuildMember from "../stores/objects/GuildMember";
import { MessageLike } from "../stores/objects/Message";
import User from "../stores/objects/User";
export interface ContextMenuOpenProps {
position: { x: number; y: number };
items: { label: string; onClick: React.MouseEventHandler<HTMLDivElement> }[];
style?: React.CSSProperties;
}
export type ContextMenuProps =
| {
type: "user";
user: User;
member?: GuildMember;
}
| {
type: "message";
message: MessageLike;
}
| {
type: "channel";
channel: Channel;
}
| {
type: "guild";
guild: Guild;
};
const useValue = () => {
const [visible, setVisible] = React.useState(false);
const [position, setPosition] = React.useState({ x: 0, y: 0 });
const [items, setItems] = React.useState<IContextMenuItem[]>([]);
const [style, setStyle] = React.useState<ContextMenuOpenProps["style"]>({});
const open = (props: ContextMenuOpenProps) => {
setPosition(props.position);
setItems(props.items);
setStyle(props.style);
setVisible(true);
};
const open2 = (e: React.MouseEvent<HTMLDivElement, MouseEvent>, items: IContextMenuItem[]) => {
e.preventDefault();
e.stopPropagation();
setPosition({ x: e.pageX, y: e.pageY });
setItems(items);
setVisible(true);
};
return {
open,
open2,
close: () => setVisible(false),
visible,
position,
items,
style,
};
export type ContextMenuContextType = {
setReferenceElement: ReturnType<typeof useFloating>["refs"]["setReference"];
onContextMenu: (e: React.MouseEvent, props: ContextMenuProps) => void;
close: () => void;
open: (props: ContextMenuProps) => void;
};
export const ContextMenuContext = React.createContext({} as ReturnType<typeof useValue>);
// @ts-expect-error not specifying a default value here
export const ContextMenuContext = React.createContext<ContextMenuContextType>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const ContextMenuContextProvider: React.FC<any> = (props) => {
return <ContextMenuContext.Provider value={useValue()}>{props.children}</ContextMenuContext.Provider>;
export const ContextMenuContextProvider: React.FC<any> = ({ children }) => {
const contextMenu = useContextMenu();
const open = (props: ContextMenuProps) => {
contextMenu.open(props);
};
const Component = contextMenu.props
? ContextMenuComponents[contextMenu.props.type]
: () => {
return null;
};
return (
<ContextMenuContext.Provider
value={{
close: contextMenu.close,
open,
setReferenceElement: contextMenu.refs.setReference,
onContextMenu: contextMenu.onContextMenu,
}}
>
{children}
<FloatingPortal>
{contextMenu.isOpen && (
<div
className="ContextMenu"
ref={contextMenu.refs.setFloating}
style={contextMenu.floatingStyles}
{...contextMenu.getFloatingProps()}
onClick={() => [contextMenu.close()]}
>
<Component {...contextMenu.props} />
</div>
)}
</FloatingPortal>
</ContextMenuContext.Provider>
);
};

View File

@ -0,0 +1,6 @@
import React from "react";
import useFloating from "../hooks/useFloating";
type ContextType = ReturnType<typeof useFloating> | null;
export const FloatingContext = React.createContext<ContextType>(null);

View File

@ -1,48 +0,0 @@
import React from "react";
export interface PopoutOpenProps {
position: DOMRect;
element: React.ReactNode;
placement?: "left" | "right" | "top" | "bottom";
}
const useValue = () => {
const [position, setPosition] = React.useState<DOMRect>(new DOMRect(0, 0, 0, 0));
const [element, setElement] = React.useState<React.ReactNode>();
const [isOpen, setIsOpen] = React.useState(false);
const [placement, setPlacement] = React.useState<"left" | "right" | "top" | "bottom">();
const close = () => {
setIsOpen(false);
setElement(undefined);
};
const open = (props: PopoutOpenProps) => {
// clicking again on the same trigger should close it
if (isOpen && JSON.stringify(position) === JSON.stringify(props.position)) {
close();
return;
}
setPosition(props.position);
setElement(props.element);
setIsOpen(true);
setPlacement(props.placement ?? "right");
};
return {
open,
close,
position,
element,
isOpen,
setIsOpen,
placement,
};
};
export const PopoutContext = React.createContext({} as ReturnType<typeof useValue>);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const PopoutContextProvider: React.FC<any> = (props) => {
return <PopoutContext.Provider value={useValue()}>{props.children}</PopoutContext.Provider>;
};

View File

@ -6,10 +6,13 @@ import { rgbToHsl } from "../utils/Utils";
const font: ThemeFont["font"] = {
weight: {
thin: 100,
// extraLight: 200,
light: 300,
regular: 400,
medium: 500,
// semiBold: 600,
bold: 700,
// extraBold: 800,
black: 900,
},
family: "Roboto, Arial, Helvetica, sans-serif",
@ -58,7 +61,8 @@ export type ThemeVariables =
| "scrollbarTrack"
| "scrollbarThumb"
| "statusIdle"
| "statusOffline";
| "statusOffline"
| "accent";
export type Overrides = {
[variable in ThemeVariables]: string;
@ -68,10 +72,13 @@ export type ThemeFont = {
font: {
weight: {
thin?: number;
extraLight?: number;
light?: number;
regular?: number;
medium?: number;
semiBold?: number;
bold?: number;
extraBold?: number;
black?: number;
};
family: string;
@ -108,9 +115,10 @@ export const ThemePresets: Record<string, Theme> = {
primaryLight: "#339dff",
primaryDark: "#005db2",
primaryContrastText: "#ffffff",
secondary: "#000115",
secondaryLight: "#000677",
secondaryDark: "#000111",
accent: "#000115",
secondary: "#4e4e4e",
secondaryLight: "#ff9633",
secondaryDark: "#b25e00",
secondaryContrastText: "",
danger: "",
dangerLight: "",
@ -153,11 +161,10 @@ export const ThemePresets: Record<string, Theme> = {
primaryLight: "#339dff",
primaryDark: "#005db2",
primaryContrastText: "#ffffff",
secondary: "#000115",
secondaryLight: "#000677",
secondaryDark: "#000111",
// secondary: "#ff7c01",
// secondaryLight: "#ff9633",
accent: "#000115",
secondary: "#4e4e4e",
secondaryLight: "#ff9633",
secondaryDark: "#b25e00",
secondaryContrastText: "#040404",
danger: "#ff3a3b",
dangerLight: "#ff6162",

View File

@ -0,0 +1,186 @@
// adapted from https://github.com/revoltchat/revite/blob/master/src/controllers/modals/ModalController.tsx
import { action, computed, makeObservable, observable } from "mobx";
import {
AddServerModal,
BanMemberModal,
CreateInviteModal,
CreateServerModal,
DeleteMessageModal,
ErrorModal,
JoinServerModal,
KickMemberModal,
LeaveServerModal,
} from "../../components/modals";
import { Modal } from "./types";
function randomUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
// eslint-disable-next-line no-bitwise
const r = (Math.random() * 16) | 0;
// eslint-disable-next-line no-bitwise, no-mixed-operators
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Components = Record<string, React.FC<any>>;
/**
* Handles layering and displaying modals to the user.
*/
class ModalController<T extends Modal> {
stack: T[] = [];
components: Components;
constructor(components: Components) {
this.components = components;
makeObservable(this, {
stack: observable,
push: action,
pop: action,
remove: action,
rendered: computed,
isVisible: computed,
});
this.close = this.close.bind(this);
this.closeAll = this.closeAll.bind(this);
}
/**
* Display a new modal on the stack
* @param modal Modal data
*/
push(modal: T) {
this.stack = [
...this.stack,
{
...modal,
key: randomUUID(), // TODO:
},
];
}
/**
* Remove the top modal from the screen
* @param signal What action to trigger
*/
pop(signal: "close" | "confirm" | "force") {
this.stack = this.stack.map((entry, index) => (index === this.stack.length - 1 ? { ...entry, signal } : entry));
}
/**
* Close the top modal
*/
close() {
this.pop("close");
}
/**
* Close all modals on the stack
*/
closeAll() {
this.stack = [];
}
/**
* Remove the keyed modal from the stack
*/
remove(key: string) {
this.stack = this.stack.filter((x) => x.key !== key);
}
/**
* Render modals
*/
get rendered() {
return (
<>
{this.stack.map((modal) => {
const Component = this.components[modal.type];
if (!Component) return null;
return <Component {...modal} onClose={() => this.remove(modal.key!)} />;
})}
</>
);
}
/**
* Whether a modal is currently visible
*/
get isVisible() {
return this.stack.length > 0;
}
}
/**
* Modal controller with additional helpers.
*/
class ModalControllerExtended extends ModalController<Modal> {
/**
* Write text to the clipboard
* @param text Text to write
*/
writeText(text: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
this.push({
type: "clipboard",
text,
});
}
}
}
export const modalController = new ModalControllerExtended({
add_server: AddServerModal,
// add_friend: AddFriend,
ban_member: BanMemberModal,
// changelog: Changelog,
// channel_info: ChannelInfo,
// clipboard: Clipboard,
// leave_group: ConfirmLeave,
// close_dm: Confirmation,
leave_server: LeaveServerModal,
// delete_server: Confirmation,
// delete_channel: Confirmation,
// delete_bot: Confirmation,
// block_user: Confirmation,
// unfriend_user: Confirmation,
// create_category: CreateCategory,
// create_channel: CreateChannel,
// create_group: CreateGroup,
create_invite: CreateInviteModal,
// create_role: CreateRole,
create_server: CreateServerModal,
join_server: JoinServerModal,
// create_bot: CreateBot,
// custom_status: CustomStatus,
delete_message: DeleteMessageModal,
error: ErrorModal,
// image_viewer: ImageViewer,
kick_member: KickMemberModal,
// link_warning: LinkWarning,
// mfa_flow: MFAFlow,
// mfa_recovery: MFARecovery,
// mfa_enable_totp: MFAEnableTOTP,
// modify_account: ModifyAccount,
// onboarding: OnboardingModal,
// out_of_date: OutOfDate,
// pending_friend_requests: PendingFriendRequests,
// server_identity: ServerIdentity,
// server_info: ServerInfo,
// show_token: ShowToken,
// signed_out: SignedOut,
// sign_out_sessions: SignOutSessions,
// user_picker: UserPicker,
// user_profile: UserProfile,
// report: ReportContent,
// report_success: ReportSuccess,
// modify_displayname: ModifyDisplayname,
// changelog_usernames: ChangelogUsernames,
});

View File

@ -0,0 +1,24 @@
// adapted from https://github.com/revoltchat/revite/blob/master/src/controllers/modals/ModalRenderer.tsx
// Removed usage of `Prompt`
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { modalController } from "./ModalController";
export default observer(() => {
useEffect(() => {
function keyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
modalController.pop("close");
} else if (event.key === "Enter") {
if (event.target instanceof HTMLSelectElement) return;
modalController.pop("confirm");
}
}
document.addEventListener("keydown", keyDown);
return () => document.removeEventListener("keydown", keyDown);
}, []);
return <>{modalController.rendered}</>;
});

View File

@ -0,0 +1,3 @@
export * from "./ModalController";
export * from "./ModalRenderer";
export * from "./types";

View File

@ -0,0 +1,50 @@
// adapted from https://github.com/revoltchat/revite/blob/master/src/controllers/modals/types.ts
import Channel from "../../stores/objects/Channel";
import Guild from "../../stores/objects/Guild";
import GuildMember from "../../stores/objects/GuildMember";
import Message from "../../stores/objects/Message";
export type Modal = {
key?: string;
} & (
| {
type: "add_server" | "create_server" | "join_server";
}
| {
type: "error";
title: string;
description?: string;
error: string;
recoverable?: boolean;
}
| {
type: "clipboard";
text: string;
}
| {
type: "create_invite";
target: Channel;
}
| {
type: "kick_member";
target: GuildMember;
}
| {
type: "ban_member";
target: GuildMember;
}
| {
type: "delete_message";
target: Message;
}
| {
type: "leave_server";
target: Guild;
}
);
export type ModalProps<T extends Modal["type"]> = Modal & { type: T } & {
onClose: () => void;
signal?: "close" | "confirm";
};

View File

@ -0,0 +1,103 @@
import { autoUpdate, flip, offset, shift, useDismiss, useFloating, useInteractions, useRole } from "@floating-ui/react";
import { useMemo, useState } from "react";
import ChannelContextMenu from "../components/contextMenus/ChannelContextMenu";
import GuildContextMenu from "../components/contextMenus/GuildContextMenu";
import MessageContextMenu from "../components/contextMenus/MessageContextMenu";
import UserContextMenu from "../components/contextMenus/UserContextMenu";
import { ContextMenuProps } from "../contexts/ContextMenuContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Components = Record<string, React.FC<any>>;
export const ContextMenuComponents: Components = {
user: UserContextMenu,
message: MessageContextMenu,
channel: ChannelContextMenu,
guild: GuildContextMenu,
};
export default function () {
const [isOpen, setIsOpen] = useState(false);
const [props, setProps] = useState<ContextMenuProps | null>(null);
const data = useFloating({
placement: "right-start",
strategy: "fixed",
open: isOpen && props !== null,
onOpenChange: setIsOpen,
whileElementsMounted: autoUpdate,
middleware: [
offset(5),
flip({
fallbackPlacements: ["left-start"],
}),
shift({
padding: 8,
}),
],
});
const context = data.context;
const role = useRole(context, { role: "menu" });
const dismiss = useDismiss(context);
// const listNavigation = useListNavigation(context, {
// listRef: listItemsRef,
// onNavigate: setActiveIndex,
// activeIndex
// });
// const typeahead = useTypeahead(context, {
// enabled: isOpen,
// listRef: listContentRef,
// onMatch: setActiveIndex,
// activeIndex
// });
const interactions = useInteractions([dismiss, role]);
const open = (props: ContextMenuProps) => {
setProps(props);
setIsOpen(true);
};
const close = () => {
setIsOpen(false);
};
function onContextMenu(e: React.MouseEvent, props: ContextMenuProps) {
e.preventDefault();
e.stopPropagation();
data.refs.setPositionReference({
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
right: e.clientX,
bottom: e.clientY,
left: e.clientX,
};
},
});
setProps(props);
setIsOpen(true);
}
return useMemo(
() => ({
isOpen,
props,
setProps,
open,
close,
onContextMenu,
...interactions,
...data,
}),
[isOpen, props, setProps, open, close, onContextMenu, interactions, data],
);
}

80
src/hooks/useFloating.tsx Normal file
View File

@ -0,0 +1,80 @@
import {
arrow,
autoUpdate,
flip,
offset,
shift,
useClick,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useRole,
} from "@floating-ui/react";
import { useMemo, useRef, useState } from "react";
import { FloatingOptions } from "../components/floating/Floating";
export default function ({
type,
initialOpen = false,
offset: offsetMiddlewareOffset,
placement,
open: controlledOpen,
onOpenChange: setControlledOpen,
}: Omit<FloatingOptions, "props">) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen);
const arrowRef = useRef<SVGSVGElement>(null);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = setControlledOpen ?? setUncontrolledOpen;
const data = useFloating({
placement: placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
offset(type === "tooltip" && !offsetMiddlewareOffset ? 10 : offsetMiddlewareOffset ?? 5),
flip(),
shift({
padding: 8,
}),
arrow({
element: arrowRef,
padding: 4,
}),
],
});
const context = data.context;
const click = useClick(context, {
enabled: type !== "tooltip",
});
const dismiss = useDismiss(context);
const role = useRole(context, {
role: type === "tooltip" ? "tooltip" : undefined,
});
const hover = useHover(context, {
move: false,
enabled: type == "tooltip",
});
const focus = useFocus(context, {
enabled: type == "tooltip",
});
const interactions = useInteractions([click, dismiss, role, hover, focus]);
return useMemo(
() => ({
open,
setOpen,
...interactions,
...data,
arrowRef,
}),
[open, setOpen, interactions, data],
);
}

View File

@ -0,0 +1,12 @@
import React from "react";
import { FloatingContext } from "../contexts/FloatingContext";
export default () => {
const context = React.useContext(FloatingContext);
if (context == null) {
throw new Error("Floating components must be wrapped in <Floating />");
}
return context;
};

View File

@ -18,9 +18,12 @@ textarea {
margin: 0;
}
html *:not(code) {
font-family: var(--font-family);
}
body {
margin: 0;
font-family: var(--font-family);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;

View File

@ -11,7 +11,7 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import "@fontsource/roboto/900.css";
import { ModalStack } from "@mattjennings/react-modal-stack";
import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar";
import relativeTime from "dayjs/plugin/relativeTime";
@ -19,11 +19,10 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { ErrorBoundaryContext } from "react-use-error-boundary";
import App from "./App";
import ModalRenderer from "./components/modals/ModalRenderer";
import { BannerContextProvider } from "./contexts/BannerContext";
import { ContextMenuContextProvider } from "./contexts/ContextMenuContext";
import { PopoutContextProvider } from "./contexts/PopoutContext";
import Theme from "./contexts/Theme";
import ModalRenderer from "./controllers/modals/ModalRenderer";
import "./index.css";
import { calendarStrings } from "./utils/i18n";
@ -33,16 +32,13 @@ dayjs.extend(calendar, calendarStrings);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<ErrorBoundaryContext>
<BrowserRouter>
<ModalStack renderModals={ModalRenderer}>
<PopoutContextProvider>
<ContextMenuContextProvider>
<BannerContextProvider>
<App />
</BannerContextProvider>
</ContextMenuContextProvider>
</PopoutContextProvider>
<Theme />
</ModalStack>
<BannerContextProvider>
<ContextMenuContextProvider>
<App />
<ModalRenderer />
</ContextMenuContextProvider>
</BannerContextProvider>
<Theme />
</BrowserRouter>
</ErrorBoundaryContext>,
);

View File

@ -40,7 +40,7 @@ function LoadingPage() {
bottom: "30vh",
}}
>
<Button variant="danger" onClick={() => app.logout()}>
<Button palette="danger" onClick={() => app.logout()}>
Logout
</Button>
</div>

View File

@ -1,5 +1,4 @@
import HCaptchaLib from "@hcaptcha/react-hcaptcha";
import { useModals } from "@mattjennings/react-modal-stack";
import { Routes } from "@spacebarchat/spacebar-api-types/v9";
import React from "react";
import { useForm } from "react-hook-form";
@ -24,7 +23,6 @@ import {
} from "../components/AuthComponents";
import { TextDivider } from "../components/Divider";
import HCaptcha, { HeaderContainer } from "../components/HCaptcha";
import ForgotPasswordModal from "../components/modals/ForgotPasswordModal";
import useLogger from "../hooks/useLogger";
import { AUTH_NO_BRANDING, useAppStore } from "../stores/AppStore";
import { Globals } from "../utils/Globals";
@ -56,7 +54,6 @@ function LoginPage() {
const captchaRef = React.useRef<HCaptchaLib>(null);
const [debounce, setDebounce] = React.useState<NodeJS.Timeout | null>(null);
const [isCheckingInstance, setCheckingInstance] = React.useState(false);
const { openModal } = useModals();
const {
register,
@ -208,7 +205,7 @@ function LoginPage() {
};
const forgotPassword = () => {
openModal(ForgotPasswordModal);
// TODO: forgot password modal
};
if (captchaSiteKey) {
@ -331,7 +328,7 @@ function LoginPage() {
Forgot your password?
</PasswordResetLink> */}
<SubmitButton variant="primary" type="submit" disabled={loading}>
<SubmitButton palette="primary" type="submit" disabled={loading}>
Login
</SubmitButton>

View File

@ -275,7 +275,7 @@ function RegistrationPage() {
</InputWrapper>
</InputContainer>
<SubmitButton variant="primary" type="submit" disabled={loading}>
<SubmitButton palette="primary" type="submit" disabled={loading}>
Create Account
</SubmitButton>

View File

@ -5,14 +5,9 @@ import styled from "styled-components";
import Banner from "../../components/Banner";
import ChannelSidebar from "../../components/ChannelSidebar";
import ContainerComponent from "../../components/Container";
import ContextMenu from "../../components/ContextMenu";
import ErrorBoundary from "../../components/ErrorBoundary";
import GuildSidebar from "../../components/GuildSidebar";
import PopoutRenderer from "../../components/PopoutRenderer";
import Chat from "../../components/messaging/Chat";
import { BannerContext } from "../../contexts/BannerContext";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import { PopoutContext } from "../../contexts/PopoutContext";
import { useAppStore } from "../../stores/AppStore";
const Container = styled(ContainerComponent)`
@ -29,9 +24,6 @@ const Wrapper = styled.div`
function ChannelPage() {
const app = useAppStore();
const contextMenuContext = React.useContext(ContextMenuContext);
const popoutContext = React.useContext(PopoutContext);
const bannerContext = React.useContext(BannerContext);
const { guildId, channelId } = useParams<{ guildId: string; channelId: string }>();
@ -44,8 +36,6 @@ function ChannelPage() {
<Container>
<Banner />
<Wrapper>
{contextMenuContext.visible && <ContextMenu {...contextMenuContext} />}
{popoutContext.element && <PopoutRenderer {...popoutContext} />}
<GuildSidebar />
<ChannelSidebar />
<ErrorBoundary section="component">

View File

@ -130,7 +130,7 @@ function MFA(props: Props) {
</InputWrapper>
</InputContainer>
<SubmitButton variant="primary" type="submit" disabled={loading}>
<SubmitButton palette="primary" type="submit" disabled={loading}>
Log In
</SubmitButton>

View File

@ -53,4 +53,8 @@ export default class ChannelStore {
sortPosition(channels: Channel[]) {
return channels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
}
has(id: string) {
return this.channels.has(id);
}
}

View File

@ -1,6 +1,7 @@
import type { Snowflake } from "@spacebarchat/spacebar-api-types/globals";
import type { APIGuildMember } from "@spacebarchat/spacebar-api-types/v9";
import { action, computed, makeObservable, observable, ObservableMap } from "mobx";
import { type APIGuildMember } from "@spacebarchat/spacebar-api-types/v9";
import { ObservableMap, action, computed, makeObservable, observable } from "mobx";
import { APIUserProfile } from "../utils/interfaces/api";
import AppStore from "./AppStore";
import Guild from "./objects/Guild";
import GuildMember from "./objects/GuildMember";
@ -71,4 +72,16 @@ export default class GuildMemberStore {
if (!meId) return null;
return this.members.get(meId);
}
@action
async fetch(id: Snowflake): Promise<GuildMember | undefined> {
if (this.has(id)) return this.get(id);
const profile = await this.app.rest.get<APIUserProfile>(`/users/${id}/profile`, {
guild_id: this.guild.id,
});
if (!profile.guild_member) return undefined;
profile.guild_member.user = profile.user;
return this.add(profile.guild_member);
}
}

View File

@ -1,11 +1,11 @@
import type { GatewayPresenceUpdateDispatchData, Snowflake } from "@spacebarchat/spacebar-api-types/v9";
import { ObservableMap, action, computed, makeObservable, observable } from "mobx";
import { action, computed, makeObservable, observable } from "mobx";
import AppStore from "./AppStore";
import Presence from "./objects/Presence";
export default class PresenceStore {
private readonly app: AppStore;
@observable presences = observable.map<Snowflake, ObservableMap<Snowflake, Presence>>();
@observable presences = observable.map<Snowflake, Presence>();
constructor(app: AppStore) {
this.app = app;
@ -15,11 +15,11 @@ export default class PresenceStore {
@action
add(data: GatewayPresenceUpdateDispatchData) {
if (!this.presences.has(data.guild_id)) {
this.presences.set(data.guild_id, observable.map<Snowflake, Presence>());
if (!this.presences.has(data.user.id)) {
this.presences.set(data.user.id, new Presence(this.app, data));
} else {
this.update(data);
}
this.presences.get(data.guild_id)?.set(data.user.id, new Presence(this.app, data));
}
@action
@ -39,7 +39,7 @@ export default class PresenceStore {
@action
update(data: GatewayPresenceUpdateDispatchData) {
this.presences.get(data.guild_id)?.get(data.user.id)?.update(data);
this.presences.get(data.user.id)?.update(data);
}
get(id: Snowflake) {

View File

@ -146,7 +146,9 @@ export default class Guild {
@computed
get channels() {
const guildChannels = this.app.channels.all.filter((channel) => this.channels_.has(channel.id));
const guildChannels = this.app.channels.all.filter(
(channel) => this.channels_.has(channel.id) && channel.hasPermission("VIEW_CHANNEL"),
);
const topLevelChannels = guildChannels.filter((channel) => !channel.parentId);
const sortedChannels = topLevelChannels
.sort(compareChannels)

View File

@ -21,6 +21,7 @@ import {
} from "@spacebarchat/spacebar-api-types/v9";
import { action, makeObservable, observable } from "mobx";
import AppStore from "../AppStore";
import Channel from "./Channel";
import MessageBase from "./MessageBase";
import QueuedMessage, { QueuedMessageData } from "./QueuedMessage";
@ -31,7 +32,8 @@ export default class Message extends MessageBase {
/**
* ID of the channel the message was sent in
*/
channel_id: Snowflake;
// channel_id: Snowflake;
channel: Channel;
/**
* When this message was edited (or null if never)
*/
@ -205,7 +207,8 @@ export default class Message extends MessageBase {
super(app, data);
this.id = data.id;
this.channel_id = data.channel_id;
// this.channel_id = data.channel_id;
this.channel = this.app.channels.get(data.channel_id)!;
// this.member = message.member ? new GuildMember(message.member) : undefined;
this.content = data.content;
this.timestamp = new Date(data.timestamp);
@ -250,6 +253,6 @@ export default class Message extends MessageBase {
}
async delete() {
await this.app.rest.delete(Routes.channelMessage(this.channel_id, this.id));
await this.app.rest.delete(Routes.channelMessage(this.channel.id, this.id));
}
}

View File

@ -1,167 +0,0 @@
import { APIAttachment } from "@spacebarchat/spacebar-api-types/v9";
import { IContextMenuItem } from "../components/ContextMenuItem";
import AccountStore from "../stores/AccountStore";
import AppStore from "../stores/AppStore";
import Guild from "../stores/objects/Guild";
import GuildMember from "../stores/objects/GuildMember";
import { MessageLike } from "../stores/objects/Message";
import User from "../stores/objects/User";
import { Permissions } from "./Permissions";
export default {
User: (user: User | AccountStore): IContextMenuItem[] => {
return [
{
label: "Copy User ID",
onClick: () => {
navigator.clipboard.writeText(user.id);
},
iconProps: {
icon: "mdiIdentifier",
},
},
];
},
Message: (app: AppStore, message: MessageLike, account: AccountStore | null): IContextMenuItem[] => {
const channel = app.channels.get(message.channel_id);
const permissions = Permissions.getPermission(account?.id, channel?.guild, channel);
const canDeleteMessage = permissions.has("MANAGE_MESSAGES") || message.author.id === account?.id;
const items: IContextMenuItem[] = [
{
label: "Copy Message ID",
onClick: () => {
navigator.clipboard.writeText(message.id);
},
iconProps: {
icon: "mdiIdentifier",
},
},
{
label: "Copy Raw Text",
onClick: () => {
navigator.clipboard.writeText(message.content);
},
iconProps: {
icon: "mdiRaw",
},
},
];
if (canDeleteMessage) {
items.push({
label: "Delete Message",
onClick: () => {
message.delete();
},
iconProps: {
icon: "mdiTrashCanOutline",
color: "red",
},
color: "red",
hover: {
backgroundColor: "red",
color: "white",
},
});
}
return items;
},
MessageAttachment: (attachment: APIAttachment): IContextMenuItem[] => {
return [
{
label: "Copy Attachment URL",
onClick: () => {
navigator.clipboard.writeText(attachment.url);
},
iconProps: {
icon: "mdiLink",
},
},
];
},
// TODO: check if target has higher role
Member: (me: AccountStore, them: GuildMember, guild?: Guild): IContextMenuItem[] => {
const permissions = Permissions.getPermission(me.id, guild);
const items: IContextMenuItem[] = [];
// if (permissions.has("KICK_MEMBERS")) {
// items.push({
// label: `Kick ${them.user!.username}`,
// onClick: () => {
// // openModal(KickModal, {
// // member: them,
// // });
// },
// color: "red",
// hover: {
// backgroundColor: "red",
// color: "white",
// },
// });
// }
// if (permissions.has("BAN_MEMBERS")) {
// items.push({
// label: `Ban ${them.user!.username}`,
// onClick: () => {
// // member.kick()
// console.log("ban member");
// },
// color: "red",
// hover: {
// backgroundColor: "red",
// color: "white",
// },
// });
// }
return items;
},
// TODO: check if target has higher role
Member2: (app: AppStore, them: User, guildId: string): IContextMenuItem[] => {
const me = app.account!;
const guild = app.guilds.get(guildId);
if (!guild) return [];
const member = guild.members.get(them.id);
if (!member) return [];
const permissions = Permissions.getPermission(me.id, guild);
const items: IContextMenuItem[] = [];
if (permissions.has("KICK_MEMBERS")) {
items.push({
label: `Kick ${them.username}`,
onClick: () => {
// openModal(KickModal, {
// member,
// });
},
color: "red",
hover: {
backgroundColor: "red",
color: "white",
},
});
}
if (permissions.has("BAN_MEMBERS")) {
items.push({
label: `Ban ${them.username}`,
onClick: () => {
// member.kick()
console.log("ban member");
},
color: "red",
hover: {
backgroundColor: "red",
color: "white",
},
});
}
return items;
},
};

View File

@ -1,3 +1,5 @@
import { APIGuildMember, PublicUser } from "@spacebarchat/spacebar-api-types/v9";
export interface IAPILoginResponseMFARequired {
token: null;
mfa: true;
@ -119,3 +121,29 @@ export interface APIError {
}
// export type RESTAPIPostInviteResponse = {} | IAPIError;
export type UserProfile = Pick<PublicUser, "bio" | "accent_color" | "banner" | "pronouns" | "theme_colors">;
export type MutualGuild = {
id: string;
nick?: string;
};
export type PublicMemberProfile = Pick<APIGuildMember, "banner" | "bio" | "pronouns" | "theme_colors"> & {
accent_color: unknown; // TODO:
emoji: unknown; // TODO:
guild_id: string;
};
export interface APIUserProfile {
user: PublicUser;
connected_accounts: unknown[]; // TODO: type
premium_guild_since?: Date;
premium_since?: Date;
mutual_guilds: unknown[]; // TODO: type
premium_type: number;
profile_themes_experiment_bucket: number;
user_profile: UserProfile;
guild_member?: APIGuildMember;
guild_member_profile?: PublicMemberProfile;
}