From da2bab21e5f986fb56fa5ebb1cc2a27ec63e6584 Mon Sep 17 00:00:00 2001 From: Trey T Date: Mon, 1 Jun 2026 18:30:37 -0500 Subject: [PATCH] Block banned-country locations and align GraphQL ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth banned-country gate covering every entry point that could set a location Feeld's policy disallows (~60 countries from their support article): - New src/config/bannedCountries.ts — single source of truth (ISO codes + aliases) - New src/utils/reverseGeocode.ts — Nominatim reverse lookup w/ localStorage cache - New src/api/links/bannedCountryLink.ts — Apollo link chokepoint; intercepts every DeviceLocationUpdate mutation and refuses to forward if reverse-geocode resolves to a banned country. Catches Settings, Discover, Likes scanner, and ApiExplorer raw GraphQL alike. - useLocation.tsx — setLocation throws BannedCountryError; saveLocation gate; sanitize banned entries on localStorage and server hydration - Settings.tsx — block at search, saved-location pick, and save-current - Likes.tsx — skip banned saved locations in scanForLikes and "Fuck It" scan - server/index.js — PUT /api/saved-locations filters; readSavedLocations filters legacy banned entries so rotation cron is safe too - nginx.conf — route additions for new backend endpoints Plus the broader rc/realign-graphql-ops session work: GraphQL query/mutation realignment after Feeld API changes, ApiExplorer updates, Profile/Discover/Likes refinements, useFavorites hook, dataSync extensions, vite proxy adjustments. Co-Authored-By: Claude Opus 4.7 --- web/nginx.conf | 9 + web/server/index.js | 130 +++- web/src/api/client.ts | 3 +- web/src/api/dataSync.ts | 72 ++ web/src/api/links/bannedCountryLink.ts | 60 ++ web/src/api/operations/experimental.ts | 77 +- web/src/api/operations/mutations.ts | 171 ++--- web/src/api/operations/queries.ts | 709 ++++++++++++------ web/src/components/profile/ProfileCard.tsx | 42 +- .../components/profile/ProfileDetailModal.tsx | 9 +- web/src/config/bannedCountries.ts | 105 +++ web/src/hooks/useFavorites.ts | 181 +++++ web/src/hooks/useLocation.tsx | 95 ++- web/src/hooks/useSentPings.ts | 5 +- web/src/pages/ApiExplorer.tsx | 39 +- web/src/pages/Discover.tsx | 4 + web/src/pages/Likes.tsx | 301 ++++++-- web/src/pages/Profile.tsx | 30 +- web/src/pages/Settings.tsx | 46 +- web/src/utils/reverseGeocode.ts | 85 +++ web/vite.config.ts | 4 + 21 files changed, 1646 insertions(+), 531 deletions(-) create mode 100644 web/src/api/links/bannedCountryLink.ts create mode 100644 web/src/config/bannedCountries.ts create mode 100644 web/src/hooks/useFavorites.ts create mode 100644 web/src/utils/reverseGeocode.ts diff --git a/web/nginx.conf b/web/nginx.conf index 3d98046..18568ff 100755 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -75,6 +75,15 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # Favorites (saved-for-later profiles) endpoint + location /api/favorites { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + # Disliked profiles endpoint location /api/disliked-profiles { proxy_pass http://backend; diff --git a/web/server/index.js b/web/server/index.js index 0b942fa..f5b0077 100755 --- a/web/server/index.js +++ b/web/server/index.js @@ -315,6 +315,81 @@ app.delete('/api/sent-pings/:targetProfileId', (req, res) => { } }); +// GET /api/favorites - Get all saved/favorited profiles +app.get('/api/favorites', (req, res) => { + const filePath = path.join(DATA_DIR, 'favorites.json'); + if (fs.existsSync(filePath)) { + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + res.json(data); + } catch (e) { + console.error('Failed to read favorites.json:', e); + res.json({ favorites: [] }); + } + } else { + res.json({ favorites: [] }); + } +}); + +// POST /api/favorites - Add a favorited profile (idempotent) +app.post('/api/favorites', (req, res) => { + const { targetProfileId, profile } = req.body; + if (!targetProfileId) { + return res.status(400).json({ success: false, error: 'targetProfileId required' }); + } + const filePath = path.join(DATA_DIR, 'favorites.json'); + + let data = { favorites: [], updatedAt: null }; + if (fs.existsSync(filePath)) { + try { + data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (e) { + console.error('Failed to read favorites.json:', e); + } + } + + if (!data.favorites.some(f => f.targetProfileId === targetProfileId)) { + data.favorites.unshift({ + targetProfileId, + savedAt: Date.now(), + profile: profile || null, + }); + data.updatedAt = new Date().toISOString(); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + } + + res.json({ success: true, favorites: data.favorites }); +}); + +// DELETE /api/favorites/:targetProfileId - Remove a favorite +app.delete('/api/favorites/:targetProfileId', (req, res) => { + const { targetProfileId } = req.params; + const filePath = path.join(DATA_DIR, 'favorites.json'); + + if (fs.existsSync(filePath)) { + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + data.favorites = data.favorites.filter(f => f.targetProfileId !== targetProfileId); + data.updatedAt = new Date().toISOString(); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + res.json({ success: true, favorites: data.favorites }); + } catch (e) { + console.error('Failed to update favorites.json:', e); + res.status(500).json({ success: false, error: e.message }); + } + } else { + res.json({ success: true, favorites: [] }); + } +}); + +// DELETE /api/favorites - Clear all favorites +app.delete('/api/favorites', (req, res) => { + const filePath = path.join(DATA_DIR, 'favorites.json'); + const data = { favorites: [], updatedAt: new Date().toISOString() }; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + res.json({ success: true, favorites: [] }); +}); + // GET /api/disliked-profiles - Get all disliked profiles app.get('/api/disliked-profiles', (req, res) => { const filePath = path.join(DATA_DIR, 'dislikedProfiles.json'); @@ -1186,6 +1261,35 @@ const AUTH_TOKENS_FILE = path.join(DATA_DIR, 'auth-tokens.json'); const ROTATION_STATE_FILE = path.join(DATA_DIR, 'locationRotation.json'); const SAVED_LOCATIONS_FILE = path.join(DATA_DIR, 'savedLocations.json'); +// Countries where Feeld is NOT available — mirror of web/src/config/bannedCountries.ts. +// Keep in sync if that file changes. Used to filter incoming saved-location syncs. +const BANNED_COUNTRY_CODES = new Set([ + 'AL','DZ','AO','AM','AZ','BH','BD','BJ','BW','BN','BF','CM','TD','CN','CG','CU', + 'EG','GA','GM','HK','IR','IQ','JO','KZ','KE','LA','LR','MO','MG','MW','ML','MR', + 'MU','MM','MA','MZ','NA','NE','OM','PK','PG','PH','QA','RU','SA','SN','SL','SD', + 'TW','TJ','TZ','TH','TG','TN','TM','UG','UZ','YE','ZM','ZW', +]); +const BANNED_COUNTRY_NAMES = new Set([ + 'albania','algeria','angola','armenia','azerbaijan','bahrain','bangladesh','benin','botswana', + 'brunei darussalam','brunei','burkina faso','cameroon','chad','china',"people's republic of china", + 'republic of the congo','congo','congo, republic of','congo-brazzaville','cuba','egypt','gabon', + 'gambia','the gambia','hong kong','iran','islamic republic of iran','iraq','jordan','kazakhstan', + 'kenya','laos',"lao people's democratic republic",'lao pdr','liberia','macau','macao','madagascar', + 'malawi','mali','mauritania','mauritius','myanmar','burma','mayanmar','morocco','mozambique', + 'namibia','niger','oman','pakistan','papua new guinea','philippines','qatar','russia', + 'russian federation','saudi arabia','senegal','sierra leone','sudan','taiwan', + 'taiwan, province of china','republic of china','tajikistan','tanzania', + 'united republic of tanzania','thailand','togo','tunisia','turkmenistan','uganda','uzbekistan', + 'yemen','zambia','zimbabwe', +]); + +function isBannedLocation(loc) { + if (!loc) return false; + if (loc.countryCode && BANNED_COUNTRY_CODES.has(String(loc.countryCode).trim().toUpperCase())) return true; + if (loc.country && BANNED_COUNTRY_NAMES.has(String(loc.country).trim().toLowerCase())) return true; + return false; +} + class FeeldAPIClient { constructor() { this.accessToken = null; @@ -1393,7 +1497,11 @@ function writeRotationState(state) { function readSavedLocations() { if (fs.existsSync(SAVED_LOCATIONS_FILE)) { try { - return JSON.parse(fs.readFileSync(SAVED_LOCATIONS_FILE, 'utf8')); + const raw = JSON.parse(fs.readFileSync(SAVED_LOCATIONS_FILE, 'utf8')); + const arr = Array.isArray(raw) ? raw : []; + // Strip any banned-country entries that might be left over from before + // the country filter was added. Rotation and selection both go through here. + return arr.filter((loc) => !isBannedLocation(loc)); } catch (e) { console.error('[Rotation] Failed to read saved locations:', e.message); } @@ -1683,14 +1791,28 @@ app.get('/api/saved-locations', (req, res) => { res.json(readSavedLocations()); }); -// PUT /api/saved-locations — Browser syncs its saved locations here +// PUT /api/saved-locations — Browser syncs its saved locations here. +// Strips any locations in Feeld-restricted countries (defense in depth against +// stale clients or directly-crafted requests). app.put('/api/saved-locations', (req, res) => { const { locations } = req.body; if (!Array.isArray(locations)) { return res.status(400).json({ success: false, error: 'locations array required' }); } - writeSavedLocations(locations); - res.json({ success: true, count: locations.length }); + const filtered = []; + const rejected = []; + for (const loc of locations) { + if (isBannedLocation(loc)) { + rejected.push({ id: loc.id, name: loc.name, country: loc.country, countryCode: loc.countryCode }); + } else { + filtered.push(loc); + } + } + if (rejected.length) { + console.warn(`[saved-locations] dropped ${rejected.length} banned-country location(s):`, rejected); + } + writeSavedLocations(filtered); + res.json({ success: true, count: filtered.length, rejected: rejected.length }); }); // ============================================================ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3aa2fd3..9c4d3f7 100755 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,6 +3,7 @@ import { onError } from '@apollo/client/link/error'; import { setContext } from '@apollo/client/link/context'; import { API_CONFIG, REQUEST_HEADERS } from '../config/constants'; import { authManager } from './auth'; +import { bannedCountryLink } from './links/bannedCountryLink'; function generateUUID(): string { if (typeof crypto !== 'undefined' && crypto.randomUUID) { @@ -105,7 +106,7 @@ const errorLink = onError(({ graphQLErrors, operation, forward }) => { }); export const apolloClient = new ApolloClient({ - link: ApolloLink.from([errorLink, authLink, httpLink]), + link: ApolloLink.from([bannedCountryLink, errorLink, authLink, httpLink]), cache: new InMemoryCache({ typePolicies: { Profile: { diff --git a/web/src/api/dataSync.ts b/web/src/api/dataSync.ts index cf53d43..f70927f 100755 --- a/web/src/api/dataSync.ts +++ b/web/src/api/dataSync.ts @@ -557,5 +557,77 @@ export const clearAllDislikedProfiles = async (): Promise => { } }; +// Favorites - "saved for later" queue, local-only (no remote Feeld action) +export interface Favorite { + targetProfileId: string; + savedAt: number; + profile?: any; +} + +export const addFavorite = async (targetProfileId: string, profile?: any): Promise => { + const newFav: Favorite = { targetProfileId, savedAt: Date.now(), profile }; + + const current: Favorite[] = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}favorites`) || '[]'); + if (!current.some(f => f.targetProfileId === targetProfileId)) { + localStorage.setItem(`${LOCAL_STORAGE_PREFIX}favorites`, JSON.stringify([newFav, ...current])); + } + + if (await checkServerAvailable()) { + try { + await fetch(`${SERVER_URL}/favorites`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetProfileId, profile }), + }); + } catch (e) { + console.error('Failed to sync favorite to server:', e); + } + } +}; + +export const removeFavorite = async (targetProfileId: string): Promise => { + const current: Favorite[] = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}favorites`) || '[]'); + localStorage.setItem( + `${LOCAL_STORAGE_PREFIX}favorites`, + JSON.stringify(current.filter(f => f.targetProfileId !== targetProfileId)), + ); + + if (await checkServerAvailable()) { + try { + await fetch(`${SERVER_URL}/favorites/${encodeURIComponent(targetProfileId)}`, { method: 'DELETE' }); + } catch (e) { + console.error('Failed to remove favorite from server:', e); + } + } +}; + +export const getFavorites = async (): Promise => { + if (await checkServerAvailable()) { + try { + const r = await fetch(`${SERVER_URL}/favorites`); + if (r.ok) { + const data = await r.json(); + const favs = data.favorites || []; + localStorage.setItem(`${LOCAL_STORAGE_PREFIX}favorites`, JSON.stringify(favs)); + return favs; + } + } catch (e) { + console.error('Failed to fetch favorites from server:', e); + } + } + return JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}favorites`) || '[]'); +}; + +export const clearAllFavorites = async (): Promise => { + localStorage.setItem(`${LOCAL_STORAGE_PREFIX}favorites`, '[]'); + if (await checkServerAvailable()) { + try { + await fetch(`${SERVER_URL}/favorites`, { method: 'DELETE' }); + } catch (e) { + console.error('Failed to clear favorites on server:', e); + } + } +}; + // Export server status check export const isServerAvailable = checkServerAvailable; diff --git a/web/src/api/links/bannedCountryLink.ts b/web/src/api/links/bannedCountryLink.ts new file mode 100644 index 0000000..9f69016 --- /dev/null +++ b/web/src/api/links/bannedCountryLink.ts @@ -0,0 +1,60 @@ +import { ApolloLink, Observable } from '@apollo/client/core'; +import { isCountryBanned, findBannedCountry } from '../../config/bannedCountries'; +import { reverseGeocode } from '../../utils/reverseGeocode'; + +// Chokepoint: intercept every DeviceLocationUpdate mutation, reverse-geocode the +// input coords, and refuse to forward if the country is on Feeld's restricted list. +// This catches paths that bypass the React-level gates (ApiExplorer raw GraphQL, +// scanner loops, anything that calls the mutation directly). + +export const bannedCountryLink = new ApolloLink((operation, forward) => { + if (operation.operationName !== 'DeviceLocationUpdate') { + return forward(operation); + } + + const input = (operation.variables as any)?.input ?? {}; + const lat = Number(input.latitude); + const lng = Number(input.longitude); + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + return forward(operation); + } + + return new Observable((observer) => { + let cancelled = false; + let subscription: { unsubscribe: () => void } | null = null; + + reverseGeocode(lat, lng) + .then((result) => { + if (cancelled) return; + + if (result.resolved && isCountryBanned(result.country, result.countryCode)) { + const hit = findBannedCountry(result.country, result.countryCode); + const name = hit?.name ?? result.country ?? result.countryCode ?? 'this country'; + const msg = `[bannedCountryLink] Refusing DeviceLocationUpdate — Feeld is not available in ${name} (${lat.toFixed(3)}, ${lng.toFixed(3)})`; + console.warn(msg); + observer.error(new Error(`Feeld is not available in ${name}. Pick a different location.`)); + return; + } + + if (!result.resolved) { + console.warn( + `[bannedCountryLink] Could not resolve country for (${lat.toFixed(3)}, ${lng.toFixed(3)}); forwarding mutation.` + ); + } + + subscription = forward(operation).subscribe(observer); + }) + .catch((err) => { + // Reverse-geocode crashed unexpectedly — fail open (forward) to avoid breaking legitimate ops. + if (cancelled) return; + console.warn('[bannedCountryLink] Reverse-geocode error, forwarding anyway:', err); + subscription = forward(operation).subscribe(observer); + }); + + return () => { + cancelled = true; + subscription?.unsubscribe(); + }; + }); +}); diff --git a/web/src/api/operations/experimental.ts b/web/src/api/operations/experimental.ts index 3af72db..da5764b 100755 --- a/web/src/api/operations/experimental.ts +++ b/web/src/api/operations/experimental.ts @@ -1,62 +1,18 @@ import { gql } from '@apollo/client/core'; +import { + DISCOVERY_ANALYTICS_METADATA_FRAGMENT, + LIKES_PROFILE_FRAGMENT, + PICTURE_FRAGMENT, +} from './queries'; -// Real endpoints discovered in v8.11.0 - replaces old wrong guesses +// pastLikes / pastDislikes — shapes verbatim from live app v8.11.0. +// LikesProfileFragment lives in queries.ts (single source of truth, also used +// by FilteredWhoLikesMe / FilteredWhoPingsMe mutations). -export const LIKES_PROFILE_FRAGMENT = gql` - fragment LikesProfileFragment on Profile { - id - age - gender - status - lastSeen - desires - connectionGoals - isUplift - sexuality - isMajestic - dateOfBirth - streamUserId - imaginaryName - bio - hiddenBio - hasHiddenBio - allowPWM - interests - verificationStatus - interactionStatus { - message - mine - theirs - __typename - } - distance { - km - mi - __typename - } - photos { - id - pictureIsPrivate - pictureIsSafe - pictureStatus - pictureType - pictureUrl - pictureUrls { - small - medium - large - __typename - } - publicId - __typename - } - __typename - } -`; - -// pastLikes - profiles you've liked (new in v8.11.0) export const PAST_LIKES_QUERY = gql` ${LIKES_PROFILE_FRAGMENT} + ${DISCOVERY_ANALYTICS_METADATA_FRAGMENT} + ${PICTURE_FRAGMENT} query pastLikes($cursor: String, $input: PastLikesQueryInput!, $limit: Int) { pastLikes(cursor: $cursor, input: $input, limit: $limit) { nodes { @@ -64,6 +20,11 @@ export const PAST_LIKES_QUERY = gql` interactionSentAt profile { ...LikesProfileFragment + ...DiscoveryAnalyticsMetadata + photos { + ...GetPictureUrlFragment + __typename + } __typename } __typename @@ -80,15 +41,21 @@ export const PAST_LIKES_QUERY = gql` } `; -// pastDislikes - profiles you've passed (new in v8.11.0) export const PAST_DISLIKES_QUERY = gql` ${LIKES_PROFILE_FRAGMENT} + ${DISCOVERY_ANALYTICS_METADATA_FRAGMENT} + ${PICTURE_FRAGMENT} query pastDislikes($cursor: String, $input: PastDislikesQueryInput!, $limit: Int) { pastDislikes(cursor: $cursor, input: $input, limit: $limit) { nodes { interactionSentAt profile { ...LikesProfileFragment + ...DiscoveryAnalyticsMetadata + photos { + ...GetPictureUrlFragment + __typename + } __typename } __typename diff --git a/web/src/api/operations/mutations.ts b/web/src/api/operations/mutations.ts index e18f5d0..26a825e 100755 --- a/web/src/api/operations/mutations.ts +++ b/web/src/api/operations/mutations.ts @@ -1,6 +1,11 @@ import { gql } from '@apollo/client/core'; +import { + LIKES_PROFILE_FRAGMENT, + PROFILE_LOCATION_FRAGMENT, + SEARCH_SETTINGS_PROFILE_FRAGMENT, +} from './queries'; -// Mutations - exact from Proxyman +// Mutations - shapes verified against live app v8.11.0 capture export const PROFILE_LIKE_MUTATION = gql` mutation ProfileLike($targetProfileId: String!) { @@ -48,6 +53,7 @@ export const PROFILE_LIKE_MUTATION = gql` `; export const DEVICE_LOCATION_UPDATE_MUTATION = gql` + ${PROFILE_LOCATION_FRAGMENT} mutation DeviceLocationUpdate($input: DeviceLocationInput!) { deviceLocationUpdate(input: $input) { id @@ -67,37 +73,7 @@ export const DEVICE_LOCATION_UPDATE_MUTATION = gql` profiles { id location { - ... on DeviceLocation { - device { - latitude - longitude - geocode { - city - country - __typename - } - __typename - } - __typename - } - ... on TeleportLocation { - current: device { - city - country - __typename - } - teleport { - latitude - longitude - geocode { - city - country - __typename - } - __typename - } - __typename - } + ...ProfileLocationFragment __typename } __typename @@ -108,12 +84,14 @@ export const DEVICE_LOCATION_UPDATE_MUTATION = gql` `; export const SEARCH_SETTINGS_UPDATE_MUTATION = gql` + ${SEARCH_SETTINGS_PROFILE_FRAGMENT} mutation SearchSettingsUpdate( $ageRange: [Int] $distanceMax: Float $desiringFor: [Desire!] $lookingFor: [LookingFor!] $recentlyOnline: Boolean + $lgbtqiaMode: LgbtqiaMode ) { profileUpdate( input: { @@ -122,52 +100,10 @@ export const SEARCH_SETTINGS_UPDATE_MUTATION = gql` desiringFor: $desiringFor lookingFor: $lookingFor recentlyOnline: $recentlyOnline + lgbtqiaMode: $lgbtqiaMode } ) { - id - ageRange - distanceMax - desiringFor - lookingFor - location { - ... on DeviceLocation { - device { - latitude - longitude - geocode { - city - country - __typename - } - __typename - } - __typename - } - ... on VirtualLocation { - core - __typename - } - ... on TeleportLocation { - current: device { - city - country - __typename - } - teleport { - latitude - longitude - geocode { - city - country - __typename - } - __typename - } - __typename - } - __typename - } - recentlyOnline + ...SearchSettingsProfileFragment __typename } } @@ -175,11 +111,7 @@ export const SEARCH_SETTINGS_UPDATE_MUTATION = gql` export const LAST_SEEN_UPDATE_MUTATION = gql` mutation LastSeenProviderUpdateProfile($profileId: String!) { - lastSeenProviderUpdateProfile(profileId: $profileId) { - id - lastSeen - __typename - } + updatedProfileLastSeen: profileUpdateLastSeen(profileId: $profileId) } `; @@ -626,45 +558,48 @@ export const APP_SETTINGS_UPDATE_MUTATION = gql` } `; -// Filtered interactions -export const FILTERED_WHO_PINGS_ME_MUTATION = gql` - mutation FilteredWhoPingsMe($input: FilteredPingInteractionInput!, $cursor: String) { - filteredWhoPingsMe(input: $input, cursor: $cursor) { +// Filtered interactions — both Likes and Pings tabs use these (mutations, not queries). +export const FILTERED_WHO_LIKES_ME_MUTATION = gql` + ${LIKES_PROFILE_FRAGMENT} + mutation FilteredWhoLikesMe($input: FilteredInteractionInput!, $cursor: String) { + filteredWhoLikesMe(input: $input, cursor: $cursor) { + filters { + ageRange + desires + lookingFor + sexualities + __typename + } profiles { nodes { - id - age - gender - sexuality - imaginaryName - bio - desires - connectionGoals - interests - verificationStatus - isMajestic - distance { - km - mi - __typename - } - interactionStatus { - message - mine - theirs - __typename - } - photos { - id - pictureUrl - pictureUrls { - small - medium - large - __typename - } - __typename - } + ...LikesProfileFragment + __typename + } + pageInfo { + total + unfilteredTotal + hasNextPage + nextPageCursor + __typename + } + __typename + } + __typename + } + } +`; + +export const FILTERED_WHO_PINGS_ME_MUTATION = gql` + ${LIKES_PROFILE_FRAGMENT} + mutation FilteredWhoPingsMe($input: FilteredPingInteractionInput!, $cursor: String) { + filteredWhoPingsMe(input: $input, cursor: $cursor) { + filters { + ageRange + __typename + } + profiles { + nodes { + ...LikesProfileFragment __typename } pageInfo { diff --git a/web/src/api/operations/queries.ts b/web/src/api/operations/queries.ts index 9f2d40b..afbe409 100755 --- a/web/src/api/operations/queries.ts +++ b/web/src/api/operations/queries.ts @@ -1,6 +1,9 @@ import { gql } from '@apollo/client/core'; -// Profile fragments - exact from Proxyman +// ============================================================================ +// Profile fragments — verbatim from live app v8.11.0 capture +// ============================================================================ + export const PROFILE_LOCATION_FRAGMENT = gql` fragment ProfileLocationFragment on ProfileLocation { ... on DeviceLocation { @@ -130,6 +133,54 @@ export const ANALYTICS_PROFILE_FRAGMENT = gql` } `; +// Analytics fragment for the OWN profile (extra fields like analyticsId, lgbtqiaMode, lookingFor, etc.) +export const ANALYTICS_OWN_PROFILE_FRAGMENT = gql` + fragment AnalyticsOwnProfileFragment on Profile { + id + age + ageRange + bio + desires + desiringFor + analyticsId + distanceMax + hasHiddenBio + hiddenBio + interests + isUplift + recentlyOnline + lgbtqiaMode + isIncognito + status + isMajestic + gender + dateOfBirth + lookingFor + sexuality + allowPWM + enableChatContentModeration + location { + ...ProfileLocationFragment + __typename + } + profilePairs { + identityId + __typename + } + __typename + } +`; + +export const DISCOVERY_ANALYTICS_METADATA_FRAGMENT = gql` + fragment DiscoveryAnalyticsMetadata on Profile { + metadata { + source + __typename + } + __typename + } +`; + export const PARTNER_FRAGMENT = gql` ${PICTURE_FRAGMENT} ${PROFILE_INTERACTION_STATUS_FRAGMENT} @@ -170,206 +221,291 @@ export const CONSTELLATION_FRAGMENT = gql` } `; -// Main queries - exact from Proxyman -export const PROFILE_QUERY = gql` +// Profile content fragment — what the app uses for ProfileQuery + DiscoverProfiles nodes +export const PROFILE_CONTENT_PROFILE_FRAGMENT = gql` ${PROFILE_LOCATION_FRAGMENT} ${PHOTO_CAROUSEL_FRAGMENT} ${CONSTELLATION_FRAGMENT} ${ANALYTICS_PROFILE_FRAGMENT} + ${DISCOVERY_ANALYTICS_METADATA_FRAGMENT} + fragment ProfileContentProfileFragment on Profile { + bio + hiddenBio + hasHiddenBio + age + streamUserId + dateOfBirth + distance { + km + mi + __typename + } + connectionGoals + desires + gender + id + status + imaginaryName + interactionStatus { + message + mine + theirs + __typename + } + interests + isMajestic + isIncognito + lastSeen + location { + ...ProfileLocationFragment + __typename + } + sexuality + photos { + ...PhotoCarouselPictureFragment + __typename + } + ...Constellation + allowPWM + verificationStatus + enableChatContentModeration + ...AnalyticsProfileFragment + ...DiscoveryAnalyticsMetadata + isParticipantToEventChat + __typename + } +`; + +// Likes-tab profile fragment — used by FilteredWhoLikesMe / pastLikes / pastDislikes +export const LIKES_PROFILE_FRAGMENT = gql` + ${PROFILE_LOCATION_FRAGMENT} + ${PHOTO_CAROUSEL_FRAGMENT} + fragment LikesProfileFragment on Profile { + id + age + gender + status + lastSeen + desires + connectionGoals + isUplift + sexuality + isMajestic + dateOfBirth + streamUserId + imaginaryName + bio + hiddenBio + hasHiddenBio + allowPWM + interests + verificationStatus + interactionStatus { + message + mine + theirs + __typename + } + profilePairs { + identityId + __typename + } + distance { + km + mi + __typename + } + location { + ...ProfileLocationFragment + __typename + } + photos { + ...PhotoCarouselPictureFragment + __typename + } + __typename + } +`; + +// ============================================================================ +// Account / auth fragments — verbatim from live app +// ============================================================================ + +export const PICTURE_VERIFICATION_META_FRAGMENT = gql` + fragment PictureVerificationMeta on Picture { + id + verification { + status + updatedAt + sessionUrl + failureReason + attempts + __typename + } + enrollment { + sessionId + status + updatedAt + failureReason + __typename + } + __typename + } +`; + +export const CHAT_USER_FRAGMENT = gql` + fragment ChatUser on Profile { + id + streamToken + streamUserId + __typename + } +`; + +export const APP_SETTINGS_OPTIONS_ACCOUNT_FRAGMENT = gql` + fragment AppSettingsOptionsAccountFragment on Account { + appSettings { + receiveMarketingNotifications + receiveNewsEmailNotifications + receivePromotionsEmailNotifications + receiveNewsPushNotifications + receivePromotionsPushNotifications + receiveNewConnectionPushNotifications + receiveNewPingPushNotifications + receiveNewMessagePushNotifications + receiveNewLikePushNotifications + __typename + } + __typename + } +`; + +export const AUTH_PROFILE_FRAGMENT = gql` + ${PICTURE_VERIFICATION_META_FRAGMENT} + ${PICTURE_FRAGMENT} + ${CHAT_USER_FRAGMENT} + ${ANALYTICS_OWN_PROFILE_FRAGMENT} + fragment AuthProfile on Profile { + imaginaryName + verificationLimits { + attemptsAvailable + __typename + } + connectionGoals + canVerify + photos { + pictureUrl + pictureStatus + ...PictureVerificationMeta + ...GetPictureUrlFragment + __typename + } + verificationStatus + reflectionResponseIds + ...ChatUser + ...AnalyticsOwnProfileFragment + showTerminatedConnectionBanner + __typename + } +`; + +export const AUTH_PROVIDER_FRAGMENT = gql` + ${APP_SETTINGS_OPTIONS_ACCOUNT_FRAGMENT} + ${AUTH_PROFILE_FRAGMENT} + fragment AuthProviderFragment on Account { + id + email + analyticsId + status + createdAt + isFinishedOnboarding + isMajestic + availablePings + upliftExpirationTimestamp + isUplift + isDistanceInMiles + language + ageVerificationStatus + verificationNumber + challenges + ...AppSettingsOptionsAccountFragment + location { + device { + country + __typename + } + __typename + } + profiles { + ...AuthProfile + __typename + } + __typename + } +`; + +// ============================================================================ +// Search-settings fragment — used by SearchSettingsQuery + SearchSettingsUpdate +// ============================================================================ + +export const SEARCH_SETTINGS_PROFILE_FRAGMENT = gql` + ${PROFILE_LOCATION_FRAGMENT} + fragment SearchSettingsProfileFragment on Profile { + id + ageRange + distanceMax + desiringFor + lookingFor + location { + ...ProfileLocationFragment + __typename + } + recentlyOnline + lgbtqiaMode + __typename + } +`; + +// ============================================================================ +// Queries — verbatim shape from live app v8.11.0 +// ============================================================================ + +export const PROFILE_QUERY = gql` + ${PROFILE_CONTENT_PROFILE_FRAGMENT} query ProfileQuery($profileId: String!) { profile(id: $profileId) { - bio - hiddenBio - hasHiddenBio - age + ...ProfileContentProfileFragment streamUserId - dateOfBirth - distance { - km - mi - __typename - } - connectionGoals - desires - lookingFor - ageRange - distanceMax - recentlyOnline - gender - id - status - imaginaryName - interactionStatus { - message - mine - theirs - __typename - } - interests - isMajestic - isIncognito - lastSeen - location { - ...ProfileLocationFragment - __typename - } - sexuality - photos { - ...PhotoCarouselPictureFragment - __typename - } - ...Constellation - allowPWM - verificationStatus - enableChatContentModeration - ...AnalyticsProfileFragment - isParticipantToEventChat __typename } } `; export const DISCOVER_PROFILES_QUERY = gql` - ${PICTURE_FRAGMENT} - ${PROFILE_INTERACTION_STATUS_FRAGMENT} - ${ANALYTICS_PROFILE_FRAGMENT} + ${PROFILE_CONTENT_PROFILE_FRAGMENT} + ${CONSTELLATION_FRAGMENT} + ${DISCOVERY_ANALYTICS_METADATA_FRAGMENT} query DiscoverProfiles($input: ProfileDiscoveryInput!) { discovery(input: $input) { nodes { - id + ...ProfileContentProfileFragment + ...DiscoveryAnalyticsMetadata + ...Constellation + streamUserId + analyticsId age - imaginaryName - gender - sexuality - isIncognito - isMajestic - verificationStatus - connectionGoals - desires - bio - interests distance { km mi __typename } - photos { - ...GetPictureUrlFragment - pictureType - __typename - } - ...ProfileInteractionStatusFragment - ...AnalyticsProfileFragment __typename } hasNextBatch - __typename - } - } -`; - -export const WHO_LIKES_ME_QUERY = gql` - ${PICTURE_FRAGMENT} - ${PROFILE_INTERACTION_STATUS_FRAGMENT} - ${ANALYTICS_PROFILE_FRAGMENT} - query WhoLikesMe($sortBy: SortBy!, $limit: Int, $cursor: String) { - interactions: whoLikesMe(input: { sortBy: $sortBy }, limit: $limit, cursor: $cursor) { - nodes { - id - age - imaginaryName - gender - sexuality - isIncognito - isMajestic - verificationStatus - desires - connectionGoals - distance { - km - mi - __typename - } - photos { - ...GetPictureUrlFragment - pictureType - __typename - } - ...ProfileInteractionStatusFragment - ...AnalyticsProfileFragment - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } - __typename - } - } -`; - -export const WHO_PINGS_ME_QUERY = gql` - ${PROFILE_LOCATION_FRAGMENT} - ${PHOTO_CAROUSEL_FRAGMENT} - query WhoPingsMe($sortBy: SortBy!, $limit: Int, $cursor: String) { - interactions: whoPingsMe( - input: { sortBy: $sortBy } - limit: $limit - cursor: $cursor - ) { - nodes { - id - age - gender - status - lastSeen - desires - connectionGoals - isUplift - sexuality - isMajestic - dateOfBirth - streamUserId - imaginaryName - bio - hiddenBio - hasHiddenBio - allowPWM - interests - verificationStatus - interactionStatus { - message - mine - theirs - __typename - } - profilePairs { - identityId - __typename - } - distance { - km - mi - __typename - } - location { - ...ProfileLocationFragment - __typename - } - photos { - ...PhotoCarouselPictureFragment - __typename - } - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } + feedGeneratedAt + generatedWithProfileUpdatedAt + feedSize + feedCapacity __typename } } @@ -415,7 +551,7 @@ export const HEADER_SUMMARIES_QUERY = gql` `; export const LIST_SUMMARIES_QUERY = gql` - query ListSummaries($limit: Int = 30, $cursor: String) { + query ListSummaries($limit: Int = 10, $cursor: String) { summaries: getChatSummariesForChatList(limit: $limit, cursor: $cursor) { nodes { id @@ -484,27 +620,48 @@ export const GET_CHAT_SUMMARY_QUERY = gql` } `; +// SearchSettingsQuery — own-profile search settings (the app fetches these +// separately from ProfileQuery, which doesn't include them). +export const SEARCH_SETTINGS_QUERY = gql` + ${SEARCH_SETTINGS_PROFILE_FRAGMENT} + query SearchSettingsQuery($profileId: String!) { + profile(id: $profileId) { + ...SearchSettingsProfileFragment + __typename + } + } +`; + export const DISCOVER_SEARCH_SETTINGS_QUERY = gql` + ${PROFILE_LOCATION_FRAGMENT} query DiscoverSearchSettingsQuery($profileId: String!) { profile(id: $profileId) { id + status ageRange - distanceMax - lookingFor desiringFor + desires + distanceMax + location { + ...ProfileLocationFragment + __typename + } + lookingFor recentlyOnline + lgbtqiaMode + allowPWM + imaginaryName __typename } } `; export const AVAILABLE_DESIRES_QUERY = gql` - query AvailableDesires($locale: String!, $version: Int!) { - availableDesires(locale: $locale, version: $version) { - id - name - description + query AvailableDesires($locale: String!, $version: Int) { + desiresByCategoryLocalised(locale: $locale, version: $version) { category + desires + localisedCategory __typename } } @@ -520,7 +677,111 @@ export const IS_INCOGNITO_QUERY = gql` } `; -// Query for Stream Chat credentials +export const IS_PROFILE_VALID_QUERY = gql` + query IsProfileValidQuery($profileId: String!) { + profile(id: $profileId) { + id + status + __typename + } + } +`; + +export const LGBTQIA_MODE_CHECK_QUERY = gql` + query LgbtqiaModeCheck($profileId: String!) { + profile(id: $profileId) { + id + gender + sexuality + __typename + } + } +`; + +export const UPLIFT_PURCHASE_QUERY = gql` + query UpliftPurchaseQuery($profileId: String!) { + profile(id: $profileId) { + id + status + __typename + } + } +`; + +export const PHOTO_SELECTOR_QUERY = gql` + query PhotoSelectorQuery($profileId: String!) { + profile(id: $profileId) { + id + photos { + id + pictureIsPrivate + pictureIsSafe + pictureOrder + pictureStatus + pictureType + pictureUrl + pictureUrls { + small + medium + large + __typename + } + publicId + verification { + status + failureReason + attempts + __typename + } + enrollment { + status + failureReason + __typename + } + __typename + } + __typename + } + } +`; + +export const WLY_FILTERS_QUERY = gql` + query WLYFiltersQuery($profileId: String!) { + profile(id: $profileId) { + id + ageRange + whoLikesMeFilters { + ageRange + desires + lookingFor + sexualities + __typename + } + whoPingsMeFilters { + ageRange + __typename + } + __typename + } + } +`; + +export const ANALYTICS_QUERY = gql` + ${ANALYTICS_OWN_PROFILE_FRAGMENT} + query AnalyticsQuery($profileId: String!) { + account { + id + analyticsId + __typename + } + profile(id: $profileId) { + ...AnalyticsOwnProfileFragment + __typename + } + } +`; + +// Stream Chat credentials (own-profile streamToken) export const STREAM_CREDENTIALS_QUERY = gql` query StreamCredentialsQuery($profileId: String!) { profile(id: $profileId) { @@ -533,22 +794,19 @@ export const STREAM_CREDENTIALS_QUERY = gql` } `; +// AuthProviderQuery — fetches the full account snapshot via AuthProviderFragment. +// The live app uses this to populate its account state at startup. export const AUTH_PROVIDER_QUERY = gql` + ${AUTH_PROVIDER_FRAGMENT} query AuthProviderQuery { account { - id - status - profiles { - id - status - imaginaryName - __typename - } + ...AuthProviderFragment __typename } } `; +// AuthProviderStatusSyncQuery — incremental account/profile sync for status changes. export const ACCOUNT_STATUS_QUERY = gql` ${PICTURE_FRAGMENT} ${PROFILE_INTERACTION_STATUS_FRAGMENT} @@ -578,7 +836,16 @@ export const ACCOUNT_STATUS_QUERY = gql` } `; -// === New queries discovered via API probing + APK analysis (v8.11.0) === +// AppSettings — same shape as AuthProviderQuery (live app reuses the fragment). +export const APP_SETTINGS_QUERY = gql` + ${AUTH_PROVIDER_FRAGMENT} + query AppSettings { + account { + ...AuthProviderFragment + __typename + } + } +`; export const POPULAR_LOCATIONS_QUERY = gql` query PopularLocationsQuery { @@ -637,53 +904,6 @@ export const REDEEMED_OFFERS_QUERY = gql` } `; -export const APP_SETTINGS_QUERY = gql` - query AppSettings { - account { - id - email - analyticsId - status - createdAt - isFinishedOnboarding - isMajestic - upliftExpirationTimestamp - isUplift - isDistanceInMiles - language - ageVerificationStatus - verificationNumber - challenges - appSettings { - receiveMarketingNotifications - receiveNewsEmailNotifications - receivePromotionsEmailNotifications - receiveNewsPushNotifications - receivePromotionsPushNotifications - receiveNewConnectionPushNotifications - receiveNewPingPushNotifications - receiveNewMessagePushNotifications - receiveNewLikePushNotifications - __typename - } - location { - device { - country - __typename - } - __typename - } - profiles { - id - status - imaginaryName - __typename - } - __typename - } - } -`; - export const PROFILE_BY_STREAM_USER_ID_QUERY = gql` ${PICTURE_FRAGMENT} query ProfileByStreamUserId($streamUserId: String!) { @@ -766,8 +986,13 @@ export const PROFILE_MATCHES_QUERY = gql` } `; +// hasLinkedReflection — operationName is lowercase to match live app exactly. export const HAS_LINKED_REFLECTION_QUERY = gql` - query HasLinkedReflection { + query hasLinkedReflection { hasLinkedReflection } `; + +// Note: the legacy WhoLikesMe / WhoPingsMe *queries* are no longer used. +// The live app uses FilteredWhoLikesMe / FilteredWhoPingsMe *mutations* +// (see mutations.ts). Don't add them back. diff --git a/web/src/components/profile/ProfileCard.tsx b/web/src/components/profile/ProfileCard.tsx index 5b29562..9987ab1 100755 --- a/web/src/components/profile/ProfileCard.tsx +++ b/web/src/components/profile/ProfileCard.tsx @@ -36,6 +36,10 @@ interface ProfileCardProps { onDislike?: (profile: any) => Promise; isDisliking?: boolean; showDislike?: boolean; + // Favorite ("save for later") — local-only, no remote action. + // Star button renders only when both props are supplied. + onToggleFavorite?: (profile: any) => void | Promise; + isFavorited?: boolean; } const safeText = (v: any): string => { @@ -45,7 +49,7 @@ const safeText = (v: any): string => { return ''; }; -export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshing, onDislike, isDisliking, showDislike }: ProfileCardProps) { +export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshing, onDislike, isDisliking, showDislike, onToggleFavorite, isFavorited }: ProfileCardProps) { const [imageError, setImageError] = useState(false); const [copied, setCopied] = useState(false); @@ -76,6 +80,11 @@ export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshi if (onDislike && !isDisliking) await onDislike(profile); }; + const handleToggleFavorite = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onToggleFavorite) onToggleFavorite(profile); + }; + return (
+ {onToggleFavorite && ( + + )} {profile.isMajestic && (
void; onMatch?: () => void; + // Fires after any committed action on this profile (like, ping, dislike). + // Used by the Saved tab to auto-remove the profile from favorites after + // an action is taken from within the modal. + onActionTaken?: (action: 'like' | 'ping' | 'dislike') => void; } const styles = { @@ -521,7 +525,7 @@ const styles = { }, }; -export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetailModalProps) { +export function ProfileDetailModal({ profileId, onClose, onMatch, onActionTaken }: ProfileDetailModalProps) { const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const [isClosing, setIsClosing] = useState(false); const [showPingModal, setShowPingModal] = useState(false); @@ -606,6 +610,7 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai if (data.profileLike.status === 'MATCHED') { onMatch?.(); } + onActionTaken?.('like'); handleClose(); }, }); @@ -617,6 +622,7 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai await addSentPing(viewingProfileId, profile?.imaginaryName); refetchAccount(); setShowPingModal(false); + onActionTaken?.('ping'); handleClose(); } }, @@ -637,6 +643,7 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai }); console.log('Disliked profile:', profile?.imaginaryName); } + onActionTaken?.('dislike'); handleClose(); }, }); diff --git a/web/src/config/bannedCountries.ts b/web/src/config/bannedCountries.ts new file mode 100644 index 0000000..d7877ee --- /dev/null +++ b/web/src/config/bannedCountries.ts @@ -0,0 +1,105 @@ +// Countries where Feeld is NOT available. +// Source: https://support.feeld.co/hc/en-gb/articles/14876019037212-Countries-where-Feeld-is-NOT-available +// +// Using a location in any of these countries will get the account flagged inactive +// per Feeld's policy. The web client refuses to set/save them. + +export interface BannedCountry { + name: string; + code: string; // ISO 3166-1 alpha-2 + aliases?: string[]; +} + +export const BANNED_COUNTRIES: BannedCountry[] = [ + { name: 'Albania', code: 'AL' }, + { name: 'Algeria', code: 'DZ' }, + { name: 'Angola', code: 'AO' }, + { name: 'Armenia', code: 'AM' }, + { name: 'Azerbaijan', code: 'AZ' }, + { name: 'Bahrain', code: 'BH' }, + { name: 'Bangladesh', code: 'BD' }, + { name: 'Benin', code: 'BJ' }, + { name: 'Botswana', code: 'BW' }, + { name: 'Brunei Darussalam', code: 'BN', aliases: ['Brunei'] }, + { name: 'Burkina Faso', code: 'BF' }, + { name: 'Cameroon', code: 'CM' }, + { name: 'Chad', code: 'TD' }, + { name: 'China', code: 'CN', aliases: ["People's Republic of China", 'PRC'] }, + { name: 'Republic of the Congo', code: 'CG', aliases: ['Congo', 'Congo, Republic of', 'Congo-Brazzaville'] }, + { name: 'Cuba', code: 'CU' }, + { name: 'Egypt', code: 'EG' }, + { name: 'Gabon', code: 'GA' }, + { name: 'Gambia', code: 'GM', aliases: ['The Gambia'] }, + { name: 'Hong Kong', code: 'HK' }, + { name: 'Iran', code: 'IR', aliases: ['Islamic Republic of Iran'] }, + { name: 'Iraq', code: 'IQ' }, + { name: 'Jordan', code: 'JO' }, + { name: 'Kazakhstan', code: 'KZ' }, + { name: 'Kenya', code: 'KE' }, + { name: 'Laos', code: 'LA', aliases: ["Lao People's Democratic Republic", 'Lao PDR'] }, + { name: 'Liberia', code: 'LR' }, + { name: 'Macau', code: 'MO', aliases: ['Macao'] }, + { name: 'Madagascar', code: 'MG' }, + { name: 'Malawi', code: 'MW' }, + { name: 'Mali', code: 'ML' }, + { name: 'Mauritania', code: 'MR' }, + { name: 'Mauritius', code: 'MU' }, + { name: 'Myanmar', code: 'MM', aliases: ['Burma', 'Mayanmar'] }, // "Mayanmar" is Feeld's page typo + { name: 'Morocco', code: 'MA' }, + { name: 'Mozambique', code: 'MZ' }, + { name: 'Namibia', code: 'NA' }, + { name: 'Niger', code: 'NE' }, + { name: 'Oman', code: 'OM' }, + { name: 'Pakistan', code: 'PK' }, + { name: 'Papua New Guinea', code: 'PG' }, + { name: 'Philippines', code: 'PH' }, + { name: 'Qatar', code: 'QA' }, + { name: 'Russia', code: 'RU', aliases: ['Russian Federation'] }, + { name: 'Saudi Arabia', code: 'SA' }, + { name: 'Senegal', code: 'SN' }, + { name: 'Sierra Leone', code: 'SL' }, + { name: 'Sudan', code: 'SD' }, + { name: 'Taiwan', code: 'TW', aliases: ['Taiwan, Province of China', 'Republic of China'] }, + { name: 'Tajikistan', code: 'TJ' }, + { name: 'Tanzania', code: 'TZ', aliases: ['United Republic of Tanzania'] }, + { name: 'Thailand', code: 'TH' }, + { name: 'Togo', code: 'TG' }, + { name: 'Tunisia', code: 'TN' }, + { name: 'Turkmenistan', code: 'TM' }, + { name: 'Uganda', code: 'UG' }, + { name: 'Uzbekistan', code: 'UZ' }, + { name: 'Yemen', code: 'YE' }, + { name: 'Zambia', code: 'ZM' }, + { name: 'Zimbabwe', code: 'ZW' }, +]; + +const BANNED_CODES = new Set(BANNED_COUNTRIES.map((c) => c.code.toUpperCase())); +const BANNED_NAMES = new Set(); +for (const c of BANNED_COUNTRIES) { + BANNED_NAMES.add(c.name.toLowerCase()); + for (const alias of c.aliases ?? []) BANNED_NAMES.add(alias.toLowerCase()); +} + +const normalize = (s: string) => s.trim().toLowerCase(); + +export function isCountryBanned(country?: string | null, countryCode?: string | null): boolean { + if (countryCode && BANNED_CODES.has(countryCode.trim().toUpperCase())) return true; + if (country && BANNED_NAMES.has(normalize(country))) return true; + return false; +} + +export function findBannedCountry(country?: string | null, countryCode?: string | null): BannedCountry | null { + if (countryCode) { + const code = countryCode.trim().toUpperCase(); + const hit = BANNED_COUNTRIES.find((c) => c.code === code); + if (hit) return hit; + } + if (country) { + const n = normalize(country); + const hit = BANNED_COUNTRIES.find( + (c) => c.name.toLowerCase() === n || c.aliases?.some((a) => a.toLowerCase() === n) + ); + if (hit) return hit; + } + return null; +} diff --git a/web/src/hooks/useFavorites.ts b/web/src/hooks/useFavorites.ts new file mode 100644 index 0000000..fcc5e89 --- /dev/null +++ b/web/src/hooks/useFavorites.ts @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useState } from 'react'; +import * as dataSync from '../api/dataSync'; +import { apolloClient } from '../api/client'; +import { PROFILE_QUERY } from '../api/operations/queries'; + +export type FavoriteProfile = { + id: string; + imaginaryName?: string; + age?: number; + gender?: string; + sexuality?: string; + bio?: string | null; + desires?: string[]; + connectionGoals?: string[]; + interests?: string[]; + verificationStatus?: string | null; + isMajestic?: boolean; + distance?: { km: number; mi: number } | null; + photos?: Array<{ + id: string; + publicId?: string; + pictureUrls?: { small?: string; medium?: string; large?: string }; + }>; + interactionStatus?: { + mine?: string | null; + theirs?: string | null; + message?: string | null; + } | null; +}; + +export interface EnrichedFavorite { + targetProfileId: string; + savedAt: number; + profile?: FavoriteProfile; + profileError?: string; +} + +// Strip volatile fields (signed photo URLs that expire) before saving the +// snapshot. Keep enough to render a card if Feeld's API isn't available. +const snapshotForStorage = (p: any): FavoriteProfile => ({ + id: p?.id, + imaginaryName: p?.imaginaryName, + age: p?.age, + gender: typeof p?.gender === 'string' ? p.gender : undefined, + sexuality: typeof p?.sexuality === 'string' ? p.sexuality : undefined, + bio: typeof p?.bio === 'string' ? p.bio : null, + desires: Array.isArray(p?.desires) ? p.desires : [], + connectionGoals: Array.isArray(p?.connectionGoals) ? p.connectionGoals : [], + interests: Array.isArray(p?.interests) ? p.interests : [], + verificationStatus: typeof p?.verificationStatus === 'string' ? p.verificationStatus : null, + isMajestic: !!p?.isMajestic, + distance: p?.distance ?? null, + photos: Array.isArray(p?.photos) + ? p.photos.map((ph: any) => ({ + id: ph?.id, + publicId: ph?.publicId, + // pictureUrls intentionally omitted — they're signed and expire + })) + : [], +}); + +export function useFavorites() { + const [favorites, setFavorites] = useState([]); + const [loading, setLoading] = useState(true); + const [profilesLoading, setProfilesLoading] = useState(false); + + const refreshFromServer = useCallback(async () => { + setLoading(true); + try { + const raw = await dataSync.getFavorites(); + // Start with snapshot data so the UI renders immediately + setFavorites( + raw.map((f) => ({ + targetProfileId: f.targetProfileId, + savedAt: f.savedAt, + profile: f.profile as FavoriteProfile | undefined, + })), + ); + } finally { + setLoading(false); + } + }, []); + + // Initial load + useEffect(() => { + refreshFromServer(); + }, [refreshFromServer]); + + // Re-fetch full profile data + fresh signed photo URLs + current + // interactionStatus for every favorite via ProfileQuery. Uses network-only + // so we never serve stale interaction state from the Apollo cache. + const enrichProfiles = useCallback(async () => { + const current = favorites; + if (current.length === 0) return; + setProfilesLoading(true); + try { + const enriched = await Promise.all( + current.map(async (f) => { + try { + const result = await apolloClient.query({ + query: PROFILE_QUERY, + variables: { profileId: f.targetProfileId }, + fetchPolicy: 'network-only', + }); + const profile = result.data?.profile; + return profile + ? { ...f, profile } + : { ...f, profileError: 'Profile not found' }; + } catch (e: any) { + return { ...f, profileError: e?.message || 'Fetch failed' }; + } + }), + ); + setFavorites(enriched); + } finally { + setProfilesLoading(false); + } + // We intentionally read the current `favorites` snapshot — re-running on + // every change would cause an infinite loop after we setFavorites below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [favorites.length]); + + // Re-fetch profile data whenever the list changes + useEffect(() => { + if (!loading && favorites.length > 0) { + enrichProfiles(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, favorites.length]); + + const addFavorite = useCallback(async (profile: any) => { + if (!profile?.id) return; + const snapshot = snapshotForStorage(profile); + await dataSync.addFavorite(profile.id, snapshot); + setFavorites((prev) => + prev.some((f) => f.targetProfileId === profile.id) + ? prev + : [{ targetProfileId: profile.id, savedAt: Date.now(), profile: snapshot }, ...prev], + ); + }, []); + + const removeFavorite = useCallback(async (targetProfileId: string) => { + await dataSync.removeFavorite(targetProfileId); + setFavorites((prev) => prev.filter((f) => f.targetProfileId !== targetProfileId)); + }, []); + + const isFavorite = useCallback( + (targetProfileId: string) => favorites.some((f) => f.targetProfileId === targetProfileId), + [favorites], + ); + + const toggleFavorite = useCallback( + async (profile: any) => { + if (!profile?.id) return; + if (isFavorite(profile.id)) { + await removeFavorite(profile.id); + } else { + await addFavorite(profile); + } + }, + [isFavorite, addFavorite, removeFavorite], + ); + + const clearAll = useCallback(async () => { + await dataSync.clearAllFavorites(); + setFavorites([]); + }, []); + + return { + favorites, + loading, + profilesLoading, + addFavorite, + removeFavorite, + isFavorite, + toggleFavorite, + clearAll, + refreshFromServer, + refreshProfiles: enrichProfiles, + }; +} diff --git a/web/src/hooks/useLocation.tsx b/web/src/hooks/useLocation.tsx index 2e493ab..fca73f9 100755 --- a/web/src/hooks/useLocation.tsx +++ b/web/src/hooks/useLocation.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import * as dataSync from '../api/dataSync'; +import { isCountryBanned, findBannedCountry } from '../config/bannedCountries'; // UUID generator that works in non-secure contexts (HTTP) function generateUUID(): string { @@ -18,19 +19,39 @@ export interface SavedLocation { name: string; latitude: number; longitude: number; + country?: string; + countryCode?: string; +} + +export class BannedCountryError extends Error { + bannedCountry: string; + constructor(country: string) { + super(`Feeld is not available in ${country}. Pick a different location.`); + this.name = 'BannedCountryError'; + this.bannedCountry = country; + } } export interface LocationState { latitude: number; longitude: number; name?: string; + country?: string; + countryCode?: string; } interface LocationContextType { location: LocationState | null; savedLocations: SavedLocation[]; + // Throws BannedCountryError if the LocationState has a banned country/countryCode. setLocation: (location: LocationState | null) => void; - saveLocation: (name: string, lat: number, lng: number) => void; + // Throws BannedCountryError if the country is on the Feeld-restricted list. + saveLocation: ( + name: string, + lat: number, + lng: number, + meta?: { country?: string; countryCode?: string } + ) => void; deleteLocation: (id: string) => void; clearLocation: () => void; } @@ -40,15 +61,25 @@ const LocationContext = createContext(null); const STORAGE_KEY = 'feeld_locations'; const CURRENT_LOCATION_KEY = 'feeld_current_location'; +// Strip any banned-country entry coming off the wire / off disk — legacy data +// from before the country filter existed should not be re-applied. +const sanitizeCurrent = (loc: LocationState | null | undefined): LocationState | null => { + if (!loc) return null; + if (isCountryBanned(loc.country, loc.countryCode)) return null; + return loc; +}; +const sanitizeSaved = (arr: SavedLocation[]): SavedLocation[] => + arr.filter((l) => !isCountryBanned(l.country, l.countryCode)); + export function LocationProvider({ children }: { children: ReactNode }) { const [location, setLocationState] = useState(() => { const saved = localStorage.getItem(CURRENT_LOCATION_KEY); - return saved ? JSON.parse(saved) : null; + return sanitizeCurrent(saved ? JSON.parse(saved) : null); }); const [savedLocations, setSavedLocations] = useState(() => { const saved = localStorage.getItem(STORAGE_KEY); - return saved ? JSON.parse(saved) : []; + return sanitizeSaved(saved ? JSON.parse(saved) : []); }); const [hasSyncedFromServer, setHasSyncedFromServer] = useState(false); @@ -59,10 +90,11 @@ export function LocationProvider({ children }: { children: ReactNode }) { try { const serverData = await dataSync.getAllFromServer(); if (serverData) { - // Merge saved locations from server + // Merge saved locations from server, dropping any banned-country legacy entries if (serverData.savedLocations && serverData.savedLocations.length > 0) { setSavedLocations(prev => { - const merged = [...serverData.savedLocations]; + const serverClean = sanitizeSaved(serverData.savedLocations); + const merged = [...serverClean]; for (const local of prev) { if (!merged.some((s: SavedLocation) => s.id === local.id)) { merged.push(local); @@ -75,15 +107,17 @@ export function LocationProvider({ children }: { children: ReactNode }) { } // Restore current location if we don't have one - if (serverData.currentLocation && !location) { - setLocationState(serverData.currentLocation); - localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.currentLocation)); + const cleanCurrent = sanitizeCurrent(serverData.currentLocation); + if (cleanCurrent && !location) { + setLocationState(cleanCurrent); + localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(cleanCurrent)); } // Restore custom location if we don't have one - if (serverData.customLocation && !location) { - setLocationState(serverData.customLocation); - localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.customLocation)); + const cleanCustom = sanitizeCurrent(serverData.customLocation); + if (cleanCustom && !location) { + setLocationState(cleanCustom); + localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(cleanCustom)); } } } catch (e) { @@ -120,6 +154,10 @@ export function LocationProvider({ children }: { children: ReactNode }) { }, [savedLocations, hasSyncedFromServer]); const setLocation = (loc: LocationState | null) => { + if (loc && isCountryBanned(loc.country, loc.countryCode)) { + const hit = findBannedCountry(loc.country, loc.countryCode); + throw new BannedCountryError(hit?.name ?? loc.country ?? loc.countryCode ?? 'this country'); + } setLocationState(loc); // Sync to server if (loc) { @@ -127,12 +165,23 @@ export function LocationProvider({ children }: { children: ReactNode }) { } }; - const saveLocation = (name: string, latitude: number, longitude: number) => { + const saveLocation = ( + name: string, + latitude: number, + longitude: number, + meta?: { country?: string; countryCode?: string } + ) => { + if (isCountryBanned(meta?.country, meta?.countryCode)) { + const hit = findBannedCountry(meta?.country, meta?.countryCode); + throw new BannedCountryError(hit?.name ?? meta?.country ?? meta?.countryCode ?? 'this country'); + } const newLocation: SavedLocation = { id: generateUUID(), name, latitude, longitude, + country: meta?.country, + countryCode: meta?.countryCode, }; setSavedLocations((prev) => [...prev, newLocation]); }; @@ -169,11 +218,19 @@ export function useLocation() { return context; } +export interface GeocodeResult { + lat: number; + lng: number; + displayName: string; + country?: string; + countryCode?: string; // ISO 3166-1 alpha-2 (uppercase) +} + // Geocoding helper using OpenStreetMap Nominatim (free, no API key needed) -export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number; displayName: string } | null> { +export async function geocodeAddress(address: string): Promise { try { const response = await fetch( - `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`, + `https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=${encodeURIComponent(address)}&limit=1`, { headers: { 'User-Agent': 'FeeldWebApp/1.0', @@ -184,10 +241,14 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; ln const data = await response.json(); if (data.length > 0) { + const hit = data[0]; + const addr = hit.address || {}; return { - lat: parseFloat(data[0].lat), - lng: parseFloat(data[0].lon), - displayName: data[0].display_name, + lat: parseFloat(hit.lat), + lng: parseFloat(hit.lon), + displayName: hit.display_name, + country: addr.country, + countryCode: addr.country_code ? String(addr.country_code).toUpperCase() : undefined, }; } diff --git a/web/src/hooks/useSentPings.ts b/web/src/hooks/useSentPings.ts index 72a23eb..2774a54 100755 --- a/web/src/hooks/useSentPings.ts +++ b/web/src/hooks/useSentPings.ts @@ -68,7 +68,10 @@ export function useSentPings() { const result = await apolloClient.query({ query: PROFILE_QUERY, variables: { profileId: ping.targetProfileId }, - fetchPolicy: 'network-first', + // Apollo doesn't have 'network-first'; using network-only ensures we + // always pull the latest interactionStatus.theirs from the server so + // declined/liked-back states aren't masked by stale cache. + fetchPolicy: 'network-only', }); const profile = result.data?.profile; diff --git a/web/src/pages/ApiExplorer.tsx b/web/src/pages/ApiExplorer.tsx index fac986e..d301b42 100755 --- a/web/src/pages/ApiExplorer.tsx +++ b/web/src/pages/ApiExplorer.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { apolloClient } from '../api/client'; import { PAST_LIKES_QUERY, PAST_DISLIKES_QUERY } from '../api/operations/experimental'; -import { WHO_LIKES_ME_QUERY, DISCOVER_PROFILES_QUERY, PROFILE_QUERY } from '../api/operations/queries'; +import { DISCOVER_PROFILES_QUERY, PROFILE_QUERY } from '../api/operations/queries'; +import { FILTERED_WHO_LIKES_ME_MUTATION } from '../api/operations/mutations'; const styles = { container: { @@ -208,14 +209,16 @@ export function ApiExplorerPage() { const testDirectProfileLookup = async () => { setLoading('directLookup'); try { - // First, get WhoLikesMe to see the anonymized profiles - const likesResult = await apolloClient.query({ - query: WHO_LIKES_ME_QUERY, - variables: { sortBy: 'LAST_INTERACTION' }, + // First, get FilteredWhoLikesMe to see the anonymized profiles + const likesResult: any = await apolloClient.mutate({ + mutation: FILTERED_WHO_LIKES_ME_MUTATION, + variables: { + input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' }, + }, fetchPolicy: 'no-cache', }); - const anonymizedProfiles = likesResult.data?.interactions?.nodes || []; + const anonymizedProfiles = likesResult.data?.filteredWhoLikesMe?.profiles?.nodes || []; if (anonymizedProfiles.length === 0) { setResults(prev => [...prev, { @@ -314,13 +317,15 @@ export function ApiExplorerPage() { const crossReferenceProfiles = async () => { setLoading('crossReference'); try { - // Fetch WhoLikesMe first - const likesResult = await apolloClient.query({ - query: WHO_LIKES_ME_QUERY, - variables: { sortBy: 'LAST_INTERACTION' }, + // Fetch FilteredWhoLikesMe first + const likesResult: any = await apolloClient.mutate({ + mutation: FILTERED_WHO_LIKES_ME_MUTATION, + variables: { + input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' }, + }, fetchPolicy: 'no-cache', }); - const whoLikesMeProfiles = likesResult.data?.interactions?.nodes || []; + const whoLikesMeProfiles = likesResult.data?.filteredWhoLikesMe?.profiles?.nodes || []; // Fetch discover profiles multiple times (4 batches) const allDiscoverProfiles: any[] = []; @@ -445,13 +450,15 @@ export function ApiExplorerPage() { const maxBatches = 50; // Safety limit try { - // First get WhoLikesMe names for comparison - const likesResult = await apolloClient.query({ - query: WHO_LIKES_ME_QUERY, - variables: { sortBy: 'LAST_INTERACTION' }, + // First get FilteredWhoLikesMe names for comparison + const likesResult: any = await apolloClient.mutate({ + mutation: FILTERED_WHO_LIKES_ME_MUTATION, + variables: { + input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' }, + }, fetchPolicy: 'no-cache', }); - const whoLikesMeProfiles = likesResult.data?.interactions?.nodes || []; + const whoLikesMeProfiles = likesResult.data?.filteredWhoLikesMe?.profiles?.nodes || []; const whoLikesMeNames = whoLikesMeProfiles.map((p: any) => p.imaginaryName); // Keep fetching until we find someone who liked us diff --git a/web/src/pages/Discover.tsx b/web/src/pages/Discover.tsx index e0f950c..31d4b42 100755 --- a/web/src/pages/Discover.tsx +++ b/web/src/pages/Discover.tsx @@ -7,6 +7,7 @@ import { ProfileDetailModal } from '../components/profile/ProfileDetailModal'; import { LoadingPage, LoadingCards } from '../components/ui/Loading'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useLocation } from '../hooks/useLocation'; +import { useFavorites } from '../hooks/useFavorites'; import { apolloClient } from '../api/client'; import { authManager } from '../api/auth'; @@ -291,6 +292,7 @@ export function DiscoverPage() { const initialLoadDone = useRef(false); const savedLikedMeProfiles = useRef>(new Set()); const { location } = useLocation(); + const { isFavorite, toggleFavorite } = useFavorites(); // Set device location on Feeld when Discover page loads (emulates app open GPS) const [updateDeviceLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION); @@ -683,6 +685,8 @@ export function DiscoverPage() { profile={profile} onClick={() => setSelectedProfileId(profile.id)} index={index} + onToggleFavorite={toggleFavorite} + isFavorited={isFavorite(profile.id)} /> ))}
diff --git a/web/src/pages/Likes.tsx b/web/src/pages/Likes.tsx index 6fb41ff..91bd735 100755 --- a/web/src/pages/Likes.tsx +++ b/web/src/pages/Likes.tsx @@ -1,15 +1,25 @@ import { useMutation } from '@apollo/client/react'; import { gql } from '@apollo/client'; -import { WHO_LIKES_ME_QUERY, WHO_PINGS_ME_QUERY, PROFILE_QUERY } from '../api/operations/queries'; -import { DEVICE_LOCATION_UPDATE_MUTATION, UNDO_PROFILE_DISLIKE_MUTATION, PROFILE_ACCEPT_PING_MUTATION, PROFILE_REJECT_PING_MUTATION } from '../api/operations/mutations'; +import { PROFILE_QUERY } from '../api/operations/queries'; +import { + DEVICE_LOCATION_UPDATE_MUTATION, + UNDO_PROFILE_DISLIKE_MUTATION, + PROFILE_ACCEPT_PING_MUTATION, + PROFILE_REJECT_PING_MUTATION, + FILTERED_WHO_LIKES_ME_MUTATION, + FILTERED_WHO_PINGS_ME_MUTATION, +} from '../api/operations/mutations'; import { PAST_LIKES_QUERY, PAST_DISLIKES_QUERY } from '../api/operations/experimental'; import { ProfileCard } from '../components/profile/ProfileCard'; import { ProfileDetailModal } from '../components/profile/ProfileDetailModal'; import { LoadingPage, LoadingCards } from '../components/ui/Loading'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useDislikedProfiles } from '../hooks/useDislikedProfiles'; +import { useFavorites } from '../hooks/useFavorites'; import { apolloClient } from '../api/client'; import { useLocation } from '../hooks/useLocation'; +import { isCountryBanned, findBannedCountry } from '../config/bannedCountries'; +import * as dataSync from '../api/dataSync'; // Same query as Discover's load more - includes alreadyShownProfileIDs support const SCAN_PROFILES_QUERY = gql` @@ -75,12 +85,13 @@ const styles = { gap: '12px', marginBottom: '32px', }, - tab: (isActive: boolean, variant: 'likes' | 'pings' | 'youLiked' | 'disliked') => { + tab: (isActive: boolean, variant: 'likes' | 'pings' | 'youLiked' | 'disliked' | 'saved') => { const gradients: Record = { likes: 'linear-gradient(135deg, #be3144 0%, #c41e3a 100%)', pings: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', youLiked: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', disliked: 'linear-gradient(135deg, #64748b 0%, #475569 100%)', + saved: 'linear-gradient(135deg, #a855f7 0%, #7c3aed 100%)', }; return { display: 'flex', @@ -99,12 +110,13 @@ const styles = { boxShadow: isActive ? '0 4px 20px rgba(0,0,0,0.3)' : 'none', }; }, - tabBadge: (isActive: boolean, variant: 'likes' | 'pings' | 'youLiked' | 'disliked') => { + tabBadge: (isActive: boolean, variant: 'likes' | 'pings' | 'youLiked' | 'disliked' | 'saved') => { const colors: Record = { likes: { bg: 'rgba(190,49,68,0.25)', color: '#f4a5b0' }, pings: { bg: 'rgba(245,158,11,0.25)', color: '#fcd34d' }, youLiked: { bg: 'rgba(34,197,94,0.25)', color: '#86efac' }, disliked: { bg: 'rgba(100,116,139,0.25)', color: '#94a3b8' }, + saved: { bg: 'rgba(168,85,247,0.25)', color: '#d8b4fe' }, }; return { padding: '4px 10px', @@ -132,12 +144,13 @@ const styles = { justifyContent: 'center', padding: '80px 20px', }, - emptyIcon: (variant: 'likes' | 'pings' | 'youLiked' | 'disliked') => { + emptyIcon: (variant: 'likes' | 'pings' | 'youLiked' | 'disliked' | 'saved') => { const backgrounds: Record = { likes: 'linear-gradient(135deg, rgba(190,49,68,0.15) 0%, rgba(190,49,68,0.05) 100%)', pings: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)', youLiked: 'linear-gradient(135deg, rgba(34,197,94,0.15) 0%, rgba(34,197,94,0.05) 100%)', disliked: 'linear-gradient(135deg, rgba(100,116,139,0.15) 0%, rgba(100,116,139,0.05) 100%)', + saved: 'linear-gradient(135deg, rgba(168,85,247,0.15) 0%, rgba(168,85,247,0.05) 100%)', }; return { width: '80px', @@ -318,7 +331,7 @@ async function scanLocationBatches(opts: { return { profiles: allProfiles, matches: matchCount }; } -type Tab = 'likes' | 'pings' | 'youLiked' | 'disliked'; +type Tab = 'likes' | 'pings' | 'youLiked' | 'disliked' | 'saved'; interface WhoLikedYouProfile { id: string; @@ -340,6 +353,21 @@ export function LikesPage() { const [activeTab, setActiveTab] = useState('likes'); const [selectedProfileId, setSelectedProfileId] = useState(null); + // Saved-for-later (favorites) tab data + const { favorites, removeFavorite } = useFavorites(); + + // Flatten favorites to profile objects for the grid (preferring enriched + // profile data when available, falling back to the saved snapshot). + // Declared near the hook so it stays above any early-return paths below + // — Rules of Hooks: same order every render. + const savedProfiles = useMemo( + () => + favorites + .map((f) => f.profile) + .filter((p): p is NonNullable => !!p), + [favorites], + ); + // Scanner state const [selectedLocationId, setSelectedLocationId] = useState(''); const [isScanning, setIsScanning] = useState(false); @@ -420,7 +448,8 @@ export function LikesPage() { fetchDiscoveredProfiles(); }, []); - // Fetch all pages of likes + // Fetch all pages of likes via FilteredWhoLikesMe (mutation, not a query — + // this is what the live app uses; the old WhoLikesMe query path is gone). const fetchAllLikes = useCallback(async () => { setLikesLoading(true); const allNodes: any[] = []; @@ -430,23 +459,25 @@ export function LikesPage() { try { while (hasMore) { - const result = await apolloClient.query({ - query: WHO_LIKES_ME_QUERY, - variables: { sortBy: 'LAST_INTERACTION', cursor }, + const result: any = await apolloClient.mutate({ + mutation: FILTERED_WHO_LIKES_ME_MUTATION, + variables: { + input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' }, + cursor, + }, fetchPolicy: 'network-only', }); - const nodes = result.data?.interactions?.nodes || []; - const pageInfo = result.data?.interactions?.pageInfo; - - console.log('WhoLikesMe pageInfo:', JSON.stringify(pageInfo)); + const profiles = result.data?.filteredWhoLikesMe?.profiles; + const nodes = profiles?.nodes || []; + const pageInfo = profiles?.pageInfo; allNodes.push(...nodes); total = pageInfo?.total || allNodes.length; hasMore = pageInfo?.hasNextPage || false; cursor = pageInfo?.nextPageCursor || null; - console.log(`Fetched ${nodes.length} likes, total so far: ${allNodes.length}/${total}, hasMore: ${hasMore}, cursor: ${cursor}`); + console.log(`Fetched ${nodes.length} likes, total so far: ${allNodes.length}/${total}, hasMore: ${hasMore}`); } setAllLikes(allNodes); @@ -458,7 +489,7 @@ export function LikesPage() { } }, []); - // Fetch all pages of pings + // Fetch all pages of pings via FilteredWhoPingsMe (mutation). const fetchAllPings = useCallback(async () => { setPingsLoading(true); const allNodes: any[] = []; @@ -468,14 +499,18 @@ export function LikesPage() { try { while (hasMore) { - const result = await apolloClient.query({ - query: WHO_PINGS_ME_QUERY, - variables: { sortBy: 'LAST_INTERACTION', cursor }, + const result: any = await apolloClient.mutate({ + mutation: FILTERED_WHO_PINGS_ME_MUTATION, + variables: { + input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' }, + cursor, + }, fetchPolicy: 'network-only', }); - const nodes = result.data?.interactions?.nodes || []; - const pageInfo = result.data?.interactions?.pageInfo; + const profiles = result.data?.filteredWhoPingsMe?.profiles; + const nodes = profiles?.nodes || []; + const pageInfo = profiles?.pageInfo; allNodes.push(...nodes); total = pageInfo?.total || allNodes.length; @@ -573,6 +608,12 @@ export function LikesPage() { return; } + if (isCountryBanned(location.country, location.countryCode)) { + const hit = findBannedCountry(location.country, location.countryCode); + setScanStatus(`Cannot scan: Feeld is not available in ${hit?.name ?? location.country ?? 'this country'}.`); + return; + } + setIsScanning(true); setScanStatus(`Setting location to ${location.name}...`); scanAbortRef.current = new AbortController(); @@ -581,7 +622,13 @@ export function LikesPage() { await updateLocation({ variables: { input: { latitude: location.latitude, longitude: location.longitude } }, }); - setLocation({ latitude: location.latitude, longitude: location.longitude, name: location.name }); + setLocation({ + latitude: location.latitude, + longitude: location.longitude, + name: location.name, + country: location.country, + countryCode: location.countryCode, + }); const alreadyShownProfileIDs: string[] = []; const { profiles, matches } = await scanLocationBatches({ @@ -612,8 +659,14 @@ export function LikesPage() { // "Fuck It" - scan ALL locations (deduplicated, cross-location dedup) const scanAllLocations = useCallback(async () => { - if (savedLocations.length === 0) { - setScanStatus('No saved locations to scan'); + // Strip banned-country entries before iterating — defense in depth on top of the + // useLocation save gate and server-side filter. + const scannable = savedLocations.filter(l => !isCountryBanned(l.country, l.countryCode)); + const skipped = savedLocations.length - scannable.length; + if (scannable.length === 0) { + setScanStatus(savedLocations.length === 0 + ? 'No saved locations to scan' + : 'All saved locations are in countries where Feeld is unavailable.'); return; } @@ -621,20 +674,22 @@ export function LikesPage() { scanAbortRef.current = new AbortController(); const signal = scanAbortRef.current.signal; const scanRadius = 400; // miles - const totalLocations = savedLocations.length; + const totalLocations = scannable.length; let grandTotalProfiles = 0; let grandTotalMatches = 0; setFuckItProgress({ current: 0, total: totalLocations, currentLocation: '', totalProfiles: 0, totalMatches: 0 }); - setScanStatus(`Scanning ${totalLocations} locations...`); + setScanStatus(skipped > 0 + ? `Scanning ${totalLocations} locations (skipped ${skipped} in restricted countries)...` + : `Scanning ${totalLocations} locations...`); // Single shared list across ALL locations — avoids re-fetching already-seen profiles const alreadyShownProfileIDs: string[] = []; try { - for (let locIndex = 0; locIndex < savedLocations.length; locIndex++) { + for (let locIndex = 0; locIndex < scannable.length; locIndex++) { if (signal.aborted) break; - const location = savedLocations[locIndex]; + const location = scannable[locIndex]; setFuckItProgress(prev => ({ ...prev, current: locIndex + 1, @@ -644,7 +699,13 @@ export function LikesPage() { await updateLocation({ variables: { input: { latitude: location.latitude, longitude: location.longitude } }, }); - setLocation({ latitude: location.latitude, longitude: location.longitude, name: location.name }); + setLocation({ + latitude: location.latitude, + longitude: location.longitude, + name: location.name, + country: location.country, + countryCode: location.countryCode, + }); try { const { profiles, matches } = await scanLocationBatches({ @@ -673,7 +734,7 @@ export function LikesPage() { } // Small delay between locations - if (locIndex < savedLocations.length - 1) { + if (locIndex < scannable.length - 1) { await new Promise(resolve => setTimeout(resolve, 500)); } } @@ -1076,34 +1137,58 @@ export function LikesPage() { } }, [dislikeProfile, fetchAllLikes]); - // Fetch past likes from API (You Liked tab) - const fetchPastLikes = useCallback(async (cursor?: string | null) => { + // Fetch past likes (You Liked tab). + // Feeld's pastLikes API returns synthetic (v5) profile IDs that don't resolve + // via ProfileQuery — deliberate anonymization for non-Majestic viewers. + // We sidestep this by using the REAL profile IDs we stored locally at like-time + // (`liked_profiles` in localStorage / backend) and fetching each via ProfileQuery + // directly. This returns full profile data + real photo URLs. + const fetchPastLikes = useCallback(async (_cursor?: string | null) => { setPastLikesLoading(true); try { - const result = await apolloClient.query({ - query: PAST_LIKES_QUERY, - variables: { - input: { sortDirection: 'NEWEST_FIRST' }, - limit: 25, - ...(cursor ? { cursor } : {}), - }, - fetchPolicy: 'network-only', - }); + const localLikes = await dataSync.getLikedProfiles(); - const nodes = result.data?.pastLikes?.nodes || []; - const pageInfo = result.data?.pastLikes?.pageInfo; + const enriched = await Promise.all( + localLikes.map(async (l) => { + try { + const r = await apolloClient.query({ + query: PROFILE_QUERY, + variables: { profileId: l.id }, + fetchPolicy: 'network-only', + }); + const profile = r.data?.profile; + if (profile) { + return { + profile, + isPing: false, + interactionSentAt: new Date(l.likedAt).toISOString(), + }; + } + } catch (e) { + console.warn(`ProfileQuery failed for ${l.id} (${l.name}):`, e); + } + return { + profile: { id: l.id, imaginaryName: l.name, photos: [] }, + isPing: false, + interactionSentAt: new Date(l.likedAt).toISOString(), + _profileFetchFailed: true, + }; + }) + ); - if (cursor) { - setPastLikes(prev => [...prev, ...nodes]); - } else { - setPastLikes(nodes); - } - setPastLikesTotal(pageInfo?.total || 0); - setPastLikesCursor(pageInfo?.nextPageCursor || null); - setPastLikesHasMore(pageInfo?.hasNextPage || false); - console.log(`Fetched ${nodes.length} past likes, total: ${pageInfo?.total}, hasMore: ${pageInfo?.hasNextPage}`); + enriched.sort( + (a, b) => + new Date(b.interactionSentAt).getTime() - + new Date(a.interactionSentAt).getTime() + ); + + setPastLikes(enriched); + setPastLikesTotal(enriched.length); + setPastLikesCursor(null); + setPastLikesHasMore(false); + console.log(`Loaded ${enriched.length} past likes from local IDs`); } catch (e) { - console.error('Failed to fetch past likes:', e); + console.error('Failed to load past likes from local:', e); } finally { setPastLikesLoading(false); } @@ -1221,7 +1306,15 @@ export function LikesPage() { } }, []); - // Fetch past likes/dislikes when tab is selected + // Prefetch past likes + dislikes on mount so tab badges show accurate counts + // before the user clicks into either tab. + useEffect(() => { + fetchPastLikes(); + fetchPastDislikes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Refetch when user switches to the tab (keeps data fresh on revisit) useEffect(() => { if (activeTab === 'youLiked') { fetchPastLikes(); @@ -1230,9 +1323,19 @@ export function LikesPage() { } }, [activeTab, fetchPastLikes, fetchPastDislikes]); + // Scanner-cache index by profile ID — used to enrich pastLikes with real photos + // when Feeld gates them. Must be declared before any early return. + const discoveredById = useMemo(() => { + const map = new Map(); + for (const p of discoveredProfiles) { + if (p?.id) map.set(p.id, p); + } + return map; + }, [discoveredProfiles]); + const loading = likesLoading || pingsLoading; - if (loading && activeTab !== 'youLiked' && activeTab !== 'disliked') return ; + if (loading && activeTab !== 'youLiked' && activeTab !== 'disliked' && activeTab !== 'saved') return ; const likes = enrichedLikes; const pings = allPings; @@ -1240,12 +1343,20 @@ export function LikesPage() { // Count how many likes have been matched with real data const matchedCount = enrichedLikes.filter((p: any) => p._matched).length; - // Map pastLikes nodes to profiles for the grid - const pastLikesProfiles = pastLikes.map((node: any) => ({ - ...node.profile, - _isPing: node.isPing, - _interactionSentAt: node.interactionSentAt, - })); + // Each node.profile already has fresh data from ProfileQuery using the real local ID. + // Scanner cache is used only as a last-resort fallback if the fetch failed. + const pastLikesProfiles = pastLikes.map((node: any) => { + const fresh = node.profile || {}; + const cached = node._profileFetchFailed ? discoveredById.get(fresh.id) : null; + return { + ...(cached || {}), + ...fresh, + photos: fresh.photos?.length ? fresh.photos : (cached?.photos || []), + _isPing: node.isPing, + _interactionSentAt: node.interactionSentAt, + _profileFetchFailed: node._profileFetchFailed, + }; + }); // Map pastDislikes nodes to profiles for the grid const pastDislikesProfiles = pastDislikes.map((node: any) => ({ @@ -1253,7 +1364,12 @@ export function LikesPage() { _interactionSentAt: node.interactionSentAt, })); - const currentProfiles = activeTab === 'likes' ? likes : activeTab === 'pings' ? pings : activeTab === 'disliked' ? pastDislikesProfiles : pastLikesProfiles; + const currentProfiles = + activeTab === 'likes' ? likes : + activeTab === 'pings' ? pings : + activeTab === 'disliked' ? pastDislikesProfiles : + activeTab === 'saved' ? savedProfiles : + pastLikesProfiles; return (
@@ -1354,6 +1470,19 @@ export function LikesPage() { {pastDislikesTotal || pastDislikesProfiles.length} + +
{/* Location Scanner */} @@ -1536,6 +1665,10 @@ export function LikesPage() { + ) : activeTab === 'saved' ? ( + + + ) : ( @@ -1543,7 +1676,10 @@ export function LikesPage() { )}

- {activeTab === 'youLiked' ? 'No likes sent yet' : activeTab === 'disliked' ? 'No passes yet' : `No ${activeTab} yet`} + {activeTab === 'youLiked' ? 'No likes sent yet' + : activeTab === 'disliked' ? 'No passes yet' + : activeTab === 'saved' ? 'Nothing saved yet' + : `No ${activeTab} yet`}

{activeTab === 'likes' @@ -1552,6 +1688,8 @@ export function LikesPage() { ? 'Pings from interested members will show up here' : activeTab === 'disliked' ? "Profiles you've passed on will appear here" + : activeTab === 'saved' + ? 'Tap the star on a Discover card to save profiles for later' : "Profiles you've liked will appear here" }

@@ -1592,6 +1730,35 @@ export function LikesPage() { )} + {/* Reciprocation status for You Liked tab */} + {activeTab === 'youLiked' && (() => { + const theirs = profile.interactionStatus?.theirs; + if (theirs !== 'LIKED' && theirs !== 'DISLIKED') return null; + const isMatch = theirs === 'LIKED'; + return ( +
+ {isMatch ? 'Liked back' : 'Passed'} +
+ ); + })()} + {/* Timestamp badge for You Liked and Passed tabs */} {(activeTab === 'youLiked' || activeTab === 'disliked') && profile._interactionSentAt && (
setSelectedProfileId(null)} + // When acting on a profile from the Saved tab, auto-remove it from + // favorites. The action itself (Like/Ping/Dislike) is already + // persisted to its own queue by the modal's mutation handlers. + onActionTaken={ + activeTab === 'saved' && selectedProfileId + ? () => { removeFavorite(selectedProfileId); } + : undefined + } /> )}
diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 9b6dd97..d5ccebc 100755 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useQuery, useMutation } from '@apollo/client/react'; -import { PROFILE_QUERY } from '../api/operations/queries'; +import { PROFILE_QUERY, SEARCH_SETTINGS_QUERY } from '../api/operations/queries'; import { PROFILE_UPDATE_MUTATION } from '../api/operations/mutations'; import { authManager } from '../api/auth'; import { LoadingPage } from '../components/ui/Loading'; @@ -459,9 +459,20 @@ export function ProfilePage() { fetchPolicy: 'network-only', // Always fetch from network, don't use cache }); + // lookingFor (and ageRange/distanceMax/recentlyOnline) live on the SearchSettings + // query, not on Profile — the live app fetches them separately. + const { data: searchSettingsData, refetch: refetchSearchSettings } = useQuery( + SEARCH_SETTINGS_QUERY, + { + variables: { profileId: authManager.getProfileId() }, + fetchPolicy: 'network-only', + } + ); + const [updateProfile] = useMutation(PROFILE_UPDATE_MUTATION, { refetchQueries: [ - { query: PROFILE_QUERY, variables: { profileId: authManager.getProfileId() } } + { query: PROFILE_QUERY, variables: { profileId: authManager.getProfileId() } }, + { query: SEARCH_SETTINGS_QUERY, variables: { profileId: authManager.getProfileId() } }, ], }); @@ -476,11 +487,12 @@ export function ProfilePage() { const handleStartEdit = () => { const profile = data?.profile; + const settings = searchSettingsData?.profile; if (profile) { setEditForm({ bio: profile.bio || '', desires: profile.desires || [], - lookingFor: profile.lookingFor || [], + lookingFor: settings?.lookingFor || [], connectionGoals: profile.connectionGoals || [], }); setIsEditing(true); @@ -503,6 +515,7 @@ export function ProfilePage() { const input: Record = {}; const profile = data?.profile; + const settings = searchSettingsData?.profile; if (!profile) return; // Only include changed fields @@ -512,7 +525,7 @@ export function ProfilePage() { if (JSON.stringify(editForm.desires) !== JSON.stringify(profile.desires || [])) { input.desires = editForm.desires; } - if (JSON.stringify(editForm.lookingFor) !== JSON.stringify(profile.lookingFor || [])) { + if (JSON.stringify(editForm.lookingFor) !== JSON.stringify(settings?.lookingFor || [])) { input.lookingFor = editForm.lookingFor; } if (JSON.stringify(editForm.connectionGoals) !== JSON.stringify(profile.connectionGoals || [])) { @@ -537,8 +550,9 @@ export function ProfilePage() { setToast({ message: 'Profile updated successfully!', type: 'success' }); setIsEditing(false); // Force network fetch to bypass Apollo cache - const refetchResult = await refetch(); - console.log('Refetch result:', refetchResult.data?.profile?.lookingFor); + await refetch(); + const settingsResult = await refetchSearchSettings(); + console.log('Refetch search settings result:', settingsResult.data?.profile?.lookingFor); } catch (err: any) { console.error('Failed to update profile:', err); const errorMessage = err?.graphQLErrors?.[0]?.message || err?.message || 'Failed to update profile'; @@ -798,8 +812,8 @@ export function ProfilePage() { ) : (
- {(profile.lookingFor && profile.lookingFor.length > 0) ? ( - profile.lookingFor.map((type: string) => ( + {(searchSettingsData?.profile?.lookingFor && searchSettingsData.profile.lookingFor.length > 0) ? ( + searchSettingsData.profile.lookingFor.map((type: string) => ( {type.replace(/_/g, ' ')} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index e25697d..378aad1 100755 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -6,7 +6,8 @@ import { useAuth } from '../hooks/useAuth'; import { authManager } from '../api/auth'; import type { AuthStatus } from '../api/auth'; import { LoadingPage } from '../components/ui/Loading'; -import { useLocation, geocodeAddress } from '../hooks/useLocation'; +import { useLocation, geocodeAddress, BannedCountryError } from '../hooks/useLocation'; +import { isCountryBanned, findBannedCountry } from '../config/bannedCountries'; import { useState, useEffect, useCallback } from 'react'; // Inline styles for guaranteed rendering @@ -600,10 +601,20 @@ export function SettingsPage() { const result = await geocodeAddress(searchQuery); if (result) { + if (isCountryBanned(result.country, result.countryCode)) { + const hit = findBannedCountry(result.country, result.countryCode); + setSearchError( + `Feeld is not available in ${hit?.name ?? result.country ?? 'this country'}. Using a location there will get your account flagged. Pick a different location.` + ); + return; + } + const newLocation = { latitude: result.lat, longitude: result.lng, name: result.displayName, + country: result.country, + countryCode: result.countryCode, }; setLocation(newLocation); @@ -630,10 +641,22 @@ export function SettingsPage() { }; const handleSelectSavedLocation = async (saved: typeof savedLocations[0]) => { + if (isCountryBanned(saved.country, saved.countryCode)) { + const hit = findBannedCountry(saved.country, saved.countryCode); + setLocationStatus({ + type: 'error', + text: `Feeld is not available in ${hit?.name ?? saved.country ?? 'that country'}. Remove this saved location and pick a different one.`, + }); + setTimeout(() => setLocationStatus(null), 5000); + return; + } + const newLocation = { latitude: saved.latitude, longitude: saved.longitude, name: saved.name, + country: saved.country, + countryCode: saved.countryCode, }; setLocation(newLocation); setLocationStatus(null); @@ -659,9 +682,24 @@ export function SettingsPage() { const handleSaveCurrentLocation = () => { if (!location || !saveLocationName.trim()) return; - saveLocation(saveLocationName.trim(), location.latitude, location.longitude); - setSaveLocationName(''); - setShowSaveDialog(false); + try { + saveLocation(saveLocationName.trim(), location.latitude, location.longitude, { + country: location.country, + countryCode: location.countryCode, + }); + setSaveLocationName(''); + setShowSaveDialog(false); + } catch (err) { + if (err instanceof BannedCountryError) { + setLocationStatus({ + type: 'error', + text: `Can't save — Feeld is not available in ${err.bannedCountry}.`, + }); + setTimeout(() => setLocationStatus(null), 5000); + } else { + throw err; + } + } }; // Desiring For options (from API LocalisedDesireCategory) diff --git a/web/src/utils/reverseGeocode.ts b/web/src/utils/reverseGeocode.ts new file mode 100644 index 0000000..6f134b6 --- /dev/null +++ b/web/src/utils/reverseGeocode.ts @@ -0,0 +1,85 @@ +// Reverse-geocode coordinates to a country via OpenStreetMap Nominatim. +// Results cached in localStorage keyed by rounded lat/lng (~1km precision) +// so we don't hammer Nominatim's 1 req/sec rate limit. + +const CACHE_KEY = 'feeld_country_cache_v1'; +const PRECISION = 2; // ~1km — country boundaries are coarse enough + +export interface CountryLookup { + country?: string; + countryCode?: string; // ISO 3166-1 alpha-2, uppercase + /** true if the lookup succeeded (cached or fresh); false if unresolved */ + resolved: boolean; +} + +type CacheShape = Record; + +function readCache(): CacheShape { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeCache(c: CacheShape) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(c)); + } catch { + // localStorage full or unavailable — ignore + } +} + +function cacheKey(lat: number, lng: number) { + return `${lat.toFixed(PRECISION)},${lng.toFixed(PRECISION)}`; +} + +// In-flight dedupe so parallel calls for the same coord share one HTTP request +const inflight = new Map>(); + +export async function reverseGeocode(lat: number, lng: number): Promise { + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + return { resolved: false }; + } + const key = cacheKey(lat, lng); + + const cache = readCache(); + if (cache[key]) { + return { country: cache[key].country, countryCode: cache[key].countryCode, resolved: true }; + } + + const existing = inflight.get(key); + if (existing) return existing; + + const p = (async (): Promise => { + try { + const res = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=3&addressdetails=1`, + { headers: { 'User-Agent': 'FeeldWebApp/1.0' } } + ); + if (!res.ok) return { resolved: false }; + const data = await res.json(); + const addr = data?.address ?? {}; + const country: string | undefined = addr.country; + const countryCode: string | undefined = addr.country_code + ? String(addr.country_code).toUpperCase() + : undefined; + if (!country && !countryCode) return { resolved: false }; + + const next = readCache(); + next[key] = { country, countryCode, t: Date.now() }; + writeCache(next); + return { country, countryCode, resolved: true }; + } catch { + return { resolved: false }; + } finally { + inflight.delete(key); + } + })(); + + inflight.set(key, p); + return p; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 1240c56..f56a334 100755 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -106,6 +106,10 @@ export default defineConfig({ target: 'http://localhost:3001', changeOrigin: true, }, + '/api/favorites': { + target: 'http://localhost:3001', + changeOrigin: true, + }, '/api/disliked-profiles': { target: 'http://localhost:3001', changeOrigin: true,