diff --git a/backend/src/api/app.py b/backend/src/api/app.py index 99a839b..7a8d3e8 100644 --- a/backend/src/api/app.py +++ b/backend/src/api/app.py @@ -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) diff --git a/backend/src/api/routers/avatars.py b/backend/src/api/routers/avatars.py index 1121bea..dcc6b37 100644 --- a/backend/src/api/routers/avatars.py +++ b/backend/src/api/routers/avatars.py @@ -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: diff --git a/backend/src/api/routers/chats.py b/backend/src/api/routers/chats.py index 364b604..5b9856e 100644 --- a/backend/src/api/routers/chats.py +++ b/backend/src/api/routers/chats.py @@ -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, ) diff --git a/backend/src/api/routers/peers.py b/backend/src/api/routers/peers.py index 25d0ef1..68b15bb 100644 --- a/backend/src/api/routers/peers.py +++ b/backend/src/api/routers/peers.py @@ -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 - ) diff --git a/backend/src/api/routers/profile.py b/backend/src/api/routers/profile.py new file mode 100644 index 0000000..fcd15aa --- /dev/null +++ b/backend/src/api/routers/profile.py @@ -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 diff --git a/backend/src/api/routers/stories.py b/backend/src/api/routers/stories.py new file mode 100644 index 0000000..b1e16f4 --- /dev/null +++ b/backend/src/api/routers/stories.py @@ -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"), + ) diff --git a/backend/src/utils/read/avatars.py b/backend/src/utils/read/avatars.py index 4ba375c..dfc9fb9 100644 --- a/backend/src/utils/read/avatars.py +++ b/backend/src/utils/read/avatars.py @@ -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] diff --git a/backend/src/utils/read/chats.py b/backend/src/utils/read/chats.py index fc67feb..bdd3da5 100644 --- a/backend/src/utils/read/chats.py +++ b/backend/src/utils/read/chats.py @@ -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] = [] diff --git a/backend/src/utils/read/models.py b/backend/src/utils/read/models.py index fc2c401..2431e9c 100644 --- a/backend/src/utils/read/models.py +++ b/backend/src/utils/read/models.py @@ -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 diff --git a/backend/src/utils/read/peers.py b/backend/src/utils/read/peers.py index d453cbc..662a8da 100644 --- a/backend/src/utils/read/peers.py +++ b/backend/src/utils/read/peers.py @@ -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 diff --git a/backend/src/utils/read/profile.py b/backend/src/utils/read/profile.py new file mode 100644 index 0000000..095e962 --- /dev/null +++ b/backend/src/utils/read/profile.py @@ -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 diff --git a/frontend/src/lib/api/avatars.ts b/frontend/src/lib/api/avatars.ts index 42afd29..aa96226 100644 --- a/frontend/src/lib/api/avatars.ts +++ b/frontend/src/lib/api/avatars.ts @@ -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 { + 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 { + 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; +} diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 20a6dd5..f9fc880 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -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 { return request(`/chats/${chatId}/messages`, { account: true, @@ -159,6 +175,90 @@ export function getPeer(peerId: number): Promise { return request(`/peers/${peerId}`, { account: true }); } +export function getPeerHistory(peerId: number): Promise { + return request(`/peers/${peerId}/history`, { + account: true, + }); +} + +export function getAvatarHistory( + kind: "peer" | "chat", + id: number +): Promise { + return request(`/avatars/${kind}/${id}/history`, { + account: true, + }); +} + +export function getChatMedia( + chatId: number, + kinds: string[], + page: { limit?: number; offset?: number } = {} +): Promise { + return request(`/chats/${chatId}/media`, { + account: true, + query: { kinds: kinds.join(","), ...page }, + }); +} + +export function getChatLinks( + chatId: number, + page: { limit?: number; offset?: number } = {} +): Promise { + return request(`/chats/${chatId}/links`, { + account: true, + query: { ...page }, + }); +} + +export function getMessageReactions( + chatId: number, + messageId: number +): Promise { + return request(`/messages/${chatId}/${messageId}/reactions`, { + account: true, + }); +} + +export function getMessageCallbacks( + chatId: number, + messageId: number +): Promise { + return request(`/messages/${chatId}/${messageId}/callbacks`, { + account: true, + }); +} + +export function getMessageLinks( + chatId: number, + messageId: number +): Promise { + return request(`/messages/${chatId}/${messageId}/links`, { + account: true, + }); +} + +export function getChatCalendar(chatId: number): Promise { + return request(`/chats/${chatId}/calendar`, { account: true }); +} + +export function getMessageAt(chatId: number, date: string): Promise { + return request(`/chats/${chatId}/message-at`, { + account: true, + query: { date }, + }); +} + +export function getStories( + peerId: number, + page: Page = {} +): Promise { + return request("/stories", { + account: true, + query: { peer_id: peerId, ...page }, + }); +} + export function getPeers(ids: number[]): Promise { if (ids.length === 0) { return Promise.resolve([]); @@ -249,3 +349,82 @@ export function fetchMedia( }, }); } + +export function listWatches(): Promise { + return request("/watches", { account: true }); +} + +export function createWatch( + kind: string, + params: Record, + enabled: boolean +): Promise { + return request("/watches", { + method: "POST", + body: { account_id: accounts.selectedId, kind, params, enabled }, + }); +} + +export function updateWatch( + id: number, + params: Record, + enabled: boolean +): Promise { + return request(`/watches/${id}`, { + method: "PUT", + body: { params, enabled }, + }); +} + +export function deleteWatch(id: number): Promise { + return request(`/watches/${id}`, { method: "DELETE" }); +} + +export function listAlerts( + options: Page & { seen?: boolean } = {} +): Promise { + return request("/alerts", { account: true, query: { ...options } }); +} + +export function markAlertSeen(id: number): Promise { + return request(`/alerts/${id}/seen`, { method: "POST" }); +} + +export function listAnnotations( + options: { chatId?: number; messageId?: number } = {} +): Promise { + return request("/annotations", { + account: true, + query: { chat_id: options.chatId, message_id: options.messageId }, + }); +} + +export function createAnnotation( + chatId: number, + messageId: number, + text: string +): Promise { + return request("/annotations", { + method: "POST", + body: { + account_id: accounts.selectedId, + chat_id: chatId, + message_id: messageId, + text, + }, + }); +} + +export function updateAnnotation( + id: number, + text: string +): Promise { + return request(`/annotations/${id}`, { + method: "PUT", + body: { text }, + }); +} + +export function deleteAnnotation(id: number): Promise { + return request(`/annotations/${id}`, { method: "DELETE" }); +} diff --git a/frontend/src/lib/api/stories.ts b/frontend/src/lib/api/stories.ts new file mode 100644 index 0000000..bf82d4d --- /dev/null +++ b/frontend/src/lib/api/stories.ts @@ -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(); +const missing = new Set(); +const inflight = new Map>(); + +function authHeaders(): Record { + return auth.token ? { Authorization: `Bearer ${auth.token}` } : {}; +} + +async function fetchStoryMedia( + account: number, + peerId: number, + storyId: number, + key: string +): Promise { + 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 { + 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; +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 4232542..927a097 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -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; diff --git a/frontend/src/lib/components/ChatHeader.svelte b/frontend/src/lib/components/ChatHeader.svelte index 30d05ac..e63d02e 100644 --- a/frontend/src/lib/components/ChatHeader.svelte +++ b/frontend/src/lib/components/ChatHeader.svelte @@ -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 @@
- -
-

{title}

- - {subtitle} - -
+ + {#snippet children({ props })} + + {/snippet} + {#snippet menu()} + ui.openPanel("profile")} + >Открыть профиль + ui.openPanel("stories")} + >Сторис + {/snippet} +
+ + {#snippet children({ props })} + + {/snippet} + {#snippet menu()} + Открыть + openWith("profile")} + >Профиль + openWith("search")} + >Поиск в чате + openWith("presence")} + >Аналитика + openWith("stories")} + >Сторис + {/snippet} + diff --git a/frontend/src/lib/components/alerts/AlertsPanel.svelte b/frontend/src/lib/components/alerts/AlertsPanel.svelte new file mode 100644 index 0000000..133ff11 --- /dev/null +++ b/frontend/src/lib/components/alerts/AlertsPanel.svelte @@ -0,0 +1,72 @@ + + +
+ + + {#if loading} + + {:else if alerts.length === 0} +

Алертов нет.

+ {:else} + + {/if} +
+ + diff --git a/frontend/src/lib/components/annotations/AnnotationEditor.svelte b/frontend/src/lib/components/annotations/AnnotationEditor.svelte new file mode 100644 index 0000000..cdea49a --- /dev/null +++ b/frontend/src/lib/components/annotations/AnnotationEditor.svelte @@ -0,0 +1,81 @@ + + +
{ + e.preventDefault(); + submit(); + }} +> + +
+ {#if oncancel} + + {/if} + +
+
+ + diff --git a/frontend/src/lib/components/annotations/AnnotationList.svelte b/frontend/src/lib/components/annotations/AnnotationList.svelte new file mode 100644 index 0000000..7c50636 --- /dev/null +++ b/frontend/src/lib/components/annotations/AnnotationList.svelte @@ -0,0 +1,113 @@ + + +
    + {#each annotations as annotation (annotation.id)} +
  • + {#if editingId === annotation.id} + { + editingId = null; + }} + onsubmit={(text) => { + onupdate(annotation, text); + editingId = null; + }} + /> + {:else} +

    {annotation.text}

    +
    + {formatFull(annotation.updated_at)} + + +
    + {/if} +
  • + {/each} +
+ + diff --git a/frontend/src/lib/components/annotations/AnnotationsPanel.svelte b/frontend/src/lib/components/annotations/AnnotationsPanel.svelte new file mode 100644 index 0000000..d99be64 --- /dev/null +++ b/frontend/src/lib/components/annotations/AnnotationsPanel.svelte @@ -0,0 +1,104 @@ + + +{#if chatId === null || messageId === null} + +{:else} +
+ + + {#if loading && items.length === 0} +
+ {:else if items.length > 0} + + {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/profile/AvatarHistory.svelte b/frontend/src/lib/components/profile/AvatarHistory.svelte new file mode 100644 index 0000000..9056523 --- /dev/null +++ b/frontend/src/lib/components/profile/AvatarHistory.svelte @@ -0,0 +1,113 @@ + + +{#if items.length > 0} +
+

История аватарок ({items.length})

+
+ {#each items as item (item.unique_id)} +
+ {#if urls[item.unique_id]} + {name} + {:else} +
+ {/if} +
+ {/each} +
+
+{/if} + + diff --git a/frontend/src/lib/components/profile/ChatCalendar.svelte b/frontend/src/lib/components/profile/ChatCalendar.svelte new file mode 100644 index 0000000..95060b4 --- /dev/null +++ b/frontend/src/lib/components/profile/ChatCalendar.svelte @@ -0,0 +1,371 @@ + + +{#if loading && days.length === 0} +
+{:else if !view} + +{:else} +
+ + +
+ {#each WEEKDAYS as weekday (weekday)} + {weekday} + {/each} +
+ +
+ {#each blanks as blank (blank)} + + {/each} + {#each cells as cell (cell.key)} + {#if cell.count > 0} + + {:else} + {cell.day} + {/if} + {/each} +
+
+{/if} + + diff --git a/frontend/src/lib/components/profile/ProfileInfo.svelte b/frontend/src/lib/components/profile/ProfileInfo.svelte new file mode 100644 index 0000000..311ec4d --- /dev/null +++ b/frontend/src/lib/components/profile/ProfileInfo.svelte @@ -0,0 +1,287 @@ + + +
+ {#if rows.length > 0} +
+
+ {#each rows as row (row.label)} +
+
{row.label}
+
{row.value}
+
+ {/each} +
+
+ {/if} + + {#if storyCount > 0} +
+ +
+ {/if} + + + + {#if changes.length > 1} +
+

История профиля

+
    + {#each changes as entry (entry.observed_at)} +
  • + {entryName(entry)} + {formatFull(entry.observed_at)} +
  • + {/each} +
+
+ {/if} +
+ + diff --git a/frontend/src/lib/components/profile/ProfilePanel.svelte b/frontend/src/lib/components/profile/ProfilePanel.svelte new file mode 100644 index 0000000..76f5171 --- /dev/null +++ b/frontend/src/lib/components/profile/ProfilePanel.svelte @@ -0,0 +1,183 @@ + + +{#if chatId === null} + +{:else} +
+
+ +

{title}

+ {subtitle} +
+ + + +
+ {#if tab === "info"} + + {:else if tab === "media"} + + {:else if tab === "files"} + + {:else if tab === "links"} + + {:else if tab === "calendar"} + + {/if} +
+
+{/if} + + diff --git a/frontend/src/lib/components/profile/SharedLinks.svelte b/frontend/src/lib/components/profile/SharedLinks.svelte new file mode 100644 index 0000000..3cc9215 --- /dev/null +++ b/frontend/src/lib/components/profile/SharedLinks.svelte @@ -0,0 +1,216 @@ + + +{#if items.length === 0} + {#if loading} +
+ {:else} + + {/if} +{:else} +
    + {#each items as item (`${item.message_id}:${item.url}`)} +
  • + +
  • + {/each} +
+{/if} + +{#if !done && items.length > 0} + +{/if} + + diff --git a/frontend/src/lib/components/profile/SharedMedia.svelte b/frontend/src/lib/components/profile/SharedMedia.svelte new file mode 100644 index 0000000..4f3002a --- /dev/null +++ b/frontend/src/lib/components/profile/SharedMedia.svelte @@ -0,0 +1,301 @@ + + +{#if items.length === 0} + {#if loading} +
+ {:else} + + {/if} +{:else if layout === "grid"} +
+ {#each items as item (item.id)} + + {/each} +
+{:else} +
    + {#each items as item (item.id)} +
  • + +
  • + {/each} +
+{/if} + +{#if !done && items.length > 0} + +{/if} + + diff --git a/frontend/src/lib/components/settings/Settings.svelte b/frontend/src/lib/components/settings/Settings.svelte index 5587c6e..b16d65c 100644 --- a/frontend/src/lib/components/settings/Settings.svelte +++ b/frontend/src/lib/components/settings/Settings.svelte @@ -11,7 +11,6 @@ { icon: "search", label: "Поиск" }, { icon: "folder", label: "Папки" }, { icon: "stats", label: "Presence и аналитика" }, - { icon: "unmute", label: "Алерты" }, ]; @@ -48,6 +47,21 @@ label="Данные и хранилище" onclick={() => ui.openPanel("jobs")} /> + ui.openPanel("stories-all")} + /> + ui.openPanel("watches")} + /> + ui.openPanel("alerts")} + /> {#each features as feature (feature.label)} {/each} diff --git a/frontend/src/lib/components/social/CallbacksPanel.svelte b/frontend/src/lib/components/social/CallbacksPanel.svelte new file mode 100644 index 0000000..4285322 --- /dev/null +++ b/frontend/src/lib/components/social/CallbacksPanel.svelte @@ -0,0 +1,183 @@ + + +{#if chatId === null || messageId === null} + +{:else if loading && items.length === 0} +
+{:else if items.length === 0} + +{:else} +
    + {#each items as item (item.position)} + {@const value = decode(item.data)} +
  • +
    + {item.label ?? "—"} + {value || "—"} +
    + {#if value} + + {/if} +
  • + {/each} +
+{/if} + + diff --git a/frontend/src/lib/components/social/LinksPanel.svelte b/frontend/src/lib/components/social/LinksPanel.svelte new file mode 100644 index 0000000..2eb015a --- /dev/null +++ b/frontend/src/lib/components/social/LinksPanel.svelte @@ -0,0 +1,151 @@ + + +{#if chatId === null || messageId === null} + +{:else if loading && items.length === 0} +
+{:else if items.length === 0} + +{:else} +
    + {#each items as item (item.position)} +
  • + + + {#if item.web_title} + {item.web_title} + {/if} + + {item.url} + + {#if item.web_site_name || item.web_description} + + {item.web_site_name ?? host(item.url)} + {#if item.web_description} + · {item.web_description} + {/if} + + {/if} + +
  • + {/each} +
+{/if} + + diff --git a/frontend/src/lib/components/social/ReactionsPanel.svelte b/frontend/src/lib/components/social/ReactionsPanel.svelte new file mode 100644 index 0000000..13ff84c --- /dev/null +++ b/frontend/src/lib/components/social/ReactionsPanel.svelte @@ -0,0 +1,193 @@ + + +{#if chatId === null || messageId === null} + +{:else if loading && items.length === 0} +
+{:else if items.length === 0} + +{:else} +
+ {#each groups as [ reaction, reactors ] (reaction)} +
+
+ + {#if reaction.startsWith(CUSTOM_PREFIX)} + + {:else} + {reaction} + {/if} + + {reactors.length} +
+
    + {#each reactors as reactor (`${reactor.peer_id}:${reactor.added_at}`)} + {@const peer = peers.get(reactor.peer_id)} +
  • + + + {peer ? peerName(peer) : String(reactor.peer_id)} + + {#if reactor.removed_at} + снято · {formatFull(reactor.removed_at)} + {:else} + {formatFull(reactor.added_at)} + {/if} + + +
  • + {/each} +
+
+ {/each} +
+{/if} + + diff --git a/frontend/src/lib/components/stories/AllStoriesArchive.svelte b/frontend/src/lib/components/stories/AllStoriesArchive.svelte new file mode 100644 index 0000000..f684b40 --- /dev/null +++ b/frontend/src/lib/components/stories/AllStoriesArchive.svelte @@ -0,0 +1,286 @@ + + +{#if groups.length === 0} + {#if loading} +
+ {:else} + + {/if} +{:else} + {#each groups as group (group.peerId)} +
+
+ + {group.name} + {group.stories.length} +
+
+ {#each group.stories as item, index (item.story_id)} + + {/each} +
+
+ {/each} + + +{/if} + + diff --git a/frontend/src/lib/components/stories/StoriesArchive.svelte b/frontend/src/lib/components/stories/StoriesArchive.svelte new file mode 100644 index 0000000..1c4c5d5 --- /dev/null +++ b/frontend/src/lib/components/stories/StoriesArchive.svelte @@ -0,0 +1,249 @@ + + +{#if peerId === null} + +{:else if items.length === 0} + {#if loading} +
+ {:else} + + {/if} +{:else} +
+ {#each items as item, index (item.story_id)} + + {/each} +
+ + {#if !done} + + {/if} + + {#if peerId !== null} + + {/if} +{/if} + + diff --git a/frontend/src/lib/components/stories/StoryViewer.svelte b/frontend/src/lib/components/stories/StoryViewer.svelte new file mode 100644 index 0000000..4cad6b0 --- /dev/null +++ b/frontend/src/lib/components/stories/StoryViewer.svelte @@ -0,0 +1,389 @@ + + + + + + + + + Сторис +
+ {#each items as item, i (item.story_id)} +
+ {#if i < index} +
+ {:else if i === index} + {#key index} + {#if ready && !isVideo} +
+ {:else if isVideo} +
+ {/if} + {/key} + {/if} +
+ {/each} +
+ +
+ {story?.date ? formatFull(story.date) : ""} +
+ {#if isVideo} + + {/if} + + + +
+
+ +
+ {#if loading} + + {:else if url && isVideo} + + + {:else if url} + { + ready = true; + }} + > + {:else} +

Медиа недоступно

+ {/if} +
+ + {#if story?.caption} +
+ + {story.caption} +
+ {/if} + + {#if story?.views} + + {story.views} + + {/if} + + + +
+
+
+ + diff --git a/frontend/src/lib/components/ui/ContextMenu.svelte b/frontend/src/lib/components/ui/ContextMenu.svelte new file mode 100644 index 0000000..611b34e --- /dev/null +++ b/frontend/src/lib/components/ui/ContextMenu.svelte @@ -0,0 +1,24 @@ + + + + + {#snippet child({ props })} + {@render children({ props })} + {/snippet} + + + + {@render menu()} + + + diff --git a/frontend/src/lib/components/ui/ContextMenuItem.svelte b/frontend/src/lib/components/ui/ContextMenuItem.svelte new file mode 100644 index 0000000..1020fd8 --- /dev/null +++ b/frontend/src/lib/components/ui/ContextMenuItem.svelte @@ -0,0 +1,20 @@ + + + + {#if icon} + + {/if} + {@render children()} + diff --git a/frontend/src/lib/components/watches/WatchEditor.svelte b/frontend/src/lib/components/watches/WatchEditor.svelte new file mode 100644 index 0000000..75a542a --- /dev/null +++ b/frontend/src/lib/components/watches/WatchEditor.svelte @@ -0,0 +1,121 @@ + + +
{ + e.preventDefault(); + submit(); + }} +> + + + {#if isKeyword} + + {:else} + + {/if} + + + + +
+ + diff --git a/frontend/src/lib/components/watches/WatchList.svelte b/frontend/src/lib/components/watches/WatchList.svelte new file mode 100644 index 0000000..58cd203 --- /dev/null +++ b/frontend/src/lib/components/watches/WatchList.svelte @@ -0,0 +1,111 @@ + + +
    + {#each watches as watch (watch.id)} +
  • +
    + {watch.kind} + {summary(watch)} +
    +
    + + +
    +
  • + {/each} +
+ + diff --git a/frontend/src/lib/components/watches/WatchesPanel.svelte b/frontend/src/lib/components/watches/WatchesPanel.svelte new file mode 100644 index 0000000..1b75ca4 --- /dev/null +++ b/frontend/src/lib/components/watches/WatchesPanel.svelte @@ -0,0 +1,80 @@ + + +
+ + + {#if loading} + + {:else if watches.length === 0} +

Пока нет отслеживаний.

+ {:else} + + {/if} +
+ + diff --git a/frontend/src/lib/format/media.ts b/frontend/src/lib/format/media.ts index db4c13c..bcee8cf 100644 --- a/frontend/src/lib/format/media.ts +++ b/frontend/src/lib/format/media.ts @@ -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]}`; +} diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index f053ceb..7a718ec 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -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(null); + let panelMessageId = $state(null); let leftColumnOpen = $state(true); let leftView = $state("main"); let jumpTarget = $state(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;