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
180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|