diff options
| author | Kit La Touche <kit@transneptune.net> | 2024-11-28 21:54:15 -0500 |
|---|---|---|
| committer | Kit La Touche <kit@transneptune.net> | 2024-11-28 21:54:15 -0500 |
| commit | 810ebb811c40b50ddb95bb9559d7515f46ec2052 (patch) | |
| tree | 993abbd49907b399af933a44fb40e2e88c6933a5 /ui | |
| parent | d23685c0ea46c92c75d43b6d6a361597241dd95e (diff) | |
| parent | 5ce6c9f6277c43caf7413cce255af7bdc947e74c (diff) | |
Merge branch 'main' into wip/stylize
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/app.css | 10 | ||||
| -rw-r--r-- | ui/lib/components/CreateChannelForm.svelte | 7 | ||||
| -rw-r--r-- | ui/lib/components/LogOut.svelte | 2 | ||||
| -rw-r--r-- | ui/lib/components/Message.svelte | 8 | ||||
| -rw-r--r-- | ui/lib/components/MessageInput.svelte | 8 | ||||
| -rw-r--r-- | ui/lib/store/messages.svelte.js | 6 | ||||
| -rw-r--r-- | ui/routes/(app)/+layout.svelte | 34 | ||||
| -rw-r--r-- | ui/routes/+layout.svelte | 2 | ||||
| -rw-r--r-- | ui/tests/lib/components/ActiveChannel.svelte.test.js | 22 | ||||
| -rw-r--r-- | ui/tests/lib/components/ChangePassword.svelte.test.js | 52 | ||||
| -rw-r--r-- | ui/tests/lib/components/Channel.svelte.test.js | 22 | ||||
| -rw-r--r-- | ui/tests/lib/components/ChannelList.svelte.test.js | 22 | ||||
| -rw-r--r-- | ui/tests/lib/components/CreateChannelForm.svelte.test.js | 52 | ||||
| -rw-r--r-- | ui/tests/lib/components/LogIn.svelte.test.js | 22 | ||||
| -rw-r--r-- | ui/tests/lib/components/MessageInput.svelte.test.js | 48 | ||||
| -rw-r--r-- | ui/tests/lib/components/MessageRun.svelte.test.js | 22 |
16 files changed, 316 insertions, 23 deletions
@@ -1,3 +1,13 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* This should help minimize swipe-to-go-back behaviour, enabling our +* swipe-to-reveal-channel-menu behaviour. It won't work in all cases; in iOS +* Safari, when swiping from the screen edge, the OS gets th event and +* handles it before the browser does. +*/ +html, +body { + overscroll-behavior-x: none; +} diff --git a/ui/lib/components/CreateChannelForm.svelte b/ui/lib/components/CreateChannelForm.svelte index afa3f78..d50e60d 100644 --- a/ui/lib/components/CreateChannelForm.svelte +++ b/ui/lib/components/CreateChannelForm.svelte @@ -2,17 +2,16 @@ import { createChannel } from '$lib/apiServer'; let name = $state(''); - let pending = false; - let disabled = $derived(pending); + let disabled = $state(false); async function handleSubmit(event) { event.preventDefault(); - pending = true; + disabled = true; const response = await createChannel(name); if (200 <= response.status && response.status < 300) { name = ''; } - pending = false; + disabled = false; } </script> diff --git a/ui/lib/components/LogOut.svelte b/ui/lib/components/LogOut.svelte index 25dd5e9..52aa039 100644 --- a/ui/lib/components/LogOut.svelte +++ b/ui/lib/components/LogOut.svelte @@ -7,7 +7,7 @@ event.preventDefault(); const response = await logOut(); if (200 <= response.status && response.status < 300) { - currentUser.update(() => null); + currentUser.set(null); goto('/login'); } } diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte index d77c29a..1b32c08 100644 --- a/ui/lib/components/Message.svelte +++ b/ui/lib/components/Message.svelte @@ -1,15 +1,11 @@ <script> - import { marked } from 'marked'; - import DOMPurify from 'dompurify'; - import { deleteMessage } from '$lib/apiServer'; function scroll(message) { message.scrollIntoView(); } - let { id, at, body, editable = false } = $props(); - let renderedBody = $derived(DOMPurify.sanitize(marked.parse(body, { breaks: true }))); + let { id, at, body, renderedBody, editable = false } = $props(); let deleteArmed = $state(false); function onDelete(event) { @@ -27,7 +23,7 @@ } </script> -<div class="message relative" class:bg-warning-800={deleteArmed} {onmouseleave}> +<div class="message relative" class:bg-warning-800={deleteArmed} {onmouseleave} role="article"> <div class="handle chip bg-surface-700 absolute -top-6 right-0"> {at} {#if editable} diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte index 26521e1..22456f3 100644 --- a/ui/lib/components/MessageInput.svelte +++ b/ui/lib/components/MessageInput.svelte @@ -5,16 +5,14 @@ let form; let value = $state(''); - let pending = false; - - let disabled = $derived(pending); + let disabled = $state(false); async function onSubmit(event) { event.preventDefault(); - pending = true; + disabled = true; await postToChannel(channel, value); form.reset(); - pending = false; + disabled = false; } function onKeyDown(event) { diff --git a/ui/lib/store/messages.svelte.js b/ui/lib/store/messages.svelte.js index c0db71b..0ceba54 100644 --- a/ui/lib/store/messages.svelte.js +++ b/ui/lib/store/messages.svelte.js @@ -1,3 +1,6 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + const RUN_COALESCE_MAX_INTERVAL = 10 /* min */ * 60 /* sec */ * 1000; /* ms */ export class Messages { @@ -9,7 +12,8 @@ export class Messages { addMessage(channel, id, { at, sender, body }) { let parsedAt = new Date(at); - const message = { id, at: parsedAt, body }; + let renderedBody = DOMPurify.sanitize(marked.parse(body, { breaks: true })); + const message = { id, at: parsedAt, body, renderedBody }; // You might be thinking, can't this be // diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 5ed3906..7d3a9eb 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -1,7 +1,9 @@ <script> import { page } from '$app/stores'; import { goto } from '$app/navigation'; + import { browser } from '$app/environment'; import { onMount, onDestroy, getContext } from 'svelte'; + import TinyGesture from 'tinygesture'; import { boot, subscribeToEvents } from '$lib/apiServer'; import { currentUser, logins, channelsList, messages } from '$lib/store'; @@ -10,6 +12,7 @@ import CreateChannelForm from '$lib/components/CreateChannelForm.svelte'; let events = null; + let gesture = null; let pageContext = getContext('page'); let { children } = $props(); @@ -17,15 +20,29 @@ let channel = $derived($page.params.channel); function onBooted(boot) { - currentUser.update(() => ({ + currentUser.set({ id: boot.login.id, username: boot.login.name - })); + }); logins.update((value) => value.setLogins(boot.logins)); channelsList.update((value) => value.setChannels(boot.channels)); messages.update((value) => value.setMessages(boot.messages)); } + function setUpGestures() { + if (!browser) { + // Meaningless if we're not in a browser, so... + return; + } + gesture = new TinyGesture(window); + gesture.on('swiperight', (event) => { + pageContext.showMenu = true; + }); + gesture.on('swipeleft', (event) => { + pageContext.showMenu = false; + }); + } + onMount(async () => { let response = await boot(); switch (response.status) { @@ -34,17 +51,19 @@ events = subscribeToEvents(response.data.resume_point); break; case 401: - currentUser.update(() => null); + currentUser.set(null); goto('/login'); break; case 503: - currentUser.update(() => null); + currentUser.set(null); goto('/setup'); break; default: // TODO: display error. break; } + setUpGestures(); + loading = false; }); @@ -52,6 +71,9 @@ if (events !== null) { events.close(); } + if (gesture !== null) { + gesture.destroy(); + } }); function beforeUnload(evt) { @@ -90,8 +112,10 @@ {/if} <style> + /* Just some global CSS variables, don't mind them. + */ :root { - --app-bar-height: 68px; + --app-bar-height: 48px; --input-row-height: 2rem; --interface-padding: 16px; --nav-width: 21rem; diff --git a/ui/routes/+layout.svelte b/ui/routes/+layout.svelte index 8940659..26033e0 100644 --- a/ui/routes/+layout.svelte +++ b/ui/routes/+layout.svelte @@ -24,7 +24,7 @@ let { children } = $props(); </script> -<AppBar class="app-bar"> +<AppBar padding="px-4 pt-0 pb-4"> <svelte:fragment slot="lead"> <button onclick={toggleMenu} class="cursor-pointer"> <img class="w-8 h-8" alt="logo" src={logo} /> diff --git a/ui/tests/lib/components/ActiveChannel.svelte.test.js b/ui/tests/lib/components/ActiveChannel.svelte.test.js new file mode 100644 index 0000000..183c823 --- /dev/null +++ b/ui/tests/lib/components/ActiveChannel.svelte.test.js @@ -0,0 +1,22 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import ActiveChannel from '$lib/components/ActiveChannel.svelte'; + +let component; + +describe('ActiveChannel', () => { + beforeEach(() => { + component = mount(ActiveChannel, { + target: document.body // `document` exists because of jsdom + }); + flushSync(); + }); + + afterEach(() => { + unmount(component); + }); + + test('mounts', async () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/tests/lib/components/ChangePassword.svelte.test.js b/ui/tests/lib/components/ChangePassword.svelte.test.js new file mode 100644 index 0000000..9eb40f5 --- /dev/null +++ b/ui/tests/lib/components/ChangePassword.svelte.test.js @@ -0,0 +1,52 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import ChangePassword from '$lib/components/ChangePassword.svelte'; +import axios from 'axios'; + +let component; + +let mocks = vi.hoisted(() => ({ + post: vi.fn() +})); + +describe('ChangePassword', () => { + beforeEach(() => { + vi.mock('axios', async (importActual) => { + const actual = await importActual(); + + const mockAxios = { + default: { + ...actual.default, + create: vi.fn(() => ({ + ...actual.default.create(), + post: mocks.post + })) + } + }; + + return mockAxios; + }); + mocks.post.mockResolvedValue({ status: 200 }); + + component = mount(ChangePassword, { + target: document.body // `document` exists because of jsdom + }); + flushSync(); + }); + + afterEach(() => { + unmount(component); + }); + + test('onsubmit happy path', async () => { + // Set value in all three inputs at once: + const inputs = document.body.querySelectorAll('input[type=password]'); + inputs.value = 'pass'; + // Click the button, then flush the changes so you can synchronously write expectations + document.body.querySelector('button[type=submit]').click(); + flushSync(); + + // expect(axios.post).toHaveBeenCalledWith('/password', { password: 'pass', to: 'pass' }); + expect(Array.from(inputs.values()).map((i) => i.value)).toEqual(['', '', '']); + }); +}); diff --git a/ui/tests/lib/components/Channel.svelte.test.js b/ui/tests/lib/components/Channel.svelte.test.js new file mode 100644 index 0000000..a6fdab9 --- /dev/null +++ b/ui/tests/lib/components/Channel.svelte.test.js @@ -0,0 +1,22 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import Channel from '$lib/components/Channel.svelte'; + +let component; + +describe('Channel', () => { + beforeEach(() => { + component = mount(Channel, { + target: document.body // `document` exists because of jsdom + }); + flushSync(); + }); + + afterEach(() => { + unmount(component); + }); + + test('mounts', async () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/tests/lib/components/ChannelList.svelte.test.js b/ui/tests/lib/components/ChannelList.svelte.test.js new file mode 100644 index 0000000..7b5ae4a --- /dev/null +++ b/ui/tests/lib/components/ChannelList.svelte.test.js @@ -0,0 +1,22 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import ChannelList from '$lib/components/ChannelList.svelte'; + +let component; + +describe('ChannelList', () => { + beforeEach(() => { + component = mount(ChannelList, { + target: document.body // `document` exists because of jsdom + }); + flushSync(); + }); + + afterEach(() => { + unmount(component); + }); + + test('mounts', async () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/tests/lib/components/CreateChannelForm.svelte.test.js b/ui/tests/lib/components/CreateChannelForm.svelte.test.js new file mode 100644 index 0000000..a4a4695 --- /dev/null +++ b/ui/tests/lib/components/CreateChannelForm.svelte.test.js @@ -0,0 +1,52 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import CreateChannelForm from '$lib/components/CreateChannelForm.svelte'; +import axios from 'axios'; + +let component; + +let mocks = vi.hoisted(() => ({ + post: vi.fn() +})); + +describe('CreateChannelForm', () => { + beforeEach(() => { + vi.mock('axios', async (importActual) => { + const actual = await importActual(); + + const mockAxios = { + default: { + ...actual.default, + create: vi.fn(() => ({ + ...actual.default.create(), + post: mocks.post + })) + } + }; + + return mockAxios; + }); + mocks.post.mockResolvedValue({ status: 200 }); + + component = mount(CreateChannelForm, { + target: document.body // `document` exists because of jsdom + }); + flushSync(); + }); + + afterEach(() => { + unmount(component); + }); + + test('onsubmit happy path', async () => { + // Set value on the one input this should match: + const inputs = document.body.querySelectorAll('input[type=text]'); + inputs.value = 'channel name'; + // Click the button, then flush the changes so you can synchronously write expectations + document.body.querySelector('button[type=submit]').click(); + flushSync(); + + expect(mocks.post).toHaveBeenCalled(); + expect(Array.from(inputs.values()).map((i) => i.value)).toEqual(['']); + }); +}); diff --git a/ui/tests/lib/components/LogIn.svelte.test.js b/ui/tests/lib/components/LogIn.svelte.test.js new file mode 100644 index 0000000..b64d846 --- /dev/null +++ b/ui/tests/lib/components/LogIn.svelte.test.js @@ -0,0 +1,22 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import LogIn from '$lib/components/LogIn.svelte'; + +let component; + +describe('LogIn', () => { + beforeEach(() => { + component = mount(LogIn, { + target: document.body // `document` exists because of jsdom + }); + flushSync(); + }); + + afterEach(() => { + unmount(component); + }); + + test('mounts', async () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/tests/lib/components/MessageInput.svelte.test.js b/ui/tests/lib/components/MessageInput.svelte.test.js new file mode 100644 index 0000000..3dde5d7 --- /dev/null +++ b/ui/tests/lib/components/MessageInput.svelte.test.js @@ -0,0 +1,48 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import MessageInput from '$lib/components/MessageInput.svelte'; +import axios from 'axios'; + +let component; + +let mocks = vi.hoisted(() => ({ + post: vi.fn() +})); + +describe('MessageInput', () => { + beforeEach(() => { + vi.mock('axios', async (importActual) => { + const actual = await importActual(); + + const mockAxios = { + default: { + ...actual.default, + create: vi.fn(() => ({ + ...actual.default.create(), + post: mocks.post + })) + } + }; + + return mockAxios; + }); + mocks.post.mockResolvedValue({ status: 200 }); + + component = mount(MessageInput, { + target: document.body // `document` exists because of jsdom + }); + flushSync(); + }); + + afterEach(() => { + unmount(component); + }); + + test('onsubmit happy path', async () => { + // Click the button, then flush the changes so you can synchronously write expectations + document.body.querySelector('button[type=submit]').click(); + flushSync(); + + expect(mocks.post).toHaveBeenCalled(); + }); +}); diff --git a/ui/tests/lib/components/MessageRun.svelte.test.js b/ui/tests/lib/components/MessageRun.svelte.test.js new file mode 100644 index 0000000..7671c52 --- /dev/null +++ b/ui/tests/lib/components/MessageRun.svelte.test.js @@ -0,0 +1,22 @@ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import MessageRun from '$lib/components/MessageRun.svelte'; + +let component; + +describe('MessageRun', () => { + beforeEach(() => { + component = mount(MessageRun, { + target: document.body // `document` exists because of jsdom + }); + flushSync(); + }); + + afterEach(() => { + unmount(component); + }); + + test('mounts', async () => { + expect(component).toBeTruthy(); + }); +}); |
