Thread creation mockup and fixed preview timelines
This commit is contained in:
parent
8abc0c0148
commit
7f6e719414
9 changed files with 509 additions and 316 deletions
162
src/App.scss
162
src/App.scss
|
@ -20,66 +20,6 @@
|
|||
grid-area: main;
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
|
||||
& > .timeline {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&.threads > .items {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
& > .items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
min-height: 100%;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
& > .actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
// background: linear-gradient(to bottom, transparent, var(--background-2) 10%);
|
||||
background: var(--background-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
border-top: solid var(--background-3) 1px;
|
||||
|
||||
& select {
|
||||
background: var(--background-3);
|
||||
border: solid var(--background-4) 1px;
|
||||
|
||||
&:hover {
|
||||
background: #fcfcfc11;
|
||||
}
|
||||
}
|
||||
|
||||
& > .create-thread {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
|
||||
& > div {
|
||||
padding: 4px;
|
||||
background: #46c;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#nav-rooms {
|
||||
|
@ -136,104 +76,6 @@
|
|||
grid-area: status;
|
||||
}
|
||||
|
||||
.timeline-thread {
|
||||
--title-font-size: calc(1rem * 1.2);
|
||||
--info-font-size: calc(1rem * 1);
|
||||
margin: 4px 32px;
|
||||
background: var(--background-2);
|
||||
border: solid var(--background-4) 1px;
|
||||
// border-radius: 4px;
|
||||
contain: content;
|
||||
|
||||
// &:hover {
|
||||
// background: #00000022;
|
||||
// }
|
||||
|
||||
& > header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
background: var(--background-3);
|
||||
border-bottom: solid var(--background-4) 1px;
|
||||
line-height: 1;
|
||||
|
||||
& > .top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
& > .title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
& > * {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
& > .icon {
|
||||
background: #34363b;
|
||||
border-radius: 50%;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
& > .title {
|
||||
font-size: 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
& > .date {
|
||||
color: var(--foreground-2);
|
||||
align-self:flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
& > .bottom {
|
||||
align-self: start;
|
||||
margin-top: 8px;
|
||||
color: var(--foreground-2);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground-1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .preview {
|
||||
// padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
& > footer {
|
||||
background: linear-gradient(to bottom, transparent, var(--background-3) 50%);
|
||||
padding: 4px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to bottom, transparent, var(--background-4) 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-create {
|
||||
margin: 32px 32px 8px;
|
||||
|
||||
& > .actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
padding: 4px;
|
||||
background: #555;
|
||||
|
@ -336,9 +178,9 @@ h1 {
|
|||
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
background: var(--background-2);
|
||||
background: var(--background-3);
|
||||
border: solid var(--background-4) 1px;
|
||||
padding: 4px;
|
||||
padding: 2px 4px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -135,7 +135,7 @@ function createSelect<T>() {
|
|||
};
|
||||
}
|
||||
|
||||
export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean, options: Array<DropdownItem<T>> }>) {
|
||||
export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean, onSelect?: (item: T | null) => void, options: Array<DropdownItem<T>> }>) {
|
||||
const [shown, setShown] = createSignal(false);
|
||||
const [inputEl, setInputEl] = createSignal<HTMLInputElement>();
|
||||
const [dropdownEl, setDropdownEl] = createSignal<HTMLDivElement>();
|
||||
|
@ -146,10 +146,10 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
|
|||
placement: "bottom",
|
||||
});
|
||||
|
||||
const select_ = createSelect<T>();
|
||||
const selector = createSelect<T>();
|
||||
|
||||
createEffect(() => {
|
||||
select_.setItems(props.options);
|
||||
selector.setItems(props.options);
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
@ -161,26 +161,26 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
|
|||
if (!shown()) {
|
||||
const idx = props.options.findIndex(i => i.item === selected());
|
||||
const next = (props.options.length + idx - 1) % props.options.length;
|
||||
setSelected(() => props.options[next]?.item);
|
||||
select(props.options[next]?.item);
|
||||
}
|
||||
},
|
||||
"ArrowDown": () => {
|
||||
if (!shown()) {
|
||||
const idx = props.options.findIndex(i => i.item === selected());
|
||||
const next = (idx + 1) % props.options.length;
|
||||
setSelected(() => props.options[next]?.item);
|
||||
select(props.options[next]?.item);
|
||||
}
|
||||
},
|
||||
"ArrowUp, Shift-Tab": (e) => {
|
||||
if (shown()) {
|
||||
e.preventDefault();
|
||||
select_.prev();
|
||||
selector.prev();
|
||||
}
|
||||
},
|
||||
"ArrowDown, Tab": (e) => {
|
||||
if (shown()) {
|
||||
e.preventDefault();
|
||||
select_.next();
|
||||
selector.next();
|
||||
}
|
||||
},
|
||||
"Escape": (e) => {
|
||||
|
@ -192,7 +192,7 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
|
|||
"Enter": (e) => {
|
||||
e.preventDefault();
|
||||
if (shown()) {
|
||||
select(select_.getHovered()?.item ?? null);
|
||||
select(selector.getHovered()?.item ?? null);
|
||||
} else {
|
||||
setShown(true);
|
||||
}
|
||||
|
@ -203,19 +203,19 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
|
|||
e.preventDefault();
|
||||
if (e.deltaY < 0) {
|
||||
if (shown()) {
|
||||
select_.prev();
|
||||
selector.prev();
|
||||
} else {
|
||||
const idx = props.options.findIndex(i => i.item === selected());
|
||||
const next = (props.options.length + idx - 1) % props.options.length;
|
||||
setSelected(() => props.options[next]?.item);
|
||||
select(props.options[next]?.item);
|
||||
}
|
||||
} else if (e.deltaY > 0) {
|
||||
if (shown()) {
|
||||
select_.next();
|
||||
selector.next();
|
||||
} else {
|
||||
const idx = props.options.findIndex(i => i.item === selected());
|
||||
const next = (idx + 1) % props.options.length;
|
||||
setSelected(() => props.options[next]?.item);
|
||||
select(props.options[next]?.item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -223,6 +223,7 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
|
|||
function select(item: T | null) {
|
||||
setSelected(() => item);
|
||||
setShown(false);
|
||||
props.onSelect?.(item);
|
||||
}
|
||||
|
||||
const [value, setValue] = createSignal<string | undefined>(undefined, { equals: false });
|
||||
|
@ -246,7 +247,7 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
|
|||
}}
|
||||
onInput={(e) => {
|
||||
const { value } = e.target;
|
||||
select_.setFilter(e.target.value);
|
||||
selector.setFilter(e.target.value);
|
||||
if (value) setShown(true);
|
||||
}}
|
||||
onKeyDown={binds}
|
||||
|
@ -268,11 +269,11 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
|
|||
}}
|
||||
>
|
||||
<ul>
|
||||
<For each={select_.getFiltered()} fallback={"no options"}>
|
||||
<For each={selector.getFiltered()} fallback={"no options"}>
|
||||
{(entry, idx) => <li
|
||||
onMouseOver={() => select_.setHovered(entry.obj)}
|
||||
onMouseOver={() => selector.setHovered(entry.obj)}
|
||||
onMouseDown={() => select(entry.obj.item)}
|
||||
classList={{ hovered: entry.obj.item === select_.getHovered()?.item, selected: idx() === selected() }}>
|
||||
classList={{ hovered: entry.obj.item === selector.getHovered()?.item, selected: idx() === selected() }}>
|
||||
{entry.obj.view ?? entry.obj.label}
|
||||
</li>}
|
||||
</For>
|
||||
|
|
|
@ -96,7 +96,7 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
|
|||
if (globals.locale) {
|
||||
i18n.changeLanguage(lng);
|
||||
} else {
|
||||
const locale = await i18n.init({ lng, debug: true });
|
||||
const locale = await i18n.init({ lng, debug: false });
|
||||
update({ locale });
|
||||
}
|
||||
if (!i18n.default.hasResourceBundle(lng, "translation") && !lngLoads.has(lng)) {
|
||||
|
|
|
@ -25,6 +25,10 @@
|
|||
color: var(--foreground-link);
|
||||
}
|
||||
|
||||
& .header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .placeholder {
|
||||
position: absolute;
|
||||
}
|
||||
|
|
|
@ -18,8 +18,10 @@ const schema = new Schema({
|
|||
},
|
||||
paragraph: {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
whitespace: "pre",
|
||||
toDOM: () => ["p", 0],
|
||||
parseDOM: ["p", "x-html-import"].map(tag => ({ tag, preserveWhitespace: "full" })),
|
||||
},
|
||||
mention: {
|
||||
group: "inline",
|
||||
|
@ -48,7 +50,7 @@ function Autocomplete(props: VoidProps<{ options: Array<UserId> }>) {
|
|||
)
|
||||
}
|
||||
|
||||
export default function Editor(props: VoidProps<{ onSubmit: (data: { text: string, html: string }) => void }>) {
|
||||
export default function Editor(props: VoidProps<{ onSubmit: (data: { text: string, html: string }) => any }>) {
|
||||
let editorEl: HTMLDivElement;
|
||||
|
||||
const [autocompleteOptions, setAutocompleteOptions] = createSignal<null | Array<string>>(null);
|
||||
|
@ -145,12 +147,12 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
|
|||
text: state.doc.textContent,
|
||||
html: marked(state.doc.textContent, { breaks: true }) as string,
|
||||
})
|
||||
props.onSubmit({
|
||||
const res = props.onSubmit({
|
||||
text: state.doc.textContent.trim(),
|
||||
html: (marked(state.doc.textContent, { breaks: true }) as string).trim(),
|
||||
});
|
||||
dispatch?.(state.tr.deleteRange(0, state.doc.nodeSize - 2));
|
||||
return true;
|
||||
if (res !== false) dispatch?.(state.tr.deleteRange(0, state.doc.nodeSize - 2));
|
||||
return !!res;
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
@ -174,6 +176,11 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
|
|||
break;
|
||||
}
|
||||
}
|
||||
case "heading": {
|
||||
decorations.push(Decoration.inline(pos + 1, pos + ast.depth + 1, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + ast.depth + 1, pos + ast.raw.length + 1, { class: "header" }));
|
||||
break;
|
||||
}
|
||||
case "em": {
|
||||
const end = pos + ast.raw.length + 1;
|
||||
decorations.push(Decoration.inline(pos + 1, pos + 2, { class: "syn" }));
|
||||
|
@ -211,21 +218,26 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
|
|||
}
|
||||
case "paragraph": ast.tokens?.forEach(walk); return;
|
||||
case "blockquote": {
|
||||
// FIXME: breaks on multiline blockquotes "> foo\n> bar"
|
||||
const synLen = ast.raw.length - ast.text.length;
|
||||
decorations.push(Decoration.inline(pos, pos + synLen, { class: "syn" }));
|
||||
pos += synLen;
|
||||
ast.tokens?.forEach(walk);
|
||||
return;
|
||||
}
|
||||
// case "list": {
|
||||
// console.log(ast);
|
||||
// for (const item of ast.items) {
|
||||
// const synLen = item.raw.length - item.text.length;
|
||||
// pos += synLen;
|
||||
// item.tokens.forEach(walk);
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
case "list": {
|
||||
ast.items.forEach(walk);
|
||||
return;
|
||||
}
|
||||
case "list_item": {
|
||||
const endLen = ast.raw.match(/\n+$/)?.[0].length ?? 0;
|
||||
const startLen = ast.raw.length - ast.text.length - endLen;
|
||||
decorations.push(Decoration.inline(pos, pos + startLen, { class: "syn" }));
|
||||
pos += startLen;
|
||||
ast.tokens?.forEach(walk);
|
||||
pos += endLen;
|
||||
return;
|
||||
}
|
||||
// default: console.log(ast);
|
||||
}
|
||||
pos += ast.raw.length;
|
||||
|
@ -235,6 +247,7 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
|
|||
return DecorationSet.create(state.doc, decorations);
|
||||
},
|
||||
handlePaste(view, _event, slice) {
|
||||
console.log(slice);
|
||||
const str = slice.content.textBetween(0, slice.size);
|
||||
const tr = view.state.tr;
|
||||
if (/^(https?:\/\/|mailto:)\S+$/i.test(str) && !tr.selection.empty) {
|
||||
|
@ -249,7 +262,7 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
|
|||
return true;
|
||||
},
|
||||
transformPastedHTML(html) {
|
||||
const tmp = document.createElement("div");
|
||||
const tmp = document.createElement("x-html-import");
|
||||
tmp.innerHTML = html;
|
||||
const replacements: Array<[string, (el: HTMLElement) => string]> = [
|
||||
["b, bold, strong, [style*='font-weight:700']", (el) => `**${el.textContent}**`],
|
||||
|
@ -257,18 +270,17 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
|
|||
["a", (el) => `[${el.textContent}](${el.getAttribute("href")})`],
|
||||
["code", (el) => `\`${el.textContent}\``],
|
||||
...[1, 2, 3, 4, 5, 6].map((level) => ["h" + level, (el: HTMLElement) => `${"#".repeat(level)} ${el.textContent}`]) as Array<any>,
|
||||
["ul", (el) => [...el.children].map(i => "- " + i.textContent).join("\n")],
|
||||
["ol", (el) => [...el.children].map((i, x) => (x + 1) + ". " + i.textContent).join("\n")],
|
||||
["ul", (el) => "\n" + [...el.children].map(i => "- " + i.textContent).join("\n") + "\n"],
|
||||
["ol", (el) => "\n" + [...el.children].map((i, x) => (x + 1) + ". " + i.textContent).join("\n") + "\n"],
|
||||
["br", () => "\n"],
|
||||
["p, span, div", (el) => el.textContent || ""],
|
||||
];
|
||||
for (const [selector, replacement] of replacements) {
|
||||
for (const el of tmp.querySelectorAll(selector)) {
|
||||
el.replaceWith(replacement(el as HTMLElement));
|
||||
}
|
||||
}
|
||||
console.log({ html, md: tmp.innerHTML });
|
||||
return tmp.innerHTML;
|
||||
console.log({ html, md: tmp.outerHTML });
|
||||
return tmp.outerHTML;
|
||||
}
|
||||
});
|
||||
view.focus();
|
||||
|
|
108
src/Main.tsx
108
src/Main.tsx
|
@ -1,7 +1,7 @@
|
|||
import { Component, For, JSX, Match, Show, Switch, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import "./App.scss";
|
||||
import { Client, Room, RoomList, Thread } from "sdk";
|
||||
import { ThreadsItem } from "./Room";
|
||||
import { RoomView, ThreadsItem } from "./Room";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { Dialog, Dropdown, Text, Time, Tooltip } from "./Atoms";
|
||||
import { useGlobals } from "./Context";
|
||||
|
@ -55,7 +55,7 @@ export default function App(): JSX.Element {
|
|||
return (
|
||||
<>
|
||||
<header id="header"></header>
|
||||
<main id="main">
|
||||
<main id="main" class={room() ? "room" : "home"}>
|
||||
<Switch>
|
||||
<Match when={!params.roomId}><Home client={globals.client} /></Match>
|
||||
<Match when={!room()}>please wait...</Match>
|
||||
|
@ -100,44 +100,6 @@ export default function App(): JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
export function RoomView(props: VoidProps<{ room: Room }>) {
|
||||
return (
|
||||
<>
|
||||
<Threads room={props.room} />
|
||||
<div class="actions">
|
||||
<RoomActions room={props.room} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Threads(props: VoidProps<{ room: Room }>) {
|
||||
const [paginator, { mutate }] = createResource(() => props.room, async (room: Room) => {
|
||||
console.log("fetch room threads")
|
||||
return room.threads.paginate();
|
||||
}, {
|
||||
storage: (init) => createSignal(init, { equals: false}),
|
||||
});
|
||||
|
||||
const threads = () => paginator()?.list ?? [];
|
||||
|
||||
return (
|
||||
<div class="timeline threads">
|
||||
<div class="items">
|
||||
<RoomHeader room={props.room} />
|
||||
<Show when={!paginator.loading}>
|
||||
<Show when={!paginator()?.isAtEnd}>
|
||||
<button onClick={() => paginator()?.paginate().then(() => mutate(paginator))}>load more</button>
|
||||
</Show>
|
||||
<For each={threads()}>
|
||||
{(thread: Thread) => <ThreadsItem thread={thread} />}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ROOM_TYPE_CHAT = "jw.chat";
|
||||
const ROOM_TYPE_SPACE = "m.space";
|
||||
|
||||
|
@ -189,71 +151,9 @@ export function Home(props: VoidProps<{ client: Client }>) {
|
|||
<p>In the year 2050, the world has made significant advancements in technology and sustainability. Cities are now powered by renewable energy sources, and transportation relies heavily on electric and autonomous vehicles. The concept of "smart cities" has become a reality, with advanced AI systems managing traffic, waste management, and public</p>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div style="height: 10000px;"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomHeader(props: VoidProps<{ room: Room }>) {
|
||||
const name = () => props.room.getState("m.room.name")?.content.name || "unnamed room";
|
||||
const topic = () => props.room.getState("m.room.topic")?.content.topic || "";
|
||||
|
||||
function invite() {
|
||||
const uid = prompt("user id");
|
||||
if (uid) props.room.client.net.roomInvite(props.room.id, uid);
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div class="timeline-create">
|
||||
<h1><Text name={name()}>room.welcome</Text></h1>
|
||||
<p><Text topic={topic()}>room.topic</Text></p>
|
||||
<p style="color:var(--foreground-2)">Debug: room_id=<code style="user-select:all">{props.room.id}</code></p>
|
||||
<div class="actions">
|
||||
<button onClick={() => navigate(`/rooms/${props.room.id}/settings`)}>edit room</button>
|
||||
<button onClick={invite}>invite people</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomActions(props: VoidProps<{ room: Room }>) {
|
||||
async function makeThread() {
|
||||
const title = prompt("title");
|
||||
const body = prompt("body");
|
||||
const thread = await props.room.sendEvent("m.thread.message", {
|
||||
title: [{ body: title }],
|
||||
});
|
||||
await props.room.sendEvent("m.message", {
|
||||
text: [{ body }],
|
||||
"m.relations": [
|
||||
{ rel_type: "m.thread", event_id: thread.id },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return <>
|
||||
<select>
|
||||
<option>default</option>
|
||||
<option>include ignoring</option>
|
||||
<option>only watching</option>
|
||||
</select>
|
||||
<Toggle enabled="[x] Unread" disabled="[ ] Unread" initial={false} />
|
||||
<Toggle enabled="[x] Watching" disabled="[ ] Watching" initial={false} />
|
||||
<div class="create-thread">
|
||||
<div onclick={makeThread}>New thread</div>
|
||||
<div>+</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function Toggle(props: { enabled: string, disabled: string, initial: boolean }) {
|
||||
const [disabled, setDisabled] = createSignal(props.initial);
|
||||
return (
|
||||
<div
|
||||
class="toggle"
|
||||
classList={{ disabled: disabled() }}
|
||||
onClick={() => setDisabled(!disabled())}
|
||||
>{disabled() ? props.enabled : props.disabled}</div>
|
||||
)
|
||||
}
|
||||
|
|
168
src/Room.scss
Normal file
168
src/Room.scss
Normal file
|
@ -0,0 +1,168 @@
|
|||
.room {
|
||||
& > .timeline {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
|
||||
&.threads > .items {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
& > .items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
min-height: 100%;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
& > .actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
// background: linear-gradient(to bottom, transparent, var(--background-2) 10%);
|
||||
background: var(--background-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
border-top: solid var(--background-3) 1px;
|
||||
|
||||
& select {
|
||||
background: var(--background-3);
|
||||
border: solid var(--background-4) 1px;
|
||||
|
||||
&:hover {
|
||||
background: #fcfcfc11;
|
||||
}
|
||||
}
|
||||
|
||||
& > .create-thread {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
|
||||
& > div {
|
||||
padding: 4px;
|
||||
background: #46c;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-thread {
|
||||
--title-font-size: calc(1rem * 1.2);
|
||||
--info-font-size: calc(1rem * 1);
|
||||
margin: 4px 32px;
|
||||
background: var(--background-2);
|
||||
border: solid var(--background-4) 1px;
|
||||
// border-radius: 4px;
|
||||
contain: content;
|
||||
|
||||
// &:hover {
|
||||
// background: #00000022;
|
||||
// }
|
||||
|
||||
&.creating {
|
||||
border: none;
|
||||
|
||||
& > .dropdown, input {
|
||||
background: var(--background-1);
|
||||
}
|
||||
}
|
||||
|
||||
& > header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
background: var(--background-3);
|
||||
border-bottom: solid var(--background-4) 1px;
|
||||
line-height: 1;
|
||||
|
||||
& > .top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
& > .title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
& > * {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
& > .icon {
|
||||
background: #34363b;
|
||||
border-radius: 50%;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
& > .title {
|
||||
font-size: 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
& > .date {
|
||||
color: var(--foreground-2);
|
||||
align-self:flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
& > .bottom {
|
||||
align-self: start;
|
||||
margin-top: 8px;
|
||||
color: var(--foreground-2);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground-1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > .preview {
|
||||
// padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
& > footer {
|
||||
background: linear-gradient(to bottom, transparent, var(--background-3) 50%);
|
||||
padding: 4px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to bottom, transparent, var(--background-4) 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-create {
|
||||
margin: 32px 32px 8px;
|
||||
|
||||
& > .actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
283
src/Room.tsx
283
src/Room.tsx
|
@ -1,35 +1,248 @@
|
|||
import { Thread } from "sdk";
|
||||
import { For, JSX, Match, Show, Suspense, Switch, VoidProps, createEffect, createResource, createSignal, onCleanup } from "solid-js";
|
||||
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 { useGlobals } from "./Context";
|
||||
import { Text, Time } from "./Atoms";
|
||||
import { Dropdown, Text, Time } from "./Atoms";
|
||||
import { ThreadTimeline } from "sdk/dist/src/timeline";
|
||||
import { shouldSplit } from "./util";
|
||||
import { MediaId } from "sdk/dist/src/api";
|
||||
import { Message, TextBlock } from "./Message";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { SetStoreFunction, createStore } from "solid-js/store";
|
||||
import "./Room.scss";
|
||||
|
||||
const Editor = lazy(() => import("./Editor"));
|
||||
|
||||
type CreatingThread = {
|
||||
type: "chat" | "voice",
|
||||
title: string,
|
||||
message: string,
|
||||
attachments?: Array<File>,
|
||||
};
|
||||
|
||||
interface RoomState {
|
||||
creating?: CreatingThread,
|
||||
scrollBy?: (x: number, y: number) => void,
|
||||
}
|
||||
|
||||
const RoomContext = createContext<[RoomState, SetStoreFunction<RoomState>]>();
|
||||
|
||||
export function RoomView(props: VoidProps<{ room: Room }>) {
|
||||
const [store, setStore] = createStore<RoomState>({});
|
||||
|
||||
return (
|
||||
<RoomContext.Provider value={[store, setStore]}>
|
||||
<Threads room={props.room} />
|
||||
<div class="actions">
|
||||
<RoomActions room={props.room} />
|
||||
</div>
|
||||
</RoomContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomActions(props: VoidProps<{ room: Room }>) {
|
||||
const [_store, setStore] = useContext(RoomContext)!;
|
||||
|
||||
async function makeThread() {
|
||||
setStore({
|
||||
creating: {
|
||||
type: "chat",
|
||||
title: "",
|
||||
message: "",
|
||||
attachments: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown options={[
|
||||
{ item: "default", label: "default" },
|
||||
{ item: "ignoring", label: "include ignoring" },
|
||||
{ item: "watching", label: "only watching" },
|
||||
]} />
|
||||
<Toggle enabled="[x] Unread" disabled="[ ] Unread" initial={false} />
|
||||
<Toggle enabled="[x] Watching" disabled="[ ] Watching" initial={false} />
|
||||
<div class="create-thread">
|
||||
<div onclick={makeThread}>New thread</div>
|
||||
<div>+</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomHeader(props: VoidProps<{ room: Room }>) {
|
||||
const name = () => props.room.getState("m.room.name")?.content.name || "unnamed room";
|
||||
const topic = () => props.room.getState("m.room.topic")?.content.topic || "";
|
||||
|
||||
function invite() {
|
||||
const uid = prompt("user id");
|
||||
if (uid) props.room.client.net.roomInvite(props.room.id, uid);
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div class="timeline-create">
|
||||
<h1><Text name={name()}>room.welcome</Text></h1>
|
||||
<p><Text topic={topic()}>room.topic</Text></p>
|
||||
<p style="color:var(--foreground-2)">Debug: room_id=<code style="user-select:all">{props.room.id}</code></p>
|
||||
<div class="actions">
|
||||
<button onClick={() => navigate(`/rooms/${props.room.id}/settings`)}>edit room</button>
|
||||
<button onClick={invite}>invite people</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle(props: { enabled: string, disabled: string, initial: boolean }) {
|
||||
const [disabled, setDisabled] = createSignal(props.initial);
|
||||
return (
|
||||
<div
|
||||
class="toggle"
|
||||
classList={{ disabled: disabled() }}
|
||||
onClick={() => setDisabled(!disabled())}
|
||||
>{disabled() ? props.enabled : props.disabled}</div>
|
||||
)
|
||||
}
|
||||
|
||||
type LoadingStatus = "loading" | "update" | "ready";
|
||||
|
||||
function createThreads(initialRoom: Room) {
|
||||
const [room, setRoom] = createSignal(initialRoom);
|
||||
const [threads, setThreads] = createSignal<Array<Thread>>([]);
|
||||
const [status, setStatus] = createSignal<LoadingStatus>("loading");
|
||||
const [location, setLocation] = createSignal({ atStart: false, atEnd: false });
|
||||
let paginator: ThreadPaginator;
|
||||
|
||||
// FIXME: thread mangling when switching rooms quickly
|
||||
async function init() {
|
||||
paginator = await room().threads.paginate();
|
||||
setStatus("update");
|
||||
setThreads(paginator.list);
|
||||
setLocation({ atStart: paginator.isAtEnd, atEnd: true });
|
||||
setStatus("ready");
|
||||
}
|
||||
|
||||
async function backwards() {
|
||||
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)));
|
||||
setStatus("update");
|
||||
setThreads([...paginator.list]);
|
||||
setLocation({ atStart: paginator.isAtEnd, atEnd: true });
|
||||
setStatus("ready");
|
||||
}
|
||||
|
||||
function forwards() {
|
||||
if (status() !== "ready") return;
|
||||
// TODO: virtual scrolling?
|
||||
}
|
||||
|
||||
function handleThread(_thread: Thread) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
let oldRoom: Room;
|
||||
createEffect(() => {
|
||||
oldRoom?.off("thread", handleThread);
|
||||
room().on("thread", handleThread);
|
||||
oldRoom = room();
|
||||
|
||||
setStatus("loading");
|
||||
init();
|
||||
});
|
||||
|
||||
return {
|
||||
getThreads: threads,
|
||||
getStatus: status,
|
||||
getLocation: location,
|
||||
setRoom,
|
||||
backwards,
|
||||
forwards,
|
||||
};
|
||||
}
|
||||
|
||||
function Threads(props: VoidProps<{ room: Room }>) {
|
||||
const [store, setStore] = useContext(RoomContext)!;
|
||||
const th = createThreads(props.room);
|
||||
let scrollEl: HTMLDivElement;
|
||||
|
||||
createEffect(() => {
|
||||
th.setRoom(props.room);
|
||||
scrollEl?.scrollBy(0, 999999);
|
||||
});
|
||||
|
||||
let scrollRefEl: HTMLDivElement | null = null;
|
||||
let offsetTop: number;
|
||||
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);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
onMount(() => {
|
||||
setStore({ scrollBy: scrollEl.scrollBy.bind(scrollEl) });
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="timeline threads" ref={scrollEl!}>
|
||||
<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>
|
||||
<Show when={th.getLocation().atEnd && store.creating}>
|
||||
<ThreadsBuilder room={props.room} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
||||
const [globals, change] = useGlobals();
|
||||
const [roomContext, _setStore] = useContext(RoomContext)!;
|
||||
|
||||
const THREAD_PREVIEW_COUNT = 10;
|
||||
const [preview, { mutate }] = createResource(props.thread, async (thread) => {
|
||||
return await thread.timelines.fetch("start");
|
||||
await thread.timelines.fetch("end", 1);
|
||||
const tl = await thread.timelines.fetch("start", 10);
|
||||
return tl;
|
||||
}, {
|
||||
storage: (init) => createSignal(init, { equals: false }),
|
||||
});
|
||||
|
||||
let wrapperEl: HTMLElement;
|
||||
const refresh = () => {
|
||||
// console.log("timeline appended")
|
||||
let offsetHeight = wrapperEl.offsetHeight;
|
||||
mutate(preview());
|
||||
setThread(thread());
|
||||
roomContext.scrollBy?.(0, wrapperEl.offsetHeight - offsetHeight);
|
||||
};
|
||||
|
||||
let oldTimeline: ThreadTimeline | undefined;
|
||||
createEffect(() => {
|
||||
// console.log("set listeners", oldTimeline, preview());
|
||||
oldTimeline?.off("timelineAppend", refresh);
|
||||
preview()?.on("timelineAppend", refresh);
|
||||
const replace = () => console.log("replace");
|
||||
oldTimeline?.off("timelineReplace", replace);
|
||||
preview()?.on("timelineReplace", replace);
|
||||
oldTimeline = preview();
|
||||
}, preview());
|
||||
});
|
||||
|
||||
const [thread, setThread] = createSignal(props.thread, { equals: false });
|
||||
const remaining = () => preview() && (thread().messageCount - Math.min(preview()!.getEvents().length, THREAD_PREVIEW_COUNT ));
|
||||
|
@ -37,6 +250,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
|||
const willOpenThread = (target: "default" | "latest") => (e: MouseEvent) => {
|
||||
// TODO: target
|
||||
const isSame = (globals.global.sidebar as Thread)?.id === props.thread.id;
|
||||
if (!isSame) props.thread.timelines.fetch("end", 50);
|
||||
change({
|
||||
type: "sidebar.focus",
|
||||
item: isSame ? null : props.thread,
|
||||
|
@ -46,8 +260,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<article class="timeline-thread">
|
||||
<article class="timeline-thread" ref={wrapperEl!}>
|
||||
<header onClick={willOpenThread("default")}>
|
||||
<div class="top">
|
||||
<div class="icon"></div>
|
||||
|
@ -71,6 +284,54 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
|||
</footer>
|
||||
</Show>
|
||||
</article>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadsBuilder(props: VoidProps<{ room: Room }>) {
|
||||
const [store, setStore] = useContext(RoomContext)!;
|
||||
|
||||
function handleSelect(item: string | null) {
|
||||
setStore({ creating: item ? { ...store.creating, type: item } as CreatingThread : undefined });
|
||||
}
|
||||
|
||||
function handleTitle(title: string) {
|
||||
setStore({ creating: { ...store.creating, title } as CreatingThread });
|
||||
}
|
||||
|
||||
async function handleSubmit({ text, html }: { text: string, html: string }) {
|
||||
if (!store.creating) return false;
|
||||
if (!store.creating.title) return false;
|
||||
if (!text) return false;
|
||||
const thread = await props.room.sendEvent("m.thread.message", {
|
||||
title: [{ body: store.creating.title }],
|
||||
});
|
||||
await props.room.sendEvent("m.message", {
|
||||
text: [
|
||||
{ body: text },
|
||||
{ body: html, type: "text/html" },
|
||||
],
|
||||
"m.relations": [
|
||||
{ rel_type: "m.thread", event_id: thread.id },
|
||||
],
|
||||
});
|
||||
setStore({ creating: undefined });
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="timeline-thread creating">
|
||||
<header>create a thread</header>
|
||||
<div class="preview" style="min-height:100px;padding:8px;">
|
||||
<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: "voice", label: "voice" },
|
||||
]} />
|
||||
<h3>thread title</h3>
|
||||
<input onInput={(e) => handleTitle(e.target.value)} autofocus />
|
||||
<h3>thread body</h3>
|
||||
<Editor onSubmit={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -100,12 +100,12 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
|
|||
throw new Error("todo!");
|
||||
}
|
||||
|
||||
async function append(_event: Event) {
|
||||
// async function append(_event: Event) {
|
||||
async function append() {
|
||||
if (status() !== "ready") return;
|
||||
if (!isAutoscrolling()) return;
|
||||
|
||||
const currentInfo = info()!;
|
||||
const newEnd = Math.min(currentInfo.end + 1, timeline().getEvents().length);
|
||||
const newEnd = timeline().getEvents().length;
|
||||
const newStart = Math.max(newEnd - SLICE_COUNT, 0);
|
||||
setInfo({ start: newStart, end: newEnd });
|
||||
setStatus("update");
|
||||
|
@ -118,11 +118,16 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
|
|||
let oldTimeline: ThreadTimeline;
|
||||
createEffect(() => {
|
||||
timeline().on("timelineAppend", append);
|
||||
oldTimeline?.off("timelineAppend", append)
|
||||
timeline().on("timelineUpdate", append);
|
||||
oldTimeline?.off("timelineAppend", append);
|
||||
oldTimeline?.off("timelineUpdate", append);
|
||||
oldTimeline = timeline();
|
||||
});
|
||||
|
||||
onCleanup(() => oldTimeline?.off("timelineAppend", append));
|
||||
onCleanup(() => {
|
||||
oldTimeline?.off("timelineAppend", append);
|
||||
oldTimeline?.off("timelineUpdate", append);
|
||||
});
|
||||
|
||||
return {
|
||||
events,
|
||||
|
|
Loading…
Reference in a new issue