Media viewer, message toolbar, fixed header
This commit is contained in:
parent
9d1a3cbf0c
commit
1066f0ccf1
12 changed files with 493 additions and 386 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
57
src/Main.tsx
57
src/Main.tsx
|
@ -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
94
src/Media.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
231
src/Media.tsx
231
src/Media.tsx
|
@ -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">,
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
116
src/Message.scss
116
src/Message.scss
|
@ -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;
|
||||
|
|
221
src/Message.tsx
221
src/Message.tsx
|
@ -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">,
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 })}>></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>
|
||||
|
|
Loading…
Reference in a new issue