inbox proof of concept

This commit is contained in:
tezlm 2024-01-15 00:07:40 -08:00
parent 347e3aecea
commit 068a61b35b
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
8 changed files with 210 additions and 128 deletions

View file

@ -117,8 +117,15 @@
}
:root {
scrollbar-color: var(--background-4) var(--background-1);
// scrollbar-color: red green;
scrollbar-color: var(--background-4) var(--background-2);
}
::-webkit-scrollbar {
background: var(--background-2);
}
::-webkit-scrollbar-thumb {
background: var(--background-4);
}
h1 {

View file

@ -27,6 +27,7 @@ function Wrapper() {
<Route path="/login" component={Auth} />
<Route path="/register" component={Auth} />
<Route path="/home" component={Main} />
<Route path="/inbox" component={Main} />
<Route path="/settings" component={UserSettings} />
<Route path="/rooms/:roomId" component={Main} />
<Route path="/rooms/:roomId/settings" component={RoomSettings} />

View file

@ -1,17 +1,23 @@
import { Component, For, JSX, Match, Show, Switch, VoidProps, createEffect, createMemo, createResource, createSignal, on, onCleanup, onMount } from "solid-js";
import "./App.scss";
import { Client, Room, RoomId, RoomList, Thread } from "sdk";
import { RoomView, ThreadsItem } from "./Room";
import { Inbox, RoomView, ThreadsItem } from "./Room";
import { Portal } from "solid-js/web";
import { Dialog, Dropdown, Text, Time, Tooltip } from "./Atoms";
import { useGlobals } from "./Context";
import { ThreadView } from "./Thread";
import { useParams, A, useNavigate, Navigate } from "@solidjs/router";
import { useParams, A, useNavigate, Navigate, useLocation } from "@solidjs/router";
import * as menu from "./Menu";
import { useFloating } from "solid-floating-ui";
import { ClientRectObject, ReferenceElement, autoPlacement, autoUpdate, shift } from "@floating-ui/dom";
import { ROOM_TYPE_DEFAULT, ROOM_TYPE_SPACE } from "./consts";
type View =
{ type: "loading" } |
{ type: "room", room: Room } |
{ type: "home" } |
{ type: "inbox" };
export default function App(): JSX.Element {
const params = useParams();
const [globals, change] = useGlobals();
@ -20,9 +26,20 @@ export default function App(): JSX.Element {
}
const [rooms, setRooms] = createSignal(globals.client.lists.get("rooms") ?? { count: 0, rooms: [] as Array<Room> });
const [view, setView] = createSignal<View>({ type: "loading" });
const [room, { refetch }] = createResource(() => params.roomId, (id) => {
return globals.client.rooms.get(id);
const loc = useLocation();
createEffect(() => {
if (loc.pathname === "/home") {
setView({ type: "home" });
} else if (loc.pathname === "/inbox") {
setView({ type: "inbox" });
} else {
setView({
type: "room",
room: globals.client.rooms.get(params.roomId)!,
})
}
});
if (!globals.client.lists.has("rooms")) {
@ -49,7 +66,14 @@ export default function App(): JSX.Element {
function updateRooms(name: string, list: RoomList) {
if (name === "rooms") setRooms({ ...list });
// if (name === "requests") console.log("update requests", list);
refetch();
const newRoom = globals.client.rooms.get(params.roomId);
if ((view() as any)?.room !== newRoom) {
setView({
type: "room",
room: newRoom!,
});
}
}
function buildTree(rooms: Array<Room>): Map<RoomId | null, Array<Room>> {
@ -122,11 +146,12 @@ export default function App(): JSX.Element {
<header id="header">
header: settings, pinned, search, info, help
</header>
<main id="main" class={room() ? "room" : "home"}>
<main id="main" class={view().type === "home" ? "home" : "room"}>
<Switch>
<Match when={!params.roomId}><Home client={globals.client} /></Match>
<Match when={!room()}>please wait...</Match>
<Match when={room()}><RoomView room={room()!} /></Match>
<Match when={view().type === "loading"}>please wait...</Match>
<Match when={view().type === "home"}><Home client={globals.client} /></Match>
<Match when={view().type === "inbox"}><Inbox /></Match>
<Match when={view().type === "room"}><RoomView room={(view() as any).room} /></Match>
</Switch>
</main>
<nav id="nav-spaces" aria-label="Space List">
@ -148,6 +173,9 @@ export default function App(): JSX.Element {
<li>
<A target="_self" href="/home">home</A>
</li>
<li>
<A target="_self" href="/inbox">inbox</A>
</li>
<For each={tree().get(space()?.id ?? null)}>
{room => room.getState("m.room.create")!.content.type !== ROOM_TYPE_SPACE && (
<li oncontextmenu={willHandleRoomContextMenu(room)}>
@ -157,26 +185,28 @@ export default function App(): JSX.Element {
</For>
</ul>
</nav>
<div id="sidebar" classList={{ thread: sidebar() instanceof Thread }}>
<Switch>
<Match when={sidebar() instanceof Thread}>
<ThreadView thread={sidebar() as Thread} />
</Match>
<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>
<Show when={sidebar()}>
<div id="sidebar" classList={{ thread: sidebar() instanceof Thread }}>
<Switch>
<Match when={sidebar() instanceof Thread}>
<ThreadView thread={sidebar() as Thread} />
</Match>
<Match when={sidebar() === "members"}>
<div style="padding: 4px">member list</div>
</Match>
<Match when={!sidebar() && false}>
<div style="padding: 4px">
<h1>{(view() as any).room?.getState("m.room.name")?.content.name || "no name"}</h1>
<p>{(view() as any).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>
</Show>
<footer id="status">
<button onClick={() => change({ type: "dialog" })}>dialog</button>
<button onClick={() => change({ type: "logout" })}>logout</button>
@ -190,7 +220,7 @@ export default function App(): JSX.Element {
<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} />} />
<Match when={contextMenu().type === "message"} children={<menu.MessageMenu thread={contextMenu().thread} event={contextMenu().event} />} />
</Switch>
</div>
</Show>

View file

@ -6,42 +6,6 @@ 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) {
const startXCenter = originX + Math.cos(arcStart) * (centerRadius) + margin;
const startYCenter = originY + Math.sin(arcStart) * (centerRadius) + margin;
const endXCenter = originX + Math.cos(arcEnd) * (centerRadius) + margin;
const endYCenter = originY + Math.sin(arcEnd) * (centerRadius) + margin;
const startXWedge = ((endXCenter - originX) * wedgeRadius) + originX + margin;
const startYWedge = ((endYCenter - originY) * wedgeRadius) + originY + margin;
const endXWedge = ((startXCenter - originX) * wedgeRadius) + originX + margin;
const endYWedge = ((startYCenter - originY) * wedgeRadius) + originY + margin;
return `M ${startXCenter} ${startYCenter} A ${centerRadius} ${centerRadius} 0 0 1 ${endXCenter} ${endYCenter} L ${startXWedge} ${startYWedge} A ${wedgeRadius} ${wedgeRadius} 0 0 0 ${endXWedge} ${endYWedge} z`;
}
const [menu, setMenu] = createSignal<{ x: number, y: number } | null>(null);
function handleClick(e: MouseEvent) {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY });
}
const quart = Math.PI / 2;
const margin = 10;
return (
<Portal>
<div style="position:fixed;top:0;left:0;height:100vh;width:100vw" onContextMenu={handleClick}>
<Show when={menu()}>
<div class="aaaa" style={`clip-path:path('${generateWedge(menu()!.x, menu()!.y, 40, 100, margin, 0, quart)}')`}></div>
<div class="aaaa" style={`clip-path:path('${generateWedge(menu()!.x, menu()!.y, 40, 100, margin, quart, quart * 2)}')`}></div>
<div class="aaaa" style={`clip-path:path('${generateWedge(menu()!.x, menu()!.y, 40, 100, margin, quart * 2, quart * 3)}')`}></div>
<div class="aaaa" style={`clip-path:path('${generateWedge(menu()!.x, menu()!.y, 40, 100, margin, quart * 3, quart * 4)}')`}></div>
</Show>
</div>
</Portal>
);
}
export function Menu(props: ParentProps<{ submenu?: boolean }>) {
return (
<menu class="context" onmousedown={(e) => !props.submenu && e.stopPropagation()}>
@ -213,12 +177,13 @@ export function ThreadMenu(props: VoidProps<{ thread: Thread }>) {
}
// the context menu for messages
export function MessageMenu(props: VoidProps<{ event: Event }>) {
export function MessageMenu(props: VoidProps<{ thread: Thread, event: Event }>) {
const copyId = () => navigator.clipboard.writeText(props.event.id);
const copyJSON = () => navigator.clipboard.writeText(JSON.stringify(props.event.content));
const markUnread = () => props.thread.ack(props.event.id);
return (
<Menu>
<Item>mark unread</Item>
<Item onClick={markUnread}>mark unread</Item>
{
// <Item>copy link</Item>
}

View file

@ -4,7 +4,7 @@ import { Match } from "solid-js";
import { Switch } from "solid-js";
import { useGlobals } from "./Context";
import "./Message.scss";
import { Event } from "sdk";
import { Event, Thread } from "sdk";
import { debounce } from "@solid-primitives/scheduled";
import * as xss from "xss";
@ -29,6 +29,14 @@ function sanitize(html: string): string {
"ol": [],
"li": [],
"blockquote": [],
"details": [],
"summary": [],
"table": [],
"thead": [],
"tbody": [],
"tr": [],
"th": [],
"td": [],
},
stripIgnoreTagBody: ["mx-reply"],
});
@ -82,7 +90,7 @@ export function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fa
);
}
export function Message(props: VoidProps<{ event: Event, title?: boolean, classes?: Record<string, boolean> }>) {
export function Message(props: VoidProps<{ thread: Thread, event: Event, title?: boolean, classes?: Record<string, boolean> }>) {
const [globals, action] = useGlobals();
const title = () => props.title;
const compact = () => globals!.settings.compact;
@ -93,7 +101,7 @@ export function Message(props: VoidProps<{ event: Event, title?: boolean, classe
const willHandleMessageContextMenu = (e: MouseEvent) => {
e.preventDefault();
action({ type: "contextmenu.set", menu: { type: "message", event: props.event, x: e.clientX, y: e.clientY } });
action({ type: "contextmenu.set", menu: { type: "message", thread: props.thread, event: props.event, x: e.clientX, y: e.clientY } });
}
if (props.event.type !== "m.message") {

View file

@ -265,6 +265,77 @@ function Threads(props: VoidProps<{ room: Room }>) {
);
}
export function Inbox() {
const [globals, action] = useGlobals();
const [threads, setThreads] = createSignal<Array<Thread>>([]);
const paginator = new ThreadPaginator(globals.client, [...globals.client.rooms.values()], {});
let scrollEl: HTMLDivElement;
let loading = false;
async function loadMore() {
if (loading) return false;
loading = true;
await paginator.paginate(50);
loading = false;
return true;
}
onMount(() => {
scrollEl?.scrollBy(0, 999999);
handleScroll();
});
const SCROLL_MARGIN = 500;
const PAGINATE_MARGIN = SCROLL_MARGIN + 100;
const handleScroll = async () => {
if (scrollEl.scrollHeight - scrollEl.offsetHeight - scrollEl.scrollTop < PAGINATE_MARGIN) {
if (!await loadMore()) return;
const scrollRefEl = scrollEl.querySelector(".timeline-thread") as HTMLDivElement | null;
const offsetTop = scrollRefEl?.offsetTop || 0;
setThreads([...paginator.list].reverse());
const newOffsetTop = scrollRefEl?.offsetTop || 0;
scrollEl.scrollBy(0, newOffsetTop - offsetTop);
}
};
async function more() {
if (!await loadMore()) return;
setThreads([...paginator.list].reverse());
}
function toggleThread(thread: Thread) {
if (globals.global.sidebar === thread) {
action({ type: "sidebar.focus", item: null });
} else {
action({ type: "sidebar.focus", item: thread });
}
}
return (
<div class="timeline threads" ref={scrollEl!} onScroll={handleScroll}>
<table>
<thead>
<tr>
<th>thread</th>
<th>room</th>
</tr>
</thead>
<tbody>
<For each={threads()}>
{(thread) => <tr>
<td onClick={() => toggleThread(thread)}>
<TextBlock text={thread.baseEvent.content.title} formatting={false} fallback={<Text>thread.unnamed</Text>} />
</td>
<td>{thread.baseEvent.room.getState("m.room.name")?.content.name || thread.baseEvent.room.id}</td>
</tr>}
</For>
</tbody>
</table>
<button onClick={more}>more</button>
</div>
);
}
export function ThreadsItem(props: VoidProps<{ thread: Thread, timeline: ThreadTimeline }>) {
const [globals, change] = useGlobals();
const [roomContext, _setStore] = useContext(RoomContext)!;

View file

@ -9,6 +9,60 @@
// scrollbar-color: var(--background-1) var(--background-3);
overflow-anchor: none;
& .thread-title {
position: sticky;
top: -1px;
z-index: 999;
visibility: hidden;
& > * {
padding: 1px 8px 0;
width: 100%;
}
& > .fake {
visibility: hidden;
}
& > .real {
position: absolute;
border-bottom: solid var(--background-3) 0;
display: flex;
transition: all .2s;
visibility: visible;
& > div {
flex: 1;
}
& > button {
display: none;
font-size: 1rem;
margin: 0;
padding: 0 4px;
font-weight: initial;
}
}
&.stuck > .real {
background: var(--background-1);
border-bottom: solid var(--background-3) 1px;
font-size: 1.2rem;
padding: 8px;
& > div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 8px;
}
& > button {
display: block;
}
}
}
& > li {
& > .spacer {
margin-top: auto;
@ -44,60 +98,6 @@
}
}
& > .thread-title {
position: sticky;
top: -1px;
z-index: 999;
visibility: hidden;
& > * {
padding: 1px 8px 0;
width: 100%;
}
& > .fake {
visibility: hidden;
}
& > .real {
position: absolute;
border-bottom: solid var(--background-3) 0;
display: flex;
transition: all .2s;
visibility: visible;
& > div {
flex: 1;
}
& > button {
display: none;
font-size: 1rem;
margin: 0;
padding: 0 4px;
font-weight: initial;
}
}
&.stuck > .real {
background: var(--background-1);
border-bottom: solid var(--background-3) 1px;
font-size: 1.2rem;
padding: 8px;
& > div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 8px;
}
& > button {
display: block;
}
}
}
& > header {
padding: 8px;

View file

@ -410,7 +410,7 @@ export function ThreadViewChat(props: VoidProps<{ thread: Thread }>) {
function getItem(item: TimelineItem) {
switch (item.type) {
case "message": return <Message event={item.event} title={item.separate} />;
case "message": return <Message thread={thread()} event={item.event} title={item.separate} />;
case "info": return <ThreadInfo thread={thread()} showHeader={item.header} />;
case "spacer": return <div style={{ "min-height": `${SCROLL_MARGIN}px` }}></div>;
case "spacer-mini": return <div style={{ "min-height": `80px` }}></div>;