init: initial commit
This commit is contained in:
commit
3b2b65447d
|
@ -0,0 +1,13 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Ignore files for PNPM, NPM and YARN
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
|
@ -0,0 +1,20 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||||
|
plugins: ['svelte3', '@typescript-eslint'],
|
||||||
|
ignorePatterns: ['*.cjs'],
|
||||||
|
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||||
|
settings: {
|
||||||
|
'svelte3/typescript': () => require('typescript')
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaVersion: 2020
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2017: true,
|
||||||
|
node: true
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,11 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.log/
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
|
@ -0,0 +1,13 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Ignore files for PNPM, NPM and YARN
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"pluginSearchDirs": ["."],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
# create-svelte
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create a new project in the current directory
|
||||||
|
npm create svelte@latest
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npm create svelte@latest my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"name": "litechatplus",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||||
|
"format": "prettier --plugin-search-dir . --write ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^1.0.0",
|
||||||
|
"@sveltejs/kit": "^1.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||||
|
"@typescript-eslint/parser": "^5.45.0",
|
||||||
|
"eslint": "^8.28.0",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-plugin-svelte3": "^4.0.0",
|
||||||
|
"prettier": "^2.8.0",
|
||||||
|
"prettier-plugin-svelte": "^2.9.0",
|
||||||
|
"sass": "^1.58.0",
|
||||||
|
"svelte": "^3.54.0",
|
||||||
|
"svelte-check": "^3.0.1",
|
||||||
|
"svelte-floating-ui": "^1.2.8",
|
||||||
|
"svelte-virtual-scroll-list": "^1.1.0",
|
||||||
|
"tslib": "^2.4.1",
|
||||||
|
"typescript": "^4.9.3",
|
||||||
|
"vite": "^4.0.0"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.2.1",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||||
|
"@fortawesome/svelte-fontawesome": "^0.2.0",
|
||||||
|
"detect-it": "^4.0.1",
|
||||||
|
"fedwave-chat-client": "^0.0.1",
|
||||||
|
"jose": "^4.11.2",
|
||||||
|
"svrollbar": "^0.12.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
// for information about these interfaces
|
||||||
|
// and what to do when importing types
|
||||||
|
declare global {
|
||||||
|
const __COMMIT__: string;
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
%sveltekit.head%
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover" style="margin: 0;" class="solarized-light">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,44 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { version } from '$app/environment';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="about d-flex col no-select">
|
||||||
|
<h1 class="litechat">litechat<span class="plus">plus</span></h1>
|
||||||
|
<p>A [<i>fedwave</i>] chat client.</p>
|
||||||
|
<div class="d-flex build-info">
|
||||||
|
<p class="version">Version: {version}</p>
|
||||||
|
<div class="spacer" />
|
||||||
|
<p class="commit">{__COMMIT__}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.about {
|
||||||
|
height: var(--top-screen-cutout-height);
|
||||||
|
width: var(--sidebar-width, 350px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.litechat {
|
||||||
|
color: var(--base-300);
|
||||||
|
|
||||||
|
margin-bottom: 0px;
|
||||||
|
|
||||||
|
& .plus {
|
||||||
|
vertical-align: super;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-info {
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
& > * {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
color: var(--base-400);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let username: string = '';
|
||||||
|
export let avatar: string = '/troll_haz2.png';
|
||||||
|
export let color: string = 'var(--base-400)';
|
||||||
|
export let size: string = 'var(--avatar-size)';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="avatar" style:--color="{color}" style:--size="{size}" on:click>
|
||||||
|
<img src="{avatar}" alt="{`${username}'s avatar`}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.avatar, .avatar > img {
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,193 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Message } from "fedwave-chat-client";
|
||||||
|
import { room, username } from "./chat";
|
||||||
|
export let globalMode = false;
|
||||||
|
export let message: Message;
|
||||||
|
export let isHeader: boolean = true;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="message" class:subseq="{!isHeader}">
|
||||||
|
{#if isHeader}
|
||||||
|
<div class="avatar" style="background-color: {message.color}">
|
||||||
|
<img alt="{message.username}'s avatar" src="{message.avatar || '/troll_haz2.png'}">
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="container">
|
||||||
|
{#if isHeader}
|
||||||
|
<div class="top">
|
||||||
|
<div class="username">
|
||||||
|
{message.username}
|
||||||
|
</div>
|
||||||
|
<div class="badge">
|
||||||
|
{message.badge || ""}
|
||||||
|
</div>
|
||||||
|
<div class="timestamp">
|
||||||
|
</div>
|
||||||
|
<div class="spacer" />
|
||||||
|
{#if globalMode}
|
||||||
|
<a href="/{message.channel}" class="channel"
|
||||||
|
class:local="{$room == message.channel.toLowerCase()}">
|
||||||
|
{message.channel}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="text">
|
||||||
|
{@html message.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--message-line-height: 22px;
|
||||||
|
--avatar-size: 35px;
|
||||||
|
--greentext-color: #789922;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-size: 14px;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-family: IBM Plex Sans
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 24px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:last-child {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover {
|
||||||
|
background: var(--base-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
min-height: var(--avatar-size);
|
||||||
|
min-width: var(--avatar-size);
|
||||||
|
max-height: var(--avatar-size);
|
||||||
|
max-width: var(--avatar-size);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar > img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:not(.subseq) {
|
||||||
|
padding-top: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subseq {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-left: calc(16px + var(--avatar-size));
|
||||||
|
}
|
||||||
|
|
||||||
|
.subseq > .container {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-left: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top .spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: bold;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
height: 12px;
|
||||||
|
font-family: IBM Plex Mono, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel {
|
||||||
|
--background: var(--base-600);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--background);
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 2px;
|
||||||
|
|
||||||
|
text-align: right;
|
||||||
|
font-family: IBM Plex Mono, Consolas, monospace;
|
||||||
|
text-transform: lowercase;
|
||||||
|
|
||||||
|
color: var(--base-700);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel.local {
|
||||||
|
--background: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text :global(.mention) {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text :global(p:empty), .text :global(br:nth-last-child(1)), .text :global(br) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text > :global(blockquote) {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--greentext-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text > :global(p), .text > :global(blockquote) {
|
||||||
|
line-height: var(--message-line-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text :global(h1) {
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text :global(*) {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text :global(p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text :global(img) {
|
||||||
|
height: var(--message-line-height);
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
.text :global(h1 img) {
|
||||||
|
height: calc(2 * var(--message-line-height));
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,123 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import FAB from './ui/FAB.svelte';
|
||||||
|
import MessageView from './MessageView.svelte';
|
||||||
|
import Input from './Input.svelte';
|
||||||
|
|
||||||
|
import { Svrollbar } from 'svrollbar';
|
||||||
|
|
||||||
|
import { chat_lock, chat, localMessages as messages, shouldTweet } from './chat';
|
||||||
|
import { globalMode } from './settings';
|
||||||
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
|
import type { Unsubscriber } from 'svelte/store';
|
||||||
|
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/svelte-fontawesome';
|
||||||
|
import { faArrowDown } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
let stickToBottom = true;
|
||||||
|
|
||||||
|
let viewport: Element, contents: Element;
|
||||||
|
let tweet: HTMLAudioElement;
|
||||||
|
|
||||||
|
shouldTweet.subscribe((should) => {
|
||||||
|
if (should) {
|
||||||
|
if (tweet) {
|
||||||
|
tweet.volume = 0.5;
|
||||||
|
tweet?.play().catch(() => {});
|
||||||
|
}
|
||||||
|
shouldTweet.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function scrollToBottom(smooth = true) {
|
||||||
|
viewport &&
|
||||||
|
viewport.scrollTo({
|
||||||
|
top: viewport.scrollHeight,
|
||||||
|
behavior: smooth ? 'smooth' : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let releaseLock: Unsubscriber;
|
||||||
|
onMount(async () => {
|
||||||
|
releaseLock = chat_lock.subscribe(() => {});
|
||||||
|
await chat.hydrate();
|
||||||
|
await tick();
|
||||||
|
viewport && (viewport.scrollTop = viewport.scrollHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
messages.subscribe(async () => {
|
||||||
|
if (stickToBottom) {
|
||||||
|
// Wait for the message to be added to the DOM
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Scroll the list to the bottom with the new DOM
|
||||||
|
await scrollToBottom(false);
|
||||||
|
// vlist?.scrollToIndex(ms.length);
|
||||||
|
|
||||||
|
// Fake debounce to make sure we're still stuck at the bottom
|
||||||
|
await tick();
|
||||||
|
stickToBottom = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
releaseLock?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onScroll(e: Event) {
|
||||||
|
if (!e.isTrusted) return;
|
||||||
|
const scrollOffset = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||||
|
|
||||||
|
if (scrollOffset >= 80) {
|
||||||
|
stickToBottom = false;
|
||||||
|
} else if (scrollOffset < 80) {
|
||||||
|
stickToBottom = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scroller">
|
||||||
|
<audio src="/tweet.mp3" bind:this={tweet} />
|
||||||
|
<Svrollbar {viewport} {contents} margin={{ right: 6, bottom: 12, top: 12 }}
|
||||||
|
alwaysVisible
|
||||||
|
--svrollbar-track-width="8px" --svrollbar-thumb-width="4px" />
|
||||||
|
<MessageView messages="{$messages}" showChannel="{$globalMode}"
|
||||||
|
bind:viewport bind:contents on:scroll={onScroll} />
|
||||||
|
{#if !stickToBottom}
|
||||||
|
<FAB --background="{'var(--base-700)'}" on:click={() => scrollToBottom()}><FontAwesomeIcon icon="{faArrowDown}" /></FAB>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Input />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scroller {
|
||||||
|
min-height: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller > :global(.viewport) {
|
||||||
|
overflow: scroll;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller :global(.contents) {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller > :global(*) {
|
||||||
|
/* hide scrollbar */
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller > :global(*::-webkit-scrollbar) {
|
||||||
|
/* hide scrollbar */
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller :global(.v-thumb) {
|
||||||
|
z-index: 101;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Input from "$lib/Input.svelte";
|
||||||
|
import Header from "$lib/Header.svelte";
|
||||||
|
import Avatar from '$lib/Avatar.svelte';
|
||||||
|
|
||||||
|
import { color, room, username } from "$lib/chat";
|
||||||
|
import { primaryInput } from 'detect-it';
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { tweened } from "svelte/motion";
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
let scrolledToTop = false, scrolledToBottom = false;
|
||||||
|
let topHeight = 0;
|
||||||
|
let container: Element;
|
||||||
|
export let darkenAmount = 0;
|
||||||
|
onMount(() => {
|
||||||
|
container.scrollTop = topHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
function overlayClick() {
|
||||||
|
if(scrolledToTop) {
|
||||||
|
container.scrollTo({top: topHeight, behavior: 'smooth'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chat screen">
|
||||||
|
{#if primaryInput == 'mouse' && scrolledToTop}
|
||||||
|
<div class="overlay" transition:fade />
|
||||||
|
{:else if primaryInput == 'touch'}
|
||||||
|
<div class="overlay"
|
||||||
|
style:--darken-amount="{darkenAmount}"
|
||||||
|
style:--should-display="{scrolledToBottom ? 'none' : 'unset'}"
|
||||||
|
on:click="{overlayClick}" />
|
||||||
|
{/if}
|
||||||
|
<Header flex --header-color="var(--top-screen-background-color)">
|
||||||
|
<div class="vert-center room-name">#{$room}</div>
|
||||||
|
<div class="spacer"/>
|
||||||
|
<div class="vert-center avatar">
|
||||||
|
<Avatar color="{$color}" username="{$username}" />
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
<slot />
|
||||||
|
<Input />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script>
|
||||||
|
export let flex = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="header" class:flex="{flex}">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
--header-size: 48px;
|
||||||
|
--header-color: var(--base-600);
|
||||||
|
--header-z: 101;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
height: var(--header-size);
|
||||||
|
|
||||||
|
/* box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px; */
|
||||||
|
|
||||||
|
background-color: var(--header-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header > :global(*) {
|
||||||
|
z-index: calc(10 + var(--header-z));
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
z-index: var(--header-z);
|
||||||
|
background-color: transparent;
|
||||||
|
bottom: -50px;
|
||||||
|
height: 50px;
|
||||||
|
width: 25px;
|
||||||
|
border-top-left-radius: 25px;
|
||||||
|
box-shadow: 0 -25px 0 0 var(--header-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
z-index: var(--header-z);
|
||||||
|
background-color: transparent;
|
||||||
|
bottom: -50px;
|
||||||
|
height: 50px;
|
||||||
|
width: 25px;
|
||||||
|
border-top-right-radius: 25px;
|
||||||
|
box-shadow: 0 -25px 0 0 var(--header-color);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte"
|
||||||
|
import { send } from "./chat";
|
||||||
|
|
||||||
|
let input = "";
|
||||||
|
async function keydown(e: KeyboardEvent) {
|
||||||
|
if(e.key === "Enter") {
|
||||||
|
send(input);
|
||||||
|
e.preventDefault();
|
||||||
|
await tick();
|
||||||
|
input = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<textarea bind:value="{input}" on:keydown={keydown} class="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
height: calc(2 * var(--message-line-height));
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
z-index: 100;
|
||||||
|
position: relative
|
||||||
|
}
|
||||||
|
|
||||||
|
.container::before, .container::after {
|
||||||
|
content: "";
|
||||||
|
z-index: 99;
|
||||||
|
background: linear-gradient(180deg, rgba(var(--base-800-rgb),0) 0%, rgb(var(--base-800-rgb)) 75%);
|
||||||
|
width: 24px;
|
||||||
|
position: absolute;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container::before { left: 0; }
|
||||||
|
.container::after { right: 0; }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex-grow: 1;
|
||||||
|
resize: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: none;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--base-700);
|
||||||
|
color: var(--base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ChatMessage from "./ChatMessage.svelte";
|
||||||
|
import type { TaggedMessage } from "./chat";
|
||||||
|
|
||||||
|
export let showChannel: boolean = true;
|
||||||
|
export let messages: TaggedMessage[] = [];
|
||||||
|
|
||||||
|
export let viewport: Element = undefined as any as Element;
|
||||||
|
export let contents: Element = undefined as any as Element;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="viewport" bind:this={viewport} on:scroll>
|
||||||
|
<div class="contents" bind:this={contents}>
|
||||||
|
{#each messages as message}
|
||||||
|
<ChatMessage message={message.m} isHeader={message.isHeader} globalMode={showChannel} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let clientWidth = 0;
|
||||||
|
export let mobile = false;
|
||||||
|
|
||||||
|
import { FontAwesomeIcon as FA } from '@fortawesome/svelte-fontawesome';
|
||||||
|
import { faHome, faPerson } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sidebar h-100vh d-flex" bind:clientWidth class:mobile="{mobile}">
|
||||||
|
<div class="overlay" />
|
||||||
|
<div class="servers d-flex col">
|
||||||
|
<button class="selected">
|
||||||
|
<FA size="2x" icon="{faPerson}" />
|
||||||
|
</button>
|
||||||
|
<button>
|
||||||
|
<FA size="2x" icon="{faHome}" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "sass:string";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sidebar-server-select-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
z-index: 0;
|
||||||
|
background: var(--base-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:not(.mobile) {
|
||||||
|
width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile {
|
||||||
|
width: 70vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, var(--base-700) 0%, var(--base-700) 5%, rgba(var(--base-700-rgb), 0.0) 50%);
|
||||||
|
filter: string.unquote("opacity(max(var(--overlay-opacity, 0), 0))");
|
||||||
|
pointer-events: var(--should-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.servers {
|
||||||
|
width: var(--sidebar-server-select-width);
|
||||||
|
background-color: var(--base-500);
|
||||||
|
|
||||||
|
& > button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin:0;
|
||||||
|
height: calc(var(--sidebar-server-select-width) - 16px);
|
||||||
|
margin: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
background-color: var(--base-300);
|
||||||
|
color: var(--base-700);
|
||||||
|
|
||||||
|
transition-property: background-color, color, border-radius;
|
||||||
|
transition-duration: 80ms;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--base-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: var(--base-700);
|
||||||
|
color: var(--base-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,162 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { decodeJwt } from 'jose';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { fly, slide } from 'svelte/transition';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let token = '';
|
||||||
|
|
||||||
|
let selectedMethod = 'token';
|
||||||
|
|
||||||
|
function isValidJwt(token: string) {
|
||||||
|
try {
|
||||||
|
decodeJwt(token);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let error: boolean = false;
|
||||||
|
|
||||||
|
function signIn() {
|
||||||
|
let detail = {};
|
||||||
|
console.log(token);
|
||||||
|
switch (selectedMethod) {
|
||||||
|
case 'token':
|
||||||
|
if (!token || !isValidJwt(token)) {
|
||||||
|
error = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detail.type = 'token';
|
||||||
|
detail.data = token;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch('signIn', {
|
||||||
|
method: selectedMethod,
|
||||||
|
...detail
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="d-flex col w-100 tab-bar">
|
||||||
|
<div class="d-flex align-center signin-types">
|
||||||
|
<p class="no-select">Method:</p>
|
||||||
|
<div class="signin-buttons grow-1 d-flex">
|
||||||
|
<button
|
||||||
|
class="grow-1"
|
||||||
|
disabled
|
||||||
|
on:click={() => (selectedMethod = 'userpass')}
|
||||||
|
class:selected={selectedMethod == 'userpass'}>Username</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="grow-1"
|
||||||
|
on:click={() => (selectedMethod = 'token')}
|
||||||
|
class:selected={selectedMethod == 'token'}>Token</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signin-field">
|
||||||
|
{#if selectedMethod == 'token'}
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
class:error
|
||||||
|
spellcheck="false"
|
||||||
|
on:change={() => (error = false)}
|
||||||
|
placeholder="Paste token here"
|
||||||
|
bind:value={token}
|
||||||
|
/>
|
||||||
|
{#if error}
|
||||||
|
<div class="error" style="font-size: 12px; position:absolute;" transition:fly>
|
||||||
|
Invalid token
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex w-100 space-between submit-buttons">
|
||||||
|
<div class="spacer" />
|
||||||
|
<button on:click={() => dispatch('close')} class="close">Close</button>
|
||||||
|
<button on:click={signIn} class="signin">Sign In</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.selected {
|
||||||
|
background-color: var(--base-600);
|
||||||
|
color: var(--base-700);
|
||||||
|
position: relative;
|
||||||
|
margin-left: -32px;
|
||||||
|
margin-right: -32px;
|
||||||
|
border-radius: 24px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-between {
|
||||||
|
& > :not(:last-child) {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-field {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
& p {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
margin-right: 16px;
|
||||||
|
font-style: oblique;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
background-color: var(--base-800);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border-color: var(--red);
|
||||||
|
outline-color: var(--red);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-buttons {
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
.signin {
|
||||||
|
background-color: var(--cyan);
|
||||||
|
color: var(--base-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
& button {
|
||||||
|
height: 36px;
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,316 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Header from './Header.svelte';
|
||||||
|
import { globalMode, theme } from './settings';
|
||||||
|
import { mentions } from './chat';
|
||||||
|
import MessageView from './MessageView.svelte';
|
||||||
|
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/svelte-fontawesome';
|
||||||
|
import { faEarthEurope, faPaintRoller, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
import { Svrollbar } from 'svrollbar';
|
||||||
|
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { tick, onMount } from 'svelte';
|
||||||
|
import About from './About.svelte';
|
||||||
|
|
||||||
|
export let clientHeight;
|
||||||
|
|
||||||
|
let viewport: Element, contents: Element;
|
||||||
|
let stickToBottom: boolean = false;
|
||||||
|
function onScroll() {
|
||||||
|
const scrollOffset = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||||
|
|
||||||
|
if (scrollOffset >= 80) {
|
||||||
|
stickToBottom = false;
|
||||||
|
} else if (scrollOffset < 80) {
|
||||||
|
stickToBottom = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
viewport &&
|
||||||
|
viewport.scrollTo({
|
||||||
|
top: viewport.scrollHeight,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
mentions.subscribe(async () => {
|
||||||
|
if (stickToBottom) {
|
||||||
|
// Wait for the message to be added to the DOM
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Scroll the list to the bottom with the new DOM
|
||||||
|
viewport &&
|
||||||
|
viewport.scrollTo({
|
||||||
|
top: viewport.scrollHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function globalModeClick() {
|
||||||
|
$globalMode = !$globalMode;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div transition:slide class="top screen" bind:clientHeight>
|
||||||
|
<div class="about">
|
||||||
|
<About />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Header flex --header-color="var(--top-screen-header-color)">
|
||||||
|
this is where instance branding goes
|
||||||
|
</Header>
|
||||||
|
<div class="contents">
|
||||||
|
<div class="settings">
|
||||||
|
<div class="quick-settings">
|
||||||
|
<div class="button" on:click="{globalModeClick}"
|
||||||
|
class:enabled="{$globalMode}">
|
||||||
|
<div class="button-icon">
|
||||||
|
<FontAwesomeIcon icon="{faEarthEurope}" />
|
||||||
|
</div>
|
||||||
|
<div class="button-text">
|
||||||
|
<div class="title">
|
||||||
|
Global Mode
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">
|
||||||
|
{$globalMode ? 'Enabled' : 'Disabled'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button">
|
||||||
|
<div class="button-icon">
|
||||||
|
<FontAwesomeIcon icon="{faPaintRoller}" />
|
||||||
|
</div>
|
||||||
|
<div class="button-text">
|
||||||
|
<div class="title">
|
||||||
|
Theme
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">
|
||||||
|
{$theme}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-icon">
|
||||||
|
<FontAwesomeIcon icon="{faChevronRight}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button" />
|
||||||
|
<div class="button" />
|
||||||
|
</div>
|
||||||
|
<div class="spacer"/>
|
||||||
|
<div class="options-bar">
|
||||||
|
<div class="button enabled">Edit</div>
|
||||||
|
<div class="button enabled">Theme</div>
|
||||||
|
<div class="button enabled">Settings</div>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"/>
|
||||||
|
</div>
|
||||||
|
<div class="recent-mentions">
|
||||||
|
<div class="caption">Recent Mentions:</div>
|
||||||
|
<div class="scroller">
|
||||||
|
{#if $mentions.length}
|
||||||
|
<Svrollbar alwaysVisible {viewport} {contents}
|
||||||
|
margin="{{top: 8, bottom: 8, right: 4}}"
|
||||||
|
--svrollbar-track-width="8px" --svrollbar-thumb-width="4px" />
|
||||||
|
<MessageView messages="{$mentions}" showChannel bind:viewport bind:contents
|
||||||
|
on:scroll="{onScroll}" />
|
||||||
|
{:else}
|
||||||
|
<div class="viewport" bind:this="{viewport}">
|
||||||
|
<div class="empty-mentions">
|
||||||
|
<p class="caption">Nobody has mentioned you yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
--top-screen-cutout-height: 20vh;
|
||||||
|
--top-screen-background-color: var(--base-700);
|
||||||
|
--top-screen-header-color: var(--base-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
height: calc(100vh - var(--top-screen-cutout-height));
|
||||||
|
max-height: calc(100vh - var(--top-screen-cutout-height));
|
||||||
|
background-color: var(--top-screen-background-color);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.container.mobile) .about {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.about {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 500;
|
||||||
|
bottom: calc(-1 * var(--top-screen-cutout-height));
|
||||||
|
opacity: var(--about-opacity);
|
||||||
|
display: var(--about-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.container.mobile) .settings {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.container:not(.mobile)) .settings {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.container.mobile) .contents {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contents {
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
padding-top: 14px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
grid-template-columns: repeat(2, 50%);
|
||||||
|
height: 35%;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
margin: 8px;
|
||||||
|
border-radius: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
/* padding-left: 24px; */
|
||||||
|
font-size: 1.4em;
|
||||||
|
transition: background-color;
|
||||||
|
transition-duration: 250ms;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: calc(15px + 0.390625vw);
|
||||||
|
}
|
||||||
|
.button.enabled {
|
||||||
|
background: var(--base-600);
|
||||||
|
color: var(--base-700);
|
||||||
|
}
|
||||||
|
.button:not(.enabled) {
|
||||||
|
background: var(--base-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button .button-icon {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.button .title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: .9em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.button .subtitle {
|
||||||
|
font-size: .85em;
|
||||||
|
font-weight: 300;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
height: 10%;
|
||||||
|
}
|
||||||
|
.options-bar .button {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.options-bar .button:nth-child(2) {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
min-height: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.scroller > :global(.viewport) {
|
||||||
|
overflow: scroll;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
.scroller > :global(*) {
|
||||||
|
/* hide scrollbar */
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
}
|
||||||
|
.scroller > :global(*::-webkit-scrollbar) {
|
||||||
|
/* hide scrollbar */
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.scroller :global(.v-scrollbar) {
|
||||||
|
z-index: 102;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.container.mobile) .recent-mentions {
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
:global(.container:not(.mobile)) .recent-mentions {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-mentions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 2;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-mentions :global(.viewport) {
|
||||||
|
background-color: var(--base-800);
|
||||||
|
border-radius: 12px;
|
||||||
|
/* border: 1.6px solid black; */
|
||||||
|
box-shadow: rgb(0 0 0 / 18%) 0px 2px 4px 0px inset;
|
||||||
|
}
|
||||||
|
.recent-mentions :global(.message) {
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-mentions {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,139 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Avatar from "./Avatar.svelte";
|
||||||
|
|
||||||
|
import { color, username, num, isTroll } from "./chat";
|
||||||
|
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/svelte-fontawesome';
|
||||||
|
import { faHashtag, faRightFromBracket, faRightToBracket, faRotateRight } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function signIn() {
|
||||||
|
dispatch('signIn')
|
||||||
|
}
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
dispatch('signOut')
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="d-flex col user-settings no-select">
|
||||||
|
<div class="d-flex">
|
||||||
|
<Avatar color="{$color}" username="{$username}" />
|
||||||
|
<div class="d-flex col username">
|
||||||
|
<div>
|
||||||
|
{$username}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex">
|
||||||
|
<FontAwesomeIcon size="sm" icon="{faHashtag}" />
|
||||||
|
<div>
|
||||||
|
{$num}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{#if $isTroll}
|
||||||
|
|
||||||
|
<div on:click={signIn}>
|
||||||
|
<div>
|
||||||
|
<FontAwesomeIcon icon="{faRightToBracket}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Sign In
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div on:click={signOut}>
|
||||||
|
<div>
|
||||||
|
<FontAwesomeIcon icon="{faRotateRight}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Refresh Troll Token
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<div on:click={signOut}>
|
||||||
|
<div>
|
||||||
|
<FontAwesomeIcon icon="{faRightFromBracket}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Sign Out
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "$lib/styles/utils";
|
||||||
|
|
||||||
|
.user-settings {
|
||||||
|
width: 250px;
|
||||||
|
|
||||||
|
margin-left: 16px;
|
||||||
|
margin-right: 16px;
|
||||||
|
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
|
||||||
|
background: var(--base-700);
|
||||||
|
color: var(--base-300);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
& > :first-child {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :last-child {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 90%;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
border-color: var(--base-600);
|
||||||
|
border-width: 0;
|
||||||
|
border-top-width: 0.5px;
|
||||||
|
border-bottom-width: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-settings {
|
||||||
|
border: 1px solid;
|
||||||
|
box-shadow: rgb(0 0 0 / 12%) 0px 1px 3px, rgb(0 0 0 / 24%) 0px 1px 2px;
|
||||||
|
|
||||||
|
& > :first-child {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
@extend .d-flex;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
& > :global(:first-child) {
|
||||||
|
margin-right: 16px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :not(:first-child):hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--base-800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { writable, readable, derived, get, type Subscriber } from 'svelte/store';
|
||||||
|
import {
|
||||||
|
FedwaveChat,
|
||||||
|
type Message,
|
||||||
|
type OutgoingMessage,
|
||||||
|
type Credentials
|
||||||
|
} from 'fedwave-chat-client';
|
||||||
|
import { globalMode } from './settings';
|
||||||
|
import * as jose from 'jose';
|
||||||
|
|
||||||
|
export const chat = new FedwaveChat();
|
||||||
|
|
||||||
|
export type TaggedMessage = {
|
||||||
|
m: Message;
|
||||||
|
id: string;
|
||||||
|
isHeader: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function send(m: string | OutgoingMessage) {
|
||||||
|
chat.sendMessage(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
let setConnected: Subscriber<boolean> | undefined;
|
||||||
|
export const connected = readable<boolean>(false, (setf) => {
|
||||||
|
setConnected = setf;
|
||||||
|
return () => {
|
||||||
|
setConnected = () => {};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCredentialsFromLocalStorage() {
|
||||||
|
try {
|
||||||
|
const token: Credentials | null = JSON.parse(localStorage.getItem('chatToken') || '""') || null;
|
||||||
|
if (token !== null) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let setCredentials: Subscriber<Credentials> | undefined;
|
||||||
|
export const credentials = readable<Credentials>(getCredentialsFromLocalStorage(), (setf) => {
|
||||||
|
setCredentials = setf;
|
||||||
|
return () => {
|
||||||
|
setCredentials = () => {};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateCredentials = (creds: Credentials) => {
|
||||||
|
setCredentials?.(creds);
|
||||||
|
|
||||||
|
if (get(connected)) {
|
||||||
|
chat.disconnect();
|
||||||
|
chat.connect(get(room), creds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
chat.onUpdateCredentials = async function (creds) {
|
||||||
|
setCredentials?.(creds);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodedToken = derived(credentials, (creds) => {
|
||||||
|
const token = typeof creds == 'string' ? creds : creds?.jwt || '';
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = jose.decodeJwt(token).sub as any;
|
||||||
|
} catch {}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const username = derived(decodedToken, (token) => {
|
||||||
|
return token?.username;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const trollRegex = /^troll:[\w]{4}$/i;
|
||||||
|
export const isTroll = derived(username, (usr) => {
|
||||||
|
return trollRegex.test(usr);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const color = derived(decodedToken, (token) => {
|
||||||
|
return token?.color;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const num = derived(decodedToken, (token) => {
|
||||||
|
return token?.num;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const room = writable<string>(chat.room);
|
||||||
|
room.subscribe((room) => {
|
||||||
|
chat.room = room;
|
||||||
|
});
|
||||||
|
|
||||||
|
globalMode.subscribe((v) => {
|
||||||
|
chat.global = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const shouldTweet = writable(false);
|
||||||
|
|
||||||
|
// Essentially a shared_ptr idiom. When nobody is subscribed to this, the chat
|
||||||
|
// d/c's.
|
||||||
|
export const chat_lock = readable(null, () => {
|
||||||
|
const release_connected = connected.subscribe(() => {});
|
||||||
|
|
||||||
|
const creds = get(credentials);
|
||||||
|
|
||||||
|
chat.connect(chat.room, creds).then(() => setConnected?.(true));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chat.disconnect();
|
||||||
|
setConnected?.(false);
|
||||||
|
|
||||||
|
release_connected();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const messageLimit = writable<number>(200);
|
||||||
|
|
||||||
|
export const usernameRegex = derived(username, (user) => {
|
||||||
|
return new RegExp(`(@${user})\\b`, 'gi');
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mentions = writable<TaggedMessage[]>([]);
|
||||||
|
|
||||||
|
export const messages = readable<TaggedMessage[]>([], (set) => {
|
||||||
|
chat.rcvMessageBulk = function (ms) {
|
||||||
|
const usrRegex = get(usernameRegex);
|
||||||
|
const old = get(messages);
|
||||||
|
let prev = old[old.length - 1]?.m ?? { username: null, timestamp: 0 };
|
||||||
|
|
||||||
|
let containsUsername = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < ms.length; ++i) {
|
||||||
|
ms[i].message = ms[i].message.replaceAll(usrRegex, `<span class="mention">$1</span>`);
|
||||||
|
const tagged: TaggedMessage = {
|
||||||
|
m: ms[i],
|
||||||
|
id: ms[i].username + ms[i].timestamp,
|
||||||
|
isHeader:
|
||||||
|
prev.username != ms[i].username || Math.abs(prev.timestamp - ms[i].timestamp) > 60000
|
||||||
|
};
|
||||||
|
|
||||||
|
if (usrRegex.test(ms[i].message)) {
|
||||||
|
mentions.update((m) => m.concat({ ...tagged, isHeader: true }));
|
||||||
|
containsUsername = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
old.push(tagged);
|
||||||
|
|
||||||
|
prev = ms[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = get(messageLimit);
|
||||||
|
if (old.length > limit) {
|
||||||
|
old.splice(0, old.length - limit);
|
||||||
|
old[0].isHeader = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldTweet.set(containsUsername);
|
||||||
|
set(old);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chat.rcvMessageBulk = function () {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const localMessages = derived([messages, room, globalMode], ([ms, room, global]) => {
|
||||||
|
if (global) return ms;
|
||||||
|
return ms.filter((m) => m.m.channel.toLowerCase() === room.toLowerCase());
|
||||||
|
});
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const globalMode = writable(true);
|
||||||
|
|
||||||
|
export type Theme = 'solarized-light' | 'solarized-dark';
|
||||||
|
|
||||||
|
export const theme = writable<Theme>('solarized-light');
|
|
@ -0,0 +1,40 @@
|
||||||
|
body {
|
||||||
|
background-color: var(--base-800);
|
||||||
|
color: var(--base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--blue);
|
||||||
|
text-decoration: none;
|
||||||
|
&:visited {
|
||||||
|
color: var(--violet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1.4px solid var(--base-600);
|
||||||
|
background-color: var(--base-700);
|
||||||
|
color: inherit;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
transition: background-color 80ms ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--base-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
border: 1.4px dashed var(--base-600);
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: var(--base-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
border-color: var(--base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
outline-color: var(--base-300);
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
@use "$lib/styles/utils";
|
||||||
|
|
||||||
|
$solarized-light: (
|
||||||
|
"base-100": #002b36,
|
||||||
|
"base-200": #073642,
|
||||||
|
"base-300": #586e75,
|
||||||
|
"base-400": #657b83,
|
||||||
|
"base-500": #839496,
|
||||||
|
"base-600": #93a1a1,
|
||||||
|
"base-700": #eee8d5,
|
||||||
|
"base-800": #fdf6e3,
|
||||||
|
"yellow": #b58900,
|
||||||
|
"orange": #cb4b16,
|
||||||
|
"red": #dc322f,
|
||||||
|
"magenta": #d33682,
|
||||||
|
"violet": #6c71c4,
|
||||||
|
"blue": #268bd2,
|
||||||
|
"cyan": #2aa198,
|
||||||
|
"green": #859900,
|
||||||
|
);
|
||||||
|
|
||||||
|
$solarized-dark: (
|
||||||
|
"base-800": #002b36,
|
||||||
|
"base-700": #073642,
|
||||||
|
"base-600": #586e75,
|
||||||
|
"base-500": #657b83,
|
||||||
|
"base-400": #839496,
|
||||||
|
"base-300": #93a1a1,
|
||||||
|
"base-200": #eee8d5,
|
||||||
|
"base-100": #fdf6e3,
|
||||||
|
"yellow": #b58900,
|
||||||
|
"orange": #cb4b16,
|
||||||
|
"red": #dc322f,
|
||||||
|
"magenta": #d33682,
|
||||||
|
"violet": #6c71c4,
|
||||||
|
"blue": #268bd2,
|
||||||
|
"cyan": #2aa198,
|
||||||
|
"green": #859900,
|
||||||
|
);
|
||||||
|
|
||||||
|
.solarized-light {
|
||||||
|
@include utils.df-scheme($solarized-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.solarized-dark {
|
||||||
|
@include utils.df-scheme($solarized-dark);
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
@mixin df-c($name, $color) {
|
||||||
|
--#{$name}: #{$color};
|
||||||
|
--#{$name}-rgb: #{color.red($color)}, #{color.green($color)}, #{color.blue($color)};
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin df-scheme($scheme) {
|
||||||
|
@each $name, $color in $scheme {
|
||||||
|
@include df-c($name, $color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-select {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer, .grow-1 {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-0 {
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.basis-0 {
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-point {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-x {
|
||||||
|
overflow: scroll;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
& > * {
|
||||||
|
@extend .snap-point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-y {
|
||||||
|
overflow: scroll;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
& > * {
|
||||||
|
@extend .snap-point;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-100vh {
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
.w-100vw {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-100 { width: 100%; }
|
||||||
|
.h-100 { height: 100%; }
|
||||||
|
|
||||||
|
.hide-scrollbar {
|
||||||
|
/* hide scrollbar */
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border: .5px solid var(--base-600);
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
<script lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="fab" on:click>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fab {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--left, calc(100% - 2 * var(--size, 48px)));
|
||||||
|
top: var(--top, calc(100% - 2 * var(--size, 48px)));
|
||||||
|
border-radius: 9999px;
|
||||||
|
width: var(--size, 48px);
|
||||||
|
height: var(--size, 48px);
|
||||||
|
background: var(--background, white);
|
||||||
|
border: none;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab > * { z-index: 202; }
|
||||||
|
|
||||||
|
.fab:active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 201;
|
||||||
|
background: rgba(0,0,0, 0.1);
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
const close = () => dispatch('close');
|
||||||
|
|
||||||
|
let modal;
|
||||||
|
|
||||||
|
const handle_keydown = e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
// trap focus
|
||||||
|
const nodes = modal.querySelectorAll('*');
|
||||||
|
const tabbable = Array.from(nodes).filter(n => n.tabIndex >= 0);
|
||||||
|
|
||||||
|
let index = tabbable.indexOf(document.activeElement);
|
||||||
|
if (index === -1 && e.shiftKey) index = 0;
|
||||||
|
|
||||||
|
index += tabbable.length + (e.shiftKey ? -1 : 1);
|
||||||
|
index %= tabbable.length;
|
||||||
|
|
||||||
|
tabbable[index].focus();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previously_focused = typeof document !== 'undefined' && document.activeElement;
|
||||||
|
|
||||||
|
if (previously_focused) {
|
||||||
|
onDestroy(() => {
|
||||||
|
previously_focused.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handle_keydown}/>
|
||||||
|
|
||||||
|
<div class="modal-background" on:click={close}></div>
|
||||||
|
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" bind:this={modal}>
|
||||||
|
<slot name="header"></slot>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-background {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: calc(100vw - 4em);
|
||||||
|
max-width: 32em;
|
||||||
|
max-height: calc(100vh - 4em);
|
||||||
|
overflow: auto;
|
||||||
|
transform: translate(-50%,-50%);
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--base-700);
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,16 @@
|
||||||
|
export function clickOutside(node: Node) {
|
||||||
|
const handleClick = (event: Event) => {
|
||||||
|
if (!node.contains(event.target as Node)) {
|
||||||
|
node.dispatchEvent(new CustomEvent('outclick'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClick, true);
|
||||||
|
document.addEventListener('touchmove', handleClick, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
document.removeEventListener('click', handleClick, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,297 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Header from "$lib/Header.svelte";
|
||||||
|
import TopScreen from '$lib/TopScreen.svelte';
|
||||||
|
import Avatar from '$lib/Avatar.svelte';
|
||||||
|
import Sidebar from "$lib/Sidebar.svelte";
|
||||||
|
import Modal from "$lib/ui/Modal.svelte";
|
||||||
|
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/svelte-fontawesome';
|
||||||
|
import { faBars, faHashtag } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { config } from '@fortawesome/fontawesome-svg-core';
|
||||||
|
import '@fortawesome/fontawesome-svg-core/styles.css' // Import the CSS
|
||||||
|
config.autoAddCss = false // Tell Font Awesome to skip adding the CSS automatically since it's being imported above
|
||||||
|
|
||||||
|
import "$lib/styles/solarized.scss";
|
||||||
|
import "$lib/styles/global.scss";
|
||||||
|
|
||||||
|
import { color, room, username, updateCredentials } from "$lib/chat";
|
||||||
|
import { primaryInput } from 'detect-it';
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
import { clickOutside } from "$lib/ui/clickOutside";
|
||||||
|
|
||||||
|
import { offset, flip, shift } from "@floating-ui/dom";
|
||||||
|
import { createFloatingActions } from "svelte-floating-ui";
|
||||||
|
import UserSettings from "$lib/UserSettings.svelte";
|
||||||
|
import SignIn from "$lib/SignIn.svelte";
|
||||||
|
const [ floatingRef, floatingContent ] = createFloatingActions({
|
||||||
|
strategy: "absolute",
|
||||||
|
placement: "bottom-end",
|
||||||
|
middleware: [
|
||||||
|
offset(6),
|
||||||
|
flip({
|
||||||
|
flipAlignment: false,
|
||||||
|
}),
|
||||||
|
shift(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let showUserSettings = false;
|
||||||
|
function closeUserSettings() {
|
||||||
|
if(showUserSettings)
|
||||||
|
showUserSettings = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let showSignIn = false;
|
||||||
|
function signIn(e: CustomEvent<any>) {
|
||||||
|
switch(e.detail.type) {
|
||||||
|
case 'token':
|
||||||
|
updateCredentials(e.detail.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSignIn = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
closeUserSettings();
|
||||||
|
updateCredentials(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mobile = primaryInput === 'touch';
|
||||||
|
|
||||||
|
let scrolledToTop = false, scrolledToBottom = false,
|
||||||
|
scrolledToLeft = false, scrolledToRight = false;
|
||||||
|
let topHeight = 0;
|
||||||
|
let sidebarWidth = 0;
|
||||||
|
let container: Element, bottomContainer: Element;
|
||||||
|
|
||||||
|
let darkenAmount = 0;
|
||||||
|
let sidebarDarkenAmount = 0;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
container.scrollTop = topHeight;
|
||||||
|
bottomContainer.scrollLeft = sidebarWidth;
|
||||||
|
scrolledToBottom = true;
|
||||||
|
scrolledToRight = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if(!scrolledToRight) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
darkenAmount = (topHeight - container.scrollTop) / topHeight;
|
||||||
|
|
||||||
|
if(container.scrollTop == 0) {
|
||||||
|
scrolledToTop = true;
|
||||||
|
} else {
|
||||||
|
scrolledToTop = false;
|
||||||
|
scrolledToBottom = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(topHeight - container.scrollTop <= 1) {
|
||||||
|
scrolledToBottom = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHorizontalScroll() {
|
||||||
|
sidebarDarkenAmount = (sidebarWidth - bottomContainer.scrollLeft) / sidebarWidth;
|
||||||
|
if(bottomContainer.scrollLeft == 0) {
|
||||||
|
scrolledToLeft = true;
|
||||||
|
} else {
|
||||||
|
scrolledToLeft = false;
|
||||||
|
scrolledToRight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(sidebarWidth - bottomContainer.scrollLeft <= 1) {
|
||||||
|
scrolledToRight = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayClick() {
|
||||||
|
if(scrolledToTop) {
|
||||||
|
container.scrollTo({top: topHeight, behavior: 'smooth'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidebarOverlayClick() {
|
||||||
|
if(scrolledToLeft) {
|
||||||
|
bottomContainer.scrollTo({left: sidebarWidth, behavior: 'smooth'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spacerClick() {
|
||||||
|
if(container && scrolledToRight) {
|
||||||
|
if(scrolledToTop) {
|
||||||
|
container.scrollTo({top: topHeight, behavior: 'smooth'});
|
||||||
|
} else if(scrolledToBottom) {
|
||||||
|
container.scrollTo({top: 0, behavior: 'smooth'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hamburgerClick() {
|
||||||
|
if(bottomContainer && scrolledToBottom) {
|
||||||
|
if(scrolledToLeft) {
|
||||||
|
bottomContainer.scrollTo({left: sidebarWidth, behavior: 'smooth'});
|
||||||
|
} else if(scrolledToRight) {
|
||||||
|
bottomContainer.scrollTo({left: 0, behavior: 'smooth'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container hide-scrollbar snap-y h-100vh"
|
||||||
|
class:mobile="{mobile}"
|
||||||
|
bind:this="{container}" on:scroll="{onScroll}"
|
||||||
|
style:--overflow-y="{!scrolledToRight ? 'hidden' : 'scroll'}"
|
||||||
|
style:--about-opacity="{darkenAmount}"
|
||||||
|
style:--about-display="{scrolledToBottom ? 'none' : 'unset'}">
|
||||||
|
|
||||||
|
<TopScreen bind:clientHeight={topHeight} />
|
||||||
|
|
||||||
|
<div class="screen hide-scrollbar" on:scroll="{onHorizontalScroll}" bind:this="{bottomContainer}"
|
||||||
|
style:--overflow-x="{!scrolledToBottom ? 'hidden' : 'scroll'}">
|
||||||
|
<Sidebar {mobile} bind:clientWidth="{sidebarWidth}"
|
||||||
|
--overlay-opacity="{darkenAmount}"
|
||||||
|
--should-display="{scrolledToBottom ? 'none' : 'unset'}" />
|
||||||
|
<div class="chat h-100vh d-flex col">
|
||||||
|
<div class="overlay clickable"
|
||||||
|
style:--opacity="{darkenAmount}"
|
||||||
|
style:--should-display="{scrolledToBottom ? 'none' : 'unset'}"
|
||||||
|
on:click="{overlayClick}" />
|
||||||
|
{#if mobile}
|
||||||
|
<div class="sidebar-overlay clickable"
|
||||||
|
style:--opacity="{sidebarDarkenAmount}"
|
||||||
|
style:--should-display="{scrolledToRight ? 'none' : 'unset'}"
|
||||||
|
on:click="{sidebarOverlayClick}" />
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
<Header flex --header-color="var(--top-screen-background-color)">
|
||||||
|
<div class="header">
|
||||||
|
{#if mobile}
|
||||||
|
<div class="vert-center hamburger clickable" on:click={hamburgerClick}>
|
||||||
|
<FontAwesomeIcon size="lg" icon="{faBars}" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="vert-center room-name no-select">
|
||||||
|
<FontAwesomeIcon style="padding-right: 4px;" icon="{faHashtag}" />{$room}
|
||||||
|
</div>
|
||||||
|
<div class="spacer clickable" on:click="{spacerClick}" />
|
||||||
|
|
||||||
|
<div class="vert-center avatar clickable" use:floatingRef>
|
||||||
|
<Avatar on:click="{() => {showUserSettings = !showUserSettings}}"
|
||||||
|
color="{$color}" username="{$username}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</Header>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showUserSettings}
|
||||||
|
<div class="user-settings" style="position:absolute;z-index:300;" use:floatingContent
|
||||||
|
use:clickOutside on:outclick="{closeUserSettings}">
|
||||||
|
<UserSettings on:signIn="{closeUserSettings}"
|
||||||
|
on:signIn="{() => showSignIn = true}"
|
||||||
|
on:signOut="{signOut}" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showSignIn}
|
||||||
|
<Modal on:close="{() => showSignIn = false}">
|
||||||
|
<svelte:fragment slot="header">
|
||||||
|
<h1 class="no-select" style="margin-top: 8px;">Sign In</h1>
|
||||||
|
</svelte:fragment>
|
||||||
|
<SignIn on:close="{() => showSignIn = false}"
|
||||||
|
on:signIn="{signIn}" />
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "sass:string";
|
||||||
|
@use "$lib/styles/utils";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
overflow-y: var(--overflow-y, scroll);
|
||||||
|
&:not(.mobile) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
@extend .d-flex;
|
||||||
|
@extend .snap-x;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
overflow-x: var(--overflow-x, scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.sidebar, .chat) {
|
||||||
|
@extend .snap-point;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
position: relative;
|
||||||
|
z-index: 50;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container:not(.mobile) .chat {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile .chat {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay, .overlay {
|
||||||
|
pointer-events: var(--should-display);
|
||||||
|
top: var(--header-size);
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
background-color: rgba(0,0,0, 0.6);
|
||||||
|
z-index: 101;
|
||||||
|
|
||||||
|
filter: string.unquote("opacity(max(var(--opacity, 0), 0))");
|
||||||
|
|
||||||
|
/* will-change: filter; */
|
||||||
|
transition: {
|
||||||
|
property: filter;
|
||||||
|
duration: 300;
|
||||||
|
timing-function: cubic-bezier(0.230, 1.000, 0.320, 1.000); /* easeOutQuint */
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: contents;
|
||||||
|
position: relative;
|
||||||
|
& > * {
|
||||||
|
z-index: calc(var(--header-z) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :global(:first-child), & > :global(:last-child) {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vert-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-name {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
import { credentials } from '$lib/chat';
|
||||||
|
export const load: LayoutLoad = async () => {
|
||||||
|
credentials.subscribe((creds) => localStorage.setItem('chatToken', JSON.stringify(creds)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssr = false;
|
|
@ -0,0 +1 @@
|
||||||
|
export const ssr = false;
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import ChatMessages from "$lib/ChatMessages.svelte";
|
||||||
|
|
||||||
|
import { room } from '$lib/chat';
|
||||||
|
|
||||||
|
$: $room = $page.params.room.toLowerCase();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ChatMessages />
|
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
@ -0,0 +1,26 @@
|
||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const file = fileURLToPath(new URL('package.json', import.meta.url));
|
||||||
|
const json = readFileSync(file, 'utf8');
|
||||||
|
const pkg = JSON.parse(json);
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
|
||||||
|
version: {
|
||||||
|
name: pkg.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import type { UserConfig } from 'vite';
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
void !(function () {
|
||||||
|
typeof self == 'undefined' &&
|
||||||
|
typeof global == 'object' &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
(global.self = global);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const config: UserConfig = {
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'socket.io-client': 'socket.io-client/dist/socket.io.js'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__COMMIT__: JSON.stringify(execSync('git rev-parse HEAD').toString().trim().slice(0, 8))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
Loading…
Reference in New Issue