feat: add annotations, user profiles, watchers, stories, search and more

This commit is contained in:
h
2026-06-01 17:15:09 +02:00
parent ed469ba8dd
commit 2465bcd184
47 changed files with 5009 additions and 242 deletions
+4
View File
@@ -26,8 +26,10 @@ from api.routers import (
peers,
policy,
presence,
profile,
search,
social,
stories,
watches,
)
from dependencies.container import container
@@ -77,6 +79,8 @@ app.include_router(avatars.router)
app.include_router(custom_emoji.router)
app.include_router(social.router)
app.include_router(presence.router)
app.include_router(stories.router)
app.include_router(profile.router)
app.include_router(events.router)
app.include_router(peers.router)
app.include_router(annotations.router)
+18 -2
View File
@@ -6,12 +6,23 @@ from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.jobs import enqueue
from utils.read.avatars import current_avatar
from utils.read.avatars import avatar_by_unique_id, avatar_history, current_avatar
from utils.read.models import AvatarHistoryView
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/avatars", tags=["avatars"], route_class=DishkaRoute)
@router.get("/{owner_kind}/{owner_id}/history")
async def serve_avatar_history(
pool: FromDishka[asyncpg.Pool],
owner_kind: str, # noqa: ARG001
owner_id: int,
account_id: Annotated[int, Query()],
) -> list[AvatarHistoryView]:
return await avatar_history(pool, account_id, owner_id)
@router.get("/{owner_kind}/{owner_id}")
async def serve_avatar(
pool: FromDishka[asyncpg.Pool],
@@ -19,8 +30,13 @@ async def serve_avatar(
owner_kind: str,
owner_id: int,
account_id: Annotated[int, Query()],
unique_id: Annotated[str | None, Query()] = None,
) -> FileResponse:
avatar = await current_avatar(pool, account_id, owner_kind, owner_id)
avatar = (
await avatar_by_unique_id(pool, account_id, owner_id, unique_id)
if unique_id is not None
else await current_avatar(pool, account_id, owner_kind, owner_id)
)
if avatar is None:
raise HTTPException(status_code=404, detail="avatar not found")
if not avatar.downloaded or avatar.storage_key is None:
+4
View File
@@ -47,6 +47,8 @@ async def chat_history(
limit: Limit = DEFAULT_LIMIT,
offset: Offset = 0,
include_deleted: Annotated[bool, Query()] = True,
before_id: Annotated[int | None, Query()] = None,
after_id: Annotated[int | None, Query()] = None,
) -> list[MessageView]:
return await chats.get_chat_history(
pool,
@@ -54,6 +56,8 @@ async def chat_history(
chat_id,
Page(limit=limit, offset=offset),
include_deleted=include_deleted,
before_id=before_id,
after_id=after_id,
)
+1 -14
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, PeerHistoryView, PeerView, StoryView
from utils.read.models import PeerHistoryView, PeerView
router = APIRouter(prefix="/api", tags=["peers"], route_class=DishkaRoute)
@@ -35,16 +35,3 @@ async def peer_history(
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
) -> list[PeerHistoryView]:
return await peers.get_peer_history(pool, account_id, peer_id)
@router.get("/stories")
async def stories(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StoryView]:
return await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
)
+70
View File
@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from utils.read import profile
from utils.read.models import (
DEFAULT_LIMIT,
ChatLinkView,
DayCount,
MediaView,
MessageAt,
Page,
)
router = APIRouter(
prefix="/api/chats/{chat_id}", tags=["profile"], route_class=DishkaRoute
)
AccountId = Annotated[int, Query()]
@router.get("/media")
async def chat_media(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
account_id: AccountId,
kinds: Annotated[str, Query()],
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[MediaView]:
parsed = [part for part in kinds.split(",") if part.strip()]
return await profile.chat_media(
pool, account_id, chat_id, parsed, Page(limit=limit, offset=offset)
)
@router.get("/links")
async def chat_links(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
account_id: AccountId,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[ChatLinkView]:
return await profile.chat_links(
pool, account_id, chat_id, Page(limit=limit, offset=offset)
)
@router.get("/calendar")
async def chat_calendar(
pool: FromDishka[asyncpg.Pool], chat_id: int, account_id: AccountId
) -> list[DayCount]:
return await profile.daily_counts(pool, account_id, chat_id)
@router.get("/message-at")
async def message_at(
pool: FromDishka[asyncpg.Pool],
chat_id: int,
account_id: AccountId,
date: Annotated[datetime, Query()],
) -> MessageAt:
found = await profile.first_message_on_day(pool, account_id, chat_id, date)
if found is None:
raise HTTPException(status_code=404, detail="no message on day")
return found
+48
View File
@@ -0,0 +1,48 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api", tags=["stories"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
@router.get("/stories")
async def list_stories(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StoryView]:
return await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
)
@router.get("/stories/{peer_id}/{story_id}/media")
async def serve_story_media(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
peer_id: int,
story_id: int,
account_id: AccountId,
) -> FileResponse:
story = await peers.get_story(pool, account_id, peer_id, story_id)
if story is None:
raise HTTPException(status_code=404, detail="story not found")
if not story.downloaded or story.storage_key is None:
raise HTTPException(status_code=409, detail="story media not downloaded")
return FileResponse(
storage.url(story.storage_key),
media_type=_STORY_MIME.get(story.media_kind or "", "application/octet-stream"),
)
+21 -1
View File
@@ -1,6 +1,6 @@
import asyncpg
from utils.read.models import AvatarRef
from utils.read.models import AvatarHistoryView, AvatarRef
_PEER_UNIQUE_ID = """
SELECT photo_unique_id FROM peers
@@ -18,6 +18,12 @@ SELECT unique_id, storage_key, downloaded, mime FROM avatars
WHERE account_id = $1 AND owner_id = $2 AND unique_id = $3
"""
_AVATAR_HISTORY = """
SELECT unique_id, first_seen_at, downloaded FROM avatars
WHERE account_id = $1 AND owner_id = $2
ORDER BY first_seen_at DESC
"""
async def current_avatar(
pool: asyncpg.Pool, account_id: int, owner_kind: str, owner_id: int
@@ -28,3 +34,17 @@ async def current_avatar(
return None
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
return AvatarRef(**dict(row)) if row else None
async def avatar_by_unique_id(
pool: asyncpg.Pool, account_id: int, owner_id: int, unique_id: str
) -> AvatarRef | None:
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
return AvatarRef(**dict(row)) if row else None
async def avatar_history(
pool: asyncpg.Pool, account_id: int, owner_id: int
) -> list[AvatarHistoryView]:
rows = await pool.fetch(_AVATAR_HISTORY, account_id, owner_id)
return [AvatarHistoryView(**dict(row)) for row in rows]
+21 -7
View File
@@ -118,25 +118,39 @@ async def list_chats(
return items
async def get_chat_history(
async def get_chat_history( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
page: Page,
*,
include_deleted: bool = True,
before_id: int | None = None,
after_id: int | None = None,
) -> list[MessageView]:
where = "account_id = $1 AND chat_id = $2"
if not include_deleted:
where += " AND deleted_at IS NULL"
rows = await pool.fetch(
params: list[object] = [account_id, chat_id]
if after_id is not None:
params.append(after_id)
where += f" AND message_id > ${len(params)}"
order = "date ASC, message_id ASC"
elif before_id is not None:
params.append(before_id)
where += f" AND message_id < ${len(params)}"
order = "date DESC, message_id DESC"
else:
order = "date DESC, message_id DESC"
params.append(page.capped_limit)
query = (
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
"ORDER BY date DESC, message_id DESC LIMIT $3 OFFSET $4",
account_id,
chat_id,
page.capped_limit,
page.offset,
f"ORDER BY {order} LIMIT ${len(params)}"
)
if before_id is None and after_id is None:
params.append(page.offset)
query += f" OFFSET ${len(params)}"
rows = await pool.fetch(query, *params)
media_by_key = await _media_map(pool, account_id, rows)
parsed = [(row, load_raw(row["raw"])) for row in rows]
views: list[MessageView] = []
+27
View File
@@ -225,6 +225,12 @@ class AvatarRef(BaseModel):
mime: str | None
class AvatarHistoryView(BaseModel):
unique_id: str
first_seen_at: datetime
downloaded: bool
class CustomEmojiRef(BaseModel):
storage_key: str | None
downloaded: bool
@@ -307,6 +313,27 @@ class PeerHistoryView(BaseModel):
is_deleted_account: bool
class ChatLinkView(BaseModel):
message_id: int
date: datetime | None
url: str
kind: str
web_url: str | None
web_title: str | None
web_site_name: str | None
class DayCount(BaseModel):
day: datetime
count: int
outgoing: int
class MessageAt(BaseModel):
message_id: int
date: datetime
class StoryView(BaseModel):
peer_id: int
story_id: int
+14
View File
@@ -68,3 +68,17 @@ async def get_stories(
*params,
)
return [StoryView(**dict(row)) for row in rows]
async def get_story(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> StoryView | None:
row = await pool.fetchrow(
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, "
"storage_key, downloaded, views, pinned, deleted FROM stories "
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
account_id,
peer_id,
story_id,
)
return StoryView(**dict(row)) if row else None
+76
View File
@@ -0,0 +1,76 @@
from datetime import datetime, timedelta
import asyncpg
from utils.read.accounts import self_user_id
from utils.read.models import ChatLinkView, DayCount, MediaView, MessageAt, Page
_MEDIA_COLS = (
"id, account_id, chat_id, message_id, kind, storage_key, file_size, "
"mime, ttl_seconds, downloaded, extracted_text, created_at"
)
async def chat_media(
pool: asyncpg.Pool, account_id: int, chat_id: int, kinds: list[str], page: Page
) -> list[MediaView]:
rows = await pool.fetch(
f"SELECT {_MEDIA_COLS} FROM media " # noqa: S608
"WHERE account_id = $1 AND chat_id = $2 AND kind = ANY($3) "
"ORDER BY message_id DESC LIMIT $4 OFFSET $5",
account_id,
chat_id,
kinds,
page.capped_limit,
page.offset,
)
return [MediaView(**dict(row)) for row in rows]
async def chat_links(
pool: asyncpg.Pool, account_id: int, chat_id: int, page: Page
) -> list[ChatLinkView]:
rows = await pool.fetch(
"SELECT l.message_id, m.date, l.url, l.kind, l.web_url, "
"l.web_title, l.web_site_name FROM links l "
"LEFT JOIN messages m ON m.account_id = l.account_id "
"AND m.chat_id = l.chat_id AND m.message_id = l.message_id "
"WHERE l.account_id = $1 AND l.chat_id = $2 "
"ORDER BY l.message_id DESC, l.position LIMIT $3 OFFSET $4",
account_id,
chat_id,
page.capped_limit,
page.offset,
)
return [ChatLinkView(**dict(row)) for row in rows]
async def daily_counts(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> list[DayCount]:
self_id = await self_user_id(pool, account_id)
rows = await pool.fetch(
"SELECT date_trunc('day', date) AS day, count(*) AS count, "
"count(*) FILTER (WHERE sender_id = $3) AS outgoing FROM messages "
"WHERE account_id = $1 AND chat_id = $2 "
"GROUP BY day ORDER BY day",
account_id,
chat_id,
self_id,
)
return [DayCount(**dict(row)) for row in rows]
async def first_message_on_day(
pool: asyncpg.Pool, account_id: int, chat_id: int, day: datetime
) -> MessageAt | None:
row = await pool.fetchrow(
"SELECT message_id, date FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND date >= $3 AND date < $4 "
"ORDER BY date, message_id LIMIT 1",
account_id,
chat_id,
day,
day + timedelta(days=1),
)
return MessageAt(**dict(row)) if row else None
+53
View File
@@ -72,3 +72,56 @@ export function loadAvatar(
inflight.set(key, promise);
return promise;
}
async function fetchVariant(
account: number,
kind: AvatarKind,
id: number,
uniqueId: string,
key: string,
retry: boolean
): Promise<string | null> {
const url = `${BASE}/avatars/${kind}/${id}?account_id=${account}&unique_id=${encodeURIComponent(uniqueId)}`;
const response = await fetch(url, { headers: authHeaders() });
if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl);
return objectUrl;
}
if (response.status === 409 && retry) {
await delay(RETRY_DELAY);
return fetchVariant(account, kind, id, uniqueId, key, false);
}
missing.add(key);
return null;
}
export function loadAvatarVariant(
kind: AvatarKind,
id: number,
uniqueId: string
): Promise<string | null> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve(null);
}
const key = `${cacheKey(account, kind, id)}:${uniqueId}`;
const cached = ready.get(key);
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
if (existing) {
return existing;
}
const promise = fetchVariant(account, kind, id, uniqueId, key, true).finally(
() => {
inflight.delete(key);
}
);
inflight.set(key, promise);
return promise;
}
+180 -1
View File
@@ -1,15 +1,24 @@
import { request } from "$lib/api/client";
import type {
Account,
Alert,
Annotation,
AvatarHistoryView,
CallbackView,
CaptureToggles,
Chat,
ChatLinkView,
DayCount,
Folder,
JobStatus,
JobView,
LinkView,
MediaVersion,
MediaView,
MessageAt,
MessageVersion,
MessageView,
PeerHistoryView,
PeerView,
PinnedView,
PolicyChatKind,
@@ -17,9 +26,12 @@ import type {
PolicyRecord,
PresenceHourly,
PresenceSample,
ReactionView,
ResponseStats,
SearchHit,
StoryView,
VolumeBucket,
Watch,
} from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
@@ -79,7 +91,11 @@ export function effectivePolicy(query: {
export function listMessages(
chatId: number,
options: Page & { include_deleted?: boolean } = {}
options: Page & {
include_deleted?: boolean;
before_id?: number;
after_id?: number;
} = {}
): Promise<MessageView[]> {
return request<MessageView[]>(`/chats/${chatId}/messages`, {
account: true,
@@ -159,6 +175,90 @@ export function getPeer(peerId: number): Promise<PeerView> {
return request<PeerView>(`/peers/${peerId}`, { account: true });
}
export function getPeerHistory(peerId: number): Promise<PeerHistoryView[]> {
return request<PeerHistoryView[]>(`/peers/${peerId}/history`, {
account: true,
});
}
export function getAvatarHistory(
kind: "peer" | "chat",
id: number
): Promise<AvatarHistoryView[]> {
return request<AvatarHistoryView[]>(`/avatars/${kind}/${id}/history`, {
account: true,
});
}
export function getChatMedia(
chatId: number,
kinds: string[],
page: { limit?: number; offset?: number } = {}
): Promise<MediaView[]> {
return request<MediaView[]>(`/chats/${chatId}/media`, {
account: true,
query: { kinds: kinds.join(","), ...page },
});
}
export function getChatLinks(
chatId: number,
page: { limit?: number; offset?: number } = {}
): Promise<ChatLinkView[]> {
return request<ChatLinkView[]>(`/chats/${chatId}/links`, {
account: true,
query: { ...page },
});
}
export function getMessageReactions(
chatId: number,
messageId: number
): Promise<ReactionView[]> {
return request<ReactionView[]>(`/messages/${chatId}/${messageId}/reactions`, {
account: true,
});
}
export function getMessageCallbacks(
chatId: number,
messageId: number
): Promise<CallbackView[]> {
return request<CallbackView[]>(`/messages/${chatId}/${messageId}/callbacks`, {
account: true,
});
}
export function getMessageLinks(
chatId: number,
messageId: number
): Promise<LinkView[]> {
return request<LinkView[]>(`/messages/${chatId}/${messageId}/links`, {
account: true,
});
}
export function getChatCalendar(chatId: number): Promise<DayCount[]> {
return request<DayCount[]>(`/chats/${chatId}/calendar`, { account: true });
}
export function getMessageAt(chatId: number, date: string): Promise<MessageAt> {
return request<MessageAt>(`/chats/${chatId}/message-at`, {
account: true,
query: { date },
});
}
export function getStories(
peerId: number,
page: Page = {}
): Promise<StoryView[]> {
return request<StoryView[]>("/stories", {
account: true,
query: { peer_id: peerId, ...page },
});
}
export function getPeers(ids: number[]): Promise<PeerView[]> {
if (ids.length === 0) {
return Promise.resolve([]);
@@ -249,3 +349,82 @@ export function fetchMedia(
},
});
}
export function listWatches(): Promise<Watch[]> {
return request<Watch[]>("/watches", { account: true });
}
export function createWatch(
kind: string,
params: Record<string, unknown>,
enabled: boolean
): Promise<Watch> {
return request<Watch>("/watches", {
method: "POST",
body: { account_id: accounts.selectedId, kind, params, enabled },
});
}
export function updateWatch(
id: number,
params: Record<string, unknown>,
enabled: boolean
): Promise<Watch> {
return request<Watch>(`/watches/${id}`, {
method: "PUT",
body: { params, enabled },
});
}
export function deleteWatch(id: number): Promise<void> {
return request<void>(`/watches/${id}`, { method: "DELETE" });
}
export function listAlerts(
options: Page & { seen?: boolean } = {}
): Promise<Alert[]> {
return request<Alert[]>("/alerts", { account: true, query: { ...options } });
}
export function markAlertSeen(id: number): Promise<void> {
return request<void>(`/alerts/${id}/seen`, { method: "POST" });
}
export function listAnnotations(
options: { chatId?: number; messageId?: number } = {}
): Promise<Annotation[]> {
return request<Annotation[]>("/annotations", {
account: true,
query: { chat_id: options.chatId, message_id: options.messageId },
});
}
export function createAnnotation(
chatId: number,
messageId: number,
text: string
): Promise<Annotation> {
return request<Annotation>("/annotations", {
method: "POST",
body: {
account_id: accounts.selectedId,
chat_id: chatId,
message_id: messageId,
text,
},
});
}
export function updateAnnotation(
id: number,
text: string
): Promise<Annotation> {
return request<Annotation>(`/annotations/${id}`, {
method: "PUT",
body: { text },
});
}
export function deleteAnnotation(id: number): Promise<void> {
return request<void>(`/annotations/${id}`, { method: "DELETE" });
}
+56
View File
@@ -0,0 +1,56 @@
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const ready = new Map<string, string>();
const missing = new Set<string>();
const inflight = new Map<string, Promise<string | null>>();
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
async function fetchStoryMedia(
account: number,
peerId: number,
storyId: number,
key: string
): Promise<string | null> {
const url = `${BASE}/stories/${peerId}/${storyId}/media?account_id=${account}`;
const response = await fetch(url, { headers: authHeaders() });
if (response.ok) {
const objectUrl = URL.createObjectURL(await response.blob());
ready.set(key, objectUrl);
return objectUrl;
}
missing.add(key);
return null;
}
export function loadStoryMedia(
peerId: number,
storyId: number
): Promise<string | null> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve(null);
}
const key = `${account}:${peerId}:${storyId}`;
const cached = ready.get(key);
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
if (existing) {
return existing;
}
const promise = fetchStoryMedia(account, peerId, storyId, key).finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
+50
View File
@@ -232,6 +232,56 @@ export interface Callback {
position: number;
}
export interface AvatarHistoryView {
downloaded: boolean;
first_seen_at: string;
unique_id: string;
}
export interface ChatLinkView {
date: string | null;
kind: string;
message_id: number;
url: string;
web_site_name: string | null;
web_title: string | null;
web_url: string | null;
}
export interface DayCount {
count: number;
day: string;
outgoing: number;
}
export interface ReactionView {
added_at: string;
peer_id: number;
reaction: string;
removed_at: string | null;
}
export interface CallbackView {
data: string | null;
label: string | null;
position: number;
}
export interface LinkView {
kind: string;
position: number;
url: string;
web_description: string | null;
web_site_name: string | null;
web_title: string | null;
web_url: string | null;
}
export interface MessageAt {
date: string;
message_id: number;
}
export interface Reaction {
added_at: string;
peer_id: number;
+56 -14
View File
@@ -7,6 +7,8 @@
import type { PeerView, PresenceSample } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { peerName } from "$lib/format/peer";
import { formatPresence } from "$lib/format/presence";
@@ -121,20 +123,45 @@
</script>
<header class="chat-header">
<Avatar
name={title}
colorKey={chatId}
size={2.5}
avatar={{ kind: avatarKind, id: chatId }}
{hasAvatar}
deleted={peer?.is_deleted_account ?? false}
/>
<div class="info">
<h2 class="title">{title}</h2>
<span class="subtitle" class:online={isDm && presence?.status === "online"}>
{subtitle}
</span>
</div>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="peek"
onclick={() => ui.openPanel("profile")}
aria-label="Открыть профиль"
>
<Avatar
name={title}
colorKey={chatId}
size={2.5}
avatar={{ kind: avatarKind, id: chatId }}
{hasAvatar}
deleted={peer?.is_deleted_account ?? false}
/>
<div class="info">
<h2 class="title">{title}</h2>
<span
class="subtitle"
class:online={isDm && presence?.status === "online"}
>
{subtitle}
</span>
</div>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
>Открыть профиль</ContextMenuItem
>
<ContextMenuItem
icon="play-story"
onselect={() => ui.openPanel("stories")}
>Сторис</ContextMenuItem
>
{/snippet}
</ContextMenu>
<div class="actions">
<Button
variant="translucent"
@@ -180,6 +207,21 @@
background-color: var(--color-background);
}
.peek {
display: flex;
flex: 1;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0;
border: 0;
text-align: left;
cursor: pointer;
background: transparent;
}
.info {
overflow: hidden;
min-width: 0;
+59 -28
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { ripple } from "$lib/actions/ripple";
import type { Chat } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import { formatListDate } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { type RightPanel, ui } from "$lib/stores/ui.svelte";
interface Props {
chat: Chat;
@@ -14,6 +18,11 @@
let { chat, selected, onclick }: Props = $props();
async function openWith(panel: RightPanel) {
await goto(`/app/${chat.chat_id}`);
ui.openPanel(panel);
}
const title = $derived(
chat.title ??
(chat.chat_id > 0 ? "Удалённый аккаунт" : `Chat ${chat.chat_id}`)
@@ -49,34 +58,56 @@
);
</script>
<button
type="button"
class="Chat ListItem-button"
class:selected
use:ripple
{onclick}
>
<Avatar
name={title}
colorKey={chat.chat_id}
avatar={{ kind: avatarKind, id: chat.chat_id }}
hasAvatar={chat.has_avatar}
/>
<div class="info">
<div class="info-row">
<h3 class="title">{title}</h3>
<span class="date">{formatListDate(chat.last_date)}</span>
</div>
<div class="subtitle">
<span class="last-message">
{#if senderPrefix}
<span class="sender">{senderPrefix}</span>
{/if}
{preview}
</span>
</div>
</div>
</button>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="Chat ListItem-button"
class:selected
use:ripple
{onclick}
>
<Avatar
name={title}
colorKey={chat.chat_id}
avatar={{ kind: avatarKind, id: chat.chat_id }}
hasAvatar={chat.has_avatar}
/>
<div class="info">
<div class="info-row">
<h3 class="title">{title}</h3>
<span class="date">{formatListDate(chat.last_date)}</span>
</div>
<div class="subtitle">
<span class="last-message">
{#if senderPrefix}
<span class="sender">{senderPrefix}</span>
{/if}
{preview}
</span>
</div>
</div>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onclick}
>Открыть</ContextMenuItem
>
<ContextMenuItem icon="info" onselect={() => openWith("profile")}
>Профиль</ContextMenuItem
>
<ContextMenuItem icon="search" onselect={() => openWith("search")}
>Поиск в чате</ContextMenuItem
>
<ContextMenuItem icon="stats" onselect={() => openWith("presence")}
>Аналитика</ContextMenuItem
>
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
>Сторис</ContextMenuItem
>
{/snippet}
</ContextMenu>
<style lang="scss">
.Chat {
+221 -82
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { MessageView } from "$lib/api/types";
import Contact from "$lib/components/Contact.svelte";
import EntityText from "$lib/components/EntityText.svelte";
@@ -12,11 +13,15 @@
import Reactions from "$lib/components/Reactions.svelte";
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import WebPage from "$lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
import { appear } from "$lib/transitions/appear";
interface Props {
@@ -75,100 +80,210 @@
? ownId !== null && peers.get(ownId)?.has_avatar
: (sender?.has_avatar ?? false)
);
const hasCallbacks = $derived(
message.inline_buttons.some((row) =>
row.some((button) => button.kind === "callback" && button.data)
)
);
const linkCount = $derived.by(() => {
const count = message.entities.filter(
(entity) => entity.type === "url" || entity.type === "text_link"
).length;
if (count > 0) {
return count;
}
return message.web_page ? 1 : 0;
});
function openReactions() {
ui.openMessagePanel("reactions", message.message_id);
}
function openCallbacks() {
ui.openMessagePanel("callbacks", message.message_id);
}
function openLinks() {
ui.openMessagePanel("links", message.message_id);
}
function openAnnotations() {
ui.openMessagePanel("annotations", message.message_id);
}
function jumpToReply() {
if (message.reply?.message_id != null) {
onjump(message.reply.message_id);
}
}
async function copyText() {
await navigator.clipboard.writeText(message.text ?? "");
toasts.success("Текст скопирован");
}
async function copyId() {
await navigator.clipboard.writeText(String(message.message_id));
toasts.success("ID скопирован");
}
function openSenderProfile() {
if (message.sender_id !== null) {
goto(`/app/${message.sender_id}`);
ui.openPanel("profile");
}
}
</script>
<div
class="Message"
class:own
class:deleted
class:highlighted
class:first-in-group={firstInGroup}
class:last-in-group={lastInGroup}
class:with-avatar={isGroupChat}
data-message-id={message.message_id}
in:appear={{ disabled: !animate }}
>
{#if isGroupChat}
<div class="avatar-slot">
{#if lastInGroup && avatarId !== null}
<Avatar
name={senderName}
colorKey={avatarId}
size={2.125}
avatar={{ kind: "peer", id: avatarId }}
hasAvatar={avatarHas}
/>
<ContextMenu>
{#snippet children({ props })}
<div
{...props}
class="Message"
class:own
class:deleted
class:highlighted
class:first-in-group={firstInGroup}
class:last-in-group={lastInGroup}
class:with-avatar={isGroupChat}
data-message-id={message.message_id}
in:appear={{ disabled: !animate }}
>
{#if isGroupChat}
<div class="avatar-slot">
{#if lastInGroup && avatarId !== null}
<Avatar
name={senderName}
colorKey={avatarId}
size={2.125}
avatar={{ kind: "peer", id: avatarId }}
hasAvatar={avatarHas}
/>
{/if}
</div>
{/if}
</div>
{/if}
<div class="message-content" class:has-appendix={lastInGroup}>
{#if showName}
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
<div class="message-content" class:has-appendix={lastInGroup}>
{#if showName}
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
{/if}
{#if message.forward}
<ForwardHeader forward={message.forward} />
{/if}
{#if message.reply}
<ReplyHeader reply={message.reply} {onjump} />
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
deleted
</span>
{/if}
{#if message.poll}
<Poll poll={message.poll} />
{/if}
{#if message.contact}
<Contact contact={message.contact} />
{/if}
{#if message.location}
<Location location={message.location} />
{/if}
{#if !special}
{#if message.media.length > 1}
<MediaAlbum
media={message.media}
chatId={message.chat_id}
onopen={onmedia}
/>
{:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
{/if}
{/if}
{#if hasText}
<div class="text">
<EntityText
text={message.text ?? ""}
entities={message.entities}
{own}
/>
</div>
{:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div>
{/if}
{#if message.web_page}
<WebPage {message} {own} {onmedia} />
{/if}
{#if message.reactions.length}
<Reactions
reactions={message.reactions}
{own}
onclick={openReactions}
/>
{/if}
{#if linkCount > 0}
<button type="button" class="detail-chip" onclick={openLinks}>
<Icon name="link" size="0.875rem" />
{linkCount}
{linkCount === 1 ? "ссылка" : "ссылки"}
</button>
{/if}
<MessageMeta {message} {own} {onversions} />
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
{/if}
{#if hasCallbacks}
<button type="button" class="detail-chip" onclick={openCallbacks}>
<Icon name="bot-command" size="0.875rem" />
Callback-данные
</button>
{/if}
{#if lastInGroup}
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
<path
class="corner"
d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z"
/>
</svg>
{/if}
</div>
</div>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="note" onselect={openAnnotations}
>Заметки</ContextMenuItem
>
{#if message.reactions.length}
<ContextMenuItem icon="heart" onselect={openReactions}
>Реакции</ContextMenuItem
>
{/if}
{#if message.forward}
<ForwardHeader forward={message.forward} />
{#if linkCount > 0}
<ContextMenuItem icon="link" onselect={openLinks}>Ссылки</ContextMenuItem>
{/if}
{#if message.reply}
<ReplyHeader reply={message.reply} {onjump} />
{#if hasCallbacks}
<ContextMenuItem icon="bot-command" onselect={openCallbacks}
>Callback-данные</ContextMenuItem
>
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
deleted
</span>
{#if message.edited_at}
<ContextMenuItem icon="edit" onselect={onversions}
>Версии</ContextMenuItem
>
{/if}
{#if message.poll}
<Poll poll={message.poll} />
{/if}
{#if message.contact}
<Contact contact={message.contact} />
{/if}
{#if message.location}
<Location location={message.location} />
{/if}
{#if !special}
{#if message.media.length > 1}
<MediaAlbum
media={message.media}
chatId={message.chat_id}
onopen={onmedia}
/>
{:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
{/if}
{#if message.reply?.message_id != null}
<ContextMenuItem icon="reply" onselect={jumpToReply}
>Перейти к ответу</ContextMenuItem
>
{/if}
{#if hasText}
<div class="text">
<EntityText
text={message.text ?? ""}
entities={message.entities}
{own}
/>
</div>
{:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div>
<ContextMenuItem icon="copy" onselect={copyText}
>Копировать текст</ContextMenuItem
>
{/if}
{#if message.web_page}
<WebPage {message} {own} {onmedia} />
<ContextMenuItem icon="hashtag" onselect={copyId}
>Копировать ID</ContextMenuItem
>
{#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile}
>Профиль автора</ContextMenuItem
>
{/if}
{#if message.reactions.length}
<Reactions reactions={message.reactions} {own} />
{/if}
<MessageMeta {message} {own} {onversions} />
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
{/if}
{#if lastInGroup}
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
<path
class="corner"
d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z"
/>
</svg>
{/if}
</div>
</div>
{/snippet}
</ContextMenu>
<style lang="scss">
.Message {
@@ -278,6 +393,30 @@
color: var(--color-error);
}
.detail-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
align-self: flex-start;
margin-top: 0.25rem;
padding: 0.125rem 0.5rem 0.125rem 0.375rem;
border: 0;
border-radius: 1rem;
font-family: inherit;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
background-color: var(--color-message-reaction);
&:hover {
color: var(--color-primary);
}
}
.svg-appendix {
position: absolute;
bottom: -0.0625rem;
+124 -21
View File
@@ -9,6 +9,7 @@
import MessageVersions from "$lib/components/MessageVersions.svelte";
import PinnedBar from "$lib/components/PinnedBar.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";
import { formatDay } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
@@ -30,12 +31,13 @@
const SCROLL_THRESHOLD = 160;
const STICK_OFFSET = 9;
const IDLE_DELAY = 1500;
const JUMP_MAX_PAGES = 12;
let messages = $state<MessageView[]>([]);
let loading = $state(true);
let loadingOlder = $state(false);
let loadingNewer = $state(false);
let hasMore = $state(true);
let hasNewer = $state(false);
let container = $state<HTMLDivElement | null>(null);
let suppressAppear = $state(false);
let scrolling = $state(false);
@@ -155,6 +157,7 @@
async function loadInitial() {
loading = true;
hasMore = true;
hasNewer = false;
try {
const page = await listMessages(chatId, {
limit: PAGE,
@@ -162,7 +165,7 @@
include_deleted: true,
});
messages = [...page].reverse();
hasMore = page.length === PAGE;
hasMore = page.length > 0;
ensurePeers(messages);
await tick();
scrollToBottom();
@@ -175,33 +178,95 @@
}
async function loadOlder() {
if (loadingOlder || !hasMore || container === null) {
if (
loadingOlder ||
!hasMore ||
container === null ||
messages.length === 0
) {
return;
}
loadingOlder = true;
suppressAppear = true;
const el = container;
const prevHeight = el.scrollHeight;
const prevTop = el.scrollTop;
try {
const page = await listMessages(chatId, {
limit: PAGE,
offset: messages.length,
before_id: messages[0].message_id,
include_deleted: true,
});
if (page.length > 0) {
messages = [...[...page].reverse(), ...messages];
ensurePeers(page);
const known = new Set(messages.map((m) => m.message_id));
const fresh = page.filter((m) => !known.has(m.message_id));
hasMore = fresh.length > 0;
if (fresh.length > 0) {
suppressAppear = true;
messages = [...[...fresh].reverse(), ...messages];
ensurePeers(fresh);
await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
}
hasMore = page.length === PAGE;
await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
} finally {
loadingOlder = false;
suppressAppear = false;
}
}
async function loadNewer() {
if (loadingNewer || !hasNewer || messages.length === 0) {
return;
}
loadingNewer = true;
try {
const page = await listMessages(chatId, {
limit: PAGE,
after_id: messages.at(-1)?.message_id,
include_deleted: true,
});
const known = new Set(messages.map((m) => m.message_id));
const fresh = page.filter((m) => !known.has(m.message_id));
hasNewer = fresh.length > 0;
if (fresh.length > 0) {
suppressAppear = true;
messages = [...messages, ...fresh];
ensurePeers(fresh);
await tick();
}
} finally {
loadingNewer = false;
suppressAppear = false;
}
}
async function loadAround(messageId: number) {
suppressAppear = true;
try {
const [older, newer] = await Promise.all([
listMessages(chatId, {
limit: PAGE,
before_id: messageId + 1,
include_deleted: true,
}),
listMessages(chatId, {
limit: PAGE,
after_id: messageId,
include_deleted: true,
}),
]);
messages = [...[...older].reverse(), ...newer];
hasMore = older.length > 0;
hasNewer = newer.length > 0;
ensurePeers(messages);
await tick();
} finally {
suppressAppear = false;
}
}
async function goToLatest() {
await loadInitial();
}
function onScroll() {
scrolling = true;
if (idleTimer) {
@@ -214,6 +279,9 @@
if (container && container.scrollTop < SCROLL_THRESHOLD) {
loadOlder();
}
if (hasNewer && isNearBottom()) {
loadNewer();
}
}
function openMedia(message: MessageView, index: number) {
@@ -248,6 +316,9 @@
replaceMessage(message);
return;
}
if (hasNewer) {
return;
}
const stick = isNearBottom();
messages = [...messages, message];
ensurePeers([message]);
@@ -296,7 +367,7 @@
}
async function resyncTail() {
if (messages.length === 0) {
if (messages.length === 0 || hasNewer) {
return;
}
const page = await listMessages(chatId, {
@@ -328,15 +399,8 @@
}
async function jumpToTarget(messageId: number) {
let guard = 0;
while (
!messages.some((m) => m.message_id === messageId) &&
hasMore &&
(messages.length === 0 || messages[0].message_id > messageId) &&
guard < JUMP_MAX_PAGES
) {
await loadOlder();
guard++;
if (!messages.some((m) => m.message_id === messageId)) {
await loadAround(messageId);
}
await tick();
jumpToMessage(messageId);
@@ -422,9 +486,22 @@
/>
{/if}
{/each}
{#if loadingNewer}
<div class="loading-older"><Spinner /></div>
{/if}
</div>
{/if}
</div>
{#if hasNewer}
<button
type="button"
class="to-latest"
onclick={goToLatest}
aria-label="К последним сообщениям"
>
<Icon name="arrow-down" />
</button>
{/if}
</div>
<MediaViewer
@@ -441,11 +518,37 @@
<style lang="scss">
.message-pane {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
}
.to-latest {
position: absolute;
right: 1.25rem;
bottom: 1.25rem;
z-index: var(--z-sticky-date);
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
cursor: pointer;
background-color: var(--color-background);
box-shadow: 0 2px 8px rgb(0 0 0 / 16%);
&:hover {
color: var(--color-text);
}
}
.message-list {
overflow-y: auto;
flex: 1;
+84 -66
View File
@@ -11,9 +11,12 @@
import TgsSticker from "$lib/components/media/TgsSticker.svelte";
import VideoNote from "$lib/components/media/VideoNote.svelte";
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
message: MessageView;
@@ -92,72 +95,87 @@
}
</script>
<div class="message-media" use:visible={start}>
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Self-destruct media</span>
</button>
{:else if !loaded}
<div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"}
<VoiceMessage url={ready.url} transcript={ready.transcript} {own} />
{:else if ready && kind === "video_note"}
<VideoNote url={ready.url} transcript={ready.transcript} />
{:else if ready && kind === "audio"}
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img src={ready.url} alt="attachment">
</button>
{:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button">
<img src={ready.url} alt="sticker">
</button>
{:else if ready && isVideoSticker}
<video
class="media-sticker-video"
src={ready.url}
autoplay
loop
muted
playsinline
></video>
{:else if ready && isTgsSticker}
<TgsSticker url={ready.url} />
{:else if ready && isAnimation}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} autoplay loop muted playsinline></video>
<span class="gif-badge">GIF</span>
</button>
{:else if ready && isThumbVideo}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} muted preload="metadata"></video>
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
</button>
{:else if ready}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="document" size="1.25rem" />
<span>{label}</span>
</button>
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Video" : "Photo"}</span>
<small>{queuing ? "Queued" : "Tap to download"}</small>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "Queued" : `Download ${label}`}</span>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Media</span>
</button>
{/if}
</div>
<ContextMenu>
{#snippet children({ props })}
<div {...props} class="message-media" use:visible={start}>
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Self-destruct media</span>
</button>
{:else if !loaded}
<div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"}
<VoiceMessage url={ready.url} transcript={ready.transcript} {own} />
{:else if ready && kind === "video_note"}
<VideoNote url={ready.url} transcript={ready.transcript} />
{:else if ready && kind === "audio"}
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img src={ready.url} alt="attachment">
</button>
{:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button">
<img src={ready.url} alt="sticker">
</button>
{:else if ready && isVideoSticker}
<video
class="media-sticker-video"
src={ready.url}
autoplay
loop
muted
playsinline
></video>
{:else if ready && isTgsSticker}
<TgsSticker url={ready.url} />
{:else if ready && isAnimation}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} autoplay loop muted playsinline></video>
<span class="gif-badge">GIF</span>
</button>
{:else if ready && isThumbVideo}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} muted preload="metadata"></video>
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
</button>
{:else if ready}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="document" size="1.25rem" />
<span>{label}</span>
</button>
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Video" : "Photo"}</span>
<small>{queuing ? "Queued" : "Tap to download"}</small>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "Queued" : `Download ${label}`}</span>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Media</span>
</button>
{/if}
</div>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
>
<ContextMenuItem icon="download" onselect={queue}>Скачать</ContextMenuItem>
{/snippet}
</ContextMenu>
<style lang="scss">
.message-media {
+12 -3
View File
@@ -3,23 +3,29 @@
import CustomEmoji from "$lib/components/CustomEmoji.svelte";
interface Props {
onclick?: () => void;
own: boolean;
reactions: ReactionCount[];
}
let { reactions, own }: Props = $props();
let { reactions, own, onclick }: Props = $props();
</script>
<div class="Reactions" class:own>
{#each reactions as reaction, index (reaction.custom_emoji_id ?? reaction.emoji ?? index)}
<span class="reaction" class:chosen={reaction.chosen}>
<button
type="button"
class="reaction"
class:chosen={reaction.chosen}
{onclick}
>
{#if reaction.custom_emoji_id}
<CustomEmoji id={reaction.custom_emoji_id} size={1.25} />
{:else}
<span class="emoji">{reaction.emoji ?? "❓"}</span>
{/if}
<span class="count">{reaction.count}</span>
</span>
</button>
{/each}
</div>
@@ -38,13 +44,16 @@
height: 1.625rem;
padding: 0 0.4375rem 0 0.375rem;
border: 0;
border-radius: 1rem;
font-family: inherit;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
color: var(--color-text);
cursor: pointer;
background-color: var(--color-message-reaction);
.own & {
+32 -1
View File
@@ -1,10 +1,19 @@
<script lang="ts">
import AlertsPanel from "$lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "$lib/components/annotations/AnnotationsPanel.svelte";
import JobsPanel from "$lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "$lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
import ProfilePanel from "$lib/components/profile/ProfilePanel.svelte";
import ChatSearchPanel from "$lib/components/search/ChatSearchPanel.svelte";
import CallbacksPanel from "$lib/components/social/CallbacksPanel.svelte";
import LinksPanel from "$lib/components/social/LinksPanel.svelte";
import ReactionsPanel from "$lib/components/social/ReactionsPanel.svelte";
import AllStoriesArchive from "$lib/components/stories/AllStoriesArchive.svelte";
import StoriesArchive from "$lib/components/stories/StoriesArchive.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import WatchesPanel from "$lib/components/watches/WatchesPanel.svelte";
import { type RightPanel, ui } from "$lib/stores/ui.svelte";
const titles: Record<RightPanel, string> = {
@@ -12,12 +21,16 @@
search: "Поиск",
versions: "Версии",
reactions: "Реакции",
callbacks: "Callback-данные",
links: "Ссылки",
annotations: "Заметки",
jobs: "Данные и хранилище",
presence: "Аналитика",
stories: "Сторис",
"stories-all": "Все сторис",
policy: "Политика захвата",
watches: "Отслеживания",
alerts: "Алерты",
};
</script>
@@ -35,7 +48,9 @@
</header>
<div class="right-body custom-scroll">
{#if ui.rightPanel === "policy"}
{#if ui.rightPanel === "profile"}
<ProfilePanel />
{:else if ui.rightPanel === "policy"}
<PolicyEditor />
{:else if ui.rightPanel === "jobs"}
<JobsPanel />
@@ -43,6 +58,22 @@
<AnalyticsPanel />
{:else if ui.rightPanel === "search"}
<ChatSearchPanel />
{:else if ui.rightPanel === "reactions"}
<ReactionsPanel />
{:else if ui.rightPanel === "callbacks"}
<CallbacksPanel />
{:else if ui.rightPanel === "links"}
<LinksPanel />
{:else if ui.rightPanel === "stories"}
<StoriesArchive />
{:else if ui.rightPanel === "stories-all"}
<AllStoriesArchive />
{:else if ui.rightPanel === "watches"}
<WatchesPanel />
{:else if ui.rightPanel === "alerts"}
<AlertsPanel />
{:else if ui.rightPanel === "annotations"}
<AnnotationsPanel />
{/if}
</div>
@@ -0,0 +1,82 @@
<script lang="ts">
import type { Alert } from "$lib/api/types";
import Button from "../ui/Button.svelte";
import Icon from "../ui/Icon.svelte";
interface Props {
alerts: Alert[];
onseen: (alert: Alert) => void;
}
const { alerts, onseen }: Props = $props();
function text(alert: Alert): string {
const p = alert.payload;
const value = p.text ?? p.message ?? p.keyword;
return typeof value === "string" ? value : JSON.stringify(p);
}
</script>
<ul class="alert-list">
{#each alerts as alert (alert.id)}
<li class="alert-item" class:unseen={!alert.seen}>
<div class="alert-main">
<p class="alert-text">{text(alert)}</p>
<time class="alert-date">{new Date(alert.ts).toLocaleString()}</time>
</div>
{#if !alert.seen}
<Button
variant="translucent"
round
smaller
onclick={() => onseen(alert)}
aria-label="Отметить прочитанным"
>
<Icon name="check" />
</Button>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
.alert-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.alert-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
&.unseen {
border-color: var(--color-primary);
}
}
.alert-main {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.alert-text {
margin: 0;
overflow-wrap: anywhere;
}
.alert-date {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,72 @@
<script lang="ts">
import { listAlerts, markAlertSeen } from "$lib/api/endpoints";
import type { Alert } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
import Spinner from "../ui/Spinner.svelte";
import AlertList from "./AlertList.svelte";
let alerts = $state<Alert[]>([]);
let loading = $state(true);
let unseenOnly = $state(false);
async function load(_account: number | null, unseen: boolean) {
loading = true;
try {
alerts = await listAlerts(unseen ? { seen: false } : {});
} finally {
loading = false;
}
}
$effect(() => {
load(accounts.selectedId, unseenOnly);
});
async function seen(alert: Alert) {
await markAlertSeen(alert.id);
if (unseenOnly) {
alerts = alerts.filter((a) => a.id !== alert.id);
} else {
alerts = alerts.map((a) =>
a.id === alert.id ? { ...a, seen: true } : a
);
}
}
</script>
<div class="panel-content">
<label class="toggle">
<input type="checkbox" bind:checked={unseenOnly}>
<span>Только непрочитанные</span>
</label>
{#if loading}
<Spinner />
{:else if alerts.length === 0}
<p class="empty">Алертов нет.</p>
{:else}
<AlertList {alerts} onseen={seen} />
{/if}
</div>
<style lang="scss">
.panel-content {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.empty {
color: var(--color-text-secondary);
text-align: center;
}
</style>
@@ -0,0 +1,81 @@
<script lang="ts">
import { untrack } from "svelte";
import Button from "../ui/Button.svelte";
interface Props {
initial?: string;
oncancel?: () => void;
onsubmit: (text: string) => void;
saving?: boolean;
submitLabel?: string;
}
const {
initial = "",
saving = false,
submitLabel = "Добавить заметку",
oncancel,
onsubmit,
}: Props = $props();
let text = $state(untrack(() => initial));
const valid = $derived(text.trim() !== "");
function submit() {
if (!valid) {
return;
}
onsubmit(text.trim());
if (oncancel === undefined) {
text = "";
}
}
</script>
<form
class="annotation-editor"
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<textarea
bind:value={text}
rows={oncancel ? 2 : 3}
placeholder="Заметка к сообщению…"
></textarea>
<div class="actions">
{#if oncancel}
<Button variant="translucent" onclick={oncancel}>Отмена</Button>
{/if}
<Button type="submit" disabled={saving || !valid}>{submitLabel}</Button>
</div>
</form>
<style lang="scss">
.annotation-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-borders);
border-radius: 0.5rem;
color: var(--color-text);
font: inherit;
background: var(--color-background);
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>
@@ -0,0 +1,113 @@
<script lang="ts">
import type { Annotation } from "$lib/api/types";
import { formatFull } from "$lib/format/datetime";
import Icon from "../ui/Icon.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte";
interface Props {
annotations: Annotation[];
ondelete: (annotation: Annotation) => void;
onupdate: (annotation: Annotation, text: string) => void;
}
const { annotations, ondelete, onupdate }: Props = $props();
let editingId = $state<number | null>(null);
</script>
<ul class="annotation-list">
{#each annotations as annotation (annotation.id)}
<li>
{#if editingId === annotation.id}
<AnnotationEditor
initial={annotation.text}
submitLabel="Сохранить"
oncancel={() => {
editingId = null;
}}
onsubmit={(text) => {
onupdate(annotation, text);
editingId = null;
}}
/>
{:else}
<p class="text">{annotation.text}</p>
<div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span>
<button
type="button"
class="action"
aria-label="Изменить"
onclick={() => {
editingId = annotation.id;
}}
>
<Icon name="edit" size="1rem" />
</button>
<button
type="button"
class="action"
aria-label="Удалить"
onclick={() => ondelete(annotation)}
>
<Icon name="delete" size="1rem" />
</button>
</div>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
.annotation-list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
gap: 0.5rem;
list-style: none;
}
li {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
}
.text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.375rem;
}
.time {
flex: 1;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.action {
cursor: pointer;
display: flex;
padding: 0.25rem;
border: 0;
border-radius: 0.375rem;
color: var(--color-text-secondary);
background: transparent;
transition: background-color 0.15s ease;
&:hover {
color: var(--color-text);
background-color: var(--color-chat-hover);
}
}
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import {
createAnnotation,
deleteAnnotation,
listAnnotations,
updateAnnotation,
} from "$lib/api/endpoints";
import type { Annotation } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte";
import AnnotationList from "./AnnotationList.svelte";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
let items = $state<Annotation[]>([]);
let loading = $state(false);
let saving = $state(false);
let token = 0;
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid === null || mid === null || accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
const current = token;
loading = true;
items = [];
listAnnotations({ chatId: cid, messageId: mid })
.then((rows) => {
if (current === token) {
items = rows;
}
})
.finally(() => {
if (current === token) {
loading = false;
}
});
});
});
async function add(text: string) {
if (chatId === null || messageId === null) {
return;
}
saving = true;
try {
const created = await createAnnotation(chatId, messageId, text);
items = [created, ...items];
} finally {
saving = false;
}
}
async function edit(annotation: Annotation, text: string) {
const updated = await updateAnnotation(annotation.id, text);
items = items.map((item) => (item.id === updated.id ? updated : item));
}
async function remove(annotation: Annotation) {
await deleteAnnotation(annotation.id);
items = items.filter((item) => item.id !== annotation.id);
}
</script>
{#if chatId === null || messageId === null}
<EmptyState title="Заметки" description="Сообщение не выбрано" />
{:else}
<div class="panel-content">
<AnnotationEditor {saving} onsubmit={add} />
{#if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length > 0}
<AnnotationList annotations={items} ondelete={remove} onupdate={edit} />
{/if}
</div>
{/if}
<style lang="scss">
.panel-content {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
</style>
@@ -0,0 +1,113 @@
<script lang="ts">
import { untrack } from "svelte";
import { type AvatarKind, loadAvatarVariant } from "$lib/api/avatars";
import { getAvatarHistory } from "$lib/api/endpoints";
import type { AvatarHistoryView } from "$lib/api/types";
import { formatFull } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
interface Props {
id: number;
kind: AvatarKind;
name: string;
}
let { kind, id, name }: Props = $props();
let items = $state<AvatarHistoryView[]>([]);
const urls = $state<Record<string, string | null>>({});
$effect(() => {
if (accounts.selectedId === null) {
return;
}
let active = true;
items = [];
getAvatarHistory(kind, id)
.then((result) => {
if (active) {
items = result;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
$effect(() => {
const list = items;
let active = true;
untrack(() => {
for (const item of list) {
if (!(item.downloaded && urls[item.unique_id] === undefined)) {
continue;
}
urls[item.unique_id] = null;
loadAvatarVariant(kind, id, item.unique_id).then((url) => {
if (active) {
urls[item.unique_id] = url;
}
});
}
});
return () => {
active = false;
};
});
</script>
{#if items.length > 0}
<section>
<h3>История аватарок ({items.length})</h3>
<div class="grid">
{#each items as item (item.unique_id)}
<figure title={formatFull(item.first_seen_at)}>
{#if urls[item.unique_id]}
<img src={urls[item.unique_id]} alt={name}>
{:else}
<div class="placeholder"></div>
{/if}
</figure>
{/each}
</div>
</section>
{/if}
<style lang="scss">
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
h3 {
margin: 0;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(4rem, 1fr));
gap: 0.375rem;
}
figure {
aspect-ratio: 1;
margin: 0;
overflow: hidden;
border-radius: 0.5rem;
background-color: var(--color-background-secondary);
}
img,
.placeholder {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
@@ -0,0 +1,371 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { getChatCalendar, getMessageAt } from "$lib/api/endpoints";
import type { DayCount } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const LEVELS = 4;
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const MONTHS = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
interface YearMonth {
month: number;
year: number;
}
interface DayCell {
count: number;
day: number;
key: string;
level: number;
}
let days = $state<DayCount[]>([]);
let loading = $state(false);
let cursor = $state<YearMonth | null>(null);
const byKey = $derived(
new Map(days.map((day) => [day.day.slice(0, 10), day.count]))
);
const maxCount = $derived(
days.reduce((max, day) => Math.max(max, day.count), 0)
);
const minMonth = $derived.by<YearMonth | null>(() => {
if (days.length === 0) {
return null;
}
const date = new Date(days[0].day);
return { year: date.getUTCFullYear(), month: date.getUTCMonth() };
});
const maxMonth = $derived.by<YearMonth | null>(() => {
if (days.length === 0) {
return null;
}
const date = new Date(days.at(-1)?.day ?? days[0].day);
return { year: date.getUTCFullYear(), month: date.getUTCMonth() };
});
const view = $derived(cursor ?? maxMonth);
function index(value: YearMonth): number {
return value.year * 12 + value.month;
}
const canPrev = $derived(
Boolean(view && minMonth && index(view) > index(minMonth))
);
const canNext = $derived(
Boolean(view && maxMonth && index(view) < index(maxMonth))
);
function level(count: number): number {
if (count <= 0 || maxCount <= 0) {
return 0;
}
return Math.max(
1,
Math.ceil((LEVELS * Math.log(count + 1)) / Math.log(maxCount + 1))
);
}
function clamp(value: YearMonth): YearMonth {
if (minMonth && index(value) < index(minMonth)) {
return minMonth;
}
if (maxMonth && index(value) > index(maxMonth)) {
return maxMonth;
}
return value;
}
function shift(months: number) {
if (!view) {
return;
}
const total = index(view) + months;
cursor = clamp({ year: Math.floor(total / 12), month: total % 12 });
}
const lead = $derived(
view
? (new Date(Date.UTC(view.year, view.month, 1)).getUTCDay() + 6) % 7
: 0
);
const blanks = $derived(Array.from({ length: lead }, (_, i) => i));
const cells = $derived.by<DayCell[]>(() => {
if (!view) {
return [];
}
const count = new Date(Date.UTC(view.year, view.month + 1, 0)).getUTCDate();
const result: DayCell[] = [];
for (let day = 1; day <= count; day++) {
const month = String(view.month + 1).padStart(2, "0");
const date = String(day).padStart(2, "0");
const key = `${view.year}-${month}-${date}`;
const value = byKey.get(key) ?? 0;
result.push({ day, key, count: value, level: level(value) });
}
return result;
});
$effect(() => {
const _id = chatId;
if (accounts.selectedId === null) {
return;
}
let active = true;
loading = true;
days = [];
cursor = null;
getChatCalendar(chatId)
.then((result) => {
if (active) {
days = result;
}
})
.catch(() => undefined)
.finally(() => {
if (active) {
loading = false;
}
});
return () => {
active = false;
};
});
async function jump(cell: DayCell) {
if (cell.count <= 0) {
return;
}
try {
const target = await getMessageAt(chatId, cell.key);
ui.requestJump(chatId, target.message_id);
goto(`/app/${chatId}`);
} catch {
// no message resolved for that day
}
}
</script>
{#if loading && days.length === 0}
<div class="center"><Spinner /></div>
{:else if !view}
<EmptyState title="Нет сообщений" />
{:else}
<div class="calendar">
<header class="nav">
<button
type="button"
class="step"
onclick={() => shift(-12)}
disabled={!canPrev}
aria-label="Предыдущий год"
>
«
</button>
<button
type="button"
class="step"
onclick={() => shift(-1)}
disabled={!canPrev}
aria-label="Предыдущий месяц"
>
<Icon name="arrow-left" />
</button>
<span class="label">{MONTHS[view.month]} {view.year}</span>
<button
type="button"
class="step"
onclick={() => shift(1)}
disabled={!canNext}
aria-label="Следующий месяц"
>
<Icon name="arrow-right" />
</button>
<button
type="button"
class="step"
onclick={() => shift(12)}
disabled={!canNext}
aria-label="Следующий год"
>
»
</button>
</header>
<div class="weekdays">
{#each WEEKDAYS as weekday (weekday)}
<span>{weekday}</span>
{/each}
</div>
<div class="grid">
{#each blanks as blank (blank)}
<span class="cell blank"></span>
{/each}
{#each cells as cell (cell.key)}
{#if cell.count > 0}
<button
type="button"
class="cell day lvl-{cell.level}"
title={`${cell.count} сообщений`}
onclick={() => jump(cell)}
>
{cell.day}
</button>
{:else}
<span class="cell day empty">{cell.day}</span>
{/if}
{/each}
</div>
</div>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.calendar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
}
.nav {
display: flex;
align-items: center;
gap: 0.25rem;
}
.label {
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-align: center;
}
.step {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 50%;
font-size: 1.125rem;
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
&:hover:not(:disabled) {
background-color: var(--color-background-secondary);
}
&:disabled {
color: var(--color-borders);
cursor: default;
}
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
}
.weekdays span {
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-align: center;
text-transform: uppercase;
}
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
}
.cell {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 0.5rem;
font-size: 0.8125rem;
background: transparent;
}
.blank {
visibility: hidden;
}
.empty {
color: var(--color-text-secondary);
opacity: 0.45;
}
.day {
color: var(--color-text);
}
button.day {
cursor: pointer;
font-weight: var(--font-weight-medium);
}
.lvl-1 {
background-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.lvl-2 {
background-color: color-mix(in srgb, var(--color-primary) 45%, transparent);
}
.lvl-3 {
color: var(--color-white);
background-color: color-mix(in srgb, var(--color-primary) 72%, transparent);
}
.lvl-4 {
color: var(--color-white);
background-color: var(--color-primary);
}
</style>
@@ -0,0 +1,287 @@
<script lang="ts">
import type { AvatarKind } from "$lib/api/avatars";
import { getPeerHistory, getStories } from "$lib/api/endpoints";
import type { PeerHistoryView, PeerView } from "$lib/api/types";
import AvatarHistory from "$lib/components/profile/AvatarHistory.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { formatFull } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
avatarKind: AvatarKind;
chatId: number;
isDm: boolean;
name: string;
peer: PeerView | null;
}
let { chatId, isDm, peer, name, avatarKind }: Props = $props();
interface InfoRow {
label: string;
value: string;
}
const rows = $derived.by(() => {
const result: InfoRow[] = [];
if (peer?.username) {
result.push({ label: "Имя пользователя", value: `@${peer.username}` });
}
if (peer?.phone) {
result.push({ label: "Телефон", value: `+${peer.phone}` });
}
result.push({ label: "ID", value: String(chatId) });
if (peer?.is_deleted_account) {
result.push({ label: "Статус", value: "Удалённый аккаунт" });
}
if (peer?.updated_at) {
result.push({ label: "Обновлено", value: formatFull(peer.updated_at) });
}
return result;
});
let history = $state<PeerHistoryView[]>([]);
function fingerprint(entry: PeerHistoryView): string {
return [
entry.first_name,
entry.last_name,
entry.username,
entry.phone,
entry.is_deleted_account,
].join("|");
}
const changes = $derived.by(() => {
const result: PeerHistoryView[] = [];
let last: string | null = null;
for (const entry of history) {
const print = fingerprint(entry);
if (print !== last) {
result.push(entry);
last = print;
}
}
return result;
});
function entryName(entry: PeerHistoryView): string {
const parts = [entry.first_name, entry.last_name].filter(Boolean);
if (parts.length > 0) {
return parts.join(" ");
}
if (entry.username) {
return `@${entry.username}`;
}
return "—";
}
$effect(() => {
if (accounts.selectedId === null || !isDm) {
history = [];
return;
}
let active = true;
history = [];
getPeerHistory(chatId)
.then((result) => {
if (active) {
history = result;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
let storyCount = $state(0);
$effect(() => {
if (accounts.selectedId === null) {
storyCount = 0;
return;
}
const id = chatId;
let active = true;
storyCount = 0;
getStories(id, { limit: 100 })
.then((result) => {
if (active) {
storyCount = result.length;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
</script>
<div class="info-tab">
{#if rows.length > 0}
<section>
<dl>
{#each rows as row (row.label)}
<div class="row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
{/each}
</dl>
</section>
{/if}
{#if storyCount > 0}
<section>
<button
type="button"
class="entry"
onclick={() => ui.openPanel("stories")}
>
<span class="entry-icon"><Icon name="play-story" /></span>
<span class="entry-label">Сторис</span>
<span class="entry-count">{storyCount}</span>
<Icon name="next" size="1rem" />
</button>
</section>
{/if}
<AvatarHistory kind={avatarKind} id={chatId} {name} />
{#if changes.length > 1}
<section>
<h3>История профиля</h3>
<ul class="changes">
{#each changes as entry (entry.observed_at)}
<li>
<span class="label">{entryName(entry)}</span>
<span class="time">{formatFull(entry.observed_at)}</span>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<style lang="scss">
.info-tab {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
}
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
h3 {
margin: 0;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
dl {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0;
}
.row {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
dt {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
dd {
margin: 0;
font-size: 0.9375rem;
color: var(--color-text);
overflow-wrap: anywhere;
}
.entry {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.5rem;
border: 0;
border-radius: 0.5rem;
cursor: pointer;
color: var(--color-text);
background: transparent;
&:hover {
background-color: var(--color-background-secondary);
}
}
.entry-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.entry-label {
flex: 1;
font-size: 0.9375rem;
text-align: left;
}
.entry-count {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.changes {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.changes li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.8125rem;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
flex-shrink: 0;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,183 @@
<script lang="ts">
import { page } from "$app/state";
import { getPeer } from "$lib/api/endpoints";
import type { PeerView } from "$lib/api/types";
import ChatCalendar from "$lib/components/profile/ChatCalendar.svelte";
import ProfileInfo from "$lib/components/profile/ProfileInfo.svelte";
import SharedLinks from "$lib/components/profile/SharedLinks.svelte";
import SharedMedia from "$lib/components/profile/SharedMedia.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { peerName } from "$lib/format/peer";
import { chats } from "$lib/stores/chats.svelte";
type Tab = "info" | "media" | "files" | "links" | "calendar";
const TABS: { id: Tab; icon: string; label: string }[] = [
{ id: "info", icon: "info", label: "Инфо" },
{ id: "media", icon: "photo", label: "Медиа" },
{ id: "files", icon: "document", label: "Файлы" },
{ id: "links", icon: "link", label: "Ссылки" },
{ id: "calendar", icon: "calendar", label: "Календарь" },
];
const MEDIA_KINDS = ["photo", "video", "animation", "video_note"];
const FILE_KINDS = ["document", "audio", "voice"];
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const isDm = $derived(chatId !== null && chatId > 0);
const chat = $derived(chatId === null ? null : chats.byId(chatId));
const avatarKind = $derived(isDm ? "peer" : "chat");
let peer = $state<PeerView | null>(null);
let tab = $state<Tab>("info");
const title = $derived(
isDm && peer
? peerName(peer)
: (chat?.title ?? (chatId === null ? "" : `Chat ${chatId}`))
);
const subtitle = $derived.by(() => {
if (isDm) {
if (peer?.username) {
return `@${peer.username}`;
}
return peer?.phone ? `+${peer.phone}` : `ID ${chatId}`;
}
const count = chat?.message_count ?? 0;
return count > 0 ? `${count} сообщений` : "группа";
});
const hasAvatar = $derived(chat?.has_avatar ?? Boolean(peer?.has_avatar));
$effect(() => {
if (chatId === null || !isDm) {
peer = null;
return;
}
let active = true;
peer = null;
getPeer(chatId)
.then((result) => {
if (active) {
peer = result;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
</script>
{#if chatId === null}
<EmptyState title="Профиль" description="Откройте чат" />
{:else}
<div class="profile">
<header class="hero">
<Avatar
name={title}
colorKey={chatId}
size={5}
avatar={{ kind: avatarKind, id: chatId }}
{hasAvatar}
deleted={peer?.is_deleted_account ?? false}
/>
<h2 class="name">{title}</h2>
<span class="sub">{subtitle}</span>
</header>
<nav class="tabs">
{#each TABS as item (item.id)}
<button
type="button"
class="tab"
class:active={tab === item.id}
onclick={() => {
tab = item.id;
}}
aria-label={item.label}
>
<Icon name={item.icon} />
</button>
{/each}
</nav>
<div class="tab-body">
{#if tab === "info"}
<ProfileInfo {chatId} {isDm} {peer} name={title} {avatarKind} />
{:else if tab === "media"}
<SharedMedia {chatId} kinds={MEDIA_KINDS} layout="grid" />
{:else if tab === "files"}
<SharedMedia {chatId} kinds={FILE_KINDS} layout="list" />
{:else if tab === "links"}
<SharedLinks {chatId} />
{:else if tab === "calendar"}
<ChatCalendar {chatId} />
{/if}
</div>
</div>
{/if}
<style lang="scss">
.profile {
display: flex;
flex-direction: column;
height: 100%;
}
.hero {
display: flex;
flex-direction: column;
flex-shrink: 0;
align-items: center;
gap: 0.25rem;
padding: 1.25rem 1rem 1rem;
}
.name {
margin: 0.5rem 0 0;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
text-align: center;
overflow-wrap: anywhere;
}
.sub {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.tabs {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid var(--color-borders);
}
.tab {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 3rem;
border: 0;
border-bottom: 2px solid transparent;
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
&.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
}
.tab-body {
overflow-y: auto;
flex: 1;
}
</style>
@@ -0,0 +1,216 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { getChatLinks } from "$lib/api/endpoints";
import type { ChatLinkView } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatListDate } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
const WWW_PREFIX = /^www\./;
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const PAGE = 60;
let items = $state<ChatLinkView[]>([]);
let loading = $state(false);
let done = $state(false);
let token = 0;
async function loadMore() {
if (loading || done) {
return;
}
const current = token;
loading = true;
try {
const batch = await getChatLinks(chatId, {
limit: PAGE,
offset: items.length,
});
if (current !== token) {
return;
}
items = [...items, ...batch];
if (batch.length < PAGE) {
done = true;
}
} finally {
if (current === token) {
loading = false;
}
}
}
$effect(() => {
const _id = chatId;
if (accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
items = [];
done = false;
loading = false;
loadMore().catch(() => undefined);
});
});
function open(messageId: number) {
ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`);
}
function host(url: string): string {
try {
return new URL(url).hostname.replace(WWW_PREFIX, "");
} catch {
return url;
}
}
</script>
{#if items.length === 0}
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Нет ссылок" />
{/if}
{:else}
<ul class="list">
{#each items as item (`${item.message_id}:${item.url}`)}
<li>
<button type="button" onclick={() => open(item.message_id)}>
<span class="link-icon"><Icon name="link" /></span>
<span class="meta">
<span class="title">{item.web_title ?? host(item.url)}</span>
<a
class="url"
href={item.url}
target="_blank"
rel="noopener"
onclick={(event) => event.stopPropagation()}
>
{item.url}
</a>
<span class="sub">
{item.web_site_name ?? host(item.url)}
{#if item.date}
· {formatListDate(item.date)}
{/if}
</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if !done && items.length > 0}
<button
type="button"
class="more"
onclick={() => loadMore()}
disabled={loading}
>
{loading ? "Загрузка…" : "Показать ещё"}
</button>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
.list > li > button {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
text-align: left;
cursor: pointer;
background: transparent;
&:hover {
background-color: var(--color-background-secondary);
}
}
.link-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title {
overflow: hidden;
font-size: 0.9375rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.url {
overflow: hidden;
font-size: 0.8125rem;
color: var(--color-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.more {
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font-size: 0.875rem;
color: var(--color-primary);
cursor: pointer;
background-color: var(--color-background-secondary);
}
</style>
@@ -0,0 +1,301 @@
<script lang="ts">
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 { MediaView } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatListDate } from "$lib/format/datetime";
import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
chatId: number;
kinds: string[];
layout: "grid" | "list";
}
let { chatId, kinds, layout }: Props = $props();
const PAGE = 60;
let items = $state<MediaView[]>([]);
let loading = $state(false);
let done = $state(false);
const previews = $state<Record<number, InlineMedia>>({});
let token = 0;
async function loadMore() {
if (loading || done) {
return;
}
const current = token;
loading = true;
try {
const batch = await getChatMedia(chatId, kinds, {
limit: PAGE,
offset: items.length,
});
if (current !== token) {
return;
}
items = [...items, ...batch];
if (batch.length < PAGE) {
done = true;
}
} finally {
if (current === token) {
loading = false;
}
}
}
$effect(() => {
const _id = chatId;
if (accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
items = [];
done = false;
loading = false;
for (const key of Object.keys(previews)) {
delete previews[Number(key)];
}
loadMore().catch(() => undefined);
});
});
$effect(() => {
if (layout !== "grid") {
return;
}
const list = items;
let active = true;
untrack(() => {
for (const item of list) {
if (!(item.downloaded && previews[item.id] === undefined)) {
continue;
}
previews[item.id] = {
state: "not-downloaded",
mediaId: item.id,
kind: item.kind,
};
loadMediaItem({
id: item.id,
message_id: item.message_id,
kind: item.kind,
downloaded: item.downloaded,
mime: item.mime,
file_size: item.file_size,
ttl_seconds: item.ttl_seconds,
duration: null,
height: null,
width: null,
}).then((result) => {
if (active) {
previews[item.id] = result;
}
});
}
});
return () => {
active = false;
};
});
function open(messageId: number) {
ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`);
}
function preview(item: MediaView): InlineMedia | undefined {
return previews[item.id];
}
</script>
{#if items.length === 0}
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Пусто" />
{/if}
{:else if layout === "grid"}
<div class="grid">
{#each items as item (item.id)}
<button type="button" class="tile" onclick={() => open(item.message_id)}>
{#if preview(item)?.state === "ready"}
{@const ready = preview(item) as Extract<InlineMedia, { state: "ready" }>}
{#if visualKind(item.kind) === "video"}
<video src={ready.url} muted preload="metadata"></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>
{/each}
</div>
{:else}
<ul class="list">
{#each items as item (item.id)}
<li>
<button type="button" onclick={() => open(item.message_id)}>
<span class="file-icon"><Icon name="document" /></span>
<span class="meta">
<span class="name">{mediaKindLabel(item.kind)}</span>
<span class="sub">
{formatListDate(item.created_at)}
{#if item.file_size}
· {formatBytes(item.file_size)}
{/if}
</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if !done && items.length > 0}
<button
type="button"
class="more"
onclick={() => loadMore()}
disabled={loading}
>
{loading ? "Загрузка…" : "Показать ещё"}
</button>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.125rem;
padding: 0.125rem;
}
.tile {
position: relative;
aspect-ratio: 1;
padding: 0;
border: 0;
cursor: pointer;
background-color: var(--color-background-secondary);
}
.tile img,
.tile video {
width: 100%;
height: 100%;
object-fit: cover;
}
.play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
}
.ph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--color-text-secondary);
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
.list button {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
text-align: left;
cursor: pointer;
background: transparent;
&:hover {
background-color: var(--color-background-secondary);
}
}
.file-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.more {
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font-size: 0.875rem;
color: var(--color-primary);
cursor: pointer;
background-color: var(--color-background-secondary);
}
</style>
@@ -11,7 +11,6 @@
{ icon: "search", label: "Поиск" },
{ icon: "folder", label: "Папки" },
{ icon: "stats", label: "Presence и аналитика" },
{ icon: "unmute", label: "Алерты" },
];
</script>
@@ -48,6 +47,21 @@
label="Данные и хранилище"
onclick={() => ui.openPanel("jobs")}
/>
<SettingsItem
icon="play-story"
label="Сторис"
onclick={() => ui.openPanel("stories-all")}
/>
<SettingsItem
icon="eye"
label="Отслеживания"
onclick={() => ui.openPanel("watches")}
/>
<SettingsItem
icon="unmute"
label="Алерты"
onclick={() => ui.openPanel("alerts")}
/>
{#each features as feature (feature.label)}
<SettingsItem icon={feature.icon} label={feature.label} soon />
{/each}
@@ -0,0 +1,183 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import { getMessageCallbacks } from "$lib/api/endpoints";
import type { CallbackView } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
const HEX_PAIR = /.{1,2}/g;
const MIN_PRINTABLE = 32;
const TAB = 9;
const LF = 10;
const CR = 13;
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
let items = $state<CallbackView[]>([]);
let loading = $state(false);
let token = 0;
let copied = $state<number | null>(null);
let resetTimer: ReturnType<typeof setTimeout> | undefined;
function isPrintable(text: string): boolean {
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (code < MIN_PRINTABLE && code !== TAB && code !== LF && code !== CR) {
return false;
}
}
return true;
}
function decode(hex: string | null): string {
if (!hex) {
return "";
}
const pairs = hex.match(HEX_PAIR);
if (!pairs) {
return hex;
}
try {
const bytes = Uint8Array.from(pairs, (pair) => Number.parseInt(pair, 16));
const text = new TextDecoder().decode(bytes);
return isPrintable(text) ? text : hex;
} catch {
return hex;
}
}
async function copy(position: number, value: string): Promise<void> {
await navigator.clipboard.writeText(value);
copied = position;
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
copied = null;
}, 1500);
}
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid === null || mid === null || accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
const current = token;
loading = true;
items = [];
getMessageCallbacks(cid, mid)
.then((rows) => {
if (current === token) {
items = rows;
}
})
.finally(() => {
if (current === token) {
loading = false;
}
});
});
});
</script>
{#if chatId === null || messageId === null}
<EmptyState title="Callback-данные" description="Сообщение не выбрано" />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет callback-кнопок" />
{:else}
<ul class="list">
{#each items as item (item.position)}
{@const value = decode(item.data)}
<li>
<div class="meta">
<span class="label">{item.label ?? "—"}</span>
<span class="data">{value || "—"}</span>
</div>
{#if value}
<button
type="button"
class="copy"
onclick={() => copy(item.position, value)}
aria-label="Скопировать"
>
<Icon name={copied === item.position ? "check" : "copy"} />
</button>
{/if}
</li>
{/each}
</ul>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
gap: 0.25rem;
list-style: none;
}
.list > li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
background-color: var(--color-background-secondary);
}
.meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.label {
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.data {
overflow-wrap: anywhere;
font-family: var(--font-family-monospace, monospace);
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.copy {
display: flex;
flex-shrink: 0;
padding: 0.375rem;
border: 0;
border-radius: 0.5rem;
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
&:hover {
color: var(--color-primary);
background-color: var(--color-chat-hover);
}
}
</style>
@@ -0,0 +1,151 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import { getMessageLinks } from "$lib/api/endpoints";
import type { LinkView } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
const WWW_PREFIX = /^www\./;
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
let items = $state<LinkView[]>([]);
let loading = $state(false);
let token = 0;
function host(url: string): string {
try {
return new URL(url).hostname.replace(WWW_PREFIX, "");
} catch {
return url;
}
}
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid === null || mid === null || accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
const current = token;
loading = true;
items = [];
getMessageLinks(cid, mid)
.then((rows) => {
if (current === token) {
items = rows;
}
})
.finally(() => {
if (current === token) {
loading = false;
}
});
});
});
</script>
{#if chatId === null || messageId === null}
<EmptyState title="Ссылки" description="Сообщение не выбрано" />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет ссылок" />
{:else}
<ul class="list">
{#each items as item (item.position)}
<li>
<span class="link-icon"><Icon name="link" /></span>
<span class="meta">
{#if item.web_title}
<span class="title">{item.web_title}</span>
{/if}
<a class="url" href={item.url} target="_blank" rel="noopener">
{item.url}
</a>
{#if item.web_site_name || item.web_description}
<span class="sub">
{item.web_site_name ?? host(item.url)}
{#if item.web_description}
· {item.web_description}
{/if}
</span>
{/if}
</span>
</li>
{/each}
</ul>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
.list > li {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 0.5rem;
}
.link-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title {
overflow: hidden;
font-size: 0.9375rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.url {
overflow-wrap: anywhere;
font-size: 0.8125rem;
color: var(--color-primary);
}
.sub {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,193 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import { getMessageReactions } from "$lib/api/endpoints";
import type { ReactionView } from "$lib/api/types";
import CustomEmoji from "$lib/components/CustomEmoji.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
import { peerColorIndex, peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { ui } from "$lib/stores/ui.svelte";
const CUSTOM_PREFIX = "custom:";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
let items = $state<ReactionView[]>([]);
let loading = $state(false);
let token = 0;
const groups = $derived.by(() => {
const map = new Map<string, ReactionView[]>();
for (const item of items) {
const list = map.get(item.reaction);
if (list) {
list.push(item);
} else {
map.set(item.reaction, [item]);
}
}
return [...map.entries()];
});
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid === null || mid === null || accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
const current = token;
loading = true;
items = [];
getMessageReactions(cid, mid)
.then((rows) => {
if (current === token) {
items = rows;
peers.ensure(rows.map((row) => row.peer_id));
}
})
.finally(() => {
if (current === token) {
loading = false;
}
});
});
});
</script>
{#if chatId === null || messageId === null}
<EmptyState title="Реакции" description="Сообщение не выбрано" />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет реакций" />
{:else}
<div class="groups">
{#each groups as [ reaction, reactors ] (reaction)}
<div class="group">
<div class="group-head">
<span class="emoji">
{#if reaction.startsWith(CUSTOM_PREFIX)}
<CustomEmoji
id={reaction.slice(CUSTOM_PREFIX.length)}
size={1.5}
/>
{:else}
{reaction}
{/if}
</span>
<span class="count">{reactors.length}</span>
</div>
<ul class="reactors">
{#each reactors as reactor (`${reactor.peer_id}:${reactor.added_at}`)}
{@const peer = peers.get(reactor.peer_id)}
<li class:removed={reactor.removed_at !== null}>
<Avatar
name={peer ? peerName(peer) : String(reactor.peer_id)}
colorKey={peerColorIndex(reactor.peer_id)}
size={2.25}
avatar={{ kind: "peer", id: reactor.peer_id }}
hasAvatar={peer?.has_avatar ?? false}
/>
<span class="info">
<span class="name"
>{peer ? peerName(peer) : String(reactor.peer_id)}</span
>
<span class="time">
{#if reactor.removed_at}
снято · {formatFull(reactor.removed_at)}
{:else}
{formatFull(reactor.added_at)}
{/if}
</span>
</span>
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.groups {
display: flex;
flex-direction: column;
padding: 0.5rem;
gap: 0.5rem;
}
.group-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
font-weight: var(--font-weight-medium);
}
.emoji {
display: inline-flex;
align-items: center;
font-size: 1.25rem;
line-height: 1;
}
.count {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.reactors {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
}
.reactors > li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-radius: 0.5rem;
&.removed {
opacity: 0.55;
}
}
.info {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,286 @@
<script lang="ts">
import { untrack } from "svelte";
import { getPeers, getStories } from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Avatar from "$lib/components/ui/Avatar.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";
import { peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
interface Group {
hasAvatar: boolean;
kind: "peer" | "chat";
name: string;
peerId: number;
stories: StoryView[];
}
const FETCH_LIMIT = 500;
let groups = $state<Group[]>([]);
let loading = $state(false);
const previews = $state<Record<number, string | null>>({});
let token = 0;
let viewerOpen = $state(false);
let viewerIndex = $state(0);
let viewerItems = $state<StoryView[]>([]);
let viewerPeerId = $state(0);
async function load() {
const current = token;
loading = true;
try {
const stories = await getStories(0, { limit: FETCH_LIMIT });
if (current !== token) {
return;
}
const byPeer = new Map<number, StoryView[]>();
for (const story of stories) {
const bucket = byPeer.get(story.peer_id);
if (bucket) {
bucket.push(story);
} else {
byPeer.set(story.peer_id, [story]);
}
}
const peerIds = [...byPeer.keys()].filter((id) => id > 0);
const peers = await getPeers(peerIds);
if (current !== token) {
return;
}
const peerById = new Map(peers.map((peer) => [peer.peer_id, peer]));
groups = [...byPeer.entries()].map(([peerId, stories]) => {
if (peerId > 0) {
const peer = peerById.get(peerId) ?? null;
return {
peerId,
kind: "peer" as const,
name: peerName(peer) || `ID ${peerId}`,
hasAvatar: Boolean(peer?.has_avatar),
stories,
};
}
const chat = chats.byId(peerId);
return {
peerId,
kind: "chat" as const,
name: chat?.title ?? `Chat ${peerId}`,
hasAvatar: Boolean(chat?.has_avatar),
stories,
};
});
} finally {
if (current === token) {
loading = false;
}
}
}
$effect(() => {
if (accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
groups = [];
loading = false;
for (const key of Object.keys(previews)) {
delete previews[Number(key)];
}
load().catch(() => undefined);
});
});
$effect(() => {
const list = groups.flatMap((group) => group.stories);
let active = true;
untrack(() => {
for (const item of list) {
if (!item.downloaded || previews[item.story_id] !== undefined) {
continue;
}
previews[item.story_id] = null;
loadStoryMedia(item.peer_id, item.story_id).then((url) => {
if (active) {
previews[item.story_id] = url;
}
});
}
});
return () => {
active = false;
};
});
function openViewer(group: Group, index: number) {
viewerItems = group.stories;
viewerPeerId = group.peerId;
viewerIndex = index;
viewerOpen = true;
}
</script>
{#if groups.length === 0}
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Нет сторис" />
{/if}
{:else}
{#each groups as group (group.peerId)}
<section class="group">
<header class="group-head">
<Avatar
name={group.name}
colorKey={group.peerId}
size={2}
avatar={{ kind: group.kind, id: group.peerId }}
hasAvatar={group.hasAvatar}
/>
<span class="group-name">{group.name}</span>
<span class="group-count">{group.stories.length}</span>
</header>
<div class="grid">
{#each group.stories as item, index (item.story_id)}
<button
type="button"
class="tile"
class:expired={item.deleted}
onclick={() => openViewer(group, index)}
>
{#if previews[item.story_id]}
{#if item.media_kind === "video"}
<video
src={previews[item.story_id]}
muted
preload="metadata"
></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.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
{/each}
</div>
</section>
{/each}
<StoryViewer
peerId={viewerPeerId}
items={viewerItems}
bind:index={viewerIndex}
bind:open={viewerOpen}
/>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.group {
border-bottom: 1px solid var(--color-borders);
}
.group-head {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
}
.group-name {
flex: 1;
overflow: hidden;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.group-count {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.125rem;
padding: 0 0.125rem 0.125rem;
}
.tile {
position: relative;
aspect-ratio: 9 / 16;
padding: 0;
border: 0;
cursor: pointer;
background-color: var(--color-background-secondary);
&.expired {
opacity: 0.55;
}
}
.tile img,
.tile video {
width: 100%;
height: 100%;
object-fit: cover;
}
.play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
}
.ph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--color-text-secondary);
}
.badge {
position: absolute;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.25rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
color: var(--color-white);
background-color: rgb(0 0 0 / 45%);
}
.views {
bottom: 0.25rem;
left: 0.25rem;
}
</style>
@@ -0,0 +1,249 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import { getStories } from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import StoryViewer from "$lib/components/stories/StoryViewer.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";
import { accounts } from "$lib/stores/accounts.svelte";
const PAGE = 60;
const peerId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
let items = $state<StoryView[]>([]);
let loading = $state(false);
let done = $state(false);
const previews = $state<Record<number, string | null>>({});
let token = 0;
let viewerOpen = $state(false);
let viewerIndex = $state(0);
async function loadMore(id: number) {
if (loading || done) {
return;
}
const current = token;
loading = true;
try {
const batch = await getStories(id, { limit: PAGE, offset: items.length });
if (current !== token) {
return;
}
items = [...items, ...batch];
if (batch.length < PAGE) {
done = true;
}
} finally {
if (current === token) {
loading = false;
}
}
}
$effect(() => {
const id = peerId;
if (id === null || accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
items = [];
done = false;
loading = false;
for (const key of Object.keys(previews)) {
delete previews[Number(key)];
}
loadMore(id).catch(() => undefined);
});
});
$effect(() => {
const list = items;
let active = true;
untrack(() => {
for (const item of list) {
if (!item.downloaded || previews[item.story_id] !== undefined) {
continue;
}
previews[item.story_id] = null;
loadStoryMedia(item.peer_id, item.story_id).then((url) => {
if (active) {
previews[item.story_id] = url;
}
});
}
});
return () => {
active = false;
};
});
function openViewer(index: number) {
viewerIndex = index;
viewerOpen = true;
}
</script>
{#if peerId === null}
<EmptyState title="Сторис" description="Откройте чат" />
{:else if items.length === 0}
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Нет сторис" />
{/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
preload="metadata"
></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
{peerId}
{items}
bind:index={viewerIndex}
bind:open={viewerOpen}
/>
{/if}
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.125rem;
padding: 0.125rem;
}
.tile {
position: relative;
aspect-ratio: 9 / 16;
padding: 0;
border: 0;
cursor: pointer;
background-color: var(--color-background-secondary);
&.expired {
opacity: 0.55;
}
}
.tile img,
.tile video {
width: 100%;
height: 100%;
object-fit: cover;
}
.play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
}
.ph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--color-text-secondary);
}
.badge {
position: absolute;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.25rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
color: var(--color-white);
background-color: rgb(0 0 0 / 45%);
}
.pin {
top: 0.25rem;
left: 0.25rem;
}
.views {
bottom: 0.25rem;
left: 0.25rem;
}
.more {
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font-size: 0.875rem;
color: var(--color-primary);
cursor: pointer;
background-color: var(--color-background-secondary);
}
</style>
@@ -0,0 +1,389 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
interface Props {
index: number;
items: StoryView[];
open: boolean;
peerId: number;
}
let {
open = $bindable(),
index = $bindable(),
items,
peerId,
}: Props = $props();
const PHOTO_SECONDS = 6;
let url = $state<string | null>(null);
let loading = $state(false);
let ready = $state(false);
let videoProgress = $state(0);
let muted = $state(true);
let token = 0;
const story = $derived(items[index] ?? null);
const isVideo = $derived(story?.media_kind === "video");
function step(delta: number) {
const next = index + delta;
if (next < 0) {
return;
}
if (next >= items.length) {
open = false;
return;
}
index = next;
}
function advance() {
step(1);
}
async function load(item: StoryView) {
loading = true;
ready = false;
url = null;
videoProgress = 0;
const current = ++token;
const next = await loadStoryMedia(item.peer_id, item.story_id);
if (current === token) {
url = next;
loading = false;
}
}
function onVideoTime(event: Event) {
const video = event.currentTarget as HTMLVideoElement;
if (video.duration > 0) {
videoProgress = video.currentTime / video.duration;
}
}
function onkeydown(event: KeyboardEvent) {
if (!open) {
return;
}
if (event.key === "ArrowLeft") {
step(-1);
} else if (event.key === "ArrowRight") {
step(1);
}
}
$effect(() => {
const item = items[index];
const isOpen = open;
untrack(() => {
if (isOpen && item) {
load(item).catch(() => {
loading = false;
});
}
});
});
$effect(() => {
if (!open) {
untrack(() => {
url = null;
ready = false;
});
}
});
</script>
<svelte:window {onkeydown} />
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="story-overlay" />
<Dialog.Content class="story-content">
<Dialog.Title class="story-a11y-title">Сторис</Dialog.Title>
<div class="bars">
{#each items as item, i (item.story_id)}
<div class="bar">
{#if i < index}
<div class="fill full"></div>
{:else if i === index}
{#key index}
{#if ready && !isVideo}
<div
class="fill anim"
style="animation-duration: {PHOTO_SECONDS}s"
onanimationend={advance}
></div>
{:else if isVideo}
<div class="fill" style="width: {videoProgress * 100}%"></div>
{/if}
{/key}
{/if}
</div>
{/each}
</div>
<header class="story-head">
<span class="when"> {story?.date ? formatFull(story.date) : ""} </span>
<div class="head-actions">
{#if isVideo}
<button
type="button"
class="round"
aria-label={muted ? "Включить звук" : "Выключить звук"}
onclick={() => {
muted = !muted;
}}
>
<Icon name={muted ? "speaker-muted-story" : "speaker-story"} />
</button>
{/if}
<Dialog.Close class="round" aria-label="Закрыть">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
</div>
</header>
<div class="story-body">
{#if loading}
<Spinner color="white" />
{:else if url && isVideo}
<!-- biome-ignore lint/a11y/useMediaCaption: archived story has no captions -->
<video
class="media"
src={url}
autoplay
{muted}
playsinline
onloadeddata={() => {
ready = true;
}}
ontimeupdate={onVideoTime}
onended={advance}
></video>
{:else if url}
<img
class="media"
src={url}
alt=""
onload={() => {
ready = true;
}}
>
{:else}
<p class="missing">Медиа недоступно</p>
{/if}
</div>
{#if story?.caption}
<div class="caption">
<Icon name="story-caption" size="1rem" />
<span>{story.caption}</span>
</div>
{/if}
{#if story?.views}
<span class="view-count">
<Icon name="eye" size="1rem" />{story.views}
</span>
{/if}
<button
type="button"
class="tap prev"
aria-label="Назад"
onclick={() => step(-1)}
></button>
<button
type="button"
class="tap next"
aria-label="Вперёд"
onclick={() => step(1)}
></button>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
:global(.story-overlay) {
position: fixed;
inset: 0;
z-index: var(--z-media-viewer);
background-color: rgba(0, 0, 0, 0.92);
}
:global(.story-content) {
position: fixed;
inset: 0;
z-index: var(--z-media-viewer);
display: flex;
flex-direction: column;
outline: none;
}
:global(.story-a11y-title) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.bars {
display: flex;
gap: 0.25rem;
padding: 0.5rem 0.75rem 0;
z-index: 2;
}
.bar {
overflow: hidden;
flex: 1;
height: 2px;
border-radius: 2px;
background-color: rgba(255, 255, 255, 0.3);
}
.fill {
width: 0;
height: 100%;
background-color: var(--color-white);
&.full {
width: 100%;
}
&.anim {
animation: story-progress linear forwards;
}
}
@keyframes story-progress {
from {
width: 0;
}
to {
width: 100%;
}
}
.story-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
z-index: 2;
}
.when {
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.85);
}
.head-actions {
display: flex;
gap: 0.25rem;
}
.round {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: 50%;
color: var(--color-white);
background-color: rgba(255, 255, 255, 0.12);
}
.story-body {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
padding: 0 1rem;
}
.media {
max-width: min(90vw, 26rem);
max-height: 100%;
border-radius: var(--border-radius-default);
object-fit: contain;
}
.missing {
color: var(--color-white);
}
.caption {
display: flex;
gap: 0.5rem;
max-width: min(90vw, 26rem);
margin: 0 auto;
padding: 0.75rem 1rem;
font-size: 0.9375rem;
color: var(--color-white);
z-index: 2;
}
.view-count {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding-bottom: 0.75rem;
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.85);
z-index: 2;
}
.tap {
position: absolute;
top: 3.5rem;
bottom: 0;
width: 35%;
padding: 0;
border: 0;
cursor: pointer;
background: transparent;
&.prev {
left: 0;
}
&.next {
right: 0;
width: 65%;
}
}
</style>
@@ -0,0 +1,24 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import type { Snippet } from "svelte";
interface Props {
children: Snippet<[{ props: Record<string, unknown> }]>;
menu: Snippet;
}
const { children, menu }: Props = $props();
</script>
<ContextMenu.Root>
<ContextMenu.Trigger>
{#snippet child({ props })}
{@render children({ props })}
{/snippet}
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="bg-menu-content">
{@render menu()}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
@@ -0,0 +1,20 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import type { Snippet } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
children: Snippet;
icon?: string;
onselect: () => void;
}
const { icon, onselect, children }: Props = $props();
</script>
<ContextMenu.Item class="bg-menu-item" onSelect={onselect}>
{#if icon}
<Icon name={icon} size="1.125rem" />
{/if}
{@render children()}
</ContextMenu.Item>
@@ -0,0 +1,121 @@
<script lang="ts">
import Button from "../ui/Button.svelte";
interface Props {
onsave: (data: {
kind: string;
params: Record<string, unknown>;
enabled: boolean;
}) => void;
saving?: boolean;
}
const { saving = false, onsave }: Props = $props();
const KINDS = [
{ value: "keyword", label: "Keyword" },
{ value: "peer_online", label: "Peer online" },
];
let kind = $state("keyword");
let keyword = $state("");
let peerId = $state("");
let enabled = $state(true);
const isKeyword = $derived(kind === "keyword");
const valid = $derived(
isKeyword ? keyword.trim() !== "" : peerId.trim() !== ""
);
function submit() {
if (!valid) {
return;
}
const params: Record<string, unknown> = isKeyword
? { keyword: keyword.trim() }
: { peer_id: Number(peerId.trim()) };
onsave({ kind, params, enabled });
keyword = "";
peerId = "";
enabled = true;
}
</script>
<form
class="watch-editor"
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label class="field">
<span class="field-label">Type</span>
<select bind:value={kind}>
{#each KINDS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
{#if isKeyword}
<label class="field">
<span class="field-label">Keyword</span>
<input type="text" bind:value={keyword} placeholder="Text to match…">
</label>
{:else}
<label class="field">
<span class="field-label">Peer ID</span>
<input
type="text"
inputmode="numeric"
bind:value={peerId}
placeholder="123456789"
>
</label>
{/if}
<label class="field-inline">
<input type="checkbox" bind:checked={enabled}>
<span>Enabled</span>
</label>
<Button type="submit" disabled={saving || !valid}>Add watch</Button>
</form>
<style lang="scss">
.watch-editor {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.field-inline {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type="text"],
select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-borders);
border-radius: 0.5rem;
background: var(--color-background);
color: var(--color-text);
}
</style>
@@ -0,0 +1,111 @@
<script lang="ts">
import type { Watch } from "$lib/api/types";
import { peerName } from "$lib/format/peer";
import { peers } from "$lib/stores/peers.svelte";
import Button from "../ui/Button.svelte";
import Icon from "../ui/Icon.svelte";
interface Props {
ondelete: (watch: Watch) => void;
ontoggle: (watch: Watch) => void;
watches: Watch[];
}
const { watches, ontoggle, ondelete }: Props = $props();
$effect(() => {
const ids = watches
.filter((w) => w.kind === "peer_online")
.map((w) => Number(w.params.peer_id))
.filter((id) => Number.isFinite(id));
peers.ensure(ids);
});
function summary(watch: Watch): string {
if (watch.kind === "keyword") {
return `“${String(watch.params.keyword ?? "")}”`;
}
const id = Number(watch.params.peer_id);
return peerName(peers.get(id) ?? null) || `#${id}`;
}
</script>
<ul class="watch-list">
{#each watches as watch (watch.id)}
<li class="watch-item" class:disabled={!watch.enabled}>
<div class="watch-main">
<span class="watch-kind">{watch.kind}</span>
<span class="watch-summary">{summary(watch)}</span>
</div>
<div class="row-actions">
<Button
variant="translucent"
round
smaller
onclick={() => ontoggle(watch)}
aria-label="Переключить"
>
<Icon name={watch.enabled ? "eye" : "eye-crossed"} />
</Button>
<Button
variant="translucent"
round
smaller
onclick={() => ondelete(watch)}
aria-label="Удалить"
>
<Icon name="delete" />
</Button>
</div>
</li>
{/each}
</ul>
<style lang="scss">
.watch-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.watch-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
&.disabled {
opacity: 0.55;
}
}
.watch-main {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.watch-kind {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.watch-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions {
display: flex;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,80 @@
<script lang="ts">
import {
createWatch,
deleteWatch,
listWatches,
updateWatch,
} from "$lib/api/endpoints";
import type { Watch } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
import Spinner from "../ui/Spinner.svelte";
import WatchEditor from "./WatchEditor.svelte";
import WatchList from "./WatchList.svelte";
let watches = $state<Watch[]>([]);
let loading = $state(true);
let saving = $state(false);
async function load(_account: number | null) {
loading = true;
try {
watches = await listWatches();
} finally {
loading = false;
}
}
$effect(() => {
load(accounts.selectedId);
});
async function add(data: {
kind: string;
params: Record<string, unknown>;
enabled: boolean;
}) {
saving = true;
try {
const created = await createWatch(data.kind, data.params, data.enabled);
watches = [created, ...watches];
} finally {
saving = false;
}
}
async function toggle(watch: Watch) {
const updated = await updateWatch(watch.id, watch.params, !watch.enabled);
watches = watches.map((w) => (w.id === updated.id ? updated : w));
}
async function remove(watch: Watch) {
await deleteWatch(watch.id);
watches = watches.filter((w) => w.id !== watch.id);
}
</script>
<div class="panel-content">
<WatchEditor {saving} onsave={add} />
{#if loading}
<Spinner />
{:else if watches.length === 0}
<p class="empty">Пока нет отслеживаний.</p>
{:else}
<WatchList {watches} ontoggle={toggle} ondelete={remove} />
{/if}
</div>
<style lang="scss">
.panel-content {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.empty {
color: var(--color-text-secondary);
text-align: center;
}
</style>
+18
View File
@@ -23,3 +23,21 @@ export function mediaKindLabel(kind: string | null): string | null {
}
return MEDIA_KIND_LABELS[kind] ?? "Media";
}
const BYTE_UNITS = ["B", "KB", "MB", "GB"];
const BYTE_STEP = 1024;
export function formatBytes(bytes: number | null): string {
if (bytes === null || bytes <= 0) {
return "";
}
let value = bytes;
let unit = 0;
while (value >= BYTE_STEP && unit < BYTE_UNITS.length - 1) {
value /= BYTE_STEP;
unit++;
}
const rounded =
value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1);
return `${rounded} ${BYTE_UNITS[unit]}`;
}
+15 -1
View File
@@ -3,12 +3,16 @@ export type RightPanel =
| "search"
| "versions"
| "reactions"
| "callbacks"
| "links"
| "annotations"
| "jobs"
| "presence"
| "stories"
| "policy";
| "stories-all"
| "policy"
| "watches"
| "alerts";
export type LeftView = "main" | "settings";
@@ -19,6 +23,7 @@ interface JumpTarget {
function createUi() {
let rightPanel = $state<RightPanel | null>(null);
let panelMessageId = $state<number | null>(null);
let leftColumnOpen = $state(true);
let leftView = $state<LeftView>("main");
let jumpTarget = $state<JumpTarget | null>(null);
@@ -27,6 +32,9 @@ function createUi() {
get rightPanel() {
return rightPanel;
},
get panelMessageId() {
return panelMessageId;
},
get leftColumnOpen() {
return leftColumnOpen;
},
@@ -50,9 +58,15 @@ function createUi() {
},
openPanel(panel: RightPanel) {
rightPanel = panel;
panelMessageId = null;
},
openMessagePanel(panel: RightPanel, messageId: number) {
rightPanel = panel;
panelMessageId = messageId;
},
closePanel() {
rightPanel = null;
panelMessageId = null;
},
toggleLeftColumn() {
leftColumnOpen = !leftColumnOpen;