420 lines
10 KiB
Svelte
420 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { ripple } from "#lib/actions/ripple.js";
|
|
import type { FeedItem, MessageView } from "#lib/api/types.js";
|
|
import FeedBulk from "#lib/components/feed/FeedBulk.svelte";
|
|
import FeedPhoto from "#lib/components/feed/FeedPhoto.svelte";
|
|
import FeedText from "#lib/components/feed/FeedText.svelte";
|
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
|
import Icon from "#lib/components/ui/Icon.svelte";
|
|
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
|
import { wordDiff } from "#lib/format/diff.js";
|
|
import { peerName } from "#lib/format/peer.js";
|
|
import { t } from "#lib/i18n/locale.svelte.js";
|
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
|
import { peers } from "#lib/stores/peers.svelte.js";
|
|
|
|
interface Props {
|
|
inChat?: boolean;
|
|
item: FeedItem;
|
|
onopen: (messageId: number) => void;
|
|
}
|
|
|
|
let { item, onopen, inChat = false }: Props = $props();
|
|
|
|
const FIELD_KEYS = {
|
|
name: "feed.field.name",
|
|
photo: "feed.field.photo",
|
|
title: "feed.field.title",
|
|
username: "feed.field.username",
|
|
} as const;
|
|
const FIELD_ORDER = ["name", "title", "username", "photo"] as const;
|
|
|
|
let expanded = $state(false);
|
|
|
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
|
const bulk = $derived(item.kind === "deleted" && item.message_count > 1);
|
|
const isGroup = $derived(item.chat.kind === "group");
|
|
|
|
$effect(() => {
|
|
if (item.sender_id !== null && item.sender_id > 0) {
|
|
peers.ensure([item.sender_id]);
|
|
}
|
|
});
|
|
|
|
function senderName(senderId: number | null, fallback: string | null) {
|
|
if (senderId === null) {
|
|
return fallback ?? "";
|
|
}
|
|
if (senderId === ownId) {
|
|
return t("chat.you");
|
|
}
|
|
const peer = peers.get(senderId);
|
|
if (peer) {
|
|
return peerName(peer);
|
|
}
|
|
return fallback ?? String(senderId);
|
|
}
|
|
|
|
function shortName(senderId: number | null, fallback: string | null) {
|
|
if (senderId === ownId) {
|
|
return t("chat.you");
|
|
}
|
|
const peer = senderId === null ? undefined : peers.get(senderId);
|
|
return peer?.first_name || senderName(senderId, fallback);
|
|
}
|
|
|
|
function prefixOf(message: MessageView): string {
|
|
if (message.sender_id === ownId) {
|
|
return t("chat.you");
|
|
}
|
|
return isGroup ? shortName(message.sender_id, null) : "";
|
|
}
|
|
|
|
const chatTitle = $derived(
|
|
item.chat.title ??
|
|
(item.chat.chat_id > 0
|
|
? t("chat.deletedAccount")
|
|
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
|
|
);
|
|
const profile = $derived(item.kind === "profile");
|
|
const fields = $derived(
|
|
FIELD_ORDER.filter((field) => item.detail?.[field]).map(
|
|
(field) => [field, item.detail?.[field] ?? [null, null]] as const
|
|
)
|
|
);
|
|
const title = $derived.by(() => {
|
|
if (!inChat || profile) {
|
|
return chatTitle;
|
|
}
|
|
if (bulk) {
|
|
return t("feed.bulk", { count: item.message_count });
|
|
}
|
|
return senderName(item.sender_id, item.sender_name);
|
|
});
|
|
const avatar = $derived.by(() => {
|
|
if (!inChat || profile) {
|
|
return {
|
|
has: item.chat.has_avatar,
|
|
id: item.chat.chat_id,
|
|
kind: item.chat.chat_id > 0 ? ("peer" as const) : ("chat" as const),
|
|
};
|
|
}
|
|
const id = item.sender_id ?? item.chat.chat_id;
|
|
return {
|
|
has: id > 0 ? (peers.get(id)?.has_avatar ?? false) : item.chat.has_avatar,
|
|
id,
|
|
kind: id > 0 ? ("peer" as const) : ("chat" as const),
|
|
};
|
|
});
|
|
const prefix = $derived.by(() => {
|
|
if (inChat || profile) {
|
|
return "";
|
|
}
|
|
if (item.outgoing) {
|
|
return t("chat.you");
|
|
}
|
|
return isGroup ? shortName(item.sender_id, item.sender_name) : "";
|
|
});
|
|
const sameText = $derived(item.has_before && item.before === item.after);
|
|
const diff = $derived(
|
|
item.kind === "edited" && item.has_before && !sameText
|
|
? wordDiff(item.before ?? "", item.after ?? "")
|
|
: null
|
|
);
|
|
|
|
function shown(field: string, value: string | null): string {
|
|
if (!value) {
|
|
return t("feed.noText");
|
|
}
|
|
return field === "username" ? `@${value}` : value;
|
|
}
|
|
|
|
function open() {
|
|
if (bulk) {
|
|
expanded = !expanded;
|
|
return;
|
|
}
|
|
onopen(item.message_id);
|
|
}
|
|
</script>
|
|
|
|
<div class="feed-entry">
|
|
<button
|
|
aria-expanded={bulk ? expanded : undefined}
|
|
class="row"
|
|
onclick={open}
|
|
type="button"
|
|
use:ripple
|
|
>
|
|
{#if inChat && bulk}
|
|
<span class="glyph"><Icon name="delete" size="1.5rem" /></span>
|
|
{:else}
|
|
<Avatar
|
|
avatar={{ id: avatar.id, kind: avatar.kind }}
|
|
colorKey={avatar.id}
|
|
hasAvatar={avatar.has}
|
|
name={title}
|
|
size={3}
|
|
/>
|
|
{/if}
|
|
<span class="body">
|
|
<span class="top">
|
|
<span class="title">{title}</span>
|
|
<time datetime={item.at} title={formatFull(item.at)}>
|
|
{formatListDate(item.at)}
|
|
</time>
|
|
</span>
|
|
{#if profile}
|
|
{#each fields as [field, pair] (field)}
|
|
<span class="change now">
|
|
<span class="tag">{t(FIELD_KEYS[field])}</span>
|
|
{#if field === "photo"}
|
|
<FeedPhoto ownerId={item.chat.chat_id} uniqueId={pair[0]} />
|
|
<span class="arrow">→</span>
|
|
<FeedPhoto ownerId={item.chat.chat_id} uniqueId={pair[1]} />
|
|
{:else}
|
|
<span class="was">{shown(field, pair[0])}</span>
|
|
<span class="arrow">→</span>
|
|
{shown(field, pair[1])}
|
|
{/if}
|
|
</span>
|
|
{/each}
|
|
{#if item.message_count > fields.length}
|
|
<span class="count">
|
|
{t("feed.profileCount", { count: item.message_count })}
|
|
</span>
|
|
{/if}
|
|
{:else if bulk}
|
|
<span class="mark">
|
|
<Icon name="delete" size="0.875rem" />
|
|
{inChat
|
|
? t("feed.deleted")
|
|
: t("feed.bulk", { count: item.message_count })}
|
|
<span class="chevron" class:open={expanded}>
|
|
<Icon name="down" size="1rem" />
|
|
</span>
|
|
</span>
|
|
{#if !expanded}
|
|
<FeedText deleted message={item.message} {prefix} />
|
|
{/if}
|
|
{:else if item.kind === "deleted"}
|
|
<FeedText
|
|
deleted
|
|
mark={t("feed.deleted")}
|
|
message={item.message}
|
|
{prefix}
|
|
/>
|
|
{:else if sameText}
|
|
<span class="mark edited">
|
|
<Icon name="edit" size="0.875rem" />
|
|
{t("feed.mediaChanged")}
|
|
</span>
|
|
<FeedText message={item.message} {prefix} />
|
|
{:else}
|
|
<span class="change">
|
|
<span class="tag">{t("feed.before")}</span>
|
|
{#if !item.has_before}
|
|
<span class="missing">{t("feed.lostText")}</span>
|
|
{:else if !item.before}
|
|
<span class="missing">{t("feed.noText")}</span>
|
|
{:else if diff}
|
|
{#each diff.before as part, index (index)}
|
|
<span class:removed={part.changed}>{part.text}</span>
|
|
{/each}
|
|
{:else}
|
|
{item.before}
|
|
{/if}
|
|
</span>
|
|
<span class="change now">
|
|
<span class="tag">{t("feed.after")}</span>
|
|
{#if prefix}
|
|
<span class="prefix">{prefix}:</span>
|
|
{/if}
|
|
{#if !item.after}
|
|
<span class="missing">{t("feed.noText")}</span>
|
|
{:else if diff}
|
|
{#each diff.after as part, index (index)}
|
|
<span class:added={part.changed}>{part.text}</span>
|
|
{/each}
|
|
{:else}
|
|
{item.after}
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
</span>
|
|
</button>
|
|
{#if bulk && expanded}
|
|
{#key `${item.id}:${item.message_count}`}
|
|
<FeedBulk {item} {onopen} senderOf={prefixOf} />
|
|
{/key}
|
|
{/if}
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.row {
|
|
cursor: pointer;
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: flex;
|
|
gap: 0.625rem;
|
|
align-items: flex-start;
|
|
|
|
width: 100%;
|
|
padding: 0.5625rem 0.5rem;
|
|
border: 0;
|
|
border-radius: var(--border-radius-default-small);
|
|
|
|
font: inherit;
|
|
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);
|
|
}
|
|
}
|
|
|
|
&:focus-visible {
|
|
outline: 2px solid var(--color-primary);
|
|
outline-offset: -2px;
|
|
}
|
|
}
|
|
|
|
.glyph {
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
width: 3rem;
|
|
height: 3rem;
|
|
border-radius: 50%;
|
|
|
|
color: var(--color-white);
|
|
|
|
background-color: var(--color-gray);
|
|
}
|
|
|
|
.body {
|
|
display: flex;
|
|
overflow: hidden;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
gap: 0.125rem;
|
|
|
|
min-width: 0;
|
|
}
|
|
|
|
.top {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: baseline;
|
|
}
|
|
|
|
.title {
|
|
overflow: hidden;
|
|
flex: 1;
|
|
|
|
font-size: 1rem;
|
|
font-weight: var(--font-weight-medium);
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
time {
|
|
flex-shrink: 0;
|
|
font-size: 0.75rem;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.mark {
|
|
display: inline-flex;
|
|
gap: 0.25rem;
|
|
align-items: center;
|
|
|
|
font-size: 0.8125rem;
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-error);
|
|
|
|
&.edited {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
}
|
|
|
|
.chevron {
|
|
display: inline-flex;
|
|
color: var(--color-text-secondary);
|
|
transition: transform 0.2s ease;
|
|
|
|
&.open {
|
|
transform: rotate(180deg);
|
|
}
|
|
}
|
|
|
|
.change {
|
|
overflow: hidden;
|
|
display: -webkit-box;
|
|
|
|
font-size: 0.9375rem;
|
|
line-height: 1.25rem;
|
|
color: var(--color-text-secondary);
|
|
overflow-wrap: anywhere;
|
|
|
|
-webkit-line-clamp: 3;
|
|
line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
|
|
&.now {
|
|
color: var(--color-text);
|
|
}
|
|
}
|
|
|
|
.tag {
|
|
margin-inline-end: 0.375rem;
|
|
|
|
font-size: 0.8125rem;
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.prefix {
|
|
margin-inline-end: 0.25rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.removed {
|
|
text-decoration: line-through;
|
|
text-decoration-color: var(--color-error);
|
|
}
|
|
|
|
.added {
|
|
border-radius: 0.25rem;
|
|
background-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
|
box-decoration-break: clone;
|
|
}
|
|
|
|
.missing {
|
|
font-style: italic;
|
|
}
|
|
|
|
.was {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.arrow {
|
|
margin-inline: 0.25rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.count {
|
|
font-size: 0.8125rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
</style>
|