feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
hh committed 2026-05-31 01:45:05 +02:00
1 parent f0afb7ec5b
commit 75425d1bee
110 files changed
+10199 -54

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
const time = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
});
const day = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
});
const full = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export function formatTime(iso: string): string {
return time.format(new Date(iso));
}
export function formatDay(iso: string): string {
return day.format(new Date(iso));
}
export function formatFull(iso: string): string {
return full.format(new Date(iso));
}
export function formatListDate(iso: string | null): string {
if (!iso) {
return "";
}
const date = new Date(iso);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return time.format(date);
}
return day.format(date);
}
+9
View File
@@ -0,0 +1,9 @@
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return "0:00";
}
const total = Math.floor(seconds);
const minutes = Math.floor(total / 60);
const rest = total % 60;
return `${minutes}:${rest.toString().padStart(2, "0")}`;
}
+105
View File
@@ -0,0 +1,105 @@
import type { EntityView } from "$lib/api/types";
export interface TextNode {
kind: "text";
text: string;
}
export interface EntityNode {
children: EntityTreeNode[];
entity: EntityView;
kind: "entity";
}
export type EntityTreeNode = TextNode | EntityNode;
function buildNodes(
text: string,
entities: EntityView[],
start: number,
end: number
): EntityTreeNode[] {
const nodes: EntityTreeNode[] = [];
let cursor = start;
let i = 0;
while (i < entities.length) {
const entity = entities[i];
const entityEnd = entity.offset + entity.length;
if (entity.offset > cursor) {
nodes.push({ kind: "text", text: text.slice(cursor, entity.offset) });
}
const nested: EntityView[] = [];
let next = i + 1;
while (next < entities.length && entities[next].offset < entityEnd) {
nested.push(entities[next]);
next += 1;
}
nodes.push({
kind: "entity",
entity,
children: buildNodes(text, nested, entity.offset, entityEnd),
});
cursor = entityEnd;
i = next;
}
if (cursor < end) {
nodes.push({ kind: "text", text: text.slice(cursor, end) });
}
return nodes;
}
export function buildEntityTree(
text: string,
entities: EntityView[]
): EntityTreeNode[] {
if (entities.length === 0) {
return [{ kind: "text", text }];
}
const sorted = [...entities].sort(
(a, b) => a.offset - b.offset || b.length - a.length
);
return buildNodes(text, sorted, 0, text.length);
}
const PROTOCOL_RE = /^[a-z]+:/i;
function ensureProtocol(url: string): string {
return PROTOCOL_RE.test(url) ? url : `https://${url}`;
}
export function nodeText(node: EntityTreeNode): string {
if (node.kind === "text") {
return node.text;
}
return node.children.map(nodeText).join("");
}
export function linkHref(node: EntityNode): string {
const { entity } = node;
if (entity.type === "text_link" && entity.url) {
return ensureProtocol(entity.url);
}
if (entity.type === "email") {
return `mailto:${nodeText(node)}`;
}
if (entity.type === "phone_number") {
return `tel:${nodeText(node)}`;
}
return ensureProtocol(nodeText(node));
}
const EMOJI_RE = /\p{Extended_Pictographic}/u;
const EMOJI_CLUSTER_RE =
/\p{Extended_Pictographic}(?:️|‍\p{Extended_Pictographic}️?)*/gu;
export function jumboEmojiCount(text: string): number {
const trimmed = text.trim();
if (!(trimmed && EMOJI_RE.test(trimmed))) {
return 0;
}
const clusters = trimmed.match(EMOJI_CLUSTER_RE);
if (!clusters || trimmed.replace(EMOJI_CLUSTER_RE, "").trim().length > 0) {
return 0;
}
return clusters.length <= 3 ? clusters.length : 0;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Chat, Folder } from "$lib/api/types";
function categoryMatch(folder: Folder, chat: Chat): boolean {
if (chat.is_broadcast) {
return folder.broadcasts;
}
if (chat.kind === "group") {
return folder.groups;
}
if (chat.is_bot) {
return folder.bots;
}
if (chat.is_contact) {
return folder.contacts;
}
return folder.non_contacts;
}
export function folderContains(folder: Folder, chat: Chat): boolean {
if (folder.exclude_ids.includes(chat.chat_id)) {
return false;
}
if (
folder.include_ids.includes(chat.chat_id) ||
folder.pinned_ids.includes(chat.chat_id)
) {
return true;
}
if (folder.is_chatlist) {
return false;
}
return categoryMatch(folder, chat);
}
+25
View File
@@ -0,0 +1,25 @@
const MEDIA_KIND_LABELS: Record<string, string> = {
photo: "Photo",
video: "Video",
animation: "GIF",
gif: "GIF",
voice: "Voice message",
audio: "Audio",
video_note: "Video message",
sticker: "Sticker",
document: "File",
contact: "Contact",
location: "Location",
venue: "Location",
poll: "Poll",
dice: "Dice",
game: "Game",
story: "Story",
};
export function mediaKindLabel(kind: string | null): string | null {
if (!kind) {
return null;
}
return MEDIA_KIND_LABELS[kind] ?? "Media";
}
+48
View File
@@ -0,0 +1,48 @@
import type { Account, PeerView } from "$lib/api/types";
const PEER_COLOR_COUNT = 7;
export function peerColorIndex(id: number): number {
return Math.abs(id) % PEER_COLOR_COUNT;
}
export function peerName(peer: PeerView | null): string {
if (!peer) {
return "";
}
if (peer.is_deleted_account) {
return "Deleted Account";
}
const parts = [peer.first_name, peer.last_name].filter(Boolean);
if (parts.length > 0) {
return parts.join(" ");
}
if (peer.username) {
return `@${peer.username}`;
}
return String(peer.peer_id);
}
export function accountName(account: Account): string {
return (
account.label ??
account.phone ??
String(account.tg_user_id ?? account.account_id)
);
}
const WHITESPACE = /\s+/;
export function initials(name: string): string {
const words = name.trim().split(WHITESPACE).filter(Boolean);
if (words.length === 0) {
return "?";
}
const first = words[0][0];
if (words.length === 1) {
return first.toUpperCase();
}
const lastWord = words.at(-1);
const last = lastWord ? lastWord[0] : "";
return (first + last).toUpperCase();
}