Files
dchain/client-app/components/ChatTile.tsx
vsecoder a75cbcd224 feat: resource caps, Saved Messages, author walls, docs for node bring-up
Node flags (cmd/node/main.go):
  --max-cpu / --max-ram-mb — Go runtime caps (GOMAXPROCS / GOMEMLIMIT)
  --feed-disk-limit-mb — hard 507 refusal for new post bodies over quota
  --chain-disk-limit-mb — advisory watcher (can't reject blocks without
  breaking consensus; logs WARN every minute)

Client — Saved Messages (self-chat):
  - Auto-created on sign-in, pinned top of chat list, blue bookmark avatar
  - Send short-circuits the relay (no encrypt, no fee, no mailbox hop)
  - Empty state rendered outside inverted FlatList — fixes the mirrored
    "say hi…" on Android RTL-aware layout builds
  - PostCard shows "You" for own posts instead of the self-contact alias

Client — user walls:
  - New route /(app)/feed/author/[pub] with infinite-scroll via
    `created_at` cursor and pull-to-refresh
  - Profile screen gains "View posts" button (universal) next to
    "Open chat" (contact-only)

Feed pipeline:
  - Bump client JPEG quality 0.5 → 0.75 to match server scrubber (Q=75),
    so a 60 KiB compose doesn't balloon past 256 KiB after server re-encode
  - ErrPostTooLarge now wraps with the actual size vs cap, errors.Is
    preserved in the HTTP layer
  - FeedMailbox quota + DiskUsage surface — supports new CLI flag

README:
  - Step-by-step "first node / joiner" section on the landing page,
    full flag tables incl. the new resource-cap group, minimal
    checklists for open/private/low-end deployments
2026-04-19 13:14:47 +03:00

180 lines
6.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ChatTile — одна строка в списке чатов на главной (Messages screen).
*
* Layout:
* [avatar 44] [name (+verified) (+kind-icon)] [time]
* [last-msg preview] [unread pill]
*
* Kind-icon — мегафон для channel, 👥 для group, ничего для direct.
* Verified checkmark — если у контакта есть @username.
* Online-dot на аватарке — только для direct-чатов с x25519 ключом.
*/
import React from 'react';
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { Contact, Message } from '@/lib/types';
import { Avatar } from '@/components/Avatar';
import { formatWhen } from '@/lib/dates';
import { useStore } from '@/lib/store';
function previewText(s: string, max = 50): string {
return s.length <= max ? s : s.slice(0, max).trimEnd() + '…';
}
/**
* Текстовое превью последнего сообщения. Если у сообщения нет текста
* (только вложение) — возвращаем маркер с иконкой названием типа:
* "🖼 Photo" / "🎬 Video" / "🎙 Voice" / "📎 File"
* Если текст есть — он используется; если есть и то и другое, префикс
* добавляется перед текстом.
*/
function lastPreview(m: Message): string {
const emojiByKind = {
image: '🖼', video: '🎬', voice: '🎙', file: '📎',
} as const;
const labelByKind = {
image: 'Photo', video: 'Video', voice: 'Voice message', file: 'File',
} as const;
const text = m.text.trim();
if (m.attachment) {
const prefix = `${emojiByKind[m.attachment.kind]} ${labelByKind[m.attachment.kind]}`;
return text ? `${prefix} ${previewText(text, 40)}` : prefix;
}
return previewText(text);
}
function shortAddr(a: string, n = 5): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
function displayName(c: Contact): string {
return c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address);
}
export interface ChatTileProps {
contact: Contact;
lastMessage: Message | null;
onPress: () => void;
/** Render as the Saved Messages tile (blue bookmark avatar, fixed name). */
saved?: boolean;
}
export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) {
const name = saved ? 'Saved Messages' : displayName(c);
const last = lastMessage;
// Визуальный маркер типа чата.
const kindIcon: React.ComponentProps<typeof Ionicons>['name'] | null =
c.kind === 'group' ? 'people' : null;
// Unread берётся из runtime-store'а (инкрементится в useGlobalInbox,
// обнуляется при открытии чата). Fallback на c.unread для legacy seed.
const storeUnread = useStore(s => s.unreadByContact[c.address] ?? 0);
const unreadCount = storeUnread || (c.unread ?? 0);
const unread = unreadCount > 0 ? unreadCount : null;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
backgroundColor: pressed ? '#0a0a0a' : 'transparent',
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 14,
paddingVertical: 12,
}}
>
<Avatar
name={name}
address={c.address}
size={44}
saved={saved}
dotColor={!saved && c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
/>
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
{/* Первая строка: [kind-icon] name [verified] ··· time */}
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{kindIcon && (
<Ionicons
name={kindIcon}
size={12}
color="#8b8b8b"
style={{ marginRight: 5 }}
/>
)}
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15, flex: 1 }}
>
{name}
</Text>
{c.username && (
<Ionicons
name="checkmark-circle"
size={14}
color="#1d9bf0"
style={{ marginLeft: 4, marginRight: 2 }}
/>
)}
{last && (
<Text style={{ color: '#8b8b8b', fontSize: 12, marginLeft: 6 }}>
{formatWhen(last.timestamp)}
</Text>
)}
</View>
{/* Вторая строка: [✓✓ mine-seen] preview ··· [unread] */}
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
{last?.mine && (
<Ionicons
name="checkmark-done-outline"
size={13}
color="#8b8b8b"
style={{ marginRight: 4 }}
/>
)}
<Text
numberOfLines={1}
style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}
>
{last
? lastPreview(last)
: saved
? 'Your personal notes & files'
: c.x25519Pub
? 'Tap to start encrypted chat'
: 'Waiting for identity…'}
</Text>
{unread !== null && (
<View
style={{
marginLeft: 8,
minWidth: 18,
height: 18,
paddingHorizontal: 5,
borderRadius: 9,
backgroundColor: '#1d9bf0',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '700' }}>
{unread > 99 ? '99+' : unread}
</Text>
</View>
)}
</View>
</View>
</View>
</Pressable>
);
}