Three related UX fixes on the client. 1. Participants count on profile DMs always have exactly two participants (you and the contact) so a "Участников: 1" row was confusing — either it's obviously the other person or it's wrong depending on how you count. Removed for direct conversations; the row still appears for group chats (and shows an em-dash until v2.1.0 gives groups a real member list). 2. Dev feed seed now activates on network / 404 errors The seed was only surfaced when the real API returned an EMPTY array. If the node was down (Network request failed) or the endpoint replied 404, the catch block quietly set posts to [] and the list stayed blank — defeating the point of the seed. Now both the empty- response path AND the network-error path fall back to getDevSeedFeed(), so scrolling / like-toggling works even without a running node. Also made the __DEV__ lookup more defensive: use `globalThis.__DEV__` at runtime instead of the typed global. Some bundler configurations have the TS type but not the runtime binding, or vice-versa — the runtime lookup always agrees with Metro. 3. Back from profile → previous screen instead of tab root Root cause: AnimatedSlot rendered <Slot>, which is stack-less. When /chats/xyz pushed /profile/abc (cross-group), the chats group unmounted. Hitting Back then re-entered chats at its root (/chats list) rather than /chats/xyz. Replaced <Slot> with <Stack> in AnimatedSlot. Tab switching still stays flat because NavBar uses router.replace (which maps to navigation.replace on the Stack — no history accumulation). Cross-group pushes (post author tap from feed, avatar tap from chat header, compose modal) now live in the outer Stack's history, so Back pops correctly to the caller. The nested Stacks (chats/_layout.tsx, feed/_layout.tsx, profile/_layout.tsx) still handle intra-group navigation as before. The PanResponder-based swipe-right-to-back was removed since the native Stack now provides iOS edge-swipe natively; Android uses the system back button. animation: 'none' keeps the visual swap instant — matches the prior Slot look so nothing flashes slide-animations that weren't there before. Sub-group layouts can opt into slide_from_right themselves (profile/_layout.tsx and feed/_layout.tsx already do). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
/**
|
||
* Profile screen — info card about any address (yours or someone else's),
|
||
* plus a Follow/Unfollow button. Posts are intentionally NOT shown here
|
||
* — this screen is chat-oriented ("who is on the other side of this
|
||
* conversation"); the feed tab + /feed/author/{pub} is where you go to
|
||
* browse someone's timeline.
|
||
*
|
||
* Route:
|
||
* /(app)/profile/<ed25519-hex>
|
||
*
|
||
* Back behaviour:
|
||
* Nested Stack layout in app/(app)/profile/_layout.tsx preserves the
|
||
* push stack, so tapping Back returns the user to whatever screen
|
||
* pushed them here (feed card tap, chat header tap, etc.).
|
||
*/
|
||
import React, { useState } from 'react';
|
||
import {
|
||
View, Text, ScrollView, Pressable, ActivityIndicator,
|
||
} from 'react-native';
|
||
import { router, useLocalSearchParams } from 'expo-router';
|
||
import * as Clipboard from 'expo-clipboard';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
import { useStore } from '@/lib/store';
|
||
import { Avatar } from '@/components/Avatar';
|
||
import { Header } from '@/components/Header';
|
||
import { IconButton } from '@/components/IconButton';
|
||
import { followUser, unfollowUser } from '@/lib/feed';
|
||
import { humanizeTxError } from '@/lib/api';
|
||
|
||
function shortAddr(a: string, n = 10): string {
|
||
if (!a) return '—';
|
||
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`;
|
||
}
|
||
|
||
export default function ProfileScreen() {
|
||
const insets = useSafeAreaInsets();
|
||
const { address } = useLocalSearchParams<{ address: string }>();
|
||
const contacts = useStore(s => s.contacts);
|
||
const keyFile = useStore(s => s.keyFile);
|
||
const contact = contacts.find(c => c.address === address);
|
||
|
||
const [following, setFollowing] = useState(false);
|
||
const [followingBusy, setFollowingBusy] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const isMe = !!keyFile && keyFile.pub_key === address;
|
||
const displayName = contact?.username
|
||
? `@${contact.username}`
|
||
: contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '', 6));
|
||
|
||
const copyAddress = async () => {
|
||
if (!address) return;
|
||
await Clipboard.setStringAsync(address);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1800);
|
||
};
|
||
|
||
const openChat = () => {
|
||
if (!address) return;
|
||
router.replace(`/(app)/chats/${address}` as never);
|
||
};
|
||
|
||
const onToggleFollow = async () => {
|
||
if (!keyFile || !address || isMe || followingBusy) return;
|
||
setFollowingBusy(true);
|
||
const wasFollowing = following;
|
||
setFollowing(!wasFollowing);
|
||
try {
|
||
if (wasFollowing) {
|
||
await unfollowUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
|
||
} else {
|
||
await followUser({ from: keyFile.pub_key, privKey: keyFile.priv_key, target: address });
|
||
}
|
||
} catch (e: any) {
|
||
setFollowing(wasFollowing);
|
||
// Surface the error via alert — feed lib already formats humanizeTxError.
|
||
alert(humanizeTxError(e));
|
||
} finally {
|
||
setFollowingBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||
<Header
|
||
title="Профиль"
|
||
divider
|
||
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
|
||
/>
|
||
|
||
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
|
||
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
|
||
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
|
||
<Avatar name={displayName} address={address} size={72} />
|
||
<View style={{ flex: 1 }} />
|
||
{!isMe ? (
|
||
<Pressable
|
||
onPress={onToggleFollow}
|
||
disabled={followingBusy}
|
||
style={({ pressed }) => ({
|
||
paddingHorizontal: 18, paddingVertical: 9,
|
||
borderRadius: 999,
|
||
backgroundColor: following
|
||
? (pressed ? '#1a1a1a' : '#111111')
|
||
: (pressed ? '#e7e7e7' : '#ffffff'),
|
||
borderWidth: following ? 1 : 0,
|
||
borderColor: '#1f1f1f',
|
||
minWidth: 120,
|
||
alignItems: 'center',
|
||
})}
|
||
>
|
||
{followingBusy ? (
|
||
<ActivityIndicator
|
||
size="small"
|
||
color={following ? '#ffffff' : '#000000'}
|
||
/>
|
||
) : (
|
||
<Text
|
||
style={{
|
||
color: following ? '#ffffff' : '#000000',
|
||
fontWeight: '700',
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{following ? 'Вы подписаны' : 'Подписаться'}
|
||
</Text>
|
||
)}
|
||
</Pressable>
|
||
) : (
|
||
<Pressable
|
||
onPress={() => router.push('/(app)/settings' as never)}
|
||
style={({ pressed }) => ({
|
||
paddingHorizontal: 18, paddingVertical: 9,
|
||
borderRadius: 999,
|
||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||
borderWidth: 1, borderColor: '#1f1f1f',
|
||
})}
|
||
>
|
||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
|
||
Редактировать
|
||
</Text>
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
|
||
{/* Name + verified tick */}
|
||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||
<Text
|
||
style={{ color: '#ffffff', fontSize: 22, fontWeight: '800', letterSpacing: -0.3 }}
|
||
numberOfLines={1}
|
||
>
|
||
{displayName}
|
||
</Text>
|
||
{contact?.username && (
|
||
<Ionicons name="checkmark-circle" size={18} color="#1d9bf0" style={{ marginLeft: 5 }} />
|
||
)}
|
||
</View>
|
||
|
||
{/* Open chat — single CTA, full width, icon inline with text.
|
||
Only when we know this is a contact (direct chat exists). */}
|
||
{!isMe && contact && (
|
||
<Pressable
|
||
onPress={openChat}
|
||
style={({ pressed }) => ({
|
||
marginTop: 14,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 6,
|
||
paddingVertical: 11,
|
||
borderRadius: 999,
|
||
backgroundColor: pressed ? '#1a1a1a' : '#111111',
|
||
borderWidth: 1, borderColor: '#1f1f1f',
|
||
})}
|
||
>
|
||
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
|
||
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
|
||
Открыть чат
|
||
</Text>
|
||
</Pressable>
|
||
)}
|
||
|
||
{/* ── Info card ───────────────────────────────────────────────── */}
|
||
<View
|
||
style={{
|
||
marginTop: 18,
|
||
borderRadius: 14,
|
||
backgroundColor: '#0a0a0a',
|
||
borderWidth: 1, borderColor: '#1f1f1f',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{/* Address — entire row is tappable → copies */}
|
||
<Pressable
|
||
onPress={copyAddress}
|
||
style={({ pressed }) => ({
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 14, paddingVertical: 12,
|
||
backgroundColor: pressed ? '#0f0f0f' : 'transparent',
|
||
})}
|
||
>
|
||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
|
||
Адрес
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
color: copied ? '#3ba55d' : '#ffffff',
|
||
fontSize: 13,
|
||
fontFamily: 'monospace',
|
||
fontWeight: '600',
|
||
}}
|
||
numberOfLines={1}
|
||
>
|
||
{copied ? 'Скопировано' : shortAddr(address ?? '')}
|
||
</Text>
|
||
<Ionicons
|
||
name={copied ? 'checkmark' : 'copy-outline'}
|
||
size={14}
|
||
color={copied ? '#3ba55d' : '#6a6a6a'}
|
||
style={{ marginLeft: 8 }}
|
||
/>
|
||
</Pressable>
|
||
|
||
{/* Encryption status */}
|
||
{contact && (
|
||
<>
|
||
<Divider />
|
||
<InfoRow
|
||
label="Шифрование"
|
||
value={contact.x25519Pub
|
||
? 'end-to-end (NaCl)'
|
||
: 'ключ ещё не опубликован'}
|
||
danger={!contact.x25519Pub}
|
||
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
|
||
/>
|
||
|
||
<Divider />
|
||
<InfoRow
|
||
label="Добавлен"
|
||
value={new Date(contact.addedAt).toLocaleDateString()}
|
||
icon="calendar-outline"
|
||
/>
|
||
|
||
{/* Group-only: participant count. DMs always have exactly two
|
||
people so the row would be noise. Groups would show real
|
||
member count here from chain state once v2.1.0 ships groups. */}
|
||
{contact.kind === 'group' && (
|
||
<>
|
||
<Divider />
|
||
<InfoRow
|
||
label="Участников"
|
||
value="—"
|
||
icon="people-outline"
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</View>
|
||
|
||
{!contact && !isMe && (
|
||
<Text style={{
|
||
color: '#6a6a6a',
|
||
fontSize: 12,
|
||
textAlign: 'center',
|
||
marginTop: 14,
|
||
paddingHorizontal: 24,
|
||
lineHeight: 17,
|
||
}}>
|
||
Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @username.
|
||
</Text>
|
||
)}
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function Divider() {
|
||
return <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />;
|
||
}
|
||
|
||
function InfoRow({
|
||
label, value, icon, danger,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
icon?: React.ComponentProps<typeof Ionicons>['name'];
|
||
danger?: boolean;
|
||
}) {
|
||
return (
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 12,
|
||
}}
|
||
>
|
||
{icon && (
|
||
<Ionicons
|
||
name={icon}
|
||
size={14}
|
||
color={danger ? '#f0b35a' : '#6a6a6a'}
|
||
style={{ marginRight: 8 }}
|
||
/>
|
||
)}
|
||
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
|
||
<Text
|
||
style={{
|
||
color: danger ? '#f0b35a' : '#ffffff',
|
||
fontSize: 13,
|
||
fontWeight: '600',
|
||
}}
|
||
numberOfLines={1}
|
||
>
|
||
{value}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|