a bunch of random changes

This commit is contained in:
tezlm 2024-01-09 11:10:56 -08:00
parent 7f6e719414
commit 858f263292
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
19 changed files with 822 additions and 237 deletions

View file

@ -5,6 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="stylesheet" href="/src/index.scss" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base target="_blank" />
<title>jackwagon</title>
</head>
<body>

View file

@ -54,6 +54,24 @@
#nav-spaces {
background: var(--background-4);
grid-area: nav-spaces;
& > ul {
display: flex;
flex-direction: column;
padding: 6px;
gap: 8px;
list-style: none;
& > li {
& > a {
display: block;
background: var(--background-1);
height: 52px;
width: 52px;
border-radius: 4px;
}
}
}
}
#sidebar {
@ -65,7 +83,8 @@
contain: strict;
&.thread {
width: 512px;
// width: 512px;
width: 1024px;
}
}
@ -130,13 +149,13 @@ h1 {
background: var(--background-1);
color: inherit;
padding: 8px;
animation: dialog .1s forwards;
animation: dialog 150ms cubic-bezier(.13,.37,.49,1) forwards;
}
}
@keyframes dialog {
from {
translate: 0 20px;
translate: 32px 0;
opacity: .5;
box-shadow: 0 0 0 #2221;
}

View file

@ -1,4 +1,4 @@
import { lazy } from "solid-js";
import { lazy, onCleanup } from "solid-js";
import "./App.scss";
import { Client } from "sdk";
import { Contextualizer } from "./Context";

View file

@ -65,7 +65,7 @@ function Login() {
<button type="submit" disabled={form.submitting}>{form.submitting ? "logging in..." : "login"}</button>
</Form>
<div style="flex:1"></div>
<p> or <A href="/register">register</A></p>
<p> or <A target="_self" href="/register">register</A></p>
</>)
}
@ -74,6 +74,6 @@ function Register() {
<h1>register</h1>
<p>todo</p>
<div style="flex:1"></div>
<p> or <A href="/login">login</A></p>
<p> or <A target="_self" href="/login">login</A></p>
</>)
}

View file

@ -111,15 +111,10 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
console.log("dispatch action", action);
switch (action.type) {
case "sidebar.focus":
update({
global: {
...globals.global,
sidebar: action.item,
}
});
update("global", "sidebar", action.item);
break;
case "login":
update({ client: action.client });
update("client", action.client);
localStorage.setItem("auth", JSON.stringify(action.client.config));
navigate("/home");
Object.assign(globalThis, { client: action.client });
@ -130,30 +125,15 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
globals.client.logout();
break;
case "dialog": {
update({
global: {
...globals.global,
dialogs: [...globals.global.dialogs, null],
}
});
update("global", "dialogs", [...globals.global.dialogs, null]);
break;
}
case "dialog.close": {
update({
global: {
...globals.global,
dialogs: globals.global.dialogs.slice(0, -1),
}
});
update("global", "dialogs", globals.global.dialogs.slice(0, -1));
break;
}
case "contextmenu.set": {
update({
global: {
...globals.global,
contextMenu: action.menu,
}
});
update("global", "contextMenu", action.menu);
}
}
}

View file

@ -50,7 +50,10 @@ function Autocomplete(props: VoidProps<{ options: Array<UserId> }>) {
)
}
export default function Editor(props: VoidProps<{ onSubmit: (data: { text: string, html: string }) => any }>) {
export default function Editor(props: VoidProps<{
placeholder: string,
onSubmit: (data: { text: string, html: string }) => any,
}>) {
let editorEl: HTMLDivElement;
const [autocompleteOptions, setAutocompleteOptions] = createSignal<null | Array<string>>(null);
@ -159,7 +162,7 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
}),
decorations(state) {
if (state.doc.firstChild!.firstChild === null) {
const placeholder = <div class="placeholder">write something nice...</div> as HTMLDivElement;
const placeholder = <div class="placeholder">{props.placeholder}</div> as HTMLDivElement;
return DecorationSet.create(state.doc, [Decoration.widget(0, placeholder)]);
}

View file

@ -1,4 +1,4 @@
import { Component, For, JSX, Match, Show, Switch, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount } from "solid-js";
import { Component, For, JSX, Match, Show, Switch, VoidProps, createEffect, createResource, createSignal, on, onCleanup, onMount } from "solid-js";
import "./App.scss";
import { Client, Room, RoomList, Thread } from "sdk";
import { RoomView, ThreadsItem } from "./Room";
@ -8,6 +8,8 @@ import { useGlobals } from "./Context";
import { ThreadView } from "./Thread";
import { useParams, A, useNavigate, Navigate } from "@solidjs/router";
import * as menu from "./Menu";
import { useFloating } from "solid-floating-ui";
import { ClientRectObject, ReferenceElement, autoPlacement, autoUpdate, shift } from "@floating-ui/dom";
export default function App(): JSX.Element {
const params = useParams();
@ -28,7 +30,8 @@ export default function App(): JSX.Element {
required_state: [
["m.room.name", ""],
["m.room.topic", ""],
["m.room.member", globals.client.config.userId],
// ["m.room.member", globals.client.config.userId],
["m.room.member", "*"],
],
// timeline_limit: 3,
} as any);
@ -51,10 +54,50 @@ export default function App(): JSX.Element {
onCleanup(() => globals.client.off("list", updateRooms));
const sidebar = () => globals.global.sidebar;
const contextMenu = () => globals.global.contextMenu;
const willHandleRoomContextMenu = (room: Room) => (e: MouseEvent) => {
e.preventDefault();
change({ type: "contextmenu.set", menu: { type: "room", room, x: e.clientX, y: e.clientY } });
}
// TODO: put on main div
function hideContextMenu() {
change({ type: "contextmenu.set", menu: null });
}
window.addEventListener("mousedown", hideContextMenu);
onCleanup(() => window.removeEventListener("mousedown", hideContextMenu));
const [menuParentRef, setMenuParentRef] = createSignal<ReferenceElement>();
const [menuRef, setMenuRef] = createSignal<HTMLElement>();
const menuFloating = useFloating(menuParentRef, menuRef, {
middleware: [shift()],
placement: "right-start",
});
createEffect(() => {
setMenuParentRef({
getBoundingClientRect(): ClientRectObject {
return {
x: contextMenu().x,
y: contextMenu().y,
left: contextMenu().x,
top: contextMenu().y,
right: contextMenu().x,
bottom: contextMenu().y,
width: 0,
height: 0,
};
}
})
});
return (
<>
<header id="header"></header>
<header id="header">
header: settings, pinned, search, info, help
</header>
<main id="main" class={room() ? "room" : "home"}>
<Switch>
<Match when={!params.roomId}><Home client={globals.client} /></Match>
@ -62,21 +105,27 @@ export default function App(): JSX.Element {
<Match when={room()}><RoomView room={room()!} /></Match>
</Switch>
</main>
<nav id="nav-spaces" aria-label="Space List">
<ul>
<For each={new Array(10).fill(null)}>
{() => <li><a href="#"></a></li>}
</For>
</ul>
</nav>
<nav id="nav-rooms" aria-label="Room List">
<ul>
<li>
<A href="/home">home</A>
<A target="_self" href="/home">home</A>
</li>
<For each={rooms().rooms}>
{room => (
<li>
<A href={`/rooms/${room.id}`}>{room.getState("m.room.name")?.content.name || "unnamed"}</A>
<li oncontextmenu={willHandleRoomContextMenu(room)}>
<A target="_self" href={`/rooms/${room.id}`}>{room.getState("m.room.name")?.content.name || "unnamed"}</A>
</li>
)}
</For>
</ul>
</nav>
<nav id="nav-spaces" aria-label="Space List"></nav>
<div id="sidebar" classList={{ thread: sidebar() instanceof Thread }}>
<Switch>
<Match when={sidebar() instanceof Thread}>
@ -85,6 +134,16 @@ export default function App(): JSX.Element {
<Match when={sidebar() === "members"}>
<div style="padding: 4px">member list</div>
</Match>
<Match when={!sidebar() && false}>
<div style="padding: 4px">
<h1>{room()?.getState("m.room.name")?.content.name || "no name"}</h1>
<p>{room()?.getState("m.room.topic")?.content.topic || "no topic"}</p>
<hr />
<p>some useful info/stats</p>
<hr />
<p>member list</p>
</div>
</Match>
</Switch>
</div>
<footer id="status">
@ -95,12 +154,22 @@ export default function App(): JSX.Element {
<For each={globals.global.dialogs}>
{() => <Dialog />}
</For>
<Show when={contextMenu()}>
<div ref={setMenuRef} style={{ position: "fixed", top: `${menuFloating.y}px`, left: `${menuFloating.x}px` }}>
<Switch>
<Match when={contextMenu().type === "room"} children={<menu.RoomMenu room={contextMenu().room} />} />
<Match when={contextMenu().type === "thread"} children={<menu.ThreadMenu thread={contextMenu().thread} />} />
<Match when={contextMenu().type === "message"} children={<menu.MessageMenu event={contextMenu().event} />} />
</Switch>
</div>
</Show>
</Portal>
</>
)
}
const ROOM_TYPE_CHAT = "jw.chat";
// const ROOM_TYPE_CHAT = "jw.chat";
const ROOM_TYPE_CHAT = "jw.room";
const ROOM_TYPE_SPACE = "m.space";
export function Home(props: VoidProps<{ client: Client }>) {
@ -125,6 +194,32 @@ export function Home(props: VoidProps<{ client: Client }>) {
return (
<div class="home" style="overflow-y:auto;max-height:100%">
<h1>welcome home</h1>
<table>
<thead>
<tr>
<th>foo</th>
<th>bar</th>
<th>baz</th>
</tr>
</thead>
<tbody>
<tr>
<td>foo</td>
<td>bar</td>
<td>baz</td>
</tr>
<tr>
<td>foo</td>
<td>bar</td>
<td>baz</td>
</tr>
<tr>
<td>foo</td>
<td>bar</td>
<td>baz</td>
</tr>
</tbody>
</table>
<p>go to <a href="/help">help</a></p>
<form onSubmit={handleSubmit}>
<input type="text" placeholder="name" name="name" />
@ -140,11 +235,8 @@ export function Home(props: VoidProps<{ client: Client }>) {
{ item: "qux", label: "qux", view: <><b>Qux</b>: does the qux</> },
]} />
<br />
<br />
<menu.RoomMenu />
<br />
<div class="embed">
<img src="https://matrix-client.matrix.org/_matrix/media/r0/thumbnail/matrix.org/2023-12-23_xvmKKBOBGXxxLMgO?width=256&height=256&method=scale" />
<img src="https://celery.eu.org/public/pfp.png" />
<div class="info">
<span class="site-name">foobar | <a href="https://example.com">https://example.com</a></span>
<h2>title a s arent oaiernst oiaensr otieanrsoitenaoisrentoaiesrnt oaierntsoaiernsotiaernts</h2>

View file

@ -2,7 +2,7 @@ menu.context {
background: var(--background-2);
border: solid var(--background-4) 1px;
border-radius: 2px;
box-shadow: 1px 1px 3px var(--background-2);
box-shadow: 2px 2px 3px var(--background-3);
display: inline-block;
min-width: 128px;
overflow: hidden;

View file

@ -1,8 +1,10 @@
import { For, JSX, ParentProps, createSignal } from "solid-js";
import { For, JSX, ParentProps, VoidProps, createSignal } from "solid-js";
import { Portal, Show } from "solid-js/web";
import "./Menu.scss";
import { useFloating } from "solid-floating-ui";
import { autoUpdate, flip, offset } from "@floating-ui/dom";
import { Event, Room, Thread } from "sdk";
import { useGlobals } from "./Context";
export function Radial() {
function generateWedge(originX: number, originY: number, centerRadius: number, wedgeRadius: number, margin: number, arcStart: number, arcEnd: number) {
@ -40,9 +42,9 @@ export function Radial() {
);
}
export function Menu(props: ParentProps) {
export function Menu(props: ParentProps<{ submenu?: boolean }>) {
return (
<menu class="context">
<menu class="context" onmousedown={(e) => !props.submenu && e.stopPropagation()}>
<ul>
{props.children}
</ul>
@ -63,7 +65,7 @@ export function Submenu(props: ParentProps<{ content: JSX.Element, onClick?: (e:
<li class="submenu" ref={setItemEl}>
<button onClick={(e) => { e.stopPropagation(); props.onClick?.(e) }}>{props.content}</button>
<div ref={setSubEl} class="subwrap" style={{ position: "fixed", left: `${dims.x}px`, top: `${dims.y}px` }}>
<Menu>
<Menu submenu>
{props.children}
</Menu>
</div>
@ -72,29 +74,156 @@ export function Submenu(props: ParentProps<{ content: JSX.Element, onClick?: (e:
}
export function Item(props: ParentProps<{ onClick?: (e: MouseEvent) => void }>) {
const [_globals, action] = useGlobals();
return (
<li>
<button onClick={(e) => { e.stopPropagation(); props.onClick?.(e) }}>{props.children}</button>
<button onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
action({ type: "contextmenu.set", menu: null });
}}>{props.children}</button>
</li>
);
}
export function Separator(props: ParentProps) {
export function Separator() {
return <li><hr /></li>
}
export function RoomMenu() {
// the context menu for spaces (currently identical to rooms)
export function SpaceMenu(props: VoidProps<{ room: Room }>) {
const copyId = () => navigator.clipboard.writeText(props.room.id);
return (
<Menu>
<Item>foo</Item>
<Item>bar</Item>
<Item>baz</Item>
<Submenu content={"submenu"}>
<Item>apple</Item>
<Item>orange</Item>
<Item>mark as read</Item>
{
// <Item>copy link</Item>
}
<Submenu content={"mute"}>
<Item>for 3 hours</Item>
<Item>for 1 day</Item>
<Item>for 1 week</Item>
<Item>forever</Item>
</Submenu>
<Submenu content={"edit"}>
<Item>general</Item>
<Item>log</Item>
<Item>permissions</Item>
<Item>etc</Item>
</Submenu>
<Item>leave</Item>
<Separator />
<Item>quux</Item>
<Item onClick={copyId}>copy id</Item>
<Item>inspect</Item>
</Menu>
)
}
// the context menu for rooms
export function RoomMenu(props: VoidProps<{ room: Room }>) {
const copyId = () => navigator.clipboard.writeText(props.room.id);
const leave = () => {
if (confirm("really leave?")) props.room.leave();
};
return (
<Menu>
<Item>mark as read</Item>
{
// <Item>copy link</Item>
}
<Submenu content={"mute"}>
<Item>for 3 hours</Item>
<Item>for 1 day</Item>
<Item>for 1 week</Item>
<Item>forever</Item>
</Submenu>
<Submenu content={"edit"}>
<Item>general</Item>
<Item>log</Item>
<Item>permissions</Item>
<Item>etc</Item>
</Submenu>
<Item onClick={leave}>leave</Item>
<Separator />
<Item onClick={copyId}>copy id</Item>
<Item>inspect</Item>
</Menu>
)
}
// the context menu for users
export function UserMenu() {
return (
<Menu>
<Item>block</Item>
<Item>dm</Item>
<Separator />
<Item>kick</Item>
<Item>ban</Item>
<Item>mute</Item>
<Item>power level</Item>
<Separator />
<Item>copy id</Item>
</Menu>
)
}
// the context menu for threads
export function ThreadMenu(props: VoidProps<{ thread: Thread }>) {
const copyId = () => navigator.clipboard.writeText(props.thread.id);
return (
<Menu>
<Item>mark as read</Item>
{
// <Item>copy link</Item>
}
<Item>watch/unwatch</Item>
<Submenu content={"mute"}>
<Item>for 3 hours</Item>
<Item>for 1 day</Item>
<Item>for 1 week</Item>
<Item>forever</Item>
</Submenu>
<Submenu content={"remind"}>
<Item>in 3 hours</Item>
<Item>in 1 day</Item>
<Item>in 1 week</Item>
</Submenu>
<Separator />
<Item>edit</Item>
<Submenu content={"tag"}>
<Item>foo</Item>
<Item>bar</Item>
<Item>baz</Item>
</Submenu>
<Item>pin</Item>
<Item>redact</Item>
<Separator />
<Item onClick={copyId}>copy id</Item>
<Item>view source</Item>
</Menu>
)
}
// the context menu for messages
export function MessageMenu(props: VoidProps<{ event: Event }>) {
const copyId = () => navigator.clipboard.writeText(props.event.id);
return (
<Menu>
<Item>mark unread</Item>
{
// <Item>copy link</Item>
}
<Item>reply</Item>
<Item>edit</Item>
<Item>fork</Item>
<Item>pin</Item>
<Item>redact</Item>
<Separator />
<Item onClick={copyId}>copy id</Item>
<Item>view source</Item>
</Menu>
)
}

View file

@ -13,7 +13,7 @@
}
&.compact {
--name-width: 144px;
--name-width: 128px;
&.title {
padding-top: 8px;
@ -178,3 +178,15 @@
bottom: 0;
}
}
.reply {
background: var(--background-4);
display: flex;
& > div {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}

View file

@ -16,6 +16,7 @@ function sanitize(html: string): string {
"strong": [],
"em": [],
"a": ["href"],
"pre": [],
"code": [],
"h1": [],
"h2": [],
@ -42,12 +43,16 @@ export function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fa
}
function hasBody() {
return props.text.some((i: any) => !i.type || ["text/plain", "text/html"].includes(i.type));
return props.text?.some?.((i: any) => !i.type || ["text/plain", "text/html"].includes(i.type)) || typeof props.text === "string";
}
function getBody() {
const html = props.text.find((i: any) => i.type === "text/html")?.body;
if (html) return sanitize(html);
if (typeof props.text === "string") return escape(props.text);
if (props.formatting !== false) {
const html = props.text.find((i: any) => i.type === "text/html")?.body;
if (html) return sanitize(html);
}
const text = props.text.find((i: any) => !i.type || i.type === "text/plain")?.body;
if (text) return escape(text);
@ -67,6 +72,8 @@ export function Message(props: VoidProps<{ event: Event, title?: boolean, classe
const title = () => props.title;
const compact = () => globals!.settings.compact;
const name = () => props.event.room.getState("m.room.member", props.event.sender)?.content.displayname || props.event.sender;
const replyId = () => props.event.content["m.relations"]?.find((rel: any) => rel.rel_type === "m.reply")?.event_id;
const reply = () => replyId() ? props.event.room.events.get(replyId()) : undefined;
if (props.event.type !== "m.message") {
console.warn("constructed Message with a non-m.message event");
@ -75,8 +82,13 @@ export function Message(props: VoidProps<{ event: Event, title?: boolean, classe
return (
<div class="message" classList={{ title: title(), [compact() ? "compact" : "cozy"]: true, ...props.classes }} data-event-id={props.event.id}>
{title() && !compact() && <div class="avatar"></div>}
{title() && compact() && <div class="name">{name()}</div>}
{compact() && <div class="name">{name()}</div>}
<div class="content">
<Show when={reply()}>
<div class="reply">
{reply()?.sender} &bull; <TextBlock text={reply()!.content.text} fallback={reply()!.content["m.files"] ? <em>{reply()!.content["m.files"].length} files</em> : <em>no content?</em>} />
</div>
</Show>
{title() && !compact() && <div class="name">{name()}</div>}
<Show when={props.event.content.text}>
<div class="body">

View file

@ -1,5 +1,5 @@
import { Room, Thread, ThreadPaginator } from "sdk";
import { Accessor, For, JSX, Match, Show, Suspense, Switch, VoidProps, createContext, createEffect, createResource, createSignal, lazy, on, onCleanup, onMount, useContext } from "solid-js";
import { Event, Room, Thread, ThreadPaginator } from "sdk";
import { Accessor, For, JSX, Match, Show, Suspense, SuspenseList, Switch, VoidProps, createContext, createEffect, createResource, createSignal, lazy, on, onCleanup, onMount, useContext } from "solid-js";
import { useGlobals } from "./Context";
import { Dropdown, Text, Time } from "./Atoms";
import { ThreadTimeline } from "sdk/dist/src/timeline";
@ -12,7 +12,7 @@ import "./Room.scss";
const Editor = lazy(() => import("./Editor"));
type CreatingThread = {
type: "chat" | "voice",
type: "chat" | "forum" | "voice",
title: string,
message: string,
attachments?: Array<File>,
@ -105,18 +105,45 @@ function Toggle(props: { enabled: string, disabled: string, initial: boolean })
type LoadingStatus = "loading" | "update" | "ready";
// TODO: useEvents helper instead of manually using createEffect onCleanup
const THREAD_PREVIEW_COUNT = 10;
// const THREAD_COUNT = 10;
function createThreads(initialRoom: Room) {
const [room, setRoom] = createSignal(initialRoom);
const [threads, setThreads] = createSignal<Array<Thread>>([]);
const [threads, setThreads] = createSignal<Array<[Thread, ThreadTimeline]>>([]);
const [status, setStatus] = createSignal<LoadingStatus>("loading");
const [location, setLocation] = createSignal({ atStart: false, atEnd: false });
const [location, setLocation] = createSignal({ atStart: true, atEnd: true });
let paginator: ThreadPaginator;
const map = new Map();
// FIXME: thread mangling when switching rooms quickly
async function refetchThreads() {
await Promise.all(paginator.list.map(t => t.timelines.fetch("end", 1)));
return await Promise.all(paginator.list.map(t => t.timelines.fetch("start", THREAD_PREVIEW_COUNT)));
}
function resetThreads(timelines: any) {
setThreads(() => paginator.list.map((thread, idx) => {
if (map.has(thread)) return map.get(thread);
const a = [thread, timelines[idx]];
map.set(thread, a);
return a;
}));
}
// HACK: this is to prevent init() being quickly called twice from clobbering each other
// the backend currently sends initial: true twice
let initVer = 0;
async function init() {
let myInitVer = ++initVer;
paginator = await room().threads.paginate();
console.log("init room threads", paginator);
const tls = await refetchThreads();
if (myInitVer !== initVer) return;
setStatus("update");
setThreads(paginator.list);
resetThreads(tls);
setLocation({ atStart: paginator.isAtEnd, atEnd: true });
setStatus("ready");
}
@ -125,10 +152,9 @@ function createThreads(initialRoom: Room) {
if (status() !== "ready") return;
setStatus("loading");
await paginator.paginate();
await Promise.all(paginator.list.map(t => t.timelines.fetch("end", 1)));
await Promise.all(paginator.list.map(t => t.timelines.fetch("start", 10)));
const tls = await refetchThreads();
setStatus("update");
setThreads([...paginator.list]);
resetThreads(tls);
setLocation({ atStart: paginator.isAtEnd, atEnd: true });
setStatus("ready");
}
@ -174,36 +200,61 @@ function Threads(props: VoidProps<{ room: Room }>) {
let scrollRefEl: HTMLDivElement | null = null;
let offsetTop: number;
let init = false;
createEffect(on(th.getStatus, (status) => {
if (status === "update") {
scrollRefEl = scrollEl.querySelector(".timeline-thread") as HTMLDivElement | null;
offsetTop = scrollRefEl?.offsetTop || 0;
} else if (status === "ready") {
setTimeout(() => {
const newOffsetTop = scrollRefEl?.offsetTop || 0;
scrollEl.scrollBy(0, newOffsetTop - offsetTop);
});
if (!init) {
init = true;
scrollEl.scrollBy(0, 9999);
handleScroll();
return;
}
const newOffsetTop = scrollRefEl?.offsetTop || 0;
scrollEl.scrollBy(0, newOffsetTop - offsetTop);
}
}));
createEffect(on(() => props.room, () => init = false));
createEffect(() => console.log(th.getLocation()));
onMount(() => {
setStore({ scrollBy: scrollEl.scrollBy.bind(scrollEl) });
});
const SCROLL_MARGIN = 500;
const PAGINATE_MARGIN = SCROLL_MARGIN + 100;
const handleScroll = async () => {
if (th.getStatus() === "ready") {
if (scrollEl.scrollTop < PAGINATE_MARGIN) {
console.log("scroll backwards");
await th.backwards();
}
}
};
// new IntersectionObserver((entries) => {
// entries[0].intersectionRatio
// });
// TODO: loading spinner
// <Show when={!th.getLocation().atStart}>
// <button onClick={() => th.backwards()}>backwards</button>
// </Show>
// <Show when={!th.getLocation().atEnd}>
// <button onClick={() => th.forwards()}>forwards</button>
// </Show>
return (
<div class="timeline threads" ref={scrollEl!}>
<div class="timeline threads" ref={scrollEl!} onScroll={handleScroll}>
<div class="items">
<Show when={th.getLocation().atStart}>
<RoomHeader room={props.room} />
</Show>
<Show when={!th.getLocation().atStart}>
<button onClick={() => th.backwards()}>backwards</button>
</Show>
<Show when={!th.getLocation().atEnd}>
<button onClick={() => th.forwards()}>forwards</button>
</Show>
<For each={th.getThreads()}>
{(thread: Thread) => <ThreadsItem thread={thread} />}
<For each={th.getThreads()} >
{([thread, timeline]) => <ThreadsItem thread={thread} timeline={timeline} />}
</For>
<Show when={th.getLocation().atEnd && store.creating}>
<ThreadsBuilder room={props.room} />
@ -213,23 +264,17 @@ function Threads(props: VoidProps<{ room: Room }>) {
);
}
export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
export function ThreadsItem(props: VoidProps<{ thread: Thread, timeline: ThreadTimeline }>) {
const [globals, change] = useGlobals();
const [roomContext, _setStore] = useContext(RoomContext)!;
const THREAD_PREVIEW_COUNT = 10;
const [preview, { mutate }] = createResource(props.thread, async (thread) => {
await thread.timelines.fetch("end", 1);
const tl = await thread.timelines.fetch("start", 10);
return tl;
}, {
storage: (init) => createSignal(init, { equals: false }),
});
const [preview, setPreview] = createSignal(props.timeline, { equals: false });
createEffect(() => setPreview(props.timeline));
let wrapperEl: HTMLElement;
const refresh = () => {
let offsetHeight = wrapperEl.offsetHeight;
mutate(preview());
setPreview(preview());
setThread(thread());
roomContext.scrollBy?.(0, wrapperEl.offsetHeight - offsetHeight);
};
@ -258,9 +303,14 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
// console.log("open thread", target);
e.stopPropagation();
};
const willHandleThreadContextMenu = (thread: Thread) => (e: MouseEvent) => {
e.preventDefault();
change({ type: "contextmenu.set", menu: { type: "thread", thread, x: e.clientX, y: e.clientY } });
}
return (
<article class="timeline-thread" ref={wrapperEl!}>
<article class="timeline-thread" ref={wrapperEl!} onContextMenu={willHandleThreadContextMenu(props.thread)}>
<header onClick={willOpenThread("default")}>
<div class="top">
<div class="icon"></div>
@ -272,11 +322,9 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
</div>
</header>
<div class="preview">
<Show when={!preview.loading}>
<For each={preview()?.getEvents().slice(0, THREAD_PREVIEW_COUNT) || []}>
{(ev, idx) => <Message event={ev} title={shouldSplit(ev, preview()!.getEvents()![idx() - 1])} />}
</For>
</Show>
<For each={preview()?.getEvents().slice(0, THREAD_PREVIEW_COUNT) || []}>
{(ev, idx) => <Message event={ev} title={shouldSplit(ev, preview()!.getEvents()![idx() - 1])} />}
</For>
</div>
<Show when={remaining() && false}>
<footer>
@ -302,7 +350,7 @@ function ThreadsBuilder(props: VoidProps<{ room: Room }>) {
if (!store.creating) return false;
if (!store.creating.title) return false;
if (!text) return false;
const thread = await props.room.sendEvent("m.thread.message", {
const thread = await props.room.sendEvent("m.thread.chat", {
title: [{ body: store.creating.title }],
});
await props.room.sendEvent("m.message", {
@ -324,13 +372,14 @@ function ThreadsBuilder(props: VoidProps<{ room: Room }>) {
<button onClick={() => setStore({ creating: undefined })}>cancel</button>
<h3>thread type</h3>
<Dropdown required onSelect={handleSelect} selected={store.creating?.type} options={[
{ item: "chat", label: "message" },
{ item: "chat", label: "chat" },
{ item: "forum", label: "forum" },
{ item: "voice", label: "voice" },
]} />
<h3>thread title</h3>
<input onInput={(e) => handleTitle(e.target.value)} autofocus />
<h3>thread body</h3>
<Editor onSubmit={handleSubmit} />
<Editor onSubmit={handleSubmit} placeholder="write something nice..." />
</div>
</div>
);

16
src/RoomSettings.scss Normal file
View file

@ -0,0 +1,16 @@
.room-settings {
display: flex;
grid-column: 1/4;
grid-row: 1/4;
& > nav {
width: 256px;
background: var(--background-2);
padding: 8px;
}
& > main {
overflow-y: auto;
padding: 8px;
}
}

View file

@ -1,36 +1,42 @@
import { Match, Switch, createSignal } from "solid-js";
import { Match, Switch, createEffect, createSignal, lazy } from "solid-js";
import { Dropdown } from "./Atoms";
import { A, useParams } from "@solidjs/router";
import "./RoomSettings.scss";
import { createForm } from "@modular-forms/solid";
const Editor = lazy(() => import("./Editor"));
export default function RoomSettings() {
const tabs = ["general", "emoji", "log", "permissions", "security", "members"];
const tabs = ["general", "log", "permissions", "security", "integrations", "members"];
const [selected, setSelected] = createSignal("general");
const params = useParams();
return (
<div style="display:flex;grid-column:1/4;grid-row: 1/4;">
<ul style="width: 200px">
{tabs.map(i => <li onClick={() => setSelected(i)}>{i}</li>)}
</ul>
<main style="overflow-y: auto">
<div class="room-settings">
<nav>
<ul>
{tabs.map(i => <li onClick={() => setSelected(i)}>{i}</li>)}
</ul>
</nav>
<main>
<Switch>
<Match when={selected() === "general"}>
<h1>general</h1>
<A href={`/rooms/${params.roomId}`}>back</A> <br />
<A target="_self" href={`/rooms/${params.roomId}`}>back</A> <br />
<input placeholder="name" />
<input placeholder="topic" />
<Editor onSubmit={() => {}} placeholder="topic" />
<button>invite</button>
<button>leave</button>
<hr />
thread notifications<br />
notifications<br />
notify when new threads are created <input type="checkbox" /><br />
notify on all messages in watched threads <input type="checkbox" /><br />
mute room <Dropdown
options={[
{ item: "a", label: "forever" },
{ item: "b", label: "for (insert time)" },
{ item: "a", label: "unmuted" },
{ item: "b", label: "forever" },
{ item: "c", label: "for (insert time)" },
]}
/> &bull; suppress ui <input type="checkbox" /><br />
/>
<hr />
room aliases
<ul>
@ -39,81 +45,18 @@ export default function RoomSettings() {
<li>#baz:domain.tld <button>remove</button> <button>set as main</button></li>
<li><input placeholder="add alias" /></li>
</ul>
</Match>
<Match when={selected() === "emoji"}>
<h1>emoji and stickers</h1>
<input placeholder="create pack" />
<ul>
<li>pack 1</li>
<li>pack 2</li>
<li>pack 3</li>
</ul>
<hr />
default thread type
<Dropdown
options={[
{ item: "a", label: "chat" },
{ item: "b", label: "forum" },
{ item: "c", label: "voice" },
]}
/>
</Match>
<Match when={selected() === "permissions"}>
<h1>permissions</h1>
<ul>
<li>
Thread permissions
<ul style="margin-left: 1rem">
<li>Create message threads</li>
<li>Create link threads</li>
<li>Create voice threads</li>
<li>Create poll threads?</li>
<li>Edit threads</li>
<li>Pin threads</li>
<li>Delete threads</li>
<li>Tag threads</li>
</ul>
</li>
<li>
Message permissions
<ul style="margin-left: 1rem">
<li>Send messages</li>
<li>Send reactions</li>
<li>Send attachments</li>
<li>Send embeds</li>
<li>Ping <b>@room</b></li>
<li>Ping <b>@thread</b></li>
<li>Edit messages</li>
<li>Pin messages</li>
<li>Delete messages</li>
</ul>
</li>
<li>
Voice permissions
<ul style="margin-left: 1rem">
<li>Connect</li>
<li>Speak</li>
<li>Video</li>
<li>Screenshare</li>
<li>Mute other people</li>
</ul>
</li>
<li>
Moderation permissions
<ul style="margin-left: 1rem">
<li>Invite members</li>
<li>Ban members</li>
<li>Kick members</li>
<li>Mute members</li>
<li>Manage permissions</li>
<li>Manage access rules</li>
<li>Enable encryption</li>
</ul>
</li>
<li>
Room permissions
<ul style="margin-left: 1rem">
<li>Change name</li>
<li>Change topic</li>
<li>Change avatar</li>
<li>Change aliases</li>
<li>Manage emojis and stickers</li>
<li>Manage thread tags</li>
<li>Upgrade room</li>
</ul>
</li>
</ul>
<Permissions />
</Match>
<Match when={selected() === "security"}>
<h1>security and privacy</h1>
@ -156,6 +99,11 @@ export default function RoomSettings() {
<li>room topic changed</li>
</ul>
</Match>
<Match when={selected() === "integrations"}>
<h1>integrations</h1>
<h2>bridges</h2>
<h2>bots</h2>
</Match>
<Match when={selected() === "members"}>
<h1>member list</h1>
filter by
@ -178,3 +126,173 @@ export default function RoomSettings() {
</div>
);
}
function Permissions() {
const [form, { Form, Field }] = createForm<{
thread: {
createChat: number,
createForum: number,
createVoice: number,
pin: number,
tag: number,
manage: number,
},
message: {
send: number,
attachments: number,
interact: number,
mentionRoom: number,
mentionThread: number,
pin: number,
manage: number,
},
voice: {
connect: number,
speak: number,
video: number,
mute: number,
},
moderation: {
invite: number,
ban: number,
kick: number,
mute: number,
manage: number,
admin: number,
},
room: {
name: number,
topic: number,
avatar: number,
tags: number,
ack: number,
},
}>();
return (
<>
<h1>permissions</h1>
{form.dirty && "form is dirty"}
<Form onSubmit={(resp) => console.log(resp)}>
Thread permissions
<ul style="margin-left: 1rem">
<li>
Create chat threads
<Field name="thread.createChat" type="number">
{(_store, props) => <input {...props} type="number" value="30" />}
</Field>
</li>
<li>Create forum threads</li>
<li>Create voice threads</li>
<li>Pin threads</li>
<li>Tag threads</li>
<li>Manage threads (edit + delete)</li>
</ul>
</Form>
<ul>
<li>
Thread permissions
<ul style="margin-left: 1rem">
<li>Create chat threads</li>
<li>Create forum threads</li>
<li>Create voice threads</li>
<li>Pin threads</li>
<li>Tag threads</li>
<li>Manage threads (edit + delete)</li>
</ul>
</li>
<li>
Message permissions
<ul style="margin-left: 1rem">
<li>Send messages</li>
<li>Send attachments</li>
<li>Send interaction</li>
<li>Ping <b>@room</b></li>
<li>Ping <b>@thread</b></li>
<li>Pin messages</li>
<li>Manage messages (edit(?) + delete)</li>
</ul>
</li>
<li>
Voice permissions
<ul style="margin-left: 1rem">
<li>Connect</li>
<li>Use voice</li>
<li>Use video and screensharing</li>
<li>Mute others</li>
</ul>
</li>
<li>
Moderation permissions
<ul style="margin-left: 1rem">
<li>Invite members</li>
<li>Ban members</li>
<li>Kick members</li>
<li>Mute members</li>
<li>Manage room (join rules, power levels, aliases)</li>
<li>Administrator (e2ee, acls, tombstone/upgrade)</li>
</ul>
</li>
<li>
Room permissions
<ul style="margin-left: 1rem">
<li>Change name</li>
<li>Change topic</li>
<li>Change avatar</li>
<li>Manage tags</li>
<li>Manage integrations</li>
<li>Ack threads/messages</li>
</ul>
</li>
</ul>
</>
);
}
/*
{
events: {
"m.thread.chat": 10,
"m.thread.forum": 10,
"m.thread.voice": 10,
"m.message": 10,
"m.ack": 10,
"m.interaction": 10,
},
state: {
"m.thread.pins": 10,
"m.thread.tags": 10,
"m.message.pins": 10,
"m.room.join_rules": 10, // manage
"m.room.power_levels": 10, // manage
"m.room.history": 10, // manage
"m.room.aliases": 10, // manage
"m.room.encryption": 10, // admin
"m.room.acl": 10, // admin
"m.room.tombstone": 10, // admin
"m.room.name": 10,
"m.room.topic": 10,
"m.room.avatar": 10,
"m.room.tags": 10,
"m.room.integration": 10,
},
notifications: {
room: 0,
thread: 0,
},
// enforced client side
voice: {
connect: 10,
voice: 10,
video: 10,
mute: 10,
},
attachments: 10, // enforced client-side
invite: 0,
kick: 0,
ban: 0,
mute: 0,
manage_messages: 0,
manage_threads: 0,
}
*/

View file

@ -134,3 +134,46 @@
display: flex;
}
}
.comment {
border-left: solid var(--background-4) 1px;
&.collapsed {
}
& > header {
display: flex;
gap: 4px;
& > button {
min-width: 24px;
padding: 0 4px;
line-height: 1;
font-family: var(--font-mono);
}
& > .author {
color: var(--foreground-2);
}
& > .summary {
white-space: nowrap;
width: fit-content;
overflow: hidden;
& > div {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
& > .content {
padding: 8px;
}
& > .children {
margin-left: 1em;
list-style: none;
}
}

View file

@ -1,4 +1,4 @@
import { For, ParentProps, Show, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount, lazy, Suspense, createRenderEffect, on, Accessor } from "solid-js";
import { For, ParentProps, Show, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount, lazy, Suspense, createRenderEffect, on, Accessor, Switch, Match, createMemo } from "solid-js";
import "./App.scss";
import { Client, Event, EventId, Room, Thread } from "sdk";
import { ThreadsItem } from "./Room";
@ -60,8 +60,6 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
setStatus("ready");
}
createEffect(on(timelineSet, init));
async function backwards() {
if (status() !== "ready") return;
if (isAtBeginning()) return;
@ -115,6 +113,8 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
setStatus("ready");
}
createEffect(on(timelineSet, init));
let oldTimeline: ThreadTimeline;
createEffect(() => {
timeline().on("timelineAppend", append);
@ -155,6 +155,101 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
// }
export function ThreadView(props: VoidProps<{ thread: Thread }>) {
console.log(props.thread.baseEvent.type);
return (
<Switch fallback="bad thread?">
<Match when={props.thread.baseEvent.type === "m.thread.chat"} children={<ThreadViewChat thread={props.thread} />} />
{/* legacy */}
<Match when={props.thread.baseEvent.type === "m.thread.message"} children={<ThreadViewChat thread={props.thread} />} />
<Match when={props.thread.baseEvent.type === "m.thread.forum"} children={<ThreadViewForum thread={props.thread} />} />
</Switch>
);
}
export function ThreadViewForum(props: VoidProps<{ thread: Thread }>) {
const [_globals, action] = useGlobals();
const tl = () => props.thread.timelines.live;
const [commentTree, setCommentTree]= createSignal(new Map());
createEffect(on(() => props.thread, async () => {
while (await tl().paginate("b", 1000));
const tree = new Map();
tree.set(null, []);
for (const event of tl().getEvents()) {
const replyId = event.content["m.relations"]?.find((rel: any) => rel.rel_type === "m.reply")?.event_id ?? null;
tree.set(event.id, []);
if (!tree.get(replyId)?.includes(event)) tree.get(replyId)?.push(event);
}
console.log(tl().getEvents(), tree)
setCommentTree(tree);
}));
let scrollEl: HTMLDivElement;
const binds = createKeybinds({
"PageUp": () => scrollEl.scrollBy({ top: -scrollEl.offsetHeight / 2, behavior: "smooth" }),
"PageDown": () => scrollEl.scrollBy({ top: scrollEl.offsetHeight / 2, behavior: "smooth" }),
// "PageUp": () => scrollEl.scrollBy({ top: -scrollEl.offsetHeight / 2, behavior: "instant" }),
// "PageDown": () => scrollEl.scrollBy({ top: scrollEl.offsetHeight / 2, behavior: "instant" }),
// "g Shift-N": () => scrollEl.scrollBy({ top: -scrollEl.offsetHeight / 2, behavior: "smooth" }),
"Escape": () => {
action({ type: "sidebar.focus", item: null });
},
});
return (
<div class="thread-view" onKeyDown={binds}>
<div class="scroll" ref={scrollEl!}>
{<ThreadInfo thread={props.thread} showHeader={true} />}
<For each={commentTree().get(null)}>
{(item) => <li>{<Comment event={item} tree={commentTree()} />}</li>}
</For>
<div style="min-height: 256px"></div>
</div>
</div>
);
}
function Comment(props: VoidProps<{ event: Event, tree: Map<EventId, Array<Event>> }>) {
const [isCollapsed, setIsCollapsed] = createSignal(false);
const childCount = createMemo(() => {
function countChildren(id: EventId): number {
return props.tree.get(id)!.reduce((acc, ev) => acc + countChildren(ev.id), 1);
}
return countChildren(props.event.id);
});
return (
<div class="comment" classList={{ collapsed: isCollapsed() }}>
<header>
<button onClick={() => setIsCollapsed(c => !c)}>{isCollapsed() ? "+" : "-"}</button>
<Show when={isCollapsed()}>
<span>[{childCount()}]</span>
</Show>
<span class="author">{props.event.sender}</span>
<Show when={isCollapsed()}>
<span class="summary">
<TextBlock formatting={false} text={props.event.content.text} />
</span>
</Show>
</header>
<Show when={!isCollapsed()}>
<div class="content">
<TextBlock text={props.event.content.text} />
</div>
<ul class="children">
<For each={props.tree.get(props.event.id)}>
{(item) => <li>{<Comment event={item} tree={props.tree} />}</li>}
</For>
</ul>
</Show>
</div>
)
}
export function ThreadViewChat(props: VoidProps<{ thread: Thread }>) {
const [_globals, action] = useGlobals();
const tl = createTimeline(() => props.thread.timelines);
@ -365,7 +460,7 @@ function Input(props: VoidProps<{ thread: Thread }>) {
</label>
</button>
<Suspense>
<Editor onSubmit={handleSubmit} />
<Editor placeholder="write something nice..." onSubmit={handleSubmit} />
</Suspense>
</div>
</div>

View file

@ -79,3 +79,26 @@ blockquote {
padding: 0 4px;
display: inline-block;
}
table {
border: solid var(--background-3) 1px;
width: 100%;
border-collapse: collapse;
& tr {
background: var(--background-2);
&:nth-child(even) {
background: var(--background-3);
}
}
& thead tr {
background: var(--background-4);
}
& td, th {
padding: 4px;
text-align: left;
}
}

View file

@ -2,5 +2,7 @@
import { render } from "solid-js/web";
import App from "./App";
// navigator.serviceWorker.register("/service.ts");
const root = document.getElementById("root");
render(() => <App />, root!);

View file

@ -1,46 +1,37 @@
// A *service worker*, to cache and provide data offline
// // A *service worker*, to cache and provide data offline
// self.addEventListener("install", () => {
// self.addEventListener("activate", async () => {
// console.log("activate")
// if (self.registration.navigationPreload) {
// await self.registration.navigationPreload.enable();
// }
// });
// self.addEventListener("fetch", (event) => {
// // const { request } = event;
// // if (request.url === "/" && request.method === "GET") {
// // event.respondWith(
// // fetch(request).catch(function(error) {
// // // `fetch()` throws an exception when the server is unreachable but not
// // // for valid HTTP responses, even `4xx` or `5xx` range.
// // console.error(
// // '[onfetch] Failed. Serving cached offline fallback ' +
// // error
// // );
// // return caches.open('offline').then(function(cache) {
// // return cache.match('offline.html');
// // });
// // })
// // );
// // }
// self.addEventListener("install", async () => {
// console.log("install")
// const cache = await caches.open(CACHE_NAME);
// // cache.addAll([""]);
// });
// /*
// self.addEventListener('install', function(event) {
// // Put `offline.html` page into cache
// var offlineRequest = new Request('offline.html');
// event.waitUntil(
// fetch(offlineRequest).then(function(response) {
// return caches.open('offline').then(function(cache) {
// console.log('[oninstall] Cached offline page', response.url);
// return cache.put(offlineRequest, response);
// });
// })
// );
// });
// // /*
// // self.addEventListener('push', function(event) {
// // event.waitUntil(
// // self.registration.showNotification(title, { body })
// // );
// // });
// // */
// self.addEventListener('push', function(event) {
// event.waitUntil(
// self.registration.showNotification(title, { body })
// );
// const CACHE_NAME = "v1";
// self.addEventListener("fetch", async ({ request }) => {
// if (request.method === "GET" && request.path.startsWith("/_matrix/media")) {
// const res = await caches.match(request);
// if (res) return res;
// return res;
// } else {
// const res = await fetch(request);
// const cache = await caches.open(CACHE_NAME);
// cache.put(request, res);
// return res;
// }
// });
// */