feat: 1-to-1 message render + web data-lake backend
This commit is contained in:
1 parent
f0afb7ec5b
commit
75425d1bee
110 files changed
+10199
-54
No files matched your search
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { accountName } from "$lib/format/peer";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
const current = $derived(accounts.selected);
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="account-trigger">
|
||||
{#if current}
|
||||
<Avatar
|
||||
name={accountName(current)}
|
||||
colorKey={current.account_id}
|
||||
size={2.25}
|
||||
/>
|
||||
<span class="account-name">{accountName(current)}</span>
|
||||
{:else}
|
||||
<span class="account-name">No account</span>
|
||||
{/if}
|
||||
<Icon name="down" size="1.25rem" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="bg-menu-content" sideOffset={6} align="start">
|
||||
{#each accounts.list as account (account.account_id)}
|
||||
<DropdownMenu.Item
|
||||
class="bg-menu-item"
|
||||
data-selected={account.account_id === accounts.selectedId
|
||||
? ""
|
||||
: undefined}
|
||||
onSelect={() => accounts.select(account.account_id)}
|
||||
>
|
||||
<Avatar
|
||||
name={accountName(account)}
|
||||
colorKey={account.account_id}
|
||||
size={1.75}
|
||||
/>
|
||||
<span>{accountName(account)}</span>
|
||||
{#if account.account_id === accounts.selectedId}
|
||||
<Icon name="check" size="1.125rem" class="trailing" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.account-trigger) {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
min-width: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.account-name {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.bg-menu-item .trailing) {
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { getPeer } from "$lib/api/endpoints";
|
||||
import type { PeerView } from "$lib/api/types";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import { peerName } from "$lib/format/peer";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
}
|
||||
|
||||
let { chatId }: Props = $props();
|
||||
|
||||
const isDm = $derived(chatId > 0);
|
||||
const chat = $derived(chats.byId(chatId));
|
||||
let peer = $state<PeerView | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId === null || !isDm) {
|
||||
peer = null;
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
peer = null;
|
||||
getPeer(chatId)
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
peer = result;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
peer = null;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
|
||||
const fallbackTitle = $derived(chat?.title ?? `Chat ${chatId}`);
|
||||
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
||||
const subtitle = $derived.by(() => {
|
||||
if (isDm) {
|
||||
if (peer?.username) {
|
||||
return `@${peer.username}`;
|
||||
}
|
||||
return peer?.phone ?? `ID ${chatId}`;
|
||||
}
|
||||
const count = chat?.message_count ?? 0;
|
||||
return count > 0 ? `${count} messages` : "group";
|
||||
});
|
||||
const avatarKind = $derived(isDm ? "peer" : "chat");
|
||||
const hasAvatar = $derived(chat?.has_avatar ?? Boolean(peer?.has_avatar));
|
||||
</script>
|
||||
|
||||
<header class="chat-header">
|
||||
<Avatar
|
||||
name={title}
|
||||
colorKey={chatId}
|
||||
size={2.5}
|
||||
avatar={{ kind: avatarKind, id: chatId }}
|
||||
{hasAvatar}
|
||||
deleted={peer?.is_deleted_account ?? false}
|
||||
/>
|
||||
<div class="info">
|
||||
<h2 class="title">{title}</h2>
|
||||
<span class="subtitle">{subtitle}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
height: var(--header-height);
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
.info {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { fly } from "svelte/transition";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import ChatListItem from "$lib/components/ChatListItem.svelte";
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import Skeleton from "$lib/components/ui/Skeleton.svelte";
|
||||
import { folderContains } from "$lib/format/folders";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { folders } from "$lib/stores/folders.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
const skeletonRows = Array.from({ length: 9 }, (_, index) => index);
|
||||
|
||||
const activeChatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
|
||||
const selectedFolder = $derived(folders.selected);
|
||||
const visibleChats = $derived(
|
||||
selectedFolder === null
|
||||
? chats.list
|
||||
: chats.list.filter((chat) => folderContains(selectedFolder, chat))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId === null) {
|
||||
return;
|
||||
}
|
||||
chats.load().catch(() => toasts.error("Failed to load chats"));
|
||||
folders.load().catch(() => toasts.error("Failed to load folders"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chat-list custom-scroll">
|
||||
{#if chats.loading && chats.list.length === 0}
|
||||
{#each skeletonRows as index (index)}
|
||||
<div class="row-skeleton">
|
||||
<Skeleton width="3rem" height="3rem" circle />
|
||||
<div class="row-skeleton-lines">
|
||||
<Skeleton width="55%" height="0.875rem" />
|
||||
<Skeleton width="80%" height="0.8125rem" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if chats.list.length === 0}
|
||||
<EmptyState title="No chats yet" />
|
||||
{:else}
|
||||
{#key folders.selectedId}
|
||||
<div
|
||||
class="folder-view"
|
||||
in:fly={{ x: folders.direction * 24, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
{#if visibleChats.length === 0}
|
||||
<EmptyState
|
||||
title="Empty folder"
|
||||
description="No chats match this folder yet"
|
||||
/>
|
||||
{:else}
|
||||
{#each visibleChats as chat (chat.chat_id)}
|
||||
<ChatListItem
|
||||
{chat}
|
||||
selected={chat.chat_id === activeChatId}
|
||||
onclick={() => goto(`/app/${chat.chat_id}`)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.4375rem;
|
||||
}
|
||||
|
||||
.row-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
}
|
||||
|
||||
.row-skeleton-lines {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
import type { Chat } from "$lib/api/types";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import { formatListDate } from "$lib/format/datetime";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
interface Props {
|
||||
chat: Chat;
|
||||
onclick: () => void;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
let { chat, selected, onclick }: Props = $props();
|
||||
|
||||
const title = $derived(chat.title ?? `Chat ${chat.chat_id}`);
|
||||
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const showSender = $derived(
|
||||
chat.kind === "group" && chat.last_sender_id !== null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (showSender && chat.last_sender_id !== ownId) {
|
||||
peers.ensure([chat.last_sender_id as number]);
|
||||
}
|
||||
});
|
||||
|
||||
const senderPrefix = $derived.by(() => {
|
||||
if (!showSender) {
|
||||
return "";
|
||||
}
|
||||
if (chat.last_sender_id === ownId) {
|
||||
return "You: ";
|
||||
}
|
||||
const peer = peers.get(chat.last_sender_id as number);
|
||||
if (!peer) {
|
||||
return "";
|
||||
}
|
||||
return `${peer.first_name ?? peer.username ?? peer.peer_id}: `;
|
||||
});
|
||||
|
||||
const preview = $derived(
|
||||
chat.last_text ?? (chat.message_count > 0 ? "Media" : "")
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="Chat ListItem-button"
|
||||
class:selected
|
||||
use:ripple
|
||||
{onclick}
|
||||
>
|
||||
<Avatar
|
||||
name={title}
|
||||
colorKey={chat.chat_id}
|
||||
avatar={{ kind: avatarKind, id: chat.chat_id }}
|
||||
hasAvatar={chat.has_avatar}
|
||||
/>
|
||||
<div class="info">
|
||||
<div class="info-row">
|
||||
<h3 class="title">{title}</h3>
|
||||
<span class="date">{formatListDate(chat.last_date)}</span>
|
||||
</div>
|
||||
<div class="subtitle">
|
||||
<span class="last-message">
|
||||
{#if senderPrefix}
|
||||
<span class="sender">{senderPrefix}</span>
|
||||
{/if}
|
||||
{preview}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.Chat {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-chat-active);
|
||||
|
||||
.title,
|
||||
.date,
|
||||
.last-message,
|
||||
.sender {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sender {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import type { EntityView } from "$lib/api/types";
|
||||
import {
|
||||
buildEntityTree,
|
||||
type EntityNode,
|
||||
type EntityTreeNode,
|
||||
jumboEmojiCount,
|
||||
linkHref,
|
||||
} from "$lib/format/entities";
|
||||
|
||||
interface Props {
|
||||
entities: EntityView[];
|
||||
own?: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
let { text, entities, own = false }: Props = $props();
|
||||
|
||||
const nodes = $derived(buildEntityTree(text, entities));
|
||||
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
|
||||
|
||||
const revealed = new SvelteSet<string>();
|
||||
|
||||
function spoilerKey(entity: EntityView): string {
|
||||
return `${entity.offset}:${entity.length}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet tree(items: EntityTreeNode[])}
|
||||
{#each items as node, i (i)}
|
||||
{#if node.kind === "text"}
|
||||
{node.text}
|
||||
{:else}
|
||||
{@render entity(node)}
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet entity(node: EntityNode)}
|
||||
{@const type = node.entity.type}
|
||||
{#if type === "bold"}
|
||||
<strong>{@render tree(node.children)}</strong>
|
||||
{:else if type === "italic"}
|
||||
<em>{@render tree(node.children)}</em>
|
||||
{:else if type === "underline"}
|
||||
<ins>{@render tree(node.children)}</ins>
|
||||
{:else if type === "strikethrough"}
|
||||
<del>{@render tree(node.children)}</del>
|
||||
{:else if type === "spoiler"}
|
||||
{@const key = spoilerKey(node.entity)}
|
||||
<button
|
||||
type="button"
|
||||
class="spoiler"
|
||||
class:revealed={revealed.has(key)}
|
||||
onclick={() => revealed.add(key)}
|
||||
>
|
||||
{@render tree(node.children)}
|
||||
</button>
|
||||
{:else if type === "code"}
|
||||
<code class="code" class:own>{@render tree(node.children)}</code>
|
||||
{:else if type === "pre"}
|
||||
<pre
|
||||
class="pre"
|
||||
class:own
|
||||
>{#if node.entity.language}<span class="pre-lang">{node.entity.language}</span>{/if}<code>{@render tree(node.children)}</code></pre>
|
||||
{:else if type === "blockquote"}
|
||||
<blockquote class="blockquote">{@render tree(node.children)}</blockquote>
|
||||
{:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"}
|
||||
<a
|
||||
class="link"
|
||||
href={linkHref(node)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{@render tree(node.children)}</a
|
||||
>
|
||||
{:else if type === "mention" || type === "text_mention" || type === "hashtag" || type === "cashtag" || type === "bot_command"}
|
||||
<span class="link">{@render tree(node.children)}</span>
|
||||
{:else}
|
||||
{@render tree(node.children)}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<span class="EntityText" class:jumbo={jumbo > 0} class:jumbo-1={jumbo === 1}>
|
||||
{@render tree(nodes)}
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.EntityText {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.jumbo {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
&.jumbo-1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-links);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.code,
|
||||
.pre {
|
||||
font-family: var(--font-family-monospace, monospace);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-code);
|
||||
background-color: var(--color-code-bg);
|
||||
|
||||
&.own {
|
||||
color: var(--color-code-own);
|
||||
background-color: var(--color-code-own-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.code {
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.pre {
|
||||
overflow-x: auto;
|
||||
|
||||
display: block;
|
||||
margin: 0.125rem 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.pre-lang {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.blockquote {
|
||||
margin: 0.125rem 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-left: 0.1875rem solid var(--color-links);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.spoiler {
|
||||
cursor: pointer;
|
||||
|
||||
padding: 0;
|
||||
border: none;
|
||||
|
||||
font: inherit;
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
|
||||
background-color: var(--color-text);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
|
||||
&.revealed {
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { folders } from "$lib/stores/folders.svelte";
|
||||
|
||||
interface Tab {
|
||||
id: number | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const tabs = $derived<Tab[]>([
|
||||
{ id: null, title: "All Chats" },
|
||||
...folders.list.map((folder) => ({
|
||||
id: folder.folder_id,
|
||||
title: folder.title,
|
||||
})),
|
||||
]);
|
||||
|
||||
const activeTab = $derived(
|
||||
Math.max(
|
||||
0,
|
||||
tabs.findIndex((tab) => tab.id === folders.selectedId)
|
||||
)
|
||||
);
|
||||
|
||||
let containerEl = $state<HTMLDivElement>();
|
||||
let indicatorEl = $state<HTMLDivElement>();
|
||||
let clipPath = $state("");
|
||||
|
||||
function updateClipPath() {
|
||||
const indicator = indicatorEl;
|
||||
const activeEl = indicator?.children[activeTab] as HTMLElement | undefined;
|
||||
if (!(indicator && activeEl) || indicator.offsetWidth === 0) {
|
||||
return;
|
||||
}
|
||||
const { offsetLeft, offsetWidth } = activeEl;
|
||||
const width = indicator.offsetWidth;
|
||||
const left = ((offsetLeft / width) * 100).toFixed(1);
|
||||
const right = (
|
||||
((width - (offsetLeft + offsetWidth)) / width) *
|
||||
100
|
||||
).toFixed(1);
|
||||
clipPath = `inset(0.25rem ${right}% 0.25rem ${left}% round var(--tab-radius))`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const index = activeTab;
|
||||
const total = tabs.length;
|
||||
updateClipPath();
|
||||
const baseEl =
|
||||
total > 0
|
||||
? (containerEl?.children[index] as HTMLElement | undefined)
|
||||
: undefined;
|
||||
baseEl?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!indicatorEl) {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => updateClipPath());
|
||||
observer.observe(indicatorEl);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={containerEl} class="container" class:ready={clipPath !== ""}>
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button type="button" class="tab" onclick={() => folders.select(tab.id)}>
|
||||
{tab.title}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<div
|
||||
bind:this={indicatorEl}
|
||||
class="active-indicator"
|
||||
style={clipPath ? `clip-path: ${clipPath}` : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button type="button" class="tab" tabindex="-1">{tab.title}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.container,
|
||||
.active-indicator {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 0.375rem;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
--tab-radius: 1.25rem;
|
||||
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
position: relative;
|
||||
|
||||
overflow-x: auto;
|
||||
|
||||
border-radius: 1.5rem;
|
||||
|
||||
opacity: 0;
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
transition: opacity 150ms;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.ready {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
pointer-events: none;
|
||||
will-change: clip-path;
|
||||
|
||||
isolation: isolate;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
|
||||
contain: layout style paint;
|
||||
overflow: hidden;
|
||||
|
||||
width: fit-content;
|
||||
|
||||
background-color: var(--color-primary-opacity);
|
||||
|
||||
transition: clip-path var(--slide-transition);
|
||||
}
|
||||
|
||||
.tab {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.375rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--tab-radius);
|
||||
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
appearance: none;
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.active-indicator & {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { ForwardView } from "$lib/api/types";
|
||||
import { peerName } from "$lib/format/peer";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
interface Props {
|
||||
forward: ForwardView;
|
||||
}
|
||||
|
||||
let { forward }: Props = $props();
|
||||
|
||||
const peer = $derived(
|
||||
forward.from_id === null ? undefined : peers.get(forward.from_id)
|
||||
);
|
||||
const name = $derived.by(() => {
|
||||
if (forward.kind === "channel") {
|
||||
return forward.chat_title ?? "Channel";
|
||||
}
|
||||
if (forward.kind === "hidden") {
|
||||
return forward.from_name ?? "Hidden account";
|
||||
}
|
||||
if (peer) {
|
||||
return peerName(peer);
|
||||
}
|
||||
return forward.from_name ?? "Unknown";
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ForwardHeader">
|
||||
<span class="label">Forwarded from</span>
|
||||
<span class="name">{name}</span>
|
||||
{#if forward.signature}
|
||||
<span class="signature">({forward.signature})</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.ForwardHeader {
|
||||
overflow: hidden;
|
||||
|
||||
margin-bottom: 0.1875rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.signature {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { auth } from "$lib/stores/auth.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
let value = $state("");
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!value.trim() || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
auth.login(value.trim());
|
||||
try {
|
||||
await accounts.load();
|
||||
await goto("/app");
|
||||
} catch {
|
||||
toasts.error("Invalid token");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login">
|
||||
<form class="login-card" onsubmit={submit}>
|
||||
<div class="logo">
|
||||
<Icon name="lock" size="2.75rem" />
|
||||
</div>
|
||||
<h1>Beavergram</h1>
|
||||
<p class="subtitle">Enter your access token to continue.</p>
|
||||
<input
|
||||
class="form-control"
|
||||
type="password"
|
||||
placeholder="Access token"
|
||||
autocomplete="current-password"
|
||||
bind:value
|
||||
>
|
||||
<Button type="submit" loading={busy} disabled={busy || !value.trim()}>
|
||||
Log in
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 2.5rem 2rem;
|
||||
border-radius: var(--border-radius-modal);
|
||||
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-white);
|
||||
background-image: linear-gradient(var(--color-primary), var(--color-primary-shade));
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 1.75rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
height: 3.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--color-borders-input);
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { MediaRef } from "$lib/api/types";
|
||||
import AlbumTile from "$lib/components/media/AlbumTile.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
media: MediaRef[];
|
||||
onopen: (index: number) => void;
|
||||
}
|
||||
|
||||
let { media, chatId, onopen }: Props = $props();
|
||||
|
||||
const columns = $derived.by(() => {
|
||||
const count = media.length;
|
||||
if (count === 2) {
|
||||
return 2;
|
||||
}
|
||||
if (count === 4) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="MediaAlbum" style:--cols={columns}>
|
||||
{#each media as item, index (item.id ?? index)}
|
||||
<AlbumTile media={item} {chatId} onopen={() => onopen(index)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.MediaAlbum {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), 1fr);
|
||||
gap: 0.125rem;
|
||||
|
||||
overflow: hidden;
|
||||
max-width: 20rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { type MediaResult, requestMediaVersion } from "$lib/api/client";
|
||||
import { visualKind } from "$lib/api/media";
|
||||
import type { MediaVersion } from "$lib/api/types";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
|
||||
interface Props {
|
||||
version: MediaVersion;
|
||||
}
|
||||
|
||||
let { version }: Props = $props();
|
||||
|
||||
let result = $state<MediaResult | null>(null);
|
||||
|
||||
const vk = $derived(visualKind(version.kind));
|
||||
|
||||
$effect(() => {
|
||||
let active = true;
|
||||
requestMediaVersion(version.id).then((value) => {
|
||||
if (active) {
|
||||
result = value;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="thumb">
|
||||
{#if result === null}
|
||||
<div class="placeholder"><Spinner /></div>
|
||||
{:else if result.state === "ready" && vk === "image"}
|
||||
<a href={result.url} target="_blank" rel="noopener">
|
||||
<img src={result.url} alt={version.kind}>
|
||||
</a>
|
||||
{:else if result.state === "ready" && vk === "video"}
|
||||
<a href={result.url} target="_blank" rel="noopener">
|
||||
<video src={result.url} muted preload="metadata"></video>
|
||||
<span class="play"><Icon name="large-play" size="1.5rem" /></span>
|
||||
</a>
|
||||
{:else if result.state === "ready"}
|
||||
<a class="file" href={result.url} target="_blank" rel="noopener">
|
||||
<Icon name="document" size="1.125rem" />
|
||||
<span>{version.kind}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="placeholder missing">
|
||||
<Icon name="no-download" size="1.25rem" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.thumb {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
display: block;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
object-fit: cover;
|
||||
background-color: var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--color-white);
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
|
||||
padding: 0.5rem 0.625rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-default-shadow);
|
||||
|
||||
&.missing {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import { untrack } from "svelte";
|
||||
import { type MediaResult, requestMedia } from "$lib/api/client";
|
||||
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints";
|
||||
import type { ViewerItem } from "$lib/api/media";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
index: number;
|
||||
items: ViewerItem[];
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
index = $bindable(),
|
||||
chatId,
|
||||
items,
|
||||
}: Props = $props();
|
||||
|
||||
let kind = $state("");
|
||||
let messageId = $state<number | null>(null);
|
||||
let result = $state<MediaResult | null>(null);
|
||||
let loading = $state(false);
|
||||
let token = 0;
|
||||
|
||||
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
|
||||
const isImage = $derived(mime.startsWith("image/") || kind === "photo");
|
||||
const isVideo = $derived(
|
||||
mime.startsWith("video/") || kind === "video" || kind === "video_note"
|
||||
);
|
||||
const isAudio = $derived(
|
||||
mime.startsWith("audio/") || kind === "voice" || kind === "audio"
|
||||
);
|
||||
const hasNav = $derived(items.length > 1);
|
||||
|
||||
function revoke() {
|
||||
if (result?.state === "ready") {
|
||||
URL.revokeObjectURL(result.url);
|
||||
}
|
||||
}
|
||||
|
||||
async function load(item: ViewerItem) {
|
||||
revoke();
|
||||
loading = true;
|
||||
result = null;
|
||||
kind = item.kind;
|
||||
messageId = item.messageId;
|
||||
const current = ++token;
|
||||
try {
|
||||
let mediaId = item.mediaId;
|
||||
let downloaded = item.downloaded;
|
||||
if (mediaId === null) {
|
||||
const meta = await getMessageMedia(chatId, item.messageId);
|
||||
mediaId = meta.id;
|
||||
downloaded = meta.downloaded;
|
||||
kind = meta.kind;
|
||||
}
|
||||
const next = downloaded
|
||||
? await requestMedia(mediaId)
|
||||
: ({ state: "not-downloaded" } as MediaResult);
|
||||
if (current === token) {
|
||||
result = next;
|
||||
}
|
||||
} catch {
|
||||
if (current === token) {
|
||||
toasts.error("Failed to load media");
|
||||
}
|
||||
} finally {
|
||||
if (current === token) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queueFetch() {
|
||||
if (messageId === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetchMedia(chatId, messageId);
|
||||
toasts.success("Download queued");
|
||||
} catch {
|
||||
toasts.error("Failed to queue download");
|
||||
}
|
||||
}
|
||||
|
||||
function step(delta: number) {
|
||||
const next = index + delta;
|
||||
if (next >= 0 && next < items.length) {
|
||||
index = next;
|
||||
}
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (!(open && hasNav)) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
untrack(() => {
|
||||
revoke();
|
||||
result = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="media-overlay" />
|
||||
<Dialog.Content class="media-content">
|
||||
<Dialog.Title class="media-title">{kind || "Media"}</Dialog.Title>
|
||||
<Dialog.Close class="media-close" aria-label="Close">
|
||||
<Icon name="close" size="1.5rem" />
|
||||
</Dialog.Close>
|
||||
{#if hasNav}
|
||||
<span class="media-counter">{index + 1} / {items.length}</span>
|
||||
{/if}
|
||||
<div class="media-body">
|
||||
{#if loading}
|
||||
<Spinner color="white" />
|
||||
{:else if result?.state === "ready" && isImage}
|
||||
<img class="media-image" src={result.url} alt={kind}>
|
||||
{:else if result?.state === "ready" && isVideo}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
||||
<video class="media-video" src={result.url} controls></video>
|
||||
{:else if result?.state === "ready" && isAudio}
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
||||
<audio src={result.url} controls></audio>
|
||||
{:else if result?.state === "ready"}
|
||||
<a class="media-download" href={result.url} download>
|
||||
<Icon name="download" />
|
||||
Download file
|
||||
</a>
|
||||
{:else if result?.state === "not-downloaded"}
|
||||
<div class="media-message">
|
||||
<p>This media has not been downloaded yet.</p>
|
||||
<Button variant="primary" fluid onclick={queueFetch}>
|
||||
Fetch media
|
||||
</Button>
|
||||
</div>
|
||||
{:else if result?.state === "missing"}
|
||||
<p class="media-message">Media not found.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasNav}
|
||||
<button
|
||||
class="media-nav prev"
|
||||
type="button"
|
||||
aria-label="Previous"
|
||||
disabled={index === 0}
|
||||
onclick={() => step(-1)}
|
||||
>
|
||||
<Icon name="previous" size="1.75rem" />
|
||||
</button>
|
||||
<button
|
||||
class="media-nav next"
|
||||
type="button"
|
||||
aria-label="Next"
|
||||
disabled={index === items.length - 1}
|
||||
onclick={() => step(1)}
|
||||
>
|
||||
<Icon name="next" size="1.75rem" />
|
||||
</button>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.media-overlay) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-media-viewer);
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
:global(.media-content) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-media-viewer);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.media-title) {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1.25rem;
|
||||
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-white);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.media-counter {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
:global(.media-close) {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 1rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-white);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.media-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.media-image,
|
||||
.media-video {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
border-radius: var(--border-radius-default);
|
||||
}
|
||||
|
||||
.media-message {
|
||||
max-width: 22rem;
|
||||
color: var(--color-white);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-download {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
color: var(--color-white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.media-nav {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-white);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&.prev {
|
||||
left: 1.25rem;
|
||||
}
|
||||
|
||||
&.next {
|
||||
right: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script lang="ts">
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import EntityText from "$lib/components/EntityText.svelte";
|
||||
import ForwardHeader from "$lib/components/ForwardHeader.svelte";
|
||||
import MediaAlbum from "$lib/components/MediaAlbum.svelte";
|
||||
import MessageMedia from "$lib/components/MessageMedia.svelte";
|
||||
import MessageMeta from "$lib/components/MessageMeta.svelte";
|
||||
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
|
||||
import Avatar from "$lib/components/ui/Avatar.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
import { appear } from "$lib/transitions/appear";
|
||||
|
||||
interface Props {
|
||||
animate: boolean;
|
||||
firstInGroup: boolean;
|
||||
highlighted: boolean;
|
||||
isGroupChat: boolean;
|
||||
lastInGroup: boolean;
|
||||
message: MessageView;
|
||||
onjump: (messageId: number) => void;
|
||||
onmedia: (index: number) => void;
|
||||
onversions: () => void;
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
message,
|
||||
own,
|
||||
isGroupChat,
|
||||
firstInGroup,
|
||||
lastInGroup,
|
||||
highlighted,
|
||||
animate,
|
||||
onjump,
|
||||
onmedia,
|
||||
onversions,
|
||||
}: Props = $props();
|
||||
|
||||
const deleted = $derived(message.deleted_at !== null);
|
||||
const hasText = $derived(Boolean(message.text));
|
||||
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const sender = $derived(
|
||||
message.sender_id === null ? undefined : peers.get(message.sender_id)
|
||||
);
|
||||
const senderName = $derived.by(() => {
|
||||
if (own) {
|
||||
return accounts.selected ? accountName(accounts.selected) : "You";
|
||||
}
|
||||
if (sender) {
|
||||
return peerName(sender);
|
||||
}
|
||||
return message.sender_id === null ? "" : String(message.sender_id);
|
||||
});
|
||||
const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0));
|
||||
const showName = $derived(isGroupChat && !own && firstInGroup);
|
||||
|
||||
const avatarId = $derived(own ? ownId : message.sender_id);
|
||||
const avatarHas = $derived(
|
||||
own
|
||||
? ownId !== null && peers.get(ownId)?.has_avatar
|
||||
: (sender?.has_avatar ?? false)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="Message"
|
||||
class:own
|
||||
class:deleted
|
||||
class:highlighted
|
||||
class:first-in-group={firstInGroup}
|
||||
class:last-in-group={lastInGroup}
|
||||
class:with-avatar={isGroupChat}
|
||||
data-message-id={message.message_id}
|
||||
in:appear={{ disabled: !animate }}
|
||||
>
|
||||
{#if isGroupChat}
|
||||
<div class="avatar-slot">
|
||||
{#if lastInGroup && avatarId !== null}
|
||||
<Avatar
|
||||
name={senderName}
|
||||
colorKey={avatarId}
|
||||
size={2.125}
|
||||
avatar={{ kind: "peer", id: avatarId }}
|
||||
hasAvatar={avatarHas}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="message-content" class:has-appendix={lastInGroup}>
|
||||
{#if showName}
|
||||
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
|
||||
{/if}
|
||||
{#if message.forward}
|
||||
<ForwardHeader forward={message.forward} />
|
||||
{/if}
|
||||
{#if message.reply}
|
||||
<ReplyHeader reply={message.reply} {onjump} />
|
||||
{/if}
|
||||
{#if deleted}
|
||||
<span class="deleted-tag">
|
||||
<Icon name="delete" size="0.875rem" />
|
||||
deleted
|
||||
</span>
|
||||
{/if}
|
||||
{#if message.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 hasText}
|
||||
<div class="text">
|
||||
<EntityText
|
||||
text={message.text ?? ""}
|
||||
entities={message.entities}
|
||||
{own}
|
||||
/>
|
||||
</div>
|
||||
{:else if !(message.has_media || deleted)}
|
||||
<div class="text empty">(no text)</div>
|
||||
{/if}
|
||||
<MessageMeta {message} {onversions} />
|
||||
{#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">
|
||||
.Message {
|
||||
--background-color: var(--color-background);
|
||||
--meta-color: var(--color-text-meta);
|
||||
|
||||
position: relative;
|
||||
transform-origin: bottom left;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.4375rem;
|
||||
margin-bottom: 0.125rem;
|
||||
|
||||
&.last-in-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&.own {
|
||||
--background-color: var(--color-background-own);
|
||||
--meta-color: var(--color-message-meta-own);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-slot {
|
||||
flex-shrink: 0;
|
||||
width: 2.125rem;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
position: relative;
|
||||
|
||||
max-width: min(30rem, 75%);
|
||||
min-width: 3.5rem;
|
||||
padding: 0.3125rem 0.5rem 0.375rem;
|
||||
border-radius: var(--border-radius-messages);
|
||||
|
||||
font-size: 1rem;
|
||||
line-height: 1.3125;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: var(--background-color);
|
||||
box-shadow: 0 1px 2px var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.Message.highlighted .message-content {
|
||||
animation: highlight-flash 1.6s ease;
|
||||
}
|
||||
|
||||
@keyframes highlight-flash {
|
||||
0%,
|
||||
60% {
|
||||
background-color: var(--color-primary-opacity);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
}
|
||||
|
||||
.Message:not(.first-in-group) .message-content {
|
||||
border-top-left-radius: var(--border-radius-messages-small);
|
||||
}
|
||||
|
||||
.Message:not(.last-in-group) .message-content {
|
||||
border-bottom-left-radius: var(--border-radius-messages-small);
|
||||
}
|
||||
|
||||
.Message.last-in-group .message-content.has-appendix {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.0625rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.empty {
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.deleted .text {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.deleted-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.svg-appendix {
|
||||
position: absolute;
|
||||
bottom: -0.0625rem;
|
||||
left: -0.5rem;
|
||||
|
||||
width: 9px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.svg-appendix .corner {
|
||||
fill: var(--background-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { listMessages } from "$lib/api/endpoints";
|
||||
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import MediaViewer from "$lib/components/MediaViewer.svelte";
|
||||
import MessageBubble from "$lib/components/MessageBubble.svelte";
|
||||
import MessageVersions from "$lib/components/MessageVersions.svelte";
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { formatDay } from "$lib/format/datetime";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
}
|
||||
|
||||
let { chatId }: Props = $props();
|
||||
|
||||
const skeletonBubbles = [42, 66, 28, 54, 38, 72, 46];
|
||||
|
||||
const PAGE = 60;
|
||||
const SCROLL_THRESHOLD = 160;
|
||||
const STICK_OFFSET = 9;
|
||||
const IDLE_DELAY = 1500;
|
||||
|
||||
let messages = $state<MessageView[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadingOlder = $state(false);
|
||||
let hasMore = $state(true);
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
let suppressAppear = $state(false);
|
||||
let scrolling = $state(false);
|
||||
let stuckDay = $state<string | null>(null);
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
let mediaOpen = $state(false);
|
||||
let mediaItems = $state<ViewerItem[]>([]);
|
||||
let mediaIndex = $state(0);
|
||||
let versionsOpen = $state(false);
|
||||
let versionsMessageId = $state<number | null>(null);
|
||||
let highlightId = $state<number | null>(null);
|
||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const isGroupChat = $derived(chatId < 0);
|
||||
|
||||
interface Row {
|
||||
dayKey: string;
|
||||
daySeparator: string | null;
|
||||
firstInGroup: boolean;
|
||||
lastInGroup: boolean;
|
||||
message: MessageView;
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
function dayKey(iso: string): string {
|
||||
return new Date(iso).toDateString();
|
||||
}
|
||||
|
||||
const rows = $derived.by<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
const prev = messages[i - 1];
|
||||
const next = messages[i + 1];
|
||||
const day = dayKey(message.date);
|
||||
const samePrevDay = prev ? dayKey(prev.date) === day : false;
|
||||
const sameNextDay = next ? dayKey(next.date) === day : false;
|
||||
out.push({
|
||||
message,
|
||||
dayKey: day,
|
||||
own: ownId !== null && message.sender_id === ownId,
|
||||
firstInGroup:
|
||||
!prev || prev.sender_id !== message.sender_id || !samePrevDay,
|
||||
lastInGroup:
|
||||
!next || next.sender_id !== message.sender_id || !sameNextDay,
|
||||
daySeparator: samePrevDay ? null : formatDay(message.date),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function ensurePeers(items: MessageView[]) {
|
||||
const ids = new Set<number>();
|
||||
for (const message of items) {
|
||||
if (message.sender_id !== null) {
|
||||
ids.add(message.sender_id);
|
||||
}
|
||||
if (message.reply?.sender_id != null) {
|
||||
ids.add(message.reply.sender_id);
|
||||
}
|
||||
if (message.forward?.from_id != null) {
|
||||
ids.add(message.forward.from_id);
|
||||
}
|
||||
}
|
||||
if (ownId !== null) {
|
||||
ids.add(ownId);
|
||||
}
|
||||
peers.ensure(ids);
|
||||
}
|
||||
|
||||
function jumpToMessage(messageId: number) {
|
||||
const target = container?.querySelector<HTMLElement>(
|
||||
`[data-message-id="${messageId}"]`
|
||||
);
|
||||
if (!target) {
|
||||
toasts.error("Message not loaded");
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
highlightId = messageId;
|
||||
if (highlightTimer) {
|
||||
clearTimeout(highlightTimer);
|
||||
}
|
||||
highlightTimer = setTimeout(() => {
|
||||
highlightId = null;
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStuck() {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
|
||||
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
|
||||
let day: string | null = null;
|
||||
for (const sep of seps) {
|
||||
if (sep.getBoundingClientRect().top <= limit) {
|
||||
day = sep.dataset.day ?? null;
|
||||
}
|
||||
}
|
||||
stuckDay = day;
|
||||
}
|
||||
|
||||
async function loadInitial() {
|
||||
loading = true;
|
||||
hasMore = true;
|
||||
try {
|
||||
const page = await listMessages(chatId, {
|
||||
limit: PAGE,
|
||||
offset: 0,
|
||||
include_deleted: true,
|
||||
});
|
||||
messages = [...page].reverse();
|
||||
hasMore = page.length === PAGE;
|
||||
ensurePeers(messages);
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
updateStuck();
|
||||
} catch {
|
||||
toasts.error("Failed to load messages");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (loadingOlder || !hasMore || container === null) {
|
||||
return;
|
||||
}
|
||||
loadingOlder = true;
|
||||
suppressAppear = true;
|
||||
const el = container;
|
||||
const prevHeight = el.scrollHeight;
|
||||
const prevTop = el.scrollTop;
|
||||
try {
|
||||
const page = await listMessages(chatId, {
|
||||
limit: PAGE,
|
||||
offset: messages.length,
|
||||
include_deleted: true,
|
||||
});
|
||||
if (page.length > 0) {
|
||||
messages = [...[...page].reverse(), ...messages];
|
||||
ensurePeers(page);
|
||||
}
|
||||
hasMore = page.length === PAGE;
|
||||
await tick();
|
||||
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||
} finally {
|
||||
loadingOlder = false;
|
||||
suppressAppear = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
scrolling = true;
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
idleTimer = setTimeout(() => {
|
||||
scrolling = false;
|
||||
}, IDLE_DELAY);
|
||||
updateStuck();
|
||||
if (container && container.scrollTop < SCROLL_THRESHOLD) {
|
||||
loadOlder();
|
||||
}
|
||||
}
|
||||
|
||||
function openMedia(message: MessageView, index: number) {
|
||||
mediaItems = viewerItemsFrom(message.message_id, message.media);
|
||||
mediaIndex = index;
|
||||
mediaOpen = true;
|
||||
}
|
||||
|
||||
function openVersions(messageId: number) {
|
||||
versionsMessageId = messageId;
|
||||
versionsOpen = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const deps = {
|
||||
account: accounts.selectedId,
|
||||
revision: chats.revision,
|
||||
};
|
||||
if (deps.account === null) {
|
||||
return;
|
||||
}
|
||||
loadInitial();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="message-list custom-scroll"
|
||||
onscroll={onScroll}
|
||||
>
|
||||
{#if loading && messages.length === 0}
|
||||
<div class="messages-container">
|
||||
{#each skeletonBubbles as width, index (index)}
|
||||
<div class="bubble-skeleton skeleton" style:width="{width}%"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if rows.length === 0}
|
||||
<EmptyState
|
||||
title="No messages"
|
||||
description="This chat has no archived messages"
|
||||
/>
|
||||
{:else}
|
||||
<div class="messages-container">
|
||||
{#if loadingOlder}
|
||||
<div class="loading-older"><Spinner /></div>
|
||||
{/if}
|
||||
{#each rows as row (row.message.message_id)}
|
||||
{#if row.daySeparator}
|
||||
<div
|
||||
class="day-separator"
|
||||
class:idle={!scrolling && row.dayKey === stuckDay}
|
||||
data-day={row.dayKey}
|
||||
>
|
||||
<span>{row.daySeparator}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<MessageBubble
|
||||
message={row.message}
|
||||
own={row.own}
|
||||
{isGroupChat}
|
||||
firstInGroup={row.firstInGroup}
|
||||
lastInGroup={row.lastInGroup}
|
||||
highlighted={highlightId === row.message.message_id}
|
||||
animate={!suppressAppear}
|
||||
onjump={jumpToMessage}
|
||||
onmedia={(index) => openMedia(row.message, index)}
|
||||
onversions={() => openVersions(row.message.message_id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<MediaViewer
|
||||
bind:open={mediaOpen}
|
||||
bind:index={mediaIndex}
|
||||
{chatId}
|
||||
items={mediaItems}
|
||||
/>
|
||||
<MessageVersions
|
||||
bind:open={versionsOpen}
|
||||
{chatId}
|
||||
messageId={versionsMessageId}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
min-height: 100%;
|
||||
padding: 1rem max(1rem, calc((100% - var(--messages-container-width)) / 2));
|
||||
}
|
||||
|
||||
.loading-older {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.bubble-skeleton {
|
||||
align-self: flex-start;
|
||||
|
||||
height: 2.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: var(--border-radius-messages);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.day-separator {
|
||||
pointer-events: none;
|
||||
|
||||
position: sticky;
|
||||
top: 0.5625rem;
|
||||
z-index: var(--z-sticky-date);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.75rem 0;
|
||||
|
||||
span {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: var(--color-default-shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&.idle span {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script lang="ts">
|
||||
import { visible } from "$lib/actions/visible";
|
||||
import { fetchMedia } from "$lib/api/endpoints";
|
||||
import {
|
||||
type InlineMedia,
|
||||
loadInlineMedia,
|
||||
visualKind,
|
||||
} from "$lib/api/media";
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import AudioFile from "$lib/components/media/AudioFile.svelte";
|
||||
import VideoNote from "$lib/components/media/VideoNote.svelte";
|
||||
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
message: MessageView;
|
||||
onopen: () => void;
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
let { message, onopen, own }: Props = $props();
|
||||
|
||||
const POLL_TRIES = 5;
|
||||
const POLL_DELAY = 3000;
|
||||
|
||||
let loaded = $state(false);
|
||||
let media = $state<InlineMedia | null>(null);
|
||||
let queuing = $state(false);
|
||||
|
||||
const ready = $derived(media?.state === "ready" ? media : null);
|
||||
const kind = $derived(ready?.kind ?? "");
|
||||
const mime = $derived(ready?.mime ?? "");
|
||||
const isImage = $derived(kind === "photo");
|
||||
const isStaticSticker = $derived(
|
||||
kind === "sticker" && mime.startsWith("image/")
|
||||
);
|
||||
const isVideoSticker = $derived(
|
||||
kind === "sticker" && mime.startsWith("video/")
|
||||
);
|
||||
const isTgsSticker = $derived(
|
||||
kind === "sticker" && mime === "application/x-tgsticker"
|
||||
);
|
||||
const isAnimation = $derived(kind === "animation" || kind === "gif");
|
||||
const isThumbVideo = $derived(kind === "video");
|
||||
|
||||
const vk = $derived(
|
||||
media && media.state !== "missing" ? visualKind(media.kind) : "other"
|
||||
);
|
||||
const label = $derived(
|
||||
media && media.state !== "missing" ? media.kind : "media"
|
||||
);
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
media = await loadInlineMedia(message.chat_id, message.message_id);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
for (let i = 0; i < POLL_TRIES; i++) {
|
||||
await delay(POLL_DELAY);
|
||||
const next = await loadInlineMedia(message.chat_id, message.message_id);
|
||||
if (next.state === "ready") {
|
||||
media = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queue() {
|
||||
if (queuing) {
|
||||
return;
|
||||
}
|
||||
queuing = true;
|
||||
try {
|
||||
await fetchMedia(message.chat_id, message.message_id);
|
||||
toasts.success("Download queued");
|
||||
poll();
|
||||
} catch {
|
||||
toasts.error("Failed to queue download");
|
||||
} finally {
|
||||
queuing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="message-media" use:visible={start}>
|
||||
{#if message.is_self_destruct}
|
||||
<button class="media-chip self-destruct" onclick={onopen} type="button">
|
||||
<Icon name="timer" size="1.25rem" />
|
||||
<span>Self-destruct media</span>
|
||||
</button>
|
||||
{:else if !loaded}
|
||||
<div class="media-skeleton"><Spinner /></div>
|
||||
{:else if ready && kind === "voice"}
|
||||
<VoiceMessage url={ready.url} transcript={ready.transcript} {own} />
|
||||
{:else if ready && kind === "video_note"}
|
||||
<VideoNote url={ready.url} transcript={ready.transcript} />
|
||||
{:else if ready && kind === "audio"}
|
||||
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} />
|
||||
{:else if ready && isImage}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<img src={ready.url} alt="attachment">
|
||||
</button>
|
||||
{:else if ready && isStaticSticker}
|
||||
<button class="media-sticker" onclick={onopen} type="button">
|
||||
<img src={ready.url} alt="sticker">
|
||||
</button>
|
||||
{:else if ready && isVideoSticker}
|
||||
<video
|
||||
class="media-sticker-video"
|
||||
src={ready.url}
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
{:else if ready && isTgsSticker}
|
||||
<div class="media-sticker tgs">
|
||||
<span class="tgs-emoji">{message.sticker?.emoji ?? "🎞"}</span>
|
||||
</div>
|
||||
{:else if ready && isAnimation}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<video src={ready.url} autoplay loop muted playsinline></video>
|
||||
<span class="gif-badge">GIF</span>
|
||||
</button>
|
||||
{:else if ready && isThumbVideo}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<video src={ready.url} muted preload="metadata"></video>
|
||||
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
|
||||
</button>
|
||||
{:else if ready}
|
||||
<button class="media-chip" onclick={onopen} type="button">
|
||||
<Icon name="document" size="1.25rem" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
{:else if media?.state === "not-downloaded" && vk !== "other"}
|
||||
<button class="media-placeholder" onclick={queue} type="button">
|
||||
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
||||
<span>{vk === "video" ? "Video" : "Photo"}</span>
|
||||
<small>{queuing ? "Queued" : "Tap to download"}</small>
|
||||
</button>
|
||||
{:else if media?.state === "not-downloaded"}
|
||||
<button class="media-chip" onclick={queue} type="button">
|
||||
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
|
||||
<span>{queuing ? "Queued" : `Download ${label}`}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="media-chip" onclick={onopen} type="button">
|
||||
<Icon name="photo" size="1.25rem" />
|
||||
<span>Media</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.message-media {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
background-color: var(--color-default-shadow);
|
||||
|
||||
img,
|
||||
video {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
max-height: 20rem;
|
||||
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.media-sticker {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
background: transparent;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.media-sticker-video {
|
||||
display: block;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tgs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.tgs-emoji {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gif-badge {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
left: 0.375rem;
|
||||
|
||||
padding: 0.0625rem 0.3125rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
display: flex;
|
||||
color: var(--color-white);
|
||||
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.media-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 12rem;
|
||||
height: 9rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
background-color: var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.media-placeholder {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
width: 12rem;
|
||||
height: 9rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
color: var(--color-primary);
|
||||
text-align: center;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.media-chip {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-primary);
|
||||
text-align: start;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
|
||||
&.self-destruct {
|
||||
color: var(--color-orange);
|
||||
background-color: var(--color-light-coral);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import { formatTime } from "$lib/format/datetime";
|
||||
|
||||
interface Props {
|
||||
message: MessageView;
|
||||
onversions?: () => void;
|
||||
}
|
||||
|
||||
let { message, onversions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="MessageMeta">
|
||||
{#if message.edited_at}
|
||||
<button
|
||||
type="button"
|
||||
class="edited"
|
||||
title="View edit history"
|
||||
onclick={onversions}
|
||||
>
|
||||
edited
|
||||
</button>
|
||||
{/if}
|
||||
<span class="time">{formatTime(message.date)}</span>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.MessageMeta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
float: right;
|
||||
margin-top: 0.125rem;
|
||||
margin-inline-start: 0.5rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
color: var(--meta-color, var(--color-text-meta));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edited {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import { getMediaVersions, listMessageVersions } from "$lib/api/endpoints";
|
||||
import type { MediaVersion, MessageVersion } from "$lib/api/types";
|
||||
import MediaVersionThumb from "$lib/components/MediaVersionThumb.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { formatFull } from "$lib/format/datetime";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
messageId: number | null;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(), chatId, messageId }: Props = $props();
|
||||
|
||||
let versions = $state<MessageVersion[]>([]);
|
||||
let mediaVersions = $state<MediaVersion[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && messageId !== null) {
|
||||
loading = true;
|
||||
mediaVersions = [];
|
||||
const id = messageId;
|
||||
Promise.all([
|
||||
listMessageVersions(chatId, id),
|
||||
getMediaVersions(chatId, id),
|
||||
])
|
||||
.then(([text, media]) => {
|
||||
versions = text;
|
||||
mediaVersions = media;
|
||||
})
|
||||
.catch(() => toasts.error("Failed to load edit history"))
|
||||
.finally(() => {
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<header class="dialog-head">
|
||||
<Dialog.Title class="dialog-title">Edit history</Dialog.Title>
|
||||
<Dialog.Close class="dialog-close" aria-label="Close">
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div class="versions">
|
||||
{#if loading}
|
||||
<div class="centered"><Spinner /></div>
|
||||
{:else if versions.length === 0 && mediaVersions.length === 0}
|
||||
<p class="empty">No versions recorded.</p>
|
||||
{:else}
|
||||
{#if mediaVersions.length > 0}
|
||||
<div class="media-versions">
|
||||
<span class="media-label">Media versions</span>
|
||||
<div class="media-strip">
|
||||
{#each mediaVersions as media (media.id)}
|
||||
<MediaVersionThumb version={media} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each versions as version, index (version.observed_at)}
|
||||
<div class="version">
|
||||
<div class="version-meta">
|
||||
<span class="version-label">Version {index + 1}</span>
|
||||
<span class="version-date"
|
||||
>{formatFull(version.observed_at)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="version-text">{version.text ?? "(no text)"}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.dialog-overlay) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
:global(.dialog-content) {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: min(32rem, 92vw);
|
||||
max-height: 80vh;
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
:global(.dialog-title) {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
:global(.dialog-close) {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: 0.375rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--color-text-secondary);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.versions {
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.media-versions {
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.media-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.media-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.version {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.version-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.version-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1rem 0;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import type { ReplyView } from "$lib/api/types";
|
||||
import { mediaKindLabel } from "$lib/format/media";
|
||||
import { peerColorIndex, peerName } from "$lib/format/peer";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
interface Props {
|
||||
onjump?: (messageId: number) => void;
|
||||
reply: ReplyView;
|
||||
}
|
||||
|
||||
let { reply, onjump }: Props = $props();
|
||||
|
||||
const sender = $derived(
|
||||
reply.sender_id === null ? undefined : peers.get(reply.sender_id)
|
||||
);
|
||||
const name = $derived(
|
||||
sender
|
||||
? peerName(sender)
|
||||
: (reply.sender_name ?? (reply.sender_id ? String(reply.sender_id) : ""))
|
||||
);
|
||||
const colorIndex = $derived(peerColorIndex(reply.sender_id ?? 0));
|
||||
const preview = $derived(
|
||||
reply.text || mediaKindLabel(reply.media_kind) || ""
|
||||
);
|
||||
const mediaOnly = $derived(!reply.text && reply.media_kind !== null);
|
||||
const canJump = $derived(reply.message_id !== null && onjump !== undefined);
|
||||
|
||||
function jump() {
|
||||
if (reply.message_id !== null) {
|
||||
onjump?.(reply.message_id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ReplyHeader peer-color-{colorIndex}"
|
||||
class:clickable={canJump}
|
||||
disabled={!canJump}
|
||||
onclick={jump}
|
||||
>
|
||||
<span class="bar"></span>
|
||||
<span class="body">
|
||||
<span class="title">{name || "Reply"}</span>
|
||||
<span class="preview" class:media={mediaOnly}>{preview}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.ReplyHeader {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
width: 100%;
|
||||
margin-bottom: 0.1875rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
text-align: left;
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.clickable:hover {
|
||||
background-color: var(--color-primary-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex-shrink: 0;
|
||||
width: 0.1875rem;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: currentColor;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview {
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.media {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { visible } from "$lib/actions/visible";
|
||||
import { fetchMedia } from "$lib/api/endpoints";
|
||||
import { type InlineMedia, loadMediaItem, visualKind } from "$lib/api/media";
|
||||
import type { MediaRef } from "$lib/api/types";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
media: MediaRef;
|
||||
onopen: () => void;
|
||||
}
|
||||
|
||||
let { media, chatId, onopen }: Props = $props();
|
||||
|
||||
let loaded = $state(false);
|
||||
let inline = $state<InlineMedia | null>(null);
|
||||
let queuing = $state(false);
|
||||
|
||||
const ready = $derived(inline?.state === "ready" ? inline : null);
|
||||
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
|
||||
|
||||
async function start() {
|
||||
inline = await loadMediaItem(media);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function queue() {
|
||||
if (queuing) {
|
||||
return;
|
||||
}
|
||||
queuing = true;
|
||||
try {
|
||||
await fetchMedia(chatId, media.message_id);
|
||||
toasts.success("Download queued");
|
||||
} catch {
|
||||
toasts.error("Failed to queue download");
|
||||
} finally {
|
||||
queuing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="AlbumTile" use:visible={start}>
|
||||
{#if ready && isVideo}
|
||||
<button class="tile" onclick={onopen} type="button">
|
||||
<video src={ready.url} muted preload="metadata"></video>
|
||||
<span class="play"><Icon name="large-play" size="2rem" /></span>
|
||||
</button>
|
||||
{:else if ready}
|
||||
<button class="tile" onclick={onopen} type="button">
|
||||
<img src={ready.url} alt="attachment">
|
||||
</button>
|
||||
{:else if inline?.state === "not-downloaded"}
|
||||
<button class="tile placeholder" onclick={queue} type="button">
|
||||
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
||||
</button>
|
||||
{:else if loaded}
|
||||
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
|
||||
{:else}
|
||||
<div class="tile placeholder"><Spinner /></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.AlbumTile {
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tile {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
background-color: var(--color-default-shadow);
|
||||
|
||||
img,
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
color: var(--color-white);
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { formatDuration } from "$lib/format/duration";
|
||||
import { claimPlayback, releasePlayback } from "$lib/media/playback";
|
||||
|
||||
interface Props {
|
||||
own: boolean;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let { url, title, own }: Props = $props();
|
||||
|
||||
let element = $state<HTMLAudioElement>();
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let paused = $state(true);
|
||||
|
||||
const progress = $derived(duration > 0 ? currentTime / duration : 0);
|
||||
|
||||
function toggle() {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
element.play().catch(() => undefined);
|
||||
} else {
|
||||
element.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function seek(event: PointerEvent) {
|
||||
if (!(element && duration > 0)) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget as HTMLElement;
|
||||
const ratio =
|
||||
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
|
||||
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="Audio" class:own>
|
||||
<button class="toggle" onclick={toggle} type="button" aria-label="Play audio">
|
||||
<Icon name={paused ? "play" : "pause"} size="1.625rem" />
|
||||
</button>
|
||||
<div class="content">
|
||||
<div class="title">{title}</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="seekline" onpointerdown={seek}>
|
||||
<div class="track"></div>
|
||||
<div class="fill" style:width={`${Math.round(progress * 100)}%`}></div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
{formatDuration(currentTime)}
|
||||
/ {formatDuration(duration)}
|
||||
</div>
|
||||
</div>
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived audio track has no captions -->
|
||||
<audio
|
||||
bind:this={element}
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:paused
|
||||
onended={() => element && releasePlayback(element)}
|
||||
onplay={() => element && claimPlayback(element)}
|
||||
preload="metadata"
|
||||
src={url}
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.Audio {
|
||||
--active: var(--color-primary);
|
||||
--toggle-fg: var(--color-white);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
min-width: 15rem;
|
||||
padding: 0.1875rem 0;
|
||||
|
||||
&.own {
|
||||
--active: var(--color-white);
|
||||
--toggle-fg: var(--color-background-own);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--toggle-fg);
|
||||
|
||||
background-color: var(--active);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seekline {
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
|
||||
position: relative;
|
||||
height: 1rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.track,
|
||||
.fill {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.track {
|
||||
width: 100%;
|
||||
background-color: var(--color-interactive-inactive);
|
||||
}
|
||||
|
||||
.fill {
|
||||
background-color: var(--active);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { formatDuration } from "$lib/format/duration";
|
||||
import { claimPlayback, releasePlayback } from "$lib/media/playback";
|
||||
|
||||
interface Props {
|
||||
transcript?: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let { url, transcript = null }: Props = $props();
|
||||
|
||||
let showTranscript = $state(false);
|
||||
|
||||
const RADIUS = 94;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
let element = $state<HTMLVideoElement>();
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let paused = $state(true);
|
||||
|
||||
const progress = $derived(duration > 0 ? currentTime / duration : 0);
|
||||
const remaining = $derived(Math.max(0, duration - currentTime));
|
||||
|
||||
function toggle() {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
element.play().catch(() => undefined);
|
||||
} else {
|
||||
element.pause();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="RoundVideoWrap">
|
||||
<button
|
||||
class="RoundVideo"
|
||||
onclick={toggle}
|
||||
type="button"
|
||||
aria-label="Play video message"
|
||||
>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
|
||||
<video
|
||||
bind:this={element}
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:paused
|
||||
onended={() => element && releasePlayback(element)}
|
||||
onplay={() => element && claimPlayback(element)}
|
||||
playsinline
|
||||
preload="metadata"
|
||||
src={url}
|
||||
></video>
|
||||
<svg class="ring" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle
|
||||
class="ring-progress"
|
||||
cx="100"
|
||||
cy="100"
|
||||
r={RADIUS}
|
||||
stroke-dasharray={CIRCUMFERENCE}
|
||||
stroke-dashoffset={CIRCUMFERENCE * (1 - progress)}
|
||||
/>
|
||||
</svg>
|
||||
{#if paused}
|
||||
<span class="play"><Icon name="large-play" size="2.75rem" /></span>
|
||||
{/if}
|
||||
<span class="badge">
|
||||
<Icon name="microphone" size="0.875rem" />
|
||||
{formatDuration(paused && currentTime === 0 ? duration : remaining)}
|
||||
</span>
|
||||
</button>
|
||||
{#if transcript}
|
||||
<button
|
||||
class="transcribe"
|
||||
class:active={showTranscript}
|
||||
onclick={() => (showTranscript = !showTranscript)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="transcribe" size="1rem" />
|
||||
Transcription
|
||||
</button>
|
||||
{#if showTranscript}
|
||||
<div class="transcript">{transcript}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.RoundVideoWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.transcribe {
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.transcript {
|
||||
max-width: 16rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.RoundVideo {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
display: block;
|
||||
|
||||
width: 13rem;
|
||||
height: 13rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
|
||||
object-fit: cover;
|
||||
background-color: var(--color-default-shadow);
|
||||
}
|
||||
|
||||
.ring {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: rotate(-90deg);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ring-progress {
|
||||
fill: transparent;
|
||||
stroke: var(--color-white);
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
stroke-opacity: 0.9;
|
||||
transition: stroke-dashoffset 0.2s linear;
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
display: flex;
|
||||
color: var(--color-white);
|
||||
|
||||
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { formatDuration } from "$lib/format/duration";
|
||||
import { claimPlayback, releasePlayback } from "$lib/media/playback";
|
||||
import { computeWaveform, flatWaveform } from "$lib/media/waveform";
|
||||
|
||||
interface Props {
|
||||
own: boolean;
|
||||
transcript?: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let { url, own, transcript = null }: Props = $props();
|
||||
|
||||
let showTranscript = $state(false);
|
||||
let element = $state<HTMLAudioElement>();
|
||||
let peaks = $state<number[]>(flatWaveform());
|
||||
let currentTime = $state(0);
|
||||
let duration = $state(0);
|
||||
let paused = $state(true);
|
||||
|
||||
const progress = $derived(duration > 0 ? currentTime / duration : 0);
|
||||
const elapsed = $derived(
|
||||
paused && currentTime === 0 ? duration : currentTime
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
let active = true;
|
||||
computeWaveform(url)
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
peaks = result;
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
element.play().catch(() => undefined);
|
||||
} else {
|
||||
element.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function seek(event: PointerEvent) {
|
||||
if (!(element && duration > 0)) {
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget as HTMLElement;
|
||||
const ratio =
|
||||
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
|
||||
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="VoiceWrap" class:own>
|
||||
<div class="Voice">
|
||||
<button
|
||||
class="toggle"
|
||||
onclick={toggle}
|
||||
type="button"
|
||||
aria-label="Play voice"
|
||||
>
|
||||
<Icon name={paused ? "play" : "pause"} size="1.5rem" />
|
||||
</button>
|
||||
<div class="body">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="waveform" onpointerdown={seek}>
|
||||
{#each peaks as peak, i (i)}
|
||||
<span
|
||||
class="bar"
|
||||
class:filled={i / peaks.length < progress}
|
||||
style:height={`${Math.round(peak * 100)}%`}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="time">{formatDuration(elapsed)}</div>
|
||||
</div>
|
||||
{#if transcript}
|
||||
<button
|
||||
class="transcribe"
|
||||
class:active={showTranscript}
|
||||
onclick={() => (showTranscript = !showTranscript)}
|
||||
type="button"
|
||||
aria-label="Show transcription"
|
||||
>
|
||||
<Icon name="transcribe" size="1.125rem" />
|
||||
</button>
|
||||
{/if}
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
|
||||
<audio
|
||||
bind:this={element}
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:paused
|
||||
onplay={() => element && claimPlayback(element)}
|
||||
onended={() => element && releasePlayback(element)}
|
||||
preload="metadata"
|
||||
src={url}
|
||||
></audio>
|
||||
</div>
|
||||
{#if transcript && showTranscript}
|
||||
<div class="transcript">{transcript}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.VoiceWrap {
|
||||
--active: var(--color-primary);
|
||||
--inactive: var(--color-interactive-inactive);
|
||||
--toggle-fg: var(--color-white);
|
||||
--voice-meta: var(--color-text-secondary);
|
||||
--transcribe-bg: var(--color-primary-tint);
|
||||
|
||||
&.own {
|
||||
--active: var(--color-white);
|
||||
--inactive: rgba(255, 255, 255, 0.45);
|
||||
--toggle-fg: var(--color-background-own);
|
||||
--voice-meta: rgba(255, 255, 255, 0.75);
|
||||
--transcribe-bg: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.Voice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
min-width: 13rem;
|
||||
padding: 0.1875rem 0;
|
||||
}
|
||||
|
||||
.transcribe {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--voice-meta);
|
||||
|
||||
background-color: var(--transcribe-bg);
|
||||
|
||||
&.active {
|
||||
color: var(--toggle-fg);
|
||||
background-color: var(--active);
|
||||
}
|
||||
}
|
||||
|
||||
.transcript {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--color-borders);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--toggle-fg);
|
||||
|
||||
background-color: var(--active);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
|
||||
height: 23px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
min-height: 2px;
|
||||
border-radius: 1px;
|
||||
|
||||
background-color: var(--inactive);
|
||||
|
||||
&.filled {
|
||||
background-color: var(--active);
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-top: 0.25rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--voice-meta);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { type AvatarKind, loadAvatar } from "$lib/api/avatars";
|
||||
import { initials as toInitials } from "$lib/format/peer";
|
||||
|
||||
interface Props {
|
||||
avatar?: { kind: AvatarKind; id: number } | null;
|
||||
colorKey?: number;
|
||||
deleted?: boolean;
|
||||
hasAvatar?: boolean;
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
colorKey = 0,
|
||||
size = 3.375,
|
||||
deleted = false,
|
||||
avatar = null,
|
||||
hasAvatar = false,
|
||||
}: Props = $props();
|
||||
|
||||
const gradients = [
|
||||
["#ff885e", "#ff516a"],
|
||||
["#ffcd6a", "#ffa85c"],
|
||||
["#82b1ff", "#665fff"],
|
||||
["#a0de7e", "#54cb68"],
|
||||
["#53edd6", "#28c9b7"],
|
||||
["#72d5fd", "#2a9ef1"],
|
||||
["#e0a2f3", "#d669ed"],
|
||||
];
|
||||
|
||||
const pair = $derived(gradients[Math.abs(colorKey) % gradients.length]);
|
||||
const initials = $derived(toInitials(name));
|
||||
|
||||
let url = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
url = null;
|
||||
if (deleted || !(hasAvatar && avatar)) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
|
||||
if (active) {
|
||||
url = resolved;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="Avatar"
|
||||
class:deleted
|
||||
style="
|
||||
--avatar-size: {size}rem;
|
||||
--avatar-from: {pair[0]};
|
||||
--avatar-to: {pair[1]};
|
||||
"
|
||||
>
|
||||
{#if url}
|
||||
<img class="photo" src={url} alt={name}>
|
||||
{:else}
|
||||
<span class="initials">{initials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.Avatar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
overflow: hidden;
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
border-radius: 50%;
|
||||
|
||||
font-size: calc(var(--avatar-size) * 0.4);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
color: var(--color-white);
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
|
||||
background-image: linear-gradient(var(--avatar-from), var(--avatar-to));
|
||||
|
||||
&.deleted {
|
||||
background-image: none;
|
||||
background-color: var(--color-deleted-account);
|
||||
}
|
||||
}
|
||||
|
||||
.photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
|
||||
type Variant =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "gray"
|
||||
| "danger"
|
||||
| "green"
|
||||
| "translucent"
|
||||
| "text";
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
children: Snippet;
|
||||
fluid?: boolean;
|
||||
loading?: boolean;
|
||||
pill?: boolean;
|
||||
round?: boolean;
|
||||
smaller?: boolean;
|
||||
tiny?: boolean;
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = "primary",
|
||||
round = false,
|
||||
tiny = false,
|
||||
smaller = false,
|
||||
pill = false,
|
||||
fluid = false,
|
||||
loading = false,
|
||||
type = "button",
|
||||
disabled = false,
|
||||
children,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="Button {variant}"
|
||||
class:round
|
||||
class:tiny
|
||||
class:smaller
|
||||
class:pill
|
||||
class:fluid
|
||||
class:loading
|
||||
class:disabled
|
||||
{type}
|
||||
{disabled}
|
||||
use:ripple
|
||||
{...rest}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.Button {
|
||||
--button-text-color: white;
|
||||
--button-background-color: transparent;
|
||||
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 3rem;
|
||||
padding: 0.625rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-button);
|
||||
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
color: var(--button-text-color);
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
|
||||
background-color: var(--button-background-color);
|
||||
outline: none;
|
||||
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s,
|
||||
opacity 0.2s;
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:not(.disabled):active,
|
||||
&:not(.disabled):focus {
|
||||
color: var(--button-active-text-color, var(--button-text-color));
|
||||
background-color: var(
|
||||
--button-active-background-color,
|
||||
var(--button-background-color)
|
||||
);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:not(.disabled):hover {
|
||||
color: var(--button-active-text-color, var(--button-text-color));
|
||||
background-color: var(
|
||||
--button-active-background-color,
|
||||
var(--button-background-color)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
--button-text-color: var(--color-white);
|
||||
--button-background-color: var(--color-primary);
|
||||
--button-active-background-color: var(--color-primary-shade);
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
--button-text-color: var(--color-text-secondary);
|
||||
--button-background-color: var(--color-background);
|
||||
--button-active-text-color: white;
|
||||
--button-active-background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
--button-text-color: var(--color-text-secondary);
|
||||
--button-background-color: var(--color-background);
|
||||
--button-active-text-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
--ripple-color: rgba(var(--color-error-rgb), 0.16);
|
||||
--button-text-color: var(--color-error);
|
||||
--button-background-color: var(--color-background);
|
||||
--button-active-text-color: var(--color-white);
|
||||
--button-active-background-color: var(--color-error);
|
||||
}
|
||||
|
||||
&.green {
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
--button-text-color: var(--color-white);
|
||||
--button-background-color: var(--color-green);
|
||||
--button-active-background-color: var(--color-green-darker);
|
||||
}
|
||||
|
||||
&.translucent {
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
--button-text-color: var(--color-text-secondary);
|
||||
--button-background-color: transparent;
|
||||
--button-active-background-color: var(--color-interactive-element-hover);
|
||||
}
|
||||
|
||||
&.text {
|
||||
--button-background-color: transparent;
|
||||
--button-text-color: var(--color-primary);
|
||||
--button-active-background-color: rgba(var(--color-primary-shade-rgb), 0.08);
|
||||
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
&.fluid {
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
&.pill {
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
border-radius: 1.75rem;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
&.smaller {
|
||||
height: 2.5rem;
|
||||
padding: 0.3125rem;
|
||||
|
||||
&.round {
|
||||
width: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.tiny {
|
||||
height: 2.25rem;
|
||||
padding: 0.4375rem;
|
||||
border-radius: var(--border-radius-button-tiny);
|
||||
font-size: 0.875rem;
|
||||
|
||||
&.round {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&.round {
|
||||
width: 3rem;
|
||||
border-radius: 50%;
|
||||
|
||||
:global(.icon) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
description?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
let { title, description }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="title">{title}</div>
|
||||
{#if description}
|
||||
<div class="description">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 100%;
|
||||
margin-bottom: 0.125rem;
|
||||
|
||||
font-size: 1.25rem;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
name: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
let { name, size, class: extra = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<i
|
||||
class="icon icon-{name} {extra}"
|
||||
style={size ? `font-size: ${size}` : undefined}
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { ripple } from "$lib/actions/ripple";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
onclick?: () => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
let { selected = false, onclick, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ListItem-button"
|
||||
class:selected
|
||||
use:ripple
|
||||
{onclick}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
.ListItem-button {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
font-size: 1rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: var(--color-white);
|
||||
background-color: var(--color-chat-active);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
circle?: boolean;
|
||||
height?: string;
|
||||
radius?: string;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
width = "100%",
|
||||
height = "1rem",
|
||||
radius = "0.375rem",
|
||||
circle = false,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton"
|
||||
style:width
|
||||
style:height
|
||||
style:border-radius={circle ? "50%" : radius}
|
||||
></div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
color?: "blue" | "white" | "gray";
|
||||
size?: string;
|
||||
}
|
||||
|
||||
let { size = "2rem", color = "blue" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="Spinner {color}" style="--spinner-size: {size}"></div>
|
||||
|
||||
<style lang="scss">
|
||||
.Spinner {
|
||||
width: var(--spinner-size);
|
||||
height: var(--spinner-size);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
animation: spinner-rotate 800ms linear infinite;
|
||||
|
||||
&.blue {
|
||||
background-image: var(--spinner-blue-data);
|
||||
}
|
||||
|
||||
&.white {
|
||||
background-image: var(--spinner-white-data);
|
||||
}
|
||||
|
||||
&.gray {
|
||||
background-image: var(--spinner-gray-data);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
</script>
|
||||
|
||||
<div class="Notification-container">
|
||||
{#each toasts.items as toast (toast.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="Notification {toast.type}"
|
||||
onclick={() => toasts.dismiss(toast.id)}
|
||||
>
|
||||
{toast.message}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.Notification-container {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
z-index: var(--z-notification);
|
||||
right: 0;
|
||||
bottom: 1.25rem;
|
||||
left: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.Notification {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
|
||||
max-width: 22rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-toast);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: var(--color-toast-background);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
|
||||
|
||||
&.error {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: var(--color-green);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user