feat: add annotations, user profiles, watchers, stories, search and more
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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>
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user