735 lines
19 KiB
Svelte
735 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { tick, untrack } from "svelte";
|
|
import { listMessages } from "#lib/api/endpoints.js";
|
|
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
|
|
import type { LiveEvent, MessageView } from "#lib/api/types.js";
|
|
import ActionMessage from "#lib/components/ActionMessage.svelte";
|
|
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
|
import MessageBubble from "#lib/components/MessageBubble.svelte";
|
|
import MessageVersions from "#lib/components/MessageVersions.svelte";
|
|
import PinnedBar from "#lib/components/PinnedBar.svelte";
|
|
import TrackChat from "#lib/components/TrackChat.svelte";
|
|
import Icon from "#lib/components/ui/Icon.svelte";
|
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
|
import { formatDay } from "#lib/format/datetime.js";
|
|
import { t } from "#lib/i18n/locale.svelte.js";
|
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
|
import { chats } from "#lib/stores/chats.svelte.js";
|
|
import { discover } from "#lib/stores/discover.svelte.js";
|
|
import { events } from "#lib/stores/events.svelte.js";
|
|
import { peers } from "#lib/stores/peers.svelte.js";
|
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
import { ui } from "#lib/stores/ui.svelte.js";
|
|
|
|
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;
|
|
const MAX_MESSAGES = 360;
|
|
|
|
let messages = $state<MessageView[]>([]);
|
|
let loading = $state(true);
|
|
let loadingOlder = $state(false);
|
|
let loadingNewer = $state(false);
|
|
let hasMore = $state(true);
|
|
let hasNewer = $state(false);
|
|
let container = $state<HTMLDivElement | null>(null);
|
|
let suppressAppear = $state(false);
|
|
let scrolling = $state(false);
|
|
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 isChannel = $derived(
|
|
Boolean(chats.byId(chatId)?.is_broadcast) ||
|
|
discover.get(chatId)?.kind === "channel"
|
|
);
|
|
const isGroupChat = $derived(chatId < 0 && !isChannel);
|
|
|
|
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 += 1) {
|
|
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({
|
|
dayKey: day,
|
|
daySeparator: samePrevDay ? null : formatDay(message.date),
|
|
firstInGroup:
|
|
!prev || prev.sender_id !== message.sender_id || !samePrevDay,
|
|
lastInGroup:
|
|
!next || next.sender_id !== message.sender_id || !sameNextDay,
|
|
message,
|
|
own: ownId !== null && message.sender_id === ownId,
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
|
|
if (message.reply?.sender_id != null) {
|
|
ids.add(message.reply.sender_id);
|
|
}
|
|
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
|
|
if (message.forward?.from_id != null) {
|
|
ids.add(message.forward.from_id);
|
|
}
|
|
if (message.service?.member_ids) {
|
|
for (const id of message.service.member_ids) {
|
|
ids.add(id);
|
|
}
|
|
}
|
|
}
|
|
if (ownId !== null) {
|
|
ids.add(ownId);
|
|
}
|
|
peers.ensure(ids);
|
|
}
|
|
|
|
function memberIds(message: MessageView): number[] {
|
|
return [message.message_id, ...message.media.map((ref) => ref.message_id)];
|
|
}
|
|
|
|
function holds(message: MessageView, messageId: number): boolean {
|
|
return memberIds(message).includes(messageId);
|
|
}
|
|
|
|
function sameAlbum(a: MessageView, b: MessageView): boolean {
|
|
return (
|
|
a.media_group_id !== null &&
|
|
a.media_group_id === b.media_group_id &&
|
|
a.chat_id === b.chat_id
|
|
);
|
|
}
|
|
|
|
function albumPrimary(base: MessageView, incoming: MessageView) {
|
|
if (
|
|
base.message_id === incoming.message_id ||
|
|
(incoming.text && !base.text)
|
|
) {
|
|
return incoming;
|
|
}
|
|
if (base.text) {
|
|
return base;
|
|
}
|
|
return incoming.message_id < base.message_id ? incoming : base;
|
|
}
|
|
|
|
function joinAlbum(base: MessageView, incoming: MessageView): MessageView {
|
|
const media = new Map(base.media.map((ref) => [ref.message_id, ref]));
|
|
for (const ref of incoming.media) {
|
|
media.set(ref.message_id, ref);
|
|
}
|
|
return {
|
|
...albumPrimary(base, incoming),
|
|
media: [...media.values()].sort((a, b) => a.message_id - b.message_id),
|
|
};
|
|
}
|
|
|
|
function mergeAlbums(list: MessageView[]): MessageView[] {
|
|
const out: MessageView[] = [];
|
|
for (const message of list) {
|
|
const prev = out.at(-1);
|
|
if (prev && sameAlbum(prev, message)) {
|
|
out[out.length - 1] = joinAlbum(prev, message);
|
|
} else {
|
|
out.push(message);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function knownIds(): Set<number> {
|
|
return new Set(messages.flatMap(memberIds));
|
|
}
|
|
|
|
function ownerOf(messageId: number): number | null {
|
|
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
|
|
}
|
|
|
|
function jumpToMessage(messageId: number, smooth = true) {
|
|
const owner = ownerOf(messageId) ?? messageId;
|
|
const target = container?.querySelector<HTMLElement>(
|
|
`[data-message-id="${owner}"]`
|
|
);
|
|
if (!target) {
|
|
toasts.error(t("thread.notFound"));
|
|
return;
|
|
}
|
|
target.scrollIntoView({
|
|
behavior: smooth ? "smooth" : "auto",
|
|
block: "center",
|
|
});
|
|
highlightId = owner;
|
|
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;
|
|
hasNewer = false;
|
|
const pending = ui.jumpTarget?.chatId === chatId ? ui.jumpTarget : null;
|
|
try {
|
|
if (pending) {
|
|
ui.clearJump();
|
|
await loadAround(pending.messageId);
|
|
await tick();
|
|
jumpToMessage(pending.messageId, false);
|
|
return;
|
|
}
|
|
const page = await listMessages(chatId, {
|
|
include_deleted: true,
|
|
limit: PAGE,
|
|
offset: 0,
|
|
});
|
|
messages = mergeAlbums([...page].reverse());
|
|
hasMore = page.length > 0;
|
|
ensurePeers(messages);
|
|
await tick();
|
|
scrollToBottom();
|
|
updateStuck();
|
|
} catch {
|
|
toasts.error(t("thread.loadFailed"));
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function trimTail() {
|
|
if (messages.length <= MAX_MESSAGES) {
|
|
return;
|
|
}
|
|
messages = messages.slice(0, MAX_MESSAGES);
|
|
hasNewer = true;
|
|
}
|
|
|
|
async function trimHead() {
|
|
if (messages.length <= MAX_MESSAGES || container === null) {
|
|
return;
|
|
}
|
|
const el = container;
|
|
const prevHeight = el.scrollHeight;
|
|
const prevTop = el.scrollTop;
|
|
messages = messages.slice(messages.length - MAX_MESSAGES);
|
|
hasMore = true;
|
|
await tick();
|
|
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
|
}
|
|
|
|
async function loadOlder() {
|
|
if (
|
|
loadingOlder ||
|
|
!hasMore ||
|
|
container === null ||
|
|
messages.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
loadingOlder = true;
|
|
const el = container;
|
|
const prevHeight = el.scrollHeight;
|
|
const prevTop = el.scrollTop;
|
|
try {
|
|
const page = await listMessages(chatId, {
|
|
before_id: Math.min(...memberIds(messages[0])),
|
|
include_deleted: true,
|
|
limit: PAGE,
|
|
});
|
|
const known = knownIds();
|
|
const fresh = page.filter((m) =>
|
|
memberIds(m).some((id) => !known.has(id))
|
|
);
|
|
hasMore = fresh.length > 0;
|
|
if (fresh.length > 0) {
|
|
suppressAppear = true;
|
|
messages = mergeAlbums([...[...fresh].reverse(), ...messages]);
|
|
ensurePeers(fresh);
|
|
await tick();
|
|
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
|
trimTail();
|
|
}
|
|
} finally {
|
|
loadingOlder = false;
|
|
suppressAppear = false;
|
|
}
|
|
}
|
|
|
|
async function loadNewer() {
|
|
if (loadingNewer || !hasNewer || messages.length === 0) {
|
|
return;
|
|
}
|
|
loadingNewer = true;
|
|
try {
|
|
const last = messages.at(-1);
|
|
const page = await listMessages(chatId, {
|
|
after_id: last ? Math.max(...memberIds(last)) : undefined,
|
|
include_deleted: true,
|
|
limit: PAGE,
|
|
});
|
|
const known = knownIds();
|
|
const fresh = page.filter((m) =>
|
|
memberIds(m).some((id) => !known.has(id))
|
|
);
|
|
hasNewer = fresh.length > 0;
|
|
if (fresh.length > 0) {
|
|
suppressAppear = true;
|
|
messages = mergeAlbums([...messages, ...fresh]);
|
|
ensurePeers(fresh);
|
|
await tick();
|
|
await trimHead();
|
|
}
|
|
} finally {
|
|
loadingNewer = false;
|
|
suppressAppear = false;
|
|
}
|
|
}
|
|
|
|
async function loadAround(messageId: number) {
|
|
suppressAppear = true;
|
|
try {
|
|
const [older, newer] = await Promise.all([
|
|
listMessages(chatId, {
|
|
before_id: messageId + 1,
|
|
include_deleted: true,
|
|
limit: PAGE,
|
|
}),
|
|
listMessages(chatId, {
|
|
after_id: messageId,
|
|
include_deleted: true,
|
|
limit: PAGE,
|
|
}),
|
|
]);
|
|
messages = mergeAlbums([...[...older].reverse(), ...newer]);
|
|
hasMore = older.length > 0;
|
|
hasNewer = newer.length > 0;
|
|
ensurePeers(messages);
|
|
await tick();
|
|
} finally {
|
|
suppressAppear = false;
|
|
}
|
|
}
|
|
|
|
async function goToLatest() {
|
|
await loadInitial();
|
|
}
|
|
|
|
function onScroll() {
|
|
scrolling = true;
|
|
if (idleTimer) {
|
|
clearTimeout(idleTimer);
|
|
}
|
|
idleTimer = setTimeout(() => {
|
|
scrolling = false;
|
|
}, IDLE_DELAY);
|
|
updateStuck();
|
|
if (container && container.scrollTop < SCROLL_THRESHOLD) {
|
|
loadOlder();
|
|
}
|
|
if (hasNewer && isNearBottom()) {
|
|
loadNewer();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function isNearBottom(): boolean {
|
|
if (!container) {
|
|
return true;
|
|
}
|
|
return (
|
|
container.scrollHeight - container.scrollTop - container.clientHeight <
|
|
SCROLL_THRESHOLD
|
|
);
|
|
}
|
|
|
|
function replaceMessage(message: MessageView) {
|
|
messages = messages.map((m) => {
|
|
if (sameAlbum(m, message)) {
|
|
return joinAlbum(m, message);
|
|
}
|
|
return m.message_id === message.message_id ? message : m;
|
|
});
|
|
}
|
|
|
|
async function appendMessage(message: MessageView) {
|
|
const last = messages.at(-1);
|
|
if (
|
|
messages.some((m) => holds(m, message.message_id)) ||
|
|
(last && sameAlbum(last, message))
|
|
) {
|
|
replaceMessage(message);
|
|
return;
|
|
}
|
|
if (hasNewer) {
|
|
return;
|
|
}
|
|
const stick = isNearBottom();
|
|
messages = [...messages, message];
|
|
ensurePeers([message]);
|
|
await trimHead();
|
|
if (stick) {
|
|
await tick();
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
function applyDelete(ids: number[]) {
|
|
const now = new Date().toISOString();
|
|
messages = messages.map((m) =>
|
|
m.deleted_at === null && ids.some((id) => holds(m, id))
|
|
? { ...m, deleted_at: now }
|
|
: m
|
|
);
|
|
}
|
|
|
|
function applyReceipt(readUpTo: number, readAt: string) {
|
|
if (ownId === null) {
|
|
return;
|
|
}
|
|
messages = messages.map((m) =>
|
|
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
|
|
? { ...m, read: true, read_at: readAt }
|
|
: m
|
|
);
|
|
}
|
|
|
|
function applyLiveEvent(event: LiveEvent) {
|
|
if (event.type === "message" && event.message.chat_id === chatId) {
|
|
appendMessage(event.message);
|
|
} else if (
|
|
(event.type === "edit" || event.type === "reaction") &&
|
|
event.message.chat_id === chatId
|
|
) {
|
|
replaceMessage(event.message);
|
|
} else if (
|
|
event.type === "delete" &&
|
|
(event.chat_id === null || event.chat_id === chatId)
|
|
) {
|
|
applyDelete(event.message_ids);
|
|
} else if (event.type === "receipt" && event.chat_id === chatId) {
|
|
applyReceipt(event.read_up_to, event.read_at);
|
|
}
|
|
}
|
|
|
|
async function resyncTail() {
|
|
if (messages.length === 0 || hasNewer) {
|
|
return;
|
|
}
|
|
const page = await listMessages(chatId, {
|
|
include_deleted: true,
|
|
limit: PAGE,
|
|
offset: 0,
|
|
});
|
|
const fresh = [...page].reverse();
|
|
const known = new Map(messages.map((m) => [m.message_id, m]));
|
|
const maxId = Math.max(0, ...messages.flatMap(memberIds));
|
|
const stick = isNearBottom();
|
|
const appended: MessageView[] = [];
|
|
for (const message of fresh) {
|
|
const current = known.get(message.message_id);
|
|
if (current) {
|
|
known.set(
|
|
message.message_id,
|
|
sameAlbum(current, message) ? joinAlbum(current, message) : message
|
|
);
|
|
} else if (message.message_id > maxId) {
|
|
appended.push(message);
|
|
}
|
|
}
|
|
messages = mergeAlbums([
|
|
...messages.map((m) => known.get(m.message_id) ?? m),
|
|
...appended,
|
|
]);
|
|
ensurePeers(fresh);
|
|
await trimHead();
|
|
if (appended.length > 0 && stick) {
|
|
await tick();
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
async function goTo(messageId: number) {
|
|
try {
|
|
const loaded = ownerOf(messageId) !== null;
|
|
if (!loaded) {
|
|
await loadAround(messageId);
|
|
}
|
|
await tick();
|
|
jumpToMessage(messageId, loaded);
|
|
} catch {
|
|
toasts.error(t("thread.loadFailed"));
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const target = ui.jumpTarget;
|
|
if (target === null || target.chatId !== chatId || loading) {
|
|
return;
|
|
}
|
|
ui.clearJump();
|
|
goTo(target.messageId);
|
|
});
|
|
|
|
$effect(() => {
|
|
const unsub = events.subscribe(applyLiveEvent);
|
|
const unsubReconnect = events.onReconnect(resyncTail);
|
|
return () => {
|
|
unsub();
|
|
unsubReconnect();
|
|
};
|
|
});
|
|
|
|
$effect(() => {
|
|
if (accounts.selectedId === null) {
|
|
return;
|
|
}
|
|
untrack(() => loadInitial());
|
|
});
|
|
</script>
|
|
|
|
<div class="message-pane">
|
|
<PinnedBar {chatId} onjump={goTo} />
|
|
<div
|
|
class="message-list custom-scroll"
|
|
onscroll={onScroll}
|
|
bind:this={container}
|
|
>
|
|
{#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}
|
|
<TrackChat {chatId} />
|
|
{: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"
|
|
data-day={row.dayKey}
|
|
class:idle={!scrolling && row.dayKey === stuckDay}
|
|
>
|
|
<span>{row.daySeparator}</span>
|
|
</div>
|
|
{/if}
|
|
{#if row.message.service}
|
|
<ActionMessage message={row.message} onjump={goTo} />
|
|
{:else}
|
|
<MessageBubble
|
|
animate={!suppressAppear}
|
|
firstInGroup={row.firstInGroup}
|
|
highlighted={highlightId === row.message.message_id}
|
|
{isGroupChat}
|
|
lastInGroup={row.lastInGroup}
|
|
message={row.message}
|
|
onjump={goTo}
|
|
onmedia={(index) => openMedia(row.message, index)}
|
|
onversions={() => openVersions(row.message.message_id)}
|
|
own={row.own}
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
{#if loadingNewer}
|
|
<div class="loading-older"><Spinner /></div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if hasNewer}
|
|
<button
|
|
aria-label={t("thread.toLatest")}
|
|
class="to-latest"
|
|
onclick={goToLatest}
|
|
type="button"
|
|
>
|
|
<Icon name="arrow-down" />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<MediaViewer
|
|
{chatId}
|
|
items={mediaItems}
|
|
bind:index={mediaIndex}
|
|
bind:open={mediaOpen}
|
|
/>
|
|
<MessageVersions
|
|
{chatId}
|
|
messageId={versionsMessageId}
|
|
bind:open={versionsOpen}
|
|
/>
|
|
|
|
<style lang="scss">
|
|
.message-pane {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.to-latest {
|
|
position: absolute;
|
|
right: 1.25rem;
|
|
bottom: 1.25rem;
|
|
z-index: var(--z-sticky-date);
|
|
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
width: 3rem;
|
|
height: 3rem;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
|
|
color: var(--color-text-secondary);
|
|
cursor: pointer;
|
|
background-color: var(--color-background);
|
|
box-shadow: 0 2px 8px rgb(0 0 0 / 16%);
|
|
|
|
&:hover {
|
|
color: var(--color-text);
|
|
}
|
|
}
|
|
|
|
.message-list {
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.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>
|