feat: support elementary keyboard shortcuts

This commit is contained in:
Shusui MOYATANI
2023-03-08 01:55:30 +09:00
parent 410b18d3a2
commit e581d3fc74
9 changed files with 218 additions and 103 deletions

View File

@@ -18,6 +18,7 @@ export type UseDeprecatedReposts = {
};
const { exec } = useBatchedEvents<UseDeprecatedRepostsProps>(() => ({
interval: 3400,
generateKey: ({ eventId }) => eventId,
mergeFilters: (args) => {
const eventIds = args.map((arg) => arg.eventId);

View File

@@ -52,7 +52,11 @@ const useFollowings = (propsProvider: () => UseFollowingsProps) => {
return result;
};
return { followings };
const followingPubkeys = (): string[] => {
return followings().map((follow) => follow.pubkey);
};
return { followings, followingPubkeys };
};
export default useFollowings;

View File

@@ -1,11 +1,14 @@
import { createSignal, createMemo, type Component, type JSX } from 'solid-js';
import { createSignal, createMemo, onMount, type Component, type JSX } from 'solid-js';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
type NotePostFormProps = {
onPost: (textNote: { content: string }) => void;
onClose: () => void;
};
const NotePostForm: Component<NotePostFormProps> = (props) => {
let textAreaRef: HTMLTextAreaElement | undefined;
const [text, setText] = createSignal<string>('');
const clearText = () => setText('');
@@ -28,15 +31,24 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const handleKeyDown: JSX.EventHandlerUnion<HTMLTextAreaElement, KeyboardEvent> = (ev) => {
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
submit();
} else if (ev.key === 'Escape') {
props.onClose();
}
};
const submitDisabled = createMemo(() => text().trim().length === 0);
onMount(() => {
if (textAreaRef != null) {
textAreaRef.focus();
}
});
return (
<div class="p-1">
<form class="flex flex-col gap-1" onSubmit={handleSubmit}>
<textarea
ref={textAreaRef}
name="text"
class="rounded border-none"
rows={4}

View File

@@ -1,15 +1,44 @@
import { createSignal, Show } from 'solid-js';
import type { Component } from 'solid-js';
import { createSignal, Show, type JSX, Component } from 'solid-js';
import MagnifyingGlass from 'heroicons/24/solid/magnifying-glass.svg';
import PencilSquare from 'heroicons/24/solid/pencil-square.svg';
type SideBarProps = {
postForm: () => JSX.Element;
};
import NotePostForm from '@/components/NotePostForm';
import useConfig from '@/clients/useConfig';
import useCommands from '@/clients/useCommands';
import usePubkey from '@/clients/usePubkey';
import { useHandleCommand } from '@/hooks/useCommandBus';
const SideBar: Component = (props) => {
const [config] = useConfig();
const pubkey = usePubkey();
const commands = useCommands();
const SideBar: Component<SideBarProps> = (props) => {
const [formOpened, setFormOpened] = createSignal(false);
const handlePost = ({ content }: { content: string }) => {
commands
.publishTextNote({
relayUrls: config().relayUrls,
pubkey: pubkey(),
content,
})
.then(() => {
console.log('ok');
})
.catch((err) => {
console.error('error', err);
});
};
useHandleCommand(() => ({
commandType: 'openPostForm',
handler: (cmd) => {
setFormOpened(true);
},
}));
return (
<div class="flex shrink-0 flex-row border-r bg-sidebar-bg">
<div class="flex w-14 flex-auto flex-col items-center gap-3 border-r border-rose-200 py-5">
@@ -25,7 +54,9 @@ const SideBar: Component<SideBarProps> = (props) => {
{/* <div>column 1</div> */}
{/* <div>column 2</div> */}
</div>
<Show when={formOpened()}>{() => props.postForm()}</Show>
<Show when={formOpened()}>
<NotePostForm onPost={handlePost} onClose={() => setFormOpened(false)} />
</Show>
</div>
);
};

View File

@@ -25,7 +25,7 @@ const ImageDisplay: Component<ImageDisplayProps> = (props) => {
return (
<a href={props.url} target="_blank" rel="noopener noreferrer">
<img
class="max-h-full max-w-full rounded object-contain shadow"
class="inline-block max-h-64 max-w-full rounded object-contain shadow"
src={fixUrl(url())}
alt={props.url}
/>

View File

@@ -0,0 +1,54 @@
import { onMount } from 'solid-js';
import { useRequestMessage, useHandleMessage } from '@/hooks/useMessageBus';
type UseHandleCommandProps = {
commandType: string;
handler: (command: Command) => void;
};
type CommandBase<T> = { command: T };
export type OpenPostForm = CommandBase<'openPostForm'>;
export type ClosePostForm = CommandBase<'closePostForm'>;
export type MoveToNextItem = CommandBase<'moveToNextItem'>;
export type MoveToPrevItem = CommandBase<'moveToPrevItem'>;
export type MoveToPrevColumn = CommandBase<'moveToPrevColumn'>;
export type MoveToNextColumn = CommandBase<'moveToNextColumn'>;
export type MoveToColumn = CommandBase<'moveToNextColumn'> & { columnIndex: number };
export type Like = CommandBase<'like'>;
export type Repost = CommandBase<'repost'>;
export type OpenReplyForm = CommandBase<'openReplyForm'>;
export type OpenHelp = CommandBase<'openHelp'>;
export type OpenItemDetail = CommandBase<'openItemDetail'>;
export type CloseItemDetail = CommandBase<'closeItemDetail'>;
export type Command =
| OpenPostForm
| ClosePostForm
| MoveToNextItem
| MoveToPrevItem
| MoveToPrevColumn
| MoveToNextColumn
| Like
| Repost
| OpenReplyForm
| OpenHelp
| OpenItemDetail
| CloseItemDetail;
export type CommandType = Command['command'];
export const useRequestCommand = () =>
useRequestMessage<Command, void>(() => ({ id: 'CommandChannel' }));
export const useHandleCommand = (propsProvider: () => UseHandleCommandProps) => {
useHandleMessage<Command, void>(() => ({
id: 'CommandChannel',
handler: (command) => {
const { commandType, handler } = propsProvider();
if (command.command === commandType) {
handler(command);
}
},
}));
};

View File

@@ -1,11 +1,12 @@
import { createSignal, onMount, type Signal } from 'solid-js';
import { createSignal, createMemo, onMount, type Signal, onCleanup } from 'solid-js';
const [channels, setChannels]: Signal<Record<string, MessageChannel>> = createSignal({});
export type UseRequestMessageProps = {
id: string;
};
export const CommandChannel = 'CommandChannel' as const;
export type UseMessageChannelProps = {
id: typeof CommandChannel;
export type UseHandleMessageProps<Req, Res> = {
id: string;
handler: (req: Req) => Res | Promise<Res>;
};
export type MessageChannelRequest<T> = {
@@ -15,39 +16,28 @@ export type MessageChannelRequest<T> = {
export type MessageChannelResponse<T> = {
requestId: string;
response: T;
response?: T;
error?: any;
};
const [channels, setChannels]: Signal<Record<string, MessageChannel>> = createSignal({});
const registerChannelIfNotExist = (id: string) => {
if (channels()[id] == null) {
setChannels((currentChannels) => ({
...currentChannels,
[id]: new MessageChannel(),
}));
}
};
// https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
type Clonable =
| number
| string
| boolean
| null
| bigint
| Date
| Array<Clonable>
| Record<string, Clonable>;
const useMessageBus = <Req extends Clonable, Res extends Clonable>(
propsProvider: () => UseMessageChannelProps,
) => {
onMount(() => {
const { id } = propsProvider();
if (channel() == null) {
setChannels((currentChannels) => ({
...currentChannels,
[id]: new MessageChannel(),
}));
}
});
export const useRequestMessage = <Req, Res>(propsProvider: () => UseRequestMessageProps) => {
const channel = () => channels()[propsProvider().id];
const sendRequest = (requestId: string, message: Req) => {
const request: MessageChannelRequest<Req> = { requestId, request: message };
const messageStr = JSON.stringify(request);
channel().port1.postMessage(messageStr);
channel().port1.postMessage(request);
};
const waitResponse = (requestId: string, timeoutMs = 1000): Promise<Res> =>
@@ -58,7 +48,11 @@ const useMessageBus = <Req extends Clonable, Res extends Clonable>(
if (data.requestId !== requestId) return;
channel().port1.removeEventListener('message', listener);
resolve(data.response);
if (data.response != null) {
resolve(data.response);
} else {
reject(data.error);
}
};
setTimeout(() => {
@@ -70,22 +64,48 @@ const useMessageBus = <Req extends Clonable, Res extends Clonable>(
channel().port1.start();
});
const sendResponse = (res: Res) => {};
onMount(() => {
const { id } = propsProvider();
registerChannelIfNotExist(id);
});
return {
async requst(message: Req): Promise<Res> {
const requestId = Math.random().toString();
const response = waitResponse(requestId);
sendRequest(requestId, message);
return response;
},
handle(handler: (message: Req) => Res | Promise<Res>) {
channel().port2.addEventListener('message', (ev) => {
const request = event.data as MessageChannelRequest<Req>;
const res = handler(request.request).then((res) => {});
});
},
return async (message: Req): Promise<Res> => {
const requestId = Math.random().toString();
const response = waitResponse(requestId);
sendRequest(requestId, message);
return response;
};
};
export default useMessageBus;
export const useHandleMessage = <Req, Res>(
propsProvider: () => UseHandleMessageProps<Req, Res>,
) => {
const props = createMemo(propsProvider);
const channel = () => channels()[props().id];
onMount(() => {
const port = channel().port2;
const messageHandler = (event: MessageEvent) => {
const { requestId, request } = event.data as MessageChannelRequest<Req>;
const result = props().handler(request);
const resultPromise = result instanceof Promise ? result : Promise.resolve(result);
resultPromise
.then((res) => {
const response: MessageChannelResponse<Res> = { requestId, response: res };
port.postMessage(response);
})
.catch((err) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response: MessageChannelResponse<Res> = { requestId, error: err };
port.postMessage(response);
});
};
port.addEventListener('message', messageHandler);
port.start();
onCleanup(() => {
port.removeEventListener('message', messageHandler);
});
});
};

View File

@@ -3,24 +3,26 @@
import { onMount, onCleanup, type JSX } from 'solid-js';
type Shortcut = { key: string; command: string };
import { useRequestCommand, type Command } from '@/hooks/useCommandBus';
type Shortcut = { key: string; command: Command };
const defaultShortcut: Shortcut[] = [
{ key: 'n', command: 'openPostForm' },
{ key: 'h', command: 'moveToPrevColumn' },
{ key: 'j', command: 'moveToNextItem' },
{ key: 'k', command: 'moveToPrevItem' },
{ key: 'l', command: 'moveToNextColumn' },
{ key: 'ArrowLeft', command: 'moveToPrevColumn' },
{ key: 'ArrowDown', command: 'moveToNextItem' },
{ key: 'ArrowUp', command: 'moveToPrevItem' },
{ key: 'ArrowRight', command: 'moveToNextColumn' },
{ key: 'f', command: 'like' },
{ key: 't', command: 'repost' },
{ key: 'r', command: 'openReplyForm' },
{ key: '?', command: 'openHelp' },
{ key: 'Enter', command: 'openItemDetail' },
{ key: 'Backspace', command: 'closeItemDetail' },
{ key: 'n', command: { command: 'openPostForm' } },
{ key: 'h', command: { command: 'moveToPrevColumn' } },
{ key: 'j', command: { command: 'moveToNextItem' } },
{ key: 'k', command: { command: 'moveToPrevItem' } },
{ key: 'l', command: { command: 'moveToNextColumn' } },
{ key: 'ArrowLeft', command: { command: 'moveToPrevColumn' } },
{ key: 'ArrowDown', command: { command: 'moveToNextItem' } },
{ key: 'ArrowUp', command: { command: 'moveToPrevItem' } },
{ key: 'ArrowRight', command: { command: 'moveToNextColumn' } },
{ key: 'f', command: { command: 'like' } },
{ key: 't', command: { command: 'repost' } },
{ key: 'r', command: { command: 'openReplyForm' } },
{ key: '?', command: { command: 'openHelp' } },
{ key: 'Enter', command: { command: 'openItemDetail' } },
{ key: 'Backspace', command: { command: 'closeItemDetail' } },
];
type UseShortcutKeysProps = {
@@ -40,7 +42,7 @@ const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcu
const shortcutsMap = createShortcutsMap(shortcuts);
onMount(() => {
const handleKeydown: JSX.EventHandler<Window, KeyboardEvent> = (ev) => {
const handleKeydown = (ev: KeyboardEvent) => {
if (ev.type !== 'keydown') return;
if (ev.target instanceof HTMLTextAreaElement || ev.target instanceof HTMLInputElement) return;
@@ -59,4 +61,14 @@ const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcu
});
};
export const useMountShortcutKeys = () => {
const request = useRequestCommand();
useShortcutKeys({
onShortcut: (shortcut) => {
request(shortcut.command);
},
});
};
export default useShortcutKeys;

View File

@@ -2,23 +2,21 @@ import { Show, For, createSignal, createEffect, onMount, type Component } from '
import { useNavigate } from '@solidjs/router';
import Column from '@/components/Column';
import NotePostForm from '@/components/NotePostForm';
import SideBar from '@/components/SideBar';
import Timeline from '@/components/Timeline';
import Notification from '@/components/Notification';
import usePool from '@/clients/usePool';
import useCommands from '@/clients/useCommands';
import useConfig from '@/clients/useConfig';
import useSubscription from '@/clients/useSubscription';
import useFollowings from '@/clients/useFollowings';
import usePubkey from '@/clients/usePubkey';
import useShortcutKeys from '@/hooks/useShortcutKeys';
import useLoginStatus from '@/hooks/useLoginStatus';
import ensureNonNull from '@/hooks/ensureNonNull';
useShortcutKeys({
onShortcut: (s) => console.log(s),
});
import { useMountShortcutKeys } from '@/hooks/useShortcutKeys';
import useLoginStatus from '@/hooks/useLoginStatus';
// import ensureNonNull from '@/hooks/ensureNonNull';
useMountShortcutKeys();
const Home: Component = () => {
const navigate = useNavigate();
@@ -27,7 +25,6 @@ const Home: Component = () => {
const pool = usePool();
const [config] = useConfig();
const pubkey = usePubkey();
const commands = useCommands();
createEffect(() => {
config().relayUrls.map(async (relayUrl) => {
@@ -38,7 +35,7 @@ const Home: Component = () => {
});
});
const { followings } = useFollowings(() => ({
const { followingPubkeys } = useFollowings(() => ({
relayUrls: config().relayUrls,
pubkey: pubkey(),
}));
@@ -48,7 +45,7 @@ const Home: Component = () => {
filters: [
{
kinds: [1, 6],
authors: [...followings()?.map((f) => f.pubkey), pubkey()] ?? [pubkey()],
authors: [...followingPubkeys(), pubkey()],
limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
},
@@ -107,31 +104,15 @@ const Home: Component = () => {
}));
*/
const handlePost = ({ content }: { content: string }) => {
commands
.publishTextNote({
relayUrls: config().relayUrls,
pubkey: pubkey(),
content,
})
.then(() => {
console.log('ok');
})
.catch((err) => {
console.error('error', err);
});
};
onMount(() => {
if (!loginStatus().loggedIn) {
navigate('/hello');
}
});
const japaneseRegex = /[あ-ん]/;
return (
<div class="flex h-screen w-screen flex-row overflow-hidden">
<SideBar postForm={() => <NotePostForm onPost={handlePost} />} />
<SideBar />
<div class="flex flex-row overflow-y-hidden overflow-x-scroll">
<Column name="ホーム" width="widest">
<Timeline events={followingsPosts()} />