Compare commits

...
2 Commits
16 changed files with 482 additions and 141 deletions
+15
View File
@@ -19,6 +19,11 @@ class BackfillRequest(BaseModel):
full: bool = False full: bool = False
class StoriesBackfillRequest(BaseModel):
account_id: int
peer_id: int
class FetchMediaRequest(BaseModel): class FetchMediaRequest(BaseModel):
account_id: int account_id: int
chat_id: int chat_id: int
@@ -76,6 +81,16 @@ async def enqueue_backfill(
return EnqueueResponse(job_id=job_id) return EnqueueResponse(job_id=job_id)
@router.post("/stories/backfill", status_code=201)
async def enqueue_stories_backfill(
pool: FromDishka[asyncpg.Pool], body: StoriesBackfillRequest
) -> EnqueueResponse:
job_id = await enqueue(
pool, body.account_id, "backfill_stories", {"peer_id": body.peer_id}
)
return EnqueueResponse(job_id=job_id)
@router.post("/media/fetch", status_code=201) @router.post("/media/fetch", status_code=201)
async def enqueue_fetch_media( async def enqueue_fetch_media(
pool: FromDishka[asyncpg.Pool], body: FetchMediaRequest pool: FromDishka[asyncpg.Pool], body: FetchMediaRequest
+3 -41
View File
@@ -1,52 +1,14 @@
from io import BytesIO
from pyrogram.types import Story from pyrogram.types import Story
from userbot import PyroClient from userbot import PyroClient
from userbot.modules.stories import repository from userbot.modules.stories.service import save_story
def _peer_id(story: Story) -> int:
if story.chat is not None:
return story.chat.id or 0
if story.from_user is not None:
return story.from_user.id or 0
return 0
@PyroClient.on_story() @PyroClient.on_story()
async def on_story(client: PyroClient, story: Story) -> None: async def on_story(client: PyroClient, story: Story) -> None:
ctx = client.capture if client.capture is None:
if ctx is None:
return return
media_kind = story.media.name.lower() if story.media else None await save_story(client, client.capture, story)
storage_key: str | None = None
file_size: int | None = None
downloaded = False
if not story.deleted and story.media is not None:
buffer = await client.download_media(story, in_memory=True)
if isinstance(buffer, BytesIO):
data = buffer.getvalue()
storage_key = ctx.storage.put(data)
file_size = len(data)
downloaded = True
await repository.upsert_story(
ctx.pool,
ctx.account_id,
_peer_id(story),
story.id,
story.date,
story.expire_date,
story.caption,
media_kind,
storage_key,
file_size,
story.views,
str(story.raw),
pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded,
)
handlers = on_story.handlers handlers = on_story.handlers
@@ -1,5 +1,6 @@
from userbot.modules.jobs.handlers import ( from userbot.modules.jobs.handlers import (
backfill, backfill,
backfill_stories,
enrich_chat, enrich_chat,
fetch_avatar, fetch_avatar,
fetch_custom_emoji, fetch_custom_emoji,
@@ -12,6 +13,7 @@ from userbot.modules.jobs.handlers import (
__all__ = [ __all__ = [
"backfill", "backfill",
"backfill_stories",
"enrich_chat", "enrich_chat",
"fetch_avatar", "fetch_avatar",
"fetch_custom_emoji", "fetch_custom_emoji",
@@ -0,0 +1,70 @@
from collections.abc import AsyncIterator, Callable
from pyrogram import Client
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from pyrogram.types import Story
from userbot.modules.capture.context import CaptureContext
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.stories.service import save_story
SAVE_EVERY = 10
StorySource = Callable[[], AsyncIterator[Story]]
def _sources(client: Client, peer_id: int, *, own: bool) -> dict[str, StorySource]:
sources: dict[str, StorySource] = {
"active": lambda: client.get_chat_stories(peer_id),
"pinned": lambda: client.get_pinned_stories(peer_id),
}
if own:
sources["archived"] = lambda: client.get_archived_stories(peer_id)
return sources
async def _drain(
ctx: JobContext, capture: CaptureContext, name: str, source: StorySource
) -> int:
client = ctx.client
if client is None:
return 0
saved = 0
async for story in source():
try:
await save_story(client, capture, story)
except (FloodWait, FloodPremiumWait):
raise
except RPCError:
continue
saved += 1
if saved % SAVE_EVERY == 0:
await ctx.report_progress({"saved": saved, "source": name})
if await ctx.is_canceled():
break
return saved
@register("backfill_stories")
async def backfill_stories(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
capture = getattr(client, "capture", None)
if capture is None:
return
peer_id = ctx.job.params["peer_id"]
own = client.me is not None and client.me.id == peer_id
saved = 0
errors: dict[str, str] = {}
for name, source in _sources(client, peer_id, own=own).items():
try:
saved += await _drain(ctx, capture, name, source)
except (FloodWait, FloodPremiumWait):
raise
except RPCError as exc:
errors[name] = type(exc).__name__
if await ctx.is_canceled():
break
await ctx.report_progress({"saved": saved, "done": True, "errors": errors})
@@ -22,6 +22,20 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
""" """
async def is_downloaded(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> bool:
return bool(
await pool.fetchval(
"SELECT downloaded FROM stories "
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
account_id,
peer_id,
story_id,
)
)
async def upsert_story( # noqa: PLR0913 async def upsert_story( # noqa: PLR0913
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
@@ -0,0 +1,49 @@
from io import BytesIO
from pyrogram import Client
from pyrogram.types import Story
from userbot.modules.capture.context import CaptureContext
from userbot.modules.stories import repository
def story_peer_id(story: Story) -> int:
if story.chat is not None:
return story.chat.id or 0
if story.from_user is not None:
return story.from_user.id or 0
return 0
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
peer_id = story_peer_id(story)
storage_key: str | None = None
file_size: int | None = None
downloaded = False
stored = await repository.is_downloaded(
capture.pool, capture.account_id, peer_id, story.id
)
if not (stored or story.deleted or story.media is None):
buffer = await client.download_media(story, in_memory=True)
if isinstance(buffer, BytesIO):
data = buffer.getvalue()
storage_key = capture.storage.put(data)
file_size = len(data)
downloaded = True
await repository.upsert_story(
capture.pool,
capture.account_id,
peer_id,
story.id,
story.date,
story.expire_date,
story.caption,
story.media.name.lower() if story.media else None,
storage_key,
file_size,
story.views,
str(story.raw),
pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded,
)
+10 -1
View File
@@ -315,7 +315,7 @@ export function getMessageAt(chatId: number, date: string): Promise<MessageAt> {
} }
export function getStories( export function getStories(
peerId: number, peerId: number | null,
page: Page = {} page: Page = {}
): Promise<StoryView[]> { ): Promise<StoryView[]> {
return request<StoryView[]>("/stories", { return request<StoryView[]>("/stories", {
@@ -367,6 +367,15 @@ export function enqueueBackfill(
}); });
} }
export function enqueueStoriesBackfill(
peerId: number
): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/stories/backfill", {
method: "POST",
body: { account_id: accounts.selectedId, peer_id: peerId },
});
}
export function discoverPeers( export function discoverPeers(
query: string, query: string,
remote = false remote = false
@@ -179,7 +179,7 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen} <ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть</ContextMenuItem >Открыть на весь экран</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="recent" icon="recent"
@@ -16,6 +16,7 @@
const KIND_LABELS: Record<string, string> = { const KIND_LABELS: Record<string, string> = {
backfill: "Бэкфилл", backfill: "Бэкфилл",
backfill_stories: "Бэкфилл сторис",
fetch_media: "Докачка медиа", fetch_media: "Докачка медиа",
fetch_avatar: "Аватар", fetch_avatar: "Аватар",
fetch_custom_emoji: "Кастом-эмодзи", fetch_custom_emoji: "Кастом-эмодзи",
@@ -88,12 +89,12 @@
} }
function processed(job: JobView): number | null { function processed(job: JobView): number | null {
const value = job.progress.processed; const value = job.progress.processed ?? job.progress.saved;
return typeof value === "number" ? value : null; return typeof value === "number" ? value : null;
} }
function chatId(job: JobView): number | null { function chatId(job: JobView): number | null {
const value = job.params.chat_id; const value = job.params.chat_id ?? job.params.peer_id;
return typeof value === "number" ? value : null; return typeof value === "number" ? value : null;
} }
@@ -6,19 +6,21 @@
import ProfileInfo from "$lib/components/profile/ProfileInfo.svelte"; import ProfileInfo from "$lib/components/profile/ProfileInfo.svelte";
import SharedLinks from "$lib/components/profile/SharedLinks.svelte"; import SharedLinks from "$lib/components/profile/SharedLinks.svelte";
import SharedMedia from "$lib/components/profile/SharedMedia.svelte"; import SharedMedia from "$lib/components/profile/SharedMedia.svelte";
import StoriesArchive from "$lib/components/stories/StoriesArchive.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte"; import Avatar from "$lib/components/ui/Avatar.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { peerName } from "$lib/format/peer"; import { peerName } from "$lib/format/peer";
import { chats } from "$lib/stores/chats.svelte"; import { chats } from "$lib/stores/chats.svelte";
type Tab = "info" | "media" | "files" | "links" | "calendar"; type Tab = "info" | "media" | "files" | "links" | "stories" | "calendar";
const TABS: { id: Tab; icon: string; label: string }[] = [ const TABS: { id: Tab; icon: string; label: string }[] = [
{ id: "info", icon: "info", label: "Инфо" }, { id: "info", icon: "info", label: "Инфо" },
{ id: "media", icon: "photo", label: "Медиа" }, { id: "media", icon: "photo", label: "Медиа" },
{ id: "files", icon: "document", label: "Файлы" }, { id: "files", icon: "document", label: "Файлы" },
{ id: "links", icon: "link", label: "Ссылки" }, { id: "links", icon: "link", label: "Ссылки" },
{ id: "stories", icon: "play-story", label: "Сторис" },
{ id: "calendar", icon: "calendar", label: "Календарь" }, { id: "calendar", icon: "calendar", label: "Календарь" },
]; ];
@@ -114,6 +116,8 @@
<SharedMedia {chatId} kinds={FILE_KINDS} layout="list" /> <SharedMedia {chatId} kinds={FILE_KINDS} layout="list" />
{:else if tab === "links"} {:else if tab === "links"}
<SharedLinks {chatId} /> <SharedLinks {chatId} />
{:else if tab === "stories"}
<StoriesArchive {chatId} />
{:else if tab === "calendar"} {:else if tab === "calendar"}
<ChatCalendar {chatId} /> <ChatCalendar {chatId} />
{/if} {/if}
@@ -2,8 +2,16 @@
import { untrack } from "svelte"; import { untrack } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { getChatMedia } from "$lib/api/endpoints"; 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 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 EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
@@ -12,6 +20,7 @@
import { poster } from "$lib/media/poster"; import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte"; import { ui } from "$lib/stores/ui.svelte";
import { isMobile } from "$lib/viewport";
interface Props { interface Props {
chatId: number; chatId: number;
@@ -26,9 +35,20 @@
let items = $state<MediaView[]>([]); let items = $state<MediaView[]>([]);
let loading = $state(false); let loading = $state(false);
let done = $state(false); let done = $state(false);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const previews = $state<Record<number, InlineMedia>>({}); const previews = $state<Record<number, InlineMedia>>({});
let token = 0; 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() { async function loadMore() {
if (loading || done) { if (loading || done) {
return; 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); ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`); goto(`/app/${chatId}`);
if (isMobile()) {
ui.closePanel();
}
} }
function preview(item: MediaView): InlineMedia | undefined { function preview(item: MediaView): InlineMedia | undefined {
@@ -128,46 +156,84 @@
{/if} {/if}
{:else if layout === "grid"} {:else if layout === "grid"}
<div class="grid"> <div class="grid">
{#each items as item (item.id)} {#each items as item, index (item.id)}
<button type="button" class="tile" onclick={() => open(item.message_id)}> <ContextMenu>
{#if preview(item)?.state === "ready"} {#snippet children({ props })}
{@const ready = preview(item) as Extract<InlineMedia, { state: "ready" }>} <button
{#if visualKind(item.kind) === "video"} {...props}
<video type="button"
src={ready.url} class="tile"
muted tabindex="0"
playsinline onclick={() => open(index)}
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={ready.url} alt="">
{/if}
{:else}
<span class="ph"
><Icon name={item.kind === "photo" ? "photo" : "video"} /></span
> >
{/if} {#if preview(item)?.state === "ready"}
</button> {@const ready = preview(item) as Extract<
InlineMedia,
{ state: "ready" }
>}
{#if visualKind(item.kind) === "video"}
<video
src={ready.url}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={ready.url} alt="">
{/if}
{:else}
<span class="ph"
><Icon name={item.kind === "photo" ? "photo" : "video"} /></span
>
{/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} {/each}
</div> </div>
{:else} {:else}
<ul class="list"> <ul class="list">
{#each items as item (item.id)} {#each items as item, index (item.id)}
<li> <li>
<button type="button" onclick={() => open(item.message_id)}> <ContextMenu>
<span class="file-icon"><Icon name="document" /></span> {#snippet children({ props })}
<span class="meta"> <button
<span class="name">{mediaKindLabel(item.kind)}</span> {...props}
<span class="sub"> type="button"
{formatListDate(item.created_at)} tabindex="0"
{#if item.file_size} onclick={() => open(index)}
· {formatBytes(item.file_size)} >
{/if} <span class="file-icon"><Icon name="document" /></span>
</span> <span class="meta">
</span> <span class="name">{mediaKindLabel(item.kind)}</span>
</button> <span class="sub">
{formatListDate(item.created_at)}
{#if item.file_size}
· {formatBytes(item.file_size)}
{/if}
</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> </li>
{/each} {/each}
</ul> </ul>
@@ -184,6 +250,13 @@
</button> </button>
{/if} {/if}
<MediaViewer
bind:open={viewerOpen}
bind:index={viewerIndex}
{chatId}
items={viewerItems}
/>
<style lang="scss"> <style lang="scss">
.center { .center {
display: flex; display: flex;
@@ -1,16 +1,23 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { getChat, getPeers, getStories } from "$lib/api/endpoints"; import {
enqueueStoriesBackfill,
getChat,
getPeers,
getStories,
} from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories"; import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types"; import type { StoryView } from "$lib/api/types";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte"; import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte"; import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { peerName } from "$lib/format/peer"; import { peerName } from "$lib/format/peer";
import { poster } from "$lib/media/poster"; import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Group { interface Group {
hasAvatar: boolean; hasAvatar: boolean;
@@ -31,12 +38,13 @@
let viewerIndex = $state(0); let viewerIndex = $state(0);
let viewerItems = $state<StoryView[]>([]); let viewerItems = $state<StoryView[]>([]);
let viewerPeerId = $state(0); let viewerPeerId = $state(0);
let backfilling = $state<number | null>(null);
async function load() { async function load() {
const current = token; const current = token;
loading = true; loading = true;
try { try {
const stories = await getStories(0, { limit: FETCH_LIMIT }); const stories = await getStories(null, { limit: FETCH_LIMIT });
if (current !== token) { if (current !== token) {
return; return;
} }
@@ -127,6 +135,21 @@
}; };
}); });
async function backfill(peerId: number) {
if (backfilling !== null) {
return;
}
backfilling = peerId;
try {
await enqueueStoriesBackfill(peerId);
toasts.success("Загружаем старые сторис");
} catch {
toasts.error("Не удалось запустить загрузку сторис");
} finally {
backfilling = null;
}
}
function openViewer(group: Group, index: number) { function openViewer(group: Group, index: number) {
viewerItems = group.stories; viewerItems = group.stories;
viewerPeerId = group.peerId; viewerPeerId = group.peerId;
@@ -154,6 +177,16 @@
/> />
<span class="group-name">{group.name}</span> <span class="group-name">{group.name}</span>
<span class="group-count">{group.stories.length}</span> <span class="group-count">{group.stories.length}</span>
<Button
variant="translucent"
round
smaller
loading={backfilling === group.peerId}
onclick={() => backfill(group.peerId)}
aria-label="Загрузить старые сторис"
>
<Icon name="cloud-download" />
</Button>
</header> </header>
<div class="grid"> <div class="grid">
{#each group.stories as item, index (item.story_id)} {#each group.stories as item, index (item.story_id)}
@@ -1,22 +1,32 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { page } from "$app/state"; import { page } from "$app/state";
import { getStories } from "$lib/api/endpoints"; import { enqueueStoriesBackfill, getStories } from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories"; import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types"; import type { StoryView } from "$lib/api/types";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte"; import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Button from "$lib/components/ui/Button.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
import { poster } from "$lib/media/poster"; import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
chatId?: number | null;
}
let { chatId = null }: Props = $props();
const PAGE = 60; const PAGE = 60;
const peerId = $derived( const peerId = $derived(
page.params.chatId ? Number(page.params.chatId) : null chatId ?? (page.params.chatId ? Number(page.params.chatId) : null)
); );
let backfilling = $state(false);
let items = $state<StoryView[]>([]); let items = $state<StoryView[]>([]);
let loading = $state(false); let loading = $state(false);
let done = $state(false); let done = $state(false);
@@ -90,67 +100,114 @@
viewerIndex = index; viewerIndex = index;
viewerOpen = true; viewerOpen = true;
} }
async function backfill(id: number) {
if (backfilling) {
return;
}
backfilling = true;
try {
await enqueueStoriesBackfill(id);
toasts.success("Загружаем старые сторис");
} catch {
toasts.error("Не удалось запустить загрузку сторис");
} finally {
backfilling = false;
}
}
async function reload(id: number) {
token++;
items = [];
done = false;
loading = false;
await loadMore(id);
}
</script> </script>
{#if peerId === null} {#if peerId === null}
<EmptyState title="Сторис" description="Откройте чат" /> <EmptyState title="Сторис" description="Откройте чат" />
{:else if items.length === 0}
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Нет сторис" />
{/if}
{:else} {:else}
<div class="grid"> <div class="toolbar">
{#each items as item, index (item.story_id)} <Button
<button variant="secondary"
type="button" pill
class="tile" smaller
class:expired={item.deleted} loading={backfilling}
onclick={() => openViewer(index)} onclick={() => peerId !== null && backfill(peerId)}
> >
{#if previews[item.story_id]} <Icon name="cloud-download" />Загрузить старые
{#if item.media_kind === "video"} </Button>
<video <Button
src={previews[item.story_id]} variant="translucent"
muted round
playsinline smaller
preload="metadata" onclick={() => peerId !== null && reload(peerId).catch(() => undefined)}
use:poster aria-label="Обновить"
></video> >
<span class="play"><Icon name="play" size="1.5rem" /></span> <Icon name="reload" />
{:else} </Button>
<img src={previews[item.story_id]} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if item.pinned}
<span class="badge pin"
><Icon name="story-priority" size="0.875rem" /></span
>
{/if}
{#if item.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
{/each}
</div> </div>
{#if !done} {#if items.length === 0}
<button {#if loading}
type="button" <div class="center"><Spinner /></div>
class="more" {:else}
onclick={() => peerId !== null && loadMore(peerId)} <EmptyState
disabled={loading} title="Нет сторис"
> description="Нажмите «Загрузить старые», чтобы забрать архив"
{loading ? "Загрузка…" : "Показать ещё"} />
</button> {/if}
{/if} {:else}
<div class="grid">
{#each items as item, index (item.story_id)}
<button
type="button"
class="tile"
class:expired={item.deleted}
onclick={() => openViewer(index)}
>
{#if previews[item.story_id]}
{#if item.media_kind === "video"}
<video
src={previews[item.story_id]}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={previews[item.story_id]} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if item.pinned}
<span class="badge pin"
><Icon name="story-priority" size="0.875rem" /></span
>
{/if}
{#if item.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
{/each}
</div>
{#if !done}
<button
type="button"
class="more"
onclick={() => peerId !== null && loadMore(peerId)}
disabled={loading}
>
{loading ? "Загрузка…" : "Показать ещё"}
</button>
{/if}
{#if peerId !== null}
<StoryViewer <StoryViewer
{peerId} {peerId}
{items} {items}
@@ -167,6 +224,14 @@
padding: 2rem 0; padding: 2rem 0;
} }
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -8,12 +8,41 @@
} }
const { children, menu }: Props = $props(); 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> </script>
<ContextMenu.Root> <ContextMenu.Root bind:open>
<ContextMenu.Trigger> <ContextMenu.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
{@render children({ props })} {@render children({ props: triggerProps(props) })}
{/snippet} {/snippet}
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Portal>
+8
View File
@@ -153,6 +153,14 @@ html.theme-transition * {
animation: ripple-animation 700ms; 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 { .bg-menu-content {
z-index: var(--z-portal-menu); z-index: var(--z-portal-menu);
min-width: 12rem; 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;
}