fix(client): DM-only info, seed on API error, proper cross-group back stack
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>
This commit is contained in:
@@ -97,9 +97,11 @@ export default function FeedScreen() {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (seq !== requestRef.current) return;
|
if (seq !== requestRef.current) return;
|
||||||
const msg = String(e?.message ?? e);
|
const msg = String(e?.message ?? e);
|
||||||
// Silence benign network/404 — just show empty state.
|
// Network / 404 is benign — node just unreachable or empty. In __DEV__
|
||||||
|
// fall back to synthetic seed posts so the scroll / tap UI stays
|
||||||
|
// testable; in production this path shows the empty state.
|
||||||
if (/Network request failed|→\s*404/.test(msg)) {
|
if (/Network request failed|→\s*404/.test(msg)) {
|
||||||
setPosts([]);
|
setPosts(getDevSeedFeed());
|
||||||
} else {
|
} else {
|
||||||
setError(msg);
|
setError(msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,16 +244,21 @@ export default function ProfileScreen() {
|
|||||||
icon="calendar-outline"
|
icon="calendar-outline"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Participants count — 1 for direct DMs. Groups would show
|
{/* Group-only: participant count. DMs always have exactly two
|
||||||
their actual member count from chain state (v2.1.0+). */}
|
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 />
|
<Divider />
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label="Участников"
|
label="Участников"
|
||||||
value={contact.kind === 'group' ? '—' : '1'}
|
value="—"
|
||||||
icon="people-outline"
|
icon="people-outline"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!contact && !isMe && (
|
{!contact && !isMe && (
|
||||||
|
|||||||
@@ -1,67 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* AnimatedSlot — обёртка над `<Slot>`. Исторически тут была slide-
|
* AnimatedSlot — renders the (app) group as a native <Stack>.
|
||||||
* анимация при смене pathname'а + tab-swipe pan. Обе фичи вызывали
|
|
||||||
* баги:
|
|
||||||
* - tab-swipe конфликтовал с vertical FlatList scroll (чаты пропадали
|
|
||||||
* при flick'е)
|
|
||||||
* - translateX застревал на ±width когда анимация прерывалась
|
|
||||||
* re-render-cascade'ом от useGlobalInbox → UI уезжал за экран
|
|
||||||
*
|
*
|
||||||
* Решение: убрали обе. Навигация между tab'ами — только через NavBar,
|
* Why Stack instead of Slot: Slot is stack-less. When a child route
|
||||||
* переходы — без slide. Sub-route back-swipe остаётся (он не конфликтует
|
* (e.g. profile/[address]) pushes another child, Slot swaps content
|
||||||
* с FlatList'ом, т.к. на chat detail FlatList inverted и смотрит вверх).
|
* with no history, so router.back() falls all the way through to the
|
||||||
|
* URL root instead of returning to the caller (e.g. /chats/xyz →
|
||||||
|
* /profile/abc → back should go to /chats/xyz, not to /chats).
|
||||||
|
*
|
||||||
|
* Tab switching stays flat because NavBar uses router.replace, which
|
||||||
|
* maps to navigation.replace on the Stack → no history accumulation.
|
||||||
|
*
|
||||||
|
* animation: 'none' on tab roots keeps tab-swap instant (matches the
|
||||||
|
* prior Slot look). Sub-routes (profile/*, compose, feed/*, chats/[id])
|
||||||
|
* inherit slide_from_right from their own nested _layout.tsx Stacks,
|
||||||
|
* which is where the push animation happens.
|
||||||
|
*
|
||||||
|
* This file is named AnimatedSlot for git-history continuity — the
|
||||||
|
* Animated.Value + translateX slide was removed earlier (got stuck at
|
||||||
|
* ±width when interrupted by re-render cascades).
|
||||||
*/
|
*/
|
||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import { PanResponder, View } from 'react-native';
|
import { Stack } from 'expo-router';
|
||||||
import { Slot, usePathname, router } from 'expo-router';
|
|
||||||
import { useWindowDimensions } from 'react-native';
|
|
||||||
|
|
||||||
function topSegment(p: string): string {
|
|
||||||
const m = p.match(/^\/[^/]+/);
|
|
||||||
return m ? m[0] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Это sub-route — внутри какого-либо tab'а, но глубже первого сегмента. */
|
|
||||||
function isSubRoute(path: string): boolean {
|
|
||||||
const seg = topSegment(path);
|
|
||||||
if (seg === '/chats') return path !== '/chats' && path !== '/chats/';
|
|
||||||
if (seg === '/feed') return path !== '/feed' && path !== '/feed/';
|
|
||||||
if (seg === '/profile') return true;
|
|
||||||
if (seg === '/settings') return true;
|
|
||||||
if (seg === '/compose') return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnimatedSlot() {
|
export function AnimatedSlot() {
|
||||||
const pathname = usePathname();
|
|
||||||
const { width } = useWindowDimensions();
|
|
||||||
|
|
||||||
// Pan-gesture только на sub-route'ах: swipe-right → back. На tab'ах
|
|
||||||
// gesture полностью отключён — исключает конфликт с vertical scroll.
|
|
||||||
const panResponder = useMemo(() => {
|
|
||||||
const sub = isSubRoute(pathname);
|
|
||||||
|
|
||||||
return PanResponder.create({
|
|
||||||
onMoveShouldSetPanResponder: (_e, g) => {
|
|
||||||
if (!sub) return false;
|
|
||||||
if (Math.abs(g.dx) < 40) return false;
|
|
||||||
if (Math.abs(g.dy) > 15) return false;
|
|
||||||
if (g.dx <= 0) return false; // только правое направление
|
|
||||||
return Math.abs(g.dx) > Math.abs(g.dy) * 3;
|
|
||||||
},
|
|
||||||
onMoveShouldSetPanResponderCapture: () => false,
|
|
||||||
|
|
||||||
onPanResponderRelease: (_e, g) => {
|
|
||||||
if (!sub) return;
|
|
||||||
if (Math.abs(g.dy) > 30) return;
|
|
||||||
if (g.dx > width * 0.30) router.back();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [pathname, width]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }} {...panResponder.panHandlers}>
|
<Stack
|
||||||
<Slot />
|
screenOptions={{
|
||||||
</View>
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: '#000000' },
|
||||||
|
animation: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,18 +164,17 @@ const SEED_POSTS: FeedPostItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** True when the current build is a Metro dev bundle. __DEV__ is a
|
|
||||||
* global injected by Metro at bundle time and typed via react-native's
|
|
||||||
* ambient declarations, so no ts-ignore is needed. */
|
|
||||||
function isDev(): boolean {
|
|
||||||
return typeof __DEV__ !== 'undefined' && __DEV__ === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the dev-seed post list (only in __DEV__). Called by the Feed
|
* Returns the dev-seed post list. Only returns actual items in dev
|
||||||
* screen as a fallback when the real API returned an empty list.
|
* builds; release bundles return an empty array so fake posts never
|
||||||
|
* appear in production.
|
||||||
|
*
|
||||||
|
* We use the runtime `globalThis.__DEV__` lookup rather than the typed
|
||||||
|
* `__DEV__` global because some builds can have the TS typing
|
||||||
|
* out-of-sync with the actual injected value.
|
||||||
*/
|
*/
|
||||||
export function getDevSeedFeed(): FeedPostItem[] {
|
export function getDevSeedFeed(): FeedPostItem[] {
|
||||||
if (!isDev()) return [];
|
const g = globalThis as unknown as { __DEV__?: boolean };
|
||||||
|
if (g.__DEV__ !== true) return [];
|
||||||
return SEED_POSTS;
|
return SEED_POSTS;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user