Files
dchain/client-app/app/(app)/profile/[address].tsx
vsecoder f7a849ddcb chore(client): translate all user-visible strings to English
Mixed-language UI was confusing — onboarding said "Why DChain / How it
works / Your keys" in English headings but feature descriptions and
CTAs were in Russian; compose's confirm dialog was Russian; feed tabs
were Russian; error messages in humanizeTxError were Russian.
Everything user-facing is now English.

Files touched (only string literals, not comments):
  app/index.tsx              onboarding slides + CTA buttons
  app/(app)/compose.tsx      composer alerts, header button, placeholder,
                             attachment-size hint
  app/(app)/feed/index.tsx   tab labels (Following/For you/Trending),
                             empty-state hints, retry button
  app/(app)/feed/[id].tsx    post detail header + stats rows (Views,
                             Likes, Size, Paid to publish, Hosted on,
                             Hashtags)
  app/(app)/feed/tag/[tag].tsx  empty-state copy
  app/(app)/profile/[address].tsx  Profile header, Follow/Following,
                             Edit, Open chat, Address, Copied, Encryption,
                             Added, Members, unknown-contact hint
  app/(app)/new-contact.tsx  Search title, placeholder, Search button,
                             empty-state hint, E2E-ready indicator,
                             Intro label + placeholder, fee-tier labels
                             (Min / Standard / Priority), Send request,
                             Insufficient-balance alert, Request-sent
                             alert
  app/(app)/requests.tsx     Notifications title, empty-state, Accept /
                             Decline buttons, decline-confirm alert,
                             "wants to add you" line
  components/SearchBar.tsx   default placeholder
  components/feed/PostCard.tsx  long-press menu (Delete post, confirm,
                             Actions / Cancel)
  components/feed/ShareSheet.tsx  sheet title, contact-search placeholder,
                             empty state, Select contacts / Send button,
                             plural helper rewritten for English
  components/chat/PostRefCard.tsx  "POST" ribbon, "photo" indicator
  lib/api.ts                 humanizeTxError (rate-limit, clock skew,
                             bad signature, 400/5xx/network-error
                             messages)
  lib/dates.ts               dateBucket now returns Today/Yesterday/
                             "Jun 17, 2025"; month array switched to
                             English short forms

Code comments left in Russian intentionally — they're developer
context, not user-facing. This commit is purely display-string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:39:38 +03:00

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 ? 'You' : 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="Profile"
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 ? 'Following' : 'Follow'}
</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 }}>
Edit
</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 }}>
Open chat
</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 }}>
Address
</Text>
<Text
style={{
color: copied ? '#3ba55d' : '#ffffff',
fontSize: 13,
fontFamily: 'monospace',
fontWeight: '600',
}}
numberOfLines={1}
>
{copied ? '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="Encryption"
value={contact.x25519Pub
? 'end-to-end (NaCl)'
: 'key not published yet'}
danger={!contact.x25519Pub}
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
/>
<Divider />
<InfoRow
label="Added"
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="Members"
value="—"
icon="people-outline"
/>
</>
)}
</>
)}
</View>
{!contact && !isMe && (
<Text style={{
color: '#6a6a6a',
fontSize: 12,
textAlign: 'center',
marginTop: 14,
paddingHorizontal: 24,
lineHeight: 17,
}}>
This user isn't in your contacts yet. Tap "Follow" to see their posts in your feed, or add them as a chat contact via @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>
);
}