mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-19 06:54:23 +01:00
feat: enable to copy event info
This commit is contained in:
@@ -42,7 +42,7 @@ const Column: Component<ColumnProps> = (props) => {
|
|||||||
<TimelineContext.Provider value={timelineState}>
|
<TimelineContext.Provider value={timelineState}>
|
||||||
<div
|
<div
|
||||||
ref={columnDivRef}
|
ref={columnDivRef}
|
||||||
class="relative flex w-[80vw] shrink-0 snap-center snap-always flex-col border-r sm:snap-align-none"
|
class="flex w-[80vw] shrink-0 snap-center snap-always flex-col border-r sm:snap-align-none"
|
||||||
classList={{
|
classList={{
|
||||||
'sm:w-[500px]': width() === 'widest',
|
'sm:w-[500px]': width() === 'widest',
|
||||||
'sm:w-[350px]': width() === 'wide',
|
'sm:w-[350px]': width() === 'wide',
|
||||||
@@ -50,14 +50,21 @@ const Column: Component<ColumnProps> = (props) => {
|
|||||||
'sm:w-[270px]': width() === 'narrow',
|
'sm:w-[270px]': width() === 'narrow',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
|
<Show
|
||||||
{/* <span class="column-icon">🏠</span> */}
|
when={timelineState.timelineState.content}
|
||||||
<span class="column-name">{props.name}</span>
|
keyed
|
||||||
</div>
|
fallback={
|
||||||
<div class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</div>
|
<>
|
||||||
<Show when={timelineState.timelineState.content} keyed>
|
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
|
||||||
|
{/* <span class="column-icon">🏠</span> */}
|
||||||
|
<span class="column-name">{props.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
{(timeline) => (
|
{(timeline) => (
|
||||||
<div class="absolute h-full w-full bg-white">
|
<div class="h-full w-full bg-white">
|
||||||
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
|
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center gap-1"
|
class="flex w-full items-center gap-1"
|
||||||
|
|||||||
100
src/components/ContextMenu.tsx
Normal file
100
src/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { createSignal, onCleanup, createEffect, For, type Component, type JSX } from 'solid-js';
|
||||||
|
|
||||||
|
export type MenuItem = {
|
||||||
|
content: () => JSX.Element;
|
||||||
|
onSelect?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContextMenuProps = {
|
||||||
|
menu: MenuItem[];
|
||||||
|
children: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MenuDisplayProps = {
|
||||||
|
menu: MenuItem[];
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MenuItemDisplayProps = {
|
||||||
|
item: MenuItem;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuItemDisplay: Component<MenuItemDisplayProps> = (props) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
props.item?.onSelect?.();
|
||||||
|
props.onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li class="border-b hover:bg-stone-200">
|
||||||
|
<button class="px-4 py-1" onClick={handleClick}>
|
||||||
|
{props.item.content()}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuDisplay: Component<MenuDisplayProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
<For each={props.menu}>
|
||||||
|
{(item) => <MenuItemDisplay item={item} onClose={props.onClose} />}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContextMenu: Component<ContextMenuProps> = (props) => {
|
||||||
|
let menuRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false);
|
||||||
|
|
||||||
|
const handleClickOutside = (ev: MouseEvent) => {
|
||||||
|
const target = ev.target as HTMLElement;
|
||||||
|
if (target != null && !menuRef?.contains(target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addClickOutsideHandler = () => {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
const removeClickOutsideHandler = () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
|
||||||
|
if (menuRef == null) return;
|
||||||
|
|
||||||
|
const buttonRect = ev.target.getBoundingClientRect();
|
||||||
|
menuRef.style.left = `${buttonRect.left}px`;
|
||||||
|
menuRef.style.top = `${buttonRect.top + buttonRect.height}px`;
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (isOpen()) {
|
||||||
|
addClickOutsideHandler();
|
||||||
|
} else {
|
||||||
|
removeClickOutsideHandler();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => removeClickOutsideHandler());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={handleClick}>{props.children}</button>
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
class="absolute z-20 min-w-[48px] rounded border bg-white shadow-md"
|
||||||
|
classList={{ hidden: !isOpen(), block: isOpen() }}
|
||||||
|
>
|
||||||
|
<MenuDisplay menu={props.menu} onClose={() => setIsOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContextMenu;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Show, For, createSignal, createMemo, onMount, type JSX, type Component } from 'solid-js';
|
import { Show, For, createSignal, createMemo, onMount, type JSX, type Component } from 'solid-js';
|
||||||
import type { Event as NostrEvent } from 'nostr-tools';
|
import { nip19, type Event as NostrEvent } from 'nostr-tools';
|
||||||
import { createMutation } from '@tanstack/solid-query';
|
import { createMutation } from '@tanstack/solid-query';
|
||||||
|
|
||||||
import HeartOutlined from 'heroicons/24/outline/heart.svg';
|
import HeartOutlined from 'heroicons/24/outline/heart.svg';
|
||||||
@@ -31,6 +31,7 @@ import NotePostForm from '@/components/NotePostForm';
|
|||||||
import ensureNonNull from '@/utils/ensureNonNull';
|
import ensureNonNull from '@/utils/ensureNonNull';
|
||||||
import npubEncodeFallback from '@/utils/npubEncodeFallback';
|
import npubEncodeFallback from '@/utils/npubEncodeFallback';
|
||||||
import useSubscription from '@/nostr/useSubscription';
|
import useSubscription from '@/nostr/useSubscription';
|
||||||
|
import ContextMenu, { MenuItem } from '../ContextMenu';
|
||||||
|
|
||||||
export type TextNoteDisplayProps = {
|
export type TextNoteDisplayProps = {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
@@ -38,9 +39,26 @@ export type TextNoteDisplayProps = {
|
|||||||
actions?: boolean;
|
actions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { noteEncode } = nip19;
|
||||||
|
|
||||||
const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
||||||
let contentRef: HTMLDivElement | undefined;
|
let contentRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const menu: MenuItem[] = [
|
||||||
|
{
|
||||||
|
content: () => 'IDをコピー',
|
||||||
|
onSelect: () => {
|
||||||
|
navigator.clipboard.writeText(noteEncode(props.event.id)).catch((err) => window.alert(err));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: () => 'JSONとしてコピー',
|
||||||
|
onSelect: () => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(props.event)).catch((err) => window.alert(err));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
const formatDate = useFormatDate();
|
const formatDate = useFormatDate();
|
||||||
const pubkey = usePubkey();
|
const pubkey = usePubkey();
|
||||||
@@ -51,7 +69,6 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
const closeReplyForm = () => setShowReplyForm(false);
|
const closeReplyForm = () => setShowReplyForm(false);
|
||||||
const [showOverflow, setShowOverflow] = createSignal(false);
|
const [showOverflow, setShowOverflow] = createSignal(false);
|
||||||
const [overflow, setOverflow] = createSignal(false);
|
const [overflow, setOverflow] = createSignal(false);
|
||||||
const [showMenu, setShowMenu] = createSignal(false);
|
|
||||||
|
|
||||||
const event = createMemo(() => eventWrapper(props.event));
|
const event = createMemo(() => eventWrapper(props.event));
|
||||||
|
|
||||||
@@ -88,13 +105,11 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
mutationKey: ['publishDeprecatedRepost', event().id],
|
mutationKey: ['publishDeprecatedRepost', event().id],
|
||||||
mutationFn: commands.publishDeprecatedRepost.bind(commands),
|
mutationFn: commands.publishDeprecatedRepost.bind(commands),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log('succeeded to publish deprecated reposts');
|
console.log('succeeded to publish reposts');
|
||||||
invalidateDeprecatedReposts().catch((err) =>
|
invalidateDeprecatedReposts().catch((err) => console.error('failed to refetch reposts', err));
|
||||||
console.error('failed to refetch deprecated reposts', err),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('failed to publish deprecated repost: ', err);
|
console.error('failed to publish repost: ', err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,10 +220,12 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="created-at shrink-0">
|
<div class="created-at shrink-0">
|
||||||
<button
|
<a
|
||||||
|
href={`nostr:${noteEncode(event().id)}`}
|
||||||
type="button"
|
type="button"
|
||||||
class="hover:underline"
|
class="hover:underline"
|
||||||
onClick={() => {
|
onClick={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
timelineContext?.setTimeline({
|
timelineContext?.setTimeline({
|
||||||
type: 'Replies',
|
type: 'Replies',
|
||||||
event: props.event,
|
event: props.event,
|
||||||
@@ -216,7 +233,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createdAt()}
|
{createdAt()}
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -318,15 +335,11 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<ContextMenu menu={menu}>
|
||||||
class="h-4 w-4 text-zinc-400"
|
<span class="inline-block h-4 w-4 text-zinc-400">
|
||||||
onClick={(ev) => {
|
<EllipsisHorizontal />
|
||||||
ev.stopPropagation();
|
</span>
|
||||||
setShowMenu((current) => !current);
|
</ContextMenu>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EllipsisHorizontal />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const eventWrapper = (event: NostrEvent) => {
|
|||||||
get rawEvent(): NostrEvent {
|
get rawEvent(): NostrEvent {
|
||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
get id(): string | undefined {
|
get id(): string {
|
||||||
return event.id;
|
return event.id;
|
||||||
},
|
},
|
||||||
get pubkey(): string {
|
get pubkey(): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user