feat(frontend): open sidebar media on click, unbreak long-press menus

This commit is contained in:
hh
2026-08-06 15:17:25 +02:00
parent 12cc7d57e3
commit 004fd56f2c
5 changed files with 156 additions and 39 deletions
@@ -179,7 +179,7 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть</ContextMenuItem
>Открыть на весь экран</ContextMenuItem
>
<ContextMenuItem
icon="recent"
@@ -2,8 +2,16 @@
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { getChatMedia } from "$lib/api/endpoints";
import { type InlineMedia, loadMediaItem, visualKind } from "$lib/api/media";
import {
type InlineMedia,
loadMediaItem,
type ViewerItem,
visualKind,
} from "$lib/api/media";
import type { MediaView } from "$lib/api/types";
import MediaViewer from "$lib/components/MediaViewer.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
@@ -12,6 +20,7 @@
import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
import { isMobile } from "$lib/viewport";
interface Props {
chatId: number;
@@ -26,9 +35,20 @@
let items = $state<MediaView[]>([]);
let loading = $state(false);
let done = $state(false);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const previews = $state<Record<number, InlineMedia>>({});
let token = 0;
const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
}))
);
async function loadMore() {
if (loading || done) {
return;
@@ -110,9 +130,17 @@
};
});
function open(messageId: number) {
function open(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function jump(messageId: number) {
ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`);
if (isMobile()) {
ui.closePanel();
}
}
function preview(item: MediaView): InlineMedia | undefined {
@@ -128,10 +156,21 @@
{/if}
{:else if layout === "grid"}
<div class="grid">
{#each items as item (item.id)}
<button type="button" class="tile" onclick={() => open(item.message_id)}>
{#each items as item, index (item.id)}
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="tile"
tabindex="0"
onclick={() => open(index)}
>
{#if preview(item)?.state === "ready"}
{@const ready = preview(item) as Extract<InlineMedia, { state: "ready" }>}
{@const ready = preview(item) as Extract<
InlineMedia,
{ state: "ready" }
>}
{#if visualKind(item.kind) === "video"}
<video
src={ready.url}
@@ -150,13 +189,30 @@
>
{/if}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть на весь экран</ContextMenuItem
>
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{/snippet}
</ContextMenu>
{/each}
</div>
{:else}
<ul class="list">
{#each items as item (item.id)}
{#each items as item, index (item.id)}
<li>
<button type="button" onclick={() => open(item.message_id)}>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
tabindex="0"
onclick={() => open(index)}
>
<span class="file-icon"><Icon name="document" /></span>
<span class="meta">
<span class="name">{mediaKindLabel(item.kind)}</span>
@@ -168,6 +224,16 @@
</span>
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть</ContextMenuItem
>
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{/snippet}
</ContextMenu>
</li>
{/each}
</ul>
@@ -184,6 +250,13 @@
</button>
{/if}
<MediaViewer
bind:open={viewerOpen}
bind:index={viewerIndex}
{chatId}
items={viewerItems}
/>
<style lang="scss">
.center {
display: flex;
@@ -8,12 +8,41 @@
}
const { children, menu }: Props = $props();
type PointerHandler = (event: PointerEvent) => void;
let open = $state(false);
let suppressClick = false;
function triggerProps(props: Record<string, unknown>) {
const onpointerdown = props.onpointerdown as PointerHandler;
const onpointerup = props.onpointerup as PointerHandler;
return {
...props,
onpointerdown(event: PointerEvent) {
suppressClick = false;
event.stopPropagation();
onpointerdown(event);
},
onpointerup(event: PointerEvent) {
suppressClick = open && event.pointerType !== "mouse";
onpointerup(event);
},
onclickcapture(event: MouseEvent) {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
}
},
};
}
</script>
<ContextMenu.Root>
<ContextMenu.Root bind:open>
<ContextMenu.Trigger>
{#snippet child({ props })}
{@render children({ props })}
{@render children({ props: triggerProps(props) })}
{/snippet}
</ContextMenu.Trigger>
<ContextMenu.Portal>
+8
View File
@@ -153,6 +153,14 @@ html.theme-transition * {
animation: ripple-animation 700ms;
}
@media (pointer: coarse) {
[data-context-menu-trigger] {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
}
.bg-menu-content {
z-index: var(--z-portal-menu);
min-width: 12rem;
+7
View File
@@ -0,0 +1,7 @@
import { browser } from "$app/environment";
const MOBILE_QUERY = "(max-width: 600px)";
export function isMobile(): boolean {
return browser && window.matchMedia(MOBILE_QUERY).matches;
}