Add connection preferences feature with API endpoints and UI updates

This commit is contained in:
MartinBraquet
2026-02-24 14:02:26 +01:00
parent 1aae688f3f
commit 80a877301a
25 changed files with 739 additions and 150 deletions

View File

@@ -22,6 +22,7 @@ import {saveSubscriptionMobile} from 'api/save-subscription-mobile'
import {sendSearchNotifications} from 'api/send-search-notifications'
import {localSendTestEmail} from 'api/test'
import {unhideProfile} from 'api/unhide-profile'
import {updateConnectionInterests} from 'api/update-connection-interests'
import {updateOptions} from 'api/update-options'
import {vote} from 'api/vote'
import {API, type APIPath} from 'common/api/schema'
@@ -57,6 +58,7 @@ import {deleteBookmarkedSearch} from './delete-bookmarked-search'
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
import {deleteMe} from './delete-me'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
import {getConnectionInterests} from './get-connection-interests'
import {getCurrentPrivateUser} from './get-current-private-user'
import {getEvents} from './get-events'
import {getLikesAndShips} from './get-likes-and-ships'
@@ -375,6 +377,8 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'update-user-locale': updateUserLocale,
'update-private-user-message-channel': updatePrivateUserMessageChannel,
'update-profile': updateProfile,
'get-connection-interests': getConnectionInterests,
'update-connection-interest': updateConnectionInterests,
'user/by-id/:id': getUser,
'user/by-id/:id/block': blockUser,
'user/by-id/:id/unblock': unblockUser,

View File

@@ -0,0 +1,44 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const getConnectionInterests: APIHandler<'get-connection-interests'> = async (
props,
auth,
) => {
const {targetUserId} = props
const userId = auth.uid
if (!targetUserId) {
throw new Error('Missing target user ID')
}
if (targetUserId === userId) {
throw new Error('Cannot get connection interests for yourself')
}
const pg = createSupabaseDirectClient()
// Get what connection interest I have with them
const _interests = await pg.query(
'SELECT connection_type FROM connection_interests WHERE user_id = $1 AND target_user_id = $2',
[userId, targetUserId],
)
const interests = _interests.map((i: {connection_type: string}) => i.connection_type) ?? []
console.log({_interests, interests})
// Get what connection interest they have with me (filtering out the ones I haven't expressed interest in
// so it's risk-free to express interest in them)
const _targetInterests = await pg.query(
'SELECT connection_type FROM connection_interests WHERE user_id = $1 AND target_user_id = $2',
[targetUserId, userId],
)
const targetInterests =
_targetInterests
?.map((i: {connection_type: string}) => i.connection_type)
?.filter((i: string) => interests.includes(i)) ?? []
return {
interests,
targetInterests,
}
}

View File

@@ -0,0 +1,32 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const updateConnectionInterests: APIHandler<'update-connection-interest'> = async (
props,
auth,
) => {
const {targetUserId, connectionType, seeking} = props
const pg = createSupabaseDirectClient()
if (!connectionType) {
throw new Error('Invalid connection type')
}
if (seeking) {
// Insert or update the interest
await pg.query(
`INSERT INTO connection_interests (user_id, target_user_id, connection_type)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, target_user_id, connection_type) DO NOTHING`,
[auth.uid, targetUserId, connectionType],
)
} else {
// Remove the interest
await pg.query(
'DELETE FROM connection_interests WHERE user_id = $1 AND target_user_id = $2 AND connection_type = $3',
[auth.uid, targetUserId, connectionType],
)
}
return {success: true}
}

View File

@@ -12,7 +12,6 @@ export type PartialProfile = Pick<
| 'last_modification_time'
| 'disabled'
| 'looking_for_matches'
| 'messaging_status'
| 'comments_enabled'
| 'visibility'
> &
@@ -48,7 +47,6 @@ export const jamesProfile: PartialProfile = {
last_modification_time: '2024-05-17T02:11:48.83+00:00',
disabled: false,
looking_for_matches: true,
messaging_status: 'open',
comments_enabled: true,
visibility: 'public',
city: 'San Francisco',

View File

@@ -1,6 +1,6 @@
export const removePinnedUrlFromPhotoUrls = async (parsedBody: {
pinned_url?: string
photo_urls?: string[] | null
pinned_url?: string | null | undefined
photo_urls?: string[] | null | undefined
}) => {
if (parsedBody.photo_urls && parsedBody.pinned_url) {
parsedBody.photo_urls = parsedBody.photo_urls.filter(

View File

@@ -50,4 +50,5 @@ BEGIN;
\i backend/supabase/migrations/20260218_add_notification_templates.sql
\i backend/supabase/migrations/20260222_add_deleted_users.sql
\i backend/supabase/migrations/20260223_add_notification_template_translations.sql
\i backend/supabase/migrations/20260224_add_connection_preferences.sql
COMMIT;

View File

@@ -0,0 +1,30 @@
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS allow_direct_messaging BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS allow_interest_indicating BOOLEAN DEFAULT TRUE;
CREATE TABLE connection_interests
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
target_user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
connection_type TEXT NOT NULL CHECK (connection_type IN ('relationship', 'friendship', 'collaboration')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (user_id != target_user_id),
UNIQUE (user_id, target_user_id, connection_type)
);
-- Row Level Security for connection_interests
ALTER TABLE connection_interests
ENABLE ROW LEVEL SECURITY;
-- Indexes
CREATE INDEX idx_ci_user_target_type
ON connection_interests (user_id, target_user_id, connection_type);
CREATE INDEX idx_ci_target_user_type
ON connection_interests (target_user_id, user_id, connection_type);

View File

@@ -1,90 +1,95 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'profile_visibility') THEN
CREATE TYPE profile_visibility AS ENUM ('public', 'member');
END IF;
END$$;
DO
$$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'profile_visibility') THEN
CREATE TYPE profile_visibility AS ENUM ('public', 'member');
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS profiles (
age INTEGER NULL,
bio JSONB,
bio_length integer null,
born_in_location TEXT,
city TEXT,
city_latitude NUMERIC(9, 6),
city_longitude NUMERIC(9, 6),
comments_enabled BOOLEAN DEFAULT TRUE NOT NULL,
company TEXT,
country TEXT,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
diet TEXT[],
disabled BOOLEAN DEFAULT FALSE NOT NULL,
drinks_per_month INTEGER,
education_level TEXT,
ethnicity TEXT[],
gender TEXT,
geodb_city_id TEXT,
has_kids INTEGER,
height_in_inches float4,
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
image_descriptions jsonb,
is_smoker BOOLEAN,
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
occupation TEXT,
occupation_title TEXT,
photo_urls TEXT[],
pinned_url TEXT,
political_beliefs TEXT[],
political_details TEXT,
pref_age_max INTEGER NULL,
pref_age_min INTEGER NULL,
pref_gender TEXT[],
pref_relation_styles TEXT[],
pref_romantic_styles TEXT[],
raised_in_city TEXT,
raised_in_country TEXT,
raised_in_geodb_city_id TEXT,
raised_in_lat NUMERIC(9, 6),
raised_in_lon NUMERIC(9, 6),
raised_in_radius INTEGER,
raised_in_region_code TEXT,
referred_by_username TEXT,
region_code TEXT,
relationship_status TEXT[],
religion TEXT[],
CREATE TABLE IF NOT EXISTS profiles
(
age INTEGER NULL,
bio JSONB,
bio_length integer null,
born_in_location TEXT,
city TEXT,
city_latitude NUMERIC(9, 6),
city_longitude NUMERIC(9, 6),
comments_enabled BOOLEAN DEFAULT TRUE NOT NULL,
company TEXT,
country TEXT,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
diet TEXT[],
disabled BOOLEAN DEFAULT FALSE NOT NULL,
drinks_per_month INTEGER,
education_level TEXT,
ethnicity TEXT[],
gender TEXT,
geodb_city_id TEXT,
has_kids INTEGER,
height_in_inches float4,
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
image_descriptions jsonb,
is_smoker BOOLEAN,
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
allow_direct_messaging BOOLEAN DEFAULT TRUE NOT NULL,
allow_interest_indicating BOOLEAN DEFAULT TRUE NOT NULL,
occupation TEXT,
occupation_title TEXT,
photo_urls TEXT[],
pinned_url TEXT,
political_beliefs TEXT[],
political_details TEXT,
pref_age_max INTEGER NULL,
pref_age_min INTEGER NULL,
pref_gender TEXT[],
pref_relation_styles TEXT[],
pref_romantic_styles TEXT[],
raised_in_city TEXT,
raised_in_country TEXT,
raised_in_geodb_city_id TEXT,
raised_in_lat NUMERIC(9, 6),
raised_in_lon NUMERIC(9, 6),
raised_in_radius INTEGER,
raised_in_region_code TEXT,
referred_by_username TEXT,
region_code TEXT,
relationship_status TEXT[],
religion TEXT[],
religious_belief_strength INTEGER,
religious_beliefs TEXT,
twitter TEXT,
university TEXT,
user_id TEXT NOT NULL,
visibility profile_visibility DEFAULT 'member'::profile_visibility NOT NULL,
wants_kids_strength INTEGER DEFAULT 0,
website TEXT,
religious_beliefs TEXT,
twitter TEXT,
university TEXT,
user_id TEXT NOT NULL,
visibility profile_visibility DEFAULT 'member'::profile_visibility NOT NULL,
wants_kids_strength INTEGER DEFAULT 0,
website TEXT,
CONSTRAINT profiles_pkey PRIMARY KEY (id)
);
);
ALTER TABLE profiles
ADD CONSTRAINT profiles_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
REFERENCES users (id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE profiles
ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON profiles;
CREATE POLICY "public read" ON profiles
FOR SELECT USING (true);
FOR SELECT USING (true);
DROP POLICY IF EXISTS "self update" ON profiles;
CREATE POLICY "self update" ON profiles
FOR UPDATE
WITH CHECK ((user_id = firebase_uid()));
FOR UPDATE
WITH CHECK ((user_id = firebase_uid()));
-- Indexes
CREATE INDEX IF NOT EXISTS profiles_user_id_idx ON public.profiles USING btree (user_id);
@@ -140,8 +145,9 @@ CREATE INDEX users_name_trgm_idx
-- Functions and Triggers
CREATE
OR REPLACE FUNCTION update_last_modification_time()
RETURNS TRIGGER AS $$
OR REPLACE FUNCTION update_last_modification_time()
RETURNS TRIGGER AS
$$
BEGIN
NEW.last_modification_time = now();
RETURN NEW;
@@ -153,7 +159,7 @@ CREATE TRIGGER trigger_update_last_mod_time
BEFORE UPDATE
ON profiles
FOR EACH ROW
EXECUTE FUNCTION update_last_modification_time();
EXECUTE FUNCTION update_last_modification_time();
-- pg_trgm
create extension if not exists pg_trgm;
@@ -163,10 +169,12 @@ CREATE INDEX profiles_bio_trgm_idx
--- bio_text
ALTER TABLE profiles ADD COLUMN bio_text TEXT;
ALTER TABLE profiles
ADD COLUMN bio_text TEXT;
ALTER TABLE profiles ADD COLUMN bio_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED;
ALTER TABLE profiles
ADD COLUMN bio_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED;
CREATE INDEX profiles_bio_tsv_idx ON profiles USING GIN (bio_tsv);
@@ -176,7 +184,8 @@ ALTER TABLE profiles
-- Rebuild search (search_txt and search_tsv)
CREATE OR REPLACE FUNCTION trg_profiles_rebuild_search()
RETURNS trigger AS $$
RETURNS trigger AS
$$
BEGIN
PERFORM rebuild_profile_search(NEW.id);
RETURN NEW;

View File

@@ -526,6 +526,11 @@
"onboarding.step2.title": "Suchen, nicht scrollen.",
"onboarding.step3.body1": "Matches sind weder magisch noch mysteriös.",
"onboarding.step3.body2": "Ihr Kompatibilitäts-Score kommt von expliziten Fragen:",
"profile.turned_off_interest_indicators": "{user} hat Interessenindikatoren deaktiviert",
"profile.mutual_interests": "Sie haben beide gemeinsame Interessen: {matches}",
"profile.mutual": "Gegenseitig",
"profile.not_both_open_to_type": "Sie sind nicht beide für eine(n) {type} offen",
"profile.wont_be_notified_unless_mutual": "Sie werden nicht benachrichtigt, es sei denn, das Interesse ist gegenseitig.",
"onboarding.step3.body3": "Sie können das System inspizieren, in Frage stellen und verbessern. Die vollständige Mathematik ist Open Source.",
"onboarding.step3.continue": "Loslegen",
"onboarding.step3.footer": "Wenn Sie nicht mit der Funktionsweise einverstanden sind, können Sie helfen, sie zu ändern.",
@@ -1004,6 +1009,7 @@
"security.title": "Sicherheit",
"send_message.button_label": "Nachricht",
"send_message.title": "Kontakt",
"messaging.send_thoughtful_message": "Sende ihnen eine durchdachte Nachricht",
"settings.action.cancel": "Abbrechen",
"settings.action.save": "Speichern",
"settings.danger_zone": "Gefahrenzone",
@@ -1033,6 +1039,8 @@
"settings.email.verification_failed": "Senden der Bestätigungs-E-Mail fehlgeschlagen.",
"settings.email.verification_sent": "Bestätigungs-E-Mail gesendet prüfen Sie Ihren Posteingang und Spam-Ordner.",
"settings.email.verified": "E-Mail bestätigt ✔️",
"settings.email.just_verified": "Ich habe meine E-Mail bestätigt",
"settings.email.not_verified": "E-Mail immer noch nicht bestätigt...",
"settings.general.account": "Konto",
"settings.general.email": "E-Mail",
"settings.general.font": "Schriftart",
@@ -1204,5 +1212,27 @@
"vote.toast.created": "Vorschlag erstellt",
"vote.urgent": "Dringend",
"vote.voted": "Stimme gespeichert",
"vote.with_priority": "mit Priorität"
"vote.with_priority": "mit Priorität",
"settings.connection_preferences.title": "Verbindungseinstellungen",
"settings.connection_preferences.description": "Kontrollieren Sie, wie andere sich mit Ihnen verbinden können.",
"settings.connection_preferences.direct_messaging": "Direktnachrichten",
"settings.connection_preferences.dm_description": "Lassen Sie jeden sofort ein Gespräch mit Ihnen beginnen.",
"settings.connection_preferences.interest_indicator": "Private Interessenssignale",
"settings.connection_preferences.indicator_description": "Erlauben Sie Menschen, privat Interesse zu signalisieren. Sie werden nur benachrichtigt, wenn das Interesse gegenseitig ist.",
"settings.connection_preferences.updated": "Einstellungen aktualisiert",
"settings.connection_preferences.update_failed": "Fehler beim Aktualisieren der Einstellungen",
"profile.connect.load_preferences_failed": "Fehler beim Laden der Einstellungen",
"profile.connect.update_preference_failed": "Fehler beim Aktualisieren der Einstellung",
"profile.connect.title": "Verbinden",
"profile.connect.send_message": "Nachricht senden",
"profile.connect.direct_messaging_disabled": "{user} hat Direktnachrichten deaktiviert",
"profile.header.tooltip.direct_messaging_off": "{name} hat Direktnachrichten deaktiviert",
"profile.header.tooltip.can_express_interest": ", aber Sie können am Ende des Profils immer noch Interesse zeigen",
"profile.connect.private_connection_signal": "Privates Verbindungssignal",
"profile.connect.mutual_notification_description": "Sie werden nicht benachrichtigt, es sei denn, das Interesse ist gegenseitig.",
"profile.connect.how_this_works": "Wie es funktioniert",
"profile.connect.not_both_open": "Sie sind nicht beide für einen {type} offen",
"profile.connect.mutual": "Gegenseitig",
"profile.connect.interest_indicators_disabled": "{user} hat Interessenssignale deaktiviert",
"profile.connect.tips": "- Wählen Sie den Verbindungstyp, für den Sie offen sind.\n- Sie sehen dies nicht, es sei denn, sie wählen denselben Typ wie Sie.\n- Wenn Sie beide denselben Typ wählen, werden Sie beide benachrichtigt."
}

View File

@@ -524,6 +524,11 @@
"onboarding.step2.item4": "Lieu et intention",
"onboarding.step2.title": "Cherchez, sans swipe.",
"onboarding.step3.body1": "Les correspondances ne sont ni magiques ni mystérieuses.",
"profile.turned_off_interest_indicators": "{user} a désactivé les indicateurs d'intérêt",
"profile.mutual_interests": "Vous avez tous les deux des intérêts communs : {matches}. Contactez-les ci-dessus!",
"profile.mutual": "Mutuel",
"profile.not_both_open_to_type": "Vous n'êtes pas tous les deux ouverts à un(e) {type}",
"profile.wont_be_notified_unless_mutual": "Il/elle ne sera pas notifié(e) à moins que l'intérêt soit mutuel.",
"onboarding.step3.body2": "Votre score de compatibilité provient de questions explicites :",
"onboarding.step3.body3": "Vous pouvez inspecter, questionner et améliorer le système. Les maths derrière sont open source.",
"onboarding.step3.continue": "Commencer",
@@ -1003,6 +1008,7 @@
"security.title": "Sécurité",
"send_message.button_label": "Contacter",
"send_message.title": "Contactez",
"messaging.send_thoughtful_message": "Envoyez-leur un message réfléchi",
"settings.action.cancel": "Annuler",
"settings.action.save": "Enregistrer",
"settings.danger_zone": "Zone dangereuse",
@@ -1032,6 +1038,8 @@
"settings.email.verification_failed": "Échec de l'envoi de l'e-mail de vérification.",
"settings.email.verification_sent": "E-mail de vérification envoyé — vérifiez votre boîte de réception et les spams.",
"settings.email.verified": "E-mail vérifié ✔️",
"settings.email.just_verified": "J'ai vérifié mon e-mail",
"settings.email.not_verified": "E-mail toujours non vérifié...",
"settings.general.account": "Compte",
"settings.general.email": "E-mail",
"settings.general.font": "Police",
@@ -1203,5 +1211,27 @@
"vote.toast.created": "Proposition créée",
"vote.urgent": "Urgente",
"vote.voted": "Vote enregistré",
"vote.with_priority": "avec priorité"
"vote.with_priority": "avec priorité",
"settings.connection_preferences.title": "Préférences de connexion",
"settings.connection_preferences.description": "Contrôlez comment les autres peuvent se connecter avec vous.",
"settings.connection_preferences.direct_messaging": "Messagerie directe",
"settings.connection_preferences.dm_description": "Laissez n'importe qui commencer une conversation avec vous immédiatement.",
"settings.connection_preferences.interest_indicator": "Signaux d'intérêt privés",
"settings.connection_preferences.indicator_description": "Autorisez les gens à signaler leur intérêt en privé. Vous n'êtes notifié que si l'intérêt est mutuel.",
"settings.connection_preferences.updated": "Préférences mises à jour",
"settings.connection_preferences.update_failed": "Échec de la mise à jour des préférences",
"profile.connect.load_preferences_failed": "Échec du chargement des préférences",
"profile.connect.update_preference_failed": "Échec de la mise à jour de la préférence",
"profile.connect.title": "Connecter",
"profile.connect.send_message": "Envoyer un message",
"profile.connect.direct_messaging_disabled": "{user} a désactivé la messagerie directe",
"profile.header.tooltip.direct_messaging_off": "{name} a désactivé la messagerie directe",
"profile.header.tooltip.can_express_interest": ", mais vous pouvez toujours exprimer votre intérêt à la fin du profil",
"profile.connect.private_connection_signal": "Signal de connexion privé",
"profile.connect.mutual_notification_description": "Ils ne seront pas notifiés à moins que l'intérêt soit mutuel.",
"profile.connect.how_this_works": "Comment ça fonctionne",
"profile.connect.not_both_open": "Vous n'êtes pas tous les deux ouverts à un {type}",
"profile.connect.mutual": "Mutuel",
"profile.connect.interest_indicators_disabled": "{user} a désactivé les indicateurs d'intérêt",
"profile.connect.tips": "- Vous choisissez le type de connexion auquel vous êtes ouvert.\n- Ils ne verront pas ceci à moins qu'ils ne choisissent le même type que vous.\n- Si vous choisissez tous les deux le même type, vous serez tous les deux notifiés."
}

View File

@@ -235,6 +235,33 @@ export const API = (_apiTypeCheck = {
summary: 'Update profile fields for the authenticated user',
tag: 'Profiles',
},
'get-connection-interests': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({
targetUserId: z.string(),
}),
returns: {} as {
interests: string[]
targetInterests: string[]
},
summary: 'Get connection preferences for a user or another user',
tag: 'Profiles',
},
'update-connection-interest': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
targetUserId: z.string(),
connectionType: z.string(),
seeking: z.boolean(),
}),
returns: {} as {success: boolean},
summary: 'Update connection preference for the authenticated user',
tag: 'Profiles',
},
'update-notif-settings': {
method: 'POST',
authed: true,

View File

@@ -46,14 +46,16 @@ export const baseProfilesSchema = z.object({
age: z.number().min(18).max(100).optional().nullable(),
bio: contentSchema.optional().nullable(),
bio_length: z.number().optional().nullable(),
city: z.string(),
city: z.string().optional().nullable(),
city_latitude: z.number().optional().nullable(),
city_longitude: z.number().optional().nullable(),
country: z.string().optional().nullable(),
gender: genderType,
gender: genderType.optional().nullable(),
geodb_city_id: z.string().optional().nullable(),
languages: z.array(z.string()).optional().nullable(),
looking_for_matches: zBoolean,
looking_for_matches: zBoolean.optional(),
allow_direct_messaging: zBoolean.optional(),
allow_interest_indicating: zBoolean.optional(),
photo_urls: z.array(z.string()).nullable(),
pinned_url: z.string(),
pref_age_max: z.number().min(18).max(100).optional().nullable(),

View File

@@ -4,7 +4,7 @@ export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: '13.0.4'
PostgrestVersion: '13.0.5'
}
public: {
Tables: {
@@ -292,6 +292,45 @@ export type Database = {
},
]
}
connection_interests: {
Row: {
connection_type: string
created_at: string
id: number
target_user_id: string
user_id: string
}
Insert: {
connection_type: string
created_at?: string
id?: never
target_user_id: string
user_id: string
}
Update: {
connection_type?: string
created_at?: string
id?: never
target_user_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: 'connection_interests_target_user_id_fkey'
columns: ['target_user_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
{
foreignKeyName: 'connection_interests_user_id_fkey'
columns: ['user_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
]
}
contact: {
Row: {
content: Json | null
@@ -528,6 +567,38 @@ export type Database = {
},
]
}
notification_template_translations: {
Row: {
created_time: string
locale: string
source_text: string
template_id: string
title: string | null
}
Insert: {
created_time?: string
locale: string
source_text: string
template_id: string
title?: string | null
}
Update: {
created_time?: string
locale?: string
source_text?: string
template_id?: string
title?: string | null
}
Relationships: [
{
foreignKeyName: 'notification_template_translations_template_id_fkey'
columns: ['template_id']
isOneToOne: false
referencedRelation: 'notification_templates'
referencedColumns: ['id']
},
]
}
notification_templates: {
Row: {
created_time: number
@@ -564,37 +635,6 @@ export type Database = {
}
Relationships: []
}
notification_template_translations: {
Row: {
template_id: string
locale: string
title: string | null
source_text: string
created_time: number
}
Insert: {
template_id: string
locale: string
title?: string | null
source_text: string
created_time?: number
}
Update: {
template_id?: string
locale?: string
title?: string | null
source_text?: string
created_time?: number
}
Relationships: [
{
foreignKeyName: 'notification_template_translations_template_id_fkey'
columns: ['template_id']
referencedRelation: 'notification_templates'
referencedColumns: ['id']
},
]
}
private_user_message_channel_members: {
Row: {
channel_id: number
@@ -764,14 +804,17 @@ export type Database = {
Row: {
data: Json
id: string
locale: string | null
}
Insert: {
data: Json
id: string
locale?: string | null
}
Update: {
data?: Json
id?: string
locale?: string | null
}
Relationships: [
{
@@ -1036,6 +1079,8 @@ export type Database = {
profiles: {
Row: {
age: number | null
allow_direct_messaging: boolean | null
allow_interest_indicating: boolean | null
big5_agreeableness: number | null
big5_conscientiousness: number | null
big5_extraversion: number | null
@@ -1069,7 +1114,6 @@ export type Database = {
last_modification_time: string
looking_for_matches: boolean
mbti: string | null
messaging_status: string
occupation: string | null
occupation_title: string | null
photo_urls: string[] | null
@@ -1105,6 +1149,8 @@ export type Database = {
}
Insert: {
age?: number | null
allow_direct_messaging?: boolean | null
allow_interest_indicating?: boolean | null
big5_agreeableness?: number | null
big5_conscientiousness?: number | null
big5_extraversion?: number | null
@@ -1138,7 +1184,6 @@ export type Database = {
last_modification_time?: string
looking_for_matches?: boolean
mbti?: string | null
messaging_status?: string
occupation?: string | null
occupation_title?: string | null
photo_urls?: string[] | null
@@ -1174,6 +1219,8 @@ export type Database = {
}
Update: {
age?: number | null
allow_direct_messaging?: boolean | null
allow_interest_indicating?: boolean | null
big5_agreeableness?: number | null
big5_conscientiousness?: number | null
big5_extraversion?: number | null
@@ -1207,7 +1254,6 @@ export type Database = {
last_modification_time?: string
looking_for_matches?: boolean
mbti?: string | null
messaging_status?: string
occupation?: string | null
occupation_title?: string | null
photo_urls?: string[] | null
@@ -1420,15 +1466,7 @@ export type Database = {
ts?: string
user_id?: string | null
}
Relationships: [
{
foreignKeyName: 'user_events_user_id_fkey'
columns: ['user_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
]
Relationships: []
}
user_notifications: {
Row: {

View File

@@ -10,6 +10,8 @@ export type User = {
link: Socials // Social links
isBannedFromPosting?: boolean
userDeleted?: boolean
allow_direct_messaging?: boolean
allow_interest_indicating?: boolean
}
export type PrivateUser = {

View File

@@ -0,0 +1,98 @@
import {useState} from 'react'
import toast from 'react-hot-toast'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {SwitchSetting} from 'web/components/switch-setting'
import {useProfile} from 'web/hooks/use-profile'
import {updateProfile} from 'web/lib/api'
import {useT} from 'web/lib/locale'
export function ConnectionPreferencesSettings() {
const t = useT()
const profile = useProfile()
const [allowDirectMessaging, setAllowDirectMessaging] = useState(
profile?.allow_direct_messaging !== false,
)
const [allowInterestIndicating, setAllowInterestIndicating] = useState(
profile?.allow_interest_indicating !== false,
)
const [isUpdating, setIsUpdating] = useState(false)
const handleUpdate = async (field: string, value: boolean) => {
setIsUpdating(true)
try {
await updateProfile({[field]: value})
// toast.success(t('settings.connection_preferences.updated', 'Preferences updated'))
} catch (error) {
console.error('Error updating preferences:', error)
toast.error(
t('settings.connection_preferences.update_failed', 'Failed to update preferences'),
)
} finally {
setIsUpdating(false)
}
}
const handleDirectMessagingChange = (checked: boolean) => {
setAllowDirectMessaging(checked)
handleUpdate('allow_direct_messaging', checked)
}
const handleInterestIndicatingChange = (checked: boolean) => {
setAllowInterestIndicating(checked)
handleUpdate('allow_interest_indicating', checked)
}
return (
<Col className="gap-4">
<div className="text-sm text-ink-500">
{t(
'settings.connection_preferences.description',
'Control how others can connect with you.',
)}
</div>
<Row className="items-center justify-between gap-4">
<div className="flex-1">
<div className="font-medium">
{t('settings.connection_preferences.direct_messaging', 'Direct Messaging')}
</div>
<div className="text-ink-500 text-sm">
{t(
'settings.connection_preferences.dm_description',
'Let anyone start a conversation with you immediately.',
)}
</div>
</div>
<SwitchSetting
checked={allowDirectMessaging}
onChange={handleDirectMessagingChange}
disabled={isUpdating}
colorMode={'primary'}
/>
</Row>
<Row className="items-center justify-between gap-4">
<div className="flex-1">
<div className="font-medium">
{t('settings.connection_preferences.interest_indicator', 'Private interest signals')}
</div>
<div className="text-ink-500 text-sm">
{t(
'settings.connection_preferences.indicator_description',
'Allow people to privately signal interest. You are only notified if the interest is mutual.',
)}
</div>
</div>
<SwitchSetting
checked={allowInterestIndicating}
onChange={handleInterestIndicatingChange}
disabled={isUpdating}
colorMode={'primary'}
/>
</Row>
</Col>
)
}

View File

@@ -23,7 +23,7 @@ export function EmailVerificationButton() {
console.log('User email verified')
return true
} else {
toast.error(t('', 'Email still not verified...'))
toast.error(t('settings.email.not_verified', 'Email still not verified...'))
}
}

View File

@@ -26,8 +26,11 @@ export const SendMessageButton = (props: {
currentUser: User | undefined | null
includeLabel?: boolean
circleButton?: boolean
text?: string
tooltipText?: string
disabled?: boolean
}) => {
const {toUser, currentUser, includeLabel, circleButton} = props
const {toUser, currentUser, includeLabel, circleButton, text, tooltipText, disabled} = props
const firebaseUser = useFirebaseUser()
const router = useRouter()
const privateUser = usePrivateUser()
@@ -40,6 +43,7 @@ export const SendMessageButton = (props: {
const [submitting, setSubmitting] = useState(false)
const messageButtonClicked = async () => {
if (disabled) return
if (!currentUser) return firebaseLogin()
const previousDirectMessageChannel = findKey(
memberIdsByChannelId,
@@ -97,11 +101,24 @@ export const SendMessageButton = (props: {
return (
<>
<Tooltip text={t('send_message.button_label', 'Message')} noTap>
{circleButton ? (
<button
className="bg-primary-900 hover:bg-primary-600 h-7 w-7 rounded-full transition-colors"
<Tooltip text={tooltipText || t('send_message.button_label', 'Message')} noTap>
{text ? (
<Button
className={clsx('h-fit gap-1', disabled && 'opacity-50 cursor-not-allowed')}
color={'gray-outline'}
onClick={messageButtonClicked}
disabled={disabled}
>
{t('messaging.send_thoughtful_message', 'Send them a thoughtful message')}
</Button>
) : circleButton ? (
<button
className={clsx(
'h-7 w-7 rounded-full transition-colors',
disabled ? 'bg-gray-400 cursor-not-allowed' : 'bg-primary-900 hover:bg-primary-600',
)}
onClick={messageButtonClicked}
disabled={disabled}
>
<BiEnvelope
className={clsx('m-auto h-5 w-5 text-white drop-shadow', includeLabel && 'mr-2')}
@@ -112,7 +129,11 @@ export const SendMessageButton = (props: {
size={'sm'}
onClick={messageButtonClicked}
color={'none'}
className="bg-canvas-200 hover:bg-canvas-300"
className={clsx(
'bg-canvas-200 hover:bg-canvas-300',
disabled && 'opacity-50 cursor-not-allowed',
)}
disabled={disabled}
>
<BiEnvelope className={clsx('h-5 w-5', includeLabel && 'mr-2')} />{' '}
{includeLabel && <>{t('send_message.button_label', 'Message')}</>}

View File

@@ -31,7 +31,7 @@ export const ProfileCommentSection = (props: {
if (!currentUser && (!profile.comments_enabled || parentComments.length == 0)) return null
return (
<Col className={'mt-4 rounded py-2'}>
<Col className={'rounded'}>
<Row className={'mb-4 justify-between'}>
<Subtitle>{t('profile.comments.section_title', 'Endorsements')}</Subtitle>
{isCurrentUser && !simpleView && (

View File

@@ -0,0 +1,204 @@
import {INVERTED_RELATIONSHIP_CHOICES, RELATIONSHIP_CHOICES} from 'common/choices'
import {Profile} from 'common/profiles/profile'
import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
import ReactMarkdown from 'react-markdown'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {SendMessageButton} from 'web/components/messaging/send-message-button'
import {Tooltip} from 'web/components/widgets/tooltip'
import {useProfile} from 'web/hooks/use-profile'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {User} from 'web/lib/firebase/users'
import {useT} from 'web/lib/locale'
export function ConnectActions(props: {profile: Profile; user: User}) {
const {profile, user} = props
const currentUser = useUser()
const currentProfile = useProfile()
const t = useT()
const isCurrentUser = currentUser?.id === user.id
const soughtConnections = currentProfile?.pref_relation_styles
const targetSoughtConnections = profile.pref_relation_styles
const [interests, setInterests] = useState<string[]>([])
const [targetInterests, setTargetInterests] = useState<string[]>([])
const matches = interests.filter((interest) => targetInterests.includes(interest))
const tips = t(
'profile.connect.tips',
`
- You choose the type of connection you're open to.
- They wont see this unless they choose the same type with you.
- If you both choose the same type, youre both notified.
`,
)
const [showHelp, setShowHelp] = useState<boolean>(false)
const loadPreferences = async () => {
try {
const result = await api('get-connection-interests', {
targetUserId: user.id,
})
console.log('Preferences:', result)
setInterests(result.interests)
setTargetInterests(result.targetInterests)
} catch (e) {
console.error('Error loading preferences:', e)
toast.error(t('profile.connect.load_preferences_failed', 'Failed to load preferences'))
}
}
// Load user preferences
useEffect(() => {
if (!currentUser || isCurrentUser) return
loadPreferences()
}, [currentUser, user.id, isCurrentUser])
const handleInterestChange = async (connectionType: string, checked: boolean) => {
if (!currentUser || isCurrentUser) return
try {
await api('update-connection-interest', {
targetUserId: user.id,
connectionType,
seeking: checked,
})
// Reload preferences to get updated data
setInterests((prev) =>
checked ? [...prev, connectionType] : prev.filter((e) => e !== connectionType),
)
if (checked) await loadPreferences()
} catch (error) {
console.error('Error updating preference:', error)
toast.error(t('profile.connect.update_preference_failed', 'Failed to update preference'))
}
}
if (isCurrentUser || !currentUser) return null
return (
<Col className="bg-canvas-0 w-full gap-6 rounded-xl px-2 shadow-sm">
<div className="border-t border-ink-200">
<h3 className="text-xl font-semibold mb-4">{t('profile.connect.title', 'Connect')}</h3>
{/* Primary Action */}
<div className="mb-6">
{profile.allow_direct_messaging || matches.length > 0 ? (
<SendMessageButton
toUser={user}
currentUser={currentUser}
text={t('profile.connect.send_message', 'Send Message')}
/>
) : (
<p className={'guidance'}>
{t(
'profile.connect.direct_messaging_disabled',
'{user} turned off direct messaging',
{user: user.name},
)}
</p>
)}
</div>
{/* Interest Section */}
<div className="prose prose-neutral dark:prose-invert">
<div className="text-ink-700 font-medium text-lg">
{t('profile.connect.private_connection_signal', 'Private connection signal')}
</div>
{profile.allow_interest_indicating ? (
<>
<Row className={'gap-2 my-4'}>
<div className="text-ink-500">
{t(
'profile.wont_be_notified_unless_mutual',
'They wont be notified unless the interest is mutual.',
)}
</div>
<button className="text-primary-600" onClick={() => setShowHelp(!showHelp)}>
{t('profile.connect.how_this_works', 'How this works')}
</button>
</Row>
{showHelp && <ReactMarkdown>{tips}</ReactMarkdown>}
<div className="flex flex-wrap gap-3">
{Object.entries(RELATIONSHIP_CHOICES).map(([label, type]) => {
const isSelected = interests.includes(type)
const isMutual = interests.includes(type) && targetInterests.includes(type)
const isDisabled =
!(
targetSoughtConnections?.includes(type) ||
(targetSoughtConnections ?? []).length === 0
) || !soughtConnections?.includes(type)
return (
<Tooltip
text={
isDisabled &&
t('profile.not_both_open_to_type', 'You are not both open to a {type}', {
type: t(`profile.relationship.${type}`, label).toLowerCase(),
})
}
>
<button
key={type}
disabled={isDisabled}
onClick={() => handleInterestChange(type, !isSelected)}
className={`
px-4 py-2 rounded-full text-sm font-medium transition
border
${
isMutual
? 'bg-primary-500 text-white border-primary-500'
: isSelected
? 'bg-primary-100 text-primary-800 border-primary-300'
: 'bg-canvas-50 text-ink-700 border-ink-300 hover:border-primary-400'
}
${isDisabled ? 'opacity-40 cursor-not-allowed' : ''}
`}
>
{t(`profile.relationship.${type}`, label)}
{isMutual && `${t('profile.mutual', 'Mutual')}`}
</button>
</Tooltip>
)
})}
</div>
{matches.length > 0 && (
<div className="my-4">
{t(
'profile.mutual_interests',
'You both have mutual interests: {matches}. Contact them above to connect!',
{
matches: matches
.map((m) =>
t(
`profile.relationship.${m}`,
INVERTED_RELATIONSHIP_CHOICES[m],
).toLowerCase(),
)
.join(', '),
},
)}
</div>
)}
</>
) : (
<p className={'guidance'}>
{t(
'profile.turned_off_interest_indicators',
'{user} turned off interest indicators',
{user: user.name},
)}
</p>
)}
</div>
</div>
</Col>
)
}

View File

@@ -61,6 +61,24 @@ export default function ProfileHeader(props: {
currentUser,
})
let tooltipText = undefined
if (!profile.allow_direct_messaging) {
tooltipText = t(
'profile.header.tooltip.direct_messaging_off',
'{name} turned off direct messaging',
{
name: user.name,
},
)
}
if (!profile.allow_direct_messaging && profile.allow_interest_indicating) {
tooltipText =
tooltipText +
t(
'profile.header.tooltip.can_express_interest',
', but you can still express interest at the end of the profile',
)
}
return (
<Col className="w-full">
{currentUser && !isCurrentUser && isHiddenFromMe && (
@@ -194,7 +212,12 @@ export default function ProfileHeader(props: {
/>
)}
{currentUser && showMessageButton && (
<SendMessageButton toUser={user} currentUser={currentUser} />
<SendMessageButton
toUser={user}
currentUser={currentUser}
tooltipText={tooltipText}
disabled={!profile.allow_direct_messaging}
/>
)}
<MoreOptionsUserButton user={user} />
</Row>

View File

@@ -10,6 +10,7 @@ import ProfileHeader from 'web/components/profile/profile-header'
import ProfileAbout from 'web/components/profile-about'
import ProfileCarousel from 'web/components/profile-carousel'
import {ProfileCommentSection} from 'web/components/profile-comment-section'
import {ConnectActions} from 'web/components/profile/connect-actions'
import {Content} from 'web/components/widgets/editor'
import {useGetter} from 'web/hooks/use-getter'
import {useHiddenProfiles} from 'web/hooks/use-hidden-profiles'
@@ -180,6 +181,7 @@ function ProfileContent(props: {
fromProfilePage={fromProfilePage}
profile={profile}
/>
<ConnectActions user={user} profile={profile} />
<ProfileCommentSection
onUser={user}
profile={profile}

View File

@@ -9,7 +9,6 @@ import {Input} from 'web/components/widgets/input'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Title} from 'web/components/widgets/title'
import {useEditableUserInfo} from 'web/hooks/use-editable-user-info'
import {useProfileDraft} from 'web/hooks/use-profile-draft'
import {useT} from 'web/lib/locale'
import {labelClassName} from 'web/pages/signup'
@@ -24,7 +23,6 @@ export const initialRequiredState = {
pref_relation_styles: [],
wants_kids_strength: -1,
looking_for_matches: true,
messaging_status: 'open',
visibility: 'member',
city: '',
pinned_url: '',
@@ -51,20 +49,12 @@ export const RequiredProfileUserForm = (props: {
onSubmit?: () => void
profileCreatedAlready?: boolean
}) => {
const {user, onSubmit, profileCreatedAlready, setProfile, profile, isSubmitting} = props
const {user, onSubmit, profileCreatedAlready, isSubmitting} = props
const {updateDisplayName, userInfo, updateUserState, updateUsername} = useEditableUserInfo(user)
const [step, setStep] = useState<number>(0)
const t = useT()
const {draftLoaded} = useProfileDraft(user.id, profile, setProfile)
useEffect(() => {
if (draftLoaded) {
setStep(1)
}
}, [draftLoaded])
const {name, username, errorUsername, loadingUsername, loadingName, errorName} = userInfo
useEffect(() => {

View File

@@ -7,7 +7,7 @@ import ShortToggle, {ToggleColorMode} from './widgets/short-toggle'
export const SwitchSetting = (props: {
checked: boolean
onChange: (checked: boolean) => void
label: 'Web' | 'Email' | 'Mobile'
label?: 'Web' | 'Email' | 'Mobile'
disabled: boolean
colorMode?: ToggleColorMode
}) => {

View File

@@ -28,7 +28,7 @@ export default function ProfilePage() {
if (!user || !profile) {
const timer = setTimeout(() => {
setShowLoading(true)
}, 3000)
}, 1000)
return () => clearTimeout(timer)
} else {
setShowLoading(false)

View File

@@ -5,6 +5,7 @@ import {useForm} from 'react-hook-form'
import toast from 'react-hot-toast'
import {AboutSettings} from 'web/components/about-settings'
import {Button} from 'web/components/buttons/button'
import {ConnectionPreferencesSettings} from 'web/components/connection-preferences-settings'
import {EmailVerificationButton} from 'web/components/email-verification-button'
import {FontPicker} from 'web/components/font-picker'
import {LanguagePicker} from 'web/components/language/language-picker'
@@ -130,6 +131,9 @@ const LoadedGeneralSettings = (props: {privateUser: PrivateUser}) => {
{t('settings.hidden_profiles.manage', 'Manage hidden profiles')}
</Button>
<h3>{t('settings.connection_preferences.title', 'Connection Preferences')}</h3>
<ConnectionPreferencesSettings />
<h3>{t('settings.general.account', 'Account')}</h3>
<h5>{t('settings.general.email', 'Email')}</h5>