Block banned-country locations and align GraphQL ops

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 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-06-01 18:30:37 -05:00
parent f84786e654
commit da2bab21e5
21 changed files with 1646 additions and 531 deletions
+9
View File
@@ -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;
+126 -4
View File
@@ -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 });
});
// ============================================================
+2 -1
View File
@@ -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: {
+72
View File
@@ -557,5 +557,77 @@ export const clearAllDislikedProfiles = async (): Promise<void> => {
}
};
// 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<void> => {
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<void> => {
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<Favorite[]> => {
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<void> => {
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;
+60
View File
@@ -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();
};
});
});
+22 -55
View File
@@ -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
+53 -118
View File
@@ -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 {
+467 -242
View File
@@ -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.
+41 -1
View File
@@ -36,6 +36,10 @@ interface ProfileCardProps {
onDislike?: (profile: any) => Promise<any>;
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<void>;
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 (
<div
onClick={onClick}
@@ -192,6 +201,37 @@ export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshi
{/* Top right badges */}
<div style={{ position: 'absolute', top: '10px', right: '10px', zIndex: 10, display: 'flex', gap: '6px' }}>
{onToggleFavorite && (
<button
onClick={handleToggleFavorite}
title={isFavorited ? 'Remove from saved' : 'Save for later'}
className="btn-press"
style={{
width: '26px', height: '26px', borderRadius: '50%',
background: isFavorited
? 'linear-gradient(135deg, #a855f7, #7c3aed)'
: 'rgba(0,0,0,0.5)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', backdropFilter: 'blur(8px)',
boxShadow: isFavorited ? '0 2px 8px rgba(168,85,247,0.4)' : 'none',
transition: 'all 200ms',
}}
>
{/* Bookmark icon — distinct from the Majestic star badge so the
"save for later" action is visually unambiguous. */}
<svg
viewBox="0 0 24 24"
fill={isFavorited ? '#fff' : 'none'}
stroke={isFavorited ? '#fff' : 'rgba(255,255,255,0.9)'}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ width: 13, height: 13 }}
>
<path d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z" />
</svg>
</button>
)}
{profile.isMajestic && (
<div style={{
width: '26px', height: '26px', borderRadius: '50%',
@@ -12,6 +12,10 @@ interface ProfileDetailModalProps {
profileId: string;
onClose: () => 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();
},
});
+105
View File
@@ -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<string>();
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;
}
+181
View File
@@ -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<EnrichedFavorite[]>([]);
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,
};
}
+78 -17
View File
@@ -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<LocationContextType | null>(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<LocationState | null>(() => {
const saved = localStorage.getItem(CURRENT_LOCATION_KEY);
return saved ? JSON.parse(saved) : null;
return sanitizeCurrent(saved ? JSON.parse(saved) : null);
});
const [savedLocations, setSavedLocations] = useState<SavedLocation[]>(() => {
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<GeocodeResult | null> {
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,
};
}
+4 -1
View File
@@ -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;
+23 -16
View File
@@ -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
+4
View File
@@ -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<Set<string>>(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)}
/>
))}
</div>
+238 -63
View File
@@ -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<string, string> = {
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<string, { bg: string; color: string }> = {
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<string, string> = {
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<Tab>('likes');
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(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<typeof p> => !!p),
[favorites],
);
// Scanner state
const [selectedLocationId, setSelectedLocationId] = useState<string>('');
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<string, any>();
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 <LoadingPage message="Loading interactions..." />;
if (loading && activeTab !== 'youLiked' && activeTab !== 'disliked' && activeTab !== 'saved') return <LoadingPage message="Loading interactions..." />;
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 (
<div style={styles.container}>
@@ -1354,6 +1470,19 @@ export function LikesPage() {
{pastDislikesTotal || pastDislikesProfiles.length}
</span>
</button>
<button
onClick={() => setActiveTab('saved')}
style={styles.tab(activeTab === 'saved', 'saved')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: '18px', height: '18px' }}>
<path d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z" />
</svg>
Saved
<span style={styles.tabBadge(activeTab === 'saved', 'saved')}>
{favorites.length}
</span>
</button>
</div>
{/* Location Scanner */}
@@ -1536,6 +1665,10 @@ export function LikesPage() {
<svg viewBox="0 0 24 24" fill="none" stroke="#64748b" strokeWidth="1" style={{ width: '40px', height: '40px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : activeTab === 'saved' ? (
<svg viewBox="0 0 24 24" fill="none" stroke="#a855f7" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ width: '40px', height: '40px' }}>
<path d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="1" style={{ width: '40px', height: '40px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
@@ -1543,7 +1676,10 @@ export function LikesPage() {
)}
</div>
<h2 style={styles.emptyTitle}>
{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`}
</h2>
<p style={styles.emptyText}>
{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"
}
</p>
@@ -1592,6 +1730,35 @@ export function LikesPage() {
</div>
)}
{/* 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 (
<div style={{
position: 'absolute',
bottom: '8px',
left: '8px',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 9px',
borderRadius: '6px',
background: isMatch ? 'rgba(34,197,94,0.85)' : 'rgba(100,116,139,0.85)',
color: '#fff',
fontSize: '11px',
fontWeight: 700,
fontFamily: "'Satoshi', sans-serif",
backdropFilter: 'blur(4px)',
letterSpacing: '0.02em',
}}>
{isMatch ? 'Liked back' : 'Passed'}
</div>
);
})()}
{/* Timestamp badge for You Liked and Passed tabs */}
{(activeTab === 'youLiked' || activeTab === 'disliked') && profile._interactionSentAt && (
<div style={{
@@ -1774,6 +1941,14 @@ export function LikesPage() {
<ProfileDetailModal
profileId={selectedProfileId}
onClose={() => 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
}
/>
)}
</div>
+22 -8
View File
@@ -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<string, any> = {};
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() {
</div>
) : (
<div style={styles.tagContainer}>
{(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) => (
<span key={type} style={styles.tag('interest')}>
{type.replace(/_/g, ' ')}
</span>
+42 -4
View File
@@ -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)
+85
View File
@@ -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<string, { country?: string; countryCode?: string; t: number }>;
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<string, Promise<CountryLookup>>();
export async function reverseGeocode(lat: number, lng: number): Promise<CountryLookup> {
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<CountryLookup> => {
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;
}
+4
View File
@@ -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,