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

Merge pull request #215 from spacebarchat/dev

merge dev into main
This commit is contained in:
Puyodead1 2024-03-10 21:29:41 -04:00 committed by GitHub
commit 23c4458f08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
164 changed files with 16624 additions and 7136 deletions

View File

@ -31,12 +31,20 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
- name: Install Frontend Dependencies
run: pnpm i
- uses: tauri-apps/tauri-action@v0
- name: Run version generator
run: pnpm run ci:prebuild
- uses: spacebarchat/tauri-action@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: spacebar-v__VERSION__
releaseName: "Spacebar Client v__VERSION__"
releaseBody: "See the assets to download this version and install."
tagName: client-__BRANCH__-v__VERSION__
releaseName: "Spacebar Client v__VERSION__ (__BRANCH__)"
releaseBody: "See the assets to download this version and install. The current commit is __SHA__."
releaseDraft: false
prerelease: true
includeDebug: true
includeRelease: true
includeUpdaterJson: true
buildIdAsVersion: true

2
.gitignore vendored
View File

@ -9,7 +9,7 @@
/coverage
# production
/build
/dist
# misc
.DS_Store

View File

@ -3,3 +3,5 @@ dist
node_modules
.github
.vscode
src-tauri/target
src-tauri/gen

View File

@ -26,7 +26,7 @@
makeCacheWritable = true;
installPhase = ''
runHook preInstall
cp -r build $out/
cp -r dist $out/
runHook postInstall
'';
};

View File

@ -26,7 +26,7 @@
makeCacheWritable = true;
installPhase = ''
runHook preInstall
cp -r build $out/
cp -r dist $out/
runHook postInstall
'';
};

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<!-- Primary Meta Tags -->

View File

@ -4,111 +4,120 @@
"url": "https://github.com/spacebarchat/client/issues"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^4.5.8",
"@fontsource/roboto-mono": "^5.0.8",
"@hcaptcha/react-hcaptcha": "^1.8.1",
"@floating-ui/react": "^0.26.9",
"@fontsource/roboto": "^5.0.8",
"@fontsource/roboto-mono": "^5.0.16",
"@hcaptcha/react-hcaptcha": "^1.10.1",
"@hookform/resolvers": "^3.3.4",
"@mattjennings/react-modal-stack": "^1.0.4",
"@mdi/js": "^7.2.96",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@mui/material": "^5.14.9",
"@mui/material": "^5.15.11",
"@originjs/vite-plugin-commonjs": "^1.0.3",
"@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-replace": "^5.0.5",
"@spacebarchat/spacebar-api-types": "0.37.51",
"@tauri-apps/api": "2.0.0-alpha.8",
"@tauri-apps/plugin-authenticator": "2.0.0-alpha.1",
"@tauri-apps/plugin-autostart": "2.0.0-alpha.1",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.1",
"@tauri-apps/plugin-dialog": "2.0.0-alpha.1",
"@tauri-apps/plugin-log": "2.0.0-alpha.1",
"@tauri-apps/plugin-notification": "2.0.0-alpha.1",
"@tauri-apps/plugin-process": "2.0.0-alpha.1",
"@tauri-apps/plugin-stronghold": "2.0.0-alpha.2",
"@tauri-apps/plugin-updater": "2.0.0-alpha.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@tauri-apps/api": "2.0.0-beta.3",
"@tauri-apps/plugin-authenticator": "2.0.0-beta.1",
"@tauri-apps/plugin-autostart": "2.0.0-beta.1",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-beta.1",
"@tauri-apps/plugin-dialog": "2.0.0-beta.1",
"@tauri-apps/plugin-log": "2.0.0-beta.1",
"@tauri-apps/plugin-notification": "2.0.0-beta.1",
"@tauri-apps/plugin-os": "2.0.0-beta.1",
"@tauri-apps/plugin-process": "2.0.0-beta.1",
"@tauri-apps/plugin-stronghold": "2.0.0-beta.1",
"@tauri-apps/plugin-updater": "2.0.0-beta.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/react-measure": "^2.0.12",
"classnames": "^2.3.2",
"dayjs": "^1.11.9",
"framer-motion": "^10.16.4",
"@types/react-portal": "^4.0.7",
"classnames": "^2.5.1",
"csstype": "^3.1.3",
"dayjs": "^1.11.10",
"framer-motion": "^11.0.6",
"marked-react": "^2.0.0",
"missing-native-js-functions": "^1.4.3",
"mobx": "^6.10.2",
"mobx-react-lite": "^3.4.3",
"mobx": "^6.12.0",
"mobx-react-lite": "^4.0.5",
"murmurhash-js": "^1.0.0",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-advanced-cropper": "^0.18.0",
"react-advanced-cropper": "^0.19.5",
"react-colorful": "^5.6.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-fps-stats": "^0.3.1",
"react-hook-form": "^7.46.1",
"react-hook-form": "^7.50.1",
"react-infinite-scroll-component": "^6.1.0",
"react-loading-skeleton": "^3.3.1",
"react-markdown": "^8.0.7",
"react-loading-skeleton": "^3.4.0",
"react-markdown": "^9.0.1",
"react-measure": "^2.5.2",
"react-router-dom": "^6.16.0",
"react-secure-storage": "^1.3.0",
"react-select-search": "^4.1.6",
"react-portal": "^4.2.2",
"react-router-dom": "^6.22.1",
"react-secure-storage": "^1.3.2",
"react-select-search": "^4.1.7",
"react-spinners": "^0.13.8",
"react-string-replace": "^1.1.1",
"react-syntax-highlighter": "^15.5.0",
"react-use-error-boundary": "^3.0.0",
"react-virtualized": "^9.22.5",
"remark-gfm": "^3.0.1",
"remark-gfm": "^4.0.0",
"reoverlay": "^1.0.3",
"styled-components": "^5.3.11",
"use-resize-observer": "^9.1.0"
"styled-components": "5.3.11",
"use-resize-observer": "^9.1.0",
"yup": "^1.3.3"
},
"devDependencies": {
"@craco/craco": "^7.1.0",
"@tauri-apps/cli": "2.0.0-alpha.16",
"@types/jest": "^27.5.2",
"@types/loadable__component": "^5.13.5",
"@tauri-apps/cli": "2.0.0-beta.5",
"@types/jest": "^29.5.12",
"@types/loadable__component": "^5.13.8",
"@types/murmurhash-js": "^1.0.6",
"@types/node": "^16.18.50",
"@types/react": "^18.2.22",
"@types/react-dom": "^18.2.7",
"@types/react-syntax-highlighter": "^15.5.7",
"@types/react-virtualized": "^9.21.22",
"@types/styled-components": "^5.1.27",
"@typescript-eslint/eslint-plugin": "^6.7.0",
"@typescript-eslint/parser": "^6.7.0",
"@vitejs/plugin-react": "^4.0.4",
"eslint": "^8.49.0",
"@types/node": "^20.11.20",
"@types/react": "^18.2.60",
"@types/react-dom": "^18.2.19",
"@types/react-syntax-highlighter": "^15.5.11",
"@types/react-virtualized": "^9.21.29",
"@types/styled-components": "^5.1.34",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitejs/plugin-react": "^4.2.1",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"internal-ip": "^7.0.0",
"typescript": "^5.2.2",
"vite": "^4.5.2",
"vite-plugin-chunk-split": "^0.4.7",
"eslint-plugin-react-refresh": "^0.4.5",
"internal-ip": "^8.0.0",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vite-plugin-chunk-split": "^0.5.0",
"vite-plugin-clean": "^1.0.0",
"vite-plugin-html": "^3.2.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-progress": "^0.0.7",
"vite-plugin-svgr": "^3.2.0"
"vite-plugin-svgr": "^4.2.0"
},
"homepage": "https://spacebar.chat",
"license": "AGPL-3.0-only",
"name": "spacebar-client",
"packageManager": "pnpm@8.10.2",
"packageManager": "pnpm@8.14.0",
"repository": {
"type": "git",
"url": "git+https://github.com/spacebarchat/client.git"
},
"scripts": {
"build": "tsc && vite build",
"ci:prebuild": "node scripts/tauri-version.js",
"dev": "vite",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "pnpx prettier . --write",
"preview": "vite preview",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:android:dev": "tauri android dev",
"tauri:android:build": "tauri android build"
"tauri:dev": "pnpm run ci:prebuild && tauri dev",
"tauri:build": "pnpm run ci:prebuild && tauri build",
"tauri:android:dev": "pnpm run ci:prebuild && tauri android dev",
"tauri:android:build": "pnpm run ci:prebuild && tauri android build"
},
"type": "module",
"version": "0.1.1"
"version": "0.1.2"
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,3 +1,15 @@
/* roboto-latin-400-normal */
@font-face {
font-family: "Roboto";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/roboto@latest/latin-400-normal.woff2) format("woff2"),
url(https://cdn.jsdelivr.net/fontsource/fonts/roboto@latest/latin-400-normal.woff) format("woff");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
* {
padding: 0;
margin: 0;
@ -5,76 +17,19 @@
body {
overflow: hidden;
background: url(Spacebar.png);
background-size: cover;
background-color: #121212;
color: #fff;
font-family: "Roboto", sans-serif;
}
img {
max-width: 100%;
max-height: 100vh;
margin: auto;
svg {
max-width: 80vw;
}
.loader-wrapper {
height: 100vh;
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
align-items: center;
height: 100vh;
}
.lds-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}

View File

@ -7,12 +7,73 @@
<title>Splashscreen</title>
</head>
<body>
<div class="loader-wrapper">
<div class="lds-ellipsis">
<div></div>
<div></div>
<div></div>
<div></div>
<div class="container">
<svg width="1442" viewBox="0 0 1442 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Iconmark">
<path
id="Vector"
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
id="RightEye"
d="M125.8 112.7C125.8 104.968 132.068 98.7 139.8 98.7H167.8C175.532 98.7 181.8 104.968 181.8 112.7V140.7C181.8 148.432 175.532 154.7 167.8 154.7H139.8C132.068 154.7 125.8 148.432 125.8 140.7V112.7Z"
fill="none"
/>
<path
id="LeftEye"
d="M54.8 112.7C54.8 104.968 61.068 98.7 68.8 98.7H96.8C104.532 98.7 110.8 104.968 110.8 112.7V140.7C110.8 148.432 104.532 154.7 96.8 154.7H68.8C61.068 154.7 54.8 148.432 54.8 140.7V112.7Z"
fill="none"
/>
</g>
<g id="Wordmark">
<path
id="Vector_2"
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
id="Vector_3"
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
id="Vector_4"
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
id="Vector_5"
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
id="Vector_6"
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
id="Vector_7"
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
id="Vector_8"
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
id="Vector_9"
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"
/>
</g>
</svg>
<div>
<span>Starting...</span>
</div>
</div>
</body>

23
scripts/tauri-version.js Normal file
View File

@ -0,0 +1,23 @@
import fs from "fs";
import path from "path";
import process from "process";
// if (!process.env.CI) {
// console.log("Not running in CI, skipping. Please do not run this script manually!");
// process.exit(0);
// }
const GITHUB_RUN_ID = process.env.GITHUB_RUN_ID || "0";
const GITHUB_RUN_ATTEMPT = process.env.GITHUB_RUN_ATTEMPT || "0";
const GITHUB_REF_NAME = process.env.GITHUB_REF_NAME;
const pkgJsonPath = path.resolve("./package.json");
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
const pkgVersion = pkgJson.version;
// const tauriJsonPath = path.resolve("./src-tauri/tauri.conf.json");
const tauriJsonPath = path.resolve("./src-tauri/version.json");
const tauriJson = {
version: `${pkgVersion}+${GITHUB_RUN_ID}${GITHUB_RUN_ATTEMPT}`,
};
fs.writeFileSync(tauriJsonPath, JSON.stringify(tauriJson, null, 4));

View File

@ -1,4 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
/target/

8
src-tauri/.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="theme" value="material" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
</component>
</project>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/src-tauri.iml" filepath="$PROJECT_DIR$/.idea/src-tauri.iml" />
</modules>
</component>
</project>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
src-tauri/.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="ALL" />
</component>
<component name="CargoProjects">
<cargoProject FILE="$PROJECT_DIR$/Cargo.toml" />
</component>
<component name="ChangeListManager">
<list default="true" id="0756a13d-f814-41e0-81ae-eb872c2c747f" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/../.editorconfig" beforeDir="false" afterPath="$PROJECT_DIR$/../.editorconfig" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.eslintignore" beforeDir="false" afterPath="$PROJECT_DIR$/../.eslintignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.eslintrc" beforeDir="false" afterPath="$PROJECT_DIR$/../.eslintrc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.github/ISSUE_TEMPLATE/bug_report.md" beforeDir="false" afterPath="$PROJECT_DIR$/../.github/ISSUE_TEMPLATE/bug_report.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.github/ISSUE_TEMPLATE/config.yml" beforeDir="false" afterPath="$PROJECT_DIR$/../.github/ISSUE_TEMPLATE/config.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.github/ISSUE_TEMPLATE/feature_request.md" beforeDir="false" afterPath="$PROJECT_DIR$/../.github/ISSUE_TEMPLATE/feature_request.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.github/workflows/pages-deploy.yml" beforeDir="false" afterPath="$PROJECT_DIR$/../.github/workflows/pages-deploy.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.github/workflows/tauri.yml" beforeDir="false" afterPath="$PROJECT_DIR$/../.github/workflows/tauri.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/../.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.prettierignore" beforeDir="false" afterPath="$PROJECT_DIR$/../.prettierignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.vscode/extensions.json" beforeDir="false" afterPath="$PROJECT_DIR$/../.vscode/extensions.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../.vscode/settings.json" beforeDir="false" afterPath="$PROJECT_DIR$/../.vscode/settings.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../LICENSE" beforeDir="false" afterPath="$PROJECT_DIR$/../LICENSE" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../flake.nix" beforeDir="false" afterPath="$PROJECT_DIR$/../flake.nix" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../flake.template.nix" beforeDir="false" afterPath="$PROJECT_DIR$/../flake.template.nix" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../nix-build-test.sh" beforeDir="false" afterPath="$PROJECT_DIR$/../nix-build-test.sh" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../nix-rebuild-flake.sh" beforeDir="false" afterPath="$PROJECT_DIR$/../nix-rebuild-flake.sh" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../public/manifest.json" beforeDir="false" afterPath="$PROJECT_DIR$/../public/manifest.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../public/robots.txt" beforeDir="false" afterPath="$PROJECT_DIR$/../public/robots.txt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../public/splashscreen.css" beforeDir="false" afterPath="$PROJECT_DIR$/../public/splashscreen.css" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../public/splashscreen.html" beforeDir="false" afterPath="$PROJECT_DIR$/../public/splashscreen.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/build.rs" beforeDir="false" afterPath="$PROJECT_DIR$/build.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.editorconfig" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.editorconfig" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.idea/compiler.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.idea/compiler.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.idea/deploymentTargetDropDown.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.idea/deploymentTargetDropDown.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.idea/discord.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.idea/discord.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.idea/gradle.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.idea/gradle.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.idea/kotlinc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.idea/kotlinc.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.idea/migrations.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.idea/migrations.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/.idea/vcs.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/.idea/vcs.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/build.gradle.kts" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/build.gradle.kts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/proguard-rules.pro" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/proguard-rules.pro" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/AndroidManifest.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/AndroidManifest.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/java/chat/spacebar/app/MainActivity.kt" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/java/chat/spacebar/app/MainActivity.kt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/res/drawable/ic_launcher_background.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/res/drawable/ic_launcher_background.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/res/layout/activity_main.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/res/layout/activity_main.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/res/values-night/themes.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/res/values-night/themes.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/res/values/colors.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/res/values/colors.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/res/values/strings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/res/values/strings.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/res/values/themes.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/res/values/themes.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/app/src/main/res/xml/file_paths.xml" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/app/src/main/res/xml/file_paths.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/build.gradle.kts" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/build.gradle.kts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/buildSrc/build.gradle.kts" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/buildSrc/build.gradle.kts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/buildSrc/src/main/java/chat/spacebar/app/kotlin/BuildTask.kt" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/buildSrc/src/main/java/chat/spacebar/app/kotlin/BuildTask.kt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/buildSrc/src/main/java/chat/spacebar/app/kotlin/RustPlugin.kt" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/buildSrc/src/main/java/chat/spacebar/app/kotlin/RustPlugin.kt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/gradle.properties" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/gradle.properties" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/gradle/wrapper/gradle-wrapper.properties" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/gradle/wrapper/gradle-wrapper.properties" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/gradlew" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/gradlew" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/gradlew.bat" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/gradlew.bat" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/android/settings.gradle" beforeDir="false" afterPath="$PROJECT_DIR$/gen/android/settings.gradle" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/ExportOptions.plist" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/ExportOptions.plist" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/Podfile" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/Podfile" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/Sources/app/bindings/bindings.h" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/Sources/app/bindings/bindings.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/Sources/app/main.mm" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/Sources/app/main.mm" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/app.xcodeproj/project.pbxproj" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/app.xcodeproj/project.pbxproj" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/app.xcodeproj/project.xcworkspace/contents.xcworkspacedata" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/app.xcodeproj/project.xcworkspace/contents.xcworkspacedata" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/app.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/app.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/app.xcodeproj/xcshareddata/xcschemes/app_iOS.xcscheme" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/app.xcodeproj/xcshareddata/xcschemes/app_iOS.xcscheme" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/app_iOS/Info.plist" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/app_iOS/Info.plist" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gen/apple/app_iOS/app_iOS.entitlements" beforeDir="false" afterPath="$PROJECT_DIR$/gen/apple/app_iOS/app_iOS.entitlements" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/lib.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/main.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/tray.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/tray.rs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/assets/images/logo/Logo-Blue.svg" beforeDir="false" afterPath="$PROJECT_DIR$/../src/assets/images/logo/Logo-Blue.svg" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/assets/images/logo/Logo-White.svg" beforeDir="false" afterPath="$PROJECT_DIR$/../src/assets/images/logo/Logo-White.svg" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/assets/images/logo/Spacebar_Icon.svg" beforeDir="false" afterPath="$PROJECT_DIR$/../src/assets/images/logo/Spacebar_Icon.svg" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/assets/images/logo/Spacebar_Logo_Blue.svg" beforeDir="false" afterPath="$PROJECT_DIR$/../src/assets/images/logo/Spacebar_Logo_Blue.svg" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/assets/images/logo/icon-rounded.svg" beforeDir="false" afterPath="$PROJECT_DIR$/../src/assets/images/logo/icon-rounded.svg" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/components/ChannelList/ChannelListItem.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/../src/components/ChannelList/ChannelListItem.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/components/modals/ModalComponents.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/../src/components/modals/ModalComponents.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/components/modals/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/../src/components/modals/index.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/controllers/modals/ModalController.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/../src/controllers/modals/ModalController.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../src/controllers/modals/types.ts" beforeDir="false" afterPath="$PROJECT_DIR$/../src/controllers/modals/types.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/.." />
</component>
<component name="MacroExpansionManager">
<option name="directoryName" value="dm7tjlct" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 4
}</component>
<component name="ProjectId" id="2d1XCyoDrz9y2JsvssmYsjETVjR" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.rust.reset.selective.auto.import": "true",
"git-widget-placeholder": "dev",
"ignore.virus.scanning.warn.message": "true",
"last_opened_file_path": "C:/Users/23562/Documents/Code/workspaces/spacebar/client-react/src-tauri/Cargo.toml",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"org.rust.cargo.project.model.PROJECT_DISCOVERY": "true",
"org.rust.cargo.project.model.impl.CargoExternalSystemProjectAware.subscribe.first.balloon": "",
"settings.editor.selected.configurable": "discord-project",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RustProjectSettings">
<option name="toolchainHomeDirectory" value="D:/cache/.cargo/bin" />
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="0756a13d-f814-41e0-81ae-eb872c2c747f" name="Changes" comment="" />
<created>1709176435458</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1709176435458</updated>
<workItem from="1709176436794" duration="84000" />
<workItem from="1709176527868" duration="30000" />
<workItem from="1709176564971" duration="695000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

3139
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.1.0"
version = "0.0.0"
description = "Spacebar Client"
authors = ["Puyodead1"]
license = "AGPL-3.0-only"
@ -17,12 +17,21 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.0.0-alpha", features = [] }
[dependencies]
# tauri = { version = "2.0.0-alpha", features = [] }
tauri = { git = "https://github.com/tauri-apps/tauri.git", branch = "dev", features = [
"devtools",
] }
tauri = { version = "2.0.0-alpha", features = ["devtools", "tray-icon"] }
tauri-plugin-updater = "2.0.0-alpha"
tauri-plugin-process = "2.0.0-alpha"
tauri-plugin-log = "2.0.0-alpha"
tauri-plugin-os = "2.0.0-alpha"
reqwest = { version = "0.11.22", features = ["json"] }
url = "2.4.1"
chrono = "0.4"
log = "0.4.20"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tauri-plugin-notification = "2.0.0-beta.1"
tauri-plugin-single-instance = "2.0.0-beta.2"
tauri-plugin-autostart = "2.0.0-beta.1"
#openssl = { version = "0.10", features = ["vendored"] }
[features]
# this feature is used for production builds or when `devPath` points to the filesystem

View File

@ -0,0 +1,24 @@
{
"identifier": "base",
"description": "base",
"windows": ["main", "splashscreen"],
"permissions": [
"path:default",
"event:default",
"window:default",
"app:default",
"resources:default",
"menu:default",
"tray:default",
"updater:default",
"notification:default",
"os:allow-platform",
"os:allow-arch",
"os:allow-family",
"os:allow-locale",
"os:allow-os-type",
"os:allow-version",
"webview:allow-internal-toggle-devtools"
],
"platforms": ["linux", "macOS", "windows", "android", "iOS"]
}

View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State />
</entry>
</value>
</component>
</project>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<compositeConfiguration>
<compositeBuild compositeDefinitionSource="SCRIPT">
<builds>
<build path="$PROJECT_DIR$/buildSrc" name="buildSrc">
<projects>
<project path="$PROJECT_DIR$/buildSrc" />
</projects>
</build>
</builds>
</compositeBuild>
</compositeConfiguration>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/buildSrc" />
<option value="D:/cache/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tauri-2.0.0-alpha.20/mobile/android" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.10" />
</component>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
</component>
</project>

View File

@ -32,7 +32,7 @@ open class BuildTask : DefaultTask() {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("C:\\Users\\23562\\Documents\\Code\\workspaces\\spacebar\\client-react\\node_modules\\.bin\\\\..\\@tauri-apps\\cli\\tauri.js", "android", "android-studio-script");
val args = listOf("..\\node_modules\\.bin\\\\..\\@tauri-apps\\cli\\tauri.js", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))

View File

@ -1,116 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@2x-1.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "AppIcon-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "AppIcon-512@2x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
"images": [
{
"size": "20x20",
"idiom": "iphone",
"filename": "AppIcon-20x20@2x.png",
"scale": "2x"
},
{
"size": "20x20",
"idiom": "iphone",
"filename": "AppIcon-20x20@3x.png",
"scale": "3x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "AppIcon-29x29@2x-1.png",
"scale": "2x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "AppIcon-29x29@3x.png",
"scale": "3x"
},
{
"size": "40x40",
"idiom": "iphone",
"filename": "AppIcon-40x40@2x.png",
"scale": "2x"
},
{
"size": "40x40",
"idiom": "iphone",
"filename": "AppIcon-40x40@3x.png",
"scale": "3x"
},
{
"size": "60x60",
"idiom": "iphone",
"filename": "AppIcon-60x60@2x.png",
"scale": "2x"
},
{
"size": "60x60",
"idiom": "iphone",
"filename": "AppIcon-60x60@3x.png",
"scale": "3x"
},
{
"size": "20x20",
"idiom": "ipad",
"filename": "AppIcon-20x20@1x.png",
"scale": "1x"
},
{
"size": "20x20",
"idiom": "ipad",
"filename": "AppIcon-20x20@2x-1.png",
"scale": "2x"
},
{
"size": "29x29",
"idiom": "ipad",
"filename": "AppIcon-29x29@1x.png",
"scale": "1x"
},
{
"size": "29x29",
"idiom": "ipad",
"filename": "AppIcon-29x29@2x.png",
"scale": "2x"
},
{
"size": "40x40",
"idiom": "ipad",
"filename": "AppIcon-40x40@1x.png",
"scale": "1x"
},
{
"size": "40x40",
"idiom": "ipad",
"filename": "AppIcon-40x40@2x-1.png",
"scale": "2x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "AppIcon-76x76@1x.png",
"scale": "1x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "AppIcon-76x76@2x.png",
"scale": "2x"
},
{
"size": "83.5x83.5",
"idiom": "ipad",
"filename": "AppIcon-83.5x83.5@2x.png",
"scale": "2x"
},
{
"size": "1024x1024",
"idiom": "ios-marketing",
"filename": "AppIcon-512@2x.png",
"scale": "1x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

View File

@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
"info": {
"version": 1,
"author": "xcode"
}
}

View File

@ -1,88 +1,88 @@
name: app
options:
bundleIdPrefix: chat.spacebar
deploymentTarget:
iOS: 13.0
bundleIdPrefix: chat.spacebar
deploymentTarget:
iOS: 13.0
fileGroups: [../../src]
configs:
debug: debug
release: release
debug: debug
release: release
settingGroups:
app:
base:
PRODUCT_NAME: Spacebar
PRODUCT_BUNDLE_IDENTIFIER: chat.spacebar.app
DEVELOPMENT_TEAM: 47RXBB8X9K
app:
base:
PRODUCT_NAME: Spacebar
PRODUCT_BUNDLE_IDENTIFIER: chat.spacebar.app
DEVELOPMENT_TEAM: 47RXBB8X9K
targetTemplates:
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
targets:
app_iOS:
type: application
platform: iOS
sources:
- path: Sources
- path: Assets.xcassets
- path: Externals
- path: app_iOS
- path: assets
buildPhase: resources
type: folder
info:
path: app_iOS/Info.plist
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.1
CFBundleVersion: 0.1.1
entitlements:
path: app_iOS/app_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64, x86_64]
VALID_ARCHS: arm64 x86_64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64-sim]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
groups: [app]
dependencies:
- framework: libspacebar.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: node /Users/rileyzicafoose/Documents/client/./node_modules/.bin/../@tauri-apps/cli/tauri.js ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/target/aarch64-apple-ios/${CONFIGURATION}/deps/libspacebar.a
- $(SRCROOT)/target/x86_64-apple-ios/${CONFIGURATION}/deps/libspacebar.a
app_iOS:
type: application
platform: iOS
sources:
- path: Sources
- path: Assets.xcassets
- path: Externals
- path: app_iOS
- path: assets
buildPhase: resources
type: folder
info:
path: app_iOS/Info.plist
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.1
CFBundleVersion: 0.1.1
entitlements:
path: app_iOS/app_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64, x86_64]
VALID_ARCHS: arm64 x86_64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64-sim]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
groups: [app]
dependencies:
- framework: libspacebar.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: node /Users/rileyzicafoose/Documents/client/./node_modules/.bin/../@tauri-apps/cli/tauri.js ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/target/aarch64-apple-ios/${CONFIGURATION}/deps/libspacebar.a
- $(SRCROOT)/target/x86_64-apple-ios/${CONFIGURATION}/deps/libspacebar.a

View File

@ -0,0 +1 @@
{"base":{"identifier":"base","description":"base","local":true,"windows":["main","splashscreen"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","updater:default","notification:default","os:allow-platform","os:allow-arch","os:allow-family","os:allow-locale","os:allow-os-type","os:allow-version","webview:allow-internal-toggle-devtools"],"platforms":["linux","macOS","windows","android","iOS"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,28 @@
use std::{sync::Arc, sync::Mutex};
use tauri::{Manager, RunEvent, State, WebviewWindow};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_log::{Target, TargetKind, WEBVIEW_TARGET};
use tauri_plugin_notification::NotificationExt;
#[cfg(desktop)]
use tauri::Manager;
mod tray;
mod updater;
// wrappers around each Window
// we use a dedicated type because Tauri can only manage a single instance of a given type
struct SplashscreenWindow(Arc<Mutex<WebviewWindow>>);
struct MainWindow(Arc<Mutex<WebviewWindow>>);
#[tauri::command]
async fn close_splashscreen(window: tauri::Window) {
#[cfg(desktop)]
{
// Close splashscreen
if let Some(splashscreen) = window.get_window("splashscreen") {
splashscreen.close().unwrap();
}
// Show main window
window.get_window("main").unwrap().show().unwrap();
}
fn close_splashscreen(
_: WebviewWindow,
splashscreen: State<SplashscreenWindow>,
main: State<MainWindow>,
) {
// Close splashscreen
splashscreen.0.lock().unwrap().close().unwrap();
// Show main window
main.0.lock().unwrap().show().unwrap();
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -20,8 +30,124 @@ pub fn run() {
std::env::set_var("RUST_BACKTRACE", "1");
std::env::set_var("RUST_LOG", "debug");
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![close_splashscreen,])
.run(tauri::generate_context!())
let mut context = tauri::generate_context!();
let config = context.config_mut();
let app = tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(
tauri_plugin_log::Builder::default()
.clear_targets()
.targets([
Target::new(TargetKind::Webview),
Target::new(TargetKind::LogDir {
file_name: Some("webview".into()),
})
.filter(|metadata| metadata.target() == WEBVIEW_TARGET),
Target::new(TargetKind::LogDir {
file_name: Some("rust".into()),
})
.filter(|metadata| metadata.target() != WEBVIEW_TARGET),
])
.format(move |out, message, record| {
out.finish(format_args!(
"{} [{}] {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
record.level(),
message
));
})
.level(log::LevelFilter::Info)
.build(),
)
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
println!("{}, {argv:?}, {cwd}", app.package_info().name);
app.notification()
.builder()
.title("This app is already running!")
.body("You can find it in the tray menu.")
.show()
.unwrap();
}))
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
Some(vec![]),
))
.plugin(tauri_plugin_process::init())
.setup(move |app| {
let app_handle = app.handle();
// set the splashscreen and main windows to be globally available with the tauri state API
app.manage(SplashscreenWindow(Arc::new(Mutex::new(
app.get_webview_window("splashscreen").unwrap(),
))));
app.manage(MainWindow(Arc::new(Mutex::new(
app.get_webview_window("main").unwrap(),
))));
app_handle.plugin(tauri_plugin_updater::Builder::new().build())?;
#[cfg(desktop)]
{
// Tray
let handle = app.handle();
tray::create_tray(handle)?;
}
// Open the dev tools automatically when debugging the application
#[cfg(debug_assertions)]
if let Some(main_window) = app.get_webview_window("main") {
main_window.open_devtools();
};
Ok(())
})
.invoke_handler(tauri::generate_handler![
close_splashscreen,
updater::check_for_updates,
updater::download_update,
updater::install_update,
updater::clear_update_cache
])
.build(context)
.expect("error while running tauri application");
#[cfg(desktop)]
app.run(|app, e| match e {
RunEvent::Ready => {
#[cfg(any(target_os = "macos", debug_assertions))]
let window = app.get_webview_window("main").unwrap();
#[cfg(debug_assertions)]
window.open_devtools();
println!("App is ready");
}
RunEvent::ExitRequested { api, code, .. } => {
// Keep the event loop running even if all windows are closed
// This allow us to catch tray icon events when there is no window
// if we manually requested an exit (code is Some(_)) we will let it go through
if code.is_none() {
api.prevent_exit();
}
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} => {
#[cfg(target_os = "macos")]
{
tauri::AppHandle::hide(&app.app_handle()).unwrap();
}
#[cfg(not(target_os = "macos"))]
{
let window = app.get_webview_window(label.as_str()).unwrap();
window.hide().unwrap();
}
api.prevent_close();
}
_ => {}
});
}

36
src-tauri/src/tray.rs Normal file
View File

@ -0,0 +1,36 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{ClickType, TrayIconBuilder},
Manager, Runtime,
};
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let branding = MenuItem::with_id(app, "name", "Spacebar", false, None::<String>)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<String>)?;
let menu1 = Menu::with_items(app, &[&branding, &quit_i])?;
let _ = TrayIconBuilder::with_id("main")
.tooltip("Spacebar")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu1)
.menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id.as_ref() {
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if event.click_type == ClickType::Left {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app);
Ok(())
}

451
src-tauri/src/updater.rs Normal file
View File

@ -0,0 +1,451 @@
use reqwest;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::{Manager, Runtime};
use tauri_plugin_updater::{Update, UpdaterExt};
use url::Url;
static UPDATE_INFO: Mutex<Option<Update>> = Mutex::new(None);
#[derive(Deserialize, Debug)]
struct Release {
assets: Vec<Asset>,
prerelease: bool,
}
#[derive(Deserialize, Debug)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Serialize, Debug, Clone)]
struct UpdateAvailable {
version: String,
body: Option<String>,
}
// ignore_version: String
#[tauri::command]
pub fn check_for_updates<R: Runtime>(ignore_prereleases: bool, window: tauri::Window<R>) {
let handle = window.app_handle().clone();
if std::env::var("DEVELOPMENT").is_ok() {
println!("[Updater] This is a development environment, not updating.");
return;
}
// TODO: readd this
// if !handle.config().tauri.bundle.updater.active {
// return;
// }
match window.emit("CHECKING_FOR_UPDATE", Some(serde_json::json!({}))) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to emit update checking event: {:?}", e);
}
}
let package_path = handle
.path()
.app_local_data_dir()
.unwrap()
.join("update.sbcup");
// if we already have an update package, remove it
if package_path.exists() {
clear_update_cache(window.clone());
}
tauri::async_runtime::spawn(async move {
println!("[Updater] Searching for update file on github.");
// Custom configure the updater.
let github_releases_endpoint = "https://api.github.com/repos/spacebarchat/client/releases";
let github_releases_endpoint = match Url::parse(github_releases_endpoint) {
Ok(url) => url,
Err(e) => {
println!(
"[Updater] Failed to parse url: {:?}. Failed to check for updates",
e
);
emit_update_error(window, format!("Failed to parse url: {:?}", e));
return;
}
};
let client = reqwest::Client::new();
let req = client
.get(github_releases_endpoint.clone())
.header("Content-Type", "application/json")
// If this is not set you will get a 403 forbidden error.
.header("User-Agent", "spacebar-client");
let response = match req.send().await {
Ok(response) => response,
Err(e) => {
println!(
"[Updater] Failed to send request: {:?}. Failed to check for updates",
e
);
emit_update_error(window, format!("Failed to send request: {:?}", e));
return;
}
};
if response.status() != reqwest::StatusCode::OK {
println!(
"[Updater] Non OK status code: {:?}. Failed to check for updates",
response.status()
);
emit_update_error(
window,
format!("Non OK status code: {:?}", response.status()),
);
return;
}
let releases = match response.json::<Vec<Release>>().await {
Ok(releases) => releases,
Err(e) => {
println!(
"[Updater] Failed to parse response: {:?}. Failed to check for updates",
e
);
emit_update_error(window, format!("Failed to parse response: {:?}", e));
return;
}
};
// check if there are any releases
if releases.len() == 0 {
println!("[Updater] No releases found. Failed to check for updates");
match window.emit("UPDATE_NOT_AVAILABLE", Some({})) {
Ok(_) => {}
Err(e) => {
println!(
"[Updater] Failed to emit update not available event: {:?}",
e
);
}
}
return;
}
// if ignore_prereleases is true, find first release that is not a prerelease, otherwise get the first release
let latest_release = if ignore_prereleases {
releases.iter().find(|release| !release.prerelease).unwrap()
} else {
releases.get(0).unwrap()
};
// Find an asset named "latest.json".
let tauri_release_asset = latest_release
.assets
.iter()
.find(|asset| asset.name == "latest.json");
// If we found the asset, set it as the updater endpoint.
let tauri_release_asset = match tauri_release_asset {
Some(tauri_release_asset) => tauri_release_asset,
None => {
println!("[Updater] Failed to find latest.json asset. Failed to check for updates\n\nFound Assets are:");
// Print a list of the assets found
for asset in latest_release.assets.iter() {
println!(" {:?}", asset.name);
}
emit_update_error(
window,
format!("Failed to find latest.json asset. Failed to check for updates"),
);
return;
}
};
let tauri_release_endpoint = match Url::parse(&tauri_release_asset.browser_download_url) {
Ok(url) => url,
Err(e) => {
println!(
"[Updater] Failed to parse url: {:?}. Failed to check for updates",
e
);
emit_update_error(window, format!("Failed to parse url: {:?}", e));
return;
}
};
let updater_builder = match handle
.updater_builder()
.version_comparator(|current_version, latest_version| {
println!("[Updater] Current version: {}", current_version);
println!(
"[Updater] Latest version: {}",
latest_version.version.clone()
);
// if the current build is 00 then its a dev environment and we should not update
if current_version.build.to_string() == "00" {
println!("[Updater] Build ID is 00, looks like a development environment, not updating.");
return false;
}
// upgrade
if latest_version.version > current_version {
println!("[Updater] An update is available.");
return true;
}
// downgrade
if latest_version.version < current_version {
println!("[Updater] The installed version is newer than the latest version. A little odd, but ok.");
return true;
}
return false;
})
.endpoints(vec![tauri_release_endpoint])
.header("User-Agent", "spacebar-client")
{
Ok(updater_builder) => updater_builder,
Err(e) => {
println!(
"[Updater] Failed to build updater builder: {:?}. Failed to check for updates",
e
);
emit_update_error(window, format!("Failed to build updater builder: {:?}", e));
return;
}
};
let updater = match updater_builder.build() {
Ok(updater) => updater,
Err(e) => {
println!(
"[Updater] Failed to build updater: {:?}. Failed to check for updates",
e
);
emit_update_error(window, format!("Failed to build updater: {:?}", e));
return;
}
};
println!("[Updater] Checking for updates");
let response = updater.check().await;
println!("[Updater] Update check response: {:?}", response);
match response {
Ok(Some(update)) => {
// if ignore_version == update.version {
// println!("Ignoring update as user has asked to ignore this version.");
// return;
// }
UPDATE_INFO.lock().unwrap().replace(update.clone());
// otherwise emit the update available event
match window.emit("UPDATE_AVAILABLE", Some({})) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to emit update available event: {:?}", e);
}
}
return download_update(window).await;
}
Ok(None) => {
println!("[Updater] No update available");
match window.emit("UPDATE_NOT_AVAILABLE", Some({})) {
Ok(_) => {}
Err(e) => {
println!(
"[Updater] Failed to emit update not available event: {:?}",
e
);
}
}
}
Err(e) => {
println!("[Updater] Failed to check for updates: {:?}.", e);
match window.emit("UPDATE_ERROR", Some(format!("{:?}", e))) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to emit update error event: {:?}", e);
}
}
}
}
});
}
#[tauri::command]
pub async fn download_update<R: Runtime>(window: tauri::Window<R>) {
if std::env::var("DEVELOPMENT").is_ok() {
println!("[Updater] This is a development environment, not updating.");
return;
}
println!("[Updater] Downloading update package");
let update = match UPDATE_INFO.lock().unwrap().clone() {
Some(update) => update,
None => {
println!("[Updater] No update found to download");
emit_update_error(window, format!("No update found to download"));
return;
}
};
// emit UPDATE_DOWNLOADING
match window.emit("UPDATE_DOWNLOADING", Some({})) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to emit update downloading event: {:?}", e);
}
}
let on_chunk = |size: usize, progress: Option<u64>| {
println!(
"[Updater] Received chunk: size={}, progress={:?}",
size, progress
);
};
let on_download_finish = || {
println!("[Updater] Download finished!");
};
let download_response = update.download(on_chunk, on_download_finish).await;
if let Err(e) = download_response {
println!("[Updater] Failed to download update: {:?}", e);
emit_update_error(window, format!("Failed to download update: {:?}", e));
return;
} else {
println!("[Updater] Update downloaded");
let handle = window.app_handle().clone();
let package_path = handle
.path()
.app_local_data_dir()
.unwrap()
.join("update.sbcup");
println!("[Updater] Saving update package to {:?}", package_path);
// store download_response bytes to a file
match std::fs::write(package_path.clone(), download_response.unwrap()) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to save update package: {:?}", e);
emit_update_error(window, format!("Failed to save update package: {:?}", e));
return;
}
}
}
match window.emit(
"UPDATE_DOWNLOADED",
Some(UpdateAvailable {
version: update.version.clone(),
body: update.body.clone(),
}),
) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to emit update downloaded event: {:?}", e);
}
}
}
#[tauri::command]
pub async fn install_update<R: Runtime>(window: tauri::Window<R>) {
if std::env::var("DEVELOPMENT").is_ok() {
println!("[Updater] This is a development environment, not updating.");
return;
}
println!("[Updater] Installing update package");
let update = match UPDATE_INFO.lock().unwrap().clone() {
Some(update) => update,
None => {
println!("[Updater] No update found to install");
emit_update_error(window, format!("No update found to install"));
return;
}
};
let handle = window.app_handle().clone();
let package_path = handle
.path()
.app_local_data_dir()
.unwrap()
.join("update.sbcup");
// check if the update package exists
if !package_path.exists() {
println!("[Updater] No pending update found to install");
emit_update_error(window, format!("No pending update found to install"));
return;
}
// read in the update package bytes
let bytes = match std::fs::read(package_path.clone()) {
Ok(bytes) => bytes,
Err(e) => {
println!("[Updater] Failed to read update package: {:?}", e);
emit_update_error(window, format!("Failed to read update package: {:?}", e));
return;
}
};
let install_response = update.install(bytes);
if let Err(e) = install_response {
println!("[Updater] Failed to install update: {:?}", e);
emit_update_error(window, format!("Failed to install update: {:?}", e));
} else {
println!("[Updater] Update installed");
// remove the update package
match std::fs::remove_file(package_path) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to remove update package: {:?}", e);
emit_update_error(window, format!("Failed to remove update package: {:?}", e));
}
}
}
}
#[tauri::command]
pub fn clear_update_cache<R: Runtime>(window: tauri::Window<R>) {
let handle = window.app_handle().clone();
let package_path = handle
.path()
.app_local_data_dir()
.unwrap()
.join("update.sbcup");
// check if the update package exists
if !package_path.exists() {
println!("[Updater] No pending update found to clear");
return;
}
// remove the update package
match std::fs::remove_file(package_path) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to remove update package: {:?}", e);
emit_update_error(window, format!("Failed to remove update package: {:?}", e));
}
}
}
// utility function to emit UPDATE_ERROR
fn emit_update_error<R: Runtime>(window: tauri::Window<R>, error: String) {
match window.emit("UPDATE_ERROR", Some(error)) {
Ok(_) => {}
Err(e) => {
println!("[Updater] Failed to emit update error event: {:?}", e);
}
}
}

View File

@ -1,56 +1,59 @@
{
"productName": "Spacebar",
"version": "./version.json",
"identifier": "chat.spacebar.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm run dev",
"beforeBuildCommand": "pnpm run build",
"devPath": "http://localhost:1420",
"distDir": "../build",
"withGlobalTauri": false
"beforeBuildCommand": "pnpm run build"
},
"package": {
"productName": "Spacebar",
"version": "../package.json"
},
"tauri": {
"bundle": {
"active": true,
"targets": "all",
"identifier": "chat.spacebar.app",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"publisher": "Spacebar",
"category": "SocialNetworking",
"shortDescription": "A free, opensource self-hostable discord-compatible chat, voice and video platform.",
"windows": {
"nsis": {
"license": "../LICENSE",
"sidebarImage": "./icons/sidebar.bmp",
"installerIcon": "./icons/icon.ico"
}
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Tauri",
"width": 800,
"height": 600,
"visible": false
},
"updater": {
"active": false,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQxRkQwNTY1NzBEOTMyMTUKUldRVk10bHdaUVg5UWVoVm9JeDg4UEs1TkpMT3FKdzc3Y29CN2NZNk9vRE9sanJCUERqT09HVVYK",
"windows": {
"installMode": "passive"
}
{
"label": "splashscreen",
"width": 400,
"height": 200,
"decorations": false,
"resizable": false,
"url": "splashscreen.html"
}
},
"security": {
"csp": null
},
"windows": []
]
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "appimage", "nsis", "app", "dmg", "updater"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"publisher": "Spacebar",
"category": "SocialNetworking",
"shortDescription": "A free, opensource self-hostable discord-compatible chat, voice and video platform.",
"licenseFile": "../LICENSE",
"windows": {
"nsis": {
"sidebarImage": "./icons/sidebar.bmp",
"installerIcon": "./icons/icon.ico"
}
}
},
"plugins": {
"shell": {
"open": true
},
"updater": {
"endpoints": ["https://update.spacebar.chat/updates/{{target}}/{{arch}}"]
"active": true,
"endpoints": ["https://github.com/spacebarchat/client/releases/download/latest/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQxRkQwNTY1NzBEOTMyMTUKUldRVk10bHdaUVg5UWVoVm9JeDg4UEs1TkpMT3FKdzc3Y29CN2NZNk9vRE9sanJCUERqT09HVVYK",
"windows": {
"installMode": "passive"
}
}
}
}

View File

@ -1,10 +1,10 @@
{
"tauri": {
"bundle": {
"iOS": {
"developmentTeam": "47RXBB8X9K"
}
},
"bundle": {
"iOS": {
"developmentTeam": "47RXBB8X9K"
}
},
"app": {
"windows": [
{
"fullscreen": false,

View File

@ -1,5 +1,5 @@
{
"tauri": {
"app": {
"windows": [
{
"width": 400,

View File

@ -1,5 +1,5 @@
{
"tauri": {
"app": {
"windows": [
{
"width": 400,

View File

@ -1,5 +1,5 @@
{
"tauri": {
"app": {
"windows": [
{
"width": 400,

3
src-tauri/version.json Normal file
View File

@ -0,0 +1,3 @@
{
"version": "0.1.1+00"
}

View File

@ -6,6 +6,8 @@ import LoginPage from "./pages/LoginPage";
import NotFoundPage from "./pages/NotFound";
import RegistrationPage from "./pages/RegistrationPage";
import { getTauriVersion, getVersion } from "@tauri-apps/api/app";
import { arch, locale, platform, version } from "@tauri-apps/plugin-os";
import { reaction } from "mobx";
import ErrorBoundary from "./components/ErrorBoundary";
import Loader from "./components/Loader";
@ -20,6 +22,7 @@ import { useAppStore } from "./stores/AppStore";
import { Globals } from "./utils/Globals";
// @ts-expect-error no types
import FPSStats from "react-fps-stats";
import { isTauri } from "./utils/Utils";
function App() {
const app = useAppStore();
@ -51,8 +54,24 @@ function App() {
},
);
const loadAsyncGlobals = async () => {
const [tauriVersion, appVersion, platformName, platformArch, platformVersion, platformLocale] =
await Promise.all([getTauriVersion(), getVersion(), platform(), arch(), version(), locale()]);
window.globals = {
tauriVersion: tauriVersion,
appVersion: appVersion,
platform: {
name: platformName,
arch: platformArch,
version: platformVersion,
locale: platformLocale,
},
};
};
isTauri && loadAsyncGlobals();
Globals.load();
app.loadToken();
app.loadSettings();
logger.debug("Loading complete");
app.setAppLoading(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

@ -66,7 +66,7 @@ export const LabelWrapper = styled.div<{ error?: boolean }>`
display: flex;
flex-direction: row;
margin-bottom: 8px;
color: ${(props) => (props.error ? "var(--error)" : "var(--text)")};
color: ${(props) => (props.error ? "var(--error)" : "var(--text-header-secondary)")};
`;
export const InputErrorText = styled.label`
@ -88,10 +88,9 @@ export const InputWrapper = styled.div`
display: flex;
`;
// TODO: Fix border hover causing small layout shift
export const Input = styled.input<{ error?: boolean }>`
export const Input = styled.input<{ error?: boolean; disableFocusRing?: boolean }>`
outline: none;
background: var(--background-secondary-alt);
background: var(--background-secondary);
padding: 10px;
font-size: 16px;
flex: 1;
@ -100,17 +99,23 @@ export const Input = styled.input<{ error?: boolean }>`
margin: 0;
border: none;
aria-invalid: ${(props) => (props.error ? "true" : "false")};
border: ${(props) => (props.error ? "1px solid var(--error)" : "none")};
border: ${(props) => (props.error ? "1px solid var(--error)" : "1px solid var(--background-secondary)")};
&:focus {
border: 1px solid var(--primary);
}
${(props) =>
!props.disableFocusRing &&
`
&:focus {
border: 1px solid var(--primary);
}
`}
// disabled styling
&:disabled {
background: var(--background-secondary-alt);
// TODO: this might need to be adjusted
background: var(--background-primary-alt);
color: var(--text-disabled);
border: 1px solid var(--background-secondary-alt);
cursor: not-allowed;
}
-moz-appearance: textfield;

View File

@ -1,68 +1,97 @@
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 }>`
const Wrapper = styled(Container)<{ size: number; hasClick?: boolean }>`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 50%;
position: relative;
background-color: transparent;
&:hover {
text-decoration: underline;
cursor: pointer;
cursor: ${(props) => (props.hasClick ? "pointer" : "default")};
}
`;
const StatusDot = styled.span<{ color: string; width?: number; height?: number }>`
position: absolute;
bottom: 0;
right: 0;
background-color: ${(props) => props.color};
border-radius: 50%;
border: 2px solid var(--background-primary);
width: ${(props) => props.width ?? 10}px;
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;
style?: React.CSSProperties;
onClick?: () => void;
onClick?: React.MouseEventHandler<HTMLDivElement> | null;
popoutPlacement?: "left" | "right" | "top" | "bottom";
presence?: Presence;
statusDotStyle?: {
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 = () => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
if (!rect) return;
popoutContext.open({
element: <UserProfilePopout user={user} />,
position: rect,
placement: props.popoutPlacement,
});
};
// if onClick is null, use a div. if we pass a function, use yes. otherwise use FloatingTrigger
const Base = props.onClick === null ? "div" : props.onClick ? Yes(props.onClick) : FloatingTrigger;
return (
<Wrapper
size={props.size ?? 32}
style={props.style}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onClick ? props.onClick() : openPopout();
<Floating
placement="right-start"
type="userPopout"
props={{
user: user as unknown as User,
}}
ref={ref}
>
<img src={user.avatarUrl} width={props.size ?? 32} height={props.size ?? 32} loading="eager" />
</Wrapper>
<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 from "./Icon";
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);
@ -24,64 +22,23 @@ const HeaderText = styled.header`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
`;
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();
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",
},
});
}
useEffect(() => {
if (isOpen) setIcon("mdiClose");
else setIcon("mdiChevronDown");
}, [isOpen]);
if (app.activeGuildId === "@me") {
return (
@ -101,10 +58,14 @@ function ChannelHeader() {
if (!app.activeGuild) return null;
return (
<Wrapper onClick={openMenu}>
<HeaderText>{app.activeGuild.name}</HeaderText>
<Icon icon="mdiChevronDown" 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

@ -10,14 +10,10 @@ const Container = styled.div`
flex: 1;
`;
export function EmptyChannelList() {
return <Container></Container>;
}
function ChannelList() {
const app = useAppStore();
if (!app.activeGuild || !app.activeChannel) return null;
if (!app.activeGuild || !app.activeChannel) return <Container />;
const { channels } = app.activeGuild;
const rowRenderer = ({ index, key, style }: ListRowProps) => {
@ -25,6 +21,7 @@ function ChannelList() {
const active = app.activeChannelId === item.id;
const isCategory = item.type === ChannelType.GuildCategory;
return (
<div style={style}>
<ChannelListItem key={key} isCategory={isCategory} active={active} channel={item} />

View File

@ -1,12 +1,14 @@
import { useModals } from "@mattjennings/react-modal-stack";
import React from "react";
import React, { useContext, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import { modalController } from "../../controllers/modals";
import { useAppStore } from "../../stores/AppStore";
import Channel from "../../stores/objects/Channel";
import { IContextMenuItem } from "../ContextMenuItem";
import { Permissions } from "../../utils/Permissions";
import Icon from "../Icon";
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")};
@ -19,19 +21,21 @@ const Wrapper = styled.div<{ isCategory?: boolean; active?: boolean }>`
border-radius: 4px;
align-items: center;
display: flex;
padding: 0 8px;
padding: ${(props) => (props.isCategory ? "0 8px 0 8px" : "0 16px")};
background-color: ${(props) => (props.active ? "var(--background-primary-alt)" : "transparent")};
justify-content: space-between;
&:hover {
background-color: var(--background-primary-alt);
background-color: ${(props) => (props.isCategory ? "transparent" : "var(--background-primary-alt)")};
}
`;
const Text = styled.span<{ isCategory?: boolean }>`
const Text = styled.span<{ isCategory?: boolean; hovered?: boolean }>`
font-size: 16px;
font-weight: var(--font-weight-regular);
white-space: nowrap;
color: var(--text-secondary);
color: ${(props) => (props.isCategory && props.hovered ? "var(--text)" : "var(--text-secondary)")};
user-select: none;
`;
interface Props {
@ -41,33 +45,22 @@ interface Props {
}
function ChannelListItem({ channel, isCategory, active }: Props) {
const app = useAppStore();
const navigate = useNavigate();
const contextMenu = useContext(ContextMenuContext);
const { openModal } = useModals();
const [wrapperHovered, setWrapperHovered] = React.useState(false);
const [createChannelHovered, setCreateChannelHovered] = React.useState(false);
const [createChannelDown, setChannelCreateDown] = React.useState(false);
const [hasCreateChannelPermission, setHasCreateChannelPermission] = React.useState(false);
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",
},
},
]);
useEffect(() => {
if (!isCategory) return;
const permission = Permissions.getPermission(app.account!.id, channel.guild, channel);
const hasPermission = permission.has("MANAGE_CHANNELS");
setHasCreateChannelPermission(hasPermission);
}, [channel]);
return (
<ListItem
@ -79,20 +72,92 @@ 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} active={active}>
{channel.channelIcon && (
<Icon
icon={channel.channelIcon}
size="16px"
style={{
marginRight: "8px",
<Wrapper
isCategory={isCategory}
active={active}
onMouseOver={() => setWrapperHovered(true)}
onMouseOut={() => setWrapperHovered(false)}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
}}
>
{channel.channelIcon && !isCategory && (
<Icon
icon={channel.channelIcon}
size="16px"
style={{
marginRight: "8px",
}}
color="var(--text-secondary)"
/>
)}
{isCategory && (
<Icon
icon="mdiChevronDown"
size="12px"
color={wrapperHovered ? "var(--text)" : "var(--text-secondary)"}
style={{
marginRight: "8px",
}}
/>
)}
<Text isCategory={isCategory} hovered={wrapperHovered}>
{channel.name}
</Text>
</div>
{isCategory && hasCreateChannelPermission && (
<Floating
placement="top"
type="tooltip"
offset={10}
props={{
content: <span>Create Channel</span>,
}}
color="var(--text-secondary)"
/>
>
<FloatingTrigger>
<span
onMouseOver={() => setCreateChannelHovered(true)}
onMouseOut={() => setCreateChannelHovered(false)}
onMouseDown={() => setChannelCreateDown(true)}
onMouseUp={() => setChannelCreateDown(false)}
onClick={() => {
if (!channel.guild) {
console.warn("No guild found for channel", channel);
return;
}
modalController.push({
type: "create_channel",
guild: channel.guild,
category: channel,
});
}}
>
<Icon
icon="mdiPlus"
size="18px"
style={{
marginLeft: "auto",
}}
color={
createChannelDown
? "var(--text-header)"
: createChannelHovered
? "var(--text)"
: "var(--text-secondary)"
}
/>
</span>
</FloatingTrigger>
</Floating>
)}
<Text isCategory={isCategory}>{channel.name}</Text>
</Wrapper>
</ListItem>
);

View File

@ -1,8 +1,7 @@
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { useAppStore } from "../stores/AppStore";
import ChannelHeader from "./ChannelHeader";
import ChannelList, { EmptyChannelList } from "./ChannelList/ChannelList";
import ChannelList from "./ChannelList/ChannelList";
import Container from "./Container";
import UserPanel from "./UserPanel";
@ -18,13 +17,11 @@ const Wrapper = styled(Container)`
`;
function ChannelSidebar() {
const app = useAppStore();
return (
<Wrapper>
{/* TODO: replace with dm search if no guild */}
<ChannelHeader />
{app.activeGuild ? <ChannelList /> : <EmptyChannelList />}
<ChannelList />
<UserPanel />
</Wrapper>
);

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

@ -7,7 +7,7 @@
position: relative;
display: block;
padding: 10px;
background: var(--background-secondary-alt);
background: var(--background-secondary);
border: none;
color: var(--text);
outline: none;

View File

@ -3,7 +3,7 @@ import styled from "styled-components";
// TODO: migrate some things from AuthComponents
export const InputSelect = styled.select`
background-color: var(--background-secondary-alt);
background-color: var(--background-secondary);
color: var(--text);
outline: none;
border: 1px solid transparent;

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;
@ -31,7 +30,9 @@ const Wrapper = styled(Container)<{ active?: boolean; hasImage?: boolean }>`
border-radius: ${(props) => (props.active ? "30%" : "50%")};
background-color: ${(props) =>
props.hasImage ? "transparent" : props.active ? "var(--primary)" : "var(--background-secondary)"};
transition: border-radius 0.2s ease, background-color 0.2s ease;
transition:
border-radius 0.2s ease,
background-color 0.2s ease;
&:hover {
border-radius: 30%;
@ -48,37 +49,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 +73,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,4 +1,4 @@
import React from "react";
import React, { ReactNode } from "react";
import styled from "styled-components";
import Icon from "./Icon";
@ -35,7 +35,7 @@ const Item = styled.span`
interface Props {
name: string;
items: string[];
items: ReactNode[];
}
function ListSection(props: Props) {
@ -49,9 +49,10 @@ function ListSection(props: Props) {
{props.name}
</Title>
<Wrapper open={open}>
{props.items.map((item, i) => (
{/* {props.items.map((item, i) => (
<Item key={i}>{item}</Item>
))}
))} */}
{...props.items}
</Wrapper>
</Container>
);

View File

@ -1,4 +1,4 @@
import { invoke } from "@tauri-apps/api";
import { invoke } from "@tauri-apps/api/core";
import { observer } from "mobx-react-lite";
import React from "react";
import LoadingPage from "../pages/LoadingPage";

View File

@ -5,6 +5,7 @@ import styled from "styled-components";
import { useAppStore } from "../../stores/AppStore";
import GuildMemberListStore from "../../stores/GuildMemberListStore";
import ListSection from "../ListSection";
import MemberListItem from "./MemberListItem";
const Container = styled.div`
display: flex;
@ -18,13 +19,6 @@ const Container = styled.div`
}
`;
const Wrapper = styled.aside`
justify-content: center;
min-width: 240px;
max-height: 100%;
display: flex;
`;
const List = styled.ul`
padding: 0;
margin: 0;
@ -61,11 +55,11 @@ function MemberList() {
<ListSection
key={i}
name={category.name}
items={
category.items.map((x) => x.nick ?? x.user?.username).filter((x) => x) as string[]
}
items={category.items.map((x) => (
<MemberListItem item={x} />
))}
/>
))
))
: null}
</List>
</Container>

View File

@ -1,68 +1,100 @@
import { useModals } from "@mattjennings/react-modal-stack";
import React from "react";
import { useNavigate } from "react-router-dom";
import { PresenceUpdateStatus } from "@spacebarchat/spacebar-api-types/v9";
import { observer } from "mobx-react-lite";
import { useContext } from "react";
import styled from "styled-components";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import { useAppStore } from "../../stores/AppStore";
import GuildMember from "../../stores/objects/GuildMember";
import { IContextMenuItem } from "../ContextMenuItem";
import Avatar from "../Avatar";
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 Wrapper = styled.div<{ isCategory?: boolean }>`
margin-left: ${(props) => (props.isCategory ? "0" : "8px")};
height: ${(props) => (props.isCategory ? "28px" : "33px")};
border-radius: 4px;
align-items: center;
display: flex;
padding: 0 8px;
const Container = styled.div`
max-width: 224px;
background-color: transparent;
box-sizing: border-box;
padding: 1px 0;
border-radius: 4px;
&:hover {
background-color: var(--background-primary-alt);
}
`;
const Text = styled.span<{ isCategory?: boolean }>`
const Wrapper = styled.div<{ offline?: boolean }>`
display: flex;
align-items: center;
border-radius: 4px;
height: 42px;
padding: 0 8px;
opacity: ${(props) => (props.offline ? 0.5 : 1)};
`;
const Text = styled.span<{ color?: string }>`
font-size: 16px;
font-weight: var(--font-weight-regular);
white-space: nowrap;
color: var(--text-secondary);
color: ${(props) => props.color ?? "var(--text-secondary)"};
`;
const TextWrapper = styled.div`
min-width: 0;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const AvatarWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
`;
interface Props {
item: string | GuildMember;
item: GuildMember;
}
function MemberListItem({ item }: Props) {
const navigate = useNavigate();
const { openModal } = useModals();
const contextMenu = React.useContext(ContextMenuContext);
const [contextMenuItems, setContextMenuItems] = React.useState<IContextMenuItem[]>([]);
const app = useAppStore();
const presence = app.presences.get(item.user!.id);
const contextMenu = useContext(ContextMenuContext);
return (
<ListItem
key={typeof item === "string" ? item : item.user?.id}
isCategory={typeof item === "string"}
// onClick={() => {
// // prevent navigating to non-text channels
// if (!channel.isTextChannel) return;
// navigate(`/channels/${channel.guildId}/${channel.id}`);
// }}
onContextMenu={(e) => contextMenu.open2(e, contextMenuItems)}
<Floating
placement="right-start"
type="userPopout"
offset={20}
props={{
user: item.user!,
member: item,
}}
>
<Wrapper isCategory={typeof item === "string"}>
<Text isCategory={typeof item === "string"}>
{typeof item === "string" ? item : item.nick ?? item.user?.username}
</Text>
</Wrapper>
</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 onClick={null} />
</AvatarWrapper>
<TextWrapper>
<Text color={item.roleColor}>{item.nick ?? item.user?.username}</Text>
</TextWrapper>
</Wrapper>
</Container>
</ListItem>
</Floating>
);
}
export default MemberListItem;
export default observer(MemberListItem);

View File

@ -1,144 +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": {
let x = position.left - position.width / 1;
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: 9999,
...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,11 +1,12 @@
import { useModals } from "@mattjennings/react-modal-stack";
import styled from "styled-components";
import { modalController } from "../controllers/modals";
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 SettingsModal from "./modals/SettingsModal";
import Floating from "./floating/Floating";
import FloatingTrigger from "./floating/FloatingTrigger";
const Section = styled.section`
flex: 0 0 auto;
@ -21,13 +22,14 @@ 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;
padding-left: 2px;
margin-right: 8px;
border-radius: 4px;
cursor: default;
&:hover {
background-color: var(--background-primary-alt);
@ -45,6 +47,7 @@ const Username = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
`;
const Subtext = styled.div`
@ -53,6 +56,7 @@ const Subtext = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
`;
const ActionsWrapper = styled.div`
@ -66,32 +70,50 @@ const ActionsWrapper = styled.div`
function UserPanel() {
const app = useAppStore();
const { openModal } = useModals();
const openSettingsModal = () => {
openModal(SettingsModal);
modalController.push({
type: "settings",
});
};
return (
<Section>
<Container>
<AvatarWrapper>
<Avatar popoutPlacement="top" />
<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

@ -1,175 +0,0 @@
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 User from "../stores/objects/User";
import Snowflake from "../utils/Snowflake";
import Avatar from "./Avatar";
import { HorizontalDivider } from "./Divider";
import Tooltip from "./Tooltip";
const Container = styled.div`
background-color: #252525;
border-radius: 4px;
display: flex;
flex-direction: column;
width: 280px;
max-height: 600px;
overflow: hidden;
// heavy shadow
box-shadow: 0 0 0 1px rgb(0 0 0 / 15%), 0 4px 8px rgb(0 0 0 / 15%);
`;
const Top = styled.div`
display: flex;
flex-direction: column;
`;
const Bottom = styled.div`
display: flex;
flex-direction: column;
background-color: var(--background-tertiary);
border-radius: 4px;
margin: 0 16px 16px;
max-height: 340px;
`;
const Section = styled.div`
padding: 12px;
display: flex;
justify-content: space-between;
display: flex;
flex-direction: column;
`;
const UsernameWrapper = styled.div`
text-overflow: clip;
white-space: nowrap;
overflow: hidden;
`;
const NicknameText = styled.span`
font-size: 20px;
font-weight: var(--font-weight-bold);
`;
const UsernameText = styled.span`
font-size: 14px;
font-weight: var(--font-weight-medium);
`;
const Heading = styled.span`
display: flex;
font-weight: var(--font-weight-bold);
margin-bottom: 6px;
font-size: 12px;
user-select: none;
`;
const MemberSinceContainer = styled.div`
display: flex;
flex-direction: row;
column-gap: 8px;
align-items: center;
user-select: none;
`;
const MemberSinceText = styled.span`
font-size: 14px;
font-weight: var(--font-weight-regular);
`;
function UserProfilePopout({ user }: { user: User | AccountStore }) {
const logger = useLogger("UserProfilePopout");
// if (!member.user) {
// logger.error("member.user is undefined");
// return null;
// }
if (!user) {
logger.error("user is undefined");
return null;
}
const id = user.id;
const { timestamp: createdAt } = Snowflake.deconstruct(id);
return (
<Container>
<Top>
<Avatar
style={{ margin: "22px 16px" }}
size={80}
onClick={() => {
// TODO: open profile modal
}}
user={user}
/>
</Top>
<Bottom>
<Section>
<div>
<UsernameWrapper>
<NicknameText>{user.username}</NicknameText>
<div>
<UsernameText>
{user.username}#{user.discriminator}
</UsernameText>
</div>
</UsernameWrapper>
</div>
</Section>
<HorizontalDivider
style={{
margin: "0 12px",
}}
/>
<Section>
<Heading>Member Since</Heading>
<MemberSinceContainer>
<Tooltip title="Spacebar" placement="top">
<div>
<SpacebarLogoBlue width={16} height={16} style={{ borderRadius: "50%" }} />
</div>
</Tooltip>
<MemberSinceText>{dayjs(createdAt).format("MMM D, YYYY")}</MemberSinceText>
{/* <div
style={{
height: "4px",
width: "4px",
borderRadius: "50%",
backgroundColor: "var(--text-disabled)",
}}
/> */}
{/* <Tooltip title={guild.name} placement="top">
{guild.icon ? (
<img
src={REST.makeCDNUrl(CDNRoutes.guildIcon(guild.id, guild.icon, ImageFormat.PNG))}
width={16}
height={16}
loading="lazy"
style={{
borderRadius: "50%",
}}
/>
) : (
<span
style={{
fontSize: "16px",
fontWeight: "bold",
cursor: "pointer",
}}
>
{guild.acronym}
</span>
)}
</Tooltip>
<MemberSinceText>{dayjs(member.joined_at).format("MMM D, YYYY")}</MemberSinceText> */}
</MemberSinceContainer>
</Section>
</Bottom>
</Container>
);
}
export default UserProfilePopout;

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,72 @@
// loosely based on https://github.com/revoltchat/frontend/blob/master/components/app/menus/UserContextMenu.tsx
import { modalController } from "../../controllers/modals";
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);
}
/**
* Copy link to clipboard
*/
function copyLink() {
navigator.clipboard.writeText(`${window.location.origin}/channels/${channel.guildId}/${channel.id}`);
}
/**
* Open invite creation modal
*/
function openInviteCreateModal() {
modalController.push({
type: "create_invite",
target: channel,
});
}
return (
<ContextMenu>
<ContextMenuButton icon="mdiAccountPlus" onClick={openInviteCreateModal}>
Create Invite
</ContextMenuButton>
<ContextMenuButton icon="mdiLink" onClick={copyLink}>
Copy Link
</ContextMenuButton>
<ContextMenuDivider />
{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,48 @@
// 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 ChannelMentionContextMenu({ channel }: MenuProps) {
/**
* Copy id to clipboard
*/
function copyId() {
navigator.clipboard.writeText(channel.id);
}
/**
* Copy link to clipboard
*/
function copyLink() {
navigator.clipboard.writeText(`${window.location.origin}/channels/${channel.guildId}/${channel.id}`);
}
return (
<ContextMenu>
<ContextMenuButton icon="mdiLink" onClick={copyLink}>
Copy Link
</ContextMenuButton>
<ContextMenuDivider />
<ContextMenuButton
icon="mdiIdentifier"
iconProps={{
style: {
filter: "invert(100%)",
background: "black",
borderRadius: "4px",
},
}}
onClick={copyId}
>
Copy Channel ID
</ContextMenuButton>
</ContextMenu>
);
}
export default ChannelMentionContextMenu;

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,79 @@
// loosely based on https://github.com/revoltchat/frontend/blob/master/components/app/menus/UserContextMenu.tsx
import { ChannelType } from "@spacebarchat/spacebar-api-types/v9";
import { modalController } from "../../controllers/modals";
import useLogger from "../../hooks/useLogger";
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 logger = useLogger("GuildContextMenu");
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,
});
}
/**
* Open invite creation modal
*/
function openInviteCreateModal() {
const channel = guild.channels.find((x) => x.type === ChannelType.GuildText && x.hasPermission("VIEW_CHANNEL"));
if (!channel) {
logger.error("Failed to find suitable channel for invite creation");
return;
}
modalController.push({
type: "create_invite",
target: channel,
});
}
return (
<ContextMenu>
<ContextMenuButton onClick={openInviteCreateModal}>Create Invite</ContextMenuButton>
<ContextMenuDivider />
{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,67 @@
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.author.id === app.account?.id) &&
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,101 @@
// 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 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) {
const app = useAppStore();
const guild = member ? app.guilds.get(member.guild.id) : undefined;
const guildMe = guild ? guild.members.get(app.account!.id) : undefined;
/**
* 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 && guildMe && (
<>
{guildMe.hasPermission("KICK_MEMBERS") && (
<ContextMenuButton destructive onClick={kick}>
Kick {member?.nick ?? user.username}
</ContextMenuButton>
)}
{guildMe.hasPermission("BAN_MEMBERS") && (
<>
<ContextMenuButton destructive onClick={ban}>
Ban {member?.nick ?? user.username}
</ContextMenuButton>
<ContextMenuDivider />
</>
)}
{guildMe.hasPermission("MANAGE_ROLES") && (
<>
<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 (
<span ref={ref} data-state={context.open ? "open" : "closed"} {...context.getReferenceProps(props)}>
{children}
</span>
);
},
);

View File

@ -0,0 +1,77 @@
import styled from "styled-components";
import useLogger from "../../hooks/useLogger";
import React, { useEffect } from "react";
import { modalController } from "../../controllers/modals";
import { useAppStore } from "../../stores/AppStore";
import { Permissions } from "../../utils/Permissions";
import { ContextMenu, ContextMenuButton, ContextMenuDivider } from "../contextMenus/ContextMenu";
const CustomContextMenu = styled(ContextMenu)`
width: 200px;
`;
function GuildMenuPopout() {
const { activeGuild, account } = useAppStore();
const logger = useLogger("GuildMenuPopout");
const [hasCreateChannelPermission, setHasCreateChannelPermission] = React.useState(false);
useEffect(() => {
if (!activeGuild) return;
const permission = Permissions.getPermission(account!.id, activeGuild, undefined);
const hasPermission = permission.has("MANAGE_CHANNELS");
setHasCreateChannelPermission(hasPermission);
}, [activeGuild]);
if (!activeGuild) {
logger.error("activeGuild is undefined");
return null;
}
function leaveGuild() {
modalController.push({
type: "leave_server",
target: activeGuild!,
});
}
function onChannelCreateClick() {
modalController.push({
type: "create_channel",
guild: activeGuild!,
});
}
return (
<CustomContextMenu>
<ContextMenuButton icon="mdiCog" disabled>
Server Settings
</ContextMenuButton>
{hasCreateChannelPermission && (
<>
<ContextMenuButton icon="mdiPlusCircle" onClick={onChannelCreateClick}>
Create Channel
</ContextMenuButton>
<ContextMenuButton icon="mdiFolderPlus" disabled>
Create Category
</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

@ -0,0 +1,295 @@
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 SpacebarLogoBlue from "../../assets/images/logo/Spacebar_Icon.svg?react";
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;
border-radius: 4px;
display: flex;
flex-direction: column;
width: 340px;
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`
display: flex;
flex-direction: column;
`;
const Bottom = styled.div`
display: flex;
flex-direction: column;
background-color: var(--background-tertiary);
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`
display: flex;
justify-content: space-between;
display: flex;
flex-direction: column;
`;
const UsernameWrapper = styled.div`
text-overflow: clip;
white-space: nowrap;
overflow: hidden;
`;
const NicknameText = styled.span`
font-size: 20px;
font-weight: var(--font-weight-bold);
`;
const UsernameText = styled.span`
font-size: 14px;
font-weight: var(--font-weight-medium);
`;
const Heading = styled.span`
display: flex;
font-weight: var(--font-weight-bold);
margin-bottom: 6px;
font-size: 12px;
user-select: none;
`;
const MemberSinceContainer = styled.div`
display: flex;
flex-direction: row;
column-gap: 8px;
align-items: center;
user-select: none;
`;
const MemberSinceText = styled.span`
font-size: 14px;
font-weight: var(--font-weight-regular);
`;
const Acronym = styled.div`
font-size: 8px;
background-image: none;
background-color: var(--background-secondary);
text-align: center;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
`;
const AcronymText = styled.div`
font-weight: var(--font-weight-bold);
overflow: hidden;
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;
member?: GuildMember;
}
function UserProfilePopout({ user, member }: Props) {
const app = useAppStore();
const logger = useLogger("UserProfilePopout");
const id = user.id;
const { timestamp: createdAt } = Snowflake.deconstruct(id);
const presence = app.presences.get(user.id);
return (
<Container>
<Top>
<Avatar
style={{ margin: "22px 16px" }}
size={80}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// TODO: open profile modal
logger.debug("open profile modal");
}}
user={user}
presence={presence}
statusDotStyle={{
width: 16,
height: 16,
}}
showPresence
/>
</Top>
<Bottom>
<Section>
<div>
<UsernameWrapper>
<NicknameText>{member?.nick ?? user.username}</NicknameText>
<div>
<UsernameText>
{user.username}#{user.discriminator}
</UsernameText>
</div>
</UsernameWrapper>
</div>
</Section>
<HorizontalDivider
style={{
margin: "0 12px",
}}
/>
<Section>
<Heading>Member Since</Heading>
<MemberSinceContainer>
<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 && (
<>
<div
style={{
height: "4px",
width: "4px",
borderRadius: "50%",
backgroundColor: "var(--text-disabled)",
}}
/>
<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>
);
}
export default UserProfilePopout;

View File

@ -176,6 +176,10 @@ const customRenderer: Partial<ReactRenderer> = {
<Mention key={i} type="role" id={match} />
));
replaced = reactStringReplace(replaced, /(@everyone|@here)/, (match, i) => (
<Mention key={i} type="text" id={match} />
));
return replaced;
},
};

View File

@ -1,23 +1,29 @@
import React, { memo } from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { PopoutContext } from "../../contexts/PopoutContext";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
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";
import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger";
const Container = styled.span<{ color?: string }>`
const MentionText = styled.span<{ color?: string; withHover?: boolean }>`
padding: 0 2px;
border-radius: 4px;
background-color: hsl(${(props) => props.color ?? "var(--primary-hsl)"} / 0.3);
user-select: ${(props) => (props.withHover ? "none" : "inherit")};
&:hover {
background-color: hsl(${(props) => props.color ?? "var(--primary-hsl)"} / 0.5);
cursor: pointer;
}
${(props) =>
props.withHover &&
`
&:hover {
background-color: hsl(${props.color ?? "var(--primary-hsl)"} / 0.5);
cursor: pointer;
}
`}
`;
interface MentionProps {
@ -25,40 +31,34 @@ 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,
});
};
const contextMenu = React.useContext(ContextMenuContext);
React.useEffect(() => {
const user = app.users.get(id);
if (user) setUser(user);
const getUser = async () => {
const resolvedUser = await app.users.resolve(id);
setUser(resolvedUser ?? null);
};
getUser();
}, [id]);
if (!user)
return (
<Container ref={ref}>
<span>@{id}</span>
</Container>
);
if (!user) return <MentionText>@{id}</MentionText>;
return (
<Container onClick={click} ref={ref}>
<span>@{user.username}</span>
</Container>
<Floating
type="userPopout"
placement="right"
props={{
user,
}}
>
<FloatingTrigger>
<MentionText withHover onContextMenu={(e) => contextMenu.onContextMenu(e, { type: "user", user })}>
@{user.username}
</MentionText>
</FloatingTrigger>
</Floating>
);
}
@ -66,9 +66,11 @@ function ChannelMention({ id }: MentionProps) {
const app = useAppStore();
const [channel, setChannel] = React.useState<Channel | null>(null);
const navigate = useNavigate();
const contextMenu = React.useContext(ContextMenuContext);
const click = () => {
const onClick = () => {
if (!channel) return;
if (!channel.isGuildTextChannel) return;
navigate(`/channels/${channel.guildId}/${channel.id}`);
};
@ -77,17 +79,16 @@ function ChannelMention({ id }: MentionProps) {
if (channel) setChannel(channel);
}, [id]);
if (!channel)
return (
<Container>
<span>#{id}</span>
</Container>
);
if (!channel) return <MentionText>#{id}</MentionText>;
return (
<Container onClick={click}>
<span>#{channel.name}</span>
</Container>
<MentionText
withHover
onClick={onClick}
onContextMenu={(e) => contextMenu.onContextMenu(e, { type: "channelMention", channel })}
>
#{channel.name}
</MentionText>
);
}
@ -110,22 +111,21 @@ function RoleMention({ id }: MentionProps) {
setColor(rgbToHsl(rgb.r, rgb.g, rgb.b));
}, [role]);
if (!role)
return (
<Container>
<span>@unknown-role</span>
</Container>
);
if (!role) return <MentionText>@unknown-role</MentionText>;
return (
<Container color={color}>
<span>@{role.name}</span>
</Container>
<MentionText color={color} withHover>
@{role.name}
</MentionText>
);
}
function CustomMention({ id }: MentionProps) {
return <MentionText>{id}</MentionText>;
}
interface Props {
type: "role" | "user" | "channel";
type: "role" | "user" | "channel" | "text";
id: string;
}
@ -133,6 +133,7 @@ function Mention({ type, id }: Props) {
if (type === "role") return <RoleMention id={id} />;
if (type === "user") return <UserMention id={id} />;
if (type === "channel") return <ChannelMention id={id} />;
if (type === "text") return <CustomMention id={id} />;
return null;
}

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

@ -8,7 +8,10 @@
code[class*="language-"],
pre[class*="language-"] {
color: #f92aad;
text-shadow: 0 0 2px #100c0f, 0 0 5px #dc078e33, 0 0 10px #fff3;
text-shadow:
0 0 2px #100c0f,
0 0 5px #dc078e33,
0 0 10px #fff3;
background: none;
font-family: var(--font-family-code);
font-size: 1em;
@ -74,7 +77,10 @@ pre[class*="language-"] {
.token.property,
.token.selector {
color: #72f1b8;
text-shadow: 0 0 2px #100c0f, 0 0 10px #257c5575, 0 0 35px #21272475;
text-shadow:
0 0 2px #100c0f,
0 0 10px #257c5575,
0 0 35px #21272475;
}
.token.function-name {
@ -85,18 +91,29 @@ pre[class*="language-"] {
.token.selector .token.id,
.token.function {
color: #fdfdfd;
text-shadow: 0 0 2px #001716, 0 0 3px #03edf975, 0 0 5px #03edf975, 0 0 8px #03edf975;
text-shadow:
0 0 2px #001716,
0 0 3px #03edf975,
0 0 5px #03edf975,
0 0 8px #03edf975;
}
.token.class-name {
color: #fff5f6;
text-shadow: 0 0 2px #000, 0 0 10px #fc1f2c75, 0 0 5px #fc1f2c75, 0 0 25px #fc1f2c75;
text-shadow:
0 0 2px #000,
0 0 10px #fc1f2c75,
0 0 5px #fc1f2c75,
0 0 25px #fc1f2c75;
}
.token.constant,
.token.symbol {
color: #f92aad;
text-shadow: 0 0 2px #100c0f, 0 0 5px #dc078e33, 0 0 10px #fff3;
text-shadow:
0 0 2px #100c0f,
0 0 5px #dc078e33,
0 0 10px #fff3;
}
.token.important,
@ -105,7 +122,10 @@ pre[class*="language-"] {
.token.selector .token.class,
.token.builtin {
color: #f4eee4;
text-shadow: 0 0 2px #393a33, 0 0 8px #f39f0575, 0 0 2px #f39f0575;
text-shadow:
0 0 2px #393a33,
0 0 8px #f39f0575,
0 0 2px #f39f0575;
}
.token.string,

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,24 +6,22 @@ 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;
padding: 0;
background-color: inherit;
border: none;
&:hover {
color: red;
}
`;
const CustomIcon = styled(Icon)<{ $active?: boolean }>`
color: ${(props) => (props.$active ? "#ffffff" : "var(--text-secondary)")};
color: ${(props) => (props.$active ? "var(--text)" : "var(--text-secondary)")};
&:hover {
color: var(--text);
color: ${(props) => (props.$active ? "var(--text-secondary)" : "var(--text)")};
cursor: pointer;
}
`;
@ -114,19 +112,35 @@ interface ActionItemProps {
ariaLabel?: string;
tooltip: string;
onClick?: () => void;
disabled?: boolean;
color?: string;
}
function ActionItem({ icon, active, ariaLabel, tooltip, onClick }: ActionItemProps) {
function ActionItem({ icon, active, ariaLabel, tooltip, disabled, color, onClick }: ActionItemProps) {
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={!disabled && active}
icon={icon}
size="24px"
aria-label={ariaLabel}
color={color}
/>
</IconButton>
</IconWrapper>
</FloatingTrigger>
</Floating>
);
}
@ -134,7 +148,7 @@ function ActionItem({ icon, active, ariaLabel, tooltip, onClick }: ActionItemPro
* Top header for channel messages section
*/
function ChatHeader({ channel }: Props) {
const { memberListVisible, toggleMemberList } = useAppStore();
const { memberListVisible, toggleMemberList, updaterStore } = useAppStore();
return (
<Container>
@ -144,6 +158,36 @@ function ChatHeader({ channel }: Props) {
<ChannelTopic channel={channel} />
{/* Action Items */}
<ActionItemsWrapper>
{updaterStore?.checkingForUpdates && (
<ActionItem
icon="mdiCloudSync"
tooltip="Checking for Updates"
ariaLabel="Checking for Updates"
disabled
/>
)}
{updaterStore?.updateAvailable && (
<ActionItem icon="mdiUpdate" tooltip="Update Available" ariaLabel="Upate Available" disabled />
)}
{updaterStore?.updateDownloading && (
<ActionItem
icon="mdiCloudDownload"
tooltip="Downloading Update"
ariaLabel="Downloading Update"
disabled
/>
)}
{updaterStore?.updateDownloaded && (
<ActionItem
icon="mdiDownload"
tooltip="Update Ready!"
ariaLabel="Update Ready!"
color="var(--success)"
onClick={() => {
updaterStore.quitAndInstall();
}}
/>
)}
{/* <ActionItem icon="mdiPound" ariaLabel="Threads" /> */}
<DummySearch>
<span>Search</span>

View File

@ -17,6 +17,7 @@ iframe {
.embedImage {
cursor: pointer;
border-radius: 8px;
}
.website {
@ -159,6 +160,7 @@ iframe {
.embedThumbnail {
margin-left: 16px;
border-radius: 8px;
}
img.image {
@ -170,3 +172,19 @@ img.image {
a {
cursor: pointer;
}
.embedGifIcon {
position: absolute;
top: 5px;
left: 5px;
}
.embedGifIconBg {
position: absolute;
top: 8px;
left: 8px;
width: 18px;
height: 18px;
background: #000000;
border-radius: 4px;
}

View File

@ -2,6 +2,8 @@
// https://github.com/revoltchat/revite/blob/master/src/components/common/messaging/embed/Embed.tsx
import { APIEmbed, EmbedType } from "@spacebarchat/spacebar-api-types/v9";
import { modalController } from "../../controllers/modals";
import Icon from "../Icon";
import styles from "./Embed.module.css";
interface Props {
@ -30,7 +32,7 @@ function EmbedMedia({ embed, width, height, thumbnail }: Props) {
return (
<iframe
style={{ borderRadius: "12px", width: "400px", height: "80px" }}
style={{ width: "400px", height: "80px", borderRadius: 12 }}
src={`https://open.spotify.com/embed/${type}/${id}`}
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
@ -92,15 +94,31 @@ function EmbedMedia({ embed, width, height, thumbnail }: Props) {
const url = embed.video.url;
return (
<video
className={styles.embedImage}
style={{ width, height }}
src={url}
loop={embed.type === EmbedType.GIFV}
controls={embed.type === EmbedType.GIFV}
autoPlay={embed.type === EmbedType.GIFV}
muted={embed.type === EmbedType.GIFV ? true : undefined}
/>
<div>
<video
className={styles.embedImage}
style={{ width, height }}
src={url}
loop={embed.type === EmbedType.GIFV}
controls={embed.type !== EmbedType.GIFV}
autoPlay={embed.type === EmbedType.GIFV}
muted={embed.type === EmbedType.GIFV ? true : undefined}
onClick={() => {
modalController.push({
type: "image_viewer",
attachment: embed.video!,
isVideo: true,
});
}}
/>
{embed.type === EmbedType.GIFV && (
<div>
<div className={styles.embedGifIconBg}></div>
<Icon icon="mdiFileGifBox" size={1} className={styles.embedGifIcon} />
</div>
)}
</div>
);
} else if (embed.image && !thumbnail) {
const url = embed.image.url;
@ -112,9 +130,11 @@ function EmbedMedia({ embed, width, height, thumbnail }: Props) {
loading="lazy"
style={{ width: "100%", height: "100%" }}
onClick={() => {
console.log("preview image");
modalController.push({
type: "image_viewer",
attachment: embed.image!,
});
}}
onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")}
/>
);
} else if (embed.thumbnail) {
@ -127,9 +147,11 @@ function EmbedMedia({ embed, width, height, thumbnail }: Props) {
loading="lazy"
style={{ width, height }}
onClick={() => {
console.log("preview image");
modalController.push({
type: "image_viewer",
attachment: embed.thumbnail!,
});
}}
onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")}
/>
);
}

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,13 +19,27 @@ 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;
const isUserMentioned = "mentions" in message && message.mentions.some((mention) => mention.id === app.account!.id);
const isRoleMentioned =
guild &&
"mention_roles" in message &&
message.mention_roles.some((r1) => guild.members.me?.roles.some((r2) => r1 === r2.id));
return (
<MessageBase header={header} onContextMenu={(e) => contextMenu.open2(e, contextMenuItems)}>
<MessageBase
header={header}
mention={isEveryoneMentioned || isUserMentioned || isRoleMentioned}
onContextMenu={(e) =>
contextMenuContext.onContextMenu(e, {
type: "message",
message: message,
})
}
>
<MessageInfo>
{header ? (
<Avatar key={message.author.id} user={message.author} size={40} />
@ -38,10 +50,11 @@ function Message({ message, header }: Props) {
<MessageContent>
{header && (
<span className="message-details">
<MessageAuthor message={message} />
<MessageAuthor message={message} guild={guild} />
<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,29 @@
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 { modalController } from "../../controllers/modals";
import useLogger from "../../hooks/useLogger";
import ContextMenus from "../../utils/ContextMenus";
import { calculateImageRatio, calculateScaledDimensions } from "../../utils/Message";
import { getFileDetails } from "../../utils/Utils";
import { IContextMenuItem } from "../ContextMenuItem";
import { getFileDetails, zoomFit } from "../../utils/Utils";
import Audio from "../media/Audio";
import File from "../media/File";
import Video from "../media/Video";
import AttachmentPreviewModal from "../modals/AttachmentPreviewModal";
const MAX_ATTACHMENT_HEIGHT = 350;
function adjustDimensions(width: number, height: number): { adjustedWidth: number; adjustedHeight: number } {
const aspectRatio = width / height;
let adjustedWidth: number = width * aspectRatio;
let adjustedHeight: number = height * aspectRatio;
// Ensure the adjusted height does not exceed the maximum height
if (adjustedHeight > MAX_ATTACHMENT_HEIGHT) {
const scale = MAX_ATTACHMENT_HEIGHT / adjustedHeight;
adjustedWidth *= scale;
adjustedHeight = MAX_ATTACHMENT_HEIGHT;
}
return { adjustedWidth: Math.floor(adjustedWidth), adjustedHeight: Math.floor(adjustedHeight) };
}
const Attachment = styled.div<{ withPointer?: boolean }>`
cursor: ${(props) => (props.withPointer ? "pointer" : "default")};
@ -20,36 +32,33 @@ const Attachment = styled.div<{ withPointer?: boolean }>`
const Image = styled.img`
border-radius: 4px;
width: 100%;
height: auto;
`;
interface AttachmentProps {
attachment: APIAttachment;
contextMenuItems?: IContextMenuItem[];
maxWidth?: number;
maxHeight?: number;
}
export default function MessageAttachment({ attachment, contextMenuItems, maxWidth, maxHeight }: AttachmentProps) {
export default function MessageAttachment({ attachment }: 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);
let finalElement: JSX.Element = <></>;
if (details.isImage && details.isEmbeddable) {
const ratio = calculateImageRatio(attachment.width!, attachment.height!, maxWidth, maxHeight);
const { scaledWidth, scaledHeight } = calculateScaledDimensions(
attachment.width!,
attachment.height!,
ratio,
maxWidth,
maxHeight,
);
const width = attachment.width!;
const height = attachment.height!;
const { adjustedWidth, adjustedHeight } = adjustDimensions(width, height);
finalElement = (
<Image src={url} alt={attachment.filename} width={scaledWidth} height={scaledHeight} loading="lazy" />
<Image
src={url}
alt={attachment.filename}
loading="lazy"
style={{ maxWidth: adjustedWidth, maxHeight: adjustedHeight }}
/>
);
} else if (details.isVideo && details.isEmbeddable) {
finalElement = <Video attachment={attachment} />;
@ -63,12 +72,15 @@ 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;
openModal(AttachmentPreviewModal, { attachment });
const { width, height } = zoomFit(attachment.width!, attachment.height!);
modalController.push({
type: "image_viewer",
attachment,
width,
height,
});
}}
>
{finalElement}

View File

@ -1,12 +1,14 @@
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 useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import Guild from "../../stores/objects/Guild";
import GuildMember from "../../stores/objects/GuildMember";
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;
@ -21,57 +23,59 @@ const Container = styled.div`
interface Props {
message: MessageLike;
guild?: Guild;
}
function MessageAuthor({ message }: Props) {
function MessageAuthor({ message, guild }: Props) {
const app = useAppStore();
const contextMenu = React.useContext(ContextMenuContext);
const popoutContext = React.useContext(PopoutContext);
const logger = useLogger("MessageAuthor");
const contextMenu = useContext(ContextMenuContext);
const [color, setColor] = React.useState<string | undefined>(undefined);
const ref = React.useRef<HTMLDivElement>(null);
const [eventData, setEventData] = React.useState<React.MouseEvent<HTMLDivElement, MouseEvent> | undefined>();
const [member, setMember] = React.useState<GuildMember | undefined>(undefined);
const { members } = guild || {};
React.useEffect(() => {
if ("guild_id" in message && 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;
const highestRole = member.roles.reduce((prev, role) => {
if (role.position > prev.position) return role;
return prev;
}, member.roles[0]);
if (highestRole?.color === "#000000") return; // TODO: why the fk do we use black as the default color???
setColor(highestRole?.color);
}
}, [message]);
if (!eventData) return;
contextMenu.onContextMenu(eventData, { type: "user", user: message.author, member });
}, [eventData, member]);
const openPopout = (e: React.MouseEvent) => {
const onContextMenu = async (e: React.MouseEvent<HTMLDivElement, 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",
});
setEventData(e);
app.guilds.get(message.guild_id!)?.members.resolve(message.author.id).then(setMember);
};
React.useEffect(() => {
if (!members) return;
const member = members.get(message.author.id);
if (!member) return;
setColor(member.roleColor);
}, [message, members]);
return (
<Container
ref={ref}
style={{
color,
<Floating
placement="right-start"
type="userPopout"
props={{
user: message.author,
}}
onContextMenu={(e) => contextMenu.open2(e, [...ContextMenus.User(message.author)])}
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;
@ -15,8 +16,10 @@ export default styled.div<Props>`
overflow: none;
flex-direction: row;
${(props) => !props.header && "align-items: center;"}
${(props) => props.header && "margin-top: 20px;"}
${(props) => props.header && "margin-top: 10px;"}
${(props) => props.mention && "background-color: hsl(var(--warning-light-hsl)/0.1);"}
padding-top: 0.2rem;
padding-bottom: 0.2rem;
.message-details {
display: flex;
@ -63,7 +66,6 @@ export const MessageInfo = styled.div`
export const MessageContent = styled.div`
position: relative;
min-width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
@ -96,18 +98,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 +138,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

@ -5,11 +5,13 @@ import { APIEmbed, EmbedType } from "@spacebarchat/spacebar-api-types/v9";
import classNames from "classnames";
import React from "react";
import { decimalColorToHex } from "../../utils/Utils";
import Markdown from "../markdown/Markdown";
import MarkdownRenderer from "../markdown/MarkdownRenderer";
import styles from "./Embed.module.css";
import EmbedMedia from "./EmbedMedia";
import { MESSAGE_AREA_PADDING, MessageAreaWidthContext } from "./MessageList";
const MAX_EMBED_WIDTH = 400;
const MAX_EMBED_WIDTH = 300;
const MAX_EMBED_HEIGHT = 640;
const THUMBNAIL_MAX_WIDTH = 80;
const CONTAINER_PADDING = 24;
@ -26,7 +28,6 @@ function MessageEmbed({ embed }: Props) {
function calculateSize(w: number, h: number): { width: number; height: number } {
const limitingWidth = Math.min(w, maxWidth);
const limitingHeight = Math.min(MAX_EMBED_HEIGHT, h);
// Calculate smallest possible WxH.
@ -75,14 +76,13 @@ function MessageEmbed({ embed }: Props) {
}
const { width, height } = calculateSize(mw, mh);
if (embed.type === EmbedType.GIFV || EMBEDDABLE_PROVIDERS.includes(embed.provider?.name ?? "")) {
return (
<EmbedMedia
embed={embed}
width={height * ((embed.image?.width ?? 0) / (embed.image?.height ?? 0))}
height={height}
/>
);
if (
embed.type === EmbedType.GIFV ||
embed.type === EmbedType.Image ||
embed.type === EmbedType.Video ||
EMBEDDABLE_PROVIDERS.includes(embed.provider?.name ?? "")
) {
return <EmbedMedia embed={embed} width={height} height={height} />;
}
return (
@ -141,7 +141,11 @@ function MessageEmbed({ embed }: Props) {
</>
)}
{embed.description && <div className={styles.embedDescription}>{embed.description}</div>}
{embed.description && (
<div className={styles.embedDescription}>
<MarkdownRenderer content={embed.description} />
</div>
)}
{embed.fields && (
<div className={styles.embedFields}>
@ -156,7 +160,9 @@ function MessageEmbed({ embed }: Props) {
}}
>
<div className={styles.embedFieldName}>{field.name}</div>
<div className={styles.embedFieldValue}>{field.value}</div>
<div className={styles.embedFieldValue}>
<Markdown content={field.value} />
</div>
</div>
))}
</div>

View File

@ -1,18 +1,17 @@
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";
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 +59,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
@ -139,7 +137,7 @@ function MessageInput({ channel }: Props) {
// TODO: handle editing last message
if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) {
if (!e.shiftKey && e.key === "Enter") {
e.preventDefault();
return sendMessage();
}
@ -161,13 +159,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;
}
@ -202,7 +197,7 @@ function MessageInput({ channel }: Props) {
channel.type === ChannelType.DM
? channel.recipients?.[0].username
: "#" + channel.name
}`
}`
: "You do not have permission to send messages in this channel."
}
disabled={!channel.hasPermission("SEND_MESSAGES")}

View File

@ -44,25 +44,34 @@ function MessageList({ guild, channel }: Props) {
const ref = React.useRef<HTMLDivElement>(null);
const { width } = useResizeObserver<HTMLDivElement>({ ref });
// handles the permission check
React.useEffect(() => {
const permission = Permissions.getPermission(app.account!.id, guild, channel);
setCanView(permission.has("READ_MESSAGE_HISTORY"));
}, [guild, channel]);
const hasPermission = permission.has("READ_MESSAGE_HISTORY");
setCanView(hasPermission);
if (!hasPermission) {
logger.debug("User cannot view this channel. Aborting initial message fetch.");
return;
}
// handles the initial fetch of channel messages
React.useEffect(() => {
if (!canView) return;
if (guild && channel && channel.messages.count === 0) {
channel.getMessages(app, true).then((r) => {
if (r !== 50) setHasMore(false);
else setHasMore(true);
if (r < 50) {
setHasMore(false);
}
});
}
}, [guild, channel, canView]);
return () => {
logger.debug("MessageList unmounted");
setHasMore(true);
setCanView(false);
};
}, [guild, channel]);
const fetchMore = React.useCallback(() => {
if (!channel.messages.count) {
logger.warn("channel has no messages, aborting!");
return;
}
// get last group
@ -77,16 +86,17 @@ function MessageList({ guild, channel }: Props) {
const before = lastGroup.messages[0].id;
logger.debug(`Fetching 50 messages before ${before} for channel ${channel.id}`);
channel.getMessages(app, false, 50, before).then((r) => {
if (r !== 50) setHasMore(false);
else setHasMore(true);
if (r < 50) {
setHasMore(false);
}
});
}, [channel, messageGroups, setHasMore]);
}, [channel, messageGroups]);
const renderGroup = React.useCallback(
(group: MessageGroupType) => (
<MessageGroup key={`messageGroup-${group.messages[group.messages.length - 1].id}`} group={group} />
),
[],
[messageGroups],
);
return (
@ -100,6 +110,7 @@ function MessageList({ guild, channel }: Props) {
display: "flex",
flexDirection: "column-reverse",
marginBottom: 30,
overflow: "hidden",
}} // to put endMessage and loader to the top.
hasMore={hasMore}
inverse={true}
@ -109,11 +120,13 @@ function MessageList({ guild, channel }: Props) {
display: "flex",
justifyContent: "center",
alignContent: "center",
marginBottom: 30,
margin: 30,
}}
color="var(--primary)"
/>
}
// FIXME: seems to be broken in react-infinite-scroll-component when using inverse
scrollThreshold={0.5}
scrollableTarget="scrollable-div"
endMessage={
<EndMessageContainer>

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