├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── docker-image.yml ├── .gitignore ├── .swcrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CompressingImages.md ├── Dockerfile ├── EmojiList └── credit.txt ├── InstanceInfo.md ├── LICENSE ├── README.md ├── SECURITY.md ├── build.ts ├── buildnode.js ├── compose.yaml ├── emoji-packer.cjs ├── eslint.config.cjs.hidden ├── package.json ├── src ├── index.ts ├── stats.ts ├── utils.ts └── webpage │ ├── Commissioner-Regular.woff2 │ ├── Dbadges.ts │ ├── app.html │ ├── audio │ ├── audio.md │ ├── audio.ts │ ├── index.html │ ├── page.ts │ ├── play.ts │ ├── sounds.jasf │ ├── track.ts │ └── voice.ts │ ├── bot.ts │ ├── channel.ts │ ├── contextmenu.ts │ ├── direct.ts │ ├── disimg.ts │ ├── embed.ts │ ├── emoji.bin │ ├── emoji.ts │ ├── favicon.ico │ ├── file.ts │ ├── guild.ts │ ├── home.html │ ├── home.ts │ ├── hover.ts │ ├── i18n.ts │ ├── icons │ ├── addfriend.svg │ ├── announce.svg │ ├── announcensfw.svg │ ├── call.svg │ ├── category.svg │ ├── channel.svg │ ├── channelnsfw.svg │ ├── copy.svg │ ├── delete.svg │ ├── edit.svg │ ├── emoji.svg │ ├── explore.svg │ ├── friends.svg │ ├── frmessage.svg │ ├── gif.svg │ ├── hangup.svg │ ├── home.svg │ ├── intoMenu.svg │ ├── leftArrow.svg │ ├── mic.svg │ ├── micmute.svg │ ├── novideo.svg │ ├── pause.svg │ ├── pin.svg │ ├── plainx.svg │ ├── play.svg │ ├── plus.svg │ ├── reply.svg │ ├── rules.svg │ ├── sad.svg │ ├── search.svg │ ├── settings.svg │ ├── soundMore.svg │ ├── spoiler.svg │ ├── sticker.svg │ ├── stopstream.svg │ ├── stream.svg │ ├── unspoiler.svg │ ├── upload.svg │ ├── video.svg │ ├── voice.svg │ ├── voicensfw.svg │ └── x.svg │ ├── index.ts │ ├── infiniteScroller.ts │ ├── instances.json │ ├── invite.html │ ├── invite.ts │ ├── jsontypes.ts │ ├── localuser.ts │ ├── login.html │ ├── login.ts │ ├── logo.svg │ ├── logo.webp │ ├── manifest.json │ ├── markdown.ts │ ├── media.ts │ ├── member.ts │ ├── message.ts │ ├── oauth2 │ ├── auth.ts │ └── authorize.html │ ├── permissions.ts │ ├── recover.ts │ ├── register.html │ ├── register.ts │ ├── reset.html │ ├── rights.ts │ ├── robots.txt │ ├── role.ts │ ├── search.ts │ ├── service.ts │ ├── settings.ts │ ├── snowflake.ts │ ├── sticker.ts │ ├── style.css │ ├── template.html │ ├── templatePage.ts │ ├── themes.css │ ├── user.ts │ ├── utils │ ├── binaryUtils.ts │ ├── dirrWorker.ts │ ├── progessiveLoad.ts │ └── utils.ts │ ├── voice.ts │ └── webhooks.ts ├── translations.md ├── translations ├── de.json ├── en.json ├── fr.json ├── ko.json ├── lb.json ├── lt.json ├── nl.json ├── qqq.json ├── ru.json └── tr.json └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | A description of the pull request 3 | 4 | # Related issues 5 | Any related issues, delete if there are none 6 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Build the Docker image 18 | run: docker build . --file Dockerfile --tag my-image-name:$(date +%s) 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | #Data file for tomoato 133 | testAccount.json 134 | CC 135 | uptime.json 136 | .directory 137 | .dist/ 138 | bun.lockb 139 | src/webpage/translations/langs.js 140 | 141 | package-lock.json 142 | pnpm-lock.yaml 143 | build.js 144 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://swc.rs/schema.json", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": false 7 | }, 8 | 9 | "target": "es2024" 10 | }, 11 | "sourceMaps": true, 12 | "minify": true 13 | } 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | It's nothing complicated, I want to foster a nice community, if there's any issues feel free to contact me in any way you see fit, though please don't create problems for me, I'm just one person and I want to work on this project, not community management. I will likely start with a warning or two if any issues arrise, though this is up to my(mathium05) sole discretion. 3 | generally follow https://docs.spacebar.chat/contributing/conduct/ and you should be fine. Do not try to pull technicalities, this is a FOSS project, not a court of law. 4 | 5 | Happy coding! 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This ain't exactly rocket science, please just describe what you've done, and follow all normal steps, it may take a few days for me to get back to you, life happens. Just try to keep the pull requests fairly small, so adding one thing or fixing one thing, but you may fix multiple bugs in one patch if they're either related or small enough. These are all soft rules and I am going to be more lenient. 2 | -------------------------------------------------------------------------------- /CompressingImages.md: -------------------------------------------------------------------------------- 1 | This is for in the future when I want to compress more images or anyone else for that matter. 2 | # Lossless 3 | 4 | ### https://squoosh.app/ 5 | good at reducing the pallet, a first step for images that have a limited number of colors, bad at actually compressing things though, for all formats except webp. 6 | 7 | ## PNGs: 8 | good ratios, though not as good as other options, though better compatibility 9 | ### oxipng 10 | Seems to be the best of all of the options, not sure if it's all you would need, but it did shrink pngs further than the other two tools afterwards. 11 | ```bash 12 | oxipng -o max --strip all --alpha 13 | ``` 14 | `all` may be replaced with `safe` if you want to be a bit safer 15 | 16 | ### pngcrush 17 | Good, but should be ran before optipng, but isn't as good as it, use in tandom 18 | ### optipng 19 | The second best tool to really shrink pngs to be as small as they can be. 20 | 21 | ## WEBP 22 | it's better than png, though I have a feeling more could be done to compress these 23 | ### cwebp 24 | so far this seems to be the best way to compress webp images with a command that kinda looks like this one 25 | ```bash 26 | cwebp -lossless -z 9 in.webp -o out.webp 27 | ``` 28 | while for all other formats squoosh is not recommended, for webp it'll be identical due to cwebp using the same libary as squoosh. 29 | 30 | ## AVIF 31 | As far as I can tell, this format just sucks at its job, at least for lossless images 32 | 33 | ## JPEGXL 34 | Really good at compression size, though it's not supported anywhere outside of safari as of now. 35 | ### cjxl 36 | this command should do the trick for compressing 37 | ```bash 38 | cjxl input.png output.jxl -q 100 -e 10 39 | ``` 40 | 41 | # Vector 42 | 43 | ## SVGs: 44 | ### https://svgomg.net/ 45 | great tool, if anyone knows how to squish them further, let me know, some manual work may go a long way to help shrink svgs, though I'm not doing that right now lol. 46 | 47 | I may look into other formats soon as well, though these are the main two I'm currently using 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-bullseye AS builder 2 | 3 | WORKDIR /devel 4 | RUN apt-get update ; apt-get upgrade -y ; apt-get install -y build-essential 5 | COPY . . 6 | RUN npm i ; npm run build 7 | 8 | FROM node:18-alpine 9 | 10 | EXPOSE 8080 11 | WORKDIR /exec 12 | RUN apk add --update nodejs npm 13 | COPY --from=builder /devel/ . 14 | RUN adduser -D jankclient 15 | 16 | USER jankclient 17 | 18 | CMD ["npm", "start"] 19 | -------------------------------------------------------------------------------- /EmojiList/credit.txt: -------------------------------------------------------------------------------- 1 | https://github.com/muan/unicode-emoji-json/ 2 | the list is from here, though the actual file isn't included, if you want to compile the binary yourself, just put data-by-group.json in this file and run the needed script. 3 | -------------------------------------------------------------------------------- /InstanceInfo.md: -------------------------------------------------------------------------------- 1 | # How to add your instance to Jank Client 2 | inside of webpage you'll see a file called `instances.json` in that file you'll need to add your instance and its information in the following format if you want your instance to be a part of the drop down. 3 | ``` 4 | { 5 | "name":, 6 | "description"?:, 7 | "descriptionLong"?:, 8 | "image"?:, 9 | "url"?:, 10 | "language":, 11 | "country":, 12 | "display":, 13 | "urls"?:{ 14 | "wellknown":, 15 | "api":, 16 | "cdn":, 17 | "gateway":, 18 | "login"?: 19 | }, 20 | "contactInfo"?:{ 21 | "discord"?:, 22 | "github"?:, 23 | "email"?:, 24 | "spacebar":?:, 25 | "matrix"?:, 26 | "mastodon"?: 27 | } 28 | } 29 | ``` 30 | anything with a `?` in-front of its `:` are optional, though you must either include `"URL"` or `"URLs"`, but you may include both, though the client will most likely ignore `"URLs"` in favor of `"URL"`, though it may use `"URLs"` as a fallback if `"URL"` does not resolve, do not rely on this behavior. 31 | wellknown should be a url that can resolve the wellknown, but it should only be the base URL and not the full wellknown url. 32 | Some of these values may not be used right now, though they will likely be used in the future, so feel free to fill out what you like, though the more you fill out the more information we can give the users about your instance in the future. 33 | language should be [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1_codes). 34 | Country should be [ISO 8166-2 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2). 35 | You can also add yourself to [this](https://github.com/spacebarchat/spacebarchat/tree/master/instances) list, and you should, though there are some disadvantages to only being in that list 36 | # Questions 37 | ## Do I have to do this to let Jank Client connect to my server? 38 | No, you may choose to not do this, this just makes it easier for people using Jank Client to find and use your instance as it's in the dropdown menu for instances, though the user may enter any instance they please. 39 | ## If my instance isn't spacebar is that allowed to be entered? 40 | If it's spacebar compatable, yes it may be entered, though if there are too many incompatablities, it may not be included, or may need a warning of sorts. 41 | ## I'm hosting my own instance of spacebar and would like to change the defualt instance on my instance of Jank Client to my own instance. 42 | Just change the first entry in the list to your own, and it should connect without issue. 43 | ## Why would I put my instance in this list over the official spacebar list? 44 | While putting your instance in the other list will get it to show up on jank client, this list does have more settings, and will show up earlier in the results, though either list will work to get in the dropdown menu 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jank Client 2 | Jank Client is a [Spacebar](https://spacebar.chat) Client written in TS, HTML, and CSS. 3 | 4 | To build it, clone the repo and run `npm install`, then `npm run build` 5 | To run it, use `npm start` 6 | or do the equivalent with bun 7 | 8 | Both [Bun](https://bun.sh) and [Node.js](https://nodejs.org) are supported, and should function as expected. 9 | 10 | To access Jank Client after starting, simply go to http://localhost:8080/login and either register a new account, or log in with your email and password. 11 | 12 | If there are any issues please report them either here, or to me dirrectly on spacebar 13 | ## Adding instances to the dropdown 14 | Please see [this](https://github.com/MathMan05/JankClient/blob/main/InstanceInfo.md) for how to add an instance to the dropdown picker 15 | ## RoadMap 16 | You can view the current roadmap on https://github.com/users/MathMan05/projects/1. 17 | ## AI Code 18 | AI code due to not being GPLv3 compatable is not allowed in this repo. I thought this didn't need to be said, but it does. 19 | ## Link 20 | The official SpaceBar server for Jank Client https://jankclient.greysilly7.xyz/invite/USgYJo?instance=https%3A%2F%2Fspacebar.chat 21 | 22 | old invite for the official client https://dev.app.spacebar.chat/invite/USgYJo 23 | 24 | The current hosted instance of JankClient https://jankclient.greysilly7.xyz/ 25 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Currently, I only support the most up to date version of jank client, there are no stable releases, but this is planned for in the future 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | main | :white_check_mark: | 10 | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | If there's an issue please disclose it responsibly to me, or here on github privatly. 15 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from "fs"; 2 | import * as swc from "@swc/core"; 3 | import {fileURLToPath} from "node:url"; 4 | import path from "node:path"; 5 | import child_process from "child_process"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | async function moveFiles(curPath: string, newPath: string) { 10 | async function processFile(file: string) { 11 | const Prom: Promise[] = []; 12 | if ((await fs.stat(path.join(curPath, file))).isDirectory()) { 13 | await fs.mkdir(path.join(newPath, file)); 14 | Prom.push(moveFiles(path.join(curPath, file), path.join(newPath, file))); 15 | } else { 16 | if (!file.endsWith(".ts")) { 17 | await fs.copyFile(path.join(curPath, file), path.join(newPath, file)); 18 | } else { 19 | const plainname = file.split(".ts")[0]; 20 | const newfileDir = path.join(newPath, plainname); 21 | const mod = await swc.transformFile(path.join(curPath, file), { 22 | minify: true, 23 | sourceMaps: true, 24 | isModule: true, 25 | }); 26 | await Promise.all([ 27 | fs.writeFile( 28 | newfileDir + ".js", 29 | mod.code + "\n" + `//# sourceMappingURL= ${plainname}.js.map`, 30 | ), 31 | fs.writeFile(newfileDir + ".js.map", mod.map as string), 32 | ]); 33 | } 34 | } 35 | await Promise.all(Prom); 36 | } 37 | await Promise.all((await fs.readdir(curPath)).map(processFile)); 38 | } 39 | async function build() { 40 | console.time("build"); 41 | 42 | console.time("Cleaning dir"); 43 | try { 44 | await fs.rm(path.join(__dirname, "dist"), {recursive: true}); 45 | } catch {} 46 | await fs.mkdir(path.join(__dirname, "dist")); 47 | console.timeEnd("Cleaning dir"); 48 | 49 | console.time("Moving and compiling files"); 50 | await moveFiles(path.join(__dirname, "src"), path.join(__dirname, "dist")); 51 | console.timeEnd("Moving and compiling files"); 52 | 53 | console.time("Moving translations"); 54 | try { 55 | await fs.mkdir(path.join(__dirname, "dist", "webpage", "translations")); 56 | } catch {} 57 | let langs = await fs.readdir(path.join(__dirname, "translations")); 58 | langs = langs.filter((e) => e !== "qqq.json"); 59 | const langobj = {}; 60 | for (const lang of langs) { 61 | const str = await fs.readFile(path.join(__dirname, "translations", lang)); 62 | const json = JSON.parse(str.toString()); 63 | langobj[lang] = json.readableName; 64 | fs.writeFile(path.join(__dirname, "dist", "webpage", "translations", lang), str); 65 | } 66 | await fs.writeFile( 67 | path.join(__dirname, "dist", "webpage", "translations", "langs.js"), 68 | `const langs=${JSON.stringify(langobj)};export{langs}`, 69 | ); 70 | console.timeEnd("Moving translations"); 71 | 72 | console.time("Adding git commit hash"); 73 | const revision = child_process.execSync("git rev-parse HEAD").toString().trim(); 74 | await fs.writeFile(path.join(__dirname, "dist", "webpage", "getupdates"), revision); 75 | console.timeEnd("Adding git commit hash"); 76 | 77 | console.timeEnd("build"); 78 | console.log(""); 79 | } 80 | 81 | await build(); 82 | if (process.argv.includes("watch")) { 83 | let last = Date.now(); 84 | (async () => { 85 | for await (const thing of fs.watch(path.join(__dirname, "src"), {recursive: true})) { 86 | if (Date.now() - last < 100) { 87 | continue; 88 | } 89 | last = Date.now(); 90 | try { 91 | await build(); 92 | } catch {} 93 | } 94 | })(); 95 | (async () => { 96 | for await (const thing of fs.watch(path.join(__dirname, "translations"))) { 97 | if (Date.now() - last < 100) { 98 | continue; 99 | } 100 | last = Date.now(); 101 | try { 102 | await build(); 103 | } catch {} 104 | } 105 | })(); 106 | } 107 | -------------------------------------------------------------------------------- /buildnode.js: -------------------------------------------------------------------------------- 1 | import * as swc from "@swc/core"; 2 | import path from "node:path"; 3 | import {fileURLToPath} from "node:url"; 4 | import {promises as fs} from "fs"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | const mod = await swc.transformFile(path.join(__dirname, "build.ts"), { 10 | minify: true, 11 | sourceMaps: true, 12 | isModule: true, 13 | }); 14 | 15 | await fs.writeFile(path.join(__dirname, "build.js"), mod.code); 16 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | jank: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | restart: unless-stopped 7 | ports: 8 | - "8080:8080" 9 | -------------------------------------------------------------------------------- /emoji-packer.cjs: -------------------------------------------------------------------------------- 1 | const emojilist=require("./EmojiList/data-by-group.json"); 2 | console.log(emojilist); 3 | 4 | const buffer=new ArrayBuffer(2**26); 5 | const view = new DataView(buffer, 0); 6 | let i=0; 7 | function write16(numb){ 8 | view.setUint16(i,numb); 9 | i+=2; 10 | } 11 | function write8(numb){ 12 | view.setUint8(i,numb); 13 | i+=1; 14 | } 15 | function writeString8(str){ 16 | const encode=new TextEncoder("utf-8").encode(str); 17 | write8(encode.length); 18 | for(const thing of encode){ 19 | write8(thing); 20 | } 21 | } 22 | function writeString16(str){ 23 | const encode=new TextEncoder("utf-8").encode(str); 24 | write16(encode.length); 25 | for(const thing of encode){ 26 | write8(thing); 27 | } 28 | } 29 | function writeStringNo(str){ 30 | const encode=new TextEncoder("utf-8").encode(str); 31 | for(const thing of encode){ 32 | write8(thing); 33 | } 34 | } 35 | 36 | write16(emojilist.length); 37 | for(const thing of emojilist){ 38 | writeString16(thing.name); 39 | write16(thing.emojis.length); 40 | for(const emoji of thing.emojis){ 41 | writeString8(emoji.name); 42 | write8(new TextEncoder("utf-8").encode(emoji.emoji).length+128*emoji.skin_tone_support); 43 | writeStringNo(emoji.emoji); 44 | } 45 | } 46 | const out=new ArrayBuffer(i); 47 | const ar=new Uint8Array(out); 48 | const br=new Uint8Array(buffer); 49 | for(const thing in ar){ 50 | ar[thing]=br[thing]; 51 | } 52 | console.log(i,ar); 53 | 54 | function decodeEmojiList(buffer){ 55 | const view = new DataView(buffer, 0); 56 | let i=0; 57 | function read16(){ 58 | const int=view.getUint16(i); 59 | i+=2; 60 | return int; 61 | } 62 | function read8(){ 63 | const int=view.getUint8(i); 64 | i+=1; 65 | return int; 66 | } 67 | function readString8(){ 68 | return readStringNo(read8()); 69 | } 70 | function readString16(){ 71 | return readStringNo(read16()); 72 | } 73 | function readStringNo(length){ 74 | const array=new Uint8Array(length); 75 | 76 | for(let i=0;i127; 94 | const emoji=readStringNo(len-skin_tone_support*128); 95 | emojis.push({ 96 | name, 97 | skin_tone_support, 98 | emoji 99 | }); 100 | } 101 | build.push({ 102 | name, 103 | emojis 104 | }); 105 | } 106 | return build; 107 | } 108 | console.log(JSON.stringify(decodeEmojiList(out))); 109 | 110 | const fs = require("node:fs"); 111 | fs.writeFile("./webpage/emoji.bin",new Uint8Array(out),_=>{ 112 | 113 | }); 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jankclient", 3 | "version": "0.2.0", 4 | "description": "A SpaceBar Client written in TS HTML and CSS to run, clone the repo and do either `npm start` or `bun start` both bun and node are supported, and both should function as expected. To access Jank Client after init simply go to http://localhost:8080/login and login with your username and password.", 5 | "main": ".dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "bunBuild": "bun build.ts", 9 | "build": "node buildnode.js && node build.js", 10 | "start": "node dist/index.js" 11 | }, 12 | "author": "MathMan05", 13 | "license": "GPL-3.0", 14 | "dependencies": { 15 | "compression": "^1.8.0", 16 | "@swc/core": "1.11.24", 17 | "express": "^4.21.2" 18 | }, 19 | "devDependencies": { 20 | "prettier": "^3.5.3", 21 | "@types/compression": "^1.7.5", 22 | "@types/express": "^4.17.21", 23 | "@types/node-fetch": "^2.6.12", 24 | "typescript": "^5.8.3" 25 | }, 26 | "prettier": { 27 | "useTabs": true, 28 | "printWidth": 100, 29 | "semi": true, 30 | "bracketSpacing": false 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import compression from "compression"; 2 | import express, {Request, Response} from "express"; 3 | import fs from "node:fs/promises"; 4 | import path from "node:path"; 5 | import {observe, uptime} from "./stats.js"; 6 | import {getApiUrls, inviteResponse} from "./utils.js"; 7 | import {fileURLToPath} from "node:url"; 8 | import {readFileSync} from "fs"; 9 | import process from "node:process"; 10 | 11 | const devmode = (process.env.NODE_ENV || "development") === "development"; 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | type dirtype = Map; 15 | async function getDirectories(path: string): Promise { 16 | return new Map( 17 | await Promise.all( 18 | (await fs.readdir(path)).map(async function (file): Promise<[string, string | dirtype]> { 19 | if ((await fs.stat(path + "/" + file)).isDirectory()) { 20 | return [file, await getDirectories(path + "/" + file)]; 21 | } else { 22 | return [file, file]; 23 | } 24 | }), 25 | ), 26 | ); 27 | } 28 | 29 | let dirs: dirtype | undefined = undefined; 30 | async function combinePath(path: string, tryAgain = true): Promise { 31 | if (!dirs) { 32 | dirs = await getDirectories(__dirname); 33 | } 34 | const pathDir = path 35 | .split("/") 36 | .reverse() 37 | .filter((_) => _ !== ""); 38 | function find(arr: string[], search: dirtype | string | undefined): boolean { 39 | if (search == undefined) return false; 40 | if (arr.length === 0) { 41 | return typeof search == "string"; 42 | } 43 | if (typeof search == "string") { 44 | return false; 45 | } 46 | const thing = arr.pop() as string; 47 | return find(arr, search.get(thing)); 48 | } 49 | if (find(pathDir, dirs)) { 50 | return __dirname + path; 51 | } else { 52 | if (!path.includes(".")) { 53 | const str = await combinePath(path + ".html", false); 54 | if (str !== __dirname + "/webpage/app.html") { 55 | return str; 56 | } 57 | } 58 | if (devmode && tryAgain) { 59 | dirs = await getDirectories(__dirname); 60 | return combinePath(path, false); 61 | } 62 | return __dirname + "/webpage/app.html"; 63 | } 64 | } 65 | interface Instance { 66 | name: string; 67 | [key: string]: any; 68 | } 69 | 70 | const app = express(); 71 | 72 | export type instace = { 73 | name: string; 74 | description?: string; 75 | descriptionLong?: string; 76 | image?: string; 77 | url?: string; 78 | language: string; 79 | country: string; 80 | display: boolean; 81 | urls?: { 82 | wellknown: string; 83 | api: string; 84 | cdn: string; 85 | gateway: string; 86 | login?: string; 87 | }; 88 | contactInfo?: { 89 | discord?: string; 90 | github?: string; 91 | email?: string; 92 | spacebar?: string; 93 | matrix?: string; 94 | mastodon?: string; 95 | }; 96 | }; 97 | const instances = JSON.parse( 98 | readFileSync(process.env.JANK_INSTANCES_PATH || __dirname + "/webpage/instances.json").toString(), 99 | ) as instace[]; 100 | 101 | const instanceNames = new Map(); 102 | 103 | for (const instance of instances) { 104 | instanceNames.set(instance.name, instance); 105 | } 106 | 107 | app.use(compression()); 108 | 109 | async function updateInstances(): Promise { 110 | try { 111 | const response = await fetch( 112 | "https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/instances/instances.json", 113 | ); 114 | const json = (await response.json()) as Instance[]; 115 | for (const instance of json) { 116 | if (instanceNames.has(instance.name)) { 117 | const existingInstance = instanceNames.get(instance.name); 118 | if (existingInstance) { 119 | for (const key of Object.keys(instance)) { 120 | if (!(key in existingInstance)) { 121 | existingInstance[key] = instance[key]; 122 | } 123 | } 124 | } 125 | } else { 126 | instances.push(instance as any); 127 | } 128 | } 129 | observe(instances); 130 | } catch (error) { 131 | console.error("Error updating instances:", error); 132 | } 133 | } 134 | 135 | updateInstances(); 136 | 137 | app.use("/services/oembed", (req: Request, res: Response) => { 138 | inviteResponse(req, res, instances); 139 | }); 140 | 141 | app.use("/uptime", (req: Request, res: Response) => { 142 | const instanceUptime = uptime.get(req.query.name as string); 143 | res.send(instanceUptime); 144 | }); 145 | 146 | app.use("/", async (req: Request, res: Response) => { 147 | const scheme = req.secure ? "https" : "http"; 148 | const host = `${scheme}://${req.get("Host")}`; 149 | let ref = host + req.originalUrl; 150 | if (Object.keys(req.query).length !== 0) { 151 | const parms = new URLSearchParams(); 152 | for (const key of Object.keys(req.query)) { 153 | parms.set(key, req.query[key] as string); 154 | } 155 | ref + `?${parms}`; 156 | } 157 | if (host && ref) { 158 | const link = `${host}/services/oembed?url=${encodeURIComponent(ref)}`; 159 | res.set( 160 | "Link", 161 | `<${link}>; rel="alternate"; type="application/json+oembed"; title="Jank Client oEmbed format"`, 162 | ); 163 | } 164 | 165 | if (req.path === "/") { 166 | res.sendFile(path.join(__dirname, "webpage", "home.html")); 167 | return; 168 | } 169 | 170 | if (req.path.startsWith("/instances.json")) { 171 | res.json(instances); 172 | return; 173 | } 174 | 175 | if (req.path.startsWith("/invite/")) { 176 | res.sendFile(path.join(__dirname, "webpage", "invite.html")); 177 | return; 178 | } 179 | if (req.path.startsWith("/template/")) { 180 | res.sendFile(path.join(__dirname, "webpage", "template.html")); 181 | return; 182 | } 183 | if (req.path === "index.html") { 184 | res.sendFile(path.join(__dirname, "webpage", "app.html")); 185 | return; 186 | } 187 | const filePath = await combinePath("/webpage/" + req.path); 188 | res.sendFile(filePath); 189 | }); 190 | 191 | app.set("trust proxy", (ip: string) => ip.startsWith("127.")); 192 | 193 | const PORT = process.env.PORT || Number(process.argv[2]) || 8080; 194 | app.listen(PORT, () => { 195 | console.log(`Server running on port ${PORT}`); 196 | }); 197 | 198 | export {getApiUrls}; 199 | -------------------------------------------------------------------------------- /src/stats.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import {getApiUrls} from "./utils.js"; 4 | import {fileURLToPath} from "node:url"; 5 | import {setTimeout, clearTimeout} from "node:timers"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | interface UptimeEntry { 11 | time: number; 12 | online: boolean; 13 | } 14 | 15 | interface Instance { 16 | name: string; 17 | urls?: {api: string}; 18 | url?: string; 19 | online?: boolean; 20 | uptime?: { 21 | daytime: number; 22 | weektime: number; 23 | alltime: number; 24 | }; 25 | } 26 | 27 | const uptimeObject: Map = loadUptimeObject(); 28 | export {uptimeObject as uptime}; 29 | 30 | function loadUptimeObject(): Map { 31 | const filePath = process.env.JANK_UPTIME_JSON_PATH || path.join(__dirname, "..", "uptime.json"); 32 | if (fs.existsSync(filePath)) { 33 | try { 34 | const data = JSON.parse(fs.readFileSync(filePath, "utf8")); 35 | return new Map(Object.entries(data)); 36 | } catch (error) { 37 | console.error("Error reading uptime.json:", error); 38 | return new Map(); 39 | } 40 | } 41 | return new Map(); 42 | } 43 | 44 | let saveTimeout: ReturnType | null = null; 45 | 46 | function saveUptimeObject(): void { 47 | if (saveTimeout) { 48 | clearTimeout(saveTimeout); 49 | } 50 | saveTimeout = setTimeout(() => { 51 | const data = Object.fromEntries(uptimeObject); 52 | fs.writeFile( 53 | process.env.JANK_UPTIME_JSON_PATH || path.join(__dirname, "..", "uptime.json"), 54 | JSON.stringify(data), 55 | (error) => { 56 | if (error) { 57 | console.error("Error saving uptime.json:", error); 58 | } 59 | }, 60 | ); 61 | }, 5000); // Batch updates every 5 seconds 62 | } 63 | 64 | function removeUndefinedKey(): void { 65 | if (uptimeObject.has("undefined")) { 66 | uptimeObject.delete("undefined"); 67 | saveUptimeObject(); 68 | } 69 | } 70 | 71 | removeUndefinedKey(); 72 | 73 | export async function observe(instances: Instance[]): Promise { 74 | const activeInstances = new Set(); 75 | const instancePromises = instances.map((instance) => resolveInstance(instance, activeInstances)); 76 | await Promise.allSettled(instancePromises); 77 | updateInactiveInstances(activeInstances); 78 | } 79 | 80 | async function resolveInstance(instance: Instance, activeInstances: Set): Promise { 81 | try { 82 | calcStats(instance); 83 | const api = await getApiUrl(instance); 84 | if (!api) { 85 | handleUnresolvedApi(instance); 86 | return; 87 | } 88 | activeInstances.add(instance.name); 89 | await checkHealth(instance, api); 90 | scheduleHealthCheck(instance, api); 91 | } catch (error) { 92 | console.error("Error resolving instance:", error); 93 | } 94 | } 95 | 96 | async function getApiUrl(instance: Instance): Promise { 97 | if (instance.urls) { 98 | return instance.urls.api; 99 | } 100 | if (instance.url) { 101 | const urls = await getApiUrls(instance.url, [], false); 102 | return urls ? urls.api : null; 103 | } 104 | return null; 105 | } 106 | 107 | function handleUnresolvedApi(instance: Instance): void { 108 | setStatus(instance, false); 109 | console.warn(`${instance.name} does not resolve api URL`, instance); 110 | setTimeout(() => resolveInstance(instance, new Set()), 1000 * 60 * 30); 111 | } 112 | 113 | function scheduleHealthCheck(instance: Instance, api: string): void { 114 | const checkInterval = 1000 * 60 * 30; 115 | const initialDelay = Math.random() * 1000 * 60 * 10; 116 | setTimeout(() => { 117 | checkHealth(instance, api); 118 | setInterval(() => checkHealth(instance, api), checkInterval); 119 | }, initialDelay); 120 | } 121 | 122 | async function checkHealth(instance: Instance, api: string, tries = 0): Promise { 123 | try { 124 | const response = await fetch(`${api}/ping`, {method: "HEAD"}); 125 | console.log(`Checking health for ${instance.name}: ${response.status}`); 126 | if (response.ok || tries > 3) { 127 | setStatus(instance, response.ok); 128 | } else { 129 | retryHealthCheck(instance, api, tries); 130 | } 131 | } catch (error) { 132 | console.error(`Error checking health for ${instance.name}:`, error); 133 | if (tries > 3) { 134 | setStatus(instance, false); 135 | } else { 136 | retryHealthCheck(instance, api, tries); 137 | } 138 | } 139 | } 140 | 141 | function retryHealthCheck(instance: Instance, api: string, tries: number): void { 142 | setTimeout(() => checkHealth(instance, api, tries + 1), 30000); 143 | } 144 | 145 | function updateInactiveInstances(activeInstances: Set): void { 146 | for (const key of uptimeObject.keys()) { 147 | if (!activeInstances.has(key)) { 148 | setStatus(key, false); 149 | } 150 | } 151 | } 152 | 153 | function calcStats(instance: Instance): void { 154 | const obj = uptimeObject.get(instance.name); 155 | if (!obj) return; 156 | 157 | const now = Date.now(); 158 | const day = now - 1000 * 60 * 60 * 24; 159 | const week = now - 1000 * 60 * 60 * 24 * 7; 160 | 161 | let totalTimePassed = 0; 162 | let alltime = 0; 163 | let daytime = 0; 164 | let weektime = 0; 165 | let online = false; 166 | 167 | for (let i = 0; i < obj.length; i++) { 168 | const entry = obj[i]; 169 | online = entry.online; 170 | const stamp = entry.time; 171 | const nextStamp = obj[i + 1]?.time || now; 172 | const timePassed = nextStamp - stamp; 173 | 174 | totalTimePassed += timePassed; 175 | alltime += Number(online) * timePassed; 176 | 177 | if (stamp + timePassed > week) { 178 | const weekTimePassed = Math.min(timePassed, nextStamp - week); 179 | weektime += Number(online) * weekTimePassed; 180 | 181 | if (stamp + timePassed > day) { 182 | const dayTimePassed = Math.min(weekTimePassed, nextStamp - day); 183 | daytime += Number(online) * dayTimePassed; 184 | } 185 | } 186 | } 187 | 188 | instance.online = online; 189 | instance.uptime = calculateUptimeStats(totalTimePassed, alltime, daytime, weektime, online); 190 | } 191 | 192 | function calculateUptimeStats( 193 | totalTimePassed: number, 194 | alltime: number, 195 | daytime: number, 196 | weektime: number, 197 | online: boolean, 198 | ): {daytime: number; weektime: number; alltime: number} { 199 | const dayInMs = 1000 * 60 * 60 * 24; 200 | const weekInMs = dayInMs * 7; 201 | 202 | alltime /= totalTimePassed; 203 | 204 | if (totalTimePassed > dayInMs) { 205 | daytime = daytime || (online ? dayInMs : 0); 206 | daytime /= dayInMs; 207 | 208 | if (totalTimePassed > weekInMs) { 209 | weektime = weektime || (online ? weekInMs : 0); 210 | weektime /= weekInMs; 211 | } else { 212 | weektime = alltime; 213 | } 214 | } else { 215 | weektime = alltime; 216 | daytime = alltime; 217 | } 218 | 219 | return {daytime, weektime, alltime}; 220 | } 221 | 222 | function setStatus(instance: string | Instance, status: boolean): void { 223 | const name = typeof instance === "string" ? instance : instance.name; 224 | let obj = uptimeObject.get(name); 225 | 226 | if (!obj) { 227 | obj = []; 228 | uptimeObject.set(name, obj); 229 | } 230 | 231 | const lastEntry = obj.at(-1); 232 | if (!lastEntry || lastEntry.online !== status) { 233 | obj.push({time: Date.now(), online: status}); 234 | saveUptimeObject(); 235 | 236 | if (typeof instance !== "string") { 237 | calcStats(instance); 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {Request, Response} from "express"; 2 | import {instace} from "./index.js"; 3 | interface ApiUrls { 4 | api: string; 5 | gateway: string; 6 | cdn: string; 7 | wellknown: string; 8 | } 9 | 10 | interface Invite { 11 | guild: { 12 | name: string; 13 | description?: string; 14 | icon?: string; 15 | id: string; 16 | }; 17 | inviter?: { 18 | username: string; 19 | }; 20 | } 21 | 22 | export async function getApiUrls( 23 | url: string, 24 | instances: instace[], 25 | check = true, 26 | ): Promise { 27 | if (!url.endsWith("/")) { 28 | url += "/"; 29 | } 30 | if (check) { 31 | let valid = false; 32 | for (const instace of instances) { 33 | const urlstr = instace.url || instace.urls?.api; 34 | if (!urlstr) { 35 | continue; 36 | } 37 | try { 38 | if (new URL(urlstr).host === new URL(url).host) { 39 | valid = true; 40 | break; 41 | } 42 | } catch (e) { 43 | //console.log(e); 44 | } 45 | } 46 | if (!valid) { 47 | throw new Error("Invalid instance"); 48 | } 49 | } 50 | try { 51 | const info: ApiUrls = await fetch(`${url}.well-known/spacebar`).then((res) => res.json()); 52 | const api = info.api; 53 | const apiUrl = new URL(api); 54 | const policies: any = await fetch( 55 | `${api}${apiUrl.pathname.includes("api") ? "" : "api"}/policies/instance/domains`, 56 | ).then((res) => res.json()); 57 | return { 58 | api: policies.apiEndpoint, 59 | gateway: policies.gateway, 60 | cdn: policies.cdn, 61 | wellknown: url, 62 | }; 63 | } catch (error) { 64 | console.error("Error fetching API URLs:", error); 65 | return null; 66 | } 67 | } 68 | 69 | export async function inviteResponse( 70 | req: Request, 71 | res: Response, 72 | instances: instace[], 73 | ): Promise { 74 | let url: URL; 75 | try { 76 | url = new URL(req.query.url as string); 77 | } catch { 78 | const scheme = req.secure ? "https" : "http"; 79 | const host = `${scheme}://${req.get("Host")}`; 80 | url = new URL(host); 81 | } 82 | 83 | try { 84 | if (!url.pathname.startsWith("invite") && !url.pathname.startsWith("/invite")) { 85 | throw new Error("Invalid invite URL"); 86 | } 87 | 88 | const code = url.pathname.split("/")[2]; 89 | const instance = url.searchParams.get("instance"); 90 | if (!instance) { 91 | throw new Error("Instance not specified"); 92 | } 93 | 94 | const urls = await getApiUrls(instance, instances); 95 | if (!urls) { 96 | throw new Error("Failed to get API URLs"); 97 | } 98 | 99 | const invite = await fetch(`${urls.api}/invites/${code}`).then( 100 | (json) => json.json() as Promise, 101 | ); 102 | const title = invite.guild.name; 103 | const description = invite.inviter 104 | ? `${invite.inviter.username} has invited you to ${invite.guild.name}${invite.guild.description ? `\n${invite.guild.description}` : ""}` 105 | : `You've been invited to ${invite.guild.name}${invite.guild.description ? `\n${invite.guild.description}` : ""}`; 106 | const thumbnail = invite.guild.icon 107 | ? `${urls.cdn}/icons/${invite.guild.id}/${invite.guild.icon}.png` 108 | : ""; 109 | 110 | res.json({ 111 | type: "link", 112 | version: "1.0", 113 | title, 114 | thumbnail, 115 | description, 116 | }); 117 | } catch (error) { 118 | //console.error("Error processing invite response:", error); 119 | res.json({ 120 | type: "link", 121 | version: "1.0", 122 | title: "Jank Client", 123 | thumbnail: "/logo.webp", 124 | description: "A spacebar client that has DMs, replying and more", 125 | url: url.toString(), 126 | }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/webpage/Commissioner-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MathMan05/JankClient/ec57d5304cd6f28d83c3626e42afd7f27d459430/src/webpage/Commissioner-Regular.woff2 -------------------------------------------------------------------------------- /src/webpage/Dbadges.ts: -------------------------------------------------------------------------------- 1 | //For those wondering what in the world this is, this is the badge data for all of the badges public_flags represents in a seperate file so it's not as much of a mess 2 | const badgeArr = [ 3 | [ 4 | "staff", 5 | { 6 | id: "staff", 7 | description: "staff", 8 | translate: true, 9 | icon: "5e74e9b61934fc1f67c65515d1f7e60d", 10 | }, 11 | ], 12 | [ 13 | "partner", 14 | { 15 | id: "partner", 16 | description: "partner", 17 | translate: true, 18 | icon: "3f9748e53446a137a052f3454e2de41e", 19 | }, 20 | ], 21 | [ 22 | "certified_moderator", 23 | { 24 | id: "certified_moderator", 25 | description: "certified_moderator", 26 | translate: true, 27 | icon: "fee1624003e2fee35cb398e125dc479b", 28 | }, 29 | ], 30 | [ 31 | "hypesquad", 32 | { 33 | id: "hypesquad", 34 | description: "hypesquad", 35 | translate: true, 36 | icon: "bf01d1073931f921909045f3a39fd264", 37 | }, 38 | ], 39 | [ 40 | "hypesquad_house_1", 41 | { 42 | id: "hypesquad_house_1", 43 | description: "hypesquad_house_1", 44 | translate: true, 45 | icon: "8a88d63823d8a71cd5e390baa45efa02", 46 | }, 47 | ], 48 | [ 49 | "hypesquad_house_2", 50 | { 51 | id: "hypesquad_house_2", 52 | description: "hypesquad_house_2", 53 | translate: true, 54 | icon: "011940fd013da3f7fb926e4a1cd2e618", 55 | }, 56 | ], 57 | [ 58 | "hypesquad_house_3", 59 | { 60 | id: "hypesquad_house_3", 61 | description: "hypesquad_house_3", 62 | translate: true, 63 | icon: "3aa41de486fa12454c3761e8e223442e", 64 | }, 65 | ], 66 | [ 67 | "bug_hunter_level_1", 68 | { 69 | id: "bug_hunter_level_1", 70 | description: "bug_hunter_level_1", 71 | translate: true, 72 | icon: "2717692c7dca7289b35297368a940dd0", 73 | }, 74 | ], 75 | [ 76 | "bug_hunter_level_2", 77 | { 78 | id: "bug_hunter_level_2", 79 | description: "bug_hunter_level_2", 80 | translate: true, 81 | icon: "848f79194d4be5ff5f81505cbd0ce1e6", 82 | }, 83 | ], 84 | [ 85 | "active_developer", 86 | { 87 | id: "active_developer", 88 | description: "active_developer", 89 | translate: true, 90 | icon: "6bdc42827a38498929a4920da12695d9", 91 | }, 92 | ], 93 | [ 94 | "verified_developer", 95 | { 96 | id: "verified_developer", 97 | description: "verified_developer", 98 | translate: true, 99 | icon: "6df5892e0f35b051f8b61eace34f4967", 100 | }, 101 | ], 102 | [ 103 | "early_supporter", 104 | { 105 | id: "early_supporter", 106 | description: "early_supporter", 107 | translate: true, 108 | icon: "7060786766c9c840eb3019e725d2b358", 109 | }, 110 | ], 111 | [ 112 | "premium", 113 | { 114 | id: "premium", 115 | description: "premium", 116 | translate: true, 117 | icon: "2ba85e8026a8614b640c2837bcdfe21b", 118 | }, 119 | ], 120 | [ 121 | "guild_booster_lvl1", 122 | { 123 | id: "guild_booster_lvl1", 124 | description: "guild_booster_lvl1", 125 | translate: true, 126 | icon: "51040c70d4f20a921ad6674ff86fc95c", 127 | }, 128 | ], 129 | [ 130 | "guild_booster_lvl2", 131 | { 132 | id: "guild_booster_lvl2", 133 | description: "guild_booster_lvl2", 134 | translate: true, 135 | icon: "0e4080d1d333bc7ad29ef6528b6f2fb7", 136 | }, 137 | ], 138 | [ 139 | "guild_booster_lvl3", 140 | { 141 | id: "guild_booster_lvl3", 142 | description: "guild_booster_lvl3", 143 | translate: true, 144 | icon: "72bed924410c304dbe3d00a6e593ff59", 145 | }, 146 | ], 147 | [ 148 | "guild_booster_lvl4", 149 | { 150 | id: "guild_booster_lvl4", 151 | description: "guild_booster_lvl4", 152 | translate: true, 153 | icon: "df199d2050d3ed4ebf84d64ae83989f8", 154 | }, 155 | ], 156 | [ 157 | "guild_booster_lvl5", 158 | { 159 | id: "guild_booster_lvl5", 160 | description: "guild_booster_lvl5", 161 | translate: true, 162 | icon: "996b3e870e8a22ce519b3a50e6bdd52f", 163 | }, 164 | ], 165 | [ 166 | "guild_booster_lvl6", 167 | { 168 | id: "guild_booster_lvl6", 169 | description: "guild_booster_lvl6", 170 | translate: true, 171 | icon: "991c9f39ee33d7537d9f408c3e53141e", 172 | }, 173 | ], 174 | [ 175 | "guild_booster_lvl7", 176 | { 177 | id: "guild_booster_lvl7", 178 | description: "guild_booster_lvl7", 179 | translate: true, 180 | icon: "cb3ae83c15e970e8f3d410bc62cb8b99", 181 | }, 182 | ], 183 | [ 184 | "guild_booster_lvl8", 185 | { 186 | id: "guild_booster_lvl8", 187 | description: "guild_booster_lvl8", 188 | translate: true, 189 | icon: "7142225d31238f6387d9f09efaa02759", 190 | }, 191 | ], 192 | [ 193 | "guild_booster_lvl9", 194 | { 195 | id: "guild_booster_lvl9", 196 | description: "guild_booster_lvl9", 197 | translate: true, 198 | icon: "ec92202290b48d0879b7413d2dde3bab", 199 | }, 200 | ], 201 | [ 202 | "bot_commands", 203 | { 204 | id: "bot_commands", 205 | description: "bot_commands", 206 | translate: true, 207 | icon: "6f9e37f9029ff57aef81db857890005e", 208 | }, 209 | ], 210 | [ 211 | "automod", 212 | { 213 | id: "automod", 214 | description: "automod", 215 | translate: true, 216 | icon: "f2459b691ac7453ed6039bbcfaccbfcd", 217 | }, 218 | ], 219 | [ 220 | "application_guild_subscription", 221 | { 222 | id: "application_guild_subscription", 223 | description: "application_guild_subscription", 224 | translate: true, 225 | icon: "d2010c413a8da2208b7e4f35bd8cd4ac", 226 | }, 227 | ], 228 | [ 229 | "legacy_username", 230 | { 231 | id: "legacy_username", 232 | description: "legacy_username", 233 | translate: true, 234 | icon: "6de6d34650760ba5551a79732e98ed60", 235 | }, 236 | ], 237 | [ 238 | "quest_completed", 239 | { 240 | id: "quest_completed", 241 | description: "quest_completed", 242 | translate: true, 243 | icon: "7d9ae358c8c5e118768335dbe68b4fb8", 244 | }, 245 | ], 246 | ]; 247 | export {badgeArr}; 248 | -------------------------------------------------------------------------------- /src/webpage/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Jank Client 10 | 11 | 12 | 13 | 14 | 15 | 16 | 28 | 29 | 30 | 31 | 32 |
33 |
34 | 35 |

Jank Client is loading

36 |

This shouldn't take long

37 |

Switch Accounts

38 |
39 |
40 |
41 |
42 |
43 |
44 |

Server Name

45 |
46 |
47 |
48 |
49 | 50 |
51 |
52 |
53 | 54 | 55 |
56 |

USERNAME

57 |

STATUS

58 |
59 |
60 | 61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | 73 | 74 | 75 | Channel name 76 | 77 | 78 |
79 |
80 | 81 |
82 |
83 | 84 | 85 |
86 | 89 | 90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | 107 |
108 | 109 | 110 | 111 |
112 |
113 | 121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /src/webpage/audio/audio.md: -------------------------------------------------------------------------------- 1 | # Jank Audio format 2 | This is a markdown file that will try to describe the jank client audio format in sufficient detail so people will know how this weird custom format works into the future. 3 | This is a byte-aligned format, which uses the sequence jasf in asci as a magic number at the start. 4 | 5 | the next 8 bits will decide how many voices this file has/will provide, if the value is 255 you'll instead have a 16 bit number that follows for how many voices there are, this *should* be unused, but I wouldn't be totally surprised if it did get used. 6 | 7 | then it'll parse for that many voices, which will be formatted like the following: 8 | name:String8; 9 | length:f32; **if this is 0, this is not an custom sound and is instead refering to something else which will be explained later™** 10 | 11 | Given a non-zero length, this will parse the sounds as following: 12 | |instruction | description | 13 | | ---------- | ----------- | 14 | | 000 | read float32 and use as value | 15 | | 001 | read time(it'll be the time in seconds) | 16 | | 002 | read frequency in hz | 17 | | 003 | the constant PI | 18 | | 004 | Math.sin() on the following sequence | 19 | | 005 | multiplies the next two expressions | 20 | | 006 | adds the next two expressions | 21 | | 007 | divides the first expression by the second | 22 | | 008 | subtracts the second expression by the first | 23 | | 009 | first expression to the power of the second | 24 | | 010 | first expression to the modulo of the second | 25 | | 011 | absolute power of the next expression | 26 | | 012 | round the next expression | 27 | | 013 | Math.cos() on the next expression | 28 | > note: 29 | > 30 | > this is likely to expand in the future as more things are needed, but this is just how it is right now. 31 | 32 | Once you've read all of the sounds in the file, you can move on to parsing the tracks. 33 | This starts out by reading a u16 to find out how many tracks there are, then you'll go on to try and parse that many. 34 | 35 | each track will then read a u16 to find out how long it is, then it'll read bytes as the following. 36 | it'll first read the index(which is either a u8 or u16 depending on if the amount of voices was u8 or u16), which is the index of the voice 1-indexed, then if it's not 0 it'll parse two float32s in this order, the volume then the pitch of the sound, if it was 0 it'll instead read one 32f as a delay in the track. if it's a default sound it'll also read a third 32f for length 37 | 38 | then finally you'll parse the audios which are the complete tracks. you'll first parse a u16 to get how many audios there are, then for each audio you'll first parse a string8 for the name, then a u16 for the length then according to the length you'll go on to parse a u16 to get the track (1-indexed again) where if it's 0 you'll instead add a delay according to the next f32, how many ever times according to the length. 39 | -------------------------------------------------------------------------------- /src/webpage/audio/audio.ts: -------------------------------------------------------------------------------- 1 | import {BinRead} from "../utils/binaryUtils.js"; 2 | import {Track} from "./track.js"; 3 | 4 | export class Audio { 5 | name: string; 6 | tracks: (Track | number)[]; 7 | constructor(name: string, tracks: (Track | number)[]) { 8 | this.tracks = tracks; 9 | this.name = name; 10 | } 11 | static parse(read: BinRead, trackarr: Track[]): Audio { 12 | const name = read.readString8(); 13 | const length = read.read16(); 14 | const tracks: (Track | number)[] = []; 15 | for (let i = 0; i < length; i++) { 16 | let index = read.read16(); 17 | if (index === 0) { 18 | tracks.push(read.readFloat32()); 19 | } else { 20 | tracks.push(trackarr[index - 1]); 21 | } 22 | } 23 | return new Audio(name, tracks); 24 | } 25 | async play() { 26 | for (const thing of this.tracks) { 27 | if (thing instanceof Track) { 28 | thing.play(); 29 | } else { 30 | await new Promise((res) => setTimeout(res, thing)); 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/webpage/audio/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jank Audio 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | 26 |

This will eventually be something

27 |

28 | I want to let the sound system of jank not be so hard coded, but I still need to work on 29 | everything a bit before that can happen. Thanks for your patience. 30 |

31 |

why does this tool need to exist?

32 |

33 | For size reasons jank does not use normal sound files, so I need to make this whole format to 34 | be more adaptable 35 |

36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/webpage/audio/page.ts: -------------------------------------------------------------------------------- 1 | import {BinWrite} from "../utils/binaryUtils.js"; 2 | import {setTheme} from "../utils/utils.js"; 3 | import {Play} from "./play.js"; 4 | 5 | setTheme(); 6 | const w = new BinWrite(2 ** 12); 7 | w.writeStringNo("jasf"); 8 | w.write8(4); 9 | 10 | w.writeString8("sin"); 11 | w.write32Float(0); 12 | w.writeString8("triangle"); 13 | w.write32Float(0); 14 | w.writeString8("square"); 15 | w.write32Float(0); 16 | 17 | w.writeString8("custom"); 18 | w.write32Float(150); 19 | //return Math.sin(((t + 2) ** Math.cos(t * 4)) * Math.PI * 2 * freq); 20 | //Math.sin((((t+2)**Math.cos((t*4)))*((Math.PI*2)*f))) 21 | w.write8(4); //sin 22 | w.write8(5); //times 23 | { 24 | w.write8(9); //Power 25 | 26 | { 27 | w.write8(6); //adding 28 | w.write8(1); //t 29 | w.write8(0); 30 | w.write32Float(2); //2 31 | } 32 | w.write8(13); //cos 33 | w.write8(5); // times 34 | w.write8(1); //t 35 | w.write8(0); 36 | w.write32Float(4); //4 37 | } 38 | { 39 | w.write8(5); //times 40 | w.write8(5); //times 41 | w.write8(3); //PI 42 | w.write8(0); 43 | w.write32Float(2); //2 44 | w.write8(2); //freq 45 | } 46 | 47 | w.write16(4); //3 tracks 48 | 49 | w.write16(1); //zip 50 | w.write8(4); 51 | w.write32Float(1); 52 | w.write32Float(700); 53 | 54 | w.write16(3); //beep 55 | { 56 | w.write8(1); 57 | w.write32Float(1); 58 | w.write32Float(700); 59 | w.write32Float(50); 60 | 61 | w.write8(0); 62 | w.write32Float(100); 63 | 64 | w.write8(1); 65 | w.write32Float(1); 66 | w.write32Float(700); 67 | w.write32Float(50); 68 | } 69 | 70 | w.write16(5); //three 71 | { 72 | w.write8(1); 73 | w.write32Float(1); 74 | w.write32Float(800); 75 | w.write32Float(50); 76 | 77 | w.write8(0); 78 | w.write32Float(50); 79 | 80 | w.write8(1); 81 | w.write32Float(1); 82 | w.write32Float(1000); 83 | w.write32Float(50); 84 | 85 | w.write8(0); 86 | w.write32Float(50); 87 | 88 | w.write8(1); 89 | w.write32Float(1); 90 | w.write32Float(1300); 91 | w.write32Float(50); 92 | } 93 | 94 | w.write16(5); //square 95 | { 96 | w.write8(3); 97 | w.write32Float(1); 98 | w.write32Float(600); 99 | w.write32Float(50); 100 | 101 | w.write8(0); 102 | w.write32Float(50); 103 | 104 | w.write8(3); 105 | w.write32Float(1); 106 | w.write32Float(800); 107 | w.write32Float(50); 108 | 109 | w.write8(0); 110 | w.write32Float(50); 111 | 112 | w.write8(3); 113 | w.write32Float(1); 114 | w.write32Float(1000); 115 | w.write32Float(50); 116 | } 117 | w.write16(4); //2 audio 118 | 119 | w.writeString8("zip"); 120 | w.write16(1); 121 | w.write16(1); 122 | 123 | w.writeString8("beep"); 124 | w.write16(1); 125 | w.write16(2); 126 | 127 | w.writeString8("three"); 128 | w.write16(1); 129 | w.write16(3); 130 | 131 | w.writeString8("square"); 132 | w.write16(1); 133 | w.write16(4); 134 | const buff = w.getBuffer(); 135 | const play = Play.parseBin(buff); 136 | /* 137 | const zip=play.audios.get("square"); 138 | if(zip){ 139 | setInterval(()=>{ 140 | zip.play() 141 | },1000) 142 | ; 143 | console.log(play.voices[3][0].info.wave) 144 | }; 145 | */ 146 | console.log(play, buff); 147 | 148 | const download = document.getElementById("download"); 149 | if (download) { 150 | download.onclick = () => { 151 | const blob = new Blob([buff], {type: "binary"}); 152 | const downloadUrl = URL.createObjectURL(blob); 153 | const a = document.createElement("a"); 154 | a.href = downloadUrl; 155 | a.download = "sounds.jasf"; 156 | document.body.appendChild(a); 157 | a.click(); 158 | URL.revokeObjectURL(downloadUrl); 159 | }; 160 | } 161 | -------------------------------------------------------------------------------- /src/webpage/audio/play.ts: -------------------------------------------------------------------------------- 1 | import {BinRead} from "../utils/binaryUtils.js"; 2 | import {Track} from "./track.js"; 3 | import {AVoice} from "./voice.js"; 4 | import {Audio} from "./audio.js"; 5 | export class Play { 6 | voices: [AVoice, string][]; 7 | tracks: Track[]; 8 | audios: Map; 9 | constructor(voices: [AVoice, string][], tracks: Track[], audios: Map) { 10 | this.voices = voices; 11 | this.tracks = tracks; 12 | this.audios = audios; 13 | } 14 | static parseBin(buffer: ArrayBuffer) { 15 | const read = new BinRead(buffer); 16 | if (read.readStringNo(4) !== "jasf") throw new Error("this is not a jasf file"); 17 | let voices = read.read8(); 18 | let six = false; 19 | if (voices === 255) { 20 | voices = read.read16(); 21 | six = true; 22 | } 23 | const voiceArr: [AVoice, string][] = []; 24 | for (let i = 0; i < voices; i++) { 25 | voiceArr.push(AVoice.getVoice(read)); 26 | } 27 | 28 | const tracks = read.read16(); 29 | const trackArr: Track[] = []; 30 | for (let i = 0; i < tracks; i++) { 31 | trackArr.push(Track.parse(read, voiceArr, six)); 32 | } 33 | 34 | const audios = read.read16(); 35 | const audioArr = new Map(); 36 | for (let i = 0; i < audios; i++) { 37 | const a = Audio.parse(read, trackArr); 38 | audioArr.set(a.name, a); 39 | } 40 | 41 | return new Play(voiceArr, trackArr, audioArr); 42 | } 43 | static async playURL(url: string) { 44 | const res = await fetch(url); 45 | const arr = await res.arrayBuffer(); 46 | return this.parseBin(arr); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/webpage/audio/sounds.jasf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MathMan05/JankClient/ec57d5304cd6f28d83c3626e42afd7f27d459430/src/webpage/audio/sounds.jasf -------------------------------------------------------------------------------- /src/webpage/audio/track.ts: -------------------------------------------------------------------------------- 1 | import {BinRead} from "../utils/binaryUtils.js"; 2 | import {AVoice} from "./voice.js"; 3 | 4 | export class Track { 5 | seq: (AVoice | number)[]; 6 | constructor(playing: (AVoice | number)[]) { 7 | this.seq = playing; 8 | } 9 | static parse(read: BinRead, play: [AVoice, string][], six: boolean): Track { 10 | const length = read.read16(); 11 | const play2: (AVoice | number)[] = []; 12 | for (let i = 0; i < length; i++) { 13 | let index: number; 14 | if (six) { 15 | index = read.read16(); 16 | } else { 17 | index = read.read8(); 18 | } 19 | if (index === 0) { 20 | play2.push(read.readFloat32()); 21 | continue; 22 | } 23 | index--; 24 | if (!play[index]) throw new Error("voice not found"); 25 | const [voice] = play[index]; 26 | let temp: AVoice; 27 | if (voice.info.wave instanceof Function) { 28 | temp = voice.clone(read.readFloat32(), read.readFloat32()); 29 | } else { 30 | temp = voice.clone(read.readFloat32(), read.readFloat32(), read.readFloat32()); 31 | } 32 | play2.push(temp); 33 | } 34 | return new Track(play2); 35 | } 36 | async play() { 37 | for (const thing of this.seq) { 38 | if (thing instanceof AVoice) { 39 | thing.playL(); 40 | } else { 41 | await new Promise((res) => setTimeout(res, thing)); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/webpage/audio/voice.ts: -------------------------------------------------------------------------------- 1 | import {BinRead} from "../utils/binaryUtils.js"; 2 | 3 | class AVoice { 4 | audioCtx: AudioContext; 5 | info: {wave: string | Function; freq: number}; 6 | playing: boolean; 7 | myArrayBuffer: AudioBuffer; 8 | gainNode: GainNode; 9 | buffer: Float32Array; 10 | source: AudioBufferSourceNode; 11 | length = 1; 12 | constructor(wave: string | Function, freq: number, volume = 1, length = 1000) { 13 | this.length = length; 14 | this.audioCtx = new window.AudioContext(); 15 | this.info = {wave, freq}; 16 | this.playing = false; 17 | this.myArrayBuffer = this.audioCtx.createBuffer( 18 | 1, 19 | (this.audioCtx.sampleRate * length) / 1000, 20 | this.audioCtx.sampleRate, 21 | ); 22 | this.gainNode = this.audioCtx.createGain(); 23 | this.gainNode.gain.value = volume; 24 | this.gainNode.connect(this.audioCtx.destination); 25 | this.buffer = this.myArrayBuffer.getChannelData(0); 26 | this.source = this.audioCtx.createBufferSource(); 27 | this.source.buffer = this.myArrayBuffer; 28 | this.source.loop = true; 29 | this.source.start(); 30 | this.updateWave(); 31 | } 32 | clone(volume: number, freq: number, length = this.length) { 33 | return new AVoice(this.wave, freq, volume, length); 34 | } 35 | get wave(): string | Function { 36 | return this.info.wave; 37 | } 38 | get freq(): number { 39 | return this.info.freq; 40 | } 41 | set wave(wave: string | Function) { 42 | this.info.wave = wave; 43 | this.updateWave(); 44 | } 45 | set freq(freq: number) { 46 | this.info.freq = freq; 47 | this.updateWave(); 48 | } 49 | updateWave(): void { 50 | const func = this.waveFunction(); 51 | for (let i = 0; i < this.buffer.length; i++) { 52 | this.buffer[i] = func(i / this.audioCtx.sampleRate, this.freq); 53 | } 54 | } 55 | waveFunction(): Function { 56 | if (typeof this.wave === "function") { 57 | return this.wave; 58 | } 59 | switch (this.wave) { 60 | case "sin": 61 | return (t: number, freq: number) => { 62 | return Math.sin(t * Math.PI * 2 * freq); 63 | }; 64 | case "triangle": 65 | return (t: number, freq: number) => { 66 | return Math.abs(((4 * t * freq) % 4) - 2) - 1; 67 | }; 68 | case "sawtooth": 69 | return (t: number, freq: number) => { 70 | return ((t * freq) % 1) * 2 - 1; 71 | }; 72 | case "square": 73 | return (t: number, freq: number) => { 74 | return (t * freq) % 2 < 1 ? 1 : -1; 75 | }; 76 | case "white": 77 | return (_t: number, _freq: number) => { 78 | return Math.random() * 2 - 1; 79 | }; 80 | } 81 | return new Function(); 82 | } 83 | play(): void { 84 | if (this.playing) { 85 | return; 86 | } 87 | this.source.connect(this.gainNode); 88 | this.playing = true; 89 | } 90 | playL() { 91 | this.play(); 92 | setTimeout(() => { 93 | this.stop(); 94 | }, this.length); 95 | } 96 | stop(): void { 97 | if (this.playing) { 98 | this.source.disconnect(); 99 | this.playing = false; 100 | } 101 | } 102 | static noises(noise: string): void { 103 | switch (noise) { 104 | case "three": { 105 | const voicy = new AVoice("sin", 800); 106 | voicy.play(); 107 | setTimeout((_) => { 108 | voicy.freq = 1000; 109 | }, 50); 110 | setTimeout((_) => { 111 | voicy.freq = 1300; 112 | }, 100); 113 | setTimeout((_) => { 114 | voicy.stop(); 115 | }, 150); 116 | break; 117 | } 118 | case "zip": { 119 | const voicy = new AVoice((t: number, freq: number) => { 120 | return Math.sin((t + 2) ** Math.cos(t * 4) * Math.PI * 2 * freq); 121 | }, 700); 122 | voicy.play(); 123 | setTimeout((_) => { 124 | voicy.stop(); 125 | }, 150); 126 | break; 127 | } 128 | case "square": { 129 | const voicy = new AVoice("square", 600, 0.4); 130 | voicy.play(); 131 | setTimeout((_) => { 132 | voicy.freq = 800; 133 | }, 50); 134 | setTimeout((_) => { 135 | voicy.freq = 1000; 136 | }, 100); 137 | setTimeout((_) => { 138 | voicy.stop(); 139 | }, 150); 140 | break; 141 | } 142 | case "beep": { 143 | const voicy = new AVoice("sin", 800); 144 | voicy.play(); 145 | setTimeout((_) => { 146 | voicy.stop(); 147 | }, 50); 148 | setTimeout((_) => { 149 | voicy.play(); 150 | }, 100); 151 | setTimeout((_) => { 152 | voicy.stop(); 153 | }, 150); 154 | break; 155 | } 156 | case "join": { 157 | const voicy = new AVoice("triangle", 600, 0.1); 158 | voicy.play(); 159 | setTimeout((_) => { 160 | voicy.freq = 800; 161 | }, 75); 162 | setTimeout((_) => { 163 | voicy.freq = 1000; 164 | }, 150); 165 | setTimeout((_) => { 166 | voicy.stop(); 167 | }, 200); 168 | break; 169 | } 170 | case "leave": { 171 | const voicy = new AVoice("triangle", 850, 0.5); 172 | voicy.play(); 173 | setTimeout((_) => { 174 | voicy.freq = 700; 175 | }, 100); 176 | setTimeout((_) => { 177 | voicy.stop(); 178 | voicy.freq = 400; 179 | }, 180); 180 | setTimeout((_) => { 181 | voicy.play(); 182 | }, 200); 183 | setTimeout((_) => { 184 | voicy.stop(); 185 | }, 250); 186 | break; 187 | } 188 | } 189 | } 190 | static get sounds() { 191 | return ["three", "zip", "square", "beep"]; 192 | } 193 | static getVoice(read: BinRead): [AVoice, string] { 194 | const name = read.readString8(); 195 | let length = read.readFloat32(); 196 | let special: Function | string; 197 | if (length !== 0) { 198 | special = this.parseExpression(read); 199 | } else { 200 | special = name; 201 | length = 1; 202 | } 203 | return [new AVoice(special, 0, 0, length), name]; 204 | } 205 | static parseExpression(read: BinRead): Function { 206 | return new Function("t", "f", `return ${this.PEHelper(read)};`); 207 | } 208 | static PEHelper(read: BinRead): string { 209 | let state = read.read8(); 210 | switch (state) { 211 | case 0: 212 | return "" + read.readFloat32(); 213 | case 1: 214 | return "t"; 215 | case 2: 216 | return "f"; 217 | case 3: 218 | return `Math.PI`; 219 | case 4: 220 | return `Math.sin(${this.PEHelper(read)})`; 221 | case 5: 222 | return `(${this.PEHelper(read)}*${this.PEHelper(read)})`; 223 | case 6: 224 | return `(${this.PEHelper(read)}+${this.PEHelper(read)})`; 225 | case 7: 226 | return `(${this.PEHelper(read)}/${this.PEHelper(read)})`; 227 | case 8: 228 | return `(${this.PEHelper(read)}-${this.PEHelper(read)})`; 229 | case 9: 230 | return `(${this.PEHelper(read)}**${this.PEHelper(read)})`; 231 | case 10: 232 | return `(${this.PEHelper(read)}%${this.PEHelper(read)})`; 233 | case 11: 234 | return `Math.abs(${this.PEHelper(read)})`; 235 | case 12: 236 | return `Math.round(${this.PEHelper(read)})`; 237 | case 13: 238 | return `Math.cos(${this.PEHelper(read)})`; 239 | default: 240 | throw new Error("unexpected case found!"); 241 | } 242 | } 243 | } 244 | 245 | export {AVoice as AVoice}; 246 | -------------------------------------------------------------------------------- /src/webpage/contextmenu.ts: -------------------------------------------------------------------------------- 1 | import {mobile} from "./utils/utils.js"; 2 | type iconJson = 3 | | { 4 | src: string; 5 | } 6 | | { 7 | css: string; 8 | } 9 | | { 10 | html: HTMLElement; 11 | }; 12 | 13 | interface menuPart { 14 | makeContextHTML(obj1: x, obj2: y, menu: HTMLDivElement): void; 15 | } 16 | 17 | class ContextButton implements menuPart { 18 | private text: string | (() => string); 19 | private onClick: (this: x, arg: y, e: MouseEvent) => void; 20 | private icon?: iconJson; 21 | private visable?: (this: x, arg: y) => boolean; 22 | private enabled?: (this: x, arg: y) => boolean; 23 | //TODO there *will* be more colors 24 | private color?: "red" | "blue"; 25 | constructor( 26 | text: ContextButton["text"], 27 | onClick: ContextButton["onClick"], 28 | addProps: { 29 | icon?: iconJson; 30 | visable?: (this: x, arg: y) => boolean; 31 | enabled?: (this: x, arg: y) => boolean; 32 | color?: "red" | "blue"; 33 | } = {}, 34 | ) { 35 | this.text = text; 36 | this.onClick = onClick; 37 | this.icon = addProps.icon; 38 | this.visable = addProps.visable; 39 | this.enabled = addProps.enabled; 40 | this.color = addProps.color; 41 | } 42 | isVisable(obj1: x, obj2: y): boolean { 43 | if (!this.visable) return true; 44 | return this.visable.call(obj1, obj2); 45 | } 46 | makeContextHTML(obj1: x, obj2: y, menu: HTMLDivElement) { 47 | if (!this.isVisable(obj1, obj2)) { 48 | return; 49 | } 50 | 51 | const intext = document.createElement("button"); 52 | intext.classList.add("contextbutton"); 53 | intext.append(this.textContent); 54 | 55 | intext.disabled = !!this.enabled && !this.enabled.call(obj1, obj2); 56 | 57 | if (this.icon) { 58 | if ("src" in this.icon) { 59 | const icon = document.createElement("img"); 60 | icon.classList.add("svgicon"); 61 | icon.src = this.icon.src; 62 | intext.append(icon); 63 | } else if ("css" in this.icon) { 64 | const icon = document.createElement("span"); 65 | icon.classList.add(this.icon.css, "svgicon"); 66 | switch (this.color) { 67 | case "red": 68 | icon.style.background = "var(--red)"; 69 | break; 70 | case "blue": 71 | icon.style.background = "var(--blue)"; 72 | break; 73 | } 74 | intext.append(icon); 75 | } else { 76 | intext.append(this.icon.html); 77 | } 78 | } 79 | 80 | switch (this.color) { 81 | case "red": 82 | intext.style.color = "var(--red)"; 83 | break; 84 | case "blue": 85 | intext.style.color = "var(--blue)"; 86 | break; 87 | } 88 | 89 | intext.onclick = (e) => { 90 | e.preventDefault(); 91 | e.stopImmediatePropagation(); 92 | menu.remove(); 93 | this.onClick.call(obj1, obj2, e); 94 | }; 95 | 96 | menu.append(intext); 97 | } 98 | get textContent() { 99 | if (this.text instanceof Function) { 100 | return this.text(); 101 | } 102 | return this.text; 103 | } 104 | } 105 | class Seperator implements menuPart { 106 | private visable?: (obj1: x, obj2: y) => boolean; 107 | constructor(visable?: (obj1: x, obj2: y) => boolean) { 108 | this.visable = visable; 109 | } 110 | makeContextHTML(obj1: x, obj2: y, menu: HTMLDivElement): void { 111 | if (!this.visable || this.visable(obj1, obj2)) { 112 | if (menu.children[menu.children.length - 1].tagName === "HR") { 113 | return; 114 | } 115 | menu.append(document.createElement("hr")); 116 | } 117 | } 118 | } 119 | class Contextmenu { 120 | static currentmenu: HTMLElement | ""; 121 | name: string; 122 | buttons: menuPart[]; 123 | div!: HTMLDivElement; 124 | static setup() { 125 | Contextmenu.currentmenu = ""; 126 | document.addEventListener("click", (event) => { 127 | if (Contextmenu.currentmenu === "") { 128 | return; 129 | } 130 | if (!Contextmenu.currentmenu.contains(event.target as Node)) { 131 | Contextmenu.currentmenu.remove(); 132 | Contextmenu.currentmenu = ""; 133 | } 134 | }); 135 | } 136 | constructor(name: string) { 137 | this.name = name; 138 | this.buttons = []; 139 | } 140 | 141 | addButton( 142 | text: ContextButton["text"], 143 | onClick: ContextButton["onClick"], 144 | addProps: { 145 | icon?: iconJson; 146 | visable?: (this: x, arg: y) => boolean; 147 | enabled?: (this: x, arg: y) => boolean; 148 | color?: "red" | "blue"; 149 | } = {}, 150 | ) { 151 | this.buttons.push(new ContextButton(text, onClick, addProps)); 152 | } 153 | addSeperator(visable?: (obj1: x, obj2: y) => boolean) { 154 | this.buttons.push(new Seperator(visable)); 155 | } 156 | makemenu(x: number, y: number, addinfo: x, other: y) { 157 | const div = document.createElement("div"); 158 | div.classList.add("contextmenu", "flexttb"); 159 | 160 | for (const button of this.buttons) { 161 | button.makeContextHTML(addinfo, other, div); 162 | } 163 | if (div.children[div.children.length - 1].tagName === "HR") { 164 | div.children[div.children.length - 1].remove(); 165 | } 166 | //NOTE I don't know if this'll ever actually happen in reality 167 | if (div.childNodes.length === 0) return; 168 | 169 | if (Contextmenu.currentmenu !== "") { 170 | Contextmenu.currentmenu.remove(); 171 | } 172 | if (y > 0) { 173 | div.style.top = y + "px"; 174 | } else { 175 | div.style.bottom = y * -1 + "px"; 176 | } 177 | div.style.left = x + "px"; 178 | document.body.appendChild(div); 179 | Contextmenu.keepOnScreen(div); 180 | console.log(div); 181 | Contextmenu.currentmenu = div; 182 | return this.div; 183 | } 184 | bindContextmenu( 185 | obj: HTMLElement, 186 | addinfo: x, 187 | other: y, 188 | touchDrag: (x: number, y: number) => unknown = () => {}, 189 | touchEnd: (x: number, y: number) => unknown = () => {}, 190 | click: "right" | "left" = "right", 191 | ) { 192 | const func = (event: MouseEvent) => { 193 | event.preventDefault(); 194 | event.stopImmediatePropagation(); 195 | this.makemenu(event.clientX, event.clientY, addinfo, other); 196 | }; 197 | if (click === "right") { 198 | obj.addEventListener("contextmenu", func); 199 | } else { 200 | obj.addEventListener("click", func); 201 | } 202 | //NOTE not sure if this code is correct, seems fine at least for now 203 | if (mobile) { 204 | let hold: NodeJS.Timeout | undefined; 205 | let x!: number; 206 | let y!: number; 207 | obj.addEventListener( 208 | "touchstart", 209 | (event: TouchEvent) => { 210 | x = event.touches[0].pageX; 211 | y = event.touches[0].pageY; 212 | if (event.touches.length > 1) { 213 | event.preventDefault(); 214 | event.stopImmediatePropagation(); 215 | this.makemenu(event.touches[0].clientX, event.touches[0].clientY, addinfo, other); 216 | } else { 217 | // 218 | event.stopImmediatePropagation(); 219 | hold = setTimeout(() => { 220 | if (lastx ** 2 + lasty ** 2 > 10 ** 2) return; 221 | this.makemenu(event.touches[0].clientX, event.touches[0].clientY, addinfo, other); 222 | console.log(obj); 223 | }, 500); 224 | } 225 | }, 226 | {passive: false}, 227 | ); 228 | let lastx = 0; 229 | let lasty = 0; 230 | obj.addEventListener("touchend", () => { 231 | if (hold) { 232 | clearTimeout(hold); 233 | } 234 | touchEnd(lastx, lasty); 235 | }); 236 | obj.addEventListener("touchmove", (event) => { 237 | lastx = event.touches[0].pageX - x; 238 | lasty = event.touches[0].pageY - y; 239 | touchDrag(lastx, lasty); 240 | }); 241 | } 242 | return func; 243 | } 244 | static keepOnScreen(obj: HTMLElement) { 245 | const html = document.documentElement.getBoundingClientRect(); 246 | const docheight = html.height; 247 | const docwidth = html.width; 248 | const box = obj.getBoundingClientRect(); 249 | console.log(box, docheight, docwidth); 250 | if (box.right > docwidth) { 251 | console.log("test"); 252 | obj.style.left = docwidth - box.width + "px"; 253 | } 254 | if (box.bottom > docheight) { 255 | obj.style.top = docheight - box.height + "px"; 256 | } 257 | } 258 | } 259 | Contextmenu.setup(); 260 | export {Contextmenu}; 261 | -------------------------------------------------------------------------------- /src/webpage/disimg.ts: -------------------------------------------------------------------------------- 1 | import {File} from "./file.js"; 2 | 3 | class ImagesDisplay { 4 | files: File[]; 5 | index = 0; 6 | constructor(files: File[], index = 0) { 7 | this.files = files; 8 | this.index = index; 9 | } 10 | weakbg = new WeakRef(document.createElement("div")); 11 | get background(): HTMLElement | undefined { 12 | return this.weakbg.deref(); 13 | } 14 | set background(e: HTMLElement) { 15 | this.weakbg = new WeakRef(e); 16 | } 17 | makeHTML(): HTMLElement { 18 | //TODO this should be able to display more than one image at a time lol 19 | const image = this.files[this.index].getHTML(false, true); 20 | image.classList.add("imgfit", "centeritem"); 21 | return image; 22 | } 23 | show() { 24 | this.background = document.createElement("div"); 25 | this.background.classList.add("background"); 26 | let cur = this.makeHTML(); 27 | if (this.files.length !== 1) { 28 | const right = document.createElement("span"); 29 | right.classList.add("rightArrow", "svg-intoMenu"); 30 | right.onclick = (e) => { 31 | e.preventDefault(); 32 | e.stopImmediatePropagation(); 33 | this.index++; 34 | this.index %= this.files.length; 35 | cur.remove(); 36 | cur = this.makeHTML(); 37 | if (this.background) { 38 | this.background.appendChild(cur); 39 | } 40 | }; 41 | 42 | const left = document.createElement("span"); 43 | left.onclick = (e) => { 44 | e.preventDefault(); 45 | e.stopImmediatePropagation(); 46 | this.index += this.files.length - 1; 47 | this.index %= this.files.length; 48 | cur.remove(); 49 | cur = this.makeHTML(); 50 | if (this.background) { 51 | this.background.appendChild(cur); 52 | } 53 | }; 54 | left.classList.add("leftArrow", "svg-leftArrow"); 55 | this.background.append(right, left); 56 | this.background.addEventListener("keydown", (e) => { 57 | if (e.key === "ArrowRight") { 58 | e.preventDefault(); 59 | e.stopImmediatePropagation(); 60 | right.click(); 61 | } 62 | if (e.key === "ArrowLeft") { 63 | e.preventDefault(); 64 | e.stopImmediatePropagation(); 65 | right.click(); 66 | } 67 | }); 68 | } 69 | 70 | this.background.appendChild(cur); 71 | this.background.onclick = (_) => { 72 | this.hide(); 73 | }; 74 | document.body.append(this.background); 75 | this.background.setAttribute("tabindex", "0"); 76 | this.background.focus(); 77 | } 78 | hide() { 79 | if (this.background) { 80 | this.background.remove(); 81 | } 82 | } 83 | } 84 | export {ImagesDisplay}; 85 | -------------------------------------------------------------------------------- /src/webpage/emoji.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MathMan05/JankClient/ec57d5304cd6f28d83c3626e42afd7f27d459430/src/webpage/emoji.bin -------------------------------------------------------------------------------- /src/webpage/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MathMan05/JankClient/ec57d5304cd6f28d83c3626e42afd7f27d459430/src/webpage/favicon.ico -------------------------------------------------------------------------------- /src/webpage/file.ts: -------------------------------------------------------------------------------- 1 | import {Message} from "./message.js"; 2 | import {filejson} from "./jsontypes.js"; 3 | import {ImagesDisplay} from "./disimg.js"; 4 | import {makePlayBox, MediaPlayer} from "./media.js"; 5 | import {I18n} from "./i18n.js"; 6 | import {createImg} from "./utils/utils.js"; 7 | class File { 8 | owner: Message | null; 9 | id: string; 10 | filename: string; 11 | content_type: string; 12 | width: number | undefined; 13 | height: number | undefined; 14 | proxy_url: string | undefined; 15 | url: string; 16 | size: number; 17 | constructor(fileJSON: filejson, owner: Message | null) { 18 | this.owner = owner; 19 | this.id = fileJSON.id; 20 | this.filename = fileJSON.filename; 21 | this.content_type = fileJSON.content_type; 22 | this.width = fileJSON.width; 23 | this.height = fileJSON.height; 24 | this.url = fileJSON.url; 25 | this.proxy_url = fileJSON.proxy_url; 26 | this.content_type = fileJSON.content_type; 27 | this.size = fileJSON.size; 28 | } 29 | getHTML(temp: boolean = false, fullScreen = false, OSpoiler = false): HTMLElement { 30 | function makeSpoilerHTML(): HTMLElement { 31 | const spoil = document.createElement("div"); 32 | spoil.classList.add("fSpoil"); 33 | const stext = document.createElement("span"); 34 | stext.textContent = I18n.spoiler(); 35 | spoil.append(stext); 36 | spoil.onclick = () => spoil.remove(); 37 | return spoil; 38 | } 39 | OSpoiler ||= this.filename.startsWith("SPOILER_"); 40 | const src = this.proxy_url || this.url; 41 | this.width ||= 1000; 42 | this.height ||= 1000; 43 | if (this.width && this.height) { 44 | let scale = 1; 45 | const max = 96 * 3; 46 | scale = Math.max(scale, this.width / max); 47 | scale = Math.max(scale, this.height / max); 48 | this.width /= scale; 49 | this.height /= scale; 50 | } 51 | 52 | if (this.content_type.startsWith("image/")) { 53 | const div = document.createElement("div"); 54 | const img = createImg(src); 55 | if (!fullScreen) { 56 | img.classList.add("messageimg"); 57 | div.classList.add("messageimgdiv"); 58 | } 59 | img.onclick = () => { 60 | if (this.owner) { 61 | const full = new ImagesDisplay( 62 | this.owner.attachments, 63 | this.owner.attachments.indexOf(this), 64 | ); 65 | full.show(); 66 | } else { 67 | const full = new ImagesDisplay([this]); 68 | full.show(); 69 | } 70 | }; 71 | div.append(img); 72 | if (this.width && !fullScreen) { 73 | div.style.maxWidth = this.width + "px"; 74 | div.style.maxHeight = this.height + "px"; 75 | } 76 | if (!fullScreen) { 77 | if (OSpoiler) { 78 | div.append(makeSpoilerHTML()); 79 | } 80 | return div; 81 | } else { 82 | return img; 83 | } 84 | } else if (this.content_type.startsWith("video/")) { 85 | const video = document.createElement("video"); 86 | const source = document.createElement("source"); 87 | source.src = src; 88 | video.append(source); 89 | source.type = this.content_type; 90 | video.controls = !temp; 91 | if (this.width && this.height) { 92 | video.width = this.width; 93 | video.height = this.height; 94 | } 95 | if (OSpoiler) { 96 | const div = document.createElement("div"); 97 | div.style.setProperty("position", "relative"); 98 | div.append(video, makeSpoilerHTML()); 99 | return div; 100 | } 101 | return video; 102 | } else if (this.content_type.startsWith("audio/")) { 103 | const a = this.getAudioHTML(); 104 | if (OSpoiler) { 105 | a.append(makeSpoilerHTML()); 106 | } 107 | return a; 108 | } else { 109 | const uk = this.createunknown(); 110 | if (OSpoiler) { 111 | uk.append(makeSpoilerHTML()); 112 | } 113 | return uk; 114 | } 115 | } 116 | private getAudioHTML() { 117 | const src = this.proxy_url || this.url; 118 | return makePlayBox(src, player); 119 | } 120 | upHTML(files: Blob[], file: globalThis.File): HTMLElement { 121 | const div = document.createElement("div"); 122 | let contained = this.getHTML(true, false, file.name.startsWith("SPOILER_")); 123 | div.classList.add("containedFile"); 124 | div.append(contained); 125 | const controls = document.createElement("div"); 126 | controls.classList.add("controls"); 127 | 128 | const garbage = document.createElement("button"); 129 | const icon = document.createElement("span"); 130 | icon.classList.add("svgicon", "svg-delete"); 131 | garbage.append(icon); 132 | garbage.onclick = (_) => { 133 | div.remove(); 134 | files.splice(files.indexOf(file), 1); 135 | }; 136 | 137 | const spoiler = document.createElement("button"); 138 | const sicon = document.createElement("span"); 139 | sicon.classList.add( 140 | "svgicon", 141 | file.name.startsWith("SPOILER_") ? "svg-unspoiler" : "svg-spoiler", 142 | ); 143 | spoiler.append(sicon); 144 | spoiler.onclick = (_) => { 145 | if (file.name.startsWith("SPOILER_")) { 146 | const name = file.name.split("SPOILER_"); 147 | name.shift(); 148 | file = files[files.indexOf(file)] = new globalThis.File([file], name.join("SPOILER_"), { 149 | type: file.type, 150 | }); 151 | sicon.classList.add("svg-spoiler"); 152 | sicon.classList.remove("svg-unspoiler"); 153 | } else { 154 | file = files[files.indexOf(file)] = new globalThis.File([file], "SPOILER_" + file.name, { 155 | type: file.type, 156 | }); 157 | sicon.classList.add("svg-unspoiler"); 158 | sicon.classList.remove("svg-spoiler"); 159 | } 160 | contained.remove(); 161 | contained = this.getHTML(true, false, file.name.startsWith("SPOILER_")); 162 | div.append(contained); 163 | }; 164 | 165 | div.append(controls); 166 | controls.append(spoiler, garbage); 167 | return div; 168 | } 169 | static initFromBlob(file: globalThis.File) { 170 | return new File( 171 | { 172 | filename: file.name, 173 | size: file.size, 174 | id: "null", 175 | content_type: file.type, 176 | width: undefined, 177 | height: undefined, 178 | url: URL.createObjectURL(file), 179 | proxy_url: undefined, 180 | }, 181 | null, 182 | ); 183 | } 184 | createunknown(): HTMLElement { 185 | console.log("🗎"); 186 | const src = this.proxy_url || this.url; 187 | const div = document.createElement("table"); 188 | div.classList.add("unknownfile"); 189 | const nametr = document.createElement("tr"); 190 | div.append(nametr); 191 | const fileicon = document.createElement("td"); 192 | nametr.append(fileicon); 193 | fileicon.append("🗎"); 194 | fileicon.classList.add("fileicon"); 195 | fileicon.rowSpan = 2; 196 | const nametd = document.createElement("td"); 197 | if (src) { 198 | const a = document.createElement("a"); 199 | a.href = src; 200 | a.textContent = this.filename; 201 | nametd.append(a); 202 | } else { 203 | nametd.textContent = this.filename; 204 | } 205 | 206 | nametd.classList.add("filename"); 207 | nametr.append(nametd); 208 | const sizetr = document.createElement("tr"); 209 | const size = document.createElement("td"); 210 | sizetr.append(size); 211 | size.textContent = "Size:" + File.filesizehuman(this.size); 212 | size.classList.add("filesize"); 213 | div.appendChild(sizetr); 214 | return div; 215 | } 216 | static filesizehuman(fsize: number) { 217 | const i = fsize == 0 ? 0 : Math.floor(Math.log(fsize) / Math.log(1024)); 218 | return ( 219 | Number((fsize / Math.pow(1024, i)).toFixed(2)) * 1 + 220 | " " + 221 | ["Bytes", "Kilobytes", "Megabytes", "Gigabytes", "Terabytes"][i] // I don't think this changes across languages, correct me if I'm wrong 222 | ); 223 | } 224 | } 225 | 226 | const player = new MediaPlayer(); 227 | export {File}; 228 | -------------------------------------------------------------------------------- /src/webpage/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jank Client 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 24 | 25 | 26 | 27 |
36 |
37 |

Welcome to Jank Client

38 |
39 |

40 | Jank Client is a Spacebar-compatible client seeking to be as good as it can be with many 41 | features including: 42 |

43 |
    44 |
  • Direct Messaging
  • 45 |
  • Reactions support
  • 46 |
  • Invites
  • 47 |
  • Account switching
  • 48 |
  • User settings
  • 49 |
  • Developer portal
  • 50 |
  • Bot invites
  • 51 |
  • Translation support
  • 52 |
53 |
54 |
55 |

Spacebar-Compatible Instances:

56 |
57 |
58 | 59 |
60 |

Translate Jank Client

61 |

You can help translate Jank Client into your own language!

62 |
63 | 68 | Translate 69 | 70 |
71 | 72 |
73 |

Contribute to Jank Client

74 |

75 | We always appreciate some help, whether that be in the form of bug reports, code, help 76 | translate, or even just pointing out some typos. 77 |

78 |
79 | Github 80 |
81 |
82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/webpage/home.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from "./i18n.js"; 2 | import {makeRegister} from "./register.js"; 3 | import {mobile} from "./utils/utils.js"; 4 | console.log(mobile); 5 | const serverbox = document.getElementById("instancebox") as HTMLDivElement; 6 | 7 | (async () => { 8 | await I18n.done; 9 | const openClient = document.getElementById("openClient"); 10 | const welcomeJank = document.getElementById("welcomeJank"); 11 | const box1title = document.getElementById("box1title"); 12 | const box1Items = document.getElementById("box1Items"); 13 | const compatableInstances = document.getElementById("compatableInstances"); 14 | const box3title = document.getElementById("box3title"); 15 | const box3description = document.getElementById("box3description"); 16 | 17 | const box4title = document.getElementById("box4title"); 18 | const box4description = document.getElementById("box4description"); 19 | const translate = document.getElementById("translate"); 20 | if ( 21 | openClient && 22 | welcomeJank && 23 | compatableInstances && 24 | box3title && 25 | box3description && 26 | box1title && 27 | box1Items && 28 | box4title && 29 | box4description && 30 | translate 31 | ) { 32 | openClient.textContent = I18n.getTranslation("htmlPages.openClient"); 33 | welcomeJank.textContent = I18n.getTranslation("htmlPages.welcomeJank"); 34 | box1title.textContent = I18n.getTranslation("htmlPages.box1title"); 35 | 36 | compatableInstances.textContent = I18n.getTranslation("htmlPages.compatableInstances"); 37 | box3title.textContent = I18n.getTranslation("htmlPages.box3title"); 38 | box3description.textContent = I18n.getTranslation("htmlPages.box3description"); 39 | 40 | box4title.textContent = I18n.htmlPages.transTitle(); 41 | box4title.textContent = I18n.htmlPages.transDesc(); 42 | box4title.textContent = I18n.htmlPages.trans(); 43 | 44 | const items = I18n.getTranslation("htmlPages.box1Items").split("|"); 45 | let i = 0; 46 | //@ts-ignore ts is being dumb here 47 | for (const item of box1Items.children) { 48 | (item as HTMLElement).textContent = items[i]; 49 | i++; 50 | } 51 | } else { 52 | console.error( 53 | openClient, 54 | welcomeJank, 55 | compatableInstances, 56 | box3title, 57 | box3description, 58 | box1title, 59 | box1Items, 60 | ); 61 | } 62 | })(); 63 | 64 | fetch("/instances.json") 65 | .then((_) => _.json()) 66 | .then( 67 | async ( 68 | json: { 69 | name: string; 70 | description?: string; 71 | descriptionLong?: string; 72 | image?: string; 73 | url?: string; 74 | display?: boolean; 75 | online?: boolean; 76 | uptime: {alltime: number; daytime: number; weektime: number}; 77 | urls: { 78 | wellknown: string; 79 | api: string; 80 | cdn: string; 81 | gateway: string; 82 | login?: string; 83 | }; 84 | }[], 85 | ) => { 86 | await I18n.done; 87 | console.warn(json); 88 | for (const instance of json) { 89 | if (instance.display === false) { 90 | continue; 91 | } 92 | const div = document.createElement("div"); 93 | div.classList.add("flexltr", "instance"); 94 | if (instance.image) { 95 | const img = document.createElement("img"); 96 | img.alt = I18n.home.icon(instance.name); 97 | img.src = instance.image; 98 | div.append(img); 99 | } 100 | const statbox = document.createElement("div"); 101 | statbox.classList.add("flexttb", "flexgrow"); 102 | 103 | { 104 | const textbox = document.createElement("div"); 105 | textbox.classList.add("flexttb", "instancetextbox"); 106 | const title = document.createElement("h2"); 107 | title.innerText = instance.name; 108 | if (instance.online !== undefined) { 109 | const status = document.createElement("span"); 110 | status.innerText = instance.online ? "Online" : "Offline"; 111 | status.classList.add("instanceStatus"); 112 | title.append(status); 113 | } 114 | textbox.append(title); 115 | if (instance.description || instance.descriptionLong) { 116 | const p = document.createElement("p"); 117 | if (instance.descriptionLong) { 118 | p.innerText = instance.descriptionLong; 119 | } else if (instance.description) { 120 | p.innerText = instance.description; 121 | } 122 | textbox.append(p); 123 | } 124 | statbox.append(textbox); 125 | } 126 | if (instance.uptime) { 127 | const stats = document.createElement("div"); 128 | stats.classList.add("flexltr"); 129 | const span = document.createElement("span"); 130 | span.innerText = I18n.getTranslation( 131 | "home.uptimeStats", 132 | Math.round(instance.uptime.alltime * 100) + "", 133 | Math.round(instance.uptime.weektime * 100) + "", 134 | Math.round(instance.uptime.daytime * 100) + "", 135 | ); 136 | stats.append(span); 137 | statbox.append(stats); 138 | } 139 | div.append(statbox); 140 | div.onclick = (_) => { 141 | if (instance.online) { 142 | makeRegister(true, instance.name); 143 | } else { 144 | alert(I18n.getTranslation("home.warnOffiline")); 145 | } 146 | }; 147 | serverbox.append(div); 148 | } 149 | }, 150 | ); 151 | -------------------------------------------------------------------------------- /src/webpage/hover.ts: -------------------------------------------------------------------------------- 1 | import {Contextmenu} from "./contextmenu.js"; 2 | import {MarkDown} from "./markdown.js"; 3 | 4 | class Hover { 5 | str: string | MarkDown | (() => Promise | MarkDown | string); 6 | constructor(txt: string | MarkDown | (() => Promise | MarkDown | string)) { 7 | this.str = txt; 8 | } 9 | addEvent(elm: HTMLElement) { 10 | let timeOut = setTimeout(() => {}, 0); 11 | let elm2 = document.createElement("div"); 12 | elm.addEventListener("mouseover", () => { 13 | timeOut = setTimeout(async () => { 14 | elm2 = await this.makeHover(elm); 15 | elm2.addEventListener("mouseover", () => { 16 | elm2.remove(); 17 | }); 18 | }, 300); 19 | }); 20 | elm.addEventListener("mouseout", () => { 21 | clearTimeout(timeOut); 22 | elm2.remove(); 23 | }); 24 | new MutationObserver(function (e) { 25 | if (e[0].removedNodes) { 26 | clearTimeout(timeOut); 27 | elm2.remove(); 28 | } 29 | }).observe(elm, {childList: true}); 30 | } 31 | async makeHover(elm: HTMLElement) { 32 | if (!document.contains(elm)) 33 | return document.createDocumentFragment() as unknown as HTMLDivElement; 34 | const div = document.createElement("div"); 35 | if (this.str instanceof MarkDown) { 36 | div.append(this.str.makeHTML()); 37 | } else if (this.str instanceof Function) { 38 | const hover = await this.str(); 39 | if (hover instanceof MarkDown) { 40 | div.append(hover.makeHTML()); 41 | } else { 42 | div.innerText = hover; 43 | } 44 | } else { 45 | div.innerText = this.str; 46 | } 47 | const box = elm.getBoundingClientRect(); 48 | div.style.top = box.bottom + 4 + "px"; 49 | div.style.left = Math.floor(box.left + box.width / 2) + "px"; 50 | div.classList.add("hoverthing"); 51 | document.body.append(div); 52 | Contextmenu.keepOnScreen(div); 53 | console.log(div, elm); 54 | return div; 55 | } 56 | } 57 | export {Hover}; 58 | -------------------------------------------------------------------------------- /src/webpage/i18n.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import {langs} from "./translations/langs.js"; 3 | const langmap = new Map(); 4 | for (const lang of Object.keys(langs) as string[]) { 5 | langmap.set(lang, langs[lang]); 6 | } 7 | console.log(langs); 8 | type translation = { 9 | [key: string]: string | translation; 10 | }; 11 | let res: () => unknown = () => {}; 12 | class I18n { 13 | static lang: string; 14 | static translations: translation[] = []; 15 | static done = new Promise((res2, _reject) => { 16 | res = res2; 17 | }); 18 | static async create(lang: string) { 19 | const json = (await (await fetch("/translations/" + lang + ".json")).json()) as translation; 20 | const translations: translation[] = []; 21 | translations.push(json); 22 | if (lang !== "en") { 23 | translations.push((await (await fetch("/translations/en.json")).json()) as translation); 24 | } 25 | this.lang = lang; 26 | this.translations = translations; 27 | 28 | res(); 29 | } 30 | static getTranslation(msg: string, ...params: string[]): string { 31 | let str: string | undefined; 32 | const path = msg.split("."); 33 | for (const json of this.translations) { 34 | let jsont: string | translation = json; 35 | for (const thing of path) { 36 | if (typeof jsont !== "string" && jsont !== undefined) { 37 | jsont = jsont[thing]; 38 | } else { 39 | jsont = json; 40 | break; 41 | } 42 | } 43 | 44 | if (typeof jsont === "string") { 45 | str = jsont; 46 | break; 47 | } 48 | } 49 | if (str) { 50 | return this.fillInBlanks(str, params); 51 | } else { 52 | throw new Error(msg + " not found"); 53 | } 54 | } 55 | static fillInBlanks(msg: string, params: string[]): string { 56 | //thanks to geotale for the regex 57 | msg = msg.replace(/\$\d+/g, (match) => { 58 | const number = Number(match.slice(1)); 59 | if (params[number - 1]) { 60 | return params[number - 1]; 61 | } else { 62 | return match; 63 | } 64 | }); 65 | msg = msg.replace(/{{(.+?)}}/g, (str, match: string) => { 66 | const [op, strsSplit] = this.fillInBlanks(match, params).split(":"); 67 | const [first, ...strs] = strsSplit.split("|"); 68 | switch (op.toUpperCase()) { 69 | case "PLURAL": { 70 | const numb = Number(first); 71 | if (numb === 0) { 72 | return strs[strs.length - 1]; 73 | } 74 | return strs[Math.min(strs.length - 1, numb - 1)]; 75 | } 76 | case "GENDER": { 77 | if (first === "male") { 78 | return strs[0]; 79 | } else if (first === "female") { 80 | return strs[1]; 81 | } else if (first === "neutral") { 82 | if (strs[2]) { 83 | return strs[2]; 84 | } else { 85 | return strs[0]; 86 | } 87 | } 88 | } 89 | } 90 | return str; 91 | }); 92 | 93 | return msg; 94 | } 95 | static options() { 96 | return [...langmap.keys()].map((e) => e.replace(".json", "")); 97 | } 98 | static setLanguage(lang: string) { 99 | if (this.options().indexOf(userLocale) !== -1) { 100 | localStorage.setItem("lang", lang); 101 | I18n.create(lang); 102 | } 103 | } 104 | } 105 | console.log(langmap); 106 | let userLocale = navigator.language.slice(0, 2) || "en"; 107 | if (I18n.options().indexOf(userLocale) === -1) { 108 | userLocale = "en"; 109 | } 110 | const storage = localStorage.getItem("lang"); 111 | if (storage) { 112 | userLocale = storage; 113 | } else { 114 | localStorage.setItem("lang", userLocale); 115 | } 116 | I18n.create(userLocale); 117 | function makeWeirdProxy(obj: [string, translation | void] = ["", undefined]) { 118 | return new Proxy(obj, { 119 | get: (target, input) => { 120 | if (target[0] === "" && input in I18n) { 121 | //@ts-ignore 122 | return I18n[input]; 123 | } else if (typeof input === "string") { 124 | let translations = obj[1]; 125 | 126 | if (!translations) { 127 | //Really weird way to make sure I get english lol 128 | translations = I18n.translations[I18n.translations.length - 1]; 129 | obj[1] = translations; 130 | } 131 | if (!translations) { 132 | return; 133 | } 134 | 135 | const value = translations[input]; 136 | if (value) { 137 | let path = obj[0]; 138 | if (path !== "") { 139 | path += "."; 140 | } 141 | path += input; 142 | if (typeof value === "string") { 143 | return (...args: string[]) => { 144 | return I18n.getTranslation(path, ...args); 145 | }; 146 | } else { 147 | return makeWeirdProxy([path, value]); 148 | } 149 | } 150 | } 151 | }, 152 | }); 153 | } 154 | import jsonType from "./../../translations/en.json"; 155 | type beforeType = typeof jsonType; 156 | 157 | type DoTheThing = { 158 | [K in keyof T]: T[K] extends string ? (...args: string[]) => string : DoTheThing; 159 | }; 160 | 161 | const proxyClass = makeWeirdProxy() as unknown as typeof I18n & DoTheThing; 162 | export {proxyClass as I18n, langmap}; 163 | -------------------------------------------------------------------------------- /src/webpage/icons/addfriend.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/announce.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/announcensfw.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/call.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/category.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/channel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/channelnsfw.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/edit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/emoji.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/explore.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/friends.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/frmessage.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/gif.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/hangup.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/intoMenu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/leftArrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/mic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/micmute.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/novideo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/pause.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/pin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/plainx.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/reply.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/rules.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/sad.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/soundMore.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/spoiler.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/sticker.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/stopstream.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/stream.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/unspoiler.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/upload.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/video.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/voice.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/voicensfw.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/icons/x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webpage/instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Spacebar", 4 | "description": "The official Spacebar instance.", 5 | "image": "https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/png/Spacebar__Icon-Discord.png", 6 | "urls": { 7 | "wellknown": "https://spacebar.chat/", 8 | "api": "https://old.server.spacebar.chat/api", 9 | "cdn": "https://cdn.old.server.spacebar.chat", 10 | "gateway": "wss://gateway.old.server.spacebar.chat" 11 | }, 12 | "url": "https://spacebar.chat" 13 | }, 14 | { 15 | "name": "Fastbar", 16 | "description": "The best Spacebar instance with 95% uptime, running under on a NVME drive running with bleeding edge stuff <3", 17 | "image": "https://spacebar.greysilly7.xyz/logo.png", 18 | "url": "https://greysilly7.xyz", 19 | "language": "en", 20 | "country": "US", 21 | "display": true, 22 | "urls": { 23 | "wellknown": "https://greysilly7.xyz", 24 | "api": "https://api-spacebar.greysilly7.xyz/api", 25 | "cdn": "https://cdn-spacebar.greysilly7.xyz", 26 | "gateway": "wss://gateway-spacebar.greysilly7.xyz" 27 | }, 28 | "contactInfo": { 29 | "dicord": "greysilly7", 30 | "github": "https://github.com/greysilly7", 31 | "email": "greysilly7@gmail.com" 32 | } 33 | } 34 | ] -------------------------------------------------------------------------------- /src/webpage/invite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jank Client 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 24 | 25 | 26 |
27 |
28 |
29 |

Server Name

30 |

Someone invited you to Server Name

31 | 32 |
33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/webpage/invite.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from "./i18n.js"; 2 | import {getapiurls} from "./utils/utils.js"; 3 | import {getBulkUsers, Specialuser} from "./utils/utils.js"; 4 | 5 | (async () => { 6 | const users = getBulkUsers(); 7 | const well = new URLSearchParams(window.location.search).get("instance"); 8 | const joinable: Specialuser[] = []; 9 | 10 | for (const key in users.users) { 11 | if (Object.prototype.hasOwnProperty.call(users.users, key)) { 12 | const user: Specialuser = users.users[key]; 13 | if (well && user.serverurls.wellknown.includes(well)) { 14 | joinable.push(user); 15 | } 16 | console.log(user); 17 | } 18 | } 19 | 20 | let urls: {api: string; cdn: string} | undefined; 21 | 22 | if (!joinable.length && well) { 23 | const out = await getapiurls(well); 24 | if (out) { 25 | urls = out; 26 | for (const key in users.users) { 27 | if (Object.prototype.hasOwnProperty.call(users.users, key)) { 28 | const user: Specialuser = users.users[key]; 29 | if (user.serverurls.api.includes(out.api)) { 30 | joinable.push(user); 31 | } 32 | console.log(user); 33 | } 34 | } 35 | } else { 36 | throw new Error("Someone needs to handle the case where the servers don't exist"); 37 | } 38 | } else { 39 | urls = joinable[0].serverurls; 40 | } 41 | await I18n.done; 42 | if (!joinable.length) { 43 | document.getElementById("AcceptInvite")!.textContent = 44 | I18n.getTranslation("htmlPages.noAccount"); 45 | } 46 | 47 | const code = window.location.pathname.split("/")[2]; 48 | let guildinfo: any; 49 | 50 | fetch(`${urls!.api}/invites/${code}`, { 51 | method: "GET", 52 | }) 53 | .then((response) => response.json()) 54 | .then((json) => { 55 | const guildjson = json.guild; 56 | guildinfo = guildjson; 57 | document.getElementById("invitename")!.textContent = guildjson.name; 58 | document.getElementById("invitedescription")!.textContent = I18n.getTranslation( 59 | "invite.longInvitedBy", 60 | json.inviter.username, 61 | guildjson.name, 62 | ); 63 | if (guildjson.icon) { 64 | const img = document.createElement("img"); 65 | img.src = `${urls!.cdn}/icons/${guildjson.id}/${guildjson.icon}.png`; 66 | img.classList.add("inviteGuild"); 67 | document.getElementById("inviteimg")!.append(img); 68 | } else { 69 | const txt = guildjson.name 70 | .replace(/'s /g, " ") 71 | .replace(/\w+/g, (word: any[]) => word[0]) 72 | .replace(/\s/g, ""); 73 | const div = document.createElement("div"); 74 | div.textContent = txt; 75 | div.classList.add("inviteGuild"); 76 | document.getElementById("inviteimg")!.append(div); 77 | } 78 | }); 79 | 80 | function showAccounts(): void { 81 | const table = document.createElement("dialog"); 82 | for (const user of joinable) { 83 | console.log(user.pfpsrc); 84 | 85 | const userinfo = document.createElement("div"); 86 | userinfo.classList.add("flexltr", "switchtable"); 87 | 88 | const pfp = document.createElement("img"); 89 | pfp.src = user.pfpsrc; 90 | pfp.classList.add("pfp"); 91 | userinfo.append(pfp); 92 | 93 | const userDiv = document.createElement("div"); 94 | userDiv.classList.add("userinfo"); 95 | userDiv.textContent = user.username; 96 | userDiv.append(document.createElement("br")); 97 | 98 | const span = document.createElement("span"); 99 | span.textContent = user.serverurls.wellknown.replace("https://", "").replace("http://", ""); 100 | span.classList.add("serverURL"); 101 | userDiv.append(span); 102 | 103 | userinfo.append(userDiv); 104 | table.append(userinfo); 105 | 106 | userinfo.addEventListener("click", () => { 107 | console.log(user); 108 | fetch(`${urls!.api}/invites/${code}`, { 109 | method: "POST", 110 | headers: { 111 | Authorization: user.token, 112 | }, 113 | }).then(() => { 114 | users.currentuser = user.uid; 115 | sessionStorage.setItem("currentuser", user.uid); 116 | localStorage.setItem("userinfos", JSON.stringify(users)); 117 | window.location.href = "/channels/" + guildinfo.id; 118 | }); 119 | }); 120 | } 121 | 122 | const td = document.createElement("div"); 123 | td.classList.add("switchtable"); 124 | td.textContent = I18n.getTranslation("invite.loginOrCreateAccount"); 125 | td.addEventListener("click", () => { 126 | const l = new URLSearchParams("?"); 127 | l.set("goback", window.location.href); 128 | l.set("instance", well!); 129 | window.location.href = "/login?" + l.toString(); 130 | }); 131 | 132 | if (!joinable.length) { 133 | const l = new URLSearchParams("?"); 134 | l.set("goback", window.location.href); 135 | l.set("instance", well!); 136 | window.location.href = "/login?" + l.toString(); 137 | } 138 | 139 | table.append(td); 140 | table.classList.add("flexttb", "accountSwitcher"); 141 | console.log(table); 142 | document.body.append(table); 143 | } 144 | 145 | document.getElementById("AcceptInvite")!.addEventListener("click", showAccounts); 146 | })(); 147 | -------------------------------------------------------------------------------- /src/webpage/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jank Client 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/webpage/login.ts: -------------------------------------------------------------------------------- 1 | import {instanceinfo, adduser} from "./utils/utils.js"; 2 | import {I18n} from "./i18n.js"; 3 | import {Dialog} from "./settings.js"; 4 | import {makeRegister} from "./register.js"; 5 | function generateRecArea(recover = document.getElementById("recover")) { 6 | if (!recover) return; 7 | recover.innerHTML = ""; 8 | const can = localStorage.getItem("canRecover"); 9 | if (can) { 10 | const a = document.createElement("a"); 11 | a.textContent = I18n.login.recover(); 12 | a.href = "/reset" + window.location.search; 13 | recover.append(a); 14 | } 15 | } 16 | const recMap = new Map>(); 17 | async function recover(e: instanceinfo, recover = document.getElementById("recover")) { 18 | const prom = new Promise(async (res) => { 19 | if (!recover) { 20 | res(false); 21 | return; 22 | } 23 | recover.innerHTML = ""; 24 | try { 25 | if (!(await recMap.get(e.api))) { 26 | if (recMap.has(e.api)) { 27 | throw Error("can't recover"); 28 | } 29 | recMap.set(e.api, prom); 30 | const json = (await (await fetch(e.api + "/policies/instance/config")).json()) as { 31 | can_recover_account: boolean; 32 | }; 33 | if (!json || !json.can_recover_account) throw Error("can't recover account"); 34 | } 35 | res(true); 36 | localStorage.setItem("canRecover", "true"); 37 | generateRecArea(recover); 38 | } catch { 39 | res(false); 40 | localStorage.removeItem("canRecover"); 41 | generateRecArea(recover); 42 | } finally { 43 | res(false); 44 | } 45 | }); 46 | } 47 | 48 | export async function makeLogin(trasparentBg = false, instance = "") { 49 | const dialog = new Dialog(""); 50 | const opt = dialog.options; 51 | opt.addTitle(I18n.login.login()); 52 | const picker = opt.addInstancePicker( 53 | (info) => { 54 | const api = info.login + (info.login.startsWith("/") ? "/" : ""); 55 | form.fetchURL = api + "/auth/login"; 56 | recover(info, rec); 57 | }, 58 | { 59 | instance, 60 | }, 61 | ); 62 | dialog.show(trasparentBg); 63 | 64 | const form = opt.addForm( 65 | "", 66 | (res) => { 67 | if ("token" in res && typeof res.token == "string") { 68 | adduser({ 69 | serverurls: JSON.parse(localStorage.getItem("instanceinfo") as string), 70 | email: email.value, 71 | token: res.token, 72 | }).username = email.value; 73 | const redir = new URLSearchParams(window.location.search).get("goback"); 74 | if (redir) { 75 | window.location.href = redir; 76 | } else { 77 | window.location.href = "/channels/@me"; 78 | } 79 | } 80 | }, 81 | { 82 | submitText: I18n.login.login(), 83 | method: "POST", 84 | headers: { 85 | "Content-type": "application/json; charset=UTF-8", 86 | }, 87 | vsmaller: true, 88 | }, 89 | ); 90 | const button = form.button.deref(); 91 | picker.giveButton(button); 92 | button?.classList.add("createAccount"); 93 | 94 | const email = form.addTextInput(I18n.htmlPages.emailField(), "login"); 95 | form.addTextInput(I18n.htmlPages.pwField(), "password", {password: true}); 96 | form.addCaptcha(); 97 | const a = document.createElement("a"); 98 | a.onclick = () => { 99 | dialog.hide(); 100 | makeRegister(trasparentBg); 101 | }; 102 | a.textContent = I18n.htmlPages.noAccount(); 103 | const rec = document.createElement("div"); 104 | form.addHTMLArea(rec); 105 | form.addHTMLArea(a); 106 | } 107 | await I18n.done; 108 | if (window.location.pathname.startsWith("/login")) { 109 | makeLogin(); 110 | } 111 | -------------------------------------------------------------------------------- /src/webpage/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/webpage/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MathMan05/JankClient/ec57d5304cd6f28d83c3626e42afd7f27d459430/src/webpage/logo.webp -------------------------------------------------------------------------------- /src/webpage/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Jank Client", 3 | "icons": [ 4 | { 5 | "src": "/logo.webp", 6 | "sizes": "512x512" 7 | } 8 | ], 9 | "start_url": "/channels/@me", 10 | "display": "standalone", 11 | "scope": "/", 12 | "theme_color": "#05050a", 13 | "description": "Welcome to the Jank Client, a spacebar FOSS client implementation", 14 | "background_color": "#05050a", 15 | "offline_enabled": true 16 | } 17 | -------------------------------------------------------------------------------- /src/webpage/oauth2/authorize.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jank Client 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 |
26 |
27 | 28 |

Bot Name

29 |

Add Bot

30 |

This will allow the bot to:

31 | 32 |
33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/webpage/permissions.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from "./i18n.js"; 2 | 3 | class Permissions { 4 | allow: bigint; 5 | deny: bigint; 6 | readonly hasDeny: boolean; 7 | constructor(allow: string, deny: string = "") { 8 | this.hasDeny = Boolean(deny); 9 | try { 10 | this.allow = BigInt(allow); 11 | this.deny = BigInt(deny); 12 | } catch { 13 | this.allow = 0n; 14 | this.deny = 0n; 15 | console.error( 16 | `Something really stupid happened with a permission with allow being ${allow} and deny being, ${deny}, execution will still happen, but something really stupid happened, please report if you know what caused this.`, 17 | ); 18 | } 19 | } 20 | getPermissionbit(b: number, big: bigint): boolean { 21 | return Boolean((big >> BigInt(b)) & 1n); 22 | } 23 | setPermissionbit(b: number, state: boolean, big: bigint): bigint { 24 | const bit = 1n << BigInt(b); 25 | return (big & ~bit) | (BigInt(state) << BigInt(b)); //thanks to geotale for this code :3 26 | } 27 | //private static info: { name: string; readableName: string; description: string }[]; 28 | static *info(): Generator<{name: string; readableName: string; description: string}> { 29 | for (const thing of this.permisions) { 30 | yield { 31 | name: thing, 32 | readableName: I18n.getTranslation("permissions.readableNames." + thing), 33 | description: I18n.getTranslation("permissions.descriptions." + thing), 34 | }; 35 | } 36 | } 37 | static permisions = [ 38 | "CREATE_INSTANT_INVITE", 39 | "KICK_MEMBERS", 40 | "BAN_MEMBERS", 41 | "ADMINISTRATOR", 42 | "MANAGE_CHANNELS", 43 | "MANAGE_GUILD", 44 | "ADD_REACTIONS", 45 | "VIEW_AUDIT_LOG", 46 | "PRIORITY_SPEAKER", 47 | "STREAM", 48 | "VIEW_CHANNEL", 49 | "SEND_MESSAGES", 50 | "SEND_TTS_MESSAGES", 51 | "MANAGE_MESSAGES", 52 | "EMBED_LINKS", 53 | "ATTACH_FILES", 54 | "READ_MESSAGE_HISTORY", 55 | "MENTION_EVERYONE", 56 | "USE_EXTERNAL_EMOJIS", 57 | "VIEW_GUILD_INSIGHTS", 58 | "CONNECT", 59 | "SPEAK", 60 | "MUTE_MEMBERS", 61 | "DEAFEN_MEMBERS", 62 | "MOVE_MEMBERS", 63 | "USE_VAD", 64 | "CHANGE_NICKNAME", 65 | "MANAGE_NICKNAMES", 66 | "MANAGE_ROLES", 67 | "MANAGE_WEBHOOKS", 68 | "MANAGE_GUILD_EXPRESSIONS", 69 | "USE_APPLICATION_COMMANDS", 70 | "REQUEST_TO_SPEAK", 71 | "MANAGE_EVENTS", 72 | "MANAGE_THREADS", 73 | "CREATE_PUBLIC_THREADS", 74 | "CREATE_PRIVATE_THREADS", 75 | "USE_EXTERNAL_STICKERS", 76 | "SEND_MESSAGES_IN_THREADS", 77 | "USE_EMBEDDED_ACTIVITIES", 78 | "MODERATE_MEMBERS", 79 | "VIEW_CREATOR_MONETIZATION_ANALYTICS", 80 | "USE_SOUNDBOARD", 81 | "CREATE_GUILD_EXPRESSIONS", 82 | "CREATE_EVENTS", 83 | "USE_EXTERNAL_SOUNDS", 84 | "SEND_VOICE_MESSAGES", 85 | "SEND_POLLS", 86 | "USE_EXTERNAL_APPS", 87 | ]; 88 | getPermission(name: string): number { 89 | if (undefined === Permissions.permisions.indexOf(name)) { 90 | console.error(name + " is not found in map", Permissions.permisions); 91 | } 92 | if (this.getPermissionbit(Permissions.permisions.indexOf(name), this.allow)) { 93 | return 1; 94 | } else if (this.getPermissionbit(Permissions.permisions.indexOf(name), this.deny)) { 95 | return -1; 96 | } else { 97 | return 0; 98 | } 99 | } 100 | hasPermission(name: string, adminOverride = true): boolean { 101 | if (this.deny) { 102 | console.warn( 103 | "This function may of been used in error, think about using getPermision instead", 104 | ); 105 | } 106 | if (this.getPermissionbit(Permissions.permisions.indexOf(name), this.allow)) return true; 107 | if (name !== "ADMINISTRATOR" && adminOverride) return this.hasPermission("ADMINISTRATOR"); 108 | return false; 109 | } 110 | setPermission(name: string, setto: number): void { 111 | const bit = Permissions.permisions.indexOf(name); 112 | if (bit === undefined) { 113 | return console.error( 114 | "Tried to set permission to " + setto + " for " + name + " but it doesn't exist", 115 | ); 116 | } 117 | 118 | if (setto === 0) { 119 | this.deny = this.setPermissionbit(bit, false, this.deny); 120 | this.allow = this.setPermissionbit(bit, false, this.allow); 121 | } else if (setto === 1) { 122 | this.deny = this.setPermissionbit(bit, false, this.deny); 123 | this.allow = this.setPermissionbit(bit, true, this.allow); 124 | } else if (setto === -1) { 125 | this.deny = this.setPermissionbit(bit, true, this.deny); 126 | this.allow = this.setPermissionbit(bit, false, this.allow); 127 | } else { 128 | console.error("invalid number entered:" + setto); 129 | } 130 | } 131 | } 132 | export {Permissions}; 133 | -------------------------------------------------------------------------------- /src/webpage/recover.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from "./i18n.js"; 2 | import {Dialog, FormError} from "./settings.js"; 3 | await I18n.done; 4 | const info = JSON.parse(localStorage.getItem("instanceinfo") as string); 5 | 6 | function makeMenu2(email: string | void) { 7 | const d2 = new Dialog(I18n.login.recovery()); 8 | const headers = { 9 | "Content-Type": "application/json", 10 | }; 11 | const opt = d2.float.options.addForm( 12 | "", 13 | async (obj) => { 14 | if ("token" in obj && typeof obj.token === "string") { 15 | window.location.href = "/login" + window.location.search; 16 | } 17 | }, 18 | { 19 | fetchURL: info.api + "/auth/reset", 20 | method: "POST", 21 | headers, 22 | }, 23 | ); 24 | if (email !== undefined) { 25 | opt.addTextInput(I18n.login.pasteInfo(), "token"); 26 | } 27 | opt.addTextInput(I18n.login.newPassword(), "password", {password: true}); 28 | const p2 = opt.addTextInput(I18n.login.enterPAgain(), "password2", {password: true}); 29 | opt.addPreprocessor((e) => { 30 | const obj = e as unknown as {password: string; password2?: string; token?: string}; 31 | const token = obj.token || window.location.href; 32 | if (URL.canParse(token)) { 33 | obj.token = new URLSearchParams(token.split("#")[1]).get("token") as string; 34 | } 35 | 36 | if (obj.password !== obj.password2) { 37 | throw new FormError(p2, I18n.localuser.PasswordsNoMatch()); 38 | } 39 | delete obj.password2; 40 | }); 41 | d2.show(false); 42 | } 43 | function makeMenu1() { 44 | const d = new Dialog(I18n.login.recovery()); 45 | let area: HTMLElement | undefined = undefined; 46 | const opt = d.float.options.addForm( 47 | "", 48 | (e) => { 49 | if (Object.keys(e).length === 0) { 50 | d.hide(); 51 | makeMenu2(email.value); 52 | } else if ("captcha_sitekey" in e && typeof e.captcha_sitekey === "string") { 53 | if (area) { 54 | eval("hcaptcha.reset()"); 55 | } else { 56 | area = document.createElement("div"); 57 | opt.addHTMLArea(area); 58 | const capty = document.createElement("div"); 59 | capty.classList.add("h-captcha"); 60 | 61 | capty.setAttribute("data-sitekey", e.captcha_sitekey); 62 | const script = document.createElement("script"); 63 | script.src = "https://js.hcaptcha.com/1/api.js"; 64 | area.append(script); 65 | area.append(capty); 66 | } 67 | } 68 | }, 69 | { 70 | fetchURL: info.api + "/auth/forgot", 71 | method: "POST", 72 | headers: { 73 | "Content-Type": "application/json", 74 | }, 75 | }, 76 | ); 77 | const email = opt.addTextInput(I18n.htmlPages.emailField(), "login"); 78 | opt.addPreprocessor((e) => { 79 | if (area) { 80 | try { 81 | //@ts-expect-error 82 | e.captcha_key = area.children[1].children[1].value; 83 | } catch (e) { 84 | console.error(e); 85 | } 86 | } 87 | }); 88 | d.show(false); 89 | } 90 | if ( 91 | window.location.href.split("#").length == 2 && 92 | new URLSearchParams(window.location.href.split("#")[1]).has("token") 93 | ) { 94 | makeMenu2(); 95 | } else { 96 | makeMenu1(); 97 | } 98 | -------------------------------------------------------------------------------- /src/webpage/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jank Client 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/webpage/register.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from "./i18n.js"; 2 | import {adduser} from "./utils/utils.js"; 3 | import {makeLogin} from "./login.js"; 4 | import {MarkDown} from "./markdown.js"; 5 | import {Dialog, FormError} from "./settings.js"; 6 | export async function makeRegister(trasparentBg = false, instance = "") { 7 | const dialog = new Dialog(""); 8 | const opt = dialog.options; 9 | opt.addTitle(I18n.htmlPages.createAccount()); 10 | const picker = opt.addInstancePicker( 11 | (info) => { 12 | const api = info.login + (info.login.startsWith("/") ? "/" : ""); 13 | form.fetchURL = api + "/auth/register"; 14 | tosLogic(md); 15 | }, 16 | {instance}, 17 | ); 18 | dialog.show(trasparentBg); 19 | 20 | const form = opt.addForm( 21 | "", 22 | (res) => { 23 | if ("token" in res && typeof res.token == "string") { 24 | adduser({ 25 | serverurls: JSON.parse(localStorage.getItem("instanceinfo") as string), 26 | email: email.value, 27 | token: res.token, 28 | }).username = user.value; 29 | const redir = new URLSearchParams(window.location.search).get("goback"); 30 | if (redir) { 31 | window.location.href = redir; 32 | } else { 33 | window.location.href = "/channels/@me"; 34 | } 35 | } 36 | }, 37 | { 38 | submitText: I18n.htmlPages.createAccount(), 39 | method: "POST", 40 | headers: { 41 | "Content-type": "application/json; charset=UTF-8", 42 | Referrer: window.location.href, 43 | }, 44 | vsmaller: true, 45 | }, 46 | ); 47 | const button = form.button.deref(); 48 | picker.giveButton(button); 49 | button?.classList.add("createAccount"); 50 | 51 | const email = form.addTextInput(I18n.htmlPages.emailField(), "email"); 52 | const user = form.addTextInput(I18n.htmlPages.userField(), "username"); 53 | const p1 = form.addTextInput(I18n.htmlPages.pwField(), "password", {password: true}); 54 | const p2 = form.addTextInput(I18n.htmlPages.pw2Field(), "password2", {password: true}); 55 | form.addDateInput(I18n.htmlPages.dobField(), "date_of_birth"); 56 | form.addPreprocessor((e) => { 57 | if (p1.value !== p2.value) { 58 | throw new FormError(p2, I18n.localuser.PasswordsNoMatch()); 59 | } 60 | //@ts-expect-error it's there 61 | delete e.password2; 62 | if (!check.checked) throw new FormError(checkbox, I18n.register.tos()); 63 | //@ts-expect-error it's there 64 | e.consent = check.checked; 65 | }); 66 | const toshtml = document.createElement("div"); 67 | const md = document.createElement("span"); 68 | const check = document.createElement("input"); 69 | check.type = "checkbox"; 70 | 71 | toshtml.append(md, check); 72 | const checkbox = form.addHTMLArea(toshtml); 73 | form.addCaptcha(); 74 | const a = document.createElement("a"); 75 | a.onclick = () => { 76 | dialog.hide(); 77 | makeLogin(trasparentBg); 78 | }; 79 | a.textContent = I18n.htmlPages.alreadyHave(); 80 | form.addHTMLArea(a); 81 | } 82 | async function tosLogic(box: HTMLElement) { 83 | const instanceInfo = JSON.parse(localStorage.getItem("instanceinfo") ?? "{}"); 84 | const apiurl = new URL(instanceInfo.api); 85 | const urlstr = apiurl.toString(); 86 | const response = await fetch(urlstr + (urlstr.endsWith("/") ? "" : "/") + "ping"); 87 | const data = await response.json(); 88 | const tosPage = data.instance.tosPage; 89 | if (!box) return; 90 | if (tosPage) { 91 | box.innerHTML = ""; 92 | box.append(new MarkDown(I18n.getTranslation("register.agreeTOS", tosPage)).makeHTML()); 93 | } else { 94 | box.textContent = I18n.getTranslation("register.noTOS"); 95 | } 96 | console.log(tosPage); 97 | } 98 | if (window.location.pathname.startsWith("/register")) { 99 | await I18n.done; 100 | makeRegister(); 101 | } 102 | -------------------------------------------------------------------------------- /src/webpage/reset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jank Client 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/webpage/rights.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from "./i18n.js"; 2 | 3 | class Rights { 4 | allow!: bigint; 5 | constructor(allow: string | number) { 6 | this.update(allow); 7 | } 8 | update(allow: string | number) { 9 | try { 10 | this.allow = BigInt(allow); 11 | } catch { 12 | this.allow = 875069521787904n; 13 | console.error( 14 | `Something really stupid happened with a permission with allow being ${allow}, execution will still happen, but something really stupid happened, please report if you know what caused this.`, 15 | ); 16 | } 17 | } 18 | getPermissionbit(b: number, big: bigint): boolean { 19 | return Boolean((big >> BigInt(b)) & 1n); 20 | } 21 | setPermissionbit(b: number, state: boolean, big: bigint): bigint { 22 | const bit = 1n << BigInt(b); 23 | return (big & ~bit) | (BigInt(state) << BigInt(b)); //thanks to geotale for this code :3 24 | } 25 | static *info(): Generator<{name: string; readableName: string; description: string}> { 26 | throw new Error("Isn't implemented"); 27 | for (const thing of this.permisions) { 28 | yield { 29 | name: thing, 30 | readableName: I18n.getTranslation("permissions.readableNames." + thing), 31 | description: I18n.getTranslation("permissions.descriptions." + thing), 32 | }; 33 | } 34 | } 35 | static readonly permisions = [ 36 | "OPERATOR", 37 | "MANAGE_APPLICATIONS", 38 | "MANAGE_GUILDS", 39 | "MANAGE_MESSAGES", 40 | "MANAGE_RATE_LIMITS", 41 | "MANAGE_ROUTING", 42 | "MANAGE_TICKETS", 43 | "MANAGE_USERS", 44 | "ADD_MEMBERS", 45 | "BYPASS_RATE_LIMITS", 46 | "CREATE_APPLICATIONS", 47 | "CREATE_CHANNELS", 48 | "CREATE_DMS", 49 | "CREATE_DM_GROUPS", 50 | "CREATE_GUILDS", 51 | "CREATE_INVITES", 52 | "CREATE_ROLES", 53 | "CREATE_TEMPLATES", 54 | "CREATE_WEBHOOKS", 55 | "JOIN_GUILDS", 56 | "PIN_MESSAGES", 57 | "SELF_ADD_REACTIONS", 58 | "SELF_DELETE_MESSAGES", 59 | "SELF_EDIT_MESSAGES", 60 | "SELF_EDIT_NAME", 61 | "SEND_MESSAGES", 62 | "USE_ACTIVITIES", 63 | "USE_VIDEO", 64 | "USE_VOICE", 65 | "INVITE_USERS", 66 | "SELF_DELETE_DISABLE", 67 | "DEBTABLE", 68 | "CREDITABLE", 69 | "KICK_BAN_MEMBERS", 70 | "SELF_LEAVE_GROUPS", 71 | "PRESENCE", 72 | "SELF_ADD_DISCOVERABLE", 73 | "MANAGE_GUILD_DIRECTORY", 74 | "POGGERS", 75 | "USE_ACHIEVEMENTS", 76 | "INITIATE_INTERACTIONS", 77 | "RESPOND_TO_INTERACTIONS", 78 | "SEND_BACKDATED_EVENTS", 79 | "USE_MASS_INVITES", 80 | "ACCEPT_INVITES", 81 | "SELF_EDIT_FLAGS", 82 | "EDIT_FLAGS", 83 | "MANAGE_GROUPS", 84 | "VIEW_SERVER_STATS", 85 | "RESEND_VERIFICATION_EMAIL", 86 | "CREATE_REGISTRATION_TOKENS", 87 | ]; 88 | getPermission(name: string): boolean { 89 | if (undefined === Rights.permisions.indexOf(name)) { 90 | console.error(name + " is not found in map", Rights.permisions); 91 | } 92 | return this.getPermissionbit(Rights.permisions.indexOf(name), this.allow); 93 | } 94 | hasPermission(name: string, adminOverride = true): boolean { 95 | if (this.getPermissionbit(Rights.permisions.indexOf(name), this.allow)) return true; 96 | if (name !== "OPERATOR" && adminOverride) return this.hasPermission("OPERATOR"); 97 | return false; 98 | } 99 | setPermission(name: string, setto: number): void { 100 | const bit = Rights.permisions.indexOf(name); 101 | if (bit === undefined) { 102 | return console.error( 103 | "Tried to set permission to " + setto + " for " + name + " but it doesn't exist", 104 | ); 105 | } 106 | 107 | if (setto === 0) { 108 | this.allow = this.setPermissionbit(bit, false, this.allow); 109 | } else if (setto === 1) { 110 | this.allow = this.setPermissionbit(bit, true, this.allow); 111 | } else { 112 | console.error("invalid number entered:" + setto); 113 | } 114 | } 115 | } 116 | export {Rights}; 117 | -------------------------------------------------------------------------------- /src/webpage/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /channel/ 3 | Allow: /invite/ 4 | Allow: /oauth2/ 5 | Allow: /login 6 | Allow: /register 7 | Allow: / 8 | -------------------------------------------------------------------------------- /src/webpage/search.ts: -------------------------------------------------------------------------------- 1 | import {Contextmenu} from "./contextmenu.js"; 2 | 3 | class Search { 4 | options: Map; 5 | readonly keys: string[]; 6 | constructor(options: [E, string[]][]) { 7 | const map = options.flatMap((e) => { 8 | const val = e[1].map((f) => [f, e[0]]); 9 | return val as [string, E][]; 10 | }); 11 | this.options = new Map(map); 12 | this.keys = [...this.options.keys()]; 13 | } 14 | generateList(str: string, max: number, res: (e: E) => void) { 15 | str = str.toLowerCase(); 16 | const options = this.keys.filter((e) => { 17 | return e.toLowerCase().includes(str); 18 | }); 19 | const div = document.createElement("div"); 20 | div.classList.add("OptionList", "flexttb"); 21 | for (const option of options.slice(0, max)) { 22 | const hoption = document.createElement("span"); 23 | hoption.textContent = option; 24 | hoption.onclick = () => { 25 | if (!this.options.has(option)) return; 26 | res(this.options.get(option) as E); 27 | }; 28 | div.append(hoption); 29 | } 30 | return div; 31 | } 32 | async find(x: number, y: number, max = 4): Promise { 33 | return new Promise((res) => { 34 | const container = document.createElement("div"); 35 | container.classList.add("fixedsearch"); 36 | console.log((x ^ 0) + "", (y ^ 0) + ""); 37 | container.style.left = (x ^ 0) + "px"; 38 | container.style.top = (y ^ 0) + "px"; 39 | const remove = container.remove; 40 | container.remove = () => { 41 | remove.call(container); 42 | res(undefined); 43 | }; 44 | 45 | function resolve(e: E) { 46 | res(e); 47 | container.remove(); 48 | } 49 | const bar = document.createElement("input"); 50 | const options = document.createElement("div"); 51 | const keydown = () => { 52 | const html = this.generateList(bar.value, max, resolve); 53 | options.innerHTML = ""; 54 | options.append(html); 55 | }; 56 | bar.oninput = keydown; 57 | keydown(); 58 | bar.type = "text"; 59 | container.append(bar); 60 | container.append(options); 61 | document.body.append(container); 62 | if (Contextmenu.currentmenu != "") { 63 | Contextmenu.currentmenu.remove(); 64 | } 65 | Contextmenu.currentmenu = container; 66 | Contextmenu.keepOnScreen(container); 67 | }); 68 | } 69 | } 70 | export {Search}; 71 | -------------------------------------------------------------------------------- /src/webpage/service.ts: -------------------------------------------------------------------------------- 1 | function deleteoldcache() { 2 | caches.delete("cache"); 3 | console.log("this ran :P"); 4 | } 5 | 6 | async function putInCache(request: URL | RequestInfo, response: Response) { 7 | console.log(request, response); 8 | const cache = await caches.open("cache"); 9 | console.log("Grabbed"); 10 | try { 11 | console.log(await cache.put(request, response)); 12 | } catch (error) { 13 | console.error(error); 14 | } 15 | } 16 | 17 | let lastcache: string; 18 | self.addEventListener("activate", async () => { 19 | console.log("Service Worker activated"); 20 | checkCache(); 21 | }); 22 | 23 | async function checkCache() { 24 | if (checkedrecently) { 25 | return; 26 | } 27 | const promise = await caches.match("/getupdates"); 28 | if (promise) { 29 | lastcache = await promise.text(); 30 | } 31 | console.log(lastcache); 32 | fetch("/getupdates").then(async (data) => { 33 | setTimeout( 34 | (_: any) => { 35 | checkedrecently = false; 36 | }, 37 | 1000 * 60 * 30, 38 | ); 39 | if (!data.ok) return; 40 | const text = await data.clone().text(); 41 | console.log(text, lastcache); 42 | if (lastcache !== text) { 43 | deleteoldcache(); 44 | putInCache("/getupdates", data); 45 | self.close(); 46 | } 47 | checkedrecently = true; 48 | }); 49 | } 50 | var checkedrecently = false; 51 | 52 | function samedomain(url: string | URL) { 53 | return new URL(url).origin === self.origin; 54 | } 55 | 56 | let enabled = "false"; 57 | let offline = false; 58 | 59 | const htmlFiles = new Set(["/app", "/login", "/home", "/register", "/oauth2/auth", "/reset"]); 60 | function isHtml(url: string): string | void { 61 | const path = new URL(url).pathname; 62 | if (htmlFiles.has(path) || htmlFiles.has(path + ".html")) { 63 | return path + path.endsWith(".html") ? "" : ".html"; 64 | } 65 | } 66 | function toPath(url: string): string { 67 | const Url = new URL(url); 68 | let html = isHtml(url); 69 | if (!html) { 70 | const path = Url.pathname; 71 | if (path.startsWith("/channels")) { 72 | html = "./app.html"; 73 | } else if (path.startsWith("/invite/") || path === "/invite") { 74 | html = "./invite.html"; 75 | } else if (path.startsWith("/template/") || path === "/template") { 76 | html = "./template.html"; 77 | } else if (path === "/") { 78 | html = "./home.html"; 79 | } 80 | } 81 | return html || Url.pathname; 82 | } 83 | let fails = 0; 84 | async function getfile(event: FetchEvent): Promise { 85 | checkCache(); 86 | if ( 87 | !samedomain(event.request.url) || 88 | enabled === "false" || 89 | (enabled === "offlineOnly" && !offline) 90 | ) { 91 | const responce = await fetch(event.request.clone()); 92 | if (samedomain(event.request.url)) { 93 | if (enabled === "offlineOnly" && responce.ok) { 94 | putInCache(toPath(event.request.url), responce.clone()); 95 | } 96 | if (!responce.ok) { 97 | fails++; 98 | if (fails > 5) { 99 | offline = true; 100 | } 101 | } 102 | } 103 | return responce; 104 | } 105 | 106 | let path = toPath(event.request.url); 107 | if (path === "/instances.json") { 108 | return await fetch(path); 109 | } 110 | console.log("Getting path: " + path); 111 | const responseFromCache = await caches.match(path); 112 | if (responseFromCache) { 113 | console.log("cache hit"); 114 | return responseFromCache; 115 | } 116 | try { 117 | const responseFromNetwork = await fetch(path); 118 | if (responseFromNetwork.ok) { 119 | await putInCache(path, responseFromNetwork.clone()); 120 | } 121 | return responseFromNetwork; 122 | } catch (e) { 123 | console.error(e); 124 | return new Response(null); 125 | } 126 | } 127 | 128 | self.addEventListener("fetch", (e) => { 129 | const event = e as FetchEvent; 130 | if (event.request.method === "POST") { 131 | return; 132 | } 133 | try { 134 | event.respondWith(getfile(event)); 135 | } catch (e) { 136 | console.error(e); 137 | } 138 | }); 139 | 140 | self.addEventListener("message", (message) => { 141 | const data = message.data; 142 | switch (data.code) { 143 | case "setMode": 144 | enabled = data.data; 145 | break; 146 | case "CheckUpdate": 147 | checkedrecently = false; 148 | checkCache(); 149 | break; 150 | case "ForceClear": 151 | deleteoldcache(); 152 | break; 153 | } 154 | }); 155 | -------------------------------------------------------------------------------- /src/webpage/snowflake.ts: -------------------------------------------------------------------------------- 1 | abstract class SnowFlake { 2 | public readonly id: string; 3 | constructor(id: string) { 4 | this.id = id; 5 | } 6 | getUnixTime(): number { 7 | return SnowFlake.stringToUnixTime(this.id); 8 | } 9 | static stringToUnixTime(str: string) { 10 | try { 11 | return Number((BigInt(str) >> 22n) + 1420070400000n); 12 | } catch { 13 | throw new Error(`The ID is corrupted, it's ${str} when it should be some number.`); 14 | } 15 | } 16 | } 17 | export {SnowFlake}; 18 | -------------------------------------------------------------------------------- /src/webpage/sticker.ts: -------------------------------------------------------------------------------- 1 | import {Contextmenu} from "./contextmenu.js"; 2 | import {Guild} from "./guild.js"; 3 | import {Hover} from "./hover.js"; 4 | import {stickerJson} from "./jsontypes.js"; 5 | import {Localuser} from "./localuser.js"; 6 | import {SnowFlake} from "./snowflake.js"; 7 | import {createImg} from "./utils/utils.js"; 8 | 9 | class Sticker extends SnowFlake { 10 | name: string; 11 | type: number; 12 | format_type: number; 13 | owner: Guild | Localuser; 14 | description: string; 15 | tags: string; 16 | get guild() { 17 | return this.owner; 18 | } 19 | get localuser() { 20 | if (this.owner instanceof Localuser) { 21 | return this.owner; 22 | } 23 | return this.owner.localuser; 24 | } 25 | constructor(json: stickerJson, owner: Guild | Localuser) { 26 | super(json.id); 27 | this.name = json.name; 28 | this.type = json.type; 29 | this.format_type = json.format_type; 30 | this.owner = owner; 31 | this.tags = json.tags; 32 | this.description = json.description || ""; 33 | } 34 | getHTML(): HTMLElement { 35 | const img = createImg( 36 | this.owner.info.cdn + "/stickers/" + this.id + ".webp?size=160&quality=lossless", 37 | ); 38 | img.classList.add("sticker"); 39 | const hover = new Hover(this.name); 40 | hover.addEvent(img); 41 | img.alt = this.description; 42 | return img; 43 | } 44 | static searchStickers(search: string, localuser: Localuser, results = 50): [Sticker, number][] { 45 | //NOTE this function is used for searching in the emoji picker for reactions, and the emoji auto-fill 46 | const ranked: [Sticker, number][] = []; 47 | function similar(json: Sticker) { 48 | if (json.name.includes(search)) { 49 | ranked.push([json, search.length / json.name.length]); 50 | return true; 51 | } else if (json.name.toLowerCase().includes(search.toLowerCase())) { 52 | ranked.push([json, search.length / json.name.length / 1.4]); 53 | return true; 54 | } else { 55 | return false; 56 | } 57 | } 58 | const weakGuild = new WeakMap(); 59 | for (const guild of localuser.guilds) { 60 | if (guild.id !== "@me" && guild.stickers.length !== 0) { 61 | for (const sticker of guild.stickers) { 62 | if (similar(sticker)) { 63 | weakGuild.set(sticker, guild); 64 | } 65 | } 66 | } 67 | } 68 | ranked.sort((a, b) => b[1] - a[1]); 69 | return ranked.splice(0, results).map((a) => { 70 | return a; 71 | }); 72 | } 73 | static getFromId(id: string, localuser: Localuser) { 74 | for (const guild of localuser.guilds) { 75 | const stick = guild.stickers.find((_) => _.id === id); 76 | if (stick) { 77 | return stick; 78 | } 79 | } 80 | return undefined; 81 | } 82 | static async stickerPicker(x: number, y: number, localuser: Localuser): Promise { 83 | let res: (r: Sticker) => void; 84 | this; 85 | const promise: Promise = new Promise((r) => { 86 | res = r; 87 | }); 88 | const menu = document.createElement("div"); 89 | menu.classList.add("flexttb", "stickerPicker"); 90 | if (y > 0) { 91 | menu.style.top = y + "px"; 92 | } else { 93 | menu.style.bottom = y * -1 + "px"; 94 | } 95 | if (x > 0) { 96 | menu.style.left = x + "px"; 97 | } else { 98 | menu.style.right = x * -1 + "px"; 99 | } 100 | 101 | const topBar = document.createElement("div"); 102 | topBar.classList.add("flexltr", "emojiHeading"); 103 | const guilds = [ 104 | localuser.lookingguild, 105 | ...localuser.guilds.filter((guild) => guild !== localuser.lookingguild), 106 | ] 107 | .filter((guild) => guild !== undefined) 108 | .filter((guild) => guild.id != "@me" && guild.stickers.length > 0); 109 | const title = document.createElement("h2"); 110 | title.textContent = guilds[0].properties.name; 111 | title.classList.add("emojiTitle"); 112 | topBar.append(title); 113 | 114 | const search = document.createElement("input"); 115 | search.type = "text"; 116 | topBar.append(search); 117 | 118 | let html: HTMLElement | undefined = undefined; 119 | let topSticker: undefined | Sticker = undefined; 120 | const updateSearch = () => { 121 | if (search.value === "") { 122 | if (html) html.click(); 123 | search.style.removeProperty("width"); 124 | topSticker = undefined; 125 | return; 126 | } 127 | 128 | search.style.setProperty("width", "3in"); 129 | title.innerText = ""; 130 | body.innerHTML = ""; 131 | const searchResults = Sticker.searchStickers(search.value, localuser, 200); 132 | if (searchResults[0]) { 133 | topSticker = searchResults[0][0]; 134 | } 135 | for (const [sticker] of searchResults) { 136 | const emojiElem = document.createElement("div"); 137 | emojiElem.classList.add("stickerSelect"); 138 | 139 | emojiElem.append(sticker.getHTML()); 140 | body.append(emojiElem); 141 | 142 | emojiElem.addEventListener("click", () => { 143 | res(sticker); 144 | if (Contextmenu.currentmenu !== "") { 145 | Contextmenu.currentmenu.remove(); 146 | } 147 | }); 148 | } 149 | }; 150 | search.addEventListener("input", () => { 151 | updateSearch.call(this); 152 | }); 153 | search.addEventListener("keyup", (e) => { 154 | if (e.key === "Enter" && topSticker) { 155 | res(topSticker); 156 | if (Contextmenu.currentmenu !== "") { 157 | Contextmenu.currentmenu.remove(); 158 | } 159 | } 160 | }); 161 | 162 | menu.append(topBar); 163 | 164 | const selection = document.createElement("div"); 165 | selection.classList.add("flexltr", "emojirow"); 166 | const body = document.createElement("div"); 167 | body.classList.add("stickerBody"); 168 | 169 | let isFirst = true; 170 | let i = 0; 171 | guilds.forEach((guild) => { 172 | const select = document.createElement("div"); 173 | if (i === 0) { 174 | html = select; 175 | i++; 176 | } 177 | select.classList.add("emojiSelect"); 178 | 179 | if (guild.properties.icon) { 180 | const img = document.createElement("img"); 181 | img.classList.add("pfp", "servericon", "emoji-server"); 182 | img.crossOrigin = "anonymous"; 183 | img.src = 184 | localuser.info.cdn + 185 | "/icons/" + 186 | guild.properties.id + 187 | "/" + 188 | guild.properties.icon + 189 | ".png?size=48"; 190 | img.alt = "Server: " + guild.properties.name; 191 | select.appendChild(img); 192 | } else { 193 | const div = document.createElement("span"); 194 | div.textContent = guild.properties.name 195 | .replace(/'s /g, " ") 196 | .replace(/\w+/g, (word) => word[0]) 197 | .replace(/\s/g, ""); 198 | select.append(div); 199 | } 200 | 201 | selection.append(select); 202 | 203 | const clickEvent = () => { 204 | search.value = ""; 205 | updateSearch.call(this); 206 | title.textContent = guild.properties.name; 207 | body.innerHTML = ""; 208 | for (const sticker of guild.stickers) { 209 | const stickerElem = document.createElement("div"); 210 | stickerElem.classList.add("stickerSelect"); 211 | stickerElem.append(sticker.getHTML()); 212 | body.append(stickerElem); 213 | stickerElem.addEventListener("click", () => { 214 | res(sticker); 215 | if (Contextmenu.currentmenu !== "") { 216 | Contextmenu.currentmenu.remove(); 217 | } 218 | }); 219 | } 220 | }; 221 | 222 | select.addEventListener("click", clickEvent); 223 | if (isFirst) { 224 | clickEvent(); 225 | isFirst = false; 226 | } 227 | }); 228 | 229 | if (Contextmenu.currentmenu !== "") { 230 | Contextmenu.currentmenu.remove(); 231 | } 232 | document.body.append(menu); 233 | Contextmenu.currentmenu = menu; 234 | Contextmenu.keepOnScreen(menu); 235 | menu.append(selection); 236 | menu.append(body); 237 | search.focus(); 238 | return promise; 239 | } 240 | } 241 | export {Sticker}; 242 | -------------------------------------------------------------------------------- /src/webpage/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Jank Client 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 24 | 25 | 26 |
27 |
28 |

Use Template Name

29 |

30 | 31 |
32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/webpage/templatePage.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from "./i18n.js"; 2 | import {templateSkim} from "./jsontypes.js"; 3 | import {getapiurls} from "./utils/utils.js"; 4 | import {getBulkUsers, Specialuser} from "./utils/utils.js"; 5 | 6 | (async () => { 7 | const users = getBulkUsers(); 8 | const well = new URLSearchParams(window.location.search).get("instance"); 9 | const joinable: Specialuser[] = []; 10 | 11 | for (const key in users.users) { 12 | if (Object.prototype.hasOwnProperty.call(users.users, key)) { 13 | const user: Specialuser = users.users[key]; 14 | if (well && user.serverurls.wellknown.includes(well)) { 15 | joinable.push(user); 16 | } 17 | console.log(user); 18 | } 19 | } 20 | 21 | let urls: {api: string; cdn: string} | undefined; 22 | 23 | if (!joinable.length && well) { 24 | const out = await getapiurls(well); 25 | if (out) { 26 | urls = out; 27 | for (const key in users.users) { 28 | if (Object.prototype.hasOwnProperty.call(users.users, key)) { 29 | const user: Specialuser = users.users[key]; 30 | if (user.serverurls.api.includes(out.api)) { 31 | joinable.push(user); 32 | } 33 | console.log(user); 34 | } 35 | } 36 | } else { 37 | throw new Error("Someone needs to handle the case where the servers don't exist"); 38 | } 39 | } else { 40 | urls = joinable[0].serverurls; 41 | } 42 | await I18n.done; 43 | if (!joinable.length) { 44 | document.getElementById("usetemplate")!.textContent = I18n.htmlPages.noAccount(); 45 | } 46 | 47 | const code = window.location.pathname.split("/")[2]; 48 | 49 | fetch(`${urls!.api}/guilds/templates/${code}`, { 50 | method: "GET", 51 | headers: { 52 | Authorization: joinable[0].token, 53 | }, 54 | }) 55 | .then((response) => response.json()) 56 | .then((json) => { 57 | const template = json as templateSkim; 58 | document.getElementById("templatename")!.textContent = I18n.useTemplate(template.name); 59 | document.getElementById("templatedescription")!.textContent = template.description; 60 | }); 61 | 62 | function showAccounts(): void { 63 | const table = document.createElement("dialog"); 64 | for (const user of joinable) { 65 | console.log(user.pfpsrc); 66 | 67 | const userinfo = document.createElement("div"); 68 | userinfo.classList.add("flexltr", "switchtable"); 69 | 70 | const pfp = document.createElement("img"); 71 | pfp.src = user.pfpsrc; 72 | pfp.classList.add("pfp"); 73 | userinfo.append(pfp); 74 | 75 | const userDiv = document.createElement("div"); 76 | userDiv.classList.add("userinfo"); 77 | userDiv.textContent = user.username; 78 | userDiv.append(document.createElement("br")); 79 | 80 | const span = document.createElement("span"); 81 | span.textContent = user.serverurls.wellknown.replace("https://", "").replace("http://", ""); 82 | span.classList.add("serverURL"); 83 | userDiv.append(span); 84 | 85 | userinfo.append(userDiv); 86 | table.append(userinfo); 87 | 88 | userinfo.addEventListener("click", () => { 89 | const search = new URLSearchParams(); 90 | search.set("templateID", code); 91 | sessionStorage.setItem("currentuser", user.uid); 92 | window.location.assign("/channels/@me?" + search); 93 | }); 94 | } 95 | 96 | if (!joinable.length) { 97 | const l = new URLSearchParams("?"); 98 | l.set("goback", window.location.href); 99 | l.set("instance", well!); 100 | window.location.href = "/login?" + l.toString(); 101 | } 102 | 103 | table.classList.add("flexttb", "accountSwitcher"); 104 | console.log(table); 105 | document.body.append(table); 106 | } 107 | 108 | document.getElementById("usetemplate")!.addEventListener("click", showAccounts); 109 | document.getElementById("usetemplate")!.textContent = I18n.useTemplateButton(); 110 | })(); 111 | -------------------------------------------------------------------------------- /src/webpage/themes.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "BetterFont"; 3 | src: url("./Commissioner-Regular.woff2") format("woff2"); 4 | } 5 | 6 | :root { 7 | --font: "BetterFont", "acumin-pro", "Helvetica Neue", Helvetica, Arial, sans-serif; 8 | --black: #000000; 9 | --red: #ff5555; 10 | --yellow: #ffc159; 11 | --green: #1c907b; 12 | --blue: #779bff; 13 | } 14 | 15 | /* Themes. See themes.txt */ 16 | .Dark-theme { 17 | color-scheme: dark; 18 | --primary-bg: #303339; 19 | --primary-hover: #272b31; 20 | --primary-text: #dfdfdf; 21 | --primary-text-soft: #adb8b9; 22 | --secondary-bg: #16191b; 23 | --secondary-hover: #252b2c; 24 | --servers-bg: #191c1d; 25 | --channels-bg: #2a2d33; 26 | --channel-selected: #3c4046; 27 | --typebox-bg: #3a3e45; 28 | --button-bg: #4e5457; 29 | --button-hover: #6b7174; 30 | --spoiler-bg: #000000; 31 | --link: #5ca9ed; 32 | --primary-text-prominent: #efefef; 33 | --dock-bg: #1b1e20; 34 | --card-bg: #000000; 35 | } 36 | 37 | .WHITE-theme { 38 | color-scheme: light; 39 | 40 | --primary-bg: #fefefe; 41 | --primary-hover: #f6f6f9; 42 | --primary-text: #4b4b59; 43 | --primary-text-soft: #656575; 44 | 45 | --secondary-bg: #e0e0ea; 46 | --secondary-hover: #d0d0dd; 47 | 48 | --servers-bg: #b4b4ca; 49 | --channels-bg: #eaeaf0; 50 | --channel-selected: #c7c7d9; 51 | --typebox-bg: #ededf4; 52 | 53 | --button-bg: #cacad8; 54 | --button-hover: #b3b3c4; 55 | --spoiler-bg: #dadada; 56 | --link: #056cd9; 57 | 58 | --black: #4b4b59; 59 | --green: #68d79d; 60 | --primary-text-prominent: #08080d; 61 | --secondary-text: #3c3c46; 62 | --secondary-text-soft: #4c4c5a; 63 | --dock-bg: #d1d1df; 64 | --dock-hover: #b8b8d0; 65 | } 66 | 67 | .Light-theme { 68 | color-scheme: light; 69 | 70 | --primary-bg: #aaafce; 71 | --primary-hover: #b1b6d4; 72 | --primary-text: #060415; 73 | --primary-text-soft: #424268; 74 | 75 | --secondary-bg: #9397bd; 76 | --secondary-hover: #9ea5cc; 77 | 78 | --servers-bg: #7a7aaa; 79 | --channels-bg: #babdd2; 80 | --channel-selected: #9c9fbf; 81 | --typebox-bg: #bac0df; 82 | 83 | --button-bg: #babdd2; 84 | --button-hover: #9c9fbf; 85 | --spoiler-bg: #34333a; 86 | --link: #283c8b; 87 | 88 | --black: #434392; 89 | --red: #ca304d; 90 | --secondary-text-soft: #211f2e; 91 | --blank-bg: #494985; 92 | --spoiler-text: #e4e6ed; 93 | } 94 | 95 | .Dark-Accent-theme { 96 | color-scheme: dark; 97 | 98 | --primary-bg: color-mix(in srgb, #3f3f3f 65%, var(--accent-color)); 99 | --primary-hover: color-mix(in srgb, #373737 68%, var(--accent-color)); 100 | --primary-text: #ebebeb; 101 | --primary-text-soft: #ebebebb8; 102 | 103 | --secondary-bg: color-mix(in srgb, #222222 72%, var(--accent-color)); 104 | --secondary-hover: color-mix(in srgb, #222222 65%, var(--accent-color)); 105 | 106 | --servers-bg: color-mix(in srgb, #0b0b0b 70%, var(--accent-color)); 107 | --channels-bg: color-mix(in srgb, #292929 68%, var(--accent-color)); 108 | --channel-selected: color-mix(in srgb, #555555 65%, var(--accent-color)); 109 | --typebox-bg: color-mix(in srgb, #666666 60%, var(--accent-color)); 110 | 111 | --button-bg: color-mix(in srgb, #777777 56%, var(--accent-color)); 112 | --button-hover: color-mix(in srgb, #585858 58%, var(--accent-color)); 113 | 114 | --spoiler: color-mix(in srgb, #101010 72%, var(--accent-color)); 115 | --link: color-mix(in srgb, #99ccff 75%, var(--accent-color)); 116 | 117 | --black: color-mix(in srgb, #000000 90%, var(--accent-color)); 118 | --icon: color-mix(in srgb, #ffffff, var(--accent-color)); 119 | --dock-bg: color-mix(in srgb, #171717 68%, var(--accent-color)); 120 | --spoiler-hover: color-mix(in srgb, #111111 80%, var(--accent-color)); 121 | --card-bg: color-mix(in srgb, #0b0b0b 70%, var(--accent-color)); 122 | } 123 | 124 | /* Optional Variables */ 125 | body { 126 | --primary-text-prominent: var(--primary-text); 127 | --secondary-text: var(--primary-text); 128 | --secondary-text-soft: var(--primary-text-soft); 129 | --text-input-bg: var(--secondary-bg); 130 | --button-text: var(--primary-text); 131 | --button-disabled-text: color-mix(in srgb, var(--button-text), transparent); 132 | 133 | --icon: var(--accent-color); 134 | --focus: var(--accent-color); 135 | --shadow: color-mix(in srgb, var(--black) 30%, transparent); 136 | --scrollbar: var(--primary-text-soft); 137 | --scrollbar-track: var(--primary-hover); 138 | 139 | --blank-bg: var(--channels-bg); 140 | --divider: color-mix(in srgb, var(--primary-text), transparent); 141 | --channels-header-bg: var(--channels-bg); 142 | --channel-hover: color-mix(in srgb, var(--channel-selected) 60%, transparent); 143 | --dock-bg: var(--secondary-bg); 144 | --dock-hover: var(--secondary-hover); 145 | --user-info-bg: var(--dock-bg); 146 | --user-info-text: var(--secondary-text); 147 | 148 | --main-header-bg: transparent; 149 | --message-jump-bg: color-mix(in srgb, var(--accent-color) 20%, transparent); 150 | --code-bg: var(--secondary-bg); 151 | --code-text: var(--secondary-text); 152 | --spoiler-text: var(--primary-text); 153 | --spoiler-hover: color-mix(in srgb, var(--spoiler-bg), var(--primary-text-soft) 10%); 154 | --quote-line: color-mix(in srgb, var(--primary-text-soft), transparent); 155 | --reply-line: color-mix(in srgb, var(--primary-text-soft) 20%, transparent); 156 | --reply-text: var(--primary-text-soft); 157 | --reply-highlight: var(--accent-color); 158 | --mention: color-mix(in srgb, var(--accent-color) 80%, transparent); 159 | --mention-highlight: var(--yellow); 160 | --reaction-bg: var(--secondary-bg); 161 | --reaction-reacted-bg: var(--secondary-hover); 162 | --filename: var(--link); 163 | --embed-bg: var(--secondary-bg); 164 | 165 | --sidebar-bg: var(--channels-bg); 166 | --sidebar-hover: var(--channel-hover); 167 | --card-bg: var(--primary-bg); 168 | --role-bg: var(--primary-bg); 169 | --role-text: var(--primary-text); 170 | --settings-bg: var(--primary-bg); 171 | --settings-header-bg: var(--main-header-bg); 172 | --settings-panel-bg: var(--channels-bg); 173 | --settings-panel-selected: var(--channel-selected); 174 | --settings-panel-hover: color-mix(in srgb, var(--settings-panel-selected), transparent); 175 | --loading-bg: var(--secondary-bg); 176 | --loading-text: var(--secondary-text); 177 | } 178 | -------------------------------------------------------------------------------- /src/webpage/utils/binaryUtils.ts: -------------------------------------------------------------------------------- 1 | class BinRead { 2 | private i = 0; 3 | private view: DataView; 4 | constructor(buffer: ArrayBuffer) { 5 | this.view = new DataView(buffer, 0); 6 | } 7 | read16() { 8 | const int = this.view.getUint16(this.i); 9 | this.i += 2; 10 | return int; 11 | } 12 | read8() { 13 | const int = this.view.getUint8(this.i); 14 | this.i += 1; 15 | return int; 16 | } 17 | readString8() { 18 | return this.readStringNo(this.read8()); 19 | } 20 | readString16() { 21 | return this.readStringNo(this.read16()); 22 | } 23 | readFloat32() { 24 | const float = this.view.getFloat32(this.i); 25 | this.i += 4; 26 | return float; 27 | } 28 | readStringNo(length: number) { 29 | const array = new Uint8Array(length); 30 | for (let i = 0; i < length; i++) { 31 | array[i] = this.read8(); 32 | } 33 | //console.log(array); 34 | return new TextDecoder("utf8").decode(array.buffer as ArrayBuffer); 35 | } 36 | } 37 | 38 | class BinWrite { 39 | private view: DataView; 40 | private buffer: ArrayBuffer; 41 | private i = 0; 42 | constructor(maxSize: number = 2 ** 26) { 43 | this.buffer = new ArrayBuffer(maxSize); 44 | this.view = new DataView(this.buffer, 0); 45 | } 46 | write32Float(numb: number) { 47 | this.view.setFloat32(this.i, numb); 48 | this.i += 4; 49 | } 50 | write16(numb: number) { 51 | this.view.setUint16(this.i, numb); 52 | this.i += 2; 53 | } 54 | write8(numb: number) { 55 | this.view.setUint8(this.i, numb); 56 | this.i += 1; 57 | } 58 | writeString8(str: string) { 59 | const encode = new TextEncoder().encode(str); 60 | this.write8(encode.length); 61 | for (const thing of encode) { 62 | this.write8(thing); 63 | } 64 | } 65 | writeString16(str: string) { 66 | const encode = new TextEncoder().encode(str); 67 | this.write16(encode.length); 68 | for (const thing of encode) { 69 | this.write8(thing); 70 | } 71 | } 72 | writeStringNo(str: string) { 73 | const encode = new TextEncoder().encode(str); 74 | for (const thing of encode) { 75 | this.write8(thing); 76 | } 77 | } 78 | getBuffer() { 79 | const buf = new ArrayBuffer(this.i); 80 | const ar1 = new Uint8Array(buf); 81 | const ar2 = new Uint8Array(this.buffer); 82 | for (let i in ar1) { 83 | ar1[+i] = ar2[+i]; 84 | } 85 | return buf; 86 | } 87 | } 88 | export {BinRead, BinWrite}; 89 | -------------------------------------------------------------------------------- /src/webpage/utils/dirrWorker.ts: -------------------------------------------------------------------------------- 1 | //This is *only* for webkit, and it really sucks 2 | //If webkit starts supporting the more sain way, let me know so I can remove this after a year or two of them supporting it 3 | onmessage = async (e) => { 4 | const [file, content, rand] = e.data as [FileSystemFileHandle, ArrayBuffer, number]; 5 | try { 6 | const handle = await file.createSyncAccessHandle(); 7 | handle.write(content); 8 | handle.close(); 9 | postMessage([rand, true]); 10 | } catch { 11 | postMessage([rand, false]); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/webpage/webhooks.ts: -------------------------------------------------------------------------------- 1 | import {Guild} from "./guild.js"; 2 | import {I18n} from "./i18n.js"; 3 | import {webhookType} from "./jsontypes.js"; 4 | import {Member} from "./member.js"; 5 | import {Dialog, Options} from "./settings.js"; 6 | import {SnowFlake} from "./snowflake.js"; 7 | import {User} from "./user.js"; 8 | 9 | async function webhookMenu( 10 | guild: Guild, 11 | hookURL: string, 12 | webhooks: Options, 13 | channelId: false | string = false, 14 | ) { 15 | const moveChannels = guild.channels.filter( 16 | (_) => _.hasPermission("MANAGE_WEBHOOKS") && _.type !== 4, 17 | ); 18 | async function regenArea() { 19 | webhooks.removeAll(); 20 | 21 | webhooks.addButtonInput("", I18n.webhooks.newWebHook(), () => { 22 | const nameBox = new Dialog(I18n.webhooks.EnterWebhookName()); 23 | const options = nameBox.float.options; 24 | const defualts = I18n.webhooks.sillyDefaults().split("\n"); 25 | let channel = channelId || moveChannels[0].id; 26 | options.addTextInput( 27 | I18n.webhooks.name(), 28 | async (name) => { 29 | const json = await ( 30 | await fetch(`${guild.info.api}/channels/${channel}/webhooks/`, { 31 | method: "POST", 32 | headers: guild.headers, 33 | body: JSON.stringify({name}), 34 | }) 35 | ).json(); 36 | makeHook(json); 37 | }, 38 | { 39 | initText: defualts[Math.floor(Math.random() * defualts.length)], 40 | }, 41 | ); 42 | if (!channelId) { 43 | const select = options.addSelect( 44 | I18n.webhooks.channel(), 45 | () => {}, 46 | moveChannels.map((_) => _.name), 47 | { 48 | defaultIndex: 0, 49 | }, 50 | ); 51 | select.watchForChange((i: number) => { 52 | channel = moveChannels[i].id; 53 | }); 54 | } 55 | options.addButtonInput("", I18n.submit(), () => { 56 | options.submit(); 57 | nameBox.hide(); 58 | }); 59 | nameBox.show(); 60 | }); 61 | const hooks = (await (await fetch(hookURL, {headers: guild.headers})).json()) as webhookType[]; 62 | for (const hook of hooks) { 63 | makeHook(hook); 64 | } 65 | } 66 | 67 | const makeHook = (hook: webhookType) => { 68 | const div = document.createElement("div"); 69 | div.classList.add("flexltr", "webhookArea"); 70 | const pfp = document.createElement("img"); 71 | if (hook.avatar) { 72 | pfp.src = `${guild.info.cdn}/avatars/${hook.id}/${hook.avatar}`; 73 | } else { 74 | const int = Number((BigInt(hook.id) >> 22n) % 6n); 75 | pfp.src = `${guild.info.cdn}/embed/avatars/${int}.png`; 76 | } 77 | pfp.classList.add("webhookpfppreview"); 78 | 79 | const namePlate = document.createElement("div"); 80 | namePlate.classList.add("flexttb"); 81 | 82 | const name = document.createElement("b"); 83 | name.textContent = hook.name; 84 | 85 | const createdAt = document.createElement("span"); 86 | createdAt.textContent = I18n.webhooks.createdAt( 87 | new Intl.DateTimeFormat(I18n.lang).format(SnowFlake.stringToUnixTime(hook.id)), 88 | ); 89 | 90 | const wtype = document.createElement("span"); 91 | let typeText: string; 92 | switch (hook.type) { 93 | case 1: 94 | typeText = I18n.webhooks.type1(); 95 | break; 96 | case 2: 97 | typeText = I18n.webhooks.type2(); 98 | break; 99 | case 3: 100 | typeText = I18n.webhooks.type3(); 101 | break; 102 | } 103 | wtype.textContent = I18n.webhooks.type(typeText); 104 | 105 | namePlate.append(name, createdAt, wtype); 106 | 107 | const icon = document.createElement("span"); 108 | icon.classList.add("svg-intoMenu", "svgicon"); 109 | 110 | div.append(pfp, namePlate, icon); 111 | 112 | div.onclick = () => { 113 | const form = webhooks.addSubForm( 114 | hook.name, 115 | (e) => { 116 | regenArea(); 117 | console.log(e); 118 | }, 119 | { 120 | traditionalSubmit: true, 121 | method: "PATCH", 122 | fetchURL: guild.info.api + "/webhooks/" + hook.id, 123 | headers: guild.headers, 124 | }, 125 | ); 126 | form.addTextInput(I18n.webhooks.name(), "name", {initText: hook.name}); 127 | form.addFileInput(I18n.webhooks.avatar(), "avatar", {clear: true}); 128 | 129 | form.addSelect( 130 | I18n.webhooks.channel(), 131 | "channel_id", 132 | moveChannels.map((_) => _.name), 133 | { 134 | defaultIndex: moveChannels.findIndex((_) => _.id === hook.channel_id), 135 | }, 136 | moveChannels.map((_) => _.id), 137 | ); 138 | 139 | form.addMDText(I18n.webhooks.token(hook.token)); 140 | form.addMDText(I18n.webhooks.url(hook.url)); 141 | form.addText(I18n.webhooks.type(typeText)); 142 | form.addButtonInput("", I18n.webhooks.copyURL(), () => { 143 | navigator.clipboard.writeText(hook.url); 144 | }); 145 | 146 | form.addText(I18n.webhooks.createdBy()); 147 | 148 | try { 149 | const user = new User(hook.user, guild.localuser); 150 | const div = user.createWidget(guild); 151 | form.addHTMLArea(div); 152 | } catch {} 153 | form.addButtonInput("", I18n.webhooks.deleteWebhook(), () => { 154 | const d = new Dialog("areYouSureDelete"); 155 | const opt = d.options; 156 | opt.addTitle(I18n.webhooks.areYouSureDelete(hook.name)); 157 | const opt2 = opt.addOptions("", {ltr: true}); 158 | opt2.addButtonInput("", I18n.yes(), () => { 159 | fetch(guild.info.api + "/webhooks/" + hook.id, { 160 | method: "DELETE", 161 | headers: guild.headers, 162 | }).then(() => { 163 | d.hide(); 164 | regenArea(); 165 | }); 166 | }); 167 | opt2.addButtonInput("", I18n.no(), () => { 168 | d.hide(); 169 | }); 170 | d.show(); 171 | }); 172 | }; 173 | webhooks.addHTMLArea(div); 174 | }; 175 | regenArea(); 176 | } 177 | export {webhookMenu}; 178 | -------------------------------------------------------------------------------- /translations.md: -------------------------------------------------------------------------------- 1 | # Translations 2 | the translations are stored in `/src/webpage/translations` in this format. 3 | ```json 4 | { 5 | "@metadata": { 6 | "authors": [ 7 | ], 8 | "last-updated": "XXXX/XX/XX", 9 | "locale": "ru", 10 | "comment":"" 11 | }, 12 | } 13 | ``` 14 | 15 | ## I want to help translate this 16 | Please go to [https://translatewiki.net/wiki/Translating:JankClient](https://translatewiki.net/wiki/Translating:JankClient) to help translate this project 17 | ## What is the format? 18 | It's the same format found [here](https://github.com/wikimedia/jquery.i18n#message-file-format), though we are not using jquery, and you might notice some of the strings use markdown, but most do not. 19 | 20 | ## I want to help correct a translation 21 | Go ahead! We're more than happy to take corrections to translations as well! 22 | -------------------------------------------------------------------------------- /translations/lb.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Volvox" 5 | ] 6 | }, 7 | "vc": { 8 | "joinstream": "Stream kucken" 9 | }, 10 | "readableName": "Lëtzebuergesch", 11 | "reply": "Äntweren", 12 | "media": { 13 | "loading": "Lueden", 14 | "moreInfo": "Méi Informatiounen", 15 | "artist": "Kënschtler: $1", 16 | "composer": "Komponist: $1", 17 | "length": "Längt: $1 Minutten a(n) $2 Sekonnen" 18 | }, 19 | "permissions": { 20 | "readableNames": { 21 | "ADMINISTRATOR": "Administrateur", 22 | "STREAM": "Video", 23 | "ATTACH_FILES": "Fichieren uspéngelen", 24 | "USE_EXTERNAL_EMOJIS": "Extern Emojie benotzen", 25 | "CONNECT": "Verbannen", 26 | "SPEAK": "Schwätzen", 27 | "MUTE_MEMBERS": "Membere stommschalten", 28 | "CHANGE_NICKNAME": "Spëtznumm änneren", 29 | "MANAGE_NICKNAMES": "Spëtznimm geréieren", 30 | "MANAGE_ROLES": "Rolle geréieren", 31 | "MANAGE_GUILD_EXPRESSIONS": "Ausdréck geréieren", 32 | "CREATE_EVENTS": "Evenementer uleeën", 33 | "SEND_POLLS": "Ëmfroen uleeën", 34 | "USE_EXTERNAL_APPS": "Extern Appe benotzen" 35 | } 36 | }, 37 | "deleteConfirm": "Sidd Dir sécher, datt Dir dat läsche wëllt?", 38 | "yes": "Jo", 39 | "no": "Neen", 40 | "todayAt": "Haut um $1", 41 | "yesterdayAt": "Gëschter um $1", 42 | "botSettings": "Bot-Astellungen", 43 | "pronouns": "Pronomen:", 44 | "profileColor": "Profilfaarf", 45 | "confirmGuildLeave": "Sidd Dir sécher, datt Dir aus $1 erausgoe wëllt", 46 | "typing": "$2 {{PLURAL:$1|ass|sinn}} am schreiwen", 47 | "blankMessage": "Eidele Message", 48 | "accessibility": { 49 | "gifSettings": { 50 | "always": "Ëmmer", 51 | "never": "Ni" 52 | } 53 | }, 54 | "channel": { 55 | "creating": "Kanal uleeën", 56 | "name": "Kanal", 57 | "copyId": "Kanal-ID kopéieren", 58 | "markRead": "Als gelies markéieren", 59 | "settings": "Astellungen", 60 | "delete": "Kanal läschen", 61 | "makeInvite": "Invitatioun maachen", 62 | "settingsFor": "Astellunge fir $1", 63 | "text": "Text", 64 | "announcement": "Ukënnegungen", 65 | "name:": "Numm:", 66 | "topic:": "Theema:", 67 | "nsfw:": "NSFW:", 68 | "selectType": "Kanaltyp auswielen", 69 | "selectName": "Numm vum Kanal", 70 | "selectCatName": "Numm vun der Kategorie", 71 | "createChannel": "Kanal uleeën", 72 | "createCatagory": "Kategorie uleeën" 73 | }, 74 | "delete": "Läschen", 75 | "webhooks": { 76 | "name": "Numm:", 77 | "channel": "Kanal", 78 | "type": "Typ: $1", 79 | "areYouSureDelete": "Sidd Dir sécher, datt Dir $1 läsche wëllt?" 80 | }, 81 | "switchAccounts": "Konto wiesselen ⇌", 82 | "htmlPages": { 83 | "instanceField": "Instanz:", 84 | "emailField": "E-Mail:", 85 | "pwField": "Passwuert:", 86 | "loginButton": "Aloggen", 87 | "noAccount": "Hutt Dir kee Benotzerkont?", 88 | "userField": "Benotzernumm:", 89 | "pw2Field": "Passwuert nach eng Kéier aginn:", 90 | "dobField": "Gebuertsdatum:", 91 | "trans": "Iwwersetzen" 92 | }, 93 | "useTemplate": "$1 als Schabloun benotzen", 94 | "useTemplateButton": "Schabloun benotzen", 95 | "register": { 96 | "passwordError:": "Passwuert: $1", 97 | "usernameError": "Benotzernumm: $1", 98 | "emailError": "E-Mail-Adress: $1", 99 | "DOBError": "Gebuertsdatum: $1" 100 | }, 101 | "edit": "Änneren", 102 | "guild": { 103 | "template": "Schabloun:", 104 | "viewTemplate": "Schabloun weisen", 105 | "tempCreatedBy": "Schabloun ugeluecht vum:", 106 | "createNewTemplate": "Nei Schabloun uleeën", 107 | "templates": "Schablounen", 108 | "templateName": "Numm vun der Schabloun:", 109 | "templateDesc": "Beschreiwung vun der Schabloun:", 110 | "templateURL": "URL vun der Schabloun: $1", 111 | "community": "Communautéit", 112 | "markRead": "Als gelies markéieren", 113 | "notifications": "Notifikatiounen", 114 | "settings": "Astellungen", 115 | "settingsFor": "Astellunge fir $1", 116 | "name:": "Numm:", 117 | "region:": "Regioun:", 118 | "roles": "Rollen", 119 | "all": "all", 120 | "none": "keng", 121 | "yesLeave": "Jo, ech si mer sécher", 122 | "serverName": "Servernumm:", 123 | "yesDelete": "Jo, ech si mer sécher", 124 | "loadingDiscovery": "Lueden…", 125 | "default": "Standard ($1)", 126 | "description:": "Beschreiwung:" 127 | }, 128 | "role": { 129 | "color": "Faarf", 130 | "delete": "Roll läschen" 131 | }, 132 | "settings": { 133 | "save": "Ännerunge späicheren" 134 | }, 135 | "localuser": { 136 | "install": "Installéieren", 137 | "status": "Status", 138 | "settings": "Astellungen", 139 | "userSettings": "Benotzerastellungen", 140 | "notisound": "Notifikatiounstoun:", 141 | "updateSettings": "Astellungen aktualiséieren", 142 | "SWOff": "Aus", 143 | "SWOffline": "Nëmmen offline", 144 | "SWOn": "Un", 145 | "clearCache": "Cache eidelmaachen", 146 | "CheckUpdate": "No Aktualiséierunge sichen", 147 | "2faDisable": "2FA desaktivéieren", 148 | "badCode": "Ongültege Code", 149 | "2faEnable": "2FA aktivéieren", 150 | "2faCode:": "Code:", 151 | "badPassword": "Falscht Passwuert", 152 | "changeEmail": "E-Mail-Adress änneren", 153 | "password:": "Passwuert", 154 | "newEmail:": "Nei E-Mail", 155 | "changeUsername": "Benotzernumm änneren", 156 | "newUsername": "Neie Benotzernumm:", 157 | "changePassword": "Passwuert änneren", 158 | "oldPassword:": "Aalt Passwuert:", 159 | "newPassword:": "Neit Passwuert:", 160 | "PasswordsNoMatch": "D'Passwierder sinn net d'selwecht", 161 | "description": "Beschreiwung:", 162 | "manageBot": "Bot geréieren", 163 | "addBot": "Bot derbäisetzen", 164 | "botUsername": "Botbenotzernumm:", 165 | "advancedBot": "Erweidert Bot-Astellungen", 166 | "language": "Sprooch:", 167 | "connections": "Verbindungen", 168 | "deleteAccountButton": "Kont läschen" 169 | }, 170 | "search": { 171 | "back": "Zréck", 172 | "next": "Nächst", 173 | "page": "Säit $1", 174 | "new": "Nei", 175 | "old": "Al", 176 | "search": "Sichen" 177 | }, 178 | "manageInstance": { 179 | "length": "Längt:", 180 | "genericType": "Geneeresch", 181 | "copy": "Kopéieren" 182 | }, 183 | "message": { 184 | "reactionAdd": "Reaktioun derbäisetzen", 185 | "delete": "Message läschen", 186 | "edit": "Message änneren", 187 | "edited": "(geännert)", 188 | "deleted": "Geläschte Message" 189 | }, 190 | "instanceStats": { 191 | "users": "Registréiert Benotzer: $1", 192 | "servers": "Serveren: $1", 193 | "messages": "Messagen: $1", 194 | "members": "Memberen: $1" 195 | }, 196 | "inviteOptions": { 197 | "title": "Leit invitéieren", 198 | "30m": "30 Minutten", 199 | "1h": "1 Stonn", 200 | "6h": "6 Stonnen", 201 | "12h": "12 Stonnen", 202 | "1d": "1 Dag", 203 | "7d": "7 Deeg", 204 | "30d": "30 Deeg", 205 | "never": "Ni", 206 | "noLimit": "Keng Limitt" 207 | }, 208 | "2faCode": "2FA-Code:", 209 | "invite": { 210 | "invitedBy": "Dir gouft vum $1 invitéiert", 211 | "alreadyJoined": "Scho bäigetrueden", 212 | "accept": "Akzeptéieren", 213 | "channel:": "Kanal:", 214 | "createInvite": "Invitatioun maachen", 215 | "never": "Ni" 216 | }, 217 | "friends": { 218 | "blocked": "Gespaart", 219 | "blockedusers": "Gespaarte Benotzer:", 220 | "notfound": "Benotzer net fonnt", 221 | "online": "Online", 222 | "friends": "Frënn" 223 | }, 224 | "DMs": { 225 | "markRead": "Als gelies markéieren" 226 | }, 227 | "user": { 228 | "online": "Online", 229 | "offline": "Offline", 230 | "invisible": "Onsiichtbar", 231 | "block": "Benotzer spären", 232 | "unblock": "Benotzer entspären", 233 | "editServerProfile": "Serverprofil änneren" 234 | }, 235 | "login": { 236 | "recover": "Passwuert vergiess?", 237 | "newPassword": "Neit Passwuert:", 238 | "login": "Aloggen" 239 | }, 240 | "member": { 241 | "reason:": "Grond:", 242 | "nick:": "Spëtznumm:" 243 | }, 244 | "badge": { 245 | "certified_moderator": "Moderateur" 246 | }, 247 | "emoji": { 248 | "title": "Emojien", 249 | "upload": "Emojien eroplueden", 250 | "image:": "Bild:", 251 | "name:": "Numm:", 252 | "confirmDel": "Sidd Dir sécher, datt Dir dësen Emoji läsche wëllt?" 253 | }, 254 | "sticker": { 255 | "title": "Stickeren", 256 | "upload": "Stickeren eroplueden", 257 | "image": "Bild:", 258 | "name": "Numm:", 259 | "desc": "Beschreiwung", 260 | "confirmDel": "Sidd Dir sécher, datt Dir dëse Sticker läsche wëllt?", 261 | "del": "Sticker läschen", 262 | "tags": "Associéierten Emoji:" 263 | }, 264 | "uploadFilesText": "Lued Är Fichieren hei erop!", 265 | "bot": "BOT" 266 | } 267 | -------------------------------------------------------------------------------- /translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "ABPMAB", 5 | "McDutchie" 6 | ] 7 | }, 8 | "readableName": "Nederlands", 9 | "permissions": { 10 | "readableNames": { 11 | "STREAM": "Video", 12 | "MANAGE_MESSAGES": "Berichten beheren", 13 | "EMBED_LINKS": "Links insluiten", 14 | "ATTACH_FILES": "Bestanden bijvoegen", 15 | "READ_MESSAGE_HISTORY": "Berichtgeschiedenis lezen", 16 | "USE_EXTERNAL_EMOJIS": "Gebruik externe emoji's", 17 | "CONNECT": "Verbinden", 18 | "SPEAK": "Spreken", 19 | "MUTE_MEMBERS": "Leden dempen", 20 | "MOVE_MEMBERS": "Leden verplaatsen", 21 | "CHANGE_NICKNAME": "Bijnaam wijzigen", 22 | "MANAGE_NICKNAMES": "Bijnamen beheren", 23 | "MANAGE_ROLES": "Rollen beheren" 24 | } 25 | }, 26 | "yes": "Ja", 27 | "no": "Nee", 28 | "todayAt": "Vandaag om $1", 29 | "yesterdayAt": "Gisteren om $1", 30 | "otherAt": "$1 om $2", 31 | "uploadBanner": "Banner uploaden:", 32 | "pronouns": "Voornaamwoorden:", 33 | "profileColor": "Profielkleur", 34 | "noMessages": "Er lijken geen berichten te zijn, wees de eerste die iets zegt!", 35 | "blankMessage": "Leeg bericht", 36 | "channel": { 37 | "markRead": "Markeren als gelezen", 38 | "settings": "Instellingen", 39 | "delete": "Kanaal verwijderen", 40 | "settingsFor": "Instellingen voor $1", 41 | "voice": "Stem", 42 | "text": "Tekst", 43 | "announcement": "Aankondigingen", 44 | "name:": "Naam:", 45 | "topic:": "Onderwerp:", 46 | "nsfw:": "Niet geschikt voor werk:", 47 | "selectType": "Selecteer kanaaltype", 48 | "selectName": "Naam van het kanaal", 49 | "selectCatName": "Naam van de categorie", 50 | "createChannel": "Kanaal aanmaken", 51 | "createCatagory": "Categorie aanmaken" 52 | }, 53 | "switchAccounts": "Accounts wisselen ⇌", 54 | "htmlPages": { 55 | "addBot": "Toevoegen aan server", 56 | "loaddesc": "Dit zou niet lang moeten duren", 57 | "switchaccounts": "Accounts wisselen", 58 | "emailField": "E-mail:", 59 | "pwField": "Wachtwoord:", 60 | "noAccount": "Nog geen account?", 61 | "userField": "Gebruikersnaam:", 62 | "pw2Field": "Voer het wachtwoord opnieuw in:", 63 | "dobField": "Geboortedatum:", 64 | "createAccount": "Account aanmaken" 65 | }, 66 | "register": { 67 | "passwordError:": "Wachtwoord: $1", 68 | "usernameError": "Gebruikersnaam: $1", 69 | "emailError": "E-mail: $1", 70 | "DOBError": "Geboortedatum: $1" 71 | }, 72 | "guild": { 73 | "markRead": "Markeren als gelezen", 74 | "notifications": "Notificaties", 75 | "settings": "Instellingen", 76 | "settingsFor": "Instellingen voor $1", 77 | "name:": "Naam:", 78 | "topic:": "Onderwerp:", 79 | "overview": "Overzicht", 80 | "region:": "Regio:", 81 | "roles": "Rollen", 82 | "all": "alle", 83 | "onlyMentions": "alleen vermeldingen", 84 | "none": "geen", 85 | "yesLeave": "Ja, ik weet het zeker", 86 | "serverName": "Naam van de server:", 87 | "yesDelete": "Ja, ik weet het zeker", 88 | "emptytitle": "Vreemde plek", 89 | "default": "Standaard ($1)", 90 | "description:": "Beschrijving:" 91 | }, 92 | "role": { 93 | "name": "Rolnaam:", 94 | "mentionable": "Iedereen toestaan deze rol te pingen:", 95 | "color": "Kleur", 96 | "remove": "Rol verwijderen", 97 | "delete": "Rol verwijderen" 98 | }, 99 | "settings": { 100 | "save": "Wijzigingen opslaan" 101 | }, 102 | "localuser": { 103 | "settings": "Instellingen", 104 | "userSettings": "Gebruikersinstellingen", 105 | "themesAndSounds": "Thema's & geluiden", 106 | "theme:": "Thema", 107 | "notisound": "Notificatiegeluid:", 108 | "updateSettings": "Instellingen bijwerken", 109 | "SWOff": "Uit", 110 | "SWOffline": "Alleen offline", 111 | "SWOn": "Aan", 112 | "clearCache": "Cache wissen", 113 | "CheckUpdate": "Controleren op updates", 114 | "accountSettings": "Accountinstellingen", 115 | "2faCode:": "Code:", 116 | "badPassword": "Onjuist wachtwoord", 117 | "changeEmail": "E-mailadres wijzigen", 118 | "password:": "Wachtwoord", 119 | "newEmail:": "Nieuw e-mailadres", 120 | "changeUsername": "Gebruikersnaam wijzigen", 121 | "newUsername": "Nieuwe gebruikersnaam:", 122 | "changePassword": "Wachtwoord wijzigen", 123 | "oldPassword:": "Oud wachtwoord:", 124 | "newPassword:": "Nieuw wachtwoord:", 125 | "PasswordsNoMatch": "Wachtwoorden komen niet overeen", 126 | "team:": "Team:", 127 | "description": "Beschrijving:", 128 | "manageBot": "Beheer bot", 129 | "addBot": "Bot toevoegen", 130 | "confuseNoBot": "Om een of andere reden heeft deze applicatie (nog) geen bot.", 131 | "botUsername": "Bot-gebruikersnaam:", 132 | "tokenDisplay": "Token: $1", 133 | "advancedBot": "Geavanceerde botinstellingen", 134 | "language": "Taal:" 135 | }, 136 | "message": { 137 | "reactionAdd": "Reactie toevoegen", 138 | "delete": "Bericht verwijderen", 139 | "edit": "Bericht bewerken", 140 | "edited": "(bewerkt)", 141 | "deleted": "Verwijderd bericht" 142 | }, 143 | "instanceStats": { 144 | "users": "Geregistreerde gebruikers: $1", 145 | "servers": "Servers: $1", 146 | "messages": "Berichten: $1", 147 | "members": "Leden: $1" 148 | }, 149 | "inviteOptions": { 150 | "title": "Mensen uitnodigen", 151 | "30m": "30 minuten", 152 | "1h": "1 uur", 153 | "6h": "6 uur", 154 | "12h": "12 uur", 155 | "1d": "1 dag", 156 | "7d": "7 dagen", 157 | "30d": "30 dagen", 158 | "never": "Nooit", 159 | "noLimit": "Geen limiet" 160 | }, 161 | "invite": { 162 | "accept": "Accepteren", 163 | "noAccount": "Maak een account aan om de uitnodiging te accepteren", 164 | "loginOrCreateAccount": "Inloggen of een account aanmaken ⇌", 165 | "inviteLinkCode": "Uitnodigingslink/-code", 166 | "channel:": "Kanaal:", 167 | "createInvite": "Uitnodiging maken" 168 | }, 169 | "friends": { 170 | "blocked": "Geblokkeerd", 171 | "blockedusers": "Geblokkeerde gebruikers:", 172 | "addfriend": "Vriend toevoegen", 173 | "removeFriend": "Vriend verwijderen", 174 | "addfriendpromt": "Vrienden toevoegen via gebruikersnaam:", 175 | "notfound": "Gebruiker niet gevonden", 176 | "pending": "In behandeling", 177 | "all": "Alle", 178 | "all:": "Alle vrienden:", 179 | "online": "Online", 180 | "friendlist": "Vriendenlijst", 181 | "friends": "Vrienden" 182 | }, 183 | "replyingTo": "Reageren op $1", 184 | "DMs": { 185 | "markRead": "Markeren als gelezen" 186 | }, 187 | "user": { 188 | "online": "Online", 189 | "offline": "Offline", 190 | "message": "Gebruiker bericht sturen", 191 | "block": "Gebruiker blokkeren", 192 | "unblock": "Gebruiker deblokkeren", 193 | "friendReq": "Vriendschapsverzoek", 194 | "ban": "Lid verbannen", 195 | "addRole": "Rollen toevoegen", 196 | "removeRole": "Rollen verwijderen" 197 | }, 198 | "member": { 199 | "reason:": "Reden:", 200 | "nick:": "Bijnaam:" 201 | }, 202 | "badge": { 203 | "certified_moderator": "Moderator", 204 | "hypesquad": "Auteursrechtelijk beschermd ding", 205 | "hypesquad_house_1": "Moed" 206 | }, 207 | "emoji": { 208 | "title": "Emoji's", 209 | "upload": "Emoji's uploaden", 210 | "image:": "Afbeelding:", 211 | "name:": "Naam:" 212 | }, 213 | "errorReconnect": "Kan geen verbinding maken met de server, opnieuw proberen over ** $1 ** seconden...", 214 | "retrying": "Opnieuw proberen..." 215 | } 216 | -------------------------------------------------------------------------------- /translations/qqq.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "last-updated": "2024/11/4", 4 | "locale": "en", 5 | "comment": "Don't know how often I'll update this top part lol", 6 | "authors": [ 7 | "MathMan05", 8 | "McDutchie", 9 | "Vl1" 10 | ] 11 | }, 12 | "readableName": "{{doc-important|This should be the name of the language you are translating into, in that language. Please DO NOT translate this into your language’s word for “English”!}}", 13 | "typing": "$1 is the number of people typing and $2 is the names of the people typing separated by commas", 14 | "webhooks": { 15 | "sillyDefaults": "{{doc-important|This is just a list of silly default names for webhooks, do not feel the need to translate dirrectly, and no need for the same count}}" 16 | }, 17 | "htmlPages": { 18 | "box1Items": "this string is slightly atypical, it has a list of items separated by |, please try to keep the same list size as this" 19 | }, 20 | "register": { 21 | "agreeTOS": "uses MarkDown" 22 | }, 23 | "guild": { 24 | "disoveryTitle": "$1 is the number of guilds discovered" 25 | }, 26 | "welcomeMessages": "These are welcome messages, they are meant to be silly, you do not need to directly translated them, there may even be a different count of messages, but only have the username once per message.", 27 | "localuser": { 28 | "updateSettings": "Title of a page that allows you to adjust the update settings", 29 | "description": "This object contains strings related to the logged in user, which is mostly the settings for the user", 30 | "sillyDeleteConfirmPhrase": "This is a silly phrase, do not translate this directly, make a joke that is easy to type in your language" 31 | }, 32 | "errorReconnect": "Uses MarkDown" 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "alwaysStrict": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "esModuleInterop": true, 8 | "importHelpers": false, 9 | "incremental": true, 10 | "lib": ["esnext", "DOM", "webworker", "DOM.AsyncIterable"], 11 | "module": "ESNext", 12 | "moduleResolution": "Bundler", 13 | "newLine": "lf", 14 | "noEmitHelpers": false, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitReturns": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "preserveConstEnums": true, 20 | "pretty": true, 21 | "removeComments": false, 22 | "resolveJsonModule": true, 23 | "sourceMap": true, 24 | "strict": true, 25 | "target": "es2022", 26 | "useDefineForClassFields": true, 27 | "resolvePackageJsonImports": true, 28 | "skipLibCheck": true, 29 | "outDir": "./dist" 30 | }, 31 | "include": ["src/**/*.ts"], 32 | "exclude": ["node_modules"] 33 | } 34 | --------------------------------------------------------------------------------