mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
feat: support elementary keyboard shortcuts
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
54
src/hooks/useCommandBus.ts
Normal file
54
src/hooks/useCommandBus.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
}));
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
Reference in New Issue
Block a user