91 Commits
1.3.0 ... 1.4.0

Author SHA1 Message Date
MartinBraquet
204a35d026 Release 1.4 2025-10-18 12:24:13 +02:00
MartinBraquet
fb2841f198 Update compat answer box 2025-10-18 12:23:47 +02:00
MartinBraquet
5de055c977 Improve importance radio contrast 2025-10-18 12:14:35 +02:00
MartinBraquet
084659ea3d Remove debug 2025-10-18 12:14:20 +02:00
MartinBraquet
c1a414afab Make votes sortable 2025-10-18 11:49:54 +02:00
MartinBraquet
a5747034d6 Fix props name 2025-10-18 10:40:35 +02:00
MartinBraquet
fda52fec97 Move proposal up and hide by default 2025-10-18 10:39:50 +02:00
MartinBraquet
e38ec79618 remove quote 2025-10-18 02:50:05 +02:00
MartinBraquet
1ef125db12 Fix md format 2025-10-18 02:46:07 +02:00
MartinBraquet
b580b640bd Remove unused react 2025-10-18 02:39:24 +02:00
MartinBraquet
214bddaca4 Add contact links 2025-10-18 02:36:54 +02:00
MartinBraquet
065d489869 Add contact form 2025-10-18 02:20:31 +02:00
MartinBraquet
46ffefbbb9 Add anonymous option for votes 2025-10-18 00:53:35 +02:00
MartinBraquet
a19db3bca9 Clean 2025-10-18 00:20:32 +02:00
MartinBraquet
2c8d8d9989 Clean 2025-10-18 00:12:53 +02:00
MartinBraquet
d52943e31e Fix 2025-10-17 23:24:08 +02:00
MartinBraquet
3eababb742 Fix 2025-10-17 23:19:20 +02:00
MartinBraquet
8a954d3c20 Add voting / proposal page 2025-10-17 23:15:15 +02:00
MartinBraquet
8516901032 Allow get notified for anyone 2025-10-17 19:04:57 +02:00
MartinBraquet
3f2d246fec Fix short bios not showing when sorting by compatibility 2025-10-17 16:55:44 +02:00
MartinBraquet
58fdaa26ca Move to distance filtering to improve accuracy and speed 2025-10-17 16:43:27 +02:00
MartinBraquet
7dc1a8790d Fix 2025-10-17 14:53:55 +02:00
MartinBraquet
70c9ec1d73 Show full political names 2025-10-17 14:02:04 +02:00
MartinBraquet
2bcbbc96ad Add political options 2025-10-17 13:55:48 +02:00
MartinBraquet
527d36a159 Move want kids closer to connection type 2025-10-17 13:50:46 +02:00
MartinBraquet
2ce21247ee Add romantic style (poly, mono, other) 2025-10-17 13:42:32 +02:00
MartinBraquet
8ea6c406e0 Add webhook to report to discord 2025-10-16 20:59:46 +02:00
MartinBraquet
e22f50ecd3 Show loading indicator 2025-10-16 15:28:29 +02:00
MartinBraquet
20dcd98fdf Allow unauth requests to get-messages-count (used in public stats) 2025-10-16 15:16:21 +02:00
MartinBraquet
bc5708857a Improve onboarding UI 2025-10-16 14:37:17 +02:00
MartinBraquet
b9c045ebfb Do not render sign up button, redirect to home 2025-10-16 14:22:53 +02:00
MartinBraquet
c69bd7018e Use compass loading sign 2025-10-16 14:08:34 +02:00
MartinBraquet
078d149175 Redirect to profiles grid after sign up 2025-10-16 14:08:24 +02:00
MartinBraquet
be9f0bd061 Add 20 core compatibility prompts 2025-10-16 13:48:00 +02:00
MartinBraquet
a4723563f5 Keep blue loading circle for buttons 2025-10-16 13:41:03 +02:00
MartinBraquet
1fdcd24f28 Improve design of loading indicator 2025-10-16 12:42:38 +02:00
MartinBraquet
a43480db92 Increase API_RATE_LIMIT_PER_MIN_UNAUTHED 2025-10-16 01:27:24 +02:00
MartinBraquet
e85a072f1c Add user loaded log 2025-10-16 01:24:05 +02:00
MartinBraquet
bbfa2a4eab Wait longer for user to appear 2025-10-16 01:23:12 +02:00
MartinBraquet
2f2db4ded8 Rollback toast error as it shows randomly 2025-10-16 00:48:38 +02:00
MartinBraquet
7296a0d2cd Remove rate limit for endpoints not prone to scraping 2025-10-16 00:41:21 +02:00
MartinBraquet
08e02b6ac0 Add too many requests toast 2025-10-16 00:28:25 +02:00
MartinBraquet
715811d7fd Commetn 2025-10-16 00:28:10 +02:00
MartinBraquet
c7d6ae6995 Hide log 2025-10-16 00:27:58 +02:00
MartinBraquet
b1d1396944 Fix import 2025-10-15 23:52:46 +02:00
MartinBraquet
25a319710e Fix import 2025-10-15 23:50:27 +02:00
MartinBraquet
796b13dd62 Add toast error for too many requests 2025-10-15 23:47:01 +02:00
MartinBraquet
8197863ac5 Clean auth and rate limiting 2025-10-15 23:37:24 +02:00
MartinBraquet
89bd164d43 Add authed 2025-10-15 23:20:20 +02:00
MartinBraquet
80d7061e5f Pre commit 2025-10-15 22:50:50 +02:00
MartinBraquet
c49bac3a09 Make API calls authed 2025-10-15 22:42:26 +02:00
MartinBraquet
06d53fe801 Redirect if logged out in /notifications 2025-10-15 22:32:58 +02:00
MartinBraquet
15ba529938 Fix "column reference "user_id" is ambiguous" 2025-10-15 19:26:10 +02:00
MartinBraquet
83054d0cd1 Fix link opening in same tab 2025-10-15 17:04:00 +02:00
MartinBraquet
8da486adf2 Optimistically remove starred profile upon deletion 2025-10-15 16:01:11 +02:00
MartinBraquet
32bc3847fa Add option to save / bookmark profiles 2025-10-15 15:45:47 +02:00
MartinBraquet
5d763c18c8 Comment log 2025-10-15 15:45:27 +02:00
MartinBraquet
bd3920cfff Fix pagination for last active sorting 2025-10-14 22:09:20 +02:00
MartinBraquet
06d94332b6 Update keyword search placeholder names 2025-10-14 21:35:37 +02:00
MartinBraquet
50614484d8 Move last above social links 2025-10-14 21:06:08 +02:00
MartinBraquet
c29d3d8c92 Clean 2025-10-14 20:51:06 +02:00
MartinBraquet
26f46af375 Fix unused botBadge 2025-10-14 20:49:36 +02:00
MartinBraquet
32b1491dd0 Fix unused node 2025-10-14 20:48:50 +02:00
MartinBraquet
51b8a6c80a Fix unused open 2025-10-14 20:48:21 +02:00
MartinBraquet
0f63d6d3a0 Remove unused react imports 2025-10-14 20:42:41 +02:00
MartinBraquet
4771b08773 Show and write when the user was last online in their profile 2025-10-14 20:36:58 +02:00
MartinBraquet
9b880101fd Make plot larger on mobile 2025-10-14 19:56:35 +02:00
MartinBraquet
594806d6e8 Improve charts design 2025-10-14 19:37:38 +02:00
MartinBraquet
e9afd4db2f Regen supabase types 2025-10-14 19:11:26 +02:00
MartinBraquet
b23efe4089 Add active members tile and move /charts to /stats 2025-10-14 19:09:56 +02:00
MartinBraquet
e33be41a93 Store last_online_time in user_activity.sql table instead of profiles 2025-10-14 19:09:08 +02:00
MartinBraquet
33b09df872 Reduce chart height 2025-10-14 19:03:30 +02:00
MartinBraquet
e9050d0aa0 Simplify and make grid for organization.tsx 2025-10-14 18:57:59 +02:00
MartinBraquet
baeb2a33fe Simplify and make grid for social 2025-10-14 18:57:52 +02:00
MartinBraquet
4ad89acdc7 Use last_online_time from user_activity instead of profiles 2025-10-14 17:53:29 +02:00
MartinBraquet
7d87af8f5c Add user_activity.sql 2025-10-14 17:52:09 +02:00
MartinBraquet
65c0e84e2a Do not prepend social url if full url is provided 2025-10-14 11:28:48 +02:00
MartinBraquet
7b15d85871 Do not render protocol and subdomain in socials 2025-10-14 11:28:21 +02:00
MartinBraquet
ad8ec0f4fd Add browser dev info 2025-10-14 11:27:39 +02:00
MartinBraquet
2d05d83dd0 Always show relationship questions when connection type includes relationships 2025-10-14 11:02:50 +02:00
MartinBraquet
bd45066b13 Add IDE note 2025-10-13 19:43:06 +02:00
MartinBraquet
8ee4274054 Rename voting members 2025-10-13 19:13:13 +02:00
MartinBraquet
83a7ed4d6b Add stats to organization page 2025-10-13 19:05:41 +02:00
MartinBraquet
07dbd86ac6 Add How fast is Compass growing? to FAQ 2025-10-13 18:49:05 +02:00
MartinBraquet
0e671d2cc0 Add nice stats 2025-10-13 18:42:39 +02:00
MartinBraquet
2d6d3c04ce Add stat box 2025-10-13 18:42:06 +02:00
MartinBraquet
b0148963c7 Remove bookmarked_searches and love_compatibility_answers upon account deletion 2025-10-13 17:35:12 +02:00
MartinBraquet
13356950f3 Wait instead of thread resend emails 2025-10-13 17:27:59 +02:00
MartinBraquet
629bcb30a7 Add health discord webhook and send error message there 2025-10-13 15:13:05 +02:00
MartinBraquet
03721fff1c Do not pass orderBy when processing saved searches 2025-10-13 15:12:24 +02:00
MartinBraquet
2a6911ae3d Move email links to our domain 2025-10-13 13:34:10 +02:00
103 changed files with 2777 additions and 792 deletions

View File

@@ -152,6 +152,16 @@ Note: it's normal if page loading locally is much slower than the deployed versi
Now you can start contributing by making changes and submitting pull requests!
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
- Components tab to see the React component tree and props (you need to install the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
- Console tab for errors and logs
- Network tab to see the requests and responses
- Storage tab to see cookies and local storage
You can also add `console.log()` statements in the code.
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
### Submission

View File

@@ -18,7 +18,7 @@
"verify": "yarn --cwd=../.. verify",
"verify:dir": "npx eslint . --max-warnings 0",
"regen-types": "cd ../supabase && make ENV=prod regen-types",
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev"
},
"engines": {
"node": ">=20.0.0"

View File

@@ -50,9 +50,15 @@ import {leavePrivateUserMessageChannel} from './leave-private-user-message-chann
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {getNotifications} from './get-notifications'
import {updateNotifSettings} from './update-notif-setting'
import {setLastOnlineTime} from './set-last-online-time'
import swaggerUi from "swagger-ui-express"
import * as fs from "fs"
import {sendSearchNotifications} from "api/send-search-notifications";
import {sendDiscordMessage} from "common/discord/core";
import {getMessagesCount} from "api/get-messages-count";
import {createVote} from "api/create-vote";
import {vote} from "api/vote";
import {contact} from "api/contact";
const allowCorsUnrestricted: RequestHandler = cors({})
@@ -153,6 +159,9 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'create-comment': createComment,
'hide-comment': hideComment,
'create-compatibility-question': createCompatibilityQuestion,
'create-vote': createVote,
'vote': vote,
'contact': contact,
'compatible-profiles': getCompatibleProfilesHandler,
'search-location': searchLocation,
'search-near-city': searchNearCity,
@@ -164,6 +173,8 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'get-channel-messages': getChannelMessages,
'get-channel-seen-time': getLastSeenChannelTime,
'set-channel-seen-time': setChannelLastSeenTime,
'get-messages-count': getMessagesCount,
'set-last-online-time': setLastOnlineTime,
}
Object.entries(handlers).forEach(([path, handler]) => {
@@ -206,6 +217,10 @@ app.post(pathWithPrefix("/internal/send-search-notifications"),
return res.status(200).json(result)
} catch (err) {
console.error("Failed to send notifications:", err);
await sendDiscordMessage(
"Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...",
"health"
)
return res.status(500).json({error: "Internal server error"});
}
}

View File

@@ -0,0 +1,41 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {sendDiscordMessage} from "common/discord/core";
import {jsonToMarkdown} from "common/md";
// Stores a contact message into the `contact` table
// Web sends TipTap JSON in `content`; we store it as string in `description`.
// If optional content metadata is provided, we include it; otherwise we fall back to user-centric defaults.
export const contact: APIHandler<'contact'> = async (
{content, userId},
_auth
) => {
const pg = createSupabaseDirectClient()
const {error} = await tryCatch(
insert(pg, 'contact', {
user_id: userId,
content: JSON.stringify(content),
})
)
if (error) throw new APIError(500, 'Failed to submit contact message')
const continuation = async () => {
try {
const md = jsonToMarkdown(content)
const message: string = `**New Contact Message**\n${md}`
await sendDiscordMessage(message, 'contact')
} catch (e) {
console.error('Failed to send discord contact', e)
}
}
return {
success: true,
result: {},
continue: continuation,
}
}

View File

@@ -8,30 +8,7 @@ import { updateUser } from 'shared/supabase/users'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
import {sendDiscordMessage} from "common/discord/core";
function extractTextFromBio(bio: any): string {
try {
const texts: string[] = []
const visit = (node: any) => {
if (!node) return
if (Array.isArray(node)) {
for (const item of node) visit(item)
return
}
if (typeof node === 'object') {
for (const [k, v] of Object.entries(node)) {
if (k === 'text' && typeof v === 'string') texts.push(v)
else visit(v as any)
}
}
}
visit(bio)
// Remove extra whitespace and join
return texts.map((t) => t.trim()).filter(Boolean).join(' ')
} catch {
return ''
}
}
import {jsonToMarkdown} from "common/md";
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -75,8 +52,8 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
try {
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
if (body.bio) {
const bioText = extractTextFromBio(body.bio)
if (bioText) message += `\n > ${bioText}`
const bioText = jsonToMarkdown(body.bio)
if (bioText) message += `\n${bioText}`
}
await sendDiscordMessage(message, 'members')
} catch (e) {

View File

@@ -0,0 +1,27 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { getUser } from 'shared/utils'
import { APIHandler, APIError } from './helpers/endpoint'
import { insert } from 'shared/supabase/utils'
import { tryCatch } from 'common/util/try-catch'
export const createVote: APIHandler<
'create-vote'
> = async ({ title, description, isAnonymous }, auth) => {
const creator = await getUser(auth.uid)
if (!creator) throw new APIError(401, 'Your account was not found')
const pg = createSupabaseDirectClient()
const { data, error } = await tryCatch(
insert(pg, 'votes', {
creator_id: creator.id,
title,
description,
is_anonymous: isAnonymous,
})
)
if (error) throw new APIError(401, 'Error creating question')
return { data }
}

View File

@@ -26,6 +26,8 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
await pg.none('DELETE FROM users WHERE id = $1', [userId])
await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
await pg.none('DELETE FROM love_compatibility_answers WHERE creator_id = $1', [userId])
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
// Delete user files from Firebase Storage

View File

@@ -16,7 +16,7 @@ export const getCompatibilityQuestions: APIHandler<
> = async (_props, _auth) => {
const pg = createSupabaseDirectClient()
const dbQuestions = await pg.manyOrNone<
const questions = await pg.manyOrNone<
Row<'love_questions'> & { answer_count: number; score: number }
>(
`SELECT
@@ -31,13 +31,13 @@ export const getCompatibilityQuestions: APIHandler<
love_questions.answer_type = 'compatibility_multiple_choice'
GROUP BY
love_questions.id
ORDER BY
score DESC
ORDER BY
love_questions.importance_score
`,
[]
)
const questions = shuffle(dbQuestions)
// const questions = shuffle(dbQuestions)
// console.debug(
// 'got questions',

View File

@@ -0,0 +1,18 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from "shared/supabase/init";
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, auth) => {
const pg = createSupabaseDirectClient()
const result = await pg.one(
`
SELECT COUNT(*) AS count
FROM private_user_messages;
`,
[]
);
const count = Number(result.count);
console.debug('private_user_messages count:', count);
return {
count: count,
}
}

View File

@@ -1,7 +1,7 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {convertRow} from 'shared/love/supabase'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {getCompatibleProfiles} from 'api/compatible-profiles'
import {intersection} from 'lodash'
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
@@ -16,17 +16,23 @@ export type profileQueryType = {
pref_age_min?: number | undefined,
pref_age_max?: number | undefined,
pref_relation_styles?: String[] | undefined,
pref_romantic_styles?: String[] | undefined,
wants_kids_strength?: number | undefined,
has_kids?: number | undefined,
is_smoker?: boolean | undefined,
shortBio?: boolean | undefined,
geodbCityIds?: String[] | undefined,
lat?: number | undefined,
lon?: number | undefined,
radius?: number | undefined,
compatibleWithUserId?: string | undefined,
skipId?: string | undefined,
orderBy?: string | undefined,
lastModificationWithin?: string | undefined,
}
const userActivityColumns = ['last_online_time']
export const loadProfiles = async (props: profileQueryType) => {
const pg = createSupabaseDirectClient()
@@ -40,17 +46,23 @@ export const loadProfiles = async (props: profileQueryType) => {
pref_age_min,
pref_age_max,
pref_relation_styles,
pref_romantic_styles,
wants_kids_strength,
has_kids,
is_smoker,
shortBio,
geodbCityIds,
lat,
lon,
radius,
compatibleWithUserId,
orderBy: orderByParam = 'created_time',
lastModificationWithin,
skipId,
} = props
const filterLocation = lat && lon && radius
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
// console.debug('keywords:', keywords)
@@ -71,6 +83,8 @@ export const loadProfiles = async (props: profileQueryType) => {
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
(!pref_relation_styles ||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
(!pref_romantic_styles ||
intersection(pref_romantic_styles, l.pref_romantic_styles).length) &&
(!wants_kids_strength ||
wants_kids_strength == -1 ||
(wants_kids_strength >= 2
@@ -84,7 +98,13 @@ export const loadProfiles = async (props: profileQueryType) => {
(l.id.toString() != skipId) &&
(!geodbCityIds ||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
((l.bio_length ?? 0) >= MIN_BIO_LENGTH)
(!filterLocation ||(
l.city_latitude && l.city_longitude &&
Math.abs(l.city_latitude - lat) < radius / 69.0 &&
Math.abs(l.city_longitude - lon) < radius / (69.0 * Math.cos(lat * Math.PI / 180)) &&
Math.pow(l.city_latitude - lat, 2) + Math.pow((l.city_longitude - lon) * Math.cos(lat * Math.PI / 180), 2) < Math.pow(radius / 69.0, 2)
)) &&
(shortBio || (l.bio_length ?? 0) >= MIN_BIO_LENGTH)
)
const cursor = after
@@ -97,10 +117,14 @@ export const loadProfiles = async (props: profileQueryType) => {
return profiles
}
const tablePrefix = userActivityColumns.includes(orderByParam) ? 'user_activity' : 'profiles'
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
const query = renderSql(
select('profiles.*, name, username, users.data as user'),
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
from('profiles'),
join('users on users.id = profiles.user_id'),
leftJoin(userActivityJoin),
where('looking_for_matches = true'),
// where(`pinned_url is not null and pinned_url != ''`),
where(
@@ -127,7 +151,13 @@ export const loadProfiles = async (props: profileQueryType) => {
pref_relation_styles?.length &&
where(
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
{ pref_relation_styles }
{pref_relation_styles}
),
pref_romantic_styles?.length &&
where(
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
{pref_romantic_styles}
),
!!wants_kids_strength &&
@@ -147,12 +177,29 @@ export const loadProfiles = async (props: profileQueryType) => {
geodbCityIds?.length &&
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
skipId && where(`user_id != $(skipId)`, {skipId}),
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
filterLocation && where(`
city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0)
AND $(target_lat) + ($(radius) / 69.0)
AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
AND SQRT(
POWER(city_latitude - $(target_lat), 2)
+ POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
) <= $(radius) / 69.0
`, {target_lat: lat, target_lon: lon, radius}),
orderBy(`${orderByParam} desc`),
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
orderBy(`${tablePrefix}.${orderByParam} DESC`),
after &&
where(
`profiles.${orderByParam} < (select profiles.${orderByParam} from profiles where id = $(after))`,
`${tablePrefix}.${orderByParam} < (
SELECT ${tablePrefix}.${orderByParam}
FROM profiles
LEFT JOIN ${userActivityJoin}
WHERE profiles.id = $(after)
)`,
{after}
),

View File

@@ -174,11 +174,63 @@ export type APIHandler<N extends APIPath> = (
req: Request
) => Promise<APIResponseOptionalContinue<N>>
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
// Not suitable for multi-instance deployments without a shared store, but provides basic protection.
// Limits are configurable via env:
// API_RATE_LIMIT_PER_MIN_AUTHED
// API_RATE_LIMIT_PER_MIN_UNAUTHED
// Endpoints can be exempted by adding their name to RATE_LIMIT_EXEMPT (comma-separated)
const __rateLimitState: Map<string, { windowStart: number; count: number }> = new Map()
function getRateLimitConfig() {
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 120)
const unAuthed = Number(process.env.API_RATE_LIMIT_PER_MIN_UNAUTHED ?? 120)
return {authedLimit: authed, unAuthLimit: unAuthed}
}
function rateLimitKey(name: string, req: Request, auth?: AuthedUser) {
if (auth) return `uid:${auth.uid}`
// fallback to IP for unauthenticated requests
return `ip:${req.ip}`
}
function checkRateLimit(name: string, req: Request, res: Response, auth?: AuthedUser) {
const {authedLimit, unAuthLimit} = getRateLimitConfig()
const key = rateLimitKey(name, req, auth)
const limit = auth ? authedLimit : unAuthLimit
const now = Date.now()
const windowMs = 60_000
const windowStart = Math.floor(now / windowMs) * windowMs
let state = __rateLimitState.get(key)
if (!state || state.windowStart !== windowStart) {
state = {windowStart, count: 0}
__rateLimitState.set(key, state)
}
state.count += 1
const remaining = Math.max(0, limit - state.count)
const reset = Math.ceil((state.windowStart + windowMs - now) / 1000)
// Set standard-ish rate limit headers
res.setHeader('X-RateLimit-Limit', String(limit))
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, remaining)))
res.setHeader('X-RateLimit-Reset', String(reset))
// console.log(`Rate limit check for ${key} on ${name}: ${state.count}/${limit} (remaining: ${remaining}, resets in ${reset}s)`)
if (state.count > limit) {
res.setHeader('Retry-After', String(reset))
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
}
}
export const typedEndpoint = <N extends APIPath>(
name: N,
handler: APIHandler<N>
) => {
const {props: propSchema, authed: authRequired, method} = API[name]
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
@@ -188,6 +240,15 @@ export const typedEndpoint = <N extends APIPath>(
if (authRequired) return next(e)
}
// Apply rate limiting before invoking the handler
if (rateLimited) {
try {
checkRateLimit(String(name), req, res, authUser)
} catch (e) {
return next(e)
}
}
const props = {
...(method === 'GET' ? req.query : req.body),
...req.params,

View File

@@ -1,7 +1,10 @@
import { APIError, APIHandler } from './helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {tryCatch} from 'common/util/try-catch'
import {insert} from 'shared/supabase/utils'
import {sendDiscordMessage} from "common/discord/core";
import {Row} from "common/supabase/utils";
import {DOMAIN} from "common/envs/constants";
// abusable: people can report the wrong person, that didn't write the comment
// but in practice we check it manually and nothing bad happens to them automatically
@@ -33,5 +36,38 @@ export const report: APIHandler<'report'> = async (body, auth) => {
throw new APIError(500, 'Failed to create report: ' + result.error.message)
}
return { success: true }
const continuation = async () => {
try {
const {data: reporter, error} = await tryCatch(
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
)
if (error) {
console.error('Failed to get user for report', error)
return
}
const {data: reported, error: userError} = await tryCatch(
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId])
)
if (userError) {
console.error('Failed to get reported user for report', userError)
return
}
let message: string = `
🚨 **New Report** 🚨
**Type:** ${contentType}
**Content ID:** ${contentId}
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://www.${DOMAIN}/${reporter?.username}))
**Reported:** ${reported?.name} ([@${reported?.username}](https://www.${DOMAIN}/${reported?.username}))
`
await sendDiscordMessage(message, 'reports')
} catch (e) {
console.error('Failed to send discord reports', e)
}
}
return {
success: true,
result: {},
continue: continuation,
}
}

View File

@@ -53,8 +53,9 @@ export const sendSearchNotifications = async () => {
for (const row of searches) {
if (typeof row.search_filters !== 'object') continue;
const { orderBy, ...filters } = (row.search_filters ?? {}) as Record<string, any>
const props = {
...row.search_filters,
...filters,
skipId: row.creator_id,
lastModificationWithin: '24 hours',
shortBio: true,

View File

@@ -0,0 +1,22 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
_,
auth
) => {
if (!auth || !auth.uid) return
const pg = createSupabaseDirectClient()
await pg.none(`
INSERT INTO user_activity (user_id, last_online_time)
VALUES ($1, now())
ON CONFLICT (user_id)
DO UPDATE
SET last_online_time = EXCLUDED.last_online_time
WHERE user_activity.last_online_time < now() - interval '1 minute';
`,
[auth.uid]
)
// console.log('setLastOnline')
}

View File

@@ -24,8 +24,7 @@ export const updateProfile: APIHandler<'update-profile'> = async (
throw new APIError(404, 'Profile not found')
}
!parsedBody.last_online_time &&
log('Updating profile', { userId: auth.uid, parsedBody })
log('Updating profile', { userId: auth.uid, parsedBody })
await removePinnedUrlFromPhotoUrls(parsedBody)
if (parsedBody.avatar_url) {

39
backend/api/src/vote.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { getUser } from 'shared/utils'
import { APIHandler, APIError } from './helpers/endpoint'
export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => {
const user = await getUser(auth.uid)
if (!user) throw new APIError(401, 'Your account was not found')
const pg = createSupabaseDirectClient()
// Map string choice to smallint (-1, 0, 1)
const choiceMap: Record<string, number> = {
'for': 1,
'abstain': 0,
'against': -1,
}
const choiceVal = choiceMap[choice]
if (choiceVal === undefined) {
throw new APIError(400, 'Invalid choice')
}
// Upsert the vote result to ensure one vote per user per vote
// Assuming table vote_results with unique (user_id, vote_id)
const query = `
insert into vote_results (user_id, vote_id, choice, priority)
values ($1, $2, $3, $4)
on conflict (user_id, vote_id)
do update set choice = excluded.choice,
priority = excluded.priority
returning *;
`
try {
const result = await pg.one(query, [user.id, voteId, choiceVal, priority])
return { data: result }
} catch (e) {
throw new APIError(500, 'Error recording vote', e as any)
}
}

View File

@@ -10,7 +10,7 @@ import {MatchesType} from "common/love/bookmarked_searches";
import NewSearchAlertsEmail from "email/new-search_alerts";
import WelcomeEmail from "email/welcome";
const from = 'Compass <no-reply@compassmeet.com>'
const from = 'Compass <compass@compassmeet.com>'
// export const sendNewMatchEmail = async (
// privateUser: PrivateUser,

View File

@@ -31,14 +31,14 @@ export const sinclairProfile: ProfileRow = {
id: 55,
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
created_time: '2023-10-27T00:41:59.851776+00:00',
last_online_time: '2024-05-17T02:11:48.83+00:00',
last_modification_time: '2024-05-17T02:11:48.83+00:00',
city: 'San Francisco',
gender: 'trans-female',
pref_gender: ['female', 'trans-female'],
pref_age_min: 18,
pref_age_max: 21,
pref_relation_styles: ['poly', 'open', 'mono'],
pref_relation_styles: ['friendship'],
pref_romantic_styles: ['poly', 'open', 'mono'],
wants_kids_strength: 3,
looking_for_matches: true,
visibility: 'public',
@@ -132,14 +132,14 @@ export const jamesProfile: ProfileRow = {
id: 2,
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
created_time: '2023-10-21T21:18:26.691211+00:00',
last_online_time: '2024-07-06T17:29:16.833+00:00',
last_modification_time: '2024-05-17T02:11:48.83+00:00',
city: 'San Francisco',
gender: 'male',
pref_gender: ['female'],
pref_age_min: 22,
pref_age_max: 32,
pref_relation_styles: ['mono'],
pref_relation_styles: ['friendship'],
pref_romantic_styles: ['poly', 'open', 'mono'],
wants_kids_strength: 4,
looking_for_matches: true,
visibility: 'public',

View File

@@ -4,10 +4,8 @@ import {
type CreateEmailOptions,
} from 'resend'
import { log } from 'shared/utils'
import {sleep} from "common/util/time";
import pLimit from 'p-limit'
const limit = pLimit(1) // 1 concurrent per second
/*
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
@@ -19,13 +17,10 @@ export const sendEmail = async (
const resend = getResend()
console.debug(resend, payload, options)
async function sendEmailThrottle(data: any, options: any) {
if (!resend) return { data: null, error: 'No Resend client' }
return limit(() => resend.emails.send(data, options))
}
if (!resend) return null
const { data, error } = await sendEmailThrottle(
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
const { data, error } = await resend.emails.send(
{ replyTo: 'Compass <hello@compassmeet.com>', ...payload },
options
)
console.debug('resend.emails.send', data, error)
@@ -39,6 +34,9 @@ export const sendEmail = async (
}
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
await sleep(1000) // to avoid rate limits (2 / second in resend free plan)
return data
}

View File

@@ -1,5 +1,4 @@
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
import {discordLink, githubRepo, patreonLink, paypalLink} from "common/constants";
import {DOMAIN} from "common/envs/constants";
interface Props {
@@ -15,40 +14,40 @@ export const Footer = ({
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
<Row>
<Column align="center">
<Link href={githubRepo} target="_blank">
<Link href={`https://${DOMAIN}/github`} target="_blank">
<Img
src={`https://${DOMAIN}/images/github-logo.png`}
width="24"
height="24"
alt="GitHub"
style={{ display: "inline-block", margin: "0 4px" }}
style={{display: "inline-block", margin: "0 4px"}}
/>
</Link>
<Link href={discordLink} target="_blank">
<Link href={`https://${DOMAIN}/discord`} target="_blank">
<Img
src={`https://${DOMAIN}/images/discord-logo.png`}
width="24"
height="24"
alt="Discord"
style={{ display: "inline-block", margin: "0 4px" }}
style={{display: "inline-block", margin: "0 4px"}}
/>
</Link>
<Link href={patreonLink} target="_blank">
<Link href={`https://${DOMAIN}/patreon`} target="_blank">
<Img
src={`https://${DOMAIN}/images/patreon-logo.png`}
width="24"
height="24"
alt="Patreon"
style={{ display: "inline-block", margin: "0 4px" }}
style={{display: "inline-block", margin: "0 4px"}}
/>
</Link>
<Link href={paypalLink} target="_blank">
<Link href={`https://${DOMAIN}/paypal`} target="_blank">
<Img
src={`https://${DOMAIN}/images/paypal-logo.png`}
width="24"
height="24"
alt="PayPal"
style={{ display: "inline-block", margin: "0 4px" }}
style={{display: "inline-block", margin: "0 4px"}}
/>
</Link>
</Column>

View File

@@ -0,0 +1,14 @@
create table if not exists
contact (
id text default uuid_generate_v4 () not null,
created_time timestamp with time zone default now(),
user_id text,
content jsonb
);
-- Foreign Keys
alter table contact
add constraint contact_user_id_fkey foreign key (user_id) references users (id);
-- Row Level Security
alter table contact enable row level security;

View File

@@ -28,7 +28,6 @@ CREATE TABLE IF NOT EXISTS profiles (
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
is_smoker BOOLEAN,
is_vegetarian_or_vegan BOOLEAN,
last_online_time TIMESTAMPTZ DEFAULT now() NOT NULL,
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
@@ -41,6 +40,7 @@ CREATE TABLE IF NOT EXISTS profiles (
pref_age_min INTEGER NULL,
pref_gender TEXT[] NOT NULL,
pref_relation_styles TEXT[] NOT NULL,
pref_romantic_styles TEXT[],
referred_by_username TEXT,
region_code TEXT,
religious_belief_strength INTEGER,
@@ -59,10 +59,8 @@ 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;
@@ -83,23 +81,26 @@ CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
ON profiles (bio_length);
-- Fastest general-purpose index
DROP INDEX IF EXISTS profiles_lat_lon_idx;
CREATE INDEX profiles_lat_lon_idx ON profiles (city_latitude, city_longitude);
-- Optional additional index for large tables / clustered inserts
DROP INDEX IF EXISTS profiles_lat_lon_brin_idx;
CREATE INDEX profiles_lat_lon_brin_idx ON profiles USING BRIN (city_latitude, city_longitude) WITH (pages_per_range = 32);
-- Functions and Triggers
CREATE
OR REPLACE FUNCTION update_last_modification_time()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.last_online_time IS DISTINCT FROM OLD.last_online_time AND row(NEW.*) = row(OLD.*) THEN
-- Only last_online_time changed, do nothing
RETURN NEW;
END IF;
-- Some other column changed
NEW.last_modification_time = now();
RETURN NEW;
NEW.last_modification_time = now();
RETURN NEW;
END;
$$
LANGUAGE plpgsql;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_last_mod_time
BEFORE UPDATE
@@ -141,5 +142,3 @@ $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_bio_text
BEFORE INSERT OR UPDATE OF bio ON profiles
FOR EACH ROW EXECUTE FUNCTION update_bio_text();

View File

@@ -0,0 +1,16 @@
CREATE TABLE user_activity
(
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
last_online_time TIMESTAMPTZ NOT NULL
);
-- Row Level Security
ALTER TABLE user_activity ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON user_activity;
CREATE POLICY "public read" ON user_activity
FOR SELECT USING (true);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_user_activity_last_online_time
ON user_activity (last_online_time DESC);

View File

@@ -0,0 +1,94 @@
CREATE TABLE IF NOT EXISTS vote_results (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
user_id TEXT NOT NULL,
vote_id BIGINT NOT NULL,
choice smallint NOT NULL CHECK (choice IN (-1, 0, 1)),
priority smallint NOT NULL CHECK (priority IN (0, 1, 2, 3)),
UNIQUE (user_id, vote_id) -- ensures one vote per user
);
-- Foreign Keys
alter table vote_results
add constraint vote_results_user_id_fkey foreign key (user_id) references users (id);
alter table vote_results
add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes (id);
-- Row Level Security
ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON vote_results;
CREATE POLICY "public read" ON vote_results
FOR ALL USING (true);
-- Indexes
DROP INDEX IF EXISTS user_id_idx;
CREATE INDEX user_id_idx ON vote_results (user_id);
DROP INDEX IF EXISTS vote_id_idx;
CREATE INDEX vote_id_idx ON vote_results (vote_id);
DROP INDEX IF EXISTS idx_vote_results_vote_choice;
CREATE INDEX idx_vote_results_vote_choice ON vote_results (vote_id, choice);
DROP INDEX IF EXISTS idx_vote_results_vote_choice_priority;
CREATE INDEX idx_vote_results_vote_choice_priority ON vote_results (vote_id, choice, priority);
DROP INDEX IF EXISTS idx_votes_created_time;
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
drop function if exists get_votes_with_results;
create or replace function get_votes_with_results(order_by text default 'recent')
returns table (
id BIGINT,
title text,
description jsonb,
created_time timestamptz,
creator_id TEXT,
is_anonymous boolean,
votes_for int,
votes_against int,
votes_abstain int,
priority int
)
as $$
with results as (
SELECT
v.id,
v.title,
v.description,
v.created_time,
v.creator_id,
v.is_anonymous,
COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 0) AS votes_for,
COALESCE(SUM(CASE WHEN r.choice = -1 THEN 1 ELSE 0 END), 0) AS votes_against,
COALESCE(SUM(CASE WHEN r.choice = 0 THEN 1 ELSE 0 END), 0) AS votes_abstain,
COALESCE(SUM(r.priority), 0)::float / GREATEST(COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 1), 1) * 100 / 3 AS priority
FROM votes v
LEFT JOIN vote_results r ON v.id = r.vote_id
GROUP BY v.id
)
SELECT
id,
title,
description,
created_time,
creator_id,
is_anonymous,
votes_for,
votes_against,
votes_abstain,
priority
FROM results
ORDER BY
CASE WHEN order_by = 'recent' THEN created_time END DESC,
CASE WHEN order_by = 'mostVoted' THEN (votes_for + votes_against + votes_abstain) END DESC,
CASE WHEN order_by = 'mostVoted' THEN created_time END DESC,
CASE WHEN order_by = 'priority' THEN priority END DESC,
CASE WHEN order_by = 'priority' THEN created_time END DESC;
$$ language sql stable;

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS votes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
title TEXT NOT NULL,
is_anonymous BOOLEAN NOT NULL,
description JSONB
);
-- Foreign Keys
alter table votes
add constraint votes_creator_id_fkey foreign key (creator_id) references users (id);
-- Row Level Security
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON votes;
CREATE POLICY "public read" ON votes
FOR ALL USING (true);
-- Indexes
DROP INDEX IF EXISTS creator_id_idx;
CREATE INDEX creator_id_idx ON votes (creator_id);
DROP INDEX IF EXISTS idx_votes_created_time;
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);

View File

@@ -4,19 +4,19 @@ import {
baseProfilesSchema,
arraybeSchema,
} from 'common/api/zod-types'
import { PrivateChatMessage } from 'common/chat-message'
import { CompatibilityScore } from 'common/love/compatibility-score'
import { MAX_COMPATIBILITY_QUESTION_LENGTH } from 'common/love/constants'
import { Profile, ProfileRow } from 'common/love/profile'
import { Row } from 'common/supabase/utils'
import { PrivateUser, User } from 'common/user'
import { z } from 'zod'
import { LikeData, ShipData } from './love-types'
import { DisplayUser, FullUser } from './user-types'
import { PrivateMessageChannel } from 'common/supabase/private-messages'
import { Notification } from 'common/notifications'
import { arrify } from 'common/util/array'
import { notification_preference } from 'common/user-notification-preferences'
import {PrivateChatMessage} from 'common/chat-message'
import {CompatibilityScore} from 'common/love/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/love/constants'
import {Profile, ProfileRow} from 'common/love/profile'
import {Row} from 'common/supabase/utils'
import {PrivateUser, User} from 'common/user'
import {z} from 'zod'
import {LikeData, ShipData} from './love-types'
import {DisplayUser, FullUser} from './user-types'
import {PrivateMessageChannel} from 'common/supabase/private-messages'
import {Notification} from 'common/notifications'
import {arrify} from 'common/util/array'
import {notification_preference} from 'common/user-notification-preferences'
// mqp: very unscientific, just balancing our willingness to accept load
// with user willingness to put up with stale data
@@ -28,6 +28,8 @@ type APIGenericSchema = {
method: 'GET' | 'POST' | 'PUT'
// whether the endpoint requires authentication
authed: boolean
// whether the endpoint requires authentication
rateLimited?: boolean
// zod schema for the request body (or for params for GET requests)
props: z.ZodType
// note this has to be JSON serializable
@@ -42,33 +44,39 @@ export const API = (_apiTypeCheck = {
health: {
method: 'GET',
authed: false,
rateLimited: false,
props: z.object({}),
returns: {} as { message: 'Server is working.'; uid?: string },
},
'get-supabase-token': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({}),
returns: {} as { jwt: string },
},
'mark-all-notifs-read': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({}),
},
'user/by-id/:id/block': {
method: 'POST',
authed: true,
props: z.object({ id: z.string() }).strict(),
rateLimited: false,
props: z.object({id: z.string()}).strict(),
},
'user/by-id/:id/unblock': {
method: 'POST',
authed: true,
props: z.object({ id: z.string() }).strict(),
rateLimited: false,
props: z.object({id: z.string()}).strict(),
},
'ban-user': {
method: 'POST',
authed: true,
rateLimited: false,
props: z
.object({
userId: z.string(),
@@ -80,6 +88,7 @@ export const API = (_apiTypeCheck = {
// TODO rest
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as { user: User; privateUser: PrivateUser },
props: z
.object({
@@ -91,12 +100,14 @@ export const API = (_apiTypeCheck = {
'create-profile': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as Row<'profiles'>,
props: baseProfilesSchema,
},
report: {
method: 'POST',
authed: true,
rateLimited: true,
props: z
.object({
contentOwnerId: z.string(),
@@ -112,6 +123,7 @@ export const API = (_apiTypeCheck = {
me: {
method: 'GET',
authed: true,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
props: z.object({}),
returns: {} as FullUser,
@@ -119,6 +131,7 @@ export const API = (_apiTypeCheck = {
'me/update': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
name: z.string().trim().min(1).optional(),
username: z.string().trim().min(1).optional(),
@@ -146,12 +159,14 @@ export const API = (_apiTypeCheck = {
'update-profile': {
method: 'POST',
authed: true,
rateLimited: true,
props: combinedLoveUsersSchema.partial(),
returns: {} as ProfileRow,
},
'update-notif-settings': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({
type: z.string() as z.ZodType<notification_preference>,
medium: z.enum(['email', 'browser', 'mobile']),
@@ -161,6 +176,7 @@ export const API = (_apiTypeCheck = {
'me/delete': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
username: z.string(), // just so you're sure
}),
@@ -168,54 +184,61 @@ export const API = (_apiTypeCheck = {
'me/private': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({}),
returns: {} as PrivateUser,
},
'user/:username': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as FullUser,
props: z.object({ username: z.string() }).strict(),
props: z.object({username: z.string()}).strict(),
},
'user/:username/lite': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as DisplayUser,
props: z.object({ username: z.string() }).strict(),
props: z.object({username: z.string()}).strict(),
},
'user/by-id/:id': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as FullUser,
props: z.object({ id: z.string() }).strict(),
props: z.object({id: z.string()}).strict(),
},
'user/by-id/:id/lite': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as DisplayUser,
props: z.object({ id: z.string() }).strict(),
props: z.object({id: z.string()}).strict(),
},
'search-users': {
method: 'GET',
authed: false,
authed: true,
rateLimited: true,
cache: DEFAULT_CACHE_STRATEGY,
returns: [] as FullUser[],
props: z
.object({
term: z.string(),
limit: z.coerce.number().gte(0).lte(1000).default(500),
limit: z.coerce.number().gte(0).lte(20).default(500),
page: z.coerce.number().gte(0).default(0),
})
.strict(),
},
'compatible-profiles': {
method: 'GET',
authed: false,
props: z.object({ userId: z.string() }),
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}),
returns: {} as {
profile: Profile
compatibleProfiles: Profile[]
@@ -227,7 +250,8 @@ export const API = (_apiTypeCheck = {
'remove-pinned-photo': {
method: 'POST',
authed: true,
returns: { success: true },
rateLimited: true,
returns: {success: true},
props: z
.object({
userId: z.string(),
@@ -236,7 +260,8 @@ export const API = (_apiTypeCheck = {
},
'get-compatibility-questions': {
method: 'GET',
authed: false,
authed: true,
rateLimited: false,
props: z.object({}),
returns: {} as {
status: 'success'
@@ -249,6 +274,7 @@ export const API = (_apiTypeCheck = {
'like-profile': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
targetUserId: z.string(),
remove: z.boolean().optional(),
@@ -260,6 +286,7 @@ export const API = (_apiTypeCheck = {
'ship-profiles': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
targetUserId1: z.string(),
targetUserId2: z.string(),
@@ -271,7 +298,8 @@ export const API = (_apiTypeCheck = {
},
'get-likes-and-ships': {
method: 'GET',
authed: false,
authed: true,
rateLimited: true,
props: z
.object({
userId: z.string(),
@@ -287,6 +315,7 @@ export const API = (_apiTypeCheck = {
'has-free-like': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({}).strict(),
returns: {} as {
status: 'success'
@@ -296,6 +325,7 @@ export const API = (_apiTypeCheck = {
'star-profile': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
targetUserId: z.string(),
remove: z.boolean().optional(),
@@ -306,10 +336,11 @@ export const API = (_apiTypeCheck = {
},
'get-profiles': {
method: 'GET',
authed: false,
authed: true,
rateLimited: true,
props: z
.object({
limit: z.coerce.number().optional().default(20),
limit: z.coerce.number().gt(0).lte(20).optional().default(20),
after: z.string().optional(),
// Search and filter parameters
name: z.string().optional(),
@@ -318,11 +349,15 @@ export const API = (_apiTypeCheck = {
pref_age_min: z.coerce.number().optional(),
pref_age_max: z.coerce.number().optional(),
pref_relation_styles: arraybeSchema.optional(),
pref_romantic_styles: arraybeSchema.optional(),
wants_kids_strength: z.coerce.number().optional(),
has_kids: z.coerce.number().optional(),
is_smoker: z.coerce.boolean().optional(),
shortBio: z.coerce.boolean().optional(),
geodbCityIds: arraybeSchema.optional(),
lat: z.coerce.number().optional(),
lon: z.coerce.number().optional(),
radius: z.coerce.number().optional(),
compatibleWithUserId: z.string().optional(),
orderBy: z
.enum(['last_online_time', 'created_time', 'compatibility_score'])
@@ -337,8 +372,9 @@ export const API = (_apiTypeCheck = {
},
'get-profile-answers': {
method: 'GET',
authed: false,
props: z.object({ userId: z.string() }).strict(),
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}).strict(),
returns: {} as {
status: 'success'
answers: Row<'love_compatibility_answers'>[]
@@ -347,6 +383,7 @@ export const API = (_apiTypeCheck = {
'create-comment': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
userId: z.string(),
content: contentSchema,
@@ -357,6 +394,7 @@ export const API = (_apiTypeCheck = {
'hide-comment': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
commentId: z.string(),
hide: z.boolean(),
@@ -366,6 +404,7 @@ export const API = (_apiTypeCheck = {
'get-channel-memberships': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({
channelId: z.coerce.number().optional(),
createdTime: z.string().optional(),
@@ -380,6 +419,7 @@ export const API = (_apiTypeCheck = {
'get-channel-messages': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({
channelId: z.coerce.number(),
limit: z.coerce.number(),
@@ -390,6 +430,7 @@ export const API = (_apiTypeCheck = {
'get-channel-seen-time': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({
channelIds: z
.array(z.coerce.number())
@@ -401,13 +442,21 @@ export const API = (_apiTypeCheck = {
'set-channel-seen-time': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({
channelId: z.coerce.number(),
}),
},
'set-last-online-time': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({}),
},
'get-notifications': {
method: 'GET',
authed: true,
rateLimited: false,
returns: [] as Notification[],
props: z
.object({
@@ -419,6 +468,7 @@ export const API = (_apiTypeCheck = {
'create-private-user-message': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
content: contentSchema,
@@ -428,6 +478,7 @@ export const API = (_apiTypeCheck = {
'create-private-user-message-channel': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
userIds: z.array(z.string()),
@@ -436,6 +487,7 @@ export const API = (_apiTypeCheck = {
'update-private-user-message-channel': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
channelId: z.number(),
@@ -445,6 +497,7 @@ export const API = (_apiTypeCheck = {
'leave-private-user-message-channel': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
channelId: z.number(),
@@ -453,15 +506,39 @@ export const API = (_apiTypeCheck = {
'create-compatibility-question': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
options: z.record(z.string(), z.number()),
}),
},
'create-vote': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
title: z.string().min(1),
isAnonymous: z.boolean(),
description: contentSchema,
}),
},
'vote': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
voteId: z.number(),
priority: z.number(),
choice: z.enum(['for', 'abstain', 'against']),
}),
},
'search-location': {
method: 'POST',
authed: false,
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
term: z.string(),
@@ -470,13 +547,31 @@ export const API = (_apiTypeCheck = {
},
'search-near-city': {
method: 'POST',
authed: false,
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
cityId: z.string(),
radius: z.number().min(1).max(500),
}),
},
'contact': {
method: 'POST',
authed: false,
rateLimited: true,
returns: {} as any,
props: z.object({
content: contentSchema,
userId: z.string().optional(),
}),
},
'get-messages-count': {
method: 'GET',
authed: false,
rateLimited: false,
props: z.object({}),
returns: {} as { count: number },
},
} as const)
export type APIPath = keyof typeof API
@@ -488,8 +583,8 @@ export type ValidatedAPIParams<N extends APIPath> = z.output<
>
export type APIResponse<N extends APIPath> = APISchema<N> extends {
returns: Record<string, any>
}
returns: Record<string, any>
}
? APISchema<N>['returns']
: void

View File

@@ -49,13 +49,7 @@ export const baseProfilesSchema = z.object({
pref_gender: genderTypes,
pref_age_min: z.number().min(18).max(100).optional(),
pref_age_max: z.number().min(18).max(100).optional(),
pref_relation_styles: z.array(
z.union([
z.literal('collaboration'),
z.literal('friendship'),
z.literal('relationship'),
])
),
pref_relation_styles: z.array(z.string()),
wants_kids_strength: z.number(),
looking_for_matches: z.boolean(),
photo_urls: z.array(z.string()),
@@ -84,7 +78,6 @@ const optionalProfilesSchema = z.object({
height_in_inches: z.number().optional(),
has_pets: z.boolean().optional(),
education_level: z.string().optional(),
last_online_time: z.string().optional(),
is_smoker: z.boolean().optional(),
drinks_per_month: z.number().min(0).optional(),
is_vegetarian_or_vegan: z.boolean().optional(),
@@ -98,6 +91,7 @@ const optionalProfilesSchema = z.object({
bio: contentSchema.optional().nullable(),
twitter: z.string().optional(),
avatar_url: z.string().optional(),
pref_romantic_styles: z.array(z.string()),
})
export const combinedLoveUsersSchema =

View File

@@ -20,3 +20,4 @@ export const pStyle = "mt-1 text-gray-800 dark:text-white whitespace-pre-line";
export const IS_MAINTENANCE = false; // set to true to enable maintenance mode banner
export const MIN_BIO_LENGTH = 250;

View File

@@ -4,10 +4,15 @@ export const sendDiscordMessage = async (content: string, channel: string) => {
let webhookUrl = {
members: process.env.DISCORD_WEBHOOK_MEMBERS,
general: process.env.DISCORD_WEBHOOK_GENERAL,
health: process.env.DISCORD_WEBHOOK_HEALTH,
reports: process.env.DISCORD_WEBHOOK_REPORTS,
contact: process.env.DISCORD_WEBHOOK_CONTACT,
}[channel]
if (IS_DEV) webhookUrl = process.env.DISCORD_WEBHOOK_DEV
// console.log(`Discord webhook URL: ${webhookUrl}`, channel, content)
if (!webhookUrl) return
const response = await fetch(webhookUrl!, {

View File

@@ -2,7 +2,7 @@ import {DEV_CONFIG} from './dev'
import {PROD_CONFIG} from './prod'
import {isProd} from "common/envs/is-prod";
export const MAX_DESCRIPTION_LENGTH = 16000
export const MAX_DESCRIPTION_LENGTH = 100000
export const MAX_ANSWER_LENGTH = 240
export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG

View File

@@ -2,9 +2,18 @@ import {Profile, ProfileRow} from "common/love/profile";
import {cloneDeep} from "lodash";
import {filterDefined} from "common/util/array";
// export type TargetArea = {
// lat: number
// lon: number
// radius: number
// }
export type FilterFields = {
orderBy: 'last_online_time' | 'created_time' | 'compatibility_score'
geodbCityIds: string[] | null
lat: number | null
lon: number | null
radius: number | null
genders: string[]
name: string | undefined
shortBio: boolean | undefined
@@ -18,6 +27,7 @@ export type FilterFields = {
| 'pref_age_min'
| 'pref_age_max'
>
export const orderProfiles = (
profiles: Profile[],
starredUserIds: string[] | undefined
@@ -39,6 +49,9 @@ export const orderProfiles = (
}
export const initialFilters: Partial<FilterFields> = {
geodbCityIds: undefined,
lat: undefined,
lon: undefined,
radius: undefined,
name: undefined,
genders: undefined,
pref_age_max: undefined,
@@ -51,4 +64,8 @@ export const initialFilters: Partial<FilterFields> = {
shortBio: undefined,
orderBy: 'created_time',
}
export type OriginLocation = { id: string; name: string }
export const FilterKeys = Object.keys(initialFilters) as (keyof FilterFields)[]
export type OriginLocation = { id: string; name: string, lat: number, lon: number }

89
common/src/md.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { JSONContent } from '@tiptap/core'
export function jsonToMarkdown(node: JSONContent): string {
if (!node) return ''
// Text node
if (node.type === 'text') {
let text = node.text || ''
if (node.marks) {
for (const mark of node.marks) {
switch (mark.type) {
case 'bold':
text = `**${text}**`
break
case 'italic':
text = `*${text}*`
break
case 'strike':
text = `~~${text}~~`
break
case 'code':
text = `\`${text}\``
break
case 'link':
text = `[${text}](${mark.attrs?.href ?? ''})`
break
}
}
}
return text
}
// Non-text nodes: recursively process children
const content = (node.content || []).map(jsonToMarkdown).join('')
switch (node.type) {
case 'paragraph':
return `${content}\n`
case 'heading': {
const level = node.attrs?.level || 1
return `${'#'.repeat(level)} ${content}\n`
}
case 'bulletList':
return `${content}`
case 'orderedList':
return `${content}`
case 'listItem':
return `- ${content}`
case 'blockquote':
return content
.split('\n')
.map((line) => (line ? `> ${line}` : ''))
.join('\n') + '\n\n'
case 'codeBlock':
return `\`\`\`\n${content}\n\`\`\`\n\n`
case 'horizontalRule':
return `---\n\n`
case 'hardBreak':
return ` \n`
default:
return content
}
}
// function extractTextFromJsonb(bio: JSONContent): string {
// try {
// const texts: string[] = []
// const visit = (node: any) => {
// if (!node) return
// if (Array.isArray(node)) {
// for (const item of node) visit(item)
// return
// }
// if (typeof node === 'object') {
// for (const [k, v] of Object.entries(node)) {
// if (k === 'text' && typeof v === 'string') texts.push(v)
// else visit(v as any)
// }
// }
// }
// visit(bio)
// // Remove extra whitespace and join
// return texts.map((t) => t.trim()).filter(Boolean).join(' ')
// } catch {
// return ''
// }
// }

View File

@@ -25,6 +25,18 @@ export type locationType = {
radius: number
}
const skippedKeys = [
'pref_age_min',
'pref_age_max',
'geodbCityIds',
'orderBy',
'shortBio',
'targetArea',
'lat',
'lon',
'radius',
]
export function formatFilters(filters: Partial<FilterFields>, location: locationType | null): String[] | null {
const entries: String[] = []
@@ -53,7 +65,7 @@ export function formatFilters(filters: Partial<FilterFields>, location: location
const typedKey = key as keyof FilterFields
if (value === undefined || value === null) return
if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy' || typedKey == 'shortBio') return
if (skippedKeys.includes(typedKey)) return
if (Array.isArray(value) && value.length === 0) return
if (initialFilters[typedKey] === value) return

View File

@@ -20,6 +20,9 @@ export const secrets = (
'NEXT_PUBLIC_FIREBASE_API_KEY',
'DISCORD_WEBHOOK_MEMBERS',
'DISCORD_WEBHOOK_GENERAL',
'DISCORD_WEBHOOK_HEALTH',
'DISCORD_WEBHOOK_REPORTS',
'DISCORD_WEBHOOK_CONTACT',
// Some typescript voodoo to keep the string literal types while being not readonly.
] as const
).concat()

View File

@@ -90,7 +90,7 @@ export const getSocialUrl = (site: Site, handle: string) =>
const urler: { [key in Site]: (handle: string) => string } = {
site: (s) => (s.startsWith('http') ? s : `https://${s}`),
okcupid: (s) => (s.startsWith('http') ? s : `https://${s}`),
x: (s) => `https://x.com/${s}`,
x: (s) => s.startsWith('http') ? s : `https://x.com/${s}`,
discord: (s) =>
(s.length === 17 || s.length === 18) && !isNaN(parseInt(s, 10))
? `https://discord.com/users/${s}` // discord user id
@@ -98,14 +98,14 @@ const urler: { [key in Site]: (handle: string) => string } = {
bluesky: (s) => `https://bsky.app/profile/${s}`,
mastodon: (s) =>
s.includes('@') ? `https://${s.split('@')[1]}/@${s.split('@')[0]}` : s,
substack: (s) => `https://${s}.substack.com`,
instagram: (s) => `https://instagram.com/${s}`,
github: (s) => `https://github.com/${s}`,
linkedin: (s) => `https://linkedin.com/in/${s}`,
facebook: (s) => `https://facebook.com/${s}`,
spotify: (s) => `https://open.spotify.com/user/${s}`,
paypal: (s) => `https://paypal.com/paypalme/${s}`,
patreon: (s) => `https://patreon.com/${s}`,
substack: (s) => s.startsWith('http') ? s : `https://${s}.substack.com`,
instagram: (s) => s.startsWith('http') ? s : `https://instagram.com/${s}`,
github: (s) => s.startsWith('http') ? s : `https://github.com/${s}`,
linkedin: (s) => s.startsWith('http') ? s : `https://linkedin.com/in/${s}`,
facebook: (s) => s.startsWith('http') ? s : `https://facebook.com/${s}`,
spotify: (s) => s.startsWith('http') ? s : `https://open.spotify.com/user/${s}`,
paypal: (s) => s.startsWith('http') ? s : `https://paypal.com/paypalme/${s}`,
patreon: (s) => s.startsWith('http') ? s : `https://patreon.com/${s}`,
calendly: (s) => (s.startsWith('http') ? s : `https://${s}`),
datingdoc: (s) => (s.startsWith('http') ? s : `https://${s}`),
friendshipdoc: (s) => (s.startsWith('http') ? s : `https://${s}`),

View File

@@ -44,6 +44,35 @@ export type Database = {
}
Relationships: []
}
contact: {
Row: {
content: Json | null
created_time: string | null
id: string
user_id: string | null
}
Insert: {
content?: Json | null
created_time?: string | null
id?: string
user_id?: string | null
}
Update: {
content?: Json | null
created_time?: string | null
id?: string
user_id?: string | null
}
Relationships: [
{
foreignKeyName: 'contact_user_id_fkey'
columns: ['user_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
love_answers: {
Row: {
created_time: string
@@ -427,7 +456,6 @@ export type Database = {
is_smoker: boolean | null
is_vegetarian_or_vegan: boolean | null
last_modification_time: string
last_online_time: string
looking_for_matches: boolean
messaging_status: string
occupation: string | null
@@ -439,6 +467,7 @@ export type Database = {
pref_age_min: number | null
pref_gender: string[]
pref_relation_styles: string[]
pref_romantic_styles: string[] | null
referred_by_username: string | null
region_code: string | null
religious_belief_strength: number | null
@@ -475,7 +504,6 @@ export type Database = {
is_smoker?: boolean | null
is_vegetarian_or_vegan?: boolean | null
last_modification_time?: string
last_online_time?: string
looking_for_matches?: boolean
messaging_status?: string
occupation?: string | null
@@ -487,6 +515,7 @@ export type Database = {
pref_age_min?: number | null
pref_gender: string[]
pref_relation_styles: string[]
pref_romantic_styles?: string[] | null
referred_by_username?: string | null
region_code?: string | null
religious_belief_strength?: number | null
@@ -523,7 +552,6 @@ export type Database = {
is_smoker?: boolean | null
is_vegetarian_or_vegan?: boolean | null
last_modification_time?: string
last_online_time?: string
looking_for_matches?: boolean
messaging_status?: string
occupation?: string | null
@@ -535,6 +563,7 @@ export type Database = {
pref_age_min?: number | null
pref_gender?: string[]
pref_relation_styles?: string[]
pref_romantic_styles?: string[] | null
referred_by_username?: string | null
region_code?: string | null
religious_belief_strength?: number | null
@@ -599,6 +628,29 @@ export type Database = {
}
]
}
user_activity: {
Row: {
last_online_time: string
user_id: string
}
Insert: {
last_online_time: string
user_id: string
}
Update: {
last_online_time?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: 'user_activity_user_id_fkey'
columns: ['user_id']
isOneToOne: true
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
user_events: {
Row: {
ad_id: string | null
@@ -677,6 +729,83 @@ export type Database = {
}
Relationships: []
}
vote_results: {
Row: {
choice: number
created_time: string
id: number
priority: number
user_id: string
vote_id: number
}
Insert: {
choice: number
created_time?: string
id?: never
priority: number
user_id: string
vote_id: number
}
Update: {
choice?: number
created_time?: string
id?: never
priority?: number
user_id?: string
vote_id?: number
}
Relationships: [
{
foreignKeyName: 'vote_results_user_id_fkey'
columns: ['user_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
{
foreignKeyName: 'vote_results_vote_id_fkey'
columns: ['vote_id']
isOneToOne: false
referencedRelation: 'votes'
referencedColumns: ['id']
}
]
}
votes: {
Row: {
created_time: string
creator_id: string
description: Json | null
id: number
is_anonymous: boolean | null
title: string
}
Insert: {
created_time?: string
creator_id: string
description?: Json | null
id?: never
is_anonymous?: boolean | null
title: string
}
Update: {
created_time?: string
creator_id?: string
description?: Json | null
id?: never
is_anonymous?: boolean | null
title?: string
}
Relationships: [
{
foreignKeyName: 'votes_creator_id_fkey'
columns: ['creator_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
}
Views: {
[_ in never]: never
@@ -710,6 +839,21 @@ export type Database = {
Args: { p_question_id: number }
Returns: Record<string, unknown>[]
}
get_votes_with_results: {
Args: Record<PropertyKey, never>
Returns: {
created_time: string
creator_id: string
description: Json
id: number
is_anonymous: boolean
priority: number
title: string
votes_abstain: number
votes_against: number
votes_for: number
}[]
}
gtrgm_compress: {
Args: { '': unknown }
Returns: unknown
@@ -763,7 +907,7 @@ export type Database = {
Returns: Json
}
ts_to_millis: {
Args: { ts: string }
Args: { ts: string } | { ts: string }
Returns: number
}
}

View File

@@ -46,6 +46,11 @@ export type PrivateUser = {
blockedByUserIds: string[]
}
export type UserActivity = {
user_id: string // same as User.id
last_online_time: string
}
export type UserAndPrivateUser = { user: User; privateUser: PrivateUser }
export function getCurrentUtcTime(): Date {

View File

@@ -67,7 +67,7 @@ export async function baseApiCall(props: {
body:
params == null || method === 'GET' ? undefined : JSON.stringify(params),
})
// console.debug(req)
// console.log('Request', req)
return fetch(req).then(async (resp) => {
const json = (await resp.json()) as { [k: string]: any }
if (!resp.ok) {

View File

@@ -0,0 +1,7 @@
export const ORDER_BY = ['recent', 'mostVoted', 'priority'] as const
export type OrderBy = typeof ORDER_BY[number]
export const Constants: Record<OrderBy, string> = {
recent: 'Most recent',
mostVoted: 'Most voted',
priority: 'Highest Priority',
}

View File

@@ -1,6 +1,6 @@
{
"name": "compass",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"workspaces": [
"common",

View File

@@ -5,6 +5,7 @@ import { Button } from 'web/components/buttons/button'
import { Col } from 'web/components/layout/col'
import { MODAL_CLASS, Modal } from 'web/components/layout/modal'
import { AnswerCompatibilityQuestionContent } from './answer-compatibility-question-content'
import router from "next/router";
export function AnswerCompatibilityQuestionButton(props: {
user: User | null | undefined
@@ -22,13 +23,16 @@ export function AnswerCompatibilityQuestionButton(props: {
} = props
const [open, setOpen] = useState(fromSignup ?? false)
if (!user) return null
if (otherQuestions.length === 0) return null
const isCore = otherQuestions.some((q) => q.importance_score === 0)
const questionsToAnswer = isCore ? otherQuestions.filter((q) => q.importance_score === 0) : otherQuestions
return (
<>
{size === 'md' ? (
<Button onClick={() => setOpen(true)} color="gray-outline">
Answer Questions{' '}
<span className="text-primary-600 ml-2">
+{otherQuestions.length}
+{questionsToAnswer.length}
</span>
</Button>
) : (
@@ -43,8 +47,11 @@ export function AnswerCompatibilityQuestionButton(props: {
open={open}
setOpen={setOpen}
user={user}
otherQuestions={otherQuestions}
otherQuestions={questionsToAnswer}
refreshCompatibilityAll={refreshCompatibilityAll}
onClose={() => {
if (fromSignup) router.push('/')
}}
/>
</>
)
@@ -83,8 +90,9 @@ function AnswerCompatibilityQuestionModal(props: {
user: User
otherQuestions: QuestionWithCountType[]
refreshCompatibilityAll: () => void
onClose?: () => void
}) {
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll } = props
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose } = props
const [questionIndex, setQuestionIndex] = useState(0)
return (
<Modal
@@ -93,6 +101,7 @@ function AnswerCompatibilityQuestionModal(props: {
onClose={() => {
refreshCompatibilityAll()
setQuestionIndex(0)
onClose?.()
}}
>
<Col className={MODAL_CLASS}>

View File

@@ -36,10 +36,10 @@ type ImportanceColorsType = {
}
export const IMPORTANCE_RADIO_COLORS: ImportanceColorsType = {
0: `bg-stone-400 ring-stone-400 dark:bg-stone-500 dark:ring-stone-500`,
1: `bg-teal-200 ring-teal-200 dark:bg-teal-100 dark:ring-teal-100 `,
2: `bg-teal-300 ring-teal-300 dark:bg-teal-200 dark:ring-teal-200 `,
3: `bg-teal-400 ring-teal-400`,
0: `bg-teal-300 ring-teal-200`,
1: `bg-teal-500 ring-teal-200`,
2: `bg-teal-700 ring-teal-300`,
3: `bg-teal-900 ring-teal-400`,
}
export const IMPORTANCE_DISPLAY_COLORS: ImportanceColorsType = {
@@ -157,11 +157,11 @@ export function AnswerCompatibilityQuestionContent(props: {
return (
<Col className="h-full w-full gap-4">
<Col className="gap-1">
<Row className="text-blue-400 -mt-4 w-full justify-start text-sm">
{compatibilityQuestion.importance_score > 0 && <Row className="text-blue-400 -mt-4 w-full justify-start text-sm">
<span>
Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.
</span>
</Row>
</Row>}
{index !== null &&
index !== undefined &&
total !== null &&

View File

@@ -402,7 +402,7 @@ function CompatibilityAnswerBlock(props: {
)}
</Row>
</Row>
<Row className="bg-canvas-50 w-fit gap-1 rounded px-2 py-1 text-sm">
<Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
{answerText}
</Row>
<Row className="px-2 -mt-4">
@@ -417,11 +417,11 @@ function CompatibilityAnswerBlock(props: {
? 'Acceptable'
: 'Also acceptable'}
</div>
<Row className="flex-wrap gap-2 -mt-2">
<Row className="flex-wrap gap-2 mt-0">
{distinctPreferredAnswersText.map((text) => (
<Row
key={text}
className="bg-canvas-50 w-fit gap-1 rounded px-2 py-1 text-sm"
className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm"
>
{text}
</Row>

View File

@@ -1,29 +1,29 @@
import { User } from 'common/user'
import { useOtherAnswers } from 'web/hooks/use-other-answers'
import { QuestionWithCountType } from 'web/hooks/use-questions'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/widgets/avatar'
import { Linkify } from 'web/components/widgets/linkify'
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
import { UserLink } from 'web/components/widgets/user-link'
import { Gender, convertGender } from 'common/gender'
import { capitalize } from 'lodash'
import {User} from 'common/user'
import {useOtherAnswers} from 'web/hooks/use-other-answers'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {Avatar} from 'web/components/widgets/avatar'
import {Linkify} from 'web/components/widgets/linkify'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {UserLink} from 'web/components/widgets/user-link'
import {convertGender, Gender} from 'common/gender'
import {capitalize} from 'lodash'
import clsx from 'clsx'
import { shortenedFromNow } from 'web/lib/util/shortenedFromNow'
import {shortenedFromNow} from 'web/lib/util/shortenedFromNow'
export function OtherProfileAnswers(props: {
question: QuestionWithCountType
user?: User
className?: string
}) {
const { question, className } = props
const {question, className} = props
const otherAnswers = useOtherAnswers(question.id)
const shownAnswers = otherAnswers?.filter(
(a) => a.multiple_choice != null || a.free_response || a.integer
)
if (otherAnswers === undefined) return <LoadingIndicator />
if (otherAnswers === undefined) return <CompassLoadingIndicator/>
if (
(otherAnswers === null ||
otherAnswers.length ||
@@ -50,7 +50,7 @@ export function OtherProfileAnswers(props: {
/>
<Col>
<span className="text-sm">
<UserLink user={answerUser} />, {otherAnswer.age}
<UserLink user={answerUser}/>, {otherAnswer.age}
</span>
<Row className="gap-1 text-xs">
{otherAnswer.city} {' '}

View File

@@ -8,10 +8,11 @@ import {Row} from 'web/components/layout/row'
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
import {updateProfile} from 'web/lib/api'
import {track} from 'web/lib/service/analytics'
import React, {useEffect, useState} from "react";
import {useEffect, useState} from "react";
import ReactMarkdown from "react-markdown";
import Link from "next/link"
import {MIN_BIO_LENGTH} from "common/constants";
import {ShowMore} from 'web/components/widgets/show-more'
const placeHolder = "Tell us about yourself — and what you're looking for!";
@@ -26,32 +27,11 @@ Write a clear and engaging bio to help others understand who you are and the con
`
export function BioTips() {
const [showMoreInfo, setShowMoreInfo] = useState(false)
return (
<div className="mt-2 mb-4">
<button
type="button"
onClick={() => setShowMoreInfo(!showMoreInfo)}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"
>
{showMoreInfo ? 'Hide info' : 'Tips'}
<svg
className={`w-4 h-4 ml-1 transition-transform ${showMoreInfo ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
</svg>
</button>
{showMoreInfo && (
<div className="mt-2 p-3 rounded-md text-sm customlink">
<ReactMarkdown>{tips}</ReactMarkdown>
<Link href="/tips-bio" target="_blank">Read full tips for writing a high-quality bio</Link>
</div>
)}
</div>
<ShowMore labelClosed="Tips" labelOpen="Hide info" className={'customlink text-sm'}>
<ReactMarkdown>{tips}</ReactMarkdown>
<Link href="/tips-bio" target="_blank">Read full tips for writing a high-quality bio</Link>
</ShowMore>
)
}

View File

@@ -0,0 +1,57 @@
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {useUser} from 'web/hooks/use-user'
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
import {JSONContent} from "@tiptap/core";
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
import {Button} from "web/components/buttons/button";
import {api} from "web/lib/api";
import {Title} from "web/components/widgets/title";
import toast from "react-hot-toast";
export function ContactComponent() {
const user = useUser()
const editor = useTextEditor({
max: MAX_DESCRIPTION_LENGTH,
defaultValue: '',
placeholder: 'Contact us here...',
})
const hideButton = editor?.getText().length == 0
return (
<Col className="mx-2">
<Title className="!mb-2 text-3xl">Contact</Title>
<Col>
<div className={'mb-2'}>
<TextEditor
editor={editor}
/>
</div>
{!hideButton && (
<Row className="right-1 justify-between gap-2">
<Button
size="xs"
onClick={async () => {
if (!editor) return
const data = {
content: editor.getJSON() as JSONContent,
userId: user?.id,
};
const result = await api('contact', data).catch(() => {
toast.error('Failed to contact — try again or contact us...')
})
if (!result) return
editor.commands.clearContent()
toast.success('Thank you for your message!')
}}
>
Submit
</Button>
</Row>
)}
</Col>
</Col>
)
}

View File

@@ -1,13 +1,40 @@
export const RELATIONSHIP_CHOICES = {
// Monogamous: 'mono',
// Polyamorous: 'poly',
// 'Open Relationship': 'open',
// Other: 'other',
Collaboration: 'collaboration',
Friendship: 'friendship',
Relationship: 'relationship',
};
export const ROMANTIC_CHOICES = {
Monogamous: 'mono',
Polyamorous: 'poly',
'Open Relationship': 'open',
};
export const POLITICAL_CHOICES = {
Progressive: 'progressive',
Liberal: 'liberal',
'Moderate / Centrist': 'moderate',
Conservative: 'conservative',
Socialist: 'socialist',
Nationalist: 'nationalist',
Populist: 'populist',
'Green / Eco-Socialist': 'green',
Technocratic: 'technocratic',
Libertarian: 'libertarian',
'Effective Accelerationism': 'e/acc',
'Pause AI / Tech Skeptic': 'pause ai',
'Independent / Other': 'other',
}
export const REVERTED_RELATIONSHIP_CHOICES = Object.fromEntries(
Object.entries(RELATIONSHIP_CHOICES).map(([key, value]) => [value, key])
);
export const REVERTED_ROMANTIC_CHOICES = Object.fromEntries(
Object.entries(ROMANTIC_CHOICES).map(([key, value]) => [value, key])
);
export const REVERTED_POLITICAL_CHOICES = Object.fromEntries(
Object.entries(POLITICAL_CHOICES).map(([key, value]) => [value, key])
);

View File

@@ -75,7 +75,7 @@ export function LocationFilter(props: {
if (!city) {
setLocation(undefined)
} else {
setLocation({ id: city.geodb_city_id, name: city.city })
setLocation({ id: city.geodb_city_id, name: city.city, lat: city.latitude, lon: city.longitude })
setLastCity(city)
}
}
@@ -123,7 +123,7 @@ function DistanceSlider(props: {
}) {
const { radius, setRadius } = props
const snapValues = [10, 50, 100, 200, 300]
const snapValues = [10, 50, 100, 200, 300, 500]
const snapToValue = (value: number) => {
const closest = snapValues.reduce((prev, curr) =>
@@ -158,7 +158,7 @@ function LocationResults(props: {
}) {
const { showAny, cities, onCitySelected, loading, className } = props
// delay loading animation by 150ms
// delay loading animation by 150 ms
const [debouncedLoading, setDebouncedLoading] = useState(loading)
useEffect(() => {
if (loading) {

View File

@@ -1,5 +1,5 @@
import {Profile} from 'common/love/profile'
import React, {useEffect, useState} from 'react'
import {useEffect, useState} from 'react'
import {IoFilterSharp} from 'react-icons/io5'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
@@ -10,13 +10,13 @@ import {Select} from 'web/components/widgets/select'
import {DesktopFilters} from './desktop-filters'
import {LocationFilterProps} from './location-filter'
import {MobileFilters} from './mobile-filters'
import {BookmarkSearchButton} from "web/components/searches/button";
import {BookmarkSearchButton, BookmarkStarButton} from "web/components/searches/button";
import {BookmarkedSearchesType} from "web/hooks/use-bookmarked-searches";
import {submitBookmarkedSearch} from "web/lib/supabase/searches";
import {useUser} from "web/hooks/use-user";
import {isEqual} from "lodash";
import toast from "react-hot-toast";
import {FilterFields, initialFilters} from "common/filters";
import {FilterFields} from "common/filters";
import {DisplayUser} from "common/api/user-types";
function isOrderBy(input: string): input is FilterFields['orderBy'] {
return ['last_online_time', 'created_time', 'compatibility_score'].includes(
@@ -32,21 +32,16 @@ export const WORDS: string[] = [
"Sustainability",
"Veganism",
"Meditation",
"Climate action",
"Animal welfare",
"Climate",
"Animal",
"Community living",
"Open source",
"Spirituality",
"Mutual aid",
// Intellectual interests
"Philosophy",
"AI safety",
"Effective altruism",
"Systems thinking",
"Psychology",
"Thinking, Fast and Slow",
"History of ideas",
// Arts & culture
"Indie film",
@@ -54,32 +49,42 @@ export const WORDS: string[] = [
"Contemporary art",
"Folk music",
"Poetry",
"Sci-fi novels",
"Sci-fi",
"Board games",
// Relationship intentions
"Platonic friendship",
"Romantic partner",
"Collaborator",
"Study buddy",
"Co-founder",
// Lifestyle
"Digital nomad",
"Slow travel",
"Forest co-ops",
"Permaculture",
"Yoga retreats",
"Mindful parenting",
"Non-smoker",
"Yoga",
// Random human quirks (to make it feel alive)
"Chess",
"Rock climbing",
"Cold plunges",
"Tea ceremonies",
"Stargazing",
"Urban gardening",
// Other
"Feminism",
"Coding",
"ENFP",
"INTP",
"Therapy",
"Science",
"Camus",
"Running",
"Writing",
"Reading",
"Anime",
"Drawing",
"Photography",
"Linux",
"History",
"Graphics design",
"Math",
"Ethereum",
]
function getRandomPair(count = 3): string {
@@ -91,7 +96,8 @@ function getRandomPair(count = 3): string {
const MAX_BOOKMARKED_SEARCHES = 10;
export const Search = (props: {
youProfile: Profile | undefined | null
starredUserIds: string[]
starredUsers: DisplayUser[]
refreshStars: () => void
// filter props
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
@@ -112,6 +118,8 @@ export const Search = (props: {
filters,
bookmarkedSearches,
refreshBookmarkedSearches,
starredUsers,
refreshStars,
} = props
const [openFiltersModal, setOpenFiltersModal] = useState(false)
@@ -123,6 +131,7 @@ export const Search = (props: {
const [bookmarked, setBookmarked] = useState(false);
const [loadingBookmark, setLoadingBookmark] = useState(false);
const [openBookmarks, setOpenBookmarks] = useState(false);
const [openStarBookmarks, setOpenStarBookmarks] = useState(false);
const user = useUser()
useEffect(() => {
@@ -226,9 +235,7 @@ export const Search = (props: {
</RightModal>
<Row className={'mb-2 gap-2'}>
<Button
disabled={
loadingBookmark || isEqual(filters, initialFilters)
}
disabled={loadingBookmark}
loading={loadingBookmark}
onClick={() => {
if (bookmarkedSearches.length >= MAX_BOOKMARKED_SEARCHES) {
@@ -249,7 +256,7 @@ export const Search = (props: {
color={'none'}
className={'bg-canvas-100 hover:bg-canvas-200'}
>
{bookmarked ? 'Bookmarked!' : loadingBookmark ? '' : 'Get Notified'}
{bookmarked ? 'Saved!' : loadingBookmark ? '' : 'Get Notified'}
</Button>
<BookmarkSearchButton
@@ -258,6 +265,16 @@ export const Search = (props: {
open={openBookmarks}
setOpen={setOpenBookmarks}
/>
<BookmarkStarButton
refreshStars={refreshStars}
starredUsers={starredUsers}
open={openStarBookmarks}
setOpen={(checked) => {
setOpenStarBookmarks(checked)
refreshStars()
}}
/>
</Row>
</Col>
)

View File

@@ -1,10 +1,8 @@
import {Profile} from "common/love/profile";
import {useIsLooking} from "web/hooks/use-is-looking";
import {usePersistentLocalState} from "web/hooks/use-persistent-local-state";
import {useCallback} from "react";
import {useCallback, useEffect} from "react";
import {debounce, isEqual} from "lodash";
import {useNearbyCities} from "web/hooks/use-nearby-locations";
import {useEffectCheckEquality} from "web/hooks/use-effect-check-equality";
import {wantsKidsDatabase, wantsKidsDatabaseToWantsKidsFilter, wantsKidsToHasKidsFilter} from "common/wants-kids";
import {FilterFields, initialFilters, OriginLocation} from "common/filters";
import {MAX_INT, MIN_INT} from "common/constants";
@@ -13,9 +11,11 @@ export const useFilters = (you: Profile | undefined) => {
const isLooking = useIsLooking()
const [filters, setFilters] = usePersistentLocalState<Partial<FilterFields>>(
isLooking ? initialFilters : {...initialFilters, orderBy: 'created_time'},
'profile-filters-2'
'profile-filters-4'
)
// console.log('filters', filters)
const updateFilter = (newState: Partial<FilterFields>) => {
const updatedState = {...newState}
@@ -31,6 +31,8 @@ export const useFilters = (you: Profile | undefined) => {
}
}
// console.log('updating filters', updatedState)
setFilters((prevState) => ({...prevState, ...updatedState}))
}
@@ -54,11 +56,17 @@ export const useFilters = (you: Profile | undefined) => {
OriginLocation | undefined | null
>(undefined, 'nearby-origin-location')
const nearbyCities = useNearbyCities(location?.id, radius)
// const nearbyCities = useNearbyCities(location?.id, radius)
//
// useEffectCheckEquality(() => {
// updateFilter({geodbCityIds: nearbyCities})
// }, [nearbyCities])
useEffectCheckEquality(() => {
updateFilter({geodbCityIds: nearbyCities})
}, [nearbyCities])
useEffect(() => {
if (location?.lat && location?.lon) {
updateFilter({lat: location.lat, lon: location.lon, radius: radius})
}
}, [location?.id, radius]);
const locationFilterProps = {
location,
@@ -99,8 +107,8 @@ export const useFilters = (you: Profile | undefined) => {
updateFilter(yourFilters)
setRadius(100)
debouncedSetRadius(100) // clear any pending debounced sets
if (you?.geodb_city_id && you.city) {
setLocation({id: you?.geodb_city_id, name: you?.city})
if (you?.geodb_city_id && you.city && you.city_latitude && you.city_longitude) {
setLocation({id: you?.geodb_city_id, name: you?.city, lat: you?.city_latitude, lon: you?.city_longitude})
}
} else {
clearFilters()

View File

@@ -24,6 +24,8 @@ import {useProfile} from 'web/hooks/use-profile'
import {Profile} from 'common/love/profile'
import {NotificationsIcon, SolidNotificationsIcon} from './notifications-icon'
import {IS_MAINTENANCE} from "common/constants";
import {MdThumbUp} from "react-icons/md";
import {FaEnvelope} from "react-icons/fa";
export function LovePage(props: {
trackPageView: string | false
@@ -113,12 +115,16 @@ const NotifsSolid = {name: 'Notifs', href: `/notifications`, icon: SolidNotifica
const Messages = {name: 'Messages', href: '/messages', icon: PrivateMessagesIcon};
const Social = {name: 'Social', href: '/social', icon: LinkIcon};
const Organization = {name: 'Organization', href: '/organization', icon: GlobeAltIcon};
const Vote = {name: 'Vote', href: '/vote', icon: MdThumbUp};
const Contact = {name: 'Contact', href: '/contact', icon: FaEnvelope};
const base = [
About,
faq,
Vote,
Social,
Organization,
Contact,
]
function getBottomNavigation(user: User, profile: Profile | null | undefined) {

View File

@@ -32,7 +32,7 @@ export default function MarkdownPage({content, filename}: Props) {
<Col className='w-full rounded px-3 py-4 sm:px-6 space-y-4 customlink'>
<ReactMarkdown
components={{
a: ({node, children, ...props}) => <MarkdownLink {...props}>{children}</MarkdownLink>
a: ({node: _node, children, ...props}) => <MarkdownLink {...props}>{children}</MarkdownLink>
}}
>{content}
</ReactMarkdown>

View File

@@ -86,7 +86,6 @@ const bottomNav = (
toggleTheme: () => void
) =>
buildArray<Item>(
!loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' },
{
name: theme ?? 'auto',
children:
@@ -114,6 +113,7 @@ const bottomNav = (
),
onClick: toggleTheme,
},
!loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' },
loggedIn && { name: 'Sign out', icon: LogoutIcon, onClick: logout }
)

View File

@@ -5,7 +5,7 @@ import { Tooltip } from 'web/components/widgets/tooltip'
import { fromNow } from 'web/lib/util/time'
export const OnlineIcon = memo(function OnlineIcon(props: {
last_online_time: string
last_online_time?: string
}) {
const { last_online_time } = props
@@ -15,21 +15,21 @@ export const OnlineIcon = memo(function OnlineIcon(props: {
const currentTime = dayjs()
// Calculate the time difference as a duration
const diff = dayjs.duration(Math.abs(currentTime.diff(lastOnlineTime)))
const timeDiff = dayjs.duration(Math.abs(currentTime.diff(lastOnlineTime)))
const STALLED_CUTOFF = 15
const INACTIVE_HOURS = 12
// Check if last online time was more than 30 minutes ago and more than one day ago
const isStalled = diff.asMinutes() > STALLED_CUTOFF
const isInactive = diff.asHours() > INACTIVE_HOURS
const isStalled = timeDiff.asMinutes() > STALLED_CUTOFF
const isInactive = timeDiff.asHours() > INACTIVE_HOURS
if (isInactive) {
return <></>
}
return (
<Tooltip text={'Last online: ' + fromNow(lastOnlineTime.valueOf())}>
<Tooltip text={'Last online: ' + fromNow(last_online_time)}>
<div
className={clsx(
'h-2 w-2 rounded-full',

View File

@@ -28,7 +28,7 @@ import {City, CityRow, profileToCity, useCitySearch} from "web/components/search
import {AddPhotosWidget} from './widgets/add-photos'
import {RadioToggleGroup} from "web/components/widgets/radio-toggle-group";
import {MultipleChoiceOptions} from "common/love/multiple-choice";
import {RELATIONSHIP_CHOICES} from "web/components/filters/choices";
import {POLITICAL_CHOICES, RELATIONSHIP_CHOICES, ROMANTIC_CHOICES} from "web/components/filters/choices";
import toast from "react-hot-toast";
export const OptionalLoveUserForm = (props: {
@@ -42,7 +42,7 @@ export const OptionalLoveUserForm = (props: {
const {profile, user, buttonLabel, setProfile, fromSignup, onSubmit} = props
const [isSubmitting, setIsSubmitting] = useState(false)
const [lookingRelationship, setLookingRelationship] = useState(false)
const [lookingRelationship, setLookingRelationship] = useState((profile.pref_relation_styles || []).includes('relationship'))
const router = useRouter()
const [heightFeet, setHeightFeet] = useState<number | undefined>(
profile.height_in_inches
@@ -276,12 +276,55 @@ export const OptionalLoveUserForm = (props: {
<MultiCheckbox
choices={RELATIONSHIP_CHOICES}
selected={profile['pref_relation_styles']}
onChange={(selected) =>
onChange={(selected) => {
setProfile('pref_relation_styles', selected)
}
setLookingRelationship((selected || []).includes('relationship'))
}}
/>
</Col>
{lookingRelationship && <>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Relationship style</label>
<MultiCheckbox
choices={ROMANTIC_CHOICES}
selected={profile['pref_romantic_styles'] || []}
onChange={(selected) => {
setProfile('pref_romantic_styles', selected)
}}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
I would like to have kids
</label>
<RadioToggleGroup
className={'w-44'}
choicesMap={MultipleChoiceOptions}
setChoice={(choice) => {
setProfile('wants_kids_strength', choice)
}}
currentChoice={profile.wants_kids_strength ?? -1}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Current number of kids</label>
<Input
type="number"
onChange={(e) => {
const value =
e.target.value === '' ? null : Number(e.target.value)
setProfile('has_kids', value)
}}
className={'w-20'}
min={0}
value={profile['has_kids'] ?? undefined}
/>
</Col>
</>}
<Col className={clsx(colClassName, 'pb-4')}>
<label className={clsx(labelClassName)}>Socials</label>
@@ -350,16 +393,7 @@ export const OptionalLoveUserForm = (props: {
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Political beliefs</label>
<MultiCheckbox
choices={{
Liberal: 'liberal',
Moderate: 'moderate',
Conservative: 'conservative',
Socialist: 'socialist',
Libertarian: 'libertarian',
'e/acc': 'e/acc',
'Pause AI': 'pause ai',
Other: 'other',
}}
choices={POLITICAL_CHOICES}
selected={profile['political_beliefs'] ?? []}
onChange={(selected) => setProfile('political_beliefs', selected)}
/>
@@ -515,45 +549,14 @@ export const OptionalLoveUserForm = (props: {
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Current number of kids</label>
<Input
type="number"
onChange={(e) => {
const value =
e.target.value === '' ? null : Number(e.target.value)
setProfile('has_kids', value)
}}
className={'w-20'}
min={0}
value={profile['has_kids'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Looking for a relationship?</label>
<ChoicesToggleGroup
currentChoice={lookingRelationship}
choicesMap={{Yes: true, No: false}}
setChoice={(c) => setLookingRelationship(c)}
/>
</Col>
{lookingRelationship && <>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
I would like to have kids
</label>
<RadioToggleGroup
className={'w-44'}
choicesMap={MultipleChoiceOptions}
setChoice={(choice) => {
setProfile('wants_kids_strength', choice)
}}
currentChoice={profile.wants_kids_strength ?? -1}
/>
</Col>
</>}
{/*<Col className={clsx(colClassName)}>*/}
{/* <label className={clsx(labelClassName)}>Looking for a relationship?</label>*/}
{/* <ChoicesToggleGroup*/}
{/* currentChoice={lookingRelationship}*/}
{/* choicesMap={{Yes: true, No: false}}*/}
{/* setChoice={(c) => setLookingRelationship(c)}*/}
{/* />*/}
{/*</Col>*/}
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Photos</label>

View File

@@ -1,41 +1,32 @@
import clsx from 'clsx'
import {
type RelationshipType,
convertRelationshipType,
} from 'web/lib/util/convert-relationship-type'
import {convertRelationshipType, type RelationshipType,} from 'web/lib/util/convert-relationship-type'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import { ReactNode } from 'react'
import { BiSolidDrink } from 'react-icons/bi'
import { BsPersonHeart } from 'react-icons/bs'
import { FaChild } from 'react-icons/fa6'
import {
LuBriefcase,
LuCigarette,
LuCigaretteOff,
LuGraduationCap,
} from 'react-icons/lu'
import { MdNoDrinks, MdOutlineChildFriendly } from 'react-icons/md'
import {
PiHandsPrayingBold,
PiMagnifyingGlassBold,
PiPlantBold,
} from 'react-icons/pi'
import { RiScales3Line } from 'react-icons/ri'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { fromNow } from 'web/lib/util/time'
import { Gender, convertGenderPlural } from 'common/gender'
import { HiOutlineGlobe } from 'react-icons/hi'
import { UserHandles } from 'web/components/user/user-handles'
import { convertRace } from './race'
import { Profile } from 'common/love/profile'
import {ReactNode} from 'react'
import {REVERTED_POLITICAL_CHOICES, REVERTED_ROMANTIC_CHOICES} from 'web/components/filters/choices'
import {BiSolidDrink} from 'react-icons/bi'
import {BsPersonHeart} from 'react-icons/bs'
import {FaChild} from 'react-icons/fa6'
import {LuBriefcase, LuCigarette, LuCigaretteOff, LuGraduationCap,} from 'react-icons/lu'
import {MdNoDrinks, MdOutlineChildFriendly} from 'react-icons/md'
import {PiHandsPrayingBold, PiMagnifyingGlassBold, PiPlantBold,} from 'react-icons/pi'
import {RiScales3Line} from 'react-icons/ri'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {fromNow} from 'web/lib/util/time'
import {convertGenderPlural, Gender} from 'common/gender'
import {HiOutlineGlobe} from 'react-icons/hi'
import {UserHandles} from 'web/components/user/user-handles'
import {convertRace} from './race'
import {Profile} from 'common/love/profile'
import {UserActivity} from "common/user";
import {ClockIcon} from "@heroicons/react/solid";
export function AboutRow(props: {
icon: ReactNode
text?: string | null | string[]
preText?: string
}) {
const { icon, text, preText } = props
const {icon, text, preText} = props
if (!text || text.length < 1) {
return <></>
}
@@ -54,45 +45,50 @@ export function AboutRow(props: {
)
}
export default function ProfileAbout(props: { profile: Profile }) {
const { profile } = props
export default function ProfileAbout(props: {
profile: Profile,
userActivity?: UserActivity,
isCurrentUser: boolean,
}) {
const {profile, userActivity, isCurrentUser} = props
return (
<Col
className={clsx('bg-canvas-0 relative gap-3 overflow-hidden rounded p-4')}
>
<Seeking profile={profile} />
<RelationshipType profile={profile} />
<HasKids profile={profile} />
<Seeking profile={profile}/>
<RelationshipType profile={profile}/>
<HasKids profile={profile}/>
<AboutRow
icon={<RiScales3Line className="h-5 w-5" />}
text={profile.political_beliefs}
icon={<RiScales3Line className="h-5 w-5"/>}
text={profile.political_beliefs?.map(belief => REVERTED_POLITICAL_CHOICES[belief])}
/>
<Education profile={profile} />
<Occupation profile={profile} />
<Education profile={profile}/>
<Occupation profile={profile}/>
<AboutRow
icon={<PiHandsPrayingBold className="h-5 w-5" />}
icon={<PiHandsPrayingBold className="h-5 w-5"/>}
text={profile.religious_beliefs}
/>
<AboutRow
icon={<HiOutlineGlobe className="h-5 w-5" />}
icon={<HiOutlineGlobe className="h-5 w-5"/>}
text={profile.ethnicity
?.filter((r) => r !== 'other')
?.map((r: any) => convertRace(r))}
/>
<Smoker profile={profile} />
<Drinks profile={profile} />
<Smoker profile={profile}/>
<Drinks profile={profile}/>
<AboutRow
icon={<PiPlantBold className="h-5 w-5" />}
icon={<PiPlantBold className="h-5 w-5"/>}
text={profile.is_vegetarian_or_vegan ? 'Vegetarian/Vegan' : null}
/>
<WantsKids profile={profile} />
<UserHandles links={profile.user.link} />
<WantsKids profile={profile}/>
{!isCurrentUser && <LastOnline lastOnlineTime={userActivity?.last_online_time}/>}
<UserHandles links={profile.user.link}/>
</Col>
)
}
function Seeking(props: { profile: Profile }) {
const { profile } = props
const {profile} = props
const prefGender = profile.pref_gender
const min = profile.pref_age_min
const max = profile.pref_age_max
@@ -110,28 +106,28 @@ function Seeking(props: { profile: Profile }) {
min == 18 && max == 100 || min == undefined && max == undefined
? 'of any age'
: min == max
? `exactly ${min} years old`
: max == 100 || max == undefined
? `older than ${min}`
: min == 18 || min == undefined
? `younger than ${max}`
: `between ${min} - ${max} years old`
? `exactly ${min} years old`
: max == 100 || max == undefined
? `older than ${min}`
: min == 18 || min == undefined
? `younger than ${max}`
: `between ${min} - ${max} years old`
if (!prefGender || prefGender.length < 1) {
return <></>
}
return (
<AboutRow
icon={<PiMagnifyingGlassBold className="h-5 w-5" />}
icon={<PiMagnifyingGlassBold className="h-5 w-5"/>}
text={`${seekingGenderText} ${ageRangeText}`}
/>
)
}
function RelationshipType(props: { profile: Profile }) {
const { profile } = props
const {profile} = props
const relationshipTypes = profile.pref_relation_styles
const seekingGenderText = stringOrStringArrayToText({
let seekingGenderText = stringOrStringArrayToText({
text: relationshipTypes.map((rel) =>
convertRelationshipType(rel as RelationshipType).toLowerCase()
).sort(),
@@ -143,16 +139,25 @@ function RelationshipType(props: { profile: Profile }) {
asSentence: true,
capitalizeFirstLetterOption: false,
})
if (relationshipTypes.includes('relationship')) {
const romanticStyles = profile.pref_romantic_styles
?.map((style) => REVERTED_ROMANTIC_CHOICES[style].toLowerCase())
.filter(Boolean)
if (romanticStyles && romanticStyles.length > 0) {
seekingGenderText += ` (${romanticStyles.join(', ')})`
}
}
return (
<AboutRow
icon={<BsPersonHeart className="h-5 w-5" />}
icon={<BsPersonHeart className="h-5 w-5"/>}
text={seekingGenderText}
/>
)
}
function Education(props: { profile: Profile }) {
const { profile } = props
const {profile} = props
const educationLevel = profile.education_level
const university = profile.university
@@ -169,14 +174,14 @@ function Education(props: { profile: Profile }) {
}${capitalizeAndRemoveUnderscores(university)}`
return (
<AboutRow
icon={<LuGraduationCap className="h-5 w-5" />}
icon={<LuGraduationCap className="h-5 w-5"/>}
text={universityText}
/>
)
}
function Occupation(props: { profile: Profile }) {
const { profile } = props
const {profile} = props
const occupation_title = profile.occupation_title
const company = profile.company
@@ -190,44 +195,44 @@ function Occupation(props: { profile: Profile }) {
}`
return (
<AboutRow
icon={<LuBriefcase className="h-5 w-5" />}
icon={<LuBriefcase className="h-5 w-5"/>}
text={occupationText}
/>
)
}
function Smoker(props: { profile: Profile }) {
const { profile } = props
const {profile} = props
const isSmoker = profile.is_smoker
if (isSmoker == null) return null
if (isSmoker) {
return (
<AboutRow icon={<LuCigarette className="h-5 w-5" />} text={'Smokes'} />
<AboutRow icon={<LuCigarette className="h-5 w-5"/>} text={'Smokes'}/>
)
}
return (
<AboutRow
icon={<LuCigaretteOff className="h-5 w-5" />}
icon={<LuCigaretteOff className="h-5 w-5"/>}
text={`Doesn't smoke`}
/>
)
}
function Drinks(props: { profile: Profile }) {
const { profile } = props
const {profile} = props
const drinksPerMonth = profile.drinks_per_month
if (drinksPerMonth == null) return null
if (drinksPerMonth === 0) {
return (
<AboutRow
icon={<MdNoDrinks className="h-5 w-5" />}
icon={<MdNoDrinks className="h-5 w-5"/>}
text={`Doesn't drink`}
/>
)
}
return (
<AboutRow
icon={<BiSolidDrink className="h-5 w-5" />}
icon={<BiSolidDrink className="h-5 w-5"/>}
text={`${drinksPerMonth} ${
drinksPerMonth == 1 ? 'drink' : 'drinks'
} per month`}
@@ -236,36 +241,48 @@ function Drinks(props: { profile: Profile }) {
}
function WantsKids(props: { profile: Profile }) {
const { profile } = props
const {profile} = props
const wantsKidsStrength = profile.wants_kids_strength
if (wantsKidsStrength == null || wantsKidsStrength < 0) return null
const wantsKidsText =
wantsKidsStrength == 0
? 'Does not want children'
: wantsKidsStrength == 1
? 'Prefers not to have children'
: wantsKidsStrength == 2
? 'Neutral or open to having children'
: wantsKidsStrength == 3
? 'Leaning towards wanting children'
: 'Wants children'
? 'Prefers not to have children'
: wantsKidsStrength == 2
? 'Neutral or open to having children'
: wantsKidsStrength == 3
? 'Leaning towards wanting children'
: 'Wants children'
return (
<AboutRow
icon={<MdOutlineChildFriendly className="h-5 w-5" />}
icon={<MdOutlineChildFriendly className="h-5 w-5"/>}
text={wantsKidsText}
/>
)
}
function LastOnline(props: { lastOnlineTime?: string }) {
const {lastOnlineTime} = props
if (!lastOnlineTime) return null
return (
<AboutRow
icon={<ClockIcon className="h-5 w-5"/>}
text={'Last online ' + fromNow(lastOnlineTime)}
/>
)
}
function HasKids(props: { profile: Profile }) {
const { profile } = props
const {profile} = props
const hasKidsText =
profile.has_kids && profile.has_kids > 0
? `Has ${profile.has_kids} ${profile.has_kids > 1 ? 'kids' : 'kid'}`
: null
return <AboutRow icon={<FaChild className="h-5 w-5" />} text={hasKidsText} />
return <AboutRow icon={<FaChild className="h-5 w-5"/>} text={hasKidsText}/>
}
export const formatProfileValue = (key: string, value: any) => {
if (Array.isArray(value)) {
return value.join(', ')

View File

@@ -1,13 +1,12 @@
import {Profile} from 'common/love/profile'
import {CompatibilityScore} from 'common/love/compatibility-score'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer'
import {track} from 'web/lib/service/analytics'
import {Col} from './layout/col'
import clsx from 'clsx'
import {JSONContent} from "@tiptap/core";
import {Content} from "web/components/widgets/editor";
import React from "react";
import Link from "next/link";
import {Row} from "web/components/layout/row";
import {CompatibleBadge} from "web/components/widgets/compatible-badge";
@@ -60,14 +59,14 @@ export const ProfileGrid = (props: {
{isLoadingMore && (
<div className="flex justify-center py-4">
<LoadingIndicator/>
<CompassLoadingIndicator/>
</div>
)}
{!isLoadingMore && !isReloading && other_profiles.length === 0 && (
<div className="py-8 text-center">
<p>No profiles found.</p>
<p>Feel free to click on Get Notified and we'll notify you when new users match it!</p>
<p>Feel free to click on Get Notified and we'll notify you when new users match your search!</p>
</div>
)}
</div>

View File

@@ -3,7 +3,7 @@ import clsx from 'clsx'
import Router from 'next/router'
import router from 'next/router'
import Link from 'next/link'
import {User} from 'common/user'
import {User, UserActivity} from 'common/user'
import {Button} from 'web/components/buttons/button'
import {MoreOptionsUserButton} from 'web/components/buttons/more-options-user-button'
import {Col} from 'web/components/layout/col'
@@ -17,15 +17,16 @@ import {ShareProfileButton} from '../widgets/share-profile-button'
import {Profile} from 'common/love/profile'
import {useUser} from 'web/hooks/use-user'
import {linkClass} from 'web/components/widgets/site-link'
import {StarButton} from '../widgets/star-button'
import {updateProfile} from 'web/lib/api'
import React, {useState} from 'react'
import {useState} from 'react'
import {VisibilityConfirmationModal} from './visibility-confirmation-modal'
import {deleteAccount} from "web/lib/util/delete";
import toast from "react-hot-toast";
import {StarButton} from "web/components/widgets/star-button";
export default function ProfileHeader(props: {
user: User
userActivity?: UserActivity
profile: Profile
simpleView?: boolean
starredUserIds: string[]
@@ -36,6 +37,7 @@ export default function ProfileHeader(props: {
const {
user,
profile,
userActivity,
simpleView,
starredUserIds,
refreshStars,
@@ -46,7 +48,7 @@ export default function ProfileHeader(props: {
const isCurrentUser = currentUser?.id === user.id
const [showVisibilityModal, setShowVisibilityModal] = useState(false)
console.debug('ProfileProfileHeader', {user, profile, currentUser})
console.debug('ProfileProfileHeader', {user, profile, userActivity, currentUser})
return (
<Col className="w-full">
@@ -54,7 +56,7 @@ export default function ProfileHeader(props: {
<Row className="items-center gap-1">
<Col className="gap-1">
<Row className="items-center gap-1 text-xl">
<OnlineIcon last_online_time={profile.last_online_time}/>
{!isCurrentUser && <OnlineIcon last_online_time={userActivity?.last_online_time}/>}
<span>
{simpleView ? (
<Link className={linkClass} href={`/${user.username}`}>
@@ -144,13 +146,13 @@ export default function ProfileHeader(props: {
className="sm:flex"
username={user.username}
/>
{/*{currentUser && (*/}
{/* <StarButton*/}
{/* targetProfile={profile}*/}
{/* isStarred={starredUserIds.includes(user.id)}*/}
{/* refresh={refreshStars}*/}
{/* />*/}
{/*)}*/}
{currentUser && (
<StarButton
targetProfile={profile}
isStarred={starredUserIds.includes(user.id)}
refresh={refreshStars}
/>
)}
{currentUser && showMessageButton && (
<SendMessageButton toUser={user} currentUser={currentUser}/>
)}

View File

@@ -14,7 +14,8 @@ import {useGetter} from 'web/hooks/use-getter'
import {getStars} from 'web/lib/supabase/stars'
import {Content} from "web/components/widgets/editor";
import {JSONContent} from "@tiptap/core";
import React from "react";
import {useUserActivity} from 'web/hooks/use-user-activity'
import {UserActivity} from "common/user";
export function ProfileInfo(props: {
profile: Profile
@@ -30,11 +31,12 @@ export function ProfileInfo(props: {
// const currentProfile = useProfile()
// const isCurrentUser = currentUser?.id === user.id
const {data: starredUserIds, refresh: refreshStars} = useGetter(
const {data: starredUsers, refresh: refreshStars} = useGetter(
'stars',
currentUser?.id,
getStars
)
const starredUserIds = starredUsers?.map((u) => u.id)
// const { data, refresh } = useAPIGetter('get-likes-and-ships', {
// userId: user.id,
@@ -61,10 +63,13 @@ export function ProfileInfo(props: {
const isProfileVisible = currentUser || profile.visibility === 'public'
const { data: userActivity } = useUserActivity(user?.id)
return (
<>
<ProfileHeader
user={user}
userActivity={userActivity}
profile={profile}
simpleView={!!fromProfilePage}
starredUserIds={starredUserIds ?? []}
@@ -75,6 +80,7 @@ export function ProfileInfo(props: {
{isProfileVisible ? (
<ProfileContent
user={user}
userActivity={userActivity}
profile={profile}
refreshProfile={refreshProfile}
fromProfilePage={fromProfilePage}
@@ -126,6 +132,7 @@ export function ProfileInfo(props: {
function ProfileContent(props: {
user: User
userActivity?: UserActivity
profile: Profile
refreshProfile: () => void
fromProfilePage?: Profile
@@ -138,6 +145,7 @@ function ProfileContent(props: {
}) {
const {
user,
userActivity,
profile,
refreshProfile,
fromProfilePage,
@@ -154,7 +162,7 @@ function ProfileContent(props: {
return (
<>
<ProfileAbout profile={profile}/>
<ProfileAbout profile={profile} userActivity={userActivity} isCurrentUser={isCurrentUser}/>
<ProfileBio
isCurrentUser={isCurrentUser}
profile={profile}

View File

@@ -7,6 +7,7 @@ import { Button } from 'web/components/buttons/button'
import Textarea from 'react-expanding-textarea'
import { toast } from 'react-hot-toast'
import { api } from 'web/lib/api'
import {randomString} from "common/util/random";
export const ReportUser = (props: { user: User; closeModal: () => void }) => {
const { user, closeModal } = props
@@ -31,7 +32,7 @@ export const ReportUser = (props: { user: User; closeModal: () => void }) => {
.promise(
api('report', {
contentType: 'user',
contentId: user.id,
contentId: randomString(16),
contentOwnerId: user.id,
description:
'Reasons: ' + [...selectedReportTypes, otherReportType].join(', '),

View File

@@ -6,14 +6,13 @@ import {useCompatibleProfiles} from 'web/hooks/use-profiles'
import {getStars} from 'web/lib/supabase/stars'
import {useCallback, useEffect, useRef, useState} from 'react'
import {ProfileGrid} from 'web/components/profile-grid'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Title} from 'web/components/widgets/title'
import {useGetter} from 'web/hooks/use-getter'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {useBookmarkedSearches} from "web/hooks/use-bookmarked-searches";
import {orderProfiles} from "common/filters";
import {useFilters} from "web/components/filters/use-filters";
export function ProfilesHome() {
@@ -29,7 +28,7 @@ export function ProfilesHome() {
locationFilterProps,
} = useFilters(you ?? undefined);
const [profiles, setProfiles] = usePersistentInMemoryState<Profile[] | undefined>(undefined, 'profile-profiles');
const [profiles, setProfiles] = usePersistentInMemoryState<Profile[] | undefined>(undefined, 'profiles');
const {bookmarkedSearches, refreshBookmarkedSearches} = useBookmarkedSearches(user?.id)
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isReloading, setIsReloading] = useState(false);
@@ -64,9 +63,12 @@ export function ProfilesHome() {
});
}, [filters]);
const {data: starredUserIds, refresh: refreshStars} = useGetter('star', user?.id, getStars);
const compatibleProfiles = useCompatibleProfiles(user?.id);
const displayProfiles = profiles && orderProfiles(profiles, starredUserIds);
const {data: starredUsers, refresh: refreshStars} = useGetter('star', user?.id, getStars)
const starredUserIds = starredUsers?.map((u) => u.id)
const compatibleProfiles = useCompatibleProfiles(user?.id)
// const displayProfiles = profiles && orderProfiles(profiles, starredUserIds);
const displayProfiles = profiles
const loadMore = useCallback(async () => {
if (!profiles || isLoadingMore) return false;
@@ -96,7 +98,8 @@ export function ProfilesHome() {
<Title className="!mb-2 text-3xl">Profiles</Title>
<Search
youProfile={you}
starredUserIds={starredUserIds ?? []}
starredUsers={starredUsers ?? []}
refreshStars={refreshStars}
filters={filters}
updateFilter={updateFilter}
clearFilters={clearFilters}
@@ -107,7 +110,7 @@ export function ProfilesHome() {
refreshBookmarkedSearches={refreshBookmarkedSearches}
/>
{displayProfiles === undefined || compatibleProfiles === undefined ? (
<LoadingIndicator/>
<CompassLoadingIndicator/>
) : (
<ProfileGrid
profiles={displayProfiles}

View File

@@ -7,6 +7,11 @@ import {useUser} from "web/hooks/use-user";
import {deleteBookmarkedSearch} from "web/lib/supabase/searches";
import {formatFilters, locationType} from "common/searches";
import {FilterFields} from "common/filters";
import {api} from "web/lib/api";
import {DisplayUser} from "common/api/user-types";
import {useState} from "react";
import toast from "react-hot-toast";
import Link from "next/link";
export function BookmarkSearchButton(props: {
bookmarkedSearches: BookmarkedSearchesType[]
@@ -26,7 +31,7 @@ export function BookmarkSearchButton(props: {
return (
<>
<Button onClick={() => setOpen(true)} color="gray-outline" size={'xs'}>
My Bookmarked Searches
Saved Searches
</Button>
<ButtonModal
open={open}
@@ -57,32 +62,166 @@ function ButtonModal(props: {
}}
>
<Col className={MODAL_CLASS}>
<h3>Bookmarked Searches</h3>
<p className='text-sm'>We'll notify you daily when new people match your searches below.</p>
<Col
className={
'border-ink-300bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 text-sm shadow-sm'
}
>
<ol className="list-decimal list-inside space-y-2">
{(bookmarkedSearches || []).map((search) => (
<li key={search.id}
className="items-center justify-between gap-2 list-item marker:text-ink-500 marker:font-bold">
{formatFilters(search.search_filters as Partial<FilterFields>, search.location as locationType)?.join(" • ")}
<button
onClick={async () => {
await deleteBookmarkedSearch(search.id)
refreshBookmarkedSearches()
}}
className="inline-flex text-xl h-5 w-5 items-center justify-center rounded-full text-red-600 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
>
×
</button>
</li>
))}
</ol>
<h3>Saved Searches</h3>
{bookmarkedSearches?.length ? (<>
<p>We'll notify you daily when new people match your searches below.</p>
<Col
className={
'border-ink-300bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 shadow-sm'
}
>
<ol className="list-decimal list-inside space-y-2">
{(bookmarkedSearches || []).map((search) => (
<li key={search.id}
className="items-center justify-between gap-2 list-item marker:text-ink-500 marker:font-bold">
{formatFilters(search.search_filters as Partial<FilterFields>, search.location as locationType)?.join(" • ")}
<button
onClick={async () => {
await deleteBookmarkedSearch(search.id)
refreshBookmarkedSearches()
}}
className="inline-flex text-xl h-5 w-5 items-center justify-center rounded-full text-red-600 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
>
×
</button>
</li>
))}
</ol>
</Col>
</Col>
</>
) :
<p>You haven't saved any search. To save one, click on Get Notified and we'll notify you daily when new people
match it.</p>}
{/*<BookmarkSearchContent*/}
{/* total={bookmarkedSearches.length}*/}
{/* compatibilityQuestion={bookmarkedSearches[questionIndex]}*/}
{/* user={user}*/}
{/* onSubmit={() => {*/}
{/* setOpen(false)*/}
{/* }}*/}
{/* isLastQuestion={questionIndex === bookmarkedSearches.length - 1}*/}
{/* onNext={() => {*/}
{/* if (questionIndex === bookmarkedSearches.length - 1) {*/}
{/* setOpen(false)*/}
{/* } else {*/}
{/* setQuestionIndex(questionIndex + 1)*/}
{/* }*/}
{/* }}*/}
{/*/>*/}
</Col>
</Modal>
)
}
export function BookmarkStarButton(props: {
starredUsers: DisplayUser[]
refreshStars: () => void
open: boolean
setOpen: (checked: boolean) => void
}) {
const {
starredUsers,
refreshStars,
open,
setOpen,
} = props
const user = useUser()
if (!user) return null
return (
<>
<Button onClick={() => setOpen(true)} color="gray-outline" size={'xs'}>
Saved Profiles
</Button>
<StarModal
open={open}
setOpen={setOpen}
user={user}
starredUsers={starredUsers}
refreshStars={refreshStars}
/>
</>
)
}
function StarModal(props: {
open: boolean
setOpen: (open: boolean) => void
user: User
starredUsers: DisplayUser[]
refreshStars: () => void
}) {
const {open, setOpen, starredUsers, refreshStars} = props
// Track items being optimistically removed so we can hide them immediately
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set())
const visibleUsers = (starredUsers || []).filter((u) => !removingIds.has(u.id))
return (
<Modal
open={open}
setOpen={setOpen}
// onClose={() => {
// refreshBookmarkedSearches()
// }}
>
<Col className={MODAL_CLASS}>
<h3>Saved Profiles</h3>
{visibleUsers?.length ? (<>
<p>Here are the profiles you saved:</p>
<Col
className={
'border-ink-300bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 shadow-sm'
}
>
<ol className="list-decimal list-inside space-y-2">
{visibleUsers.map((user) => (
<li key={user.id}
className="items-center justify-between gap-2 list-item marker:text-ink-500 marker:font-bold">
<a className={'customlink'}>
{user.name} (<Link
href={`/${user.username}`}
// style={{color: "#2563eb", textDecoration: "none"}}
>
@{user.username}
</Link>) {' '}
</a>
<button
onClick={() => {
// Optimistically remove the user from the list
setRemovingIds((prev) => new Set(prev).add(user.id))
// Fire the API call without blocking UI
api('star-profile', {
targetUserId: user.id,
remove: true,
})
.then(() => {
// Sync with server state
refreshStars()
})
.catch(() => {
toast.error("Couldn't remove saved profile. Please try again.")
// Revert optimistic removal on failure
setRemovingIds((prev) => {
const next = new Set(prev)
next.delete(user.id)
return next
})
})
}}
className="inline-flex text-xl h-5 w-5 items-center justify-center rounded-full text-red-600 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
>
×
</button>
</li>
))}
</ol>
</Col>
</>
) : <p>You haven't saved any profile. To save one, click on the star on their profile page.</p>}
{/*<BookmarkSearchContent*/}
{/* total={bookmarkedSearches.length}*/}
{/* compatibilityQuestion={bookmarkedSearches[questionIndex]}*/}

View File

@@ -20,7 +20,9 @@ export function UserHandles(props: { links: Socials; className?: string }) {
Object.entries(links),
([platform]) => -[...SITE_ORDER].reverse().indexOf(platform as Site)
).map(([platform, label]) => {
const renderedLabel: string = LABELS_TO_RENDER.includes(platform) ? PLATFORM_LABELS[platform as Site] : label
let renderedLabel: string = LABELS_TO_RENDER.includes(platform) ? PLATFORM_LABELS[platform as Site] : label
renderedLabel = renderedLabel?.replace(/\/+$/, '') // remove trailing slashes
renderedLabel = renderedLabel?.replace(/^(https?:\/\/)?(www\.)?/, '') // remove protocol and www
return {
platform,
label: renderedLabel,
@@ -28,6 +30,10 @@ export function UserHandles(props: { links: Socials; className?: string }) {
}
})
if (display.length === 0) {
return null
}
return (
<Row
className={clsx(

View File

@@ -0,0 +1,153 @@
import {Row} from 'web/components/layout/row'
import {Button} from 'web/components/buttons/button'
import clsx from 'clsx'
import toast from 'react-hot-toast'
import {api} from 'web/lib/api'
import {useState, useEffect, useRef} from 'react'
import {useUser} from "web/hooks/use-user";
export type VoteChoice = 'for' | 'abstain' | 'against'
function VoteButton(props: {
color: string
count: number
title: string
disabled?: boolean
onClick?: () => void
}) {
const {color, count, title, disabled, onClick} = props
return (
<Button
size="xs"
disabled={disabled}
className={clsx('px-2 xs:px-4 py-2 rounded-lg', color)}
onClick={onClick}
color={'gray-white'}
>
<div className="font-semibold mx-1 xs:mx-2">{count}</div>
<div className="text-sm">{title}</div>
</Button>
)
}
const priorities = [
{label: 'Urgent', value: 3},
{label: 'High', value: 2},
{label: 'Medium', value: 1},
{label: 'Low', value: 0},
] as const
export function VoteButtons(props: {
voteId: number
counts: { for: number; abstain: number; against: number }
onVoted?: () => void | Promise<void>
className?: string
}) {
const user = useUser()
const {voteId, counts, onVoted, className} = props
const [loading, setLoading] = useState<VoteChoice | null>(null)
const [showPriority, setShowPriority] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const disabled = loading !== null
// Close the dropdown when clicking outside or pressing Escape
useEffect(() => {
if (!showPriority) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node | null
if (containerRef.current && target && !containerRef.current.contains(target)) {
setShowPriority(false)
}
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowPriority(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [showPriority])
const sendVote = async (choice: VoteChoice, priority: number) => {
try {
setLoading(choice)
if (!user) {
toast.error('Please sign in to vote')
return
}
await api('vote', {voteId, choice, priority})
toast.success(`Voted ${choice}${choice === 'for' ? ` with priority ${priority}` : ''}`)
await onVoted?.()
} catch (e) {
console.error(e)
toast.error('Failed to vote — please try again')
} finally {
setLoading(null)
}
}
const handleVote = async (choice: VoteChoice) => {
if (choice === 'for') {
// Toggle the priority dropdown
setShowPriority((v) => !v)
return
}
// Default priority 0 for non-for choices
await sendVote(choice, 0)
}
return (
<Row className={clsx('gap-2 xs:gap-4 mt-2 flex-wrap', className)}>
<div className="relative" ref={containerRef}>
<VoteButton
color={clsx('bg-green-700 text-white hover:bg-green-500')}
count={counts.for}
title={'For'}
disabled={disabled}
onClick={() => handleVote('for')}
/>
{showPriority && (
<div className={clsx(
'absolute z-10 mt-2 w-40 rounded-md border border-ink-200 bg-canvas-50 shadow-lg',
'dark:bg-ink-900'
)}>
{priorities.map((p) => (
<button
key={p.value}
className={clsx(
'w-full text-left px-3 py-2 text-sm hover:bg-ink-100 bg-canvas-50',
'dark:hover:bg-canvas-100'
)}
onClick={async () => {
setShowPriority(false)
await sendVote('for', p.value)
}}
>
{p.label} priority
</button>
))}
</div>
)}
</div>
<VoteButton
color={clsx('bg-yellow-700 text-white hover:bg-yellow-500')}
count={counts.abstain}
title={'Abstain'}
disabled={disabled}
onClick={() => handleVote('abstain')}
/>
<VoteButton
color={clsx('bg-red-700 text-white hover:bg-red-500')}
count={counts.against}
title={'Against'}
disabled={disabled}
onClick={() => handleVote('against')}
/>
</Row>
)
}

View File

@@ -0,0 +1,152 @@
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {useUser} from 'web/hooks/use-user'
import {useGetter} from 'web/hooks/use-getter'
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
import {JSONContent} from "@tiptap/core";
import {getVotes} from "web/lib/supabase/votes";
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
import {useEffect, useState} from "react";
import {Button} from "web/components/buttons/button";
import {Input} from "web/components/widgets/input";
import {api} from "web/lib/api";
import {Title} from "web/components/widgets/title";
import toast from "react-hot-toast";
import {Vote, VoteItem} from 'web/components/votes/vote-item'
import Link from "next/link";
import {formLink} from "common/constants";
import { ShowMore } from "../widgets/show-more";
import {ORDER_BY, Constants, OrderBy} from "common/votes/constants";
export function VoteComponent() {
const user = useUser()
const [orderBy, setOrderBy] = useState<OrderBy>('recent')
const {data: votes, refresh: refreshVotes} = useGetter(
'votes',
{orderBy},
getVotes
)
const [title, setTitle] = useState<string>('')
const [editor, setEditor] = useState<any>(null)
const [isAnonymous, setIsAnonymous] = useState<boolean>(false)
const hideButton = title.length == 0
return (
<Col className="mx-2">
<Row className="items-center justify-between flex-col xxs:flex-row mb-4 xxs:mb-0 gap-2">
<Title className="text-3xl">Proposals</Title>
<div className="flex items-center gap-2 text-sm justify-end">
<label htmlFor="orderBy" className="text-gray-600">Order by:</label>
<select
id="orderBy"
value={orderBy}
onChange={(e) => setOrderBy(e.target.value as OrderBy)}
className="rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50"
>
{ORDER_BY.map((key) => (
<option key={key} value={key}>
{Constants[key]}
</option>
))}
</select>
</div>
</Row>
<p className={'customlink'}>
You can discuss any of those proposals through the <Link href={'/contact'}>contact form</Link>, the <Link href={formLink}>feedback form</Link>, or any of our <Link href={'/social'}>socials</Link>.
</p>
{user && <Col>
<ShowMore labelClosed="Add a new proposal" labelOpen="Hide">
<Input
value={title}
placeholder={'Title'}
className={'w-full mb-2'}
onChange={(e) => {
setTitle(e.target.value)
}}
/>
<VoteCreator
onEditor={(e) => setEditor(e)}
/>
<Row className="mx-2 mb-2 items-center gap-2 text-sm text-gray-500">
<input
type="checkbox"
id="anonymous"
checked={isAnonymous}
onChange={(e) => setIsAnonymous(e.target.checked)}
className="h-4 w-4 rounded-md border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="anonymous">Anonymous?</label>
</Row>
{!hideButton && (
<Row className="right-1 justify-between gap-2">
<Button
size="xs"
onClick={async () => {
const data = {
title: title,
description: editor.getJSON() as JSONContent,
isAnonymous: isAnonymous,
};
const newVote = await api('create-vote', data).catch(() => {
toast.error('Failed to create vote — try again or contact us...')
})
if (!newVote) return
setTitle('')
editor.commands.clearContent()
toast.success('Vote created')
console.debug('Vote created', newVote)
refreshVotes()
}}
>
Submit
</Button>
</Row>
)}
</ShowMore>
</Col>
}
{votes && votes.length > 0 && <Col className={'mt-4'}>
{votes.map((vote: Vote) => {
return (
<VoteItem key={vote.id} vote={vote} onVoted={refreshVotes}/>
)
})}
</Col>}
</Col>
)
}
interface VoteCreatorProps {
defaultValue?: any
onBlur?: (editor: any) => void
onEditor?: (editor: any) => void
}
export function VoteCreator({defaultValue, onBlur, onEditor}: VoteCreatorProps) {
const editor = useTextEditor({
// extensions: [StarterKit],
max: MAX_DESCRIPTION_LENGTH,
defaultValue: defaultValue,
placeholder: 'Please describe your proposal here',
})
useEffect(() => {
onEditor?.(editor)
}, [editor, onEditor])
return (
<div className={'mb-2'}>
{/*<p>Description</p>*/}
<TextEditor
editor={editor}
onBlur={() => onBlur?.(editor)}
/>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {Row as rowFor} from 'common/supabase/utils'
import {Content} from 'web/components/widgets/editor'
import {JSONContent} from '@tiptap/core'
import {VoteButtons} from 'web/components/votes/vote-buttons'
import {getVoteCreator} from "web/lib/supabase/votes";
import {useEffect, useState} from "react";
import Link from "next/link";
export type Vote = rowFor<'votes'> & {
votes_for: number
votes_against: number
votes_abstain: number
priority: number
}
export function VoteItem(props: {
vote: Vote
onVoted?: () => void | Promise<void>
}) {
const {vote, onVoted} = props
const [creator, setCreator] = useState<any>(null)
useEffect(() => {
getVoteCreator(vote.creator_id).then(setCreator)
}, [vote.creator_id])
// console.debug('creator', creator)
return (
<Col className={'mb-4 rounded-lg border border-ink-200 p-4'}>
<Row className={'mb-2'}>
<Col className={'flex-grow'}>
<p className={'text-2xl'}>{vote.title}</p>
<Col className='text-sm text-gray-500 italic'>
<Content className="w-full" content={vote.description as JSONContent}/>
</Col>
<Row className={'gap-2 mt-2 items-center justify-between w-full customlink flex-wrap'}>
{!!vote.priority ? <div>Priority: {vote.priority.toFixed(0)}%</div> : <p></p>}
{!vote.is_anonymous && creator?.username && <Link href={`/${creator.username}`} className="customlink">{creator.username}</Link>}
</Row>
<VoteButtons
voteId={vote.id}
counts={{
for: vote.votes_for,
abstain: vote.votes_abstain,
against: vote.votes_against,
}}
onVoted={onVoted}
/>
</Col>
</Row>
</Col>
)
}

View File

@@ -9,7 +9,7 @@ export const Card = forwardRef(function Card(
return (
<div
className={clsx(
'bg-canvas-0 border-ink-300 cursor-pointer rounded-lg border transition-shadow hover:shadow-md focus:shadow-md',
'bg-canvas-0 border-ink-300 rounded-lg border transition-shadow hover:shadow-md focus:shadow-md',
className
)}
ref={ref}

View File

@@ -28,6 +28,22 @@ function cumulativeFromCounts(counts: Record<string, number>, sortedDates: strin
export default function ChartMembers() {
const [data, setData] = useState<any[]>([])
const [chartHeight, setChartHeight] = useState<number>(400)
useEffect(() => {
// Set responsive chart height: 300px on small widths, 400px otherwise
function applyHeight() {
if (typeof window !== 'undefined') {
const isSmall = window.innerWidth < 420
setChartHeight(isSmall ? 320 : 400)
}
}
applyHeight()
window.addEventListener('resize', applyHeight)
return () => window.removeEventListener('resize', applyHeight)
}, [])
useEffect(() => {
async function load() {
const [allProfiles, bioProfiles] = await Promise.all([
@@ -92,49 +108,64 @@ export default function ChartMembers() {
// One LineChart with two Line series sharing the same data array
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
{/*<CartesianGrid strokeDasharray="3 3"/>*/}
<XAxis
dataKey="dateTs"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts) => new Date(ts).toISOString().split("T")[0]}
label={{value: "Date", position: "insideBottomRight", offset: -5}}
/>
<YAxis label={{value: "Number of Members", angle: -90, position: "insideLeft"}}/>
<Tooltip
contentStyle={{
backgroundColor: "rgb(var(--color-canvas-100))",
border: "none",
borderRadius: "8px",
color: "rgb(var(--color-primary-900))",
}}
labelStyle={{
color: "rgb(var(--color-primary-900))",
}}
labelFormatter={(value, payload) => (payload && payload[0] && payload[0].payload?.date) || new Date(value as number).toISOString().split("T")[0]}
/>
<Legend/>
<Line
type="monotone"
dataKey="profilesCreations"
name="Total"
stroke="rgb(var(--color-primary-900))"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="profilesWithBioCreations"
name="With Bio"
stroke="#9ca3af"
strokeDasharray="4 2"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
<div>
<ResponsiveContainer width="100%" height={chartHeight}>
<LineChart data={data} margin={{top: 24, right: 16, bottom: 24, left: -20}}>
<text
x="50%"
y="24"
textAnchor="middle"
dominantBaseline="middle"
style={{
fontSize: "16px",
fontWeight: 600,
fill: "rgb(var(--color-primary-900))",
}}
>
Number of Members
</text>
{/*<CartesianGrid strokeDasharray="3 3"/>*/}
<XAxis
dataKey="dateTs"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts) => new Date(ts).toISOString().split("T")[0]}
label={{value: "Date", position: "insideBottomRight", offset: -5}}
/>
<YAxis/>
<Tooltip
contentStyle={{
backgroundColor: "rgb(var(--color-canvas-100))",
border: "none",
borderRadius: "8px",
color: "rgb(var(--color-primary-900))",
}}
labelStyle={{
color: "rgb(var(--color-primary-900))",
}}
labelFormatter={(value, payload) => (payload && payload[0] && payload[0].payload?.date) || new Date(value as number).toISOString().split("T")[0]}
/>
<Legend/>
<Line
type="monotone"
dataKey="profilesCreations"
name="Total"
stroke="rgb(var(--color-primary-900))"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="profilesWithBioCreations"
name="With Bio"
stroke="#9ca3af"
strokeDasharray="4 2"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -1,4 +1,7 @@
import clsx from 'clsx'
import {useEffect, useRef} from 'react'
import FavIcon from "web/public/FavIcon";
export type SpinnerSize = 'sm' | 'md' | 'lg'
function getSizeClass(size: SpinnerSize) {
@@ -32,3 +35,55 @@ export function LoadingIndicator(props: {
</div>
)
}
export function CompassLoadingIndicator(props: {
className?: string
spinnerClassName?: string
size?: 'sm' | 'md' | 'lg'
}) {
const {className, spinnerClassName} = props
const compassRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = compassRef.current
if (!el) return
let angle = 0
let timeoutId: number
const randomTurn = () => {
// Randomly choose direction and angle
const direction = Math.random() > 0.5 ? 1 : -1
const delta = Math.random() * 75 + 5
angle = direction * delta
el.style.transform = `rotate(${angle}deg)`
// Random delay before next movement
const delay = Math.random() * 400 + 400
timeoutId = window.setTimeout(randomTurn, delay)
}
randomTurn()
return () => clearTimeout(timeoutId)
}, [])
return (
<div className={clsx('flex items-center justify-center mt-8', className)}>
<div
ref={compassRef}
className={clsx(
'inline-block transition-transform duration-700 ease-in-out',
// getSizeClass(size),
spinnerClassName
)}
role="status"
>
<FavIcon className="dark:invert w-20 h-20"/>
</div>
</div>
)
}

View File

@@ -27,7 +27,7 @@ export function RadioToggleGroup(props: {
const length = orderedChoicesMap.length
return (
<Row className="text-ink-300 dark:text-ink-600 mb-6 items-center gap-3 text-sm">
<Row className="text-ink-500 dark:text-ink-500 mb-6 items-center gap-3 text-sm">
{orderedChoicesMap[0][0]}
<RadioGroup
className={clsx(

View File

@@ -52,7 +52,7 @@ export function SearchableSelect(props: {
return (
<Popover className={clsx('relative', parentClassName)}>
{({ open, close }) => (
{({ open: _open, close }) => (
<>
<Popover.Button
ref={setReferenceElement}

View File

@@ -0,0 +1,38 @@
import {useState, ReactNode} from 'react'
interface ShowMoreProps {
labelClosed?: string
labelOpen?: string
children: ReactNode
className?: string
}
export function ShowMore(props: ShowMoreProps) {
const {labelClosed = 'Show more', labelOpen = 'Hide', children, className} = props
const [showMoreInfo, setShowMoreInfo] = useState(false)
return (
<div className={`mt-2 mb-4 ${className ?? ''}`}>
<button
type="button"
onClick={() => setShowMoreInfo(!showMoreInfo)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"
>
{showMoreInfo ? labelOpen : labelClosed}
<svg
className={`w-4 h-4 ml-1 transition-transform ${showMoreInfo ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
</svg>
</button>
{showMoreInfo && (
<div className="mt-2 p-3 rounded-md">
{children}
</div>
)}
</div>
)
}

View File

@@ -52,7 +52,7 @@ export const StarButton = (props: {
>
<StarIcon
className={clsx(
'h-10 w-10 transition-colors group-hover:fill-yellow-400/70',
'h-8 w-8 transition-colors group-hover:fill-yellow-400/70',
isStarred &&
'fill-yellow-400 stroke-yellow-500 dark:stroke-yellow-600'
)}
@@ -63,7 +63,7 @@ export const StarButton = (props: {
if (hideTooltip) return button
return (
<Tooltip text={isStarred ? 'Remove star' : 'Add star'} noTap>
<Tooltip text={isStarred ? 'Unsave Profile' : 'Save Profile'} noTap>
{button}
</Tooltip>
)

View File

@@ -0,0 +1,52 @@
import clsx from 'clsx'
import { Card } from './card'
import { ReactNode } from 'react'
export type StatBoxProps = {
// The main numeric/stat value to display large and centered
value: string | number
// The short label/caption shown below the value
label?: ReactNode
// Optional additional content (e.g., sublabel) shown below the label
children?: ReactNode
// Additional classes for the outer Card wrapper
className?: string
// Control the size of the number (default: xl)
size?: 'lg' | 'xl' | '2xl' | '3xl'
}
/**
* A box component that displays a large number in the center with a few words below it.
* It composes the shared Card style for visual consistency.
*/
export function StatBox(props: StatBoxProps) {
const { value, label, children, className, size = '2xl' } = props
const sizeClass =
size === '3xl'
? 'text-6xl'
: size === '2xl'
? 'text-5xl'
: size === 'xl'
? 'text-4xl'
: 'text-3xl'
return (
<Card
className={clsx(
'flex h-full w-full flex-col items-center justify-center gap-2 p-6 text-center',
className
)}
>
<div className={clsx('font-semibold leading-none tracking-tight', sizeClass)}>
{value}
</div>
{label && (
<div className="text-ink-700 text-sm">{label}</div>
)}
{children}
</Card>
)
}
export default StatBox

View File

@@ -1,14 +1,13 @@
import Link from 'next/link'
import clsx from 'clsx'
import { VERIFIED_USERNAMES, MOD_IDS } from 'common/envs/constants'
import { SparklesIcon } from '@heroicons/react/solid'
import { Tooltip } from './tooltip'
import { BadgeCheckIcon, ShieldCheckIcon } from '@heroicons/react/outline'
import { Row } from '../layout/row'
import { Avatar } from './avatar'
import { DAY_MS } from 'common/util/time'
import { linkClass } from './site-link'
import { Col } from 'web/components/layout/col'
import {MOD_IDS, VERIFIED_USERNAMES} from 'common/envs/constants'
import {SparklesIcon} from '@heroicons/react/solid'
import {Tooltip} from './tooltip'
import {BadgeCheckIcon, ShieldCheckIcon} from '@heroicons/react/outline'
import {Row} from '../layout/row'
import {Avatar} from './avatar'
import {DAY_MS} from 'common/util/time'
import {linkClass} from './site-link'
export const isFresh = (createdTime: number) =>
createdTime > Date.now() - DAY_MS * 14
@@ -16,15 +15,13 @@ export const isFresh = (createdTime: number) =>
export function shortenName(name: string) {
const firstName = name.split(' ')[0]
const maxLength = 10
const shortName =
firstName.length >= 3 && name.length > maxLength
? firstName.length < maxLength
? firstName
: firstName.substring(0, maxLength - 3) + '...'
: name.length > maxLength
return firstName.length >= 3 && name.length > maxLength
? firstName.length < maxLength
? firstName
: firstName.substring(0, maxLength - 3) + '...'
: name.length > maxLength
? name.substring(0, maxLength - 3) + '...'
: name
return shortName
}
export function UserAvatarAndBadge(props: {
@@ -32,8 +29,8 @@ export function UserAvatarAndBadge(props: {
noLink?: boolean
className?: string
}) {
const { user, noLink, className } = props
const { username, avatarUrl } = user
const {user, noLink, className} = props
const {username, avatarUrl} = user
return (
<Row className={clsx('items-center gap-2', className)}>
@@ -43,7 +40,7 @@ export function UserAvatarAndBadge(props: {
size={'sm'}
noLink={noLink}
/>
<UserLink user={user} noLink={noLink} />
<UserLink user={user} noLink={noLink}/>
</Row>
)
}
@@ -57,7 +54,7 @@ export function UserLink(props: {
hideBadge?: boolean
}) {
const {
user: { id, name, username },
user: {id, name, username},
className,
short,
noLink,
@@ -70,7 +67,7 @@ export function UserLink(props: {
<>
<span className="max-w-[200px] truncate">{shortName}</span>
{!hideBadge && (
<UserBadge userId={id} username={username} fresh={fresh} />
<UserBadge userId={id} username={username} fresh={fresh}/>
)}
</>
)
@@ -98,13 +95,13 @@ export function UserLink(props: {
)
}
function BotBadge() {
return (
<span className="bg-ink-100 text-ink-800 ml-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-medium">
Bot
</span>
)
}
// function BotBadge() {
// return (
// <span className="bg-ink-100 text-ink-800 ml-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-medium">
// Bot
// </span>
// )
// }
export function BannedBadge() {
return (
@@ -112,7 +109,8 @@ export function BannedBadge() {
text="Can't create comments, messages, or questions"
placement="bottom"
>
<span className="ml-1.5 rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-700 dark:text-yellow-100">
<span
className="ml-1.5 rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-700 dark:text-yellow-100">
Banned
</span>
</Tooltip>
@@ -124,17 +122,17 @@ export function UserBadge(props: {
username: string
fresh?: boolean
}) {
const { userId, username, fresh } = props
const {userId, username, fresh} = props
const badges = []
if (MOD_IDS.includes(userId)) {
badges.push(<ModBadge key="mod" />)
badges.push(<ModBadge key="mod"/>)
}
if (VERIFIED_USERNAMES.includes(username)) {
badges.push(<VerifiedBadge key="check" />)
badges.push(<VerifiedBadge key="check"/>)
}
if (fresh) {
badges.push(<FreshBadge key="fresh" />)
badges.push(<FreshBadge key="fresh"/>)
}
return <>{badges}</>
}
@@ -155,7 +153,7 @@ function ModBadge() {
function VerifiedBadge() {
return (
<Tooltip text="Verified" placement="right">
<BadgeCheckIcon className="text-primary-700 h-4 w-4" aria-hidden />
<BadgeCheckIcon className="text-primary-700 h-4 w-4" aria-hidden/>
</Tooltip>
)
}
@@ -164,51 +162,51 @@ function VerifiedBadge() {
function FreshBadge() {
return (
<Tooltip text="I'm new here!" placement="right">
<SparklesIcon className="h-4 w-4 text-green-500" aria-hidden="true" />
<SparklesIcon className="h-4 w-4 text-green-500" aria-hidden="true"/>
</Tooltip>
)
}
export const StackedUserNames = (props: {
user: {
id: string
name: string
username: string
createdTime: number
isBannedFromPosting?: boolean
}
followsYou?: boolean
className?: string
usernameClassName?: string
}) => {
const { user, followsYou, usernameClassName, className } = props
return (
<Col>
<div className={'inline-flex flex-row items-center gap-1 pt-1'}>
<span className={clsx('break-anywhere ', className)}>{user.name}</span>
{
<UserBadge
userId={user.id}
username={user.username}
fresh={isFresh(user.createdTime)}
/>
}
{user.isBannedFromPosting && <BannedBadge />}
</div>
<Row className={'flex-shrink flex-wrap gap-x-2'}>
<span className={clsx('text-ink-400 text-sm', usernameClassName)}>
@{user.username}{' '}
</span>
{followsYou && (
<span
className={
'bg-ink-200 w-fit self-center rounded-md p-0.5 px-1 text-xs'
}
>
Follows you
</span>
)}
</Row>
</Col>
)
}
// export const StackedUserNames = (props: {
// user: {
// id: string
// name: string
// username: string
// createdTime: number
// isBannedFromPosting?: boolean
// }
// followsYou?: boolean
// className?: string
// usernameClassName?: string
// }) => {
// const {user, followsYou, usernameClassName, className} = props
// return (
// <Col>
// <div className={'inline-flex flex-row items-center gap-1 pt-1'}>
// <span className={clsx('break-anywhere ', className)}>{user.name}</span>
// {
// <UserBadge
// userId={user.id}
// username={user.username}
// fresh={isFresh(user.createdTime)}
// />
// }
// {user.isBannedFromPosting && <BannedBadge/>}
// </div>
// <Row className={'flex-shrink flex-wrap gap-x-2'}>
// <span className={clsx('text-ink-400 text-sm', usernameClassName)}>
// @{user.username}{' '}
// </span>
// {followsYou && (
// <span
// className={
// 'bg-ink-200 w-fit self-center rounded-md p-0.5 px-1 text-xs'
// }
// >
// Follows you
// </span>
// )}
// </Row>
// </Col>
// )
// }

View File

@@ -1,9 +1,10 @@
import { useEffect } from 'react'
import {useEffect} from 'react'
import { APIParams, APIPath, APIResponse } from 'common/api/schema'
import { usePersistentInMemoryState } from './use-persistent-in-memory-state'
import { APIError, api } from 'web/lib/api'
import { useEvent } from './use-event'
import {APIParams, APIPath, APIResponse} from 'common/api/schema'
import {usePersistentInMemoryState} from './use-persistent-in-memory-state'
import {api} from 'web/lib/api'
import {useEvent} from './use-event'
import {APIError} from "common/api/utils";
const promiseCache: Record<string, Promise<any> | undefined> = {}
@@ -45,7 +46,7 @@ export const useAPIGetter = <P extends APIPath>(
refresh()
}, [propsStringToTriggerRefresh])
return { data, error, refresh }
return {data, error, refresh}
}
function deepCopyWithoutKeys(obj: any, keysToRemove: string[]): any {

View File

@@ -1,6 +1,6 @@
import { unauthedApi } from 'common/util/api'
import { useEffect, useRef } from 'react'
import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'
import {useEffect, useRef} from 'react'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {api} from "web/lib/api";
export function useNearbyCities(
referenceCityId: string | null | undefined,
@@ -15,7 +15,7 @@ export function useNearbyCities(
searchCount.current++
const thisSearchCount = searchCount.current
if (referenceCityId) {
unauthedApi('search-near-city', {
api('search-near-city', {
cityId: referenceCityId,
radius,
}).then((result) => {

View File

@@ -1,18 +1,23 @@
import { useEffect } from 'react'
import { useProfile } from 'web/hooks/use-profile'
import { useIsAuthorized } from 'web/hooks/use-user'
import { run } from 'common/supabase/utils'
import { db } from 'web/lib/supabase/db'
import {useEffect} from 'react'
import {useProfile} from 'web/hooks/use-profile'
import {useIsAuthorized} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
export const useOnline = () => {
const profile = useProfile()
const isAuthed = useIsAuthorized()
useEffect(() => {
if (!profile || !isAuthed) return
run(
db
.from('profiles')
.update({ last_online_time: new Date().toISOString() })
.eq('id', profile.id)
)
}, [])
void (async () => {
const date = new Date().toISOString()
// const result = await run(
// db
// .from('profiles')
// .update({ last_online_time: date })
// .eq('id', profile.id)
// )
api('set-last-online-time')
// console.log('set last online time for', profile.id, date)
})()
}, [profile?.id, isAuthed])
}

View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react'
import { db } from 'web/lib/supabase/db'
import { run } from 'common/supabase/utils'
import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state'
import { UserActivity } from 'common/user'
export function useUserActivity(userId: string | undefined) {
const [userActivity, setUserActivity] = usePersistentInMemoryState<
UserActivity | undefined
>(undefined, `user-activity-${userId ?? 'none'}`)
const refresh = async () => {
if (!userId) return
const { data } = await run(
db.from('user_activity')
.select('*')
.eq('user_id', userId).limit(1).single()
)
if (data) setUserActivity(data as unknown as UserActivity)
}
useEffect(() => {
refresh().catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId])
return { data: userActivity, refresh }
}

View File

@@ -1,8 +1,7 @@
import { API, APIParams, APIPath } from 'common/api/schema'
import { typedAPICall } from 'common/util/api'
import { sleep } from 'common/util/time'
import { auth } from './firebase/users'
export { APIError } from 'common/api/utils'
import {API, APIParams, APIPath} from 'common/api/schema'
import {typedAPICall} from 'common/util/api'
import {sleep} from 'common/util/time'
import {auth} from './firebase/users'
export async function api<P extends APIPath>(
path: P,
@@ -14,11 +13,12 @@ export async function api<P extends APIPath>(
while (!auth.currentUser) {
i++
await sleep(i * 10)
if (i > 10) {
console.error('User did not load after 10 iterations')
if (i > 300) {
console.error('User did not load after 300 iterations')
break
}
}
console.debug('User loaded after', i, 'iterations')
}
return typedAPICall(path, params, auth.currentUser)

View File

@@ -1,5 +1,6 @@
import { run } from 'common/supabase/utils'
import { db } from 'web/lib/supabase/db'
import {DisplayUser} from "common/api/user-types";
export const getStars = async (creatorId: string) => {
const { data } = await run(
@@ -12,5 +13,13 @@ export const getStars = async (creatorId: string) => {
if (!data) return []
return data.map((d) => d.target_id as string)
const ids = data.map((d) => d.target_id as string)
const {data: users} = await run(
db
.from('users')
.select(`id, name, username`)
.in('id', ids)
)
return users as unknown as DisplayUser[]
}

View File

@@ -1,9 +1,11 @@
import {db} from './db'
import {run} from 'common/supabase/utils'
import {APIError, api} from 'web/lib/api'
import {api} from 'web/lib/api'
import {unauthedApi} from 'common/util/api'
import type {DisplayUser} from 'common/api/user-types'
import {MIN_BIO_LENGTH} from "common/constants";
import {MONTH_MS} from "common/util/time";
import {APIError} from "common/api/utils";
export type {DisplayUser}
@@ -43,7 +45,7 @@ export async function getFullUserById(id: string) {
}
export async function searchUsers(prompt: string, limit: number) {
return unauthedApi('search-users', {term: prompt, limit: limit})
return api('search-users', {term: prompt, limit: limit})
}
export async function getDisplayUsers(userIds: string[]) {
@@ -75,4 +77,30 @@ export async function getProfilesWithBioCreations() {
.order('created_time')
)
return data
}
}
export async function getCount(table: string) {
if (table == 'private_user_messages') {
const result = await api('get-messages-count')
return result.count
}
if (table == 'active_members') {
const {count} = await run(
db
.from('user_activity')
.select('*', {count: 'exact', head: true})
.gt('last_online_time', new Date(Date.now() - MONTH_MS).toISOString()) // last month
)
return count;
}
const {count} = await run(
db
.from(table)
.select('*', {count: 'exact', head: true})
)
return count;
}
// export async function getNumberProfiles() {
// return await getCount('profiles');
// }

26
web/lib/supabase/votes.ts Normal file
View File

@@ -0,0 +1,26 @@
import {run} from 'common/supabase/utils'
import {db} from 'web/lib/supabase/db'
import {OrderBy} from "common/votes/constants";
export const getVotes = async (params: { orderBy: OrderBy }) => {
const { orderBy } = params
const {data, error} = await db.rpc('get_votes_with_results' as any, {
order_by: orderBy,
});
if (error) throw error;
return data
}
export const getVoteCreator = async (creatorId: string) => {
const {data} = await run(
db
.from('users')
.select(`id, name, username`)
.eq('id', creatorId)
.limit(1)
)
return data[0]
}

View File

@@ -5,11 +5,12 @@ import { getProfileRow } from 'common/love/profile'
export const signupThenMaybeRedirectToSignup = async () => {
const creds = await firebaseLogin()
await Router.push('/')
const userId = creds?.user.uid
if (userId) {
const profile = await getProfileRow(userId, db)
if (!profile) {
if (profile) {
await Router.push('/')
} else {
await Router.push('/signup')
}
}

View File

@@ -3,7 +3,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
export function fromNow(time: number) {
export function fromNow(time: number | string | Date) {
return dayjs(time).fromNow()
}

View File

@@ -69,6 +69,12 @@ module.exports = {
return config
},
async redirects() {
return []
return [
{ source: '/discord', destination: 'https://discord.gg/8Vd7jzqjun', permanent: false },
{ source: '/patreon', destination: 'https://patreon.com/CompassMeet', permanent: false },
{ source: '/paypal', destination: 'https://www.paypal.com/paypalme/CompassConnections', permanent: false },
{ source: '/github', destination: "https://github.com/CompassConnections/Compass", permanent: false },
{ source: '/charts', destination: "/stats", permanent: true },
];
},
}

View File

@@ -1,12 +1,9 @@
import {useState} from 'react'
import Router from 'next/router'
import Head from 'next/head'
import {useRouter} from 'next/router'
import Head from 'next/head'
import {LovePage} from 'web/components/love-page'
import {useProfileByUser} from 'web/hooks/use-profile'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {SEO} from 'web/components/SEO'
import {useUser} from 'web/hooks/use-user'
import {useTracking} from 'web/hooks/use-tracking'
@@ -19,6 +16,7 @@ import {ProfileInfo} from 'web/components/profile/profile-info'
import {User} from 'common/user'
import {getUserForStaticProps} from 'common/supabase/users'
import {type GetStaticProps} from 'next'
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
export const getStaticProps: GetStaticProps<
UserPageProps,
@@ -124,7 +122,7 @@ function UserPageInner(props: ActiveUserPageProps) {
const fromSignup = query.fromSignup === 'true'
const currentUser = useUser()
const isCurrentUser = currentUser?.id === user?.id
// const isCurrentUser = currentUser?.id === user?.id
useSaveReferral(currentUser, {defaultReferrerUsername: username})
useTracking('view love profile', {username: user?.username})
@@ -166,24 +164,8 @@ function UserPageInner(props: ActiveUserPageProps) {
refreshProfile={refreshProfile}
fromSignup={fromSignup}
/>
) : isCurrentUser ? (
<Col className={'mt-4 w-full items-center'}>
<Row>
<Button onClick={() => Router.push('/signup')}>
Create a profile
</Button>
</Row>
</Col>
) : (
<Col className="bg-canvas-0 rounded p-4 ">
<div>{user.name} hasn't created a profile yet.</div>
<Button
className="mt-4 self-start"
onClick={() => Router.push('/')}
>
See more profiles
</Button>
</Col>
<CompassLoadingIndicator/>
)}
</Col>
)}

View File

@@ -61,7 +61,7 @@ export default function About() {
<AboutBlock
title="Democratic"
text={<span
className="customlink">Governed by the community, while ensuring no drift through our <Link
className="customlink">Governed and <Link href="/vote">voted</Link> by the community, while ensuring no drift through our <Link
href="/constitution">constitution</Link>.</span>}
/>

View File

@@ -1,13 +0,0 @@
import {LovePage} from "web/components/love-page";
import ChartMembers from "web/components/widgets/charts";
export default function Charts() {
return (
<LovePage
trackPageView={'charts'}
>
<h1 className="text-3xl font-semibold text-center mb-6">Community Growth over Time</h1>
<ChartMembers/>
</LovePage>
);
}

20
web/pages/contact.tsx Normal file
View File

@@ -0,0 +1,20 @@
import {LovePage} from 'web/components/love-page'
import {SEO} from 'web/components/SEO'
import {ContactComponent} from "web/components/contact";
export default function ContactPage() {
return (
<LovePage
trackPageView={'vote page'}
className={'relative p-2 sm:pt-0'}
>
<SEO
title={`Contact`}
description={'Contact us'}
url={`/contact`}
/>
<ContactComponent/>
</LovePage>
)
}

10
web/pages/loading.tsx Normal file
View File

@@ -0,0 +1,10 @@
"use client";
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
import {LovePage} from "web/components/love-page";
export default function Loading() {
return <LovePage trackPageView={'loading'}>
<CompassLoadingIndicator/>
</LovePage>;
}

View File

@@ -1,65 +1,53 @@
import { LovePage } from 'web/components/love-page'
import { useRouter } from 'next/router'
import {
usePrivateMessages,
useSortedPrivateMessageMemberships,
} from 'web/hooks/use-private-messages'
import { Col } from 'web/components/layout/col'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users'
import { uniq } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { useTextEditor } from 'web/components/widgets/editor'
import { api } from 'web/lib/api'
import {
ChatMessageItem,
SystemChatMessageItem,
} from 'web/components/chat/chat-message'
import { CommentInputTextArea } from 'web/components/comments/comment-input'
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
import { DAY_MS, YEAR_MS } from 'common/util/time'
import { useUsersInStore } from 'web/hooks/use-user-supabase'
import { Row } from 'web/components/layout/row'
import {LovePage} from 'web/components/love-page'
import {useRouter} from 'next/router'
import {usePrivateMessages, useSortedPrivateMessageMemberships,} from 'web/hooks/use-private-messages'
import {Col} from 'web/components/layout/col'
import {User} from 'common/user'
import {useEffect, useState} from 'react'
import {track} from 'web/lib/service/analytics'
import {firebaseLogin} from 'web/lib/firebase/users'
import {uniq} from 'lodash'
import {useUser} from 'web/hooks/use-user'
import {useTextEditor} from 'web/components/widgets/editor'
import {api} from 'web/lib/api'
import {ChatMessageItem, SystemChatMessageItem,} from 'web/components/chat/chat-message'
import {CommentInputTextArea} from 'web/components/comments/comment-input'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {DAY_MS, YEAR_MS} from 'common/util/time'
import {useUsersInStore} from 'web/hooks/use-user-supabase'
import {Row} from 'web/components/layout/row'
import clsx from 'clsx'
import { useRedirectIfSignedOut } from 'web/hooks/use-redirect-if-signed-out'
import { MultipleOrSingleAvatars } from 'web/components/multiple-or-single-avatars'
import { Modal, MODAL_CLASS } from 'web/components/layout/modal'
import {
BannedBadge,
UserAvatarAndBadge,
} from 'web/components/widgets/user-link'
import {useRedirectIfSignedOut} from 'web/hooks/use-redirect-if-signed-out'
import {MultipleOrSingleAvatars} from 'web/components/multiple-or-single-avatars'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {BannedBadge, UserAvatarAndBadge,} from 'web/components/widgets/user-link'
import DropdownMenu from 'web/components/comments/dropdown-menu'
import { DotsVerticalIcon } from '@heroicons/react/solid'
import { FaUserFriends, FaUserMinus } from 'react-icons/fa'
import { buildArray, filterDefined } from 'common/util/array'
import { GiSpeakerOff } from 'react-icons/gi'
import {DotsVerticalIcon} from '@heroicons/react/solid'
import {FaUserFriends, FaUserMinus} from 'react-icons/fa'
import {buildArray, filterDefined} from 'common/util/array'
import {GiSpeakerOff} from 'react-icons/gi'
import toast from 'react-hot-toast'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import {
useGroupedMessages,
usePaginatedScrollingMessages,
} from 'web/lib/supabase/chat-messages'
import { PrivateMessageChannel } from 'common/supabase/private-messages'
import { ChatMessage } from 'common/chat-message'
import { BackButton } from 'web/components/back-button'
import {useIsMobile} from 'web/hooks/use-is-mobile'
import {useGroupedMessages, usePaginatedScrollingMessages,} from 'web/lib/supabase/chat-messages'
import {PrivateMessageChannel} from 'common/supabase/private-messages'
import {ChatMessage} from 'common/chat-message'
import {BackButton} from 'web/components/back-button'
export default function PrivateMessagesPage() {
const router = useRouter()
const { channelId: channelIdString } = router.query as { channelId: string }
const {channelId: channelIdString} = router.query as { channelId: string }
const channelId = router.isReady ? parseInt(channelIdString) : undefined
const user = useUser()
if (user === null) {
router.replace(`/signin?returnTo=${encodeURIComponent('/messages')}`)
return <LoadingIndicator />
return <CompassLoadingIndicator/>
}
return (
<LovePage trackPageView={'private messages page'}>
{router.isReady && channelId && user ? (
<PrivateMessagesContent user={user} channelId={channelId} />
<PrivateMessagesContent user={user} channelId={channelId}/>
) : (
<LoadingIndicator />
<CompassLoadingIndicator/>
)}
</LovePage>
)
@@ -71,13 +59,13 @@ export function PrivateMessagesContent(props: {
}) {
useRedirectIfSignedOut()
const { channelId, user } = props
const {channelId, user} = props
const channelMembership = useSortedPrivateMessageMemberships(
user.id,
1,
channelId
)
const { channels, memberIdsByChannelId } = channelMembership
const {channels, memberIdsByChannelId} = channelMembership
const thisChannel = channels?.find((c) => c.channel_id == channelId)
const loaded = channels !== undefined && channelId
const memberIds = thisChannel
@@ -87,9 +75,9 @@ export function PrivateMessagesContent(props: {
return (
<>
{user && loaded && thisChannel && memberIds ? (
<PrivateChat channel={thisChannel} user={user} memberIds={memberIds} />
<PrivateChat channel={thisChannel} user={user} memberIds={memberIds}/>
) : (
<LoadingIndicator />
<CompassLoadingIndicator/>
)}
</>
)
@@ -100,7 +88,7 @@ export const PrivateChat = (props: {
channel: PrivateMessageChannel
memberIds: string[]
}) => {
const { user, channel, memberIds } = props
const {user, channel, memberIds} = props
const channelId = channel.channel_id
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
const isMobile = useIsMobile()
@@ -132,7 +120,7 @@ export const PrivateChat = (props: {
)
const router = useRouter()
const { topVisibleRef, showMessages, messages, innerDiv, outerDiv } =
const {topVisibleRef, showMessages, messages, innerDiv, outerDiv} =
usePaginatedScrollingMessages(
realtimeMessages?.map(
(m) =>
@@ -193,7 +181,7 @@ export const PrivateChat = (props: {
'border-ink-200 bg-canvas-50 h-14 items-center gap-1 border-b'
}
>
<BackButton className="self-stretch" />
<BackButton className="self-stretch"/>
<MultipleOrSingleAvatars
size="sm"
spacing={0.5}
@@ -219,22 +207,22 @@ export const PrivateChat = (props: {
)}
{members?.length == 1 && members[0].isBannedFromPosting && (
<BannedBadge />
<BannedBadge/>
)}
<DropdownMenu
className={'ml-auto [&_button]:p-4'}
menuWidth={'w-44'}
icon={<DotsVerticalIcon className="h-5 w-5" />}
icon={<DotsVerticalIcon className="h-5 w-5"/>}
items={buildArray(
{
icon: <FaUserFriends className={'h-5 w-5'} />,
icon: <FaUserFriends className={'h-5 w-5'}/>,
name: 'See members',
onClick: () => {
setShowUsers(true)
},
},
{
icon: <GiSpeakerOff className="h-5 w-5" />,
icon: <GiSpeakerOff className="h-5 w-5"/>,
name: 'Mute 1 day',
onClick: async () => {
await toast.promise(
@@ -251,7 +239,7 @@ export const PrivateChat = (props: {
},
},
{
icon: <GiSpeakerOff className="h-5 w-5" />,
icon: <GiSpeakerOff className="h-5 w-5"/>,
name: 'Mute forever',
onClick: async () => {
await toast.promise(
@@ -268,7 +256,7 @@ export const PrivateChat = (props: {
},
},
{
icon: <FaUserMinus className="h-5 w-5" />,
icon: <FaUserMinus className="h-5 w-5"/>,
name: 'Leave chat',
onClick: async () => {
await api('leave-private-user-message-channel', {
@@ -287,7 +275,7 @@ export const PrivateChat = (props: {
key={user.id}
className={'w-full items-center justify-start gap-2'}
>
<UserAvatarAndBadge user={user} />
<UserAvatarAndBadge user={user}/>
</Row>
))}
</Col>
@@ -311,13 +299,13 @@ export const PrivateChat = (props: {
}}
>
{realtimeMessages === undefined ? (
<LoadingIndicator />
<CompassLoadingIndicator/>
) : (
<>
<div
className={'absolute h-1 '}
ref={topVisibleRef}
style={{ top: heightFromTop }}
style={{top: heightFromTop}}
/>
{groupedMessages.map((messages, i) => {
const firstMessage = messages[0]
@@ -383,5 +371,5 @@ export const PrivateChat = (props: {
}
const setAsSeen = async (channelId: number) => {
return api('set-channel-seen-time', { channelId })
return api('set-channel-seen-time', {channelId})
}

View File

@@ -1,39 +1,37 @@
import {
NOTIFICATIONS_PER_PAGE,
NOTIFICATION_TYPES_TO_SELECT,
type Notification,
} from 'common/notifications'
import { PrivateUser, type User } from 'common/src/user'
import {type Notification, NOTIFICATION_TYPES_TO_SELECT, NOTIFICATIONS_PER_PAGE,} from 'common/notifications'
import {PrivateUser, type User} from 'common/src/user'
import {
notification_destination_types,
notification_preference,
notification_preferences,
} from 'common/user-notification-preferences'
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { NoSEO } from 'web/components/NoSEO'
import { Col } from 'web/components/layout/col'
import { UncontrolledTabs } from 'web/components/layout/tabs'
import { LovePage } from 'web/components/love-page'
import { NotificationItem } from 'web/components/notification-items'
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
import { Pagination } from 'web/components/widgets/pagination'
import { Title } from 'web/components/widgets/title'
import { useGroupedNotifications } from 'web/hooks/use-notifications'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { api } from 'web/lib/api'
import { MultiSelectAnswers } from 'web/components/answers/answer-compatibility-question-content'
import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state'
import { debounce } from 'lodash'
import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'
import {NoSEO} from 'web/components/NoSEO'
import {Col} from 'web/components/layout/col'
import {UncontrolledTabs} from 'web/components/layout/tabs'
import {LovePage} from 'web/components/love-page'
import {NotificationItem} from 'web/components/notification-items'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Pagination} from 'web/components/widgets/pagination'
import {Title} from 'web/components/widgets/title'
import {useGroupedNotifications} from 'web/hooks/use-notifications'
import {usePrivateUser, useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {MultiSelectAnswers} from 'web/components/answers/answer-compatibility-question-content'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {debounce} from 'lodash'
import {useRedirectIfSignedOut} from "web/hooks/use-redirect-if-signed-out";
export default function NotificationsPage() {
useRedirectIfSignedOut()
return (
<LovePage trackPageView={'notifications page'}>
<NoSEO />
<NoSEO/>
<Title>Updates</Title>
<UncontrolledTabs
tabs={[
{ title: 'Notifications', content: <NotificationsContent /> },
{ title: 'Settings', content: <NotificationSettings /> },
{title: 'Notifications', content: <NotificationsContent/>},
{title: 'Settings', content: <NotificationSettings/>},
]}
trackingName={'notifications page'}
/>
@@ -43,15 +41,15 @@ export default function NotificationsPage() {
const NotificationsContent = () => {
const user = useUser()
if (!user) return <LoadingIndicator />
return <LoadedNotificationsContent user={user} />
if (!user) return <CompassLoadingIndicator/>
return <LoadedNotificationsContent user={user}/>
}
function LoadedNotificationsContent(props: { user: User }) {
const { user } = props
const {user} = props
const privateUser = usePrivateUser()
const { groupedNotifications, mostRecentNotification } =
const {groupedNotifications, mostRecentNotification} =
useGroupedNotifications(user, NOTIFICATION_TYPES_TO_SELECT)
const [page, setPage] = useState(0)
@@ -65,7 +63,7 @@ function LoadedNotificationsContent(props: { user: User }) {
// Mark all notifications as seen. Rerun as new notifications come in.
useEffect(() => {
if (!privateUser) return
api('mark-all-notifs-read', { seen: true })
api('mark-all-notifs-read', {seen: true})
groupedNotifications
?.map((ng) => ng.notifications)
.flat()
@@ -79,7 +77,7 @@ function LoadedNotificationsContent(props: { user: User }) {
<Col className={'min-h-[100vh] gap-0 text-sm'}>
{groupedNotifications === undefined ||
paginatedGroupedNotifications === undefined ? (
<LoadingIndicator />
<CompassLoadingIndicator/>
) : paginatedGroupedNotifications.length === 0 ? (
<div className={'mt-2'}>You don't have any notifications, yet.</div>
) : (
@@ -107,15 +105,15 @@ function RenderNotificationGroups(props: {
page: number
setPage: (page: number) => void
}) {
const { notificationGroups, page, setPage, totalItems } = props
const {notificationGroups, page, setPage, totalItems} = props
return (
<>
{notificationGroups.map((notification) => {
return notification.notifications.map((notification: Notification) => (
<Fragment key={notification.id}>
<NotificationItem notification={notification} />
<div className="bg-ink-300 mx-2 box-border h-[1.5px]" />
<NotificationItem notification={notification}/>
<div className="bg-ink-300 mx-2 box-border h-[1.5px]"/>
</Fragment>
))
})}
@@ -136,11 +134,11 @@ function RenderNotificationGroups(props: {
const NotificationSettings = () => {
const privateUser = usePrivateUser()
if (!privateUser) return null
return <LoadedNotificationSettings privateUser={privateUser} />
return <LoadedNotificationSettings privateUser={privateUser}/>
}
const LoadedNotificationSettings = (props: { privateUser: PrivateUser }) => {
const { privateUser } = props
const {privateUser} = props
const [prefs, setPrefs] =
usePersistentInMemoryState<notification_preferences>(
@@ -195,14 +193,14 @@ const LoadedNotificationSettings = (props: { privateUser: PrivateUser }) => {
return (
<div className="mx-auto max-w-2xl">
<div className="flex flex-col gap-8 p-4">
{notificationTypes.map(({ type, question }) => (
{notificationTypes.map(({type, question}) => (
<NotificationOption
key={type}
type={type}
question={question}
selected={prefs[type]}
onUpdate={(selected) => {
setPrefs((prevPrefs) => ({ ...prevPrefs, [type]: selected }))
setPrefs((prevPrefs) => ({...prevPrefs, [type]: selected}))
}}
/>
))}
@@ -217,7 +215,7 @@ const NotificationOption = (props: {
selected: notification_destination_types[]
onUpdate: (selected: notification_destination_types[]) => void
}) => {
const { type, question, selected, onUpdate } = props
const {type, question, selected, onUpdate} = props
const getSelectedValues = (destinations: string[]) => {
const values: number[] = []

View File

@@ -1,24 +1,28 @@
import {LovePage} from 'web/components/love-page'
import {GeneralButton} from "web/components/buttons/general-button";
import clsx from "clsx";
import {Col} from "web/components/layout/col";
export default function Organization() {
return (
<LovePage trackPageView={'social'}>
<div className="text-gray-600 dark:text-white min-h-screen p-6">
<div className="w-full">
<div className="relative py-8 mt-12 overflow-hidden">
<div className="relative z-10 max-w-3xl mx-auto px-4">
<h3 className="text-4xl font-bold text-center mt-8 mb-8">Organization</h3>
<GeneralButton url={'/support'} content={'Support'}/>
<GeneralButton url={'/constitution'} content={'Constitution'}/>
<GeneralButton url={'/financials'} content={'Financials'}/>
<GeneralButton url={'/terms'} content={'Terms and Conditions'}/>
<GeneralButton url={'/privacy'} content={'Privacy Policy'}/>
</div>
</div>
</div>
</div>
<h3 className="text-4xl font-bold text-center mt-8 mb-8">Organization</h3>
<Col
className={clsx(
'pb-[58px] lg:pb-0', // bottom bar padding
'text-ink-1000 mx-auto w-full grid grid-cols-1 gap-8 max-w-3xl sm:grid-cols-2 lg:min-h-0 lg:pt-4 mt-4',
)}
>
<GeneralButton url={'/support'} content={'Support'}/>
<GeneralButton url={'/constitution'} content={'Constitution'}/>
<GeneralButton url={'/vote'} content={'Proposals'}/>
<GeneralButton url={'/financials'} content={'Financials'}/>
<GeneralButton url={'/stats'} content={'Growth & Stats'}/>
<GeneralButton url={'/terms'} content={'Terms and Conditions'}/>
<GeneralButton url={'/privacy'} content={'Privacy Policy'}/>
<GeneralButton url={'/contact'} content={'Contact'}/>
</Col>
</LovePage>
)
}

View File

@@ -44,8 +44,10 @@ function RegisterComponent() {
if (user) {
const profile = await getProfileRow(user.id, db)
if (profile) {
console.log("Router.push('/')")
await Router.push('/')
} else {
console.log("Router.push('/signup')")
await Router.push('/signup')
}
setIsLoading(false);

View File

@@ -1,13 +1,12 @@
import {useEffect, useState} from 'react'
import {useEffect, useRef, useState} from 'react'
import {Col} from 'web/components/layout/col'
import {initialRequiredState, RequiredLoveUserForm,} from 'web/components/required-profile-form'
import {OptionalLoveUserForm} from 'web/components/optional-profile-form'
import {useUser} from 'web/hooks/use-user'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {CACHED_REFERRAL_USERNAME_KEY,} from 'web/lib/firebase/users'
import {api} from 'web/lib/api'
import Router, {useRouter} from 'next/router'
import SiteLogo from 'web/components/site-logo'
import {useRouter} from 'next/router'
import {useTracking} from 'web/hooks/use-tracking'
import {track} from 'web/lib/service/analytics'
import {safeLocalStorage} from 'web/lib/util/local'
@@ -15,7 +14,6 @@ import {removeNullOrUndefinedProps} from 'common/util/object'
import {useProfileByUserId} from 'web/hooks/use-profile'
import {ProfileRow} from 'common/love/profile'
import {LovePage} from "web/components/love-page";
import {Button} from "web/components/buttons/button";
export default function SignupPage() {
const [step, setStep] = useState(0)
@@ -24,6 +22,36 @@ export default function SignupPage() {
const router = useRouter()
useTracking('view love signup page')
// Hold loading indicator for 5s when user transitions from undefined -> null
const prevUserRef = useRef<ReturnType<typeof useUser>>()
const holdTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [holdLoading, setHoldLoading] = useState(true)
useEffect(() => {
const prev = prevUserRef.current
// Transition: undefined -> null
if (prev === undefined && user === null) {
setHoldLoading(true)
if (holdTimeoutRef.current) clearTimeout(holdTimeoutRef.current)
holdTimeoutRef.current = setTimeout(() => {
setHoldLoading(false)
holdTimeoutRef.current = null
}, 10000)
}
// If user becomes defined, stop holding immediately
if (user && holdLoading) {
setHoldLoading(false)
if (holdTimeoutRef.current) {
clearTimeout(holdTimeoutRef.current)
holdTimeoutRef.current = null
}
}
prevUserRef.current = user
return () => {
// no-op
}
}, [user, holdLoading])
// Omit the id, created_time?
const [profileForm, setProfileForm] = useState<ProfileRow>({
...initialRequiredState,
@@ -55,23 +83,15 @@ export default function SignupPage() {
</LovePage>
}
if (user === null && !holdLoading) {
console.log('user === null && !holdLoading')
return <CompassLoadingIndicator/>
}
return (
<Col className="items-center">
{user === undefined ? (
<div/>
) : user === null ? (
<Col className={'items-center justify-around gap-4 pt-[20vh]'}>
<SiteLogo/>
<Button
color={'gray-outline'}
size={'2xl'}
className={''}
onClick={() => {
Router.push('register')
}}>
Sign up
</Button>
</Col>
{!user ? (
<CompassLoadingIndicator/>
) : (
<Col className={'w-full max-w-2xl px-6 py-4'}>
{step === 0 ? (
@@ -112,9 +132,9 @@ export default function SignupPage() {
}}
/>
) : step === 1 ? (
<></>
<CompassLoadingIndicator/>
) : (
<LoadingIndicator/>
<CompassLoadingIndicator/>
)}
</Col>
)}

View File

@@ -1,26 +1,27 @@
import {LovePage} from 'web/components/love-page'
import {discordLink, githubRepo, redditLink, stoatLink, supportEmail, xLink} from "common/constants";
import {GeneralButton} from "web/components/buttons/general-button";
import clsx from "clsx";
import {Col} from "web/components/layout/col";
export default function Social() {
return (
<LovePage trackPageView={'social'}>
<div className="text-gray-600 dark:text-white min-h-screen p-6">
<div className="w-full">
<div className="relative py-8 mt-12 overflow-hidden">
<div className="relative z-10 max-w-3xl mx-auto px-4">
<h3 className="text-4xl font-bold text-center mt-8 mb-8">Socials</h3>
<GeneralButton url={discordLink} content={'Discord'}/>
<GeneralButton url={stoatLink} content={'Revolt / Stoat'}/>
<GeneralButton url={redditLink} content={'Reddit'}/>
<GeneralButton url={githubRepo} content={'GitHub'}/>
<GeneralButton url={xLink} content={'X'}/>
<GeneralButton url={`mailto:${supportEmail}`} content={`${supportEmail}`}/>
</div>
</div>
</div>
</div>
<h3 className="text-4xl font-bold text-center mt-8 mb-8">Socials</h3>
<Col
className={clsx(
'pb-[58px] lg:pb-0', // bottom bar padding
'text-ink-1000 mx-auto w-full grid grid-cols-1 gap-8 max-w-3xl sm:grid-cols-2 lg:min-h-0 lg:pt-4 mt-4',
)}
>
<GeneralButton url={discordLink} content={'Discord'}/>
<GeneralButton url={stoatLink} content={'Revolt / Stoat'}/>
<GeneralButton url={redditLink} content={'Reddit'}/>
<GeneralButton url={githubRepo} content={'GitHub'}/>
<GeneralButton url={xLink} content={'X'}/>
<GeneralButton url={`mailto:${supportEmail}`} content={`${supportEmail}`}/>
</Col>
</LovePage>
)
}

65
web/pages/stats.tsx Normal file
View File

@@ -0,0 +1,65 @@
import {LovePage} from "web/components/love-page";
import ChartMembers from "web/components/widgets/charts";
import {getCount} from "web/lib/supabase/users";
import {useEffect, useState} from "react";
import StatBox from "web/components/widgets/stat-box";
import clsx from "clsx";
import {Col} from "web/components/layout/col";
export default function Stats() {
const [data, setData] = useState<Record<string, number | null>>({})
useEffect(() => {
async function load() {
const tables = [
'profiles',
'active_members',
'bookmarked_searches',
'private_user_message_channels',
'private_user_messages',
'profile_comments',
'love_compatibility_answers',
] as const
const settled = await Promise.allSettled(
tables.map((t) => getCount(t))
)
const result: Record<string, number | null> = {}
settled.forEach((res, i) => {
const key = tables[i]
if (res.status === 'fulfilled') result[key] = res.value
else result[key] = null
})
setData(result)
}
void load()
}, [])
return (
<LovePage trackPageView={'charts'}>
<h1 className="text-3xl font-semibold text-center mb-6">Growth & Stats</h1>
<Col className={'sm:mx-4 mx-1 mb-8'}>
<ChartMembers/>
</Col>
<Col className={'mx-4 mb-8'}>
<Col
className={clsx(
'pb-[58px] lg:pb-0', // bottom bar padding
'text-ink-1000 mx-auto w-full grid grid-cols-1 gap-8 max-w-3xl sm:grid-cols-2 lg:min-h-0 lg:pt-4 mt-4',
)}
>
{!!data.profiles && <StatBox value={data.profiles} label={'Members'} />}
{!!data.active_members && <StatBox value={data.active_members} label={'Active Members (last month)'} />}
{!!data.private_user_message_channels && <StatBox value={data.private_user_message_channels} label={'Discussions'} />}
{!!data.private_user_messages && <StatBox value={data.private_user_messages} label={'Messages'} />}
{!!data.bookmarked_searches && <StatBox value={data.bookmarked_searches} label={'Searches Bookmarked'} />}
{!!data.profile_comments && <StatBox value={data.profile_comments} label={'Endorsements'} />}
{!!data.love_compatibility_answers && <StatBox value={data.love_compatibility_answers} label={'Prompts Answered'} />}
</Col>
</Col>
</LovePage>
);
}

33
web/pages/vote.tsx Normal file
View File

@@ -0,0 +1,33 @@
import {LovePage} from 'web/components/love-page'
import {Col} from 'web/components/layout/col'
import {SEO} from 'web/components/SEO'
import {useUser} from 'web/hooks/use-user'
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
import {VoteComponent} from "web/components/votes/vote-info";
export default function VotePage() {
const user = useUser()
// console.log('user:', user)
return (
<LovePage
trackPageView={'vote page'}
className={'relative p-2 sm:pt-0'}
>
<SEO
title={`Votes`}
description={'A place to vote on decisions'}
url={`/vote`}
/>
{user === undefined ? (
<CompassLoadingIndicator/>
) : (
<Col className={'gap-4'}>
<VoteComponent/>
</Col>
)}
</LovePage>
)
}

View File

@@ -36,7 +36,7 @@ Martin continues to serve as an initiator and steward of Compass, but its direct
Compass is run democratically under a [constitution](/constitution) that prevents central control and ensures long-term alignment with its mission.
* Major decisions (scope, funding, rules) are voted on by **active contributors**.
* Major decisions (scope, funding, rules) are [voted](/vote) on by **active contributors**.
* The full constitution is **public and transparent**.
* No corporate capture — Compass will always remain a community-owned project.
@@ -103,16 +103,38 @@ We chose the name Compass because our goal is to help people orient themselves t
* **Give Feedback**: [Fill out the suggestion form](https://forms.gle/tKnXUMAbEreMK6FC6)
* **Join the Discussion**: [Discord Community](https://discord.gg/8Vd7jzqjun)
* **Vote on proposals**: [vote here](/vote)
* **Contribute to Development**: [View the code on GitHub](https://github.com/CompassConnections/Compass)
* **Donate**: [Support the infrastructure](/support)
* **Spread the Word**: Tell friends and family who value depth and real connection.
### How can I contact the community?
You can reach us through the [contact form](/contact), the [feedback form](https://forms.gle/tKnXUMAbEreMK6FC6), or any of our [socials](/social).
### How fast is Compass growing?
Compass has officially **launched** in October 2025 and is growing fast. You can explore real-time stats and transparent community data on our [**Growth & Stats page**](/stats). It includes information such as:
* Community growth over time
* Messages sent
* Discussions started
* Search Bookmarks created
* Endorsements given
* Prompts answered
[//]: # (* Feature votes and participation rates)
[//]: # (* Contributions and donations)
[//]: # (* Number of active users)
Because Compass is fully transparent and community-owned, you can see how the ecosystem evolves — not just in numbers, but in how people connect, collaborate, and help shape the platform together.
### Whats the long-term vision?
Our goal is for Compass to become what Linux is for software, Wikipedia is for knowledge, or Firefox is for browsing — a public, open-source infrastructure that anyone can use, contribute to, and trust. We believe meaningful human connection deserves the same treatment: free, transparent, community-owned, and protected from corporate capture.
### Whats next?
Compass has officially **launched** in October 2025 and is growing fast. Our focus is now toward **gathering feedback**, **growing the community** and **securing donations** to sustain and expand the platform.
Our focus is now toward **gathering feedback**, **growing the community** and **securing donations** to sustain and expand the platform.
Every action, whether sharing, donating, or contributing, directly helps Compass remain **ad-free, subscription-free, and community-owned**.

Some files were not shown because too many files have changed in this diff Show More