From 80a877301adadd79c7d22f2f9cf939792048326a Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Tue, 24 Feb 2026 14:02:26 +0100 Subject: [PATCH] Add connection preferences feature with API endpoints and UI updates --- backend/api/src/app.ts | 4 + backend/api/src/get-connection-interests.ts | 44 ++++ .../api/src/update-connection-interests.ts | 32 +++ backend/email/emails/functions/mock.ts | 2 - backend/shared/src/profiles/parse-photos.ts | 4 +- backend/supabase/migration.sql | 1 + .../20260224_add_connection_preferences.sql | 30 +++ backend/supabase/profiles.sql | 159 +++++++------- common/messages/de.json | 32 ++- common/messages/fr.json | 32 ++- common/src/api/schema.ts | 27 +++ common/src/api/zod-types.ts | 8 +- common/src/supabase/schema.ts | 126 +++++++---- common/src/user.ts | 2 + .../connection-preferences-settings.tsx | 98 +++++++++ web/components/email-verification-button.tsx | 2 +- .../messaging/send-message-button.tsx | 33 ++- web/components/profile-comment-section.tsx | 2 +- web/components/profile/connect-actions.tsx | 204 ++++++++++++++++++ web/components/profile/profile-header.tsx | 25 ++- web/components/profile/profile-info.tsx | 2 + web/components/required-profile-form.tsx | 12 +- web/components/switch-setting.tsx | 2 +- web/pages/profile.tsx | 2 +- web/pages/settings.tsx | 4 + 25 files changed, 739 insertions(+), 150 deletions(-) create mode 100644 backend/api/src/get-connection-interests.ts create mode 100644 backend/api/src/update-connection-interests.ts create mode 100644 backend/supabase/migrations/20260224_add_connection_preferences.sql create mode 100644 web/components/connection-preferences-settings.tsx create mode 100644 web/components/profile/connect-actions.tsx diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 0f389bb8..14b6d3a9 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -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} = { '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, diff --git a/backend/api/src/get-connection-interests.ts b/backend/api/src/get-connection-interests.ts new file mode 100644 index 00000000..371d4c03 --- /dev/null +++ b/backend/api/src/get-connection-interests.ts @@ -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, + } +} diff --git a/backend/api/src/update-connection-interests.ts b/backend/api/src/update-connection-interests.ts new file mode 100644 index 00000000..4986aaee --- /dev/null +++ b/backend/api/src/update-connection-interests.ts @@ -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} +} diff --git a/backend/email/emails/functions/mock.ts b/backend/email/emails/functions/mock.ts index e2c1f42a..f8e2b430 100644 --- a/backend/email/emails/functions/mock.ts +++ b/backend/email/emails/functions/mock.ts @@ -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', diff --git a/backend/shared/src/profiles/parse-photos.ts b/backend/shared/src/profiles/parse-photos.ts index f9ad4a6d..0e934f68 100644 --- a/backend/shared/src/profiles/parse-photos.ts +++ b/backend/shared/src/profiles/parse-photos.ts @@ -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( diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 823da2e1..53b71c97 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -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; diff --git a/backend/supabase/migrations/20260224_add_connection_preferences.sql b/backend/supabase/migrations/20260224_add_connection_preferences.sql new file mode 100644 index 00000000..4ef51f5e --- /dev/null +++ b/backend/supabase/migrations/20260224_add_connection_preferences.sql @@ -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); diff --git a/backend/supabase/profiles.sql b/backend/supabase/profiles.sql index 39bc6fc5..4b5189b9 100644 --- a/backend/supabase/profiles.sql +++ b/backend/supabase/profiles.sql @@ -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; diff --git a/common/messages/de.json b/common/messages/de.json index 22e6ba05..cab0dc13 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -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." } diff --git a/common/messages/fr.json b/common/messages/fr.json index df087b7e..e20bf143 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -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." } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 060e7fc4..9b98b29c 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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, diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts index 9d8b3973..2b83972a 100644 --- a/common/src/api/zod-types.ts +++ b/common/src/api/zod-types.ts @@ -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(), diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index b50e2043..14527b37 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -4,7 +4,7 @@ export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(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: { diff --git a/common/src/user.ts b/common/src/user.ts index c2285ed0..52559936 100644 --- a/common/src/user.ts +++ b/common/src/user.ts @@ -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 = { diff --git a/web/components/connection-preferences-settings.tsx b/web/components/connection-preferences-settings.tsx new file mode 100644 index 00000000..82784615 --- /dev/null +++ b/web/components/connection-preferences-settings.tsx @@ -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 ( + +
+ {t( + 'settings.connection_preferences.description', + 'Control how others can connect with you.', + )} +
+ + +
+
+ {t('settings.connection_preferences.direct_messaging', 'Direct Messaging')} +
+
+ {t( + 'settings.connection_preferences.dm_description', + 'Let anyone start a conversation with you immediately.', + )} +
+
+ +
+ + +
+
+ {t('settings.connection_preferences.interest_indicator', 'Private interest signals')} +
+
+ {t( + 'settings.connection_preferences.indicator_description', + 'Allow people to privately signal interest. You are only notified if the interest is mutual.', + )} +
+
+ +
+ + ) +} diff --git a/web/components/email-verification-button.tsx b/web/components/email-verification-button.tsx index 5ffd7280..443e53cc 100644 --- a/web/components/email-verification-button.tsx +++ b/web/components/email-verification-button.tsx @@ -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...')) } } diff --git a/web/components/messaging/send-message-button.tsx b/web/components/messaging/send-message-button.tsx index 2f50ebd5..2a4c7100 100644 --- a/web/components/messaging/send-message-button.tsx +++ b/web/components/messaging/send-message-button.tsx @@ -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 ( <> - - {circleButton ? ( - + ) : circleButton ? ( + + + {showHelp && {tips}} +
+ {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 ( + + + + ) + })} +
+ {matches.length > 0 && ( +
+ {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(', '), + }, + )} +
+ )} + + ) : ( +

+ {t( + 'profile.turned_off_interest_indicators', + '{user} turned off interest indicators', + {user: user.name}, + )} +

+ )} + + + + ) +} diff --git a/web/components/profile/profile-header.tsx b/web/components/profile/profile-header.tsx index 03761b89..58455c46 100644 --- a/web/components/profile/profile-header.tsx +++ b/web/components/profile/profile-header.tsx @@ -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 ( {currentUser && !isCurrentUser && isHiddenFromMe && ( @@ -194,7 +212,12 @@ export default function ProfileHeader(props: { /> )} {currentUser && showMessageButton && ( - + )} diff --git a/web/components/profile/profile-info.tsx b/web/components/profile/profile-info.tsx index aa49197c..7f272810 100644 --- a/web/components/profile/profile-info.tsx +++ b/web/components/profile/profile-info.tsx @@ -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} /> + 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(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(() => { diff --git a/web/components/switch-setting.tsx b/web/components/switch-setting.tsx index 49859aeb..de1b7571 100644 --- a/web/components/switch-setting.tsx +++ b/web/components/switch-setting.tsx @@ -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 }) => { diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index ad681c5d..9fa8d481 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -28,7 +28,7 @@ export default function ProfilePage() { if (!user || !profile) { const timer = setTimeout(() => { setShowLoading(true) - }, 3000) + }, 1000) return () => clearTimeout(timer) } else { setShowLoading(false) diff --git a/web/pages/settings.tsx b/web/pages/settings.tsx index c9e7ba49..75642fc3 100644 --- a/web/pages/settings.tsx +++ b/web/pages/settings.tsx @@ -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')} +

{t('settings.connection_preferences.title', 'Connection Preferences')}

+ +

{t('settings.general.account', 'Account')}

{t('settings.general.email', 'Email')}