feat: add annotations, user profiles, watchers, stories, search and more
This commit is contained in:
@@ -26,8 +26,10 @@ from api.routers import (
|
|||||||
peers,
|
peers,
|
||||||
policy,
|
policy,
|
||||||
presence,
|
presence,
|
||||||
|
profile,
|
||||||
search,
|
search,
|
||||||
social,
|
social,
|
||||||
|
stories,
|
||||||
watches,
|
watches,
|
||||||
)
|
)
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
@@ -77,6 +79,8 @@ app.include_router(avatars.router)
|
|||||||
app.include_router(custom_emoji.router)
|
app.include_router(custom_emoji.router)
|
||||||
app.include_router(social.router)
|
app.include_router(social.router)
|
||||||
app.include_router(presence.router)
|
app.include_router(presence.router)
|
||||||
|
app.include_router(stories.router)
|
||||||
|
app.include_router(profile.router)
|
||||||
app.include_router(events.router)
|
app.include_router(events.router)
|
||||||
app.include_router(peers.router)
|
app.include_router(peers.router)
|
||||||
app.include_router(annotations.router)
|
app.include_router(annotations.router)
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ from fastapi import APIRouter, HTTPException, Query
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from utils.jobs import enqueue
|
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
|
from utils.storage import ContentAddressedStorage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/avatars", tags=["avatars"], route_class=DishkaRoute)
|
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}")
|
@router.get("/{owner_kind}/{owner_id}")
|
||||||
async def serve_avatar(
|
async def serve_avatar(
|
||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
@@ -19,8 +30,13 @@ async def serve_avatar(
|
|||||||
owner_kind: str,
|
owner_kind: str,
|
||||||
owner_id: int,
|
owner_id: int,
|
||||||
account_id: Annotated[int, Query()],
|
account_id: Annotated[int, Query()],
|
||||||
|
unique_id: Annotated[str | None, Query()] = None,
|
||||||
) -> FileResponse:
|
) -> 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:
|
if avatar is None:
|
||||||
raise HTTPException(status_code=404, detail="avatar not found")
|
raise HTTPException(status_code=404, detail="avatar not found")
|
||||||
if not avatar.downloaded or avatar.storage_key is None:
|
if not avatar.downloaded or avatar.storage_key is None:
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ async def chat_history(
|
|||||||
limit: Limit = DEFAULT_LIMIT,
|
limit: Limit = DEFAULT_LIMIT,
|
||||||
offset: Offset = 0,
|
offset: Offset = 0,
|
||||||
include_deleted: Annotated[bool, Query()] = True,
|
include_deleted: Annotated[bool, Query()] = True,
|
||||||
|
before_id: Annotated[int | None, Query()] = None,
|
||||||
|
after_id: Annotated[int | None, Query()] = None,
|
||||||
) -> list[MessageView]:
|
) -> list[MessageView]:
|
||||||
return await chats.get_chat_history(
|
return await chats.get_chat_history(
|
||||||
pool,
|
pool,
|
||||||
@@ -54,6 +56,8 @@ async def chat_history(
|
|||||||
chat_id,
|
chat_id,
|
||||||
Page(limit=limit, offset=offset),
|
Page(limit=limit, offset=offset),
|
||||||
include_deleted=include_deleted,
|
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 fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from utils.read import peers
|
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)
|
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
|
pool: FromDishka[asyncpg.Pool], peer_id: int, account_id: AccountId
|
||||||
) -> list[PeerHistoryView]:
|
) -> list[PeerHistoryView]:
|
||||||
return await peers.get_peer_history(pool, account_id, peer_id)
|
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
|
import asyncpg
|
||||||
|
|
||||||
from utils.read.models import AvatarRef
|
from utils.read.models import AvatarHistoryView, AvatarRef
|
||||||
|
|
||||||
_PEER_UNIQUE_ID = """
|
_PEER_UNIQUE_ID = """
|
||||||
SELECT photo_unique_id FROM peers
|
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
|
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(
|
async def current_avatar(
|
||||||
pool: asyncpg.Pool, account_id: int, owner_kind: str, owner_id: int
|
pool: asyncpg.Pool, account_id: int, owner_kind: str, owner_id: int
|
||||||
@@ -28,3 +34,17 @@ async def current_avatar(
|
|||||||
return None
|
return None
|
||||||
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
|
row = await pool.fetchrow(_AVATAR, account_id, owner_id, unique_id)
|
||||||
return AvatarRef(**dict(row)) if row else None
|
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
|
return items
|
||||||
|
|
||||||
|
|
||||||
async def get_chat_history(
|
async def get_chat_history( # noqa: PLR0913
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
page: Page,
|
page: Page,
|
||||||
*,
|
*,
|
||||||
include_deleted: bool = True,
|
include_deleted: bool = True,
|
||||||
|
before_id: int | None = None,
|
||||||
|
after_id: int | None = None,
|
||||||
) -> list[MessageView]:
|
) -> list[MessageView]:
|
||||||
where = "account_id = $1 AND chat_id = $2"
|
where = "account_id = $1 AND chat_id = $2"
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
where += " AND deleted_at IS NULL"
|
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
|
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
|
||||||
"ORDER BY date DESC, message_id DESC LIMIT $3 OFFSET $4",
|
f"ORDER BY {order} LIMIT ${len(params)}"
|
||||||
account_id,
|
|
||||||
chat_id,
|
|
||||||
page.capped_limit,
|
|
||||||
page.offset,
|
|
||||||
)
|
)
|
||||||
|
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)
|
media_by_key = await _media_map(pool, account_id, rows)
|
||||||
parsed = [(row, load_raw(row["raw"])) for row in rows]
|
parsed = [(row, load_raw(row["raw"])) for row in rows]
|
||||||
views: list[MessageView] = []
|
views: list[MessageView] = []
|
||||||
|
|||||||
@@ -225,6 +225,12 @@ class AvatarRef(BaseModel):
|
|||||||
mime: str | None
|
mime: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarHistoryView(BaseModel):
|
||||||
|
unique_id: str
|
||||||
|
first_seen_at: datetime
|
||||||
|
downloaded: bool
|
||||||
|
|
||||||
|
|
||||||
class CustomEmojiRef(BaseModel):
|
class CustomEmojiRef(BaseModel):
|
||||||
storage_key: str | None
|
storage_key: str | None
|
||||||
downloaded: bool
|
downloaded: bool
|
||||||
@@ -307,6 +313,27 @@ class PeerHistoryView(BaseModel):
|
|||||||
is_deleted_account: bool
|
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):
|
class StoryView(BaseModel):
|
||||||
peer_id: int
|
peer_id: int
|
||||||
story_id: int
|
story_id: int
|
||||||
|
|||||||
@@ -68,3 +68,17 @@ async def get_stories(
|
|||||||
*params,
|
*params,
|
||||||
)
|
)
|
||||||
return [StoryView(**dict(row)) for row in rows]
|
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);
|
inflight.set(key, promise);
|
||||||
return 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 { request } from "$lib/api/client";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
|
Alert,
|
||||||
|
Annotation,
|
||||||
|
AvatarHistoryView,
|
||||||
|
CallbackView,
|
||||||
CaptureToggles,
|
CaptureToggles,
|
||||||
Chat,
|
Chat,
|
||||||
|
ChatLinkView,
|
||||||
|
DayCount,
|
||||||
Folder,
|
Folder,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
JobView,
|
JobView,
|
||||||
|
LinkView,
|
||||||
MediaVersion,
|
MediaVersion,
|
||||||
MediaView,
|
MediaView,
|
||||||
|
MessageAt,
|
||||||
MessageVersion,
|
MessageVersion,
|
||||||
MessageView,
|
MessageView,
|
||||||
|
PeerHistoryView,
|
||||||
PeerView,
|
PeerView,
|
||||||
PinnedView,
|
PinnedView,
|
||||||
PolicyChatKind,
|
PolicyChatKind,
|
||||||
@@ -17,9 +26,12 @@ import type {
|
|||||||
PolicyRecord,
|
PolicyRecord,
|
||||||
PresenceHourly,
|
PresenceHourly,
|
||||||
PresenceSample,
|
PresenceSample,
|
||||||
|
ReactionView,
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
SearchHit,
|
SearchHit,
|
||||||
|
StoryView,
|
||||||
VolumeBucket,
|
VolumeBucket,
|
||||||
|
Watch,
|
||||||
} from "$lib/api/types";
|
} from "$lib/api/types";
|
||||||
import { accounts } from "$lib/stores/accounts.svelte";
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
|
|
||||||
@@ -79,7 +91,11 @@ export function effectivePolicy(query: {
|
|||||||
|
|
||||||
export function listMessages(
|
export function listMessages(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
options: Page & { include_deleted?: boolean } = {}
|
options: Page & {
|
||||||
|
include_deleted?: boolean;
|
||||||
|
before_id?: number;
|
||||||
|
after_id?: number;
|
||||||
|
} = {}
|
||||||
): Promise<MessageView[]> {
|
): Promise<MessageView[]> {
|
||||||
return request<MessageView[]>(`/chats/${chatId}/messages`, {
|
return request<MessageView[]>(`/chats/${chatId}/messages`, {
|
||||||
account: true,
|
account: true,
|
||||||
@@ -159,6 +175,90 @@ export function getPeer(peerId: number): Promise<PeerView> {
|
|||||||
return request<PeerView>(`/peers/${peerId}`, { account: true });
|
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[]> {
|
export function getPeers(ids: number[]): Promise<PeerView[]> {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return Promise.resolve([]);
|
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;
|
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 {
|
export interface Reaction {
|
||||||
added_at: string;
|
added_at: string;
|
||||||
peer_id: number;
|
peer_id: number;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import type { PeerView, PresenceSample } from "$lib/api/types";
|
import type { PeerView, PresenceSample } from "$lib/api/types";
|
||||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||||
import Button from "$lib/components/ui/Button.svelte";
|
import 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 Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import { peerName } from "$lib/format/peer";
|
import { peerName } from "$lib/format/peer";
|
||||||
import { formatPresence } from "$lib/format/presence";
|
import { formatPresence } from "$lib/format/presence";
|
||||||
@@ -121,20 +123,45 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<Avatar
|
<ContextMenu>
|
||||||
name={title}
|
{#snippet children({ props })}
|
||||||
colorKey={chatId}
|
<button
|
||||||
size={2.5}
|
{...props}
|
||||||
avatar={{ kind: avatarKind, id: chatId }}
|
type="button"
|
||||||
{hasAvatar}
|
class="peek"
|
||||||
deleted={peer?.is_deleted_account ?? false}
|
onclick={() => ui.openPanel("profile")}
|
||||||
/>
|
aria-label="Открыть профиль"
|
||||||
<div class="info">
|
>
|
||||||
<h2 class="title">{title}</h2>
|
<Avatar
|
||||||
<span class="subtitle" class:online={isDm && presence?.status === "online"}>
|
name={title}
|
||||||
{subtitle}
|
colorKey={chatId}
|
||||||
</span>
|
size={2.5}
|
||||||
</div>
|
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">
|
<div class="actions">
|
||||||
<Button
|
<Button
|
||||||
variant="translucent"
|
variant="translucent"
|
||||||
@@ -180,6 +207,21 @@
|
|||||||
background-color: var(--color-background);
|
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 {
|
.info {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import { ripple } from "$lib/actions/ripple";
|
import { ripple } from "$lib/actions/ripple";
|
||||||
import type { Chat } from "$lib/api/types";
|
import type { Chat } from "$lib/api/types";
|
||||||
import Avatar from "$lib/components/ui/Avatar.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 { formatListDate } from "$lib/format/datetime";
|
import { formatListDate } from "$lib/format/datetime";
|
||||||
import { accounts } from "$lib/stores/accounts.svelte";
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
import { peers } from "$lib/stores/peers.svelte";
|
import { peers } from "$lib/stores/peers.svelte";
|
||||||
|
import { type RightPanel, ui } from "$lib/stores/ui.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chat: Chat;
|
chat: Chat;
|
||||||
@@ -14,6 +18,11 @@
|
|||||||
|
|
||||||
let { chat, selected, onclick }: Props = $props();
|
let { chat, selected, onclick }: Props = $props();
|
||||||
|
|
||||||
|
async function openWith(panel: RightPanel) {
|
||||||
|
await goto(`/app/${chat.chat_id}`);
|
||||||
|
ui.openPanel(panel);
|
||||||
|
}
|
||||||
|
|
||||||
const title = $derived(
|
const title = $derived(
|
||||||
chat.title ??
|
chat.title ??
|
||||||
(chat.chat_id > 0 ? "Удалённый аккаунт" : `Chat ${chat.chat_id}`)
|
(chat.chat_id > 0 ? "Удалённый аккаунт" : `Chat ${chat.chat_id}`)
|
||||||
@@ -49,34 +58,56 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<ContextMenu>
|
||||||
type="button"
|
{#snippet children({ props })}
|
||||||
class="Chat ListItem-button"
|
<button
|
||||||
class:selected
|
{...props}
|
||||||
use:ripple
|
type="button"
|
||||||
{onclick}
|
class="Chat ListItem-button"
|
||||||
>
|
class:selected
|
||||||
<Avatar
|
use:ripple
|
||||||
name={title}
|
{onclick}
|
||||||
colorKey={chat.chat_id}
|
>
|
||||||
avatar={{ kind: avatarKind, id: chat.chat_id }}
|
<Avatar
|
||||||
hasAvatar={chat.has_avatar}
|
name={title}
|
||||||
/>
|
colorKey={chat.chat_id}
|
||||||
<div class="info">
|
avatar={{ kind: avatarKind, id: chat.chat_id }}
|
||||||
<div class="info-row">
|
hasAvatar={chat.has_avatar}
|
||||||
<h3 class="title">{title}</h3>
|
/>
|
||||||
<span class="date">{formatListDate(chat.last_date)}</span>
|
<div class="info">
|
||||||
</div>
|
<div class="info-row">
|
||||||
<div class="subtitle">
|
<h3 class="title">{title}</h3>
|
||||||
<span class="last-message">
|
<span class="date">{formatListDate(chat.last_date)}</span>
|
||||||
{#if senderPrefix}
|
</div>
|
||||||
<span class="sender">{senderPrefix}</span>
|
<div class="subtitle">
|
||||||
{/if}
|
<span class="last-message">
|
||||||
{preview}
|
{#if senderPrefix}
|
||||||
</span>
|
<span class="sender">{senderPrefix}</span>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
{preview}
|
||||||
</button>
|
</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">
|
<style lang="scss">
|
||||||
.Chat {
|
.Chat {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import type { MessageView } from "$lib/api/types";
|
import type { MessageView } from "$lib/api/types";
|
||||||
import Contact from "$lib/components/Contact.svelte";
|
import Contact from "$lib/components/Contact.svelte";
|
||||||
import EntityText from "$lib/components/EntityText.svelte";
|
import EntityText from "$lib/components/EntityText.svelte";
|
||||||
@@ -12,11 +13,15 @@
|
|||||||
import Reactions from "$lib/components/Reactions.svelte";
|
import Reactions from "$lib/components/Reactions.svelte";
|
||||||
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
|
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
|
||||||
import Avatar from "$lib/components/ui/Avatar.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 Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import WebPage from "$lib/components/WebPage.svelte";
|
import WebPage from "$lib/components/WebPage.svelte";
|
||||||
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
|
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
|
||||||
import { accounts } from "$lib/stores/accounts.svelte";
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
import { peers } from "$lib/stores/peers.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";
|
import { appear } from "$lib/transitions/appear";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -75,100 +80,210 @@
|
|||||||
? ownId !== null && peers.get(ownId)?.has_avatar
|
? ownId !== null && peers.get(ownId)?.has_avatar
|
||||||
: (sender?.has_avatar ?? false)
|
: (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>
|
</script>
|
||||||
|
|
||||||
<div
|
<ContextMenu>
|
||||||
class="Message"
|
{#snippet children({ props })}
|
||||||
class:own
|
<div
|
||||||
class:deleted
|
{...props}
|
||||||
class:highlighted
|
class="Message"
|
||||||
class:first-in-group={firstInGroup}
|
class:own
|
||||||
class:last-in-group={lastInGroup}
|
class:deleted
|
||||||
class:with-avatar={isGroupChat}
|
class:highlighted
|
||||||
data-message-id={message.message_id}
|
class:first-in-group={firstInGroup}
|
||||||
in:appear={{ disabled: !animate }}
|
class:last-in-group={lastInGroup}
|
||||||
>
|
class:with-avatar={isGroupChat}
|
||||||
{#if isGroupChat}
|
data-message-id={message.message_id}
|
||||||
<div class="avatar-slot">
|
in:appear={{ disabled: !animate }}
|
||||||
{#if lastInGroup && avatarId !== null}
|
>
|
||||||
<Avatar
|
{#if isGroupChat}
|
||||||
name={senderName}
|
<div class="avatar-slot">
|
||||||
colorKey={avatarId}
|
{#if lastInGroup && avatarId !== null}
|
||||||
size={2.125}
|
<Avatar
|
||||||
avatar={{ kind: "peer", id: avatarId }}
|
name={senderName}
|
||||||
hasAvatar={avatarHas}
|
colorKey={avatarId}
|
||||||
/>
|
size={2.125}
|
||||||
|
avatar={{ kind: "peer", id: avatarId }}
|
||||||
|
hasAvatar={avatarHas}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="message-content" class:has-appendix={lastInGroup}>
|
<div class="message-content" class:has-appendix={lastInGroup}>
|
||||||
{#if showName}
|
{#if showName}
|
||||||
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
|
<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}
|
||||||
{#if message.forward}
|
{#if linkCount > 0}
|
||||||
<ForwardHeader forward={message.forward} />
|
<ContextMenuItem icon="link" onselect={openLinks}>Ссылки</ContextMenuItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.reply}
|
{#if hasCallbacks}
|
||||||
<ReplyHeader reply={message.reply} {onjump} />
|
<ContextMenuItem icon="bot-command" onselect={openCallbacks}
|
||||||
|
>Callback-данные</ContextMenuItem
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if deleted}
|
{#if message.edited_at}
|
||||||
<span class="deleted-tag">
|
<ContextMenuItem icon="edit" onselect={onversions}
|
||||||
<Icon name="delete" size="0.875rem" />
|
>Версии</ContextMenuItem
|
||||||
deleted
|
>
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.poll}
|
{#if message.reply?.message_id != null}
|
||||||
<Poll poll={message.poll} />
|
<ContextMenuItem icon="reply" onselect={jumpToReply}
|
||||||
{/if}
|
>Перейти к ответу</ContextMenuItem
|
||||||
{#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}
|
||||||
{#if hasText}
|
{#if hasText}
|
||||||
<div class="text">
|
<ContextMenuItem icon="copy" onselect={copyText}
|
||||||
<EntityText
|
>Копировать текст</ContextMenuItem
|
||||||
text={message.text ?? ""}
|
>
|
||||||
entities={message.entities}
|
|
||||||
{own}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if !(message.has_media || deleted || special)}
|
|
||||||
<div class="text empty">(no text)</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.web_page}
|
<ContextMenuItem icon="hashtag" onselect={copyId}
|
||||||
<WebPage {message} {own} {onmedia} />
|
>Копировать ID</ContextMenuItem
|
||||||
|
>
|
||||||
|
{#if isGroupChat && !own && message.sender_id !== null}
|
||||||
|
<ContextMenuItem icon="info" onselect={openSenderProfile}
|
||||||
|
>Профиль автора</ContextMenuItem
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.reactions.length}
|
{/snippet}
|
||||||
<Reactions reactions={message.reactions} {own} />
|
</ContextMenu>
|
||||||
{/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>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.Message {
|
.Message {
|
||||||
@@ -278,6 +393,30 @@
|
|||||||
color: var(--color-error);
|
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 {
|
.svg-appendix {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -0.0625rem;
|
bottom: -0.0625rem;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import MessageVersions from "$lib/components/MessageVersions.svelte";
|
import MessageVersions from "$lib/components/MessageVersions.svelte";
|
||||||
import PinnedBar from "$lib/components/PinnedBar.svelte";
|
import PinnedBar from "$lib/components/PinnedBar.svelte";
|
||||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||||
import { formatDay } from "$lib/format/datetime";
|
import { formatDay } from "$lib/format/datetime";
|
||||||
import { accounts } from "$lib/stores/accounts.svelte";
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
@@ -30,12 +31,13 @@
|
|||||||
const SCROLL_THRESHOLD = 160;
|
const SCROLL_THRESHOLD = 160;
|
||||||
const STICK_OFFSET = 9;
|
const STICK_OFFSET = 9;
|
||||||
const IDLE_DELAY = 1500;
|
const IDLE_DELAY = 1500;
|
||||||
const JUMP_MAX_PAGES = 12;
|
|
||||||
|
|
||||||
let messages = $state<MessageView[]>([]);
|
let messages = $state<MessageView[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadingOlder = $state(false);
|
let loadingOlder = $state(false);
|
||||||
|
let loadingNewer = $state(false);
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
|
let hasNewer = $state(false);
|
||||||
let container = $state<HTMLDivElement | null>(null);
|
let container = $state<HTMLDivElement | null>(null);
|
||||||
let suppressAppear = $state(false);
|
let suppressAppear = $state(false);
|
||||||
let scrolling = $state(false);
|
let scrolling = $state(false);
|
||||||
@@ -155,6 +157,7 @@
|
|||||||
async function loadInitial() {
|
async function loadInitial() {
|
||||||
loading = true;
|
loading = true;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
|
hasNewer = false;
|
||||||
try {
|
try {
|
||||||
const page = await listMessages(chatId, {
|
const page = await listMessages(chatId, {
|
||||||
limit: PAGE,
|
limit: PAGE,
|
||||||
@@ -162,7 +165,7 @@
|
|||||||
include_deleted: true,
|
include_deleted: true,
|
||||||
});
|
});
|
||||||
messages = [...page].reverse();
|
messages = [...page].reverse();
|
||||||
hasMore = page.length === PAGE;
|
hasMore = page.length > 0;
|
||||||
ensurePeers(messages);
|
ensurePeers(messages);
|
||||||
await tick();
|
await tick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -175,33 +178,95 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOlder() {
|
async function loadOlder() {
|
||||||
if (loadingOlder || !hasMore || container === null) {
|
if (
|
||||||
|
loadingOlder ||
|
||||||
|
!hasMore ||
|
||||||
|
container === null ||
|
||||||
|
messages.length === 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadingOlder = true;
|
loadingOlder = true;
|
||||||
suppressAppear = true;
|
|
||||||
const el = container;
|
const el = container;
|
||||||
const prevHeight = el.scrollHeight;
|
const prevHeight = el.scrollHeight;
|
||||||
const prevTop = el.scrollTop;
|
const prevTop = el.scrollTop;
|
||||||
try {
|
try {
|
||||||
const page = await listMessages(chatId, {
|
const page = await listMessages(chatId, {
|
||||||
limit: PAGE,
|
limit: PAGE,
|
||||||
offset: messages.length,
|
before_id: messages[0].message_id,
|
||||||
include_deleted: true,
|
include_deleted: true,
|
||||||
});
|
});
|
||||||
if (page.length > 0) {
|
const known = new Set(messages.map((m) => m.message_id));
|
||||||
messages = [...[...page].reverse(), ...messages];
|
const fresh = page.filter((m) => !known.has(m.message_id));
|
||||||
ensurePeers(page);
|
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 {
|
} finally {
|
||||||
loadingOlder = false;
|
loadingOlder = false;
|
||||||
suppressAppear = 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() {
|
function onScroll() {
|
||||||
scrolling = true;
|
scrolling = true;
|
||||||
if (idleTimer) {
|
if (idleTimer) {
|
||||||
@@ -214,6 +279,9 @@
|
|||||||
if (container && container.scrollTop < SCROLL_THRESHOLD) {
|
if (container && container.scrollTop < SCROLL_THRESHOLD) {
|
||||||
loadOlder();
|
loadOlder();
|
||||||
}
|
}
|
||||||
|
if (hasNewer && isNearBottom()) {
|
||||||
|
loadNewer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMedia(message: MessageView, index: number) {
|
function openMedia(message: MessageView, index: number) {
|
||||||
@@ -248,6 +316,9 @@
|
|||||||
replaceMessage(message);
|
replaceMessage(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasNewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const stick = isNearBottom();
|
const stick = isNearBottom();
|
||||||
messages = [...messages, message];
|
messages = [...messages, message];
|
||||||
ensurePeers([message]);
|
ensurePeers([message]);
|
||||||
@@ -296,7 +367,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resyncTail() {
|
async function resyncTail() {
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0 || hasNewer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const page = await listMessages(chatId, {
|
const page = await listMessages(chatId, {
|
||||||
@@ -328,15 +399,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function jumpToTarget(messageId: number) {
|
async function jumpToTarget(messageId: number) {
|
||||||
let guard = 0;
|
if (!messages.some((m) => m.message_id === messageId)) {
|
||||||
while (
|
await loadAround(messageId);
|
||||||
!messages.some((m) => m.message_id === messageId) &&
|
|
||||||
hasMore &&
|
|
||||||
(messages.length === 0 || messages[0].message_id > messageId) &&
|
|
||||||
guard < JUMP_MAX_PAGES
|
|
||||||
) {
|
|
||||||
await loadOlder();
|
|
||||||
guard++;
|
|
||||||
}
|
}
|
||||||
await tick();
|
await tick();
|
||||||
jumpToMessage(messageId);
|
jumpToMessage(messageId);
|
||||||
@@ -422,9 +486,22 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if loadingNewer}
|
||||||
|
<div class="loading-older"><Spinner /></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if hasNewer}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="to-latest"
|
||||||
|
onclick={goToLatest}
|
||||||
|
aria-label="К последним сообщениям"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-down" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MediaViewer
|
<MediaViewer
|
||||||
@@ -441,11 +518,37 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.message-pane {
|
.message-pane {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
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 {
|
.message-list {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -11,9 +11,12 @@
|
|||||||
import TgsSticker from "$lib/components/media/TgsSticker.svelte";
|
import TgsSticker from "$lib/components/media/TgsSticker.svelte";
|
||||||
import VideoNote from "$lib/components/media/VideoNote.svelte";
|
import VideoNote from "$lib/components/media/VideoNote.svelte";
|
||||||
import VoiceMessage from "$lib/components/media/VoiceMessage.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 Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte";
|
import { toasts } from "$lib/stores/toasts.svelte";
|
||||||
|
import { ui } from "$lib/stores/ui.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
@@ -92,72 +95,87 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="message-media" use:visible={start}>
|
<ContextMenu>
|
||||||
{#if message.is_self_destruct}
|
{#snippet children({ props })}
|
||||||
<button class="media-chip self-destruct" onclick={onopen} type="button">
|
<div {...props} class="message-media" use:visible={start}>
|
||||||
<Icon name="timer" size="1.25rem" />
|
{#if message.is_self_destruct}
|
||||||
<span>Self-destruct media</span>
|
<button class="media-chip self-destruct" onclick={onopen} type="button">
|
||||||
</button>
|
<Icon name="timer" size="1.25rem" />
|
||||||
{:else if !loaded}
|
<span>Self-destruct media</span>
|
||||||
<div class="media-skeleton"><Spinner /></div>
|
</button>
|
||||||
{:else if ready && kind === "voice"}
|
{:else if !loaded}
|
||||||
<VoiceMessage url={ready.url} transcript={ready.transcript} {own} />
|
<div class="media-skeleton"><Spinner /></div>
|
||||||
{:else if ready && kind === "video_note"}
|
{:else if ready && kind === "voice"}
|
||||||
<VideoNote url={ready.url} transcript={ready.transcript} />
|
<VoiceMessage url={ready.url} transcript={ready.transcript} {own} />
|
||||||
{:else if ready && kind === "audio"}
|
{:else if ready && kind === "video_note"}
|
||||||
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} />
|
<VideoNote url={ready.url} transcript={ready.transcript} />
|
||||||
{:else if ready && isImage}
|
{:else if ready && kind === "audio"}
|
||||||
<button class="media-thumb" onclick={onopen} type="button">
|
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} />
|
||||||
<img src={ready.url} alt="attachment">
|
{:else if ready && isImage}
|
||||||
</button>
|
<button class="media-thumb" onclick={onopen} type="button">
|
||||||
{:else if ready && isStaticSticker}
|
<img src={ready.url} alt="attachment">
|
||||||
<button class="media-sticker" onclick={onopen} type="button">
|
</button>
|
||||||
<img src={ready.url} alt="sticker">
|
{:else if ready && isStaticSticker}
|
||||||
</button>
|
<button class="media-sticker" onclick={onopen} type="button">
|
||||||
{:else if ready && isVideoSticker}
|
<img src={ready.url} alt="sticker">
|
||||||
<video
|
</button>
|
||||||
class="media-sticker-video"
|
{:else if ready && isVideoSticker}
|
||||||
src={ready.url}
|
<video
|
||||||
autoplay
|
class="media-sticker-video"
|
||||||
loop
|
src={ready.url}
|
||||||
muted
|
autoplay
|
||||||
playsinline
|
loop
|
||||||
></video>
|
muted
|
||||||
{:else if ready && isTgsSticker}
|
playsinline
|
||||||
<TgsSticker url={ready.url} />
|
></video>
|
||||||
{:else if ready && isAnimation}
|
{:else if ready && isTgsSticker}
|
||||||
<button class="media-thumb" onclick={onopen} type="button">
|
<TgsSticker url={ready.url} />
|
||||||
<video src={ready.url} autoplay loop muted playsinline></video>
|
{:else if ready && isAnimation}
|
||||||
<span class="gif-badge">GIF</span>
|
<button class="media-thumb" onclick={onopen} type="button">
|
||||||
</button>
|
<video src={ready.url} autoplay loop muted playsinline></video>
|
||||||
{:else if ready && isThumbVideo}
|
<span class="gif-badge">GIF</span>
|
||||||
<button class="media-thumb" onclick={onopen} type="button">
|
</button>
|
||||||
<video src={ready.url} muted preload="metadata"></video>
|
{:else if ready && isThumbVideo}
|
||||||
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
|
<button class="media-thumb" onclick={onopen} type="button">
|
||||||
</button>
|
<video src={ready.url} muted preload="metadata"></video>
|
||||||
{:else if ready}
|
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
|
||||||
<button class="media-chip" onclick={onopen} type="button">
|
</button>
|
||||||
<Icon name="document" size="1.25rem" />
|
{:else if ready}
|
||||||
<span>{label}</span>
|
<button class="media-chip" onclick={onopen} type="button">
|
||||||
</button>
|
<Icon name="document" size="1.25rem" />
|
||||||
{:else if media?.state === "not-downloaded" && vk !== "other"}
|
<span>{label}</span>
|
||||||
<button class="media-placeholder" onclick={queue} type="button">
|
</button>
|
||||||
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
{:else if media?.state === "not-downloaded" && vk !== "other"}
|
||||||
<span>{vk === "video" ? "Video" : "Photo"}</span>
|
<button class="media-placeholder" onclick={queue} type="button">
|
||||||
<small>{queuing ? "Queued" : "Tap to download"}</small>
|
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
||||||
</button>
|
<span>{vk === "video" ? "Video" : "Photo"}</span>
|
||||||
{:else if media?.state === "not-downloaded"}
|
<small>{queuing ? "Queued" : "Tap to download"}</small>
|
||||||
<button class="media-chip" onclick={queue} type="button">
|
</button>
|
||||||
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
|
{:else if media?.state === "not-downloaded"}
|
||||||
<span>{queuing ? "Queued" : `Download ${label}`}</span>
|
<button class="media-chip" onclick={queue} type="button">
|
||||||
</button>
|
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
|
||||||
{:else}
|
<span>{queuing ? "Queued" : `Download ${label}`}</span>
|
||||||
<button class="media-chip" onclick={onopen} type="button">
|
</button>
|
||||||
<Icon name="photo" size="1.25rem" />
|
{:else}
|
||||||
<span>Media</span>
|
<button class="media-chip" onclick={onopen} type="button">
|
||||||
</button>
|
<Icon name="photo" size="1.25rem" />
|
||||||
{/if}
|
<span>Media</span>
|
||||||
</div>
|
</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">
|
<style lang="scss">
|
||||||
.message-media {
|
.message-media {
|
||||||
|
|||||||
@@ -3,23 +3,29 @@
|
|||||||
import CustomEmoji from "$lib/components/CustomEmoji.svelte";
|
import CustomEmoji from "$lib/components/CustomEmoji.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
onclick?: () => void;
|
||||||
own: boolean;
|
own: boolean;
|
||||||
reactions: ReactionCount[];
|
reactions: ReactionCount[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { reactions, own }: Props = $props();
|
let { reactions, own, onclick }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="Reactions" class:own>
|
<div class="Reactions" class:own>
|
||||||
{#each reactions as reaction, index (reaction.custom_emoji_id ?? reaction.emoji ?? index)}
|
{#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}
|
{#if reaction.custom_emoji_id}
|
||||||
<CustomEmoji id={reaction.custom_emoji_id} size={1.25} />
|
<CustomEmoji id={reaction.custom_emoji_id} size={1.25} />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="emoji">{reaction.emoji ?? "❓"}</span>
|
<span class="emoji">{reaction.emoji ?? "❓"}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="count">{reaction.count}</span>
|
<span class="count">{reaction.count}</span>
|
||||||
</span>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,13 +44,16 @@
|
|||||||
|
|
||||||
height: 1.625rem;
|
height: 1.625rem;
|
||||||
padding: 0 0.4375rem 0 0.375rem;
|
padding: 0 0.4375rem 0 0.375rem;
|
||||||
|
border: 0;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
background-color: var(--color-message-reaction);
|
background-color: var(--color-message-reaction);
|
||||||
|
|
||||||
.own & {
|
.own & {
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<script lang="ts">
|
<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 JobsPanel from "$lib/components/jobs/JobsPanel.svelte";
|
||||||
import PolicyEditor from "$lib/components/policy/PolicyEditor.svelte";
|
import PolicyEditor from "$lib/components/policy/PolicyEditor.svelte";
|
||||||
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.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 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 Button from "$lib/components/ui/Button.svelte";
|
||||||
import Icon from "$lib/components/ui/Icon.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";
|
import { type RightPanel, ui } from "$lib/stores/ui.svelte";
|
||||||
|
|
||||||
const titles: Record<RightPanel, string> = {
|
const titles: Record<RightPanel, string> = {
|
||||||
@@ -12,12 +21,16 @@
|
|||||||
search: "Поиск",
|
search: "Поиск",
|
||||||
versions: "Версии",
|
versions: "Версии",
|
||||||
reactions: "Реакции",
|
reactions: "Реакции",
|
||||||
|
callbacks: "Callback-данные",
|
||||||
links: "Ссылки",
|
links: "Ссылки",
|
||||||
annotations: "Заметки",
|
annotations: "Заметки",
|
||||||
jobs: "Данные и хранилище",
|
jobs: "Данные и хранилище",
|
||||||
presence: "Аналитика",
|
presence: "Аналитика",
|
||||||
stories: "Сторис",
|
stories: "Сторис",
|
||||||
|
"stories-all": "Все сторис",
|
||||||
policy: "Политика захвата",
|
policy: "Политика захвата",
|
||||||
|
watches: "Отслеживания",
|
||||||
|
alerts: "Алерты",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -35,7 +48,9 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="right-body custom-scroll">
|
<div class="right-body custom-scroll">
|
||||||
{#if ui.rightPanel === "policy"}
|
{#if ui.rightPanel === "profile"}
|
||||||
|
<ProfilePanel />
|
||||||
|
{:else if ui.rightPanel === "policy"}
|
||||||
<PolicyEditor />
|
<PolicyEditor />
|
||||||
{:else if ui.rightPanel === "jobs"}
|
{:else if ui.rightPanel === "jobs"}
|
||||||
<JobsPanel />
|
<JobsPanel />
|
||||||
@@ -43,6 +58,22 @@
|
|||||||
<AnalyticsPanel />
|
<AnalyticsPanel />
|
||||||
{:else if ui.rightPanel === "search"}
|
{:else if ui.rightPanel === "search"}
|
||||||
<ChatSearchPanel />
|
<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}
|
{/if}
|
||||||
</div>
|
</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: "search", label: "Поиск" },
|
||||||
{ icon: "folder", label: "Папки" },
|
{ icon: "folder", label: "Папки" },
|
||||||
{ icon: "stats", label: "Presence и аналитика" },
|
{ icon: "stats", label: "Presence и аналитика" },
|
||||||
{ icon: "unmute", label: "Алерты" },
|
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,6 +47,21 @@
|
|||||||
label="Данные и хранилище"
|
label="Данные и хранилище"
|
||||||
onclick={() => ui.openPanel("jobs")}
|
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)}
|
{#each features as feature (feature.label)}
|
||||||
<SettingsItem icon={feature.icon} label={feature.label} soon />
|
<SettingsItem icon={feature.icon} label={feature.label} soon />
|
||||||
{/each}
|
{/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";
|
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"
|
| "search"
|
||||||
| "versions"
|
| "versions"
|
||||||
| "reactions"
|
| "reactions"
|
||||||
|
| "callbacks"
|
||||||
| "links"
|
| "links"
|
||||||
| "annotations"
|
| "annotations"
|
||||||
| "jobs"
|
| "jobs"
|
||||||
| "presence"
|
| "presence"
|
||||||
| "stories"
|
| "stories"
|
||||||
| "policy";
|
| "stories-all"
|
||||||
|
| "policy"
|
||||||
|
| "watches"
|
||||||
|
| "alerts";
|
||||||
|
|
||||||
export type LeftView = "main" | "settings";
|
export type LeftView = "main" | "settings";
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ interface JumpTarget {
|
|||||||
|
|
||||||
function createUi() {
|
function createUi() {
|
||||||
let rightPanel = $state<RightPanel | null>(null);
|
let rightPanel = $state<RightPanel | null>(null);
|
||||||
|
let panelMessageId = $state<number | null>(null);
|
||||||
let leftColumnOpen = $state(true);
|
let leftColumnOpen = $state(true);
|
||||||
let leftView = $state<LeftView>("main");
|
let leftView = $state<LeftView>("main");
|
||||||
let jumpTarget = $state<JumpTarget | null>(null);
|
let jumpTarget = $state<JumpTarget | null>(null);
|
||||||
@@ -27,6 +32,9 @@ function createUi() {
|
|||||||
get rightPanel() {
|
get rightPanel() {
|
||||||
return rightPanel;
|
return rightPanel;
|
||||||
},
|
},
|
||||||
|
get panelMessageId() {
|
||||||
|
return panelMessageId;
|
||||||
|
},
|
||||||
get leftColumnOpen() {
|
get leftColumnOpen() {
|
||||||
return leftColumnOpen;
|
return leftColumnOpen;
|
||||||
},
|
},
|
||||||
@@ -50,9 +58,15 @@ function createUi() {
|
|||||||
},
|
},
|
||||||
openPanel(panel: RightPanel) {
|
openPanel(panel: RightPanel) {
|
||||||
rightPanel = panel;
|
rightPanel = panel;
|
||||||
|
panelMessageId = null;
|
||||||
|
},
|
||||||
|
openMessagePanel(panel: RightPanel, messageId: number) {
|
||||||
|
rightPanel = panel;
|
||||||
|
panelMessageId = messageId;
|
||||||
},
|
},
|
||||||
closePanel() {
|
closePanel() {
|
||||||
rightPanel = null;
|
rightPanel = null;
|
||||||
|
panelMessageId = null;
|
||||||
},
|
},
|
||||||
toggleLeftColumn() {
|
toggleLeftColumn() {
|
||||||
leftColumnOpen = !leftColumnOpen;
|
leftColumnOpen = !leftColumnOpen;
|
||||||
|
|||||||
Reference in New Issue
Block a user