inbox proof of concept
This commit is contained in:
parent
347e3aecea
commit
068a61b35b
8 changed files with 210 additions and 128 deletions
11
src/App.scss
11
src/App.scss
|
@ -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 {
|
||||
|
|
|
@ -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} />
|
||||
|
|
90
src/Main.tsx
90
src/Main.tsx
|
@ -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>
|
||||
|
|
41
src/Menu.tsx
41
src/Menu.tsx
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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") {
|
||||
|
|
71
src/Room.tsx
71
src/Room.tsx
|
@ -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)!;
|
||||
|
|
108
src/Thread.scss
108
src/Thread.scss
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Reference in a new issue