Media viewer, message toolbar, fixed header

This commit is contained in:
tezlm 2024-01-17 00:18:02 -08:00
parent 9d1a3cbf0c
commit 1066f0ccf1
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
12 changed files with 493 additions and 386 deletions

View file

@ -152,12 +152,15 @@ h1 {
backdrop-filter: blur(5px);
& > .dialog {
border: solid var(--background-3) 1px;
outline: none !important;
animation: dialog 150ms cubic-bezier(.13,.37,.49,1) forwards;
}
& > .dialog:not(.raw) {
border: solid var(--background-3) 1px;
background: var(--background-1);
color: inherit;
padding: 8px;
animation: dialog 150ms cubic-bezier(.13,.37,.49,1) forwards;
}
}

View file

@ -285,7 +285,7 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
);
}
export function Dialog() {
export function Dialog(props: ParentProps<{ raw?: boolean }>) {
const [_globals, action] = useGlobals();
// TODO: close animation
@ -297,10 +297,8 @@ export function Dialog() {
return (
<Portal>
<div class="backdrop" onClick={close} onKeyDown={binds}>
<div class="dialog" role="dialog" onClick={(e) => e.stopPropagation()}>
<h1>here is a modal</h1>
<p>some more info hewe</p>
<button onClick={close}>ok</button>
<div class="dialog" classList={{ raw: props.raw }} role="dialog" onClick={(e) => e.stopPropagation()}>
{props.children}
</div>
</div>
</Portal>

View file

@ -1,34 +1,38 @@
import { Accessor, ParentProps, Resource, createContext, createEffect, createResource, createSignal, onCleanup, useContext } from "solid-js";
import * as i18n from "i18next";
import { Client, ClientState, Event, Room, Thread } from "sdk";
import { EventId, UserId } from "sdk/dist/src/api";
import { EventId, RoomId, UserId } from "sdk/dist/src/api";
import { Store, createStore } from "solid-js/store";
import { createEmitter } from "@solid-primitives/event-bus";
import { useNavigate } from "@solidjs/router";
import { FileI } from "./Media";
interface Settings {
compact: boolean,
locale: string,
}
interface GlobalState {
dialogs: Array<any>,
sidebar: null | "members" | Thread,
contextMenu: null | any,
}
interface RoomState {
}
interface RoomState {}
interface ThreadState {
// reply: Event,
reply: Event | null,
}
type Dialog = { type: "media", file: FileI };
type ContextMenu = { x: number, y: number } & (
{ type: "room", room: Room } |
{ type: "thread", thread: Thread } |
{ type: "message", thread: Thread, event: Event }
);
interface Globals {
settings: Settings,
global: GlobalState,
room: RoomState | null,
thread: ThreadState | null,
dialogs: Array<Dialog>,
sidebar: null | "members" | Thread,
contextMenu: null | ContextMenu,
rooms: Record<RoomId, RoomState>,
threads: Record<EventId, ThreadState>,
locale: i18n.TFunction | null,
client: Client,
scene: Scene,
@ -54,9 +58,10 @@ type Action =
{ type: "login", client: Client } |
{ type: "logout" } |
{ type: "sidebar.focus", item: Thread | "members" | null } |
{ type: "dialog" } |
{ type: "dialog.open", dialog: Dialog } |
{ type: "dialog.close" } |
{ type: "contextmenu.set", menu: any }
{ type: "input.reply", thread: Thread, event: Event | null } |
{ type: "contextmenu.set", menu: ContextMenu | null }
const GlobalsContext = createContext<[Store<Globals>, (change: Action) => void]>();
export const useGlobals = () => useContext(GlobalsContext)!;
@ -77,13 +82,11 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
compact: true,
locale: "en",
},
global: {
dialogs: [],
sidebar: null,
contextMenu: null,
},
room: null,
thread: null,
dialogs: [],
sidebar: null,
contextMenu: null,
rooms: {},
threads: {},
locale: null,
// FIXME: type safety
client: props.client as Client,
@ -109,10 +112,10 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
const navigate = useNavigate();
function redux(action: Action) {
// console.log("dispatch action", action);
console.log("dispatch action", action);
switch (action.type) {
case "sidebar.focus":
update("global", "sidebar", action.item);
update("sidebar", action.item);
break;
case "login":
update("client", action.client);
@ -125,16 +128,24 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
case "logout":
globals.client.logout();
break;
case "dialog": {
update("global", "dialogs", [...globals.global.dialogs, null]);
case "dialog.open": {
update("dialogs", [...globals.dialogs, action.dialog]);
break;
}
case "dialog.close": {
update("global", "dialogs", globals.global.dialogs.slice(0, -1));
update("dialogs", globals.dialogs.slice(0, -1));
break;
}
case "input.reply": {
if (!globals.threads[action.thread.id]) {
update("threads", action.thread.id, { reply: action.event });
} else {
update("threads", action.thread.id, "reply", action.event);
}
break;
}
case "contextmenu.set": {
update("global", "contextMenu", action.menu);
update("contextMenu", action.menu);
}
}
}

View file

@ -11,6 +11,7 @@ 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";
import { File, FileI } from "./Media";
type View =
{ type: "loading" } |
@ -101,9 +102,6 @@ export default function App(): JSX.Element {
globals.client.on("list", updateRooms);
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 } });
@ -120,7 +118,7 @@ export default function App(): JSX.Element {
const [menuParentRef, setMenuParentRef] = createSignal<ReferenceElement>();
const [menuRef, setMenuRef] = createSignal<HTMLElement>();
const menuFloating = useFloating(menuParentRef, menuRef, {
middleware: [shift()],
middleware: [shift({ mainAxis: true, crossAxis: true, padding: 8 })],
placement: "right-start",
});
@ -128,12 +126,12 @@ export default function App(): JSX.Element {
setMenuParentRef({
getBoundingClientRect(): ClientRectObject {
return {
x: contextMenu().x,
y: contextMenu().y,
left: contextMenu().x,
top: contextMenu().y,
right: contextMenu().x,
bottom: contextMenu().y,
x: globals.contextMenu!.x,
y: globals.contextMenu!.y,
left: globals.contextMenu!.x,
top: globals.contextMenu!.y,
right: globals.contextMenu!.x,
bottom: globals.contextMenu!.y,
width: 0,
height: 0,
};
@ -185,16 +183,16 @@ export default function App(): JSX.Element {
</For>
</ul>
</nav>
<Show when={sidebar()}>
<div id="sidebar" classList={{ thread: sidebar() instanceof Thread }}>
<Show when={globals.sidebar}>
<div id="sidebar" classList={{ thread: globals.sidebar instanceof Thread }}>
<Switch>
<Match when={sidebar() instanceof Thread}>
<ThreadView thread={sidebar() as Thread} />
<Match when={globals.sidebar instanceof Thread}>
<ThreadView thread={globals.sidebar as Thread} />
</Match>
<Match when={sidebar() === "members"}>
<Match when={globals.sidebar === "members"}>
<div style="padding: 4px">member list</div>
</Match>
<Match when={!sidebar() && false}>
<Match when={!globals.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>
@ -208,19 +206,22 @@ export default function App(): JSX.Element {
</div>
</Show>
<footer id="status">
<button onClick={() => change({ type: "dialog" })}>dialog</button>
<button onClick={() => change({ type: "logout" })}>logout</button>
</footer>
<Portal>
<For each={globals.global.dialogs}>
{() => <Dialog />}
<For each={globals.dialogs}>
{(dialog) => <Switch>
<Match when={dialog.type === "media"}>
<MediaDialog file={dialog.file} />
</Match>
</Switch>}
</For>
<Show when={contextMenu()}>
<Show when={globals.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 thread={contextMenu().thread} event={contextMenu().event} />} />
<Match when={globals.contextMenu!.type === "room"} children={<menu.RoomMenu room={(globals.contextMenu as any).room} />} />
<Match when={globals.contextMenu!.type === "thread"} children={<menu.ThreadMenu thread={(globals.contextMenu as any).thread} />} />
<Match when={globals.contextMenu!.type === "message"} children={<menu.MessageMenu thread={(globals.contextMenu as any).thread} event={(globals.contextMenu as any).event} />} />
</Switch>
</div>
</Show>
@ -229,6 +230,16 @@ export default function App(): JSX.Element {
)
}
function MediaDialog(props: { file: FileI }) {
return (
<Dialog raw>
<div style="max-height: 80%; max-width: 80%">
<File file={props.file} />
</div>
</Dialog>
)
}
export function Home(props: VoidProps<{ client: Client }>) {
/*
Menu items:

94
src/Media.scss Normal file
View file

@ -0,0 +1,94 @@
.image {
display: flex;
background: var(--background-3);
position: relative;
& > img {
height: 100%;
}
& > figcaption {
opacity: 0;
transition: all .2s;
position: absolute;
bottom: -2px;
left: 0;
background: #111d;
width: 100%;
padding: 4px;
}
&:hover > figcaption {
opacity: 1;
bottom: 0;
}
}
.video {
contain: content;
display: block;
& > .info {
position: fixed;
bottom: -2px;
background: #111d;
width: 100%;
opacity: 0;
transition: all .2s;
&.shown, &:hover {
bottom: 0;
opacity: 1;
}
& > .progress {
height: 8px;
background: var(--background-4);
& > .bar {
height: 8px;
width: 0;
background: var(--foreground-link);
}
}
}
& > video {
width: 100%;
height: 100%;
}
}
.audio {
background: var(--background-1);
border: solid var(--background-4) 1px;
display: flex;
flex-direction: column;
& > .main {
display: flex;
height: 10;
& > .icon {
height: 48px;
width: 48px;
background: #4af;
}
& > .info {
padding: 4px 8px;
line-height: 1;
}
}
& > .progress {
height: 8px;
background: var(--background-3);
& > .bar {
height: 8px;
background: var(--foreground-link);
}
}
}

View file

@ -1 +1,232 @@
import { MediaId } from "sdk/dist/src/api";
import { Match, Switch, VoidProps, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { useGlobals } from "./Context";
import { debounce } from "@solid-primitives/scheduled";
import "./Media.scss";
export function File(props: VoidProps<{
file: FileI,
bounding?: { height?: number, width?: number },
onClick?: (e: MouseEvent) => void,
}>) {
const [globals, action] = useGlobals();
const major = () => props.file.info?.mimetype?.split("/")[0] || "application";
const httpUrl = () => props.file.url.replace(/^mxc:\/\//, `${globals.client.config.baseUrl}/_matrix/media/v3/download/`);
return (
<Switch>
<Match when={major() === "image"}>
<Image file={props.file} src={httpUrl()} bounding={props.bounding} onClick={props.onClick} />
</Match>
<Match when={major() === "audio"}>
<Audio file={props.file} src={httpUrl()} />
</Match>
<Match when={major() === "video"}>
<Video file={props.file} src={httpUrl()} />
</Match>
<Match when={major() === "text" || props.file.info?.mimetype === "application/json"}>
<div>
text: {props.file.info?.name}: <a href={httpUrl()}>link</a>
</div>
</Match>
<Match when={true}>
<div>
other: {props.file.info?.name}: <a href={httpUrl()}>link</a>
</div>
</Match>
</Switch>
)
}
function Image(props: VoidProps<{
file: FileI,
src: string,
bounding?: { height?: number, width?: number },
onClick?: (e: MouseEvent) => void,
}>) {
const info = () => props.file.info ?? {};
return (
<figure class="image" onClick={props.onClick}>
<img src={props.src} style={getDimensions(info(), props.bounding)} />
<figcaption>{props.file.info?.name}</figcaption>
</figure>
);
}
function Audio(props: VoidProps<{ file: FileI, src: string }>) {
const audio = new window.Audio();
createEffect(() => audio.src = props.src);
const [duration, setDuration] = createSignal(0);
const [progress, setProgress] = createSignal(0);
const [isPlaying, setIsPlaying] = createSignal(false);
audio.ondurationchange = () => setDuration(audio.duration);
audio.ontimeupdate = () => setProgress(audio.currentTime);
audio.onplay = () => setIsPlaying(true);
audio.onpause = () => setIsPlaying(false);
function togglePlayPause() {
if (isPlaying()) {
audio.pause();
} else {
audio.play();
}
}
onCleanup(() => audio.pause());
return (
<div class="audio">
<div class="main">
<div class="icon"></div>
<div class="info">
<div><b>{props.file.info?.name}</b></div>
<div>
{formatTime(progress())} / {formatTime(duration())}
<button onClick={togglePlayPause}>playpause</button>
</div>
</div>
</div>
<div class="progress"></div>
</div>
);
}
function Video(props: VoidProps<{
file: FileI,
src: string,
bounding?: { height?: number, width?: number },
}>) {
let videoEl: HTMLVideoElement;
let wrapperEl: HTMLDivElement;
const [duration, setDuration] = createSignal(0);
const [progress, setProgress] = createSignal(0);
const [isFullscreen, setIsFullscreen] = createSignal(false);
const [state, setState] = createSignal<"play" | "pause" | "end">("end");
onMount(() => {
videoEl.ondurationchange = () => setDuration(videoEl.duration);
videoEl.ontimeupdate = () => setProgress(videoEl.currentTime);
videoEl.onplay = () => setState("play");
videoEl.onpause = () => setState("pause");
videoEl.onended = () => setState("end");
wrapperEl.onfullscreenchange = () => setIsFullscreen(document.fullscreenElement === wrapperEl);
});
function togglePlayPause() {
if (state() === "play") {
videoEl.pause();
} else {
videoEl.play();
}
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
videoEl.currentTime += e.deltaY < 0 ? -5 : 5;
}
function toggleFullscreen() {
if (isFullscreen()) {
document.exitFullscreen();
} else {
wrapperEl.requestFullscreen();
}
}
const [isMouseMoved, setIsMouseMoved] = createSignal(false);
const [isHovering, setIsHovering] = createSignal(false);
const unsetIsMouseMoved = debounce(() => setIsMouseMoved(false), 1000);
function handleMouseMove() {
setIsMouseMoved(true);
setIsHovering(true);
unsetIsMouseMoved();
}
function handleMouseLeave() {
setIsHovering(false);
}
const isInfoShown = () => {
return state() === "pause" || (isFullscreen() ? isMouseMoved() : isHovering());
}
return (
<div
class="video"
ref={wrapperEl!}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
classList={{ [state()]: true }}
style={{ ...getDimensions(props.file.info ?? {}, props.bounding) }}
>
<video onClick={togglePlayPause} ref={videoEl!} src={props.src}></video>
<div class="info" classList={{ shown: isInfoShown() }}>
<div><b>{props.file.info?.name}</b> - <button onClick={toggleFullscreen}>full</button></div>
<div onWheel={handleWheel}>
{formatTime(progress())} / {formatTime(duration())} - {state()}
</div>
<div class="progress">
<div class="bar" style={{ width: `${progress() * 100 / duration()}%`}}></div>
</div>
</div>
</div>
);
}
function formatTime(time: number): string {
const t = Math.floor(time);
const seconds = t % 60;
const minutes = Math.floor(t / 60) % 60;
const hours = Math.floor(t / 3600);
if (hours) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
}
function getDimensions(info: { width?: number, height?: number }, bounding?: { width?: number, height?: number }) {
let height = Math.max(info.height || bounding?.height || 300, 100);
let width = Math.max(info.width || bounding?.width || 300, 100);
if (bounding?.height) {
const newHeight = Math.min(height, bounding.height);
width *= newHeight / height;
height = newHeight;
}
if (bounding?.width) {
const newWidth = Math.min(width, 300);
height *= newWidth / width;
width = newWidth;
}
return {
height: `${height}px`,
width: `${width}px`,
};
}
export interface FileI {
url: MediaId,
info?: {
name?: string,
mimetype?: string,
alt?: string,
size?: number,
height?: number,
width?: number,
duration?: number,
},
encryption?: {
kty: string,
key_ops: ["encrypt", "decrypt"],
alg: string,
k: string,
ext: true
},
thumbnail?: Exclude<File, "thumbnail">,
};

View file

@ -178,16 +178,18 @@ export function ThreadMenu(props: VoidProps<{ thread: Thread }>) {
// the context menu for messages
export function MessageMenu(props: VoidProps<{ thread: Thread, event: Event }>) {
const [_globals, action] = useGlobals();
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);
const reply = () => action({ type: "input.reply", event: props.event, thread: props.thread });
return (
<Menu>
<Item onClick={markUnread}>mark unread</Item>
{
// <Item>copy link</Item>
}
<Item>reply</Item>
<Item onClick={reply}>reply</Item>
<Item>edit</Item>
<Item>fork</Item>
<Item>pin</Item>

View file

@ -16,7 +16,7 @@
--name-width: 128px;
&.title {
padding-top: 8px;
margin-top: 4px;
}
& .name {
@ -79,109 +79,29 @@
gap: 4px;
list-style: none;
& > li > .audio {
& > li {
width: min-content;
}
}
}
.audio {
background: var(--background-1);
border: solid var(--background-4) 1px;
display: inline-flex;
flex-direction: column;
& > .main {
display: flex;
height: 10;
& > .icon {
height: 48px;
width: 48px;
background: #4af;
}
& > .info {
padding: 4px 8px;
line-height: 1;
}
}
& > .progress {
height: 8px;
background: var(--background-3);
& > .bar {
height: 8px;
background: var(--foreground-link);
}
}
}
.video {
contain: content;
display: inline-block;
& > .info {
position: fixed;
bottom: -2px;
background: #111d;
width: 100%;
opacity: 0;
transition: all .2s;
&.shown, &:hover {
bottom: 0;
opacity: 1;
}
& > .progress {
height: 8px;
background: var(--background-4);
& > .bar {
height: 8px;
width: 0;
background: var(--foreground-link);
}
}
}
& > video {
width: 100%;
height: 100%;
}
}
.image {
display: inline-flex;
background: var(--background-3);
position: relative;
& > img {
height: 100%;
}
& > figcaption {
opacity: 0;
transition: all .2s;
position: absolute;
bottom: -2px;
left: 0;
background: #111d;
width: 100%;
padding: 4px;
}
&:hover > figcaption {
opacity: 1;
bottom: 0;
}
}
.reply {
background: var(--background-4);
display: flex;
font-size: .9em;
line-height: 1;
margin-top: 4px;
&::before {
content: "";
border-left: solid #55555a 1px;
border-top: solid #55555a 1px;
position: relative;
width: 16px;
height: 6px;
top: calc(1rem - 8px);
margin-left: 2px;
margin-right: 4px;
}
& > div {
flex: 1;

View file

@ -7,6 +7,7 @@ import "./Message.scss";
import { Event, Thread } from "sdk";
import { debounce } from "@solid-primitives/scheduled";
import * as xss from "xss";
import { File } from "./Media";
const sanitizeAllowList = {
...Object.fromEntries([
@ -130,7 +131,13 @@ export function Message(props: VoidProps<{ thread: Thread, event: Event, title?:
<Show when={props.event.content["m.attachments"]}>
<ul class="attachments">
<For each={props.event.content["m.attachments"]}>
{(file) => <li><File file={file} /></li>}
{(file) => <li>
<File
file={file}
onClick={() => action({ type: "dialog.open", dialog: { type: "media", file }})}
bounding={{ height: 300, width: 400 }}
/>
</li>}
</For>
</ul>
</Show>
@ -138,215 +145,3 @@ export function Message(props: VoidProps<{ thread: Thread, event: Event, title?:
</div>
);
}
function File(props: VoidProps<{ file: FileI }>) {
const [globals] = useGlobals();
const major = () => props.file.info?.mimetype?.split("/")[0] || "application";
const httpUrl = () => props.file.url.replace(/^mxc:\/\//, `${globals.client.config.baseUrl}/_matrix/media/v3/download/`);
return (
<Switch>
<Match when={major() === "image"}>
<div>
<Image file={props.file} src={httpUrl()} />
</div>
</Match>
<Match when={major() === "audio"}>
<Audio file={props.file} src={httpUrl()} />
</Match>
<Match when={major() === "video"}>
<Video file={props.file} src={httpUrl()} />
</Match>
<Match when={major() === "text" || props.file.info?.mimetype === "application/json"}>
<div>
text: {props.file.info?.name}: <a href={httpUrl()}>link</a>
</div>
</Match>
<Match when={true}>
<div>
other: {props.file.info?.name}: <a href={httpUrl()}>link</a>
</div>
</Match>
</Switch>
)
}
function Image(props: VoidProps<{ file: FileI, src: string }>) {
const info = () => props.file.info ?? {};
return (
<figure class="image">
<img src={props.src} style={getDimensions(info())} />
<figcaption>{props.file.info?.name}</figcaption>
</figure>
);
}
function Audio(props: VoidProps<{ file: FileI, src: string }>) {
const audio = new window.Audio();
createEffect(() => audio.src = props.src);
const [duration, setDuration] = createSignal(0);
const [progress, setProgress] = createSignal(0);
const [isPlaying, setIsPlaying] = createSignal(false);
audio.ondurationchange = () => setDuration(audio.duration);
audio.ontimeupdate = () => setProgress(audio.currentTime);
audio.onplay = () => setIsPlaying(true);
audio.onpause = () => setIsPlaying(false);
function togglePlayPause() {
if (isPlaying()) {
audio.pause();
} else {
audio.play();
}
}
onCleanup(() => audio.pause());
return (
<div class="audio">
<div class="main">
<div class="icon"></div>
<div class="info">
<div><b>{props.file.info?.name}</b></div>
<div>
{formatTime(progress())} / {formatTime(duration())}
<button onClick={togglePlayPause}>playpause</button>
</div>
</div>
</div>
<div class="progress"></div>
</div>
);
}
function Video(props: VoidProps<{ file: FileI, src: string }>) {
let videoEl: HTMLVideoElement;
let wrapperEl: HTMLDivElement;
const [duration, setDuration] = createSignal(0);
const [progress, setProgress] = createSignal(0);
const [isFullscreen, setIsFullscreen] = createSignal(false);
const [state, setState] = createSignal<"play" | "pause" | "end">("end");
onMount(() => {
videoEl.ondurationchange = () => setDuration(videoEl.duration);
videoEl.ontimeupdate = () => setProgress(videoEl.currentTime);
videoEl.onplay = () => setState("play");
videoEl.onpause = () => setState("pause");
videoEl.onended = () => setState("end");
wrapperEl.onfullscreenchange = () => setIsFullscreen(document.fullscreenElement === wrapperEl);
});
function togglePlayPause() {
if (state() === "play") {
videoEl.pause();
} else {
videoEl.play();
}
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
videoEl.currentTime += e.deltaY < 0 ? -5 : 5;
}
function toggleFullscreen() {
if (isFullscreen()) {
document.exitFullscreen();
} else {
wrapperEl.requestFullscreen();
}
}
const [isMouseMoved, setIsMouseMoved] = createSignal(false);
const [isHovering, setIsHovering] = createSignal(false);
const unsetIsMouseMoved = debounce(() => setIsMouseMoved(false), 1000);
function handleMouseMove() {
setIsMouseMoved(true);
setIsHovering(true);
unsetIsMouseMoved();
}
function handleMouseLeave() {
setIsHovering(false);
}
const isInfoShown = () => {
return state() === "pause" || (isFullscreen() ? isMouseMoved() : isHovering());
}
return (
<div
class="video"
ref={wrapperEl!}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
classList={{ [state()]: true }}
style={{ ...getDimensions(props.file.info ?? {}) }}
>
<video onClick={togglePlayPause} ref={videoEl!} src={props.src}></video>
<div class="info" classList={{ shown: isInfoShown() }}>
<div><b>{props.file.info?.name}</b> - <button onClick={toggleFullscreen}>full</button></div>
<div onWheel={handleWheel}>
{formatTime(progress())} / {formatTime(duration())} - {state()}
</div>
<div class="progress">
<div class="bar" style={{ width: `${progress() * 100 / duration()}%`}}></div>
</div>
</div>
</div>
);
}
function formatTime(time: number): string {
const t = Math.floor(time);
const seconds = t % 60;
const minutes = Math.floor(t / 60) % 60;
const hours = Math.floor(t / 3600);
if (hours) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
}
function getDimensions(info: { width?: number, height?: number }) {
let height = Math.max(info.height || 300, 100);
let width = Math.max(info.width || 300, 100);
const newHeight = Math.min(height, 300);
width *= newHeight / height;
height = newHeight;
const newWidth = Math.min(width, 300);
height *= newWidth / width;
width = newWidth;
return {
height: `${height}px`,
width: `${width}px`,
};
}
interface FileI {
url: MediaId,
info?: {
name?: string,
mimetype?: string,
alt?: string,
size?: number,
height?: number,
width?: number,
duration?: number,
},
encryption?: {
kty: string,
key_ops: ["encrypt", "decrypt"],
alg: string,
k: string,
ext: true
},
thumbnail?: Exclude<File, "thumbnail">,
};

View file

@ -313,7 +313,7 @@ export function Inbox() {
};
function toggleThread(thread: Thread) {
if (globals.global.sidebar === thread) {
if (globals.sidebar === thread) {
action({ type: "sidebar.focus", item: null });
} else {
action({ type: "sidebar.focus", item: thread });
@ -387,7 +387,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread, timeline: ThreadT
const willOpenThread = (target: "default" | "latest") => (e: MouseEvent) => {
// TODO: target
const isSame = (globals.global.sidebar as Thread)?.id === props.thread.id;
const isSame = (globals.sidebar as Thread)?.id === props.thread.id;
if (!isSame) props.thread.timelines.fetch("end", 50);
change({
type: "sidebar.focus",

View file

@ -42,6 +42,34 @@
background: var(--foreground-link);
}
}
& > .toolbar-wrap {
position: relative;
& > .toolbar {
visibility: hidden;
position: absolute;
top: -16px;
right: 8px;
display: flex;
z-index: 1;
border: solid var(--background-4) 1px;
& > button {
height: 32px;
width: 32px;
border: none;
&:active {
padding-top: 5px;
}
}
}
&:hover > .toolbar {
visibility: visible;
}
}
}
& > .header {
@ -105,8 +133,8 @@
background: var(--background-2);
display: flex;
flex-direction: column;
justify-content: end;
padding: 8px;
gap: 8px;
border-top: solid var(--background-4) 1px;
box-shadow: 0 0 4px var(--background-4);

View file

@ -13,6 +13,8 @@ import "./Thread.scss";
import { createProgress, delay, shouldSplit } from "./util";
import { createStore, reconcile } from "solid-js/store";
import { classList } from "solid-js/web";
import { action } from "@solidjs/router";
import { useFloating } from "solid-floating-ui";
const Editor = lazy(() => import("./Editor"));
@ -361,7 +363,7 @@ export function ThreadViewForum(props: VoidProps<{ thread: Thread }>) {
return (
<div class="thread-view" onKeyDown={binds}>
<ul class="scroll" ref={scrollEl!}>
<li><ThreadInfo thread={props.thread} showHeader={true} /></li>
<li class="header"><ThreadInfo thread={props.thread} showHeader={true} /></li>
<For each={commentTree().get(null)}>
{(item) => <li>{<Comment event={item} tree={commentTree()} />}</li>}
</For>
@ -450,12 +452,26 @@ export function ThreadViewChat(props: VoidProps<{ thread: Thread }>) {
createEffect(on(thread, () => {
console.log("thread updated!");
tl.setIsAutoscrolling(true);
list.scrollBy(99999);
queueMicrotask(() => {
list.scrollBy(99999);
});
}));
function getItem(item: TimelineItem) {
switch (item.type) {
case "message": return <Message thread={thread()} event={item.event} title={item.separate} />;
case "message": {
// return <Message thread={thread()} event={item.event} title={item.separate} />
return (
<div class="toolbar-wrap">
<div class="toolbar">
<button onClick={() => alert("todo")}>+</button>
<button onClick={() => action({ type: "input.reply", thread: thread(), event: item.event })}>&gt;</button>
<button onClick={(e) => action({ type: "contextmenu.set", menu: { type: "message", x: e.clientX, y: e.clientY, thread: thread(), event: item.event } })}>:</button>
</div>
<Message thread={thread()} event={item.event} title={item.separate} />
</div>
);
}
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>;
@ -472,8 +488,9 @@ export function ThreadViewChat(props: VoidProps<{ thread: Thread }>) {
}
function Input(props: VoidProps<{ thread: Thread }>) {
const [globals, action] = useGlobals();
const [files, setFiles] = createSignal<Array<File>>([]);
const [reply, setReply] = createSignal<null | Event>(null);
const state = () => globals.threads[props.thread.id] ?? {};
// TODO: send asynchronously, instead of blocking the input
// the queue should upload all files as fast as possible, but send messages sequentially
@ -554,14 +571,14 @@ function Input(props: VoidProps<{ thread: Thread }>) {
});
}
const relations = [{ rel_type: "m.thread", event_id: props.thread.id }];
if (reply()) relations.push({ rel_type: "m.reply", event_id: reply()!.id });
if (state().reply) relations.push({ rel_type: "m.reply", event_id: state().reply!.id });
await props.thread.room.sendEvent("m.message", {
text: [{ body: text }, { type: "text/html", body: html }],
"m.relations": relations,
"m.attachments": contentFiles.length ? contentFiles : undefined,
});
setFiles([]);
setReply(null);
action({ type: "input.reply", thread: props.thread, event: null });
setTempInputBlocking(false);
}
@ -579,10 +596,6 @@ function Input(props: VoidProps<{ thread: Thread }>) {
function removeFile(target: File) {
setFiles(files().filter(f => f !== target));
}
function tempSetReply() {
setReply(props.thread.room.events.get(prompt("event id")!) ?? null);
}
return (
<div class="input">
@ -591,8 +604,10 @@ function Input(props: VoidProps<{ thread: Thread }>) {
{(f) => <li onClick={() => removeFile(f)}>{f.name}</li>}
</For>
</ul>
<Show when={reply()}>
is replying
<Show when={state().reply}>
<div onClick={() => action({ type: "input.reply", thread: props.thread, event: null })} style="background:var(--background-3);display:flex">
is replying: <TextBlock text={state().reply!.content.text} />
</div>
</Show>
<div class="bar">
<button disabled={tempInputBlocking()}>
@ -601,7 +616,6 @@ function Input(props: VoidProps<{ thread: Thread }>) {
<input type="file" ref={fileEl!} onChange={handleFile} hidden multiple />
</label>
</button>
<button onClick={() => tempSetReply()}>reply</button>
<Suspense>
<Editor placeholder="write something nice..." onSubmit={handleSubmit} onUpload={handleUpload} />
</Suspense>