feat(mobile): ukrywanie stron w zakladce Sites
Some checks failed
Backend tests / test (push) Has been cancelled

Przytrzymanie strony otwiera menu Pin/Unpin, Hide this site. Ukryte znikaja
z listy, nad nia "N hidden sites, tap to manage" prowadzi do Hidden content,
gdzie nowa sekcja Hidden sites ma Unhide. Emulator: ukrycie sxyprn, sekcja
z wpisem, odkrycie i powrot na liste, wpis w hidden_sites i jego usuniecie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-10-06 16:37:15 +02:00
parent 6540d4a009
commit ce174b671e
5 changed files with 99 additions and 6 deletions

View file

@ -474,6 +474,22 @@ export class GoonClient {
} }
} }
// Ukryte strony (lista w GET /blacklist, pole `sites`).
async hideSite(origin: string): Promise<void> {
await this.request(`/blacklist/sites/${encodeURIComponent(origin)}`, { method: 'POST' });
}
async unhideSite(origin: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/blacklist/sites/${encodeURIComponent(origin)}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
async listFavoriteMovies(): Promise<FavoriteMovieListOut> { async listFavoriteMovies(): Promise<FavoriteMovieListOut> {
return this.request('/favorites/movies'); return this.request('/favorites/movies');
} }

View file

@ -35,6 +35,14 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-10-06b',
date: 'October 2026',
items: [
'You can hide a site: long-press it in the Sites tab and pick Hide this site. Scenes that are only on that site disappear from every list, and on a scene that is also elsewhere its button is gone. Unhide in Settings, Hidden content.',
'Pinned sites now come across with your sync code too.',
],
},
{ {
id: '2026-10-06a', id: '2026-10-06a',
date: 'October 2026', date: 'October 2026',

View file

@ -51,6 +51,21 @@ export function BlacklistScreen() {
client.removeBlacklist(kind, id), client.removeBlacklist(kind, id),
onSuccess: afterChange, onSuccess: afterChange,
}); });
// Ukryte strony (przytrzymanie strony w zakladce Sites). Nazwy z listy stron, ktora
// i tak jest w cache; bez niej pokazujemy sam origin bez 'tube:'.
const sources = useQuery({ queryKey: ['sources'], queryFn: () => client.listSources(), staleTime: 60_000 });
const siteName = (origin: string) => {
const s = sources.data?.items.find((x) => x.origin === origin);
const raw = s?.display_name || origin.replace(/^tube:/, '');
return raw.replace(/\.[a-z]{2,5}$/i, '') || raw;
};
const unhideSite = useMutation({
mutationFn: (origin: string) => client.unhideSite(origin),
onSuccess: () => {
afterChange();
queryClient.invalidateQueries({ queryKey: ['scene'], refetchType: 'none' });
},
});
const bl = blacklistQuery.data; const bl = blacklistQuery.data;
const blacklistedTagIds = new Set((bl?.tags ?? []).map((t) => t.id)); const blacklistedTagIds = new Set((bl?.tags ?? []).map((t) => t.id));
@ -79,7 +94,8 @@ export function BlacklistScreen() {
<View style={{ paddingBottom: 40 }}> <View style={{ paddingBottom: 40 }}>
<Text style={styles.intro}> <Text style={styles.intro}>
Scenes tagged with, or from, anything you hide here stop showing up anywhere in Scenes tagged with, or from, anything you hide here stop showing up anywhere in
the app. Add a tag below (e.g. a category you never want to see). the app. Add a tag below (e.g. a category you never want to see). To hide a site,
long-press it in the Sites tab.
</Text> </Text>
<Text style={styles.sectionTitle}>Hide a tag</Text> <Text style={styles.sectionTitle}>Hide a tag</Text>
@ -130,6 +146,18 @@ export function BlacklistScreen() {
<Section title="Hidden studios" count={bl?.studios.length ?? 0}> <Section title="Hidden studios" count={bl?.studios.length ?? 0}>
{(bl?.studios ?? []).map((e) => renderRow('studio', e))} {(bl?.studios ?? []).map((e) => renderRow('studio', e))}
</Section> </Section>
<Section title="Hidden sites" count={bl?.sites?.length ?? 0}>
{(bl?.sites ?? []).map((origin) => (
<View key={`site-${origin}`} style={styles.row}>
<Text style={styles.rowName} numberOfLines={1}>
{siteName(origin)}
</Text>
<Pressable hitSlop={10} onPress={() => unhideSite.mutate(origin)} style={styles.removeBtn}>
<Text style={styles.removeText}>Unhide</Text>
</Pressable>
</View>
))}
</Section>
</> </>
)} )}
</View> </View>

View file

@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Modal, Modal,
Pressable, Pressable,
@ -87,11 +88,42 @@ export function SitesScreen() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['favorite-sites'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['favorite-sites'] }),
}); });
// Ukryte strony (zgloszenie 3910b19b): znikaja z tej listy, a z list scen znikaja sceny,
// ktore maja zrodla tylko z ukrytych stron. Na serwerze, razem z reszta "Hidden content",
// odkrywanie w Settings, Hidden content.
const blQuery = useQuery({
queryKey: ['blacklist'],
queryFn: () => client.listBlacklist(),
staleTime: 60_000,
});
const hidden = useMemo(() => new Set(blQuery.data?.sites ?? []), [blQuery.data?.sites]);
const hideSite = useMutation({
mutationFn: (origin: string) => client.hideSite(origin),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['blacklist'] });
qc.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
qc.invalidateQueries({ queryKey: ['scene'], refetchType: 'none' });
},
onError: (e) => Alert.alert('Could not hide the site', e instanceof Error ? e.message : String(e)),
});
const openMenu = (item: SourceOut) => {
const isPinned = pinned.has(item.origin);
Alert.alert(prettySiteName(item.display_name), undefined, [
{ text: isPinned ? 'Unpin' : 'Pin to top', onPress: () => togglePin.mutate(item.origin) },
{
text: 'Hide this site',
style: 'destructive',
onPress: () => hideSite.mutate(item.origin),
},
{ text: 'Cancel', style: 'cancel' },
]);
};
// Sort + filter client-side, lista ma <50 entries, nie warto roundtripować. // Sort + filter client-side, lista ma <50 entries, nie warto roundtripować.
// Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność // Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność
// zachowana. Dla 'recent' sortujemy po last_scraped_at DESC. // zachowana. Dla 'recent' sortujemy po last_scraped_at DESC.
const items = useMemo<SourceOut[]>(() => { const items = useMemo<SourceOut[]>(() => {
const all = data?.items ?? []; const all = (data?.items ?? []).filter((s) => !hidden.has(s.origin));
const filtered = debouncedQ const filtered = debouncedQ
? all.filter( ? all.filter(
(s) => (s) =>
@ -118,7 +150,7 @@ export function SitesScreen() {
} }
if (pinned.size === 0) return sorted; if (pinned.size === 0) return sorted;
return [...sorted.filter((s) => pinned.has(s.origin)), ...sorted.filter((s) => !pinned.has(s.origin))]; return [...sorted.filter((s) => pinned.has(s.origin)), ...sorted.filter((s) => !pinned.has(s.origin))];
}, [data?.items, debouncedQ, order, pinned]); }, [data?.items, debouncedQ, order, pinned, hidden]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@ -141,8 +173,15 @@ export function SitesScreen() {
}} }}
onClose={() => setSortOpen(false)} onClose={() => setSortOpen(false)}
/> />
{favQuery.data && pinned.size === 0 ? ( {favQuery.data && pinned.size === 0 && hidden.size === 0 ? (
<Text style={styles.hint}>long-press a site to pin it to the top</Text> <Text style={styles.hint}>long-press a site to pin it or hide it</Text>
) : null}
{hidden.size > 0 ? (
<Pressable onPress={() => navigation.navigate('Blacklist')} hitSlop={6}>
<Text style={styles.hint}>
{hidden.size} hidden {hidden.size === 1 ? 'site' : 'sites'}, tap to manage
</Text>
</Pressable>
) : null} ) : null}
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />} {isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
@ -167,7 +206,7 @@ export function SitesScreen() {
name: prettySiteName(item.display_name), name: prettySiteName(item.display_name),
}) })
} }
onLongPress={() => togglePin.mutate(item.origin)} onLongPress={() => openMenu(item)}
onShowRating={() => setDetail(item)} onShowRating={() => setDetail(item)}
/> />
)} )}

View file

@ -410,6 +410,8 @@ export interface BlacklistOut {
performers: BlacklistEntry[]; performers: BlacklistEntry[];
studios: BlacklistEntry[]; studios: BlacklistEntry[];
tags: BlacklistEntry[]; tags: BlacklistEntry[];
// Ukryte strony (originy, np. 'tube:porndoecom'); starszy serwer ich nie zwraca.
sites?: string[];
} }
export type BlacklistKind = 'performer' | 'studio' | 'tag'; export type BlacklistKind = 'performer' | 'studio' | 'tag';