fix(mobile): Edge - serduszko nie rusza dzwieku ani nie przewija klipu, jeden przycisk dzwieku, stroik feedu
Zgloszenia Jana 2026-09-16: 1+3. Tapniecie serduszka przelaczalo dzwiek i przewijalo klip na poczatek. Gesty lapala cala komorka razem z przyciskami, wiec tap w serduszko byl tez tapem w ekran, a zmiana dzwieku restartowala odtwarzanie (efekt aktywacji strony mial soundOn w zaleznosciach). Teraz gesty lapie tylko warstwa wideo, a wyciszenie ma osobny efekt. 2. Dwa przyciski dzwieku. Zostaje ten w kolumnie przy kciuku, w gornym pasku wchodzi stroik. 4. Za malo tagow na starcie: bylo 24 z 40, „anal" (27. co do liczby klipow) wypadal. Teraz 60 tagow + nisze + szukanie po calym slowniku (1,7 tys. tagow, nowy param q), wskazanie wazy 8 zamiast 5, a stroik mozna otworzyc w kazdej chwili i zmiana dziala od nastepnego rozdania. 5. Losowe miganie przy przewijaniu: dociagniecie kolejnej strony feedu podmienialo tablice klipow i restartowalo biezacy klip. Efekt zalezy teraz od numeru strony i adresow wideo. Sprawdzone na emulatorze: tap w serduszko zostawia wyciszenie bez zmian i zapisuje like, przycisk dzwieku odcisza, gorny pasek otwiera stroik, szukanie „anal" daje 8 wariantow, a po Apply w clip_affinity siedzi anal=8.0. Edge dalej odklada zdarzenia w clip_feed_events. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
40865d6ce8
commit
7bfd194485
6 changed files with 213 additions and 59 deletions
|
|
@ -487,16 +487,35 @@ _OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
|
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
|
||||||
def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut:
|
def onboarding_options(
|
||||||
"""Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu.
|
session: Session = Depends(get_session),
|
||||||
Liczone z bazy, cache 10 min w procesie."""
|
q: str | None = Query(default=None, max_length=64, description="Szukaj w slowniku tagow"),
|
||||||
|
) -> OnboardingOptionsOut:
|
||||||
|
"""Najpopularniejsze tagi i nisze do wyboru przy pierwszym wejsciu w Edge.
|
||||||
|
Liczone z bazy, cache 10 min w procesie (szukanie z pominieciem cache).
|
||||||
|
|
||||||
|
`q` szuka po calym slowniku (1,7 tys. tagow), bo sama czolowka nie wystarcza: „anal"
|
||||||
|
to 27. tag co do liczby klipow i wypadal poza liste (uwaga Jana 2026-09-16)."""
|
||||||
|
if q and q.strip():
|
||||||
|
needle = f"%{q.strip().lower()}%"
|
||||||
|
found = [
|
||||||
|
OnboardingOptionOut(value=t, count=n)
|
||||||
|
for t, n in session.execute(
|
||||||
|
select(ClipTag.tag, func.count())
|
||||||
|
.where(ClipTag.tag.like(needle))
|
||||||
|
.group_by(ClipTag.tag)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return OnboardingOptionsOut(tags=found, niches=[])
|
||||||
hit = _OPTIONS_CACHE.get("v1")
|
hit = _OPTIONS_CACHE.get("v1")
|
||||||
if hit and time.monotonic() - hit[0] < 600:
|
if hit and time.monotonic() - hit[0] < 600:
|
||||||
return hit[1]
|
return hit[1]
|
||||||
tags = [
|
tags = [
|
||||||
OnboardingOptionOut(value=t, count=n)
|
OnboardingOptionOut(value=t, count=n)
|
||||||
for t, n in session.execute(
|
for t, n in session.execute(
|
||||||
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40)
|
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(60)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
niches = [
|
niches = [
|
||||||
|
|
@ -516,15 +535,19 @@ def onboarding(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
device_id: str = Depends(get_device_id),
|
device_id: str = Depends(get_device_id),
|
||||||
) -> AffinityOut:
|
) -> AffinityOut:
|
||||||
|
# +8 za wskazany tag, wiecej niz polubienie klipu (+5): to jawna deklaracja, a feed
|
||||||
|
# inaczej zbyt dlugo dochodzil do tego, co user i tak powiedzial na starcie (uwaga
|
||||||
|
# Jana 2026-09-16). Ten sam endpoint sluzy pozniejszemu dostrajaniu z zakladki Edge,
|
||||||
|
# wiec kolejne wskazania dokladaja sie do wag.
|
||||||
row, aff = _load_affinity(session, device_id)
|
row, aff = _load_affinity(session, device_id)
|
||||||
for t in body.tags:
|
for t in body.tags:
|
||||||
t = t.strip().lower()[:64]
|
t = t.strip().lower()[:64]
|
||||||
if t:
|
if t:
|
||||||
aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3)
|
aff.tags[t] = round(aff.tags.get(t, 0.0) + 8.0, 3)
|
||||||
for n in body.niches:
|
for n in body.niches:
|
||||||
n = n.strip()[:64]
|
n = n.strip()[:64]
|
||||||
if n:
|
if n:
|
||||||
aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3)
|
aff.niches[n] = round(aff.niches.get(n, 0.0) + 8.0, 3)
|
||||||
_save_affinity(session, device_id, row, aff, 0)
|
_save_affinity(session, device_id, row, aff, 0)
|
||||||
session.commit()
|
session.commit()
|
||||||
row2 = session.get(ClipAffinity, device_id)
|
row2 = session.get(ClipAffinity, device_id)
|
||||||
|
|
|
||||||
|
|
@ -669,8 +669,12 @@ export class GoonClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClipOnboardingOptions(): Promise<ClipOnboardingOptions> {
|
/** Bez `q`: najpopularniejsze tagi i nisze. Z `q`: szukanie po calym slowniku tagow. */
|
||||||
return this.request('/clips/onboarding-options');
|
async getClipOnboardingOptions(q?: string): Promise<ClipOnboardingOptions> {
|
||||||
|
const path = q && q.trim()
|
||||||
|
? `/clips/onboarding-options?q=${encodeURIComponent(q.trim())}`
|
||||||
|
: '/clips/onboarding-options';
|
||||||
|
return this.request(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> {
|
async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,15 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
id: '2026-09-16c',
|
||||||
|
date: 'September 2026',
|
||||||
|
items: [
|
||||||
|
'Edge: tapping the heart no longer restarts the clip or flips the sound on, and the clip no longer jumps back to the start when the next batch loads in the background.',
|
||||||
|
'Edge: one sound button instead of two, and a new tuning button at the top. Open it any time to add what you want more of, including a search across every tag we have, and the feed uses it from the next deal on.',
|
||||||
|
],
|
||||||
|
minRuntime: '1.4',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '2026-09-16b',
|
id: '2026-09-16b',
|
||||||
date: 'September 2026',
|
date: 'September 2026',
|
||||||
|
|
|
||||||
|
|
@ -146,36 +146,43 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
|
||||||
const tags = clip.tags.slice(0, 4);
|
const tags = clip.tags.slice(0, 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={gestures}>
|
// Gesty (tap = dzwiek, double-tap = serce, przytrzymanie = 2x, w lewo = „nie
|
||||||
<View style={styles.root}>
|
// interesuje") lapie TYLKO warstwa wideo. Wczesniej GestureDetector obejmowal cala
|
||||||
{poster ? (
|
// komorke razem z przyciskami, wiec tapniecie serduszka bylo jednoczesnie tapnieciem
|
||||||
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit="cover" transition={0} />
|
// w ekran i przelaczalo dzwiek (zgloszenie Jana 2026-09-16).
|
||||||
) : null}
|
<View style={styles.root}>
|
||||||
{active && player ? (
|
<GestureDetector gesture={gestures}>
|
||||||
<VideoView
|
<View style={StyleSheet.absoluteFill} collapsable={false}>
|
||||||
player={player}
|
{poster ? (
|
||||||
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
|
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit="cover" transition={0} />
|
||||||
nativeControls={false}
|
) : null}
|
||||||
contentFit="cover"
|
{active && player ? (
|
||||||
allowsPictureInPicture={false}
|
<VideoView
|
||||||
/>
|
player={player}
|
||||||
) : null}
|
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
|
||||||
|
nativeControls={false}
|
||||||
|
contentFit="cover"
|
||||||
|
allowsPictureInPicture={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Wybuch serca po double-tapie */}
|
{/* Wybuch serca po double-tapie */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={[
|
style={[
|
||||||
styles.heartBurst,
|
styles.heartBurst,
|
||||||
{
|
{
|
||||||
opacity: heart,
|
opacity: heart,
|
||||||
transform: [{ scale: Animated.add(0.4, Animated.multiply(heart, 0.9)) }],
|
transform: [{ scale: Animated.add(0.4, Animated.multiply(heart, 0.9)) }],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
|
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
|
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
|
||||||
{/* Kolumna przyciskow */}
|
{/* Kolumna przyciskow */}
|
||||||
<View style={styles.rail} pointerEvents="box-none">
|
<View style={styles.rail} pointerEvents="box-none">
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|
@ -243,9 +250,8 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
|
||||||
<View style={styles.progressTrack} pointerEvents="none">
|
<View style={styles.progressTrack} pointerEvents="none">
|
||||||
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
|
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,11 +109,24 @@ export function ClipPager({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
|
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
|
||||||
|
//
|
||||||
|
// Zaleznosci to NUMER strony i adresy wideo, nie cala tablica `items` ani `soundOn`
|
||||||
|
// (2026-09-16). Kazda zmiana tamtych rzeczy przewijala klip na poczatek: dociagniecie
|
||||||
|
// kolejnej strony feedu podmienialo tablice w srodku ogladania („losowo miga i laduje
|
||||||
|
// sie od nowa"), a wlaczenie dzwieku restartowalo wideo. Wyciszenie ma wlasny efekt
|
||||||
|
// nizej, `items` czytamy przez ref.
|
||||||
const prevIndexRef = React.useRef<number | null>(null);
|
const prevIndexRef = React.useRef<number | null>(null);
|
||||||
const leaveRef = React.useRef(onLeave);
|
const leaveRef = React.useRef(onLeave);
|
||||||
leaveRef.current = onLeave;
|
leaveRef.current = onLeave;
|
||||||
|
const itemsRef = React.useRef(items);
|
||||||
|
itemsRef.current = items;
|
||||||
|
const soundRef = React.useRef(soundOn);
|
||||||
|
soundRef.current = soundOn;
|
||||||
|
const activeSrc = items[index] ? clipSource(items[index]) : null;
|
||||||
|
const nextSrc = items[index + 1] ? clipSource(items[index + 1]) : null;
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const ps = players.current;
|
const ps = players.current;
|
||||||
|
const items = itemsRef.current;
|
||||||
if (!ps.length || !items.length) return;
|
if (!ps.length || !items.length) return;
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
@ -144,8 +157,8 @@ export function ClipPager({
|
||||||
activatedAtRef.current = Date.now();
|
activatedAtRef.current = Date.now();
|
||||||
loopsRef.current = 0;
|
loopsRef.current = 0;
|
||||||
|
|
||||||
ensureSource(active, clipSource(item));
|
ensureSource(active, activeSrc);
|
||||||
active.muted = !soundOn;
|
active.muted = !soundRef.current;
|
||||||
active.playbackRate = 1.0;
|
active.playbackRate = 1.0;
|
||||||
try {
|
try {
|
||||||
active.currentTime = 0;
|
active.currentTime = 0;
|
||||||
|
|
@ -158,9 +171,8 @@ export function ClipPager({
|
||||||
loopsRef.current += 1;
|
loopsRef.current += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const next = items[index + 1];
|
if (nextSrc) {
|
||||||
if (next) {
|
ensureSource(standby, nextSrc);
|
||||||
ensureSource(standby, clipSource(next));
|
|
||||||
try {
|
try {
|
||||||
standby.pause();
|
standby.pause();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -169,7 +181,7 @@ export function ClipPager({
|
||||||
}
|
}
|
||||||
Haptics.selectionAsync().catch(() => {});
|
Haptics.selectionAsync().catch(() => {});
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
}, [index, items, ensureSource, soundOn]);
|
}, [index, activeSrc, nextSrc, ensureSource]);
|
||||||
|
|
||||||
// Zmiana dzwieku bez zmiany strony.
|
// Zmiana dzwieku bez zmiany strony.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
|
import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
|
||||||
|
|
@ -56,6 +56,7 @@ export function SwipeScreen() {
|
||||||
const [index, setIndex] = React.useState(0);
|
const [index, setIndex] = React.useState(0);
|
||||||
const [liked, setLiked] = React.useState<Set<string>>(new Set());
|
const [liked, setLiked] = React.useState<Set<string>>(new Set());
|
||||||
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
|
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
|
||||||
|
const [tuneOpen, setTuneOpen] = React.useState(false);
|
||||||
const pagerRef = React.useRef<any>(null);
|
const pagerRef = React.useRef<any>(null);
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
|
|
@ -190,9 +191,23 @@ export function SwipeScreen() {
|
||||||
const options = useQuery({
|
const options = useQuery({
|
||||||
queryKey: ['clip-onboarding-options'],
|
queryKey: ['clip-onboarding-options'],
|
||||||
queryFn: () => client.getClipOnboardingOptions(),
|
queryFn: () => client.getClipOnboardingOptions(),
|
||||||
enabled: prefsReady && !prefs.onboarded,
|
enabled: prefsReady && (!prefs.onboarded || tuneOpen),
|
||||||
staleTime: 10 * 60 * 1000,
|
staleTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
// Dostrojenie juz w trakcie: te same wagi co przy pierwszym wejsciu, potem nowe
|
||||||
|
// rozdanie, zeby zmiana byla widoczna od razu, a nie po wyczerpaniu biezacych stron.
|
||||||
|
const tuneFeed = React.useCallback(
|
||||||
|
async (tags: string[], niches: string[]) => {
|
||||||
|
try {
|
||||||
|
await client.postClipOnboarding({ tags, niches });
|
||||||
|
} catch {
|
||||||
|
// brak sieci: wagi dojda przy kolejnej probie, feed i tak uczy sie z ogladania
|
||||||
|
}
|
||||||
|
newDeal();
|
||||||
|
},
|
||||||
|
[client, newDeal],
|
||||||
|
);
|
||||||
|
|
||||||
const finishOnboarding = React.useCallback(
|
const finishOnboarding = React.useCallback(
|
||||||
async (tags: string[], niches: string[]) => {
|
async (tags: string[], niches: string[]) => {
|
||||||
if (tags.length || niches.length) {
|
if (tags.length || niches.length) {
|
||||||
|
|
@ -258,8 +273,11 @@ export function SwipeScreen() {
|
||||||
<MaterialCommunityIcons name={prefs.autoSwipeSec ? 'play-speed' : 'pause-circle-outline'} size={22} color="#fff" />
|
<MaterialCommunityIcons name={prefs.autoSwipeSec ? 'play-speed' : 'pause-circle-outline'} size={22} color="#fff" />
|
||||||
{prefs.autoSwipeSec ? <Text style={styles.topBtnLabel}>{prefs.autoSwipeSec}s</Text> : null}
|
{prefs.autoSwipeSec ? <Text style={styles.topBtnLabel}>{prefs.autoSwipeSec}s</Text> : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={toggleMute} style={styles.topBtn} accessibilityLabel="Sound">
|
{/* Dzwiek ma JEDEN przycisk: ten w kolumnie po prawej, przy kciuku (uwaga Jana
|
||||||
<MaterialCommunityIcons name={prefs.soundOn ? 'volume-high' : 'volume-off'} size={22} color="#fff" />
|
2026-09-16, byly dwa). Tapniecie w wideo dalej przelacza. Tu za to wejscie do
|
||||||
|
stroika feedu, bo po starcie nie bylo jak dolozyc tagow. */}
|
||||||
|
<Pressable onPress={() => setTuneOpen(true)} style={styles.topBtn} accessibilityLabel="Tune my feed">
|
||||||
|
<MaterialCommunityIcons name="tune-variant" size={22} color="#fff" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={newDeal} style={styles.topBtn} accessibilityLabel="New deal">
|
<Pressable onPress={newDeal} style={styles.topBtn} accessibilityLabel="New deal">
|
||||||
<MaterialCommunityIcons name="refresh" size={22} color="#fff" />
|
<MaterialCommunityIcons name="refresh" size={22} color="#fff" />
|
||||||
|
|
@ -301,12 +319,21 @@ export function SwipeScreen() {
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Onboarding 3 tapow */}
|
{/* Wybor zainteresowan: przy pierwszym wejsciu i pozniej spod ikony stroika */}
|
||||||
<OnboardingModal
|
<OnboardingModal
|
||||||
visible={prefsReady && !prefs.onboarded && !!options.data}
|
visible={prefsReady && !prefs.onboarded && !!options.data}
|
||||||
options={options.data}
|
options={options.data}
|
||||||
onDone={finishOnboarding}
|
onDone={finishOnboarding}
|
||||||
/>
|
/>
|
||||||
|
<OnboardingModal
|
||||||
|
visible={tuneOpen && !!options.data}
|
||||||
|
options={options.data}
|
||||||
|
tune
|
||||||
|
onDone={(tags, niches) => {
|
||||||
|
setTuneOpen(false);
|
||||||
|
if (tags.length || niches.length) void tuneFeed(tags, niches);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -325,13 +352,34 @@ function OnboardingModal({
|
||||||
visible,
|
visible,
|
||||||
options,
|
options,
|
||||||
onDone,
|
onDone,
|
||||||
|
tune,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
options: ClipOnboardingOptions | undefined;
|
options: ClipOnboardingOptions | undefined;
|
||||||
onDone: (tags: string[], niches: string[]) => void;
|
onDone: (tags: string[], niches: string[]) => void;
|
||||||
|
/** true = otwarte z zakladki (dostrajanie), false = pierwsze wejscie. */
|
||||||
|
tune?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const client = useClient();
|
||||||
const [picked, setPicked] = React.useState<Set<string>>(new Set());
|
const [picked, setPicked] = React.useState<Set<string>>(new Set());
|
||||||
const tags = (options?.tags ?? []).slice(0, 24);
|
const [pickedNiches, setPickedNiches] = React.useState<Set<string>>(new Set());
|
||||||
|
const [q, setQ] = React.useState('');
|
||||||
|
const [debouncedQ, setDebouncedQ] = React.useState('');
|
||||||
|
React.useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedQ(q.trim()), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [q]);
|
||||||
|
// Szukanie idzie po CALYM slowniku tagow (1,7 tys.), bo sama czolowka go nie miesci:
|
||||||
|
// "anal" jest dopiero 27. co do liczby klipow i wypadal z listy (uwaga Jana 2026-09-16).
|
||||||
|
const search = useQuery({
|
||||||
|
queryKey: ['clip-onboarding-search', debouncedQ],
|
||||||
|
queryFn: () => client.getClipOnboardingOptions(debouncedQ),
|
||||||
|
enabled: visible && debouncedQ.length >= 2,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const searching = debouncedQ.length >= 2;
|
||||||
|
const tags = searching ? (search.data?.tags ?? []) : (options?.tags ?? []);
|
||||||
|
const niches = searching ? [] : (options?.niches ?? []).slice(0, 12);
|
||||||
const toggle = (t: string) =>
|
const toggle = (t: string) =>
|
||||||
setPicked((s) => {
|
setPicked((s) => {
|
||||||
const n = new Set(s);
|
const n = new Set(s);
|
||||||
|
|
@ -339,27 +387,66 @@ function OnboardingModal({
|
||||||
else n.add(t);
|
else n.add(t);
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
|
const toggleNiche = (t: string) =>
|
||||||
|
setPickedNiches((s) => {
|
||||||
|
const n = new Set(s);
|
||||||
|
if (n.has(t)) n.delete(t);
|
||||||
|
else n.add(t);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
const total = picked.size + pickedNiches.size;
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade">
|
<Modal visible={visible} transparent animationType="fade">
|
||||||
<View style={styles.onboardBackdrop}>
|
<View style={styles.onboardBackdrop}>
|
||||||
<View style={styles.onboardCard}>
|
<View style={styles.onboardCard}>
|
||||||
<Text style={styles.sheetTitle}>What do you want to see?</Text>
|
<Text style={styles.sheetTitle}>{tune ? 'More of what you like' : 'What do you want to see?'}</Text>
|
||||||
<Text style={styles.centerText}>Pick three or more. The feed learns from what you watch anyway, this only gives it a head start.</Text>
|
<Text style={styles.centerText}>
|
||||||
<ScrollView style={{ maxHeight: 300 }} contentContainerStyle={styles.chipsWrap}>
|
{tune
|
||||||
|
? 'Pick anything you want more of. It counts right away and the next deal uses it.'
|
||||||
|
: 'Pick three or more, or search for anything that is not on the list. The feed learns from what you watch anyway, this only gives it a head start.'}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.search}
|
||||||
|
value={q}
|
||||||
|
onChangeText={setQ}
|
||||||
|
placeholder="search tags..."
|
||||||
|
placeholderTextColor={theme.muted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
style={{ maxHeight: 300 }}
|
||||||
|
contentContainerStyle={styles.chipsWrap}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
{tags.map((t) => (
|
{tags.map((t) => (
|
||||||
<Chip key={t.value} label={`#${t.value}`} selected={picked.has(t.value)} onPress={() => toggle(t.value)} />
|
<Chip key={t.value} label={`#${t.value}`} selected={picked.has(t.value)} onPress={() => toggle(t.value)} />
|
||||||
))}
|
))}
|
||||||
|
{searching && !search.isLoading && tags.length === 0 ? (
|
||||||
|
<Text style={styles.centerText}>Nothing matches that.</Text>
|
||||||
|
) : null}
|
||||||
|
{niches.map((n) => (
|
||||||
|
<Chip
|
||||||
|
key={n.value}
|
||||||
|
label={n.value}
|
||||||
|
selected={pickedNiches.has(n.value)}
|
||||||
|
onPress={() => toggleNiche(n.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.onboardRow}>
|
<View style={styles.onboardRow}>
|
||||||
<Pressable onPress={() => onDone([], [])} style={styles.secondaryBtn}>
|
<Pressable onPress={() => onDone([], [])} style={styles.secondaryBtn}>
|
||||||
<Text style={styles.secondaryBtnText}>Skip</Text>
|
<Text style={styles.secondaryBtnText}>{tune ? 'Cancel' : 'Skip'}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => onDone([...picked], [])}
|
onPress={() => onDone([...picked], [...pickedNiches])}
|
||||||
disabled={picked.size < 3}
|
disabled={tune ? total === 0 : total < 3}
|
||||||
style={[styles.primaryBtn, picked.size < 3 && { opacity: 0.4 }]}
|
style={[styles.primaryBtn, (tune ? total === 0 : total < 3) && { opacity: 0.4 }]}
|
||||||
>
|
>
|
||||||
<Text style={styles.primaryBtnText}>Start{picked.size ? ` (${picked.size})` : ''}</Text>
|
<Text style={styles.primaryBtnText}>
|
||||||
|
{tune ? 'Apply' : 'Start'}
|
||||||
|
{total ? ` (${total})` : ''}
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -391,6 +478,19 @@ const styles = StyleSheet.create({
|
||||||
onboardBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 18 },
|
onboardBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 18 },
|
||||||
onboardCard: { backgroundColor: theme.card, borderRadius: 18, padding: 18 },
|
onboardCard: { backgroundColor: theme.card, borderRadius: 18, padding: 18 },
|
||||||
onboardRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12 },
|
onboardRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12 },
|
||||||
|
search: {
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
backgroundColor: theme.bgElevated,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.border,
|
||||||
|
color: theme.fg,
|
||||||
|
fontFamily: fonts.body,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
primaryBtn: { backgroundColor: theme.accent, borderRadius: 14, paddingHorizontal: 18, paddingVertical: 10, marginLeft: 10, marginTop: 8 },
|
primaryBtn: { backgroundColor: theme.accent, borderRadius: 14, paddingHorizontal: 18, paddingVertical: 10, marginLeft: 10, marginTop: 8 },
|
||||||
primaryBtnText: { color: '#fff', fontFamily: fonts.display, fontSize: 15 },
|
primaryBtnText: { color: '#fff', fontFamily: fonts.display, fontSize: 15 },
|
||||||
secondaryBtn: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, marginTop: 8 },
|
secondaryBtn: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, marginTop: 8 },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue