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,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);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user