77 Commits
1.2.0 ... 1.3.0

Author SHA1 Message Date
MartinBraquet
164eddecab Release v1.3.0 2025-10-13 12:39:38 +02:00
MartinBraquet
9eacb38eb9 Show bio in discord message of profile creation 2025-10-13 12:26:40 +02:00
MartinBraquet
20f5cfb9a7 Fix demo 2025-10-13 10:49:19 +02:00
Martin Braquet
6c6c1cc90a Upgrade geodb plan to increase radius and page limit (#14)
* Upgrade geodb plan to increase radius and page limit

* Speed location debounce
2025-10-12 15:58:52 +02:00
MartinBraquet
a32c099cc1 Rename social 2025-10-12 14:54:58 +02:00
MartinBraquet
fe2f832e83 Improve support page 2025-10-12 14:52:39 +02:00
MartinBraquet
868746cc23 Use Atkinson Hyperlegible font 2025-10-12 14:35:59 +02:00
MartinBraquet
3be7a54284 Fix compat modal (had to scroll to see Next on mobile) 2025-10-12 13:13:01 +02:00
MartinBraquet
635e1ec8e2 Add TODO readme info 2025-10-11 23:23:52 +02:00
MartinBraquet
a638a35a76 Upgrade ban logic 2025-10-11 22:58:50 +02:00
MartinBraquet
8cc33d3418 Add massive upgrade text 2025-10-11 21:42:46 +02:00
MartinBraquet
9947f7b967 Fix 2025-10-11 21:42:33 +02:00
MartinBraquet
daf5350f41 Add stem vector search in bio 2025-10-11 21:15:03 +02:00
MartinBraquet
020b9ddb8d Fix 2025-10-11 19:56:54 +02:00
MartinBraquet
23aff9497a Fix 2025-10-11 19:54:25 +02:00
MartinBraquet
3c119396f3 Add demo 2025-10-11 19:51:48 +02:00
MartinBraquet
f7c7c47ac0 Remove backup info from git 2025-10-11 19:44:38 +02:00
MartinBraquet
dbe2369bbe Fix avatar link 2025-10-11 19:44:12 +02:00
MartinBraquet
4e8033d221 Add info about contact 2025-10-11 19:44:02 +02:00
MartinBraquet
97a0f87cbd Use georgia font 2025-10-11 12:15:26 +02:00
MartinBraquet
bfa2713d43 Fix wording 2025-10-11 11:46:38 +02:00
MartinBraquet
fe5e109751 Improve reading 2025-10-10 22:53:30 +02:00
MartinBraquet
8cc96030b1 Speed up placeholder 2025-10-10 22:52:09 +02:00
MartinBraquet
a2b172ad58 Improve charts 2025-10-10 21:31:30 +02:00
MartinBraquet
e756225d8b Move pics above endorsements 2025-10-10 20:58:06 +02:00
MartinBraquet
dd803b604f Update reserved paths 2025-10-10 20:20:10 +02:00
MartinBraquet
b5c961c8ee Hide complete profile button 2025-10-10 20:05:01 +02:00
MartinBraquet
47cd9d227e Add shortBio filter to mobile filters 2025-10-10 19:13:32 +02:00
MartinBraquet
e2be3aafcd Add shortBio filter 2025-10-10 19:03:57 +02:00
MartinBraquet
015fe76c44 Hide profiles with small bio 2025-10-10 18:33:47 +02:00
MartinBraquet
44666aec03 Update post install 2025-10-10 18:33:11 +02:00
MartinBraquet
6a265e4f35 Do not render home before user loads 2025-10-10 18:32:37 +02:00
MartinBraquet
12c7316524 Refactor buttons 2025-10-10 17:04:26 +02:00
MartinBraquet
dcf9741d69 Format required form as step by step onboarding 2025-10-10 16:46:17 +02:00
MartinBraquet
63dd1fdd50 Replace user with voting member and member with volunteer for clarity and inclusion 2025-10-10 15:22:55 +02:00
MartinBraquet
5aa166bbfd Open links in same tab 2025-10-10 15:12:48 +02:00
MartinBraquet
34cbf7093e Skip welcome email if local 2025-10-10 14:51:22 +02:00
MartinBraquet
159d58949e Reformat 2025-10-09 21:51:21 +02:00
MartinBraquet
fcf802b7e3 Refactor bios and add character counter 2025-10-09 21:51:08 +02:00
MartinBraquet
92ff6dadb0 Add email 2025-10-09 20:00:01 +02:00
MartinBraquet
05fa2f9883 Add socials and organization pages 2025-10-09 19:47:32 +02:00
MartinBraquet
71bb8fd784 Commetn 2025-10-09 19:33:51 +02:00
MartinBraquet
16ffd6dfab Fix message view without sign in 2025-10-09 19:30:24 +02:00
MartinBraquet
2661d15910 Remove waitlist 2025-10-09 19:17:15 +02:00
MartinBraquet
394102bb93 Fix avatar icon 2025-10-09 18:37:11 +02:00
MartinBraquet
3585b12dfd Remove maintenance banner 2025-10-09 18:20:53 +02:00
MartinBraquet
423d87d5f1 Remove logs 2025-10-09 18:19:14 +02:00
MartinBraquet
13b13b1104 Fix 2025-10-09 18:02:15 +02:00
MartinBraquet
a77e7b96b7 Move logs to debug status 2025-10-09 17:59:10 +02:00
MartinBraquet
d7213c255c Add client side heartbeat 2025-10-09 17:50:43 +02:00
MartinBraquet
ddeb1dcdb7 Improve ping pong connection duration 2025-10-09 17:38:05 +02:00
MartinBraquet
221cfa3528 Fix websockets not reaching the container and remove v0/ prefix 2025-10-09 16:58:20 +02:00
MartinBraquet
d6f6348ff1 Add maintenance banner 2025-10-09 16:25:47 +02:00
MartinBraquet
0c6afdc98e Add star 2025-10-09 15:14:38 +02:00
MartinBraquet
02a2148b3f Improve messages width 2025-10-09 13:14:38 +02:00
MartinBraquet
36a02268d8 Fix 2025-10-09 11:28:11 +02:00
MartinBraquet
450f07f505 Add private backup 2025-10-09 11:27:24 +02:00
MartinBraquet
777eba9fed Move backup to private storage 2025-10-09 11:23:30 +02:00
MartinBraquet
eaa8fa57d1 Add private bucket 2025-10-09 11:18:37 +02:00
MartinBraquet
200bf479e1 Clean 2025-10-09 00:43:27 +02:00
MartinBraquet
331f409af9 Increase debounce 2025-10-09 00:28:27 +02:00
MartinBraquet
ce875a5e63 Fix Porto not showing 2025-10-09 00:16:58 +02:00
MartinBraquet
638013f835 Update email address 2025-10-08 23:44:37 +02:00
MartinBraquet
1de87cbfec Add welcome email 2025-10-08 23:40:53 +02:00
MartinBraquet
7f3428b36a Factor out unsubscribe url 2025-10-08 23:40:37 +02:00
MartinBraquet
35595ded47 Fix bullets 2025-10-08 23:40:18 +02:00
MartinBraquet
35e9264017 Show profiles number, not users number 2025-10-08 23:39:42 +02:00
MartinBraquet
02d33c8f83 Rename mock user 2025-10-08 20:38:31 +02:00
MartinBraquet
f229ebc3a8 Add email confirmation 2025-10-08 20:38:20 +02:00
MartinBraquet
0062351f6d Add welcome email 2025-10-08 20:38:09 +02:00
MartinBraquet
e86f6798ec Fix bullet 2025-10-08 20:37:35 +02:00
MartinBraquet
4f53f7136b Add members 2025-10-08 20:35:57 +02:00
MartinBraquet
d80b982dde Simplify tab title 2025-10-08 17:32:19 +02:00
MartinBraquet
24788aa9af Add optional Garamond font 2025-10-08 14:11:51 +02:00
MartinBraquet
9ffae658df Clean 2025-10-08 11:58:58 +02:00
MartinBraquet
82ad573cac Add stoat link 2025-10-08 11:58:52 +02:00
MartinBraquet
36bf7ad65b Fix 2025-10-07 22:53:37 +02:00
124 changed files with 1169 additions and 940 deletions

1
.gitignore vendored
View File

@@ -84,4 +84,5 @@ email-preview
*.tfstate
*.tfstate.backup
*.terraform
/backups/firebase/auth/data/
/backups/firebase/storage/data/

View File

@@ -21,11 +21,28 @@ This repository contains the source code for [Compass](https://compassmeet.com)
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
<p style="text-align: center;">
<img src="https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fdemo_compass.gif?alt=media&token=e3ae4334-4e3f-4026-b121-c08b4b724cd1" alt="Compass Demo" width="600">
</p>
## To Do
No contribution is too small—whether its changing a color, resizing a button, tweaking a font, or improving wording. Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The goal is to make the platform better step by step, and every improvement counts. If you see something that could be clearer, smoother, or more engaging, **please jump in**!
Here are some examples of things that would be very useful. If you want to help but dont know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, just use your preferred option:
- Ask or DM an admin on Discord
- Email hello@compassmeet.com
- Raise an issue on GitHub
If you want to add tasks without creating an account, you can simply email
```
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
```
Put the task title in the email subject and the task description in the email content.
Here is a tailored selection of things that would be very useful. If you want to help but dont know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
- [x] Authentication (user/password and Google Sign In)
- [x] Set up PostgreSQL in Production with supabase
@@ -58,7 +75,7 @@ Everything is open to anyone for collaboration, but the following ones are parti
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
- [ ] Add email verification
- [ ] Add password reset
- [ ] Add automated welcome email
- [x] Add automated welcome email
- [ ] Security audit and penetration testing
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
- [ ] Create settings page (change email, password, delete account, etc.)
@@ -105,7 +122,7 @@ Almost all the features will work out of the box, so you can skip this step and
We can't make the following information public, for security and privacy reasons:
- Database, otherwise anyone could access all the user data (including private messages)
- Firebase, otherwise anyone could remove users or modify the media files
- Email, analytics, and location services, otherwise anyone could use our paid plan
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.

View File

@@ -54,6 +54,9 @@ gcloud projects add-iam-policy-binding compass-130ba \
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
gcloud run services list
gcloud compute backend-services update api-backend \
--global \
--timeout=600s
```
Set up the saved search notifications job:

View File

@@ -185,29 +185,29 @@ resource "google_compute_url_map" "api_url_map" {
path_matcher {
name = "allpaths"
default_service = google_compute_backend_service.api_backend.self_link
# Priority 0: passthrough /v0/* requests
route_rules {
priority = 1
match_rules {
prefix_match = "/v0"
}
service = google_compute_backend_service.api_backend.self_link
}
# Priority 1: rewrite everything else to /v0
route_rules {
priority = 2
match_rules {
prefix_match = "/"
}
route_action {
url_rewrite {
path_prefix_rewrite = "/v0/"
}
}
service = google_compute_backend_service.api_backend.self_link
}
#
# # Priority 0: passthrough /v0/* requests
# route_rules {
# priority = 1
# match_rules {
# prefix_match = "/v0"
# }
# service = google_compute_backend_service.api_backend.self_link
# }
#
# # Priority 1: rewrite everything else to /v0
# route_rules {
# priority = 2
# match_rules {
# prefix_match = "/"
# }
# route_action {
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
# path_prefix_rewrite = "/v0/"
# }
# }
# service = google_compute_backend_service.api_backend.self_link
# }
}
}

View File

@@ -108,7 +108,7 @@ swaggerDocument.info = {
version: "1.0.0",
contact: {
name: "Compass",
email: "compass.meet.info@gmail.com",
email: "hello@compassmeet.com",
url: "https://compassmeet.com"
}
};
@@ -191,7 +191,7 @@ Object.entries(handlers).forEach(([path, handler]) => {
}
})
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
// console.debug('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
// Internal Endpoints
app.post(pathWithPrefix("/internal/send-search-notifications"),

View File

@@ -9,6 +9,30 @@ 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 ''
}
}
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -29,7 +53,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
}
console.log('body', body)
console.debug('body', body)
const { data, error } = await tryCatch(
insert(pg, 'profiles', { user_id: auth.uid, ...body })
@@ -46,15 +70,17 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.log('Failed to track create profile', e)
console.error('Failed to track create profile', e)
}
try {
await sendDiscordMessage(
`**${user.name}** just created a profile at https://www.compassmeet.com/${user.username}`,
'members',
)
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}`
}
await sendDiscordMessage(message, 'members')
} catch (e) {
console.log('Failed to send discord new profile', e)
console.error('Failed to send discord new profile', e)
}
try {
const nProfiles = await pg.one<number>(
@@ -69,7 +95,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
n % 50 === 0
)
}
console.log(nProfiles, isMilestone(nProfiles))
console.debug(nProfiles, isMilestone(nProfiles))
if (isMilestone(nProfiles)) {
await sendDiscordMessage(
`We just reached **${nProfiles}** total profiles! 🎉`,
@@ -78,7 +104,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
}
} catch (e) {
console.log('Failed to send discord user milestone', e)
console.error('Failed to send discord user milestone', e)
}
}

View File

@@ -7,12 +7,13 @@ import {APIError, APIHandler} from './helpers/endpoint'
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
import {removeUndefinedProps} from 'common/util/object'
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
import {RESERVED_PATHS} from 'common/envs/constants'
import {IS_LOCAL, RESERVED_PATHS} from 'common/envs/constants'
import {getUser, getUserByUsername, log} from 'shared/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {convertPrivateUser, convertUser} from 'common/supabase/users'
import {getBucket} from "shared/firebase-utils";
import {sendWelcomeEmail} from "email/functions/helpers";
export const createUser: APIHandler<'create-user'> = async (
props,
@@ -126,7 +127,12 @@ export const createUser: APIHandler<'create-user'> = async (
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.log('Failed to track create profile', e)
console.error('Failed to track create profile', e)
}
try {
if (!IS_LOCAL) await sendWelcomeEmail(user, privateUser)
} catch (e) {
console.error('Failed to sendWelcomeEmail', e)
}
}

View File

@@ -35,7 +35,7 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
try {
const auth = admin.auth()
await auth.deleteUser(userId)
console.log(`Deleted user ${userId} from Firebase Auth and Supabase`)
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
} catch (e) {
console.error('Error deleting user from Firebase Auth:', e)
}

View File

@@ -39,7 +39,7 @@ export const getCompatibilityQuestions: APIHandler<
const questions = shuffle(dbQuestions)
// console.log(
// console.debug(
// 'got questions',
// questions.map((q) => q.question + ' ' + q.score)
// )

View File

@@ -4,7 +4,7 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {getCompatibleProfiles} from 'api/compatible-profiles'
import {intersection} from 'lodash'
import {MAX_INT, MIN_INT} from "common/constants";
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
export type profileQueryType = {
limit?: number | undefined,
@@ -19,6 +19,7 @@ export type profileQueryType = {
wants_kids_strength?: number | undefined,
has_kids?: number | undefined,
is_smoker?: boolean | undefined,
shortBio?: boolean | undefined,
geodbCityIds?: String[] | undefined,
compatibleWithUserId?: string | undefined,
skipId?: string | undefined,
@@ -29,7 +30,7 @@ export type profileQueryType = {
export const loadProfiles = async (props: profileQueryType) => {
const pg = createSupabaseDirectClient()
console.log(props)
console.debug(props)
const {
limit: limitParam,
after,
@@ -42,6 +43,7 @@ export const loadProfiles = async (props: profileQueryType) => {
wants_kids_strength,
has_kids,
is_smoker,
shortBio,
geodbCityIds,
compatibleWithUserId,
orderBy: orderByParam = 'created_time',
@@ -81,13 +83,14 @@ export const loadProfiles = async (props: profileQueryType) => {
(!is_smoker || l.is_smoker === is_smoker) &&
(l.id.toString() != skipId) &&
(!geodbCityIds ||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
((l.bio_length ?? 0) >= MIN_BIO_LENGTH)
)
const cursor = after
? profiles.findIndex((l) => l.id.toString() === after) + 1
: 0
console.log(cursor)
console.debug(cursor)
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
@@ -106,7 +109,7 @@ export const loadProfiles = async (props: profileQueryType) => {
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
...keywords.map(word => where(
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%'`,
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
{word}
)),
@@ -153,12 +156,14 @@ export const loadProfiles = async (props: profileQueryType) => {
{after}
),
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
limitParam && limit(limitParam)
)
// console.log('query:', query)
// console.debug('query:', query)
return await pg.map(query, [], convertRow)
}

View File

@@ -163,7 +163,7 @@ const notifyOtherUserInChannelIfInactive = async (
// TODO: notification only for active user
const otherUser = await getUser(otherUserId.user_id)
console.log('otherUser:', otherUser)
console.debug('otherUser:', otherUser)
if (!otherUser) return
await createNewMessageNotification(creator, otherUser, channelId)
@@ -175,7 +175,7 @@ const createNewMessageNotification = async (
channelId: number
) => {
const privateUser = await getPrivateUser(toUser.id)
console.log('privateUser:', privateUser)
console.debug('privateUser:', privateUser)
if (!privateUser) return
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}

View File

@@ -4,5 +4,6 @@ import {geodbFetch} from "common/geodb";
export const searchLocation: APIHandler<'search-location'> = async (body) => {
const {term, limit} = body
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
// const endpoint = `/countries?namePrefix=${term}&limit=${limit ?? 10}&offset=0`
return await geodbFetch(endpoint)
}

View File

@@ -2,13 +2,12 @@ import {APIHandler} from './helpers/endpoint'
import {geodbFetch} from "common/geodb";
const searchNearCityMain = async (cityId: string, radius: number) => {
// Limit to 10 cities for now for free plan, was 100 before (may need to buy plan)
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=10`
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
return await geodbFetch(endpoint)
}
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
const { cityId, radius } = body
const {cityId, radius} = body
return await searchNearCityMain(cityId, radius)
}

View File

@@ -25,7 +25,7 @@ export const sendSearchNotifications = async () => {
from('bookmarked_searches'),
)
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
console.log(`Running ${searches.length} bookmarked searches`)
console.debug(`Running ${searches.length} bookmarked searches`)
const _users = await pg.map(
renderSql(
@@ -36,7 +36,7 @@ export const sendSearchNotifications = async () => {
convertSearchRow
) as Row<'users'>[]
const users = keyBy(_users, 'id')
console.log('users', users)
console.debug('users', users)
const _privateUsers = await pg.map(
renderSql(
@@ -47,15 +47,20 @@ export const sendSearchNotifications = async () => {
convertSearchRow
) as Row<'private_users'>[]
const privateUsers = keyBy(_privateUsers, 'id')
console.log('privateUsers', privateUsers)
console.debug('privateUsers', privateUsers)
const matches: MatchesByUserType = {}
for (const row of searches) {
if (typeof row.search_filters !== 'object') continue;
const props = {...row.search_filters, skipId: row.creator_id, lastModificationWithin: '24 hours'}
const props = {
...row.search_filters,
skipId: row.creator_id,
lastModificationWithin: '24 hours',
shortBio: true,
}
const profiles = await loadProfiles(props as profileQueryType)
console.log(profiles.map((item: any) => item.name))
console.debug(profiles.map((item: any) => item.name))
if (!profiles.length) continue
if (!(row.creator_id in matches)) {
if (!privateUsers[row.creator_id]) continue
@@ -74,7 +79,7 @@ export const sendSearchNotifications = async () => {
})),
})
}
console.log('matches:', JSON.stringify(matches, null, 2))
console.debug('matches:', JSON.stringify(matches, null, 2))
await notifyBookmarkedSearch(matches)
return {status: 'success'}

View File

@@ -1,5 +1,5 @@
import {PrivateUser, User} from 'common/user'
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
import {getNotificationDestinationsForUser, UNSUBSCRIBE_URL} from 'common/user-notification-preferences'
import {sendEmail} from './send-email'
import {NewMessageEmail} from '../new-message'
import {NewEndorsementEmail} from '../new-endorsement'
@@ -8,6 +8,7 @@ import {getProfile} from 'shared/love/supabase'
import { render } from "@react-email/render"
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>'
@@ -75,6 +76,25 @@ export const sendNewMessageEmail = async (
})
}
export const sendWelcomeEmail = async (
toUser: User,
privateUser: PrivateUser,
) => {
if (!privateUser.email) return
return await sendEmail({
from,
subject: `Welcome to Compass!`,
to: privateUser.email,
html: await render(
<WelcomeEmail
toUser={toUser}
unsubscribeUrl={UNSUBSCRIBE_URL}
email={privateUser.email}
/>
),
})
}
export const sendSearchAlertsEmail = async (
toUser: User,
privateUser: PrivateUser,

View File

@@ -1,9 +1,9 @@
import { ProfileRow } from 'common/love/profile'
import type { User } from 'common/user'
import {ProfileRow} from 'common/love/profile'
import type {User} from 'common/user'
// for email template testing
export const sinclairUser: User = {
export const mockUser: User = {
createdTime: 0,
bio: 'the futa in futarchy',
website: 'sincl.ai',
@@ -78,6 +78,7 @@ export const sinclairProfile: ProfileRow = {
city_longitude: -122.416389,
geodb_city_id: '126964',
referred_by_username: null,
bio_length: 1000,
bio: {
type: 'doc',
content: [
@@ -101,6 +102,8 @@ export const sinclairProfile: ProfileRow = {
},
],
},
bio_text: 'the futa in futarchy',
bio_tsv: 'the futa in futarchy',
age: 25,
}
@@ -173,6 +176,7 @@ export const jamesProfile: ProfileRow = {
city_longitude: -122.416389,
geodb_city_id: '126964',
referred_by_username: null,
bio_length: 1000,
bio: {
type: 'doc',
content: [
@@ -202,5 +206,7 @@ export const jamesProfile: ProfileRow = {
},
],
},
bio_text: 'the futa in futarchy',
bio_tsv: 'the futa in futarchy',
age: 32,
}

View File

@@ -17,7 +17,7 @@ export const sendEmail = async (
options?: CreateEmailRequestOptions
) => {
const resend = getResend()
console.log(resend, payload, options)
console.debug(resend, payload, options)
async function sendEmailThrottle(data: any, options: any) {
if (!resend) return { data: null, error: 'No Resend client' }
@@ -28,7 +28,7 @@ export const sendEmail = async (
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
options
)
console.log('resend.emails.send', data, error)
console.debug('resend.emails.send', data, error)
if (error) {
log.error(
@@ -47,12 +47,12 @@ const getResend = () => {
if (resend) return resend
if (!process.env.RESEND_KEY) {
console.log('No RESEND_KEY, skipping email send')
console.debug('No RESEND_KEY, skipping email send')
return
}
const apiKey = process.env.RESEND_KEY as string
// console.log(`RESEND_KEY: ${apiKey}`)
// console.debug(`RESEND_KEY: ${apiKey}`)
resend = new Resend(apiKey)
return resend
}

View File

@@ -4,11 +4,11 @@ if (require.main === module) {
const email = process.argv[2]
if (!email) {
console.error('Please provide an email address')
console.log('Usage: ts-node send-test-email.ts your@email.com')
console.debug('Usage: ts-node send-test-email.ts your@email.com')
process.exit(1)
}
sendTestEmail(email)
.then(() => console.log('Email sent successfully!'))
.then(() => console.debug('Email sent successfully!'))
.catch((error) => console.error('Failed to send email:', error))
}

View File

@@ -1,7 +1,7 @@
import {Body, Button, Column, Container, Head, Html, Preview, Row, Section, Text,} from '@react-email/components'
import {type User} from 'common/user'
import {DOMAIN} from 'common/envs/constants'
import {jamesUser, sinclairUser} from './functions/mock'
import {jamesUser, mockUser} from './functions/mock'
import {button, container, content, Footer, main, paragraph} from "email/utils";
interface NewEndorsementEmailProps {
@@ -74,7 +74,7 @@ export const NewEndorsementEmail = ({
NewEndorsementEmail.PreviewProps = {
fromUser: jamesUser,
onUser: sinclairUser,
onUser: mockUser,
endorsementText:
"Sinclair is someone you want to have around because she injects creativity and humor into every conversation, and her laugh is infectious! Not to mention that she's a great employee, treats everyone with respect, and is even-tempered.",
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',

View File

@@ -2,7 +2,7 @@ import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@rea
import {DOMAIN} from 'common/envs/constants'
import {type ProfileRow} from 'common/love/profile'
import {type User} from 'common/user'
import {jamesProfile, jamesUser, sinclairUser} from './functions/mock'
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
import {Footer} from "email/utils";
interface NewMatchEmailProps {
@@ -70,7 +70,7 @@ export const NewMatchEmail = ({
}
NewMatchEmail.PreviewProps = {
onUser: sinclairUser,
onUser: mockUser,
matchedWithUser: jamesUser,
matchedProfile: jamesProfile,
email: 'someone@gmail.com',

View File

@@ -1,7 +1,7 @@
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
import {type User} from 'common/user'
import {type ProfileRow} from 'common/love/profile'
import {jamesProfile, jamesUser, sinclairUser,} from './functions/mock'
import {jamesProfile, jamesUser, mockUser,} from './functions/mock'
import {DOMAIN} from 'common/envs/constants'
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
@@ -74,7 +74,7 @@ export const NewMessageEmail = ({
NewMessageEmail.PreviewProps = {
fromUser: jamesUser,
fromUserProfile: jamesProfile,
toUser: sinclairUser,
toUser: mockUser,
channelId: 1,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',

View File

@@ -1,6 +1,6 @@
import {Body, Container, Head, Html, Link, Preview, Section, Text,} from '@react-email/components'
import {type User} from 'common/user'
import {sinclairUser,} from './functions/mock'
import {mockUser,} from './functions/mock'
import {DOMAIN} from 'common/envs/constants'
import {container, content, Footer, main, paragraph} from "email/utils";
import {MatchesType} from "common/love/bookmarked_searches";
@@ -140,7 +140,7 @@ const matchSamples = [
]
NewSearchAlertsEmail.PreviewProps = {
toUser: sinclairUser,
toUser: mockUser,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
matches: matchSamples,

View File

@@ -0,0 +1,82 @@
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
import {type User} from 'common/user'
import {mockUser,} from './functions/mock'
import {button, container, content, Footer, main, paragraph} from "email/utils";
function randomHex(length: number) {
const bytes = new Uint8Array(Math.ceil(length / 2));
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0"))
.join("")
.slice(0, length);
}
interface WelcomeEmailProps {
toUser: User
unsubscribeUrl: string
email?: string
}
export const WelcomeEmail = ({
toUser,
unsubscribeUrl,
email,
}: WelcomeEmailProps) => {
const name = toUser.name.split(' ')[0]
const confirmUrl = `https://compassmeet.com/confirm-email/${randomHex(16)}`
return (
<Html>
<Head/>
<Preview>Welcome to Compass Please confirm your email</Preview>
<Body style={main}>
<Container style={container}>
<Section style={content}>
<Text style={paragraph}>Welcome to Compass, {name}!</Text>
<Text style={paragraph}>
Compass is a free, community-owned platform built to help people form
deep, meaningful connections platonic, romantic, or collaborative.
There are no ads, no hidden algorithms, and no subscriptions just a
transparent, open-source space shaped by people like you.
</Text>
<Text style={paragraph}>
To finish creating your account and start exploring Compass, please
confirm your email below:
</Text>
<Button
style={button}
href={confirmUrl}
>
Confirm My Email
</Button>
<Text style={{marginTop: "40px", fontSize: "10px", color: "#555"}}>
Or copy and paste this link into your browser: <br/>
<a href={confirmUrl}>{confirmUrl}</a>
</Text>
<Text style={{marginTop: "40px", fontSize: "12px", color: "#555"}}>
Your presence and participation are what make Compass possible. Thank you
for helping us build an internet space that prioritizes depth, trust, and
community over monetization.
</Text>
</Section>
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
</Container>
</Body>
</Html>
)
}
WelcomeEmail.PreviewProps = {
toUser: mockUser,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
} as WelcomeEmailProps
export default WelcomeEmail

View File

@@ -5,7 +5,7 @@
"rules": "storage.rules"
},
{
"bucket": "compass-130ba-private.firebasestorage.app",
"bucket": "compass-130ba-private",
"rules": "private-storage.rules"
}
]

View File

@@ -7,4 +7,4 @@ service firebase.storage {
allow write: if request.auth.uid == userId && request.resource.size <= 20 * 1024 * 1024; // 20MB
}
}
}
}

View File

@@ -4,8 +4,7 @@ service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read;
// Don't require auth, as dream uploads can be done by anyone
allow write: if request.resource.size <= 10 * 1024 * 1024; // 10MB
allow write: if request.auth != null && request.resource.size <= 10 * 1024 * 1024;
}
}
}

View File

@@ -36,7 +36,7 @@ runScript(async ({ pg }) => {
}
}
// console.log('updates', updates.slice(0, 10))
// console.debug('updates', updates.slice(0, 10))
// return
let count = 0

View File

@@ -24,7 +24,7 @@ runScript(async ({ pg }) => {
})
const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
console.log(`\nSearching comments for ${nodeName}...`)
console.debug(`\nSearching comments for ${nodeName}...`)
const commentQuery = renderSql(
select('id, user_id, on_user_id, content'),
from('profile_comments'),
@@ -32,15 +32,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
)
const comments = await pg.manyOrNone(commentQuery)
console.log(`Found ${comments.length} comments:`)
console.debug(`Found ${comments.length} comments:`)
comments.forEach((comment) => {
console.log('\nComment ID:', comment.id)
console.log('From user:', comment.user_id)
console.log('On user:', comment.on_user_id)
console.log('Content:', JSON.stringify(comment.content))
console.debug('\nComment ID:', comment.id)
console.debug('From user:', comment.user_id)
console.debug('On user:', comment.on_user_id)
console.debug('Content:', JSON.stringify(comment.content))
})
console.log(`\nSearching private messages for ${nodeName}...`)
console.debug(`\nSearching private messages for ${nodeName}...`)
const messageQuery = renderSql(
select('id, user_id, channel_id, content'),
from('private_user_messages'),
@@ -48,15 +48,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
)
const messages = await pg.manyOrNone(messageQuery)
console.log(`Found ${messages.length} private messages:`)
console.debug(`Found ${messages.length} private messages:`)
messages.forEach((msg) => {
console.log('\nMessage ID:', msg.id)
console.log('From user:', msg.user_id)
console.log('Channel:', msg.channel_id)
console.log('Content:', JSON.stringify(msg.content))
console.debug('\nMessage ID:', msg.id)
console.debug('From user:', msg.user_id)
console.debug('Channel:', msg.channel_id)
console.debug('Content:', JSON.stringify(msg.content))
})
console.log(`\nSearching profiles for ${nodeName}...`)
console.debug(`\nSearching profiles for ${nodeName}...`)
const users = renderSql(
select('user_id, bio'),
from('profiles'),
@@ -64,9 +64,9 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
)
const usersWithMentions = await pg.manyOrNone(users)
console.log(`Found ${usersWithMentions.length} users:`)
console.debug(`Found ${usersWithMentions.length} users:`)
usersWithMentions.forEach((user) => {
console.log('\nUser ID:', user.user_id)
console.log('Bio:', JSON.stringify(user.bio))
console.debug('\nUser ID:', user.user_id)
console.debug('Bio:', JSON.stringify(user.bio))
})
}

View File

@@ -178,7 +178,7 @@ async function getTableInfo(pg: SupabaseDirectClient, tableName: string) {
}
async function getFunctions(pg: SupabaseDirectClient) {
console.log('Getting functions')
console.debug('Getting functions')
const rows = await pg.manyOrNone<{
function_name: string
definition: string
@@ -196,7 +196,7 @@ async function getFunctions(pg: SupabaseDirectClient) {
}
async function getViews(pg: SupabaseDirectClient) {
console.log('Getting views')
console.debug('Getting views')
return pg.manyOrNone<{ view_name: string; definition: string }>(
`SELECT
table_name AS view_name,
@@ -214,7 +214,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
(row) => row.tablename as string
)
console.log(`Getting info for ${tables.length} tables`)
console.debug(`Getting info for ${tables.length} tables`)
const tableInfos = await Promise.all(
tables.map((table) => getTableInfo(pg, table))
)
@@ -331,7 +331,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
await fs.writeFile(`${outputDir}/${tableInfo.tableName}.sql`, content)
}
console.log('Writing remaining functions to functions.sql')
console.debug('Writing remaining functions to functions.sql')
let functionsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
for (const func of functions) {
@@ -340,7 +340,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
await fs.writeFile(`${outputDir}/functions.sql`, functionsContent)
console.log('Writing views to views.sql')
console.debug('Writing views to views.sql')
let viewsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
for (const view of views) {
@@ -350,7 +350,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
await fs.writeFile(`${outputDir}/views.sql`, viewsContent)
console.log('Prettifying SQL files...')
console.debug('Prettifying SQL files...')
execSync(
`prettier --write ${outputDir}/*.sql --ignore-path ../supabase/.gitignore`
)

View File

@@ -30,7 +30,7 @@ const removeNodesOfType = (
runScript(async ({ pg }) => {
const nodeType = 'linkPreview'
console.log('\nSearching comments for linkPreviews...')
console.debug('\nSearching comments for linkPreviews...')
const commentQuery = renderSql(
select('id, content'),
from('profile_comments'),
@@ -38,21 +38,21 @@ runScript(async ({ pg }) => {
)
const comments = await pg.manyOrNone(commentQuery)
console.log(`Found ${comments.length} comments with linkPreviews`)
console.debug(`Found ${comments.length} comments with linkPreviews`)
for (const comment of comments) {
const newContent = removeNodesOfType(comment.content, nodeType)
console.log('before', comment.content)
console.log('after', newContent)
console.debug('before', comment.content)
console.debug('after', newContent)
await pg.none('update profile_comments set content = $1 where id = $2', [
newContent,
comment.id,
])
console.log('Updated comment:', comment.id)
console.debug('Updated comment:', comment.id)
}
console.log('\nSearching private messages for linkPreviews...')
console.debug('\nSearching private messages for linkPreviews...')
const messageQuery = renderSql(
select('id, content'),
from('private_user_messages'),
@@ -60,17 +60,17 @@ runScript(async ({ pg }) => {
)
const messages = await pg.manyOrNone(messageQuery)
console.log(`Found ${messages.length} messages with linkPreviews`)
console.debug(`Found ${messages.length} messages with linkPreviews`)
for (const msg of messages) {
const newContent = removeNodesOfType(msg.content, nodeType)
console.log('before', JSON.stringify(msg.content, null, 2))
console.log('after', JSON.stringify(newContent, null, 2))
console.debug('before', JSON.stringify(msg.content, null, 2))
console.debug('after', JSON.stringify(newContent, null, 2))
await pg.none(
'update private_user_messages set content = $1 where id = $2',
[newContent, msg.id]
)
console.log('Updated message:', msg.id)
console.debug('Updated message:', msg.id)
}
})

View File

@@ -5,7 +5,7 @@ import {ENV_CONFIG, getStorageBucketId} from "common/envs/constants";
export const getServiceAccountCredentials = () => {
let keyPath = ENV_CONFIG.googleApplicationCredentials
// console.log('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
// console.debug('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
if (!keyPath) {
// throw new Error(
// `Please set the GOOGLE_APPLICATION_CREDENTIALS environment variable to contain the path to your key file.`
@@ -16,7 +16,7 @@ export const getServiceAccountCredentials = () => {
if (!keyPath.startsWith('/')) {
// Make relative paths relative to the current file
keyPath = __dirname + '/' + keyPath
// console.log(keyPath)
// console.debug(keyPath)
}
try {
@@ -41,11 +41,11 @@ export async function deleteUserFiles(username: string) {
const [files] = await bucket.getFiles({prefix: path});
if (files.length === 0) {
console.log(`No files found in bucket for user ${username}`);
console.debug(`No files found in bucket for user ${username}`);
return;
}
await Promise.all(files.map(file => file.delete()));
console.log(`Deleted ${files.length} files for user ${username}`);
console.debug(`Deleted ${files.length} files for user ${username}`);
}

View File

@@ -25,7 +25,7 @@ export const generateAvatarUrl = async (
const buffer = await res.arrayBuffer()
return await upload(userId, Buffer.from(buffer), bucket)
} catch (e) {
console.log('error generating avatar', e)
console.debug('error generating avatar', e)
return `https://${DOMAIN}/images/default-avatar.png`
}
}

View File

@@ -3,7 +3,7 @@ export const constructPrefixTsQuery = (term: string) => {
.replace(/'/g, "''")
.replace(/[!&|():*<>]/g, '')
.trim()
console.log(`Term: "${sanitized}"`)
console.debug(`Term: "${sanitized}"`)
if (sanitized === '') return ''
const tokens = sanitized.split(/\s+/)
return tokens.join(' & ') + ':*'

View File

@@ -9,12 +9,12 @@ export const initAdmin = () => {
if (IS_LOCAL) {
try {
const serviceAccount = getServiceAccountCredentials()
// console.log(serviceAccount)
// console.debug(serviceAccount)
if (!serviceAccount.project_id) {
console.log(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
console.debug(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
return
}
console.log(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
console.debug(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
return admin.initializeApp({
projectId: serviceAccount.project_id,
credential: admin.credential.cert(serviceAccount),
@@ -25,6 +25,6 @@ export const initAdmin = () => {
}
}
console.log(`Initializing connection to default Firebase...`)
console.debug(`Initializing connection to default Firebase...`)
return admin.initializeApp()
}

View File

@@ -14,10 +14,14 @@ export function convertRow(row: ProfileAndUserRow): Profile
export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
if (!row) return null
return {
// Remove internal/search-only fields from the returned profile row
const profile: any = {
...row,
user: { ...row.user, name: row.name, username: row.username } as User,
} as Profile
}
delete profile.bio_text
delete profile.bio_tsv
return profile as Profile
}
const LOVER_COLS = 'profiles.*, name, username, users.data as user'

View File

@@ -79,7 +79,7 @@ function writeLog(
// record error properties in GCP if you just do log(err)
output['error'] = msg
}
console.log(JSON.stringify(output, replacer))
console.debug(JSON.stringify(output, replacer))
} else {
const category = Object.values(pick(data, DISPLAY_CATEGORY_KEYS)).join()
const categoryLabel = category ? dim(category) + ' ' : ''

View File

@@ -104,7 +104,7 @@ export class MetricWriter {
for (const entry of freshEntries) {
entry.fresh = false
}
if (!IS_GOOGLE_CLOUD) {
if (IS_GOOGLE_CLOUD) {
log.debug('Writing GCP metrics.', {entries: freshEntries})
if (this.instance == null) {
this.instance = await getInstanceInfo()

View File

@@ -64,7 +64,7 @@ const newClient = (
...settings,
}
// console.log(config)
// console.debug(config)
return pgp(config)
}

View File

@@ -12,10 +12,15 @@ import {
import {IS_LOCAL} from "common/envs/constants";
import {getWebsocketUrl} from "common/api/utils";
// Extend the type definition locally
interface HeartbeatWebSocket extends WebSocket {
isAlive?: boolean
}
const SWITCHBOARD = new Switchboard()
// if a connection doesn't ping for this long, we assume the other side is toast
const CONNECTION_TIMEOUT_MS = 60 * 1000
// const CONNECTION_TIMEOUT_MS = 60 * 1000
export class MessageParseError extends Error {
details?: unknown
@@ -52,7 +57,7 @@ function parseMessage(data: RawData): ClientMessage {
}
}
function processMessage(ws: WebSocket, data: RawData): ServerMessage<'ack'> {
function processMessage(ws: HeartbeatWebSocket, data: RawData): ServerMessage<'ack'> {
try {
const msg = parseMessage(data)
const { type, txid } = msg
@@ -129,21 +134,26 @@ export function listen(server: HttpServer, path: string) {
let deadConnectionCleaner: NodeJS.Timeout | undefined
wss.on('listening', () => {
log.info(`Web socket server listening on ${path}. ${getWebsocketUrl()}`)
deadConnectionCleaner = setInterval(function ping() {
const now = Date.now()
for (const ws of wss.clients) {
const lastSeen = SWITCHBOARD.getClient(ws).lastSeen
if (lastSeen < now - CONNECTION_TIMEOUT_MS) {
ws.terminate()
deadConnectionCleaner = setInterval(() => {
for (const ws of wss.clients as Set<HeartbeatWebSocket>) {
if (ws.isAlive === false) {
log.debug('Terminating dead connection');
ws.terminate();
continue;
}
ws.isAlive = false;
// log.debug('Sending ping to client');
ws.ping();
}
}, CONNECTION_TIMEOUT_MS)
}, 25000);
})
wss.on('error', (err) => {
log.error('Error on websocket server.', { error: err })
})
wss.on('connection', (ws) => {
// todo: should likely kill connections that haven't sent any ping for a long time
wss.on('connection', (ws: HeartbeatWebSocket) => {
ws.isAlive = true;
// log.debug('Received pong from client');
ws.on('pong', () => (ws.isAlive = true));
metrics.inc('ws/connections_established')
metrics.set('ws/open_connections', wss.clients.size)
log.debug('WS client connected.')

View File

@@ -9,6 +9,7 @@ END$$;
CREATE TABLE IF NOT EXISTS profiles (
age INTEGER NULL,
bio JSONB,
bio_length integer null,
born_in_location TEXT,
city TEXT NOT NULL,
city_latitude NUMERIC(9, 6),
@@ -79,6 +80,10 @@ CREATE UNIQUE INDEX unique_user_id ON public.profiles USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
ON public.profiles USING btree (last_modification_time);
CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
ON profiles (bio_length);
-- Functions and Triggers
CREATE
OR REPLACE FUNCTION update_last_modification_time()
@@ -110,25 +115,31 @@ CREATE INDEX profiles_bio_trgm_idx
--- bio_text
-- ALTER TABLE profiles ADD COLUMN bio_text tsvector;
--
-- CREATE OR REPLACE FUNCTION profiles_bio_tsvector_update()
-- RETURNS trigger AS $$
-- BEGIN
-- new.bio_text := to_tsvector(
-- 'english',
-- (
-- SELECT string_agg(trim(both '"' from x::text), ' ')
-- FROM jsonb_path_query(new.bio, '$.**.text'::jsonpath) AS x
-- )
-- );
-- RETURN new;
-- END;
-- $$ LANGUAGE plpgsql;
--
-- CREATE TRIGGER profiles_bio_tsvector_trigger
-- BEFORE INSERT OR UPDATE OF bio ON profiles
-- FOR EACH ROW EXECUTE FUNCTION profiles_bio_tsvector_update();
--
-- create index on profiles using gin(bio_text);
ALTER TABLE profiles ADD COLUMN bio_text TEXT;
UPDATE profiles
SET bio_text = (
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
FROM jsonb_path_query(bio, '$.**.text') AS t(value)
);
ALTER TABLE profiles ADD COLUMN bio_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED;
CREATE INDEX profiles_bio_tsv_idx ON profiles USING GIN (bio_tsv);
CREATE OR REPLACE FUNCTION update_bio_text()
RETURNS trigger AS $$
BEGIN
NEW.bio_text := (
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
FROM jsonb_path_query(NEW.bio, '$.**.text') AS t(value)
);
RETURN NEW;
END;
$$ 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

@@ -1,18 +0,0 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
PROJECT=compass-130ba
TIMESTAMP=$(date +"%F_%H-%M-%S")
DESTINATION=./data/$TIMESTAMP
mkdir -p $DESTINATION
gsutil -m cp -r gs://$PROJECT.firebasestorage.app $DESTINATION
echo Backup of Firebase Storage done

View File

@@ -1,170 +0,0 @@
locals {
project = "compass-130ba"
region = "us-west1"
zone = "us-west1-b"
service_name = "backup"
machine_type = "e2-micro"
}
variable "env" {
description = "Environment (env or prod)"
type = string
default = "prod"
}
provider "google" {
project = local.project
region = local.region
zone = local.zone
}
# Service account for the VM (needs Secret Manager + Storage access)
resource "google_service_account" "backup_vm_sa" {
account_id = "backup-vm-sa"
display_name = "Backup VM Service Account"
}
# IAM roles
resource "google_project_iam_member" "backup_sa_secret_manager" {
project = "compass-130ba"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
}
resource "google_project_iam_member" "backup_sa_storage_admin" {
project = "compass-130ba"
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
}
# Minimal VM
resource "google_compute_instance" "backup_vm" {
name = "supabase-backup-vm"
machine_type = local.machine_type
zone = local.zone
boot_disk {
initialize_params {
image = "debian-11-bullseye-v20250915"
size = 20
}
}
network_interface {
network = "default"
access_config {}
}
service_account {
email = google_service_account.backup_vm_sa.email
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}
metadata_startup_script = <<-EOT
#!/bin/bash
apt-get update
apt-get install -y postgresql-client cron wget curl unzip
# Add PostgreSQL repo
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update
sudo apt-get install -y postgresql-client-17
sudo apt-get install -y mailutils
# Create backup directory
mkdir -p /home/martin/supabase_backups
chown -R martin:martin /home/martin
# Example backup script
cat <<'EOF' > /home/martin/backup.sh
#!/bin/bash
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
set -e
cd $(dirname "$0")
export ENV=prod
if [ "$ENV" = "prod" ]; then
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
elif [ "$ENV" = "dev" ]; then
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
else
echo "Error: ENV must be 'prod' or 'dev'" >&2
exit 1
fi
# Config
PGPORT="5432"
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
PGDATABASE="postgres"
# Retrieve password from Secret Manager
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
BACKUP_DIR="/tmp/supabase_backups"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%F_%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
export PGPASSWORD
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
if [ $? -ne 0 ]; then
echo "Backup failed!"
exit 1
fi
echo "Backup successful: $BACKUP_FILE"
# UPLOAD TO GCS
echo "Uploading backup to GCS..."
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
# LOCAL RETENTION
LOCAL_RETENTION_DAYS=7
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
# GCS RETENTION
echo "Cleaning old backups from GCS..."
gsutil ls "$BUCKET_NAME/" | while read file; do
filename=$(basename "$file")
# Extract timestamp from filename
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
# Convert to seconds since epoch
file_date="2025-09-24_13-00-54"
date_part=${file_date%_*} # "2025-09-24"
time_part=${file_date#*_} # "13-00-54"
time_part=${time_part//-/:} # "13:00:54"
file_ts=$(date -d "$date_part $time_part" +%s)
# echo "$file, $filename, $file_date, $file_ts"
if [ -z "$file_ts" ]; then
continue
fi
now=$(date +%s)
diff_days=$(( (now - file_ts) / 86400 ))
echo "File: $filename is $diff_days days old."
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
echo "Deleting $file from GCS..."
gsutil rm "$file"
fi
done
echo "Backup and retention process completed at $(date)."
EOF
chmod +x /home/martin/backup.sh
# Add cron job: daily at 2AM
( crontab -l 2>/dev/null; echo '0 2 * * * /home/martin/backup.sh >> /home/martin/backup.log 2>&1 || curl -H "Content-Type: application/json" -X POST -d "{\"content\": \" Backup FAILED on $(hostname) at $(date)\"}" https://discord.com/api/webhooks/1420405275340574873/XgF5pgHABvvWT2fyWASBs3VhAF7Zy11rCH2BkI_RBxH1Xd5duWxGtukrc1cPy1ZucNwx' ) | crontab -
# tail -f /home/martin/backup.log
}

View File

@@ -1,18 +0,0 @@
#!/bin/bash
set -e
cd $(dirname "$0")
#gcloud compute firewall-rules create allow-iap-ssh \
# --direction=INGRESS \
# --action=ALLOW \
# --rules=tcp:22 \
# --source-ranges=35.235.240.0/20 \
# --target-tags=iap-ssh
# gcloud compute instances add-tags "supabase-backup-vm" --tags=iap-ssh --zone="us-west1-b"
gcloud compute ssh --zone "us-west1-b" "supabase-backup-vm" --project "compass-130ba" --tunnel-through-iap
# sudo crontab -u backup -l

View File

@@ -1,81 +0,0 @@
#!/bin/bash
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
set -e
cd $(dirname "$0")
export ENV=prod
if [ "$ENV" = "prod" ]; then
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
elif [ "$ENV" = "dev" ]; then
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
else
echo "Error: ENV must be 'prod' or 'dev'" >&2
exit 1
fi
# Config
PGPORT="5432"
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
PGDATABASE="postgres"
# Retrieve password from Secret Manager
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
BACKUP_DIR="/tmp/supabase_backups"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%F_%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
export PGPASSWORD
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
if [ $? -ne 0 ]; then
echo "Backup failed!"
exit 1
fi
echo "Backup successful: $BACKUP_FILE"
# UPLOAD TO GCS
echo "Uploading backup to GCS..."
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
# LOCAL RETENTION
LOCAL_RETENTION_DAYS=7
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
# GCS RETENTION
echo "Cleaning old backups from GCS..."
gsutil ls "$BUCKET_NAME/" | while read file; do
filename=$(basename "$file")
# Extract timestamp from filename
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
# Convert to seconds since epoch
file_date="2025-09-24_13-00-54"
date_part=${file_date%_*} # "2025-09-24"
time_part=${file_date#*_} # "13-00-54"
time_part=${time_part//-/:} # "13:00:54"
file_ts=$(date -d "$date_part $time_part" +%s)
# echo "$file, $filename, $file_date, $file_ts"
if [ -z "$file_ts" ]; then
continue
fi
now=$(date +%s)
diff_days=$(( (now - file_ts) / 86400 ))
echo "File: $filename is $diff_days days old."
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
echo "Deleting $file from GCS..."
gsutil rm "$file"
fi
done
echo "Backup and retention process completed at $(date)."

View File

@@ -321,6 +321,7 @@ export const API = (_apiTypeCheck = {
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(),
compatibleWithUserId: z.string().optional(),
orderBy: z

View File

@@ -20,10 +20,10 @@ export class APIError extends Error {
}
}
const prefix = 'v0'
const prefix = ''
export function pathWithPrefix(path: string) {
return `/${prefix}${path}`
return `${prefix}${path}`
}
export function getWebsocketUrl() {
@@ -33,5 +33,5 @@ export function getWebsocketUrl() {
export function getApiUrl(path: string) {
const protocol = IS_LOCAL ? 'http' : 'https'
return `${protocol}://${BACKEND_DOMAIN}/${prefix}/${path}`
return `${protocol}://${BACKEND_DOMAIN}${prefix}/${path}`
}

View File

@@ -54,7 +54,7 @@ export class APIRealtimeClient {
// subscribers by the topic they are subscribed to
subscriptions: Map<string, BroadcastHandler[]>
connectTimeout?: NodeJS.Timeout
heartbeat?: NodeJS.Timeout
heartbeat?: number | undefined;
constructor(url: string) {
this.url = url
@@ -90,10 +90,12 @@ export class APIRealtimeClient {
if (VERBOSE_LOGGING) {
console.info('API websocket opened.')
}
this.heartbeat = setInterval(
async () => this.sendMessage('ping', {}).catch(console.error),
30000
)
// Send a heartbeat ping every 25s
this.heartbeat = window.setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.sendMessage('ping', {}).catch(console.error);
}
}, 25000);
if (this.subscriptions.size > 0) {
this.sendMessage('subscribe', {
topics: Array.from(this.subscriptions.keys()),
@@ -105,7 +107,7 @@ export class APIRealtimeClient {
if (VERBOSE_LOGGING) {
console.info(`API websocket closed with code=${ev.code}: ${ev.reason}`)
}
clearInterval(this.heartbeat)
if (this.heartbeat) clearInterval(this.heartbeat)
// mqp: we might need to change how the txn stuff works if we ever want to
// implement "wait until i am subscribed, and then do something" in a component.

View File

@@ -62,6 +62,7 @@ export const baseProfilesSchema = z.object({
visibility: z.union([z.literal('public'), z.literal('member')]),
bio: contentSchema.optional().nullable(),
bio_length: z.number().optional().nullable(),
geodb_city_id: z.string().optional(),
city: z.string(),

View File

@@ -1,8 +1,8 @@
export const MAX_INT = 99999
export const MIN_INT = -MAX_INT
export const supportEmail = 'compass.meet.info@gmail.com';
export const marketingEmail = 'compass.meet.marketing@gmail.com';
export const supportEmail = 'hello@compassmeet.com';
// export const marketingEmail = 'hello@compassmeet.com';
export const githubRepo = "https://github.com/CompassConnections/Compass";
export const githubIssues = `${githubRepo}/issues`
@@ -10,6 +10,13 @@ export const githubIssues = `${githubRepo}/issues`
export const paypalLink = "https://www.paypal.com/paypalme/CompassConnections"
export const patreonLink = "https://patreon.com/CompassMeet"
export const discordLink = "https://discord.gg/8Vd7jzqjun"
export const stoatLink = "https://stt.gg/YKQp81yA"
export const redditLink = "https://www.reddit.com/r/CompassConnect"
export const xLink = "https://x.com/compassmeet"
export const formLink = "https://forms.gle/tKnXUMAbEreMK6FC6"
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

@@ -26,7 +26,7 @@ export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
export const IS_LOCAL = !IS_GOOGLE_CLOUD && !IS_VERCEL
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_LOCAL ? 'local' : 'unknown'
console.log(`Running in ${HOSTING_ENV} (${ENV})`,);
console.debug(`Running in ${HOSTING_ENV} (${ENV})`,);
// class MissingKeyError implements Error {
// constructor(key: string) {
@@ -68,20 +68,25 @@ export const VERIFIED_USERNAMES = [
export const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
export const RESERVED_PATHS = [
'404',
'_app',
'_document',
'_next',
'about',
'ad',
'add-funds',
'ads',
'admin',
'ads',
'analytics',
'api',
'browse',
'career',
'careers',
'charts',
'chat',
'chats',
'common',
'confirm-email',
'contact',
'contacts',
'create',
@@ -89,6 +94,8 @@ export const RESERVED_PATHS = [
'discord',
'embed',
'facebook',
'faq',
'financials',
'find',
'github',
'google',
@@ -96,18 +103,23 @@ export const RESERVED_PATHS = [
'groups',
'help',
'home',
'index',
'link',
'linkAccount',
'links',
'live',
'login',
'love-questions',
'manifest',
'market',
'markets',
'md',
'members',
'message',
'messages',
'notifications',
'og-test',
'organization',
'payments',
'privacy',
'profile',
@@ -115,16 +127,22 @@ export const RESERVED_PATHS = [
'questions',
'referral',
'referrals',
'register',
'send',
'server-sitemap',
'sign-in',
'sign-in-waiting',
'signin',
'signup',
'sitemap',
'slack',
'social',
'stats',
'styles',
'support',
'team',
'terms',
'tips-bio',
'twitch',
'twitter',
'user',

View File

@@ -5,7 +5,7 @@ export const DEV_CONFIG: EnvConfig = {
domain: 'dev.compassmeet.com',
backendDomain: 'api.dev.compassmeet.com',
supabaseInstanceId: 'zbspxezubpzxmuxciurg',
supabasePwd: 'FO3y0G7chzdq6aE7', // For database write access (dev). A 16-character password with digits and letters.
supabasePwd: 'ZTNlifGKofSKhu8c', // For database write access (dev). A 16-character password with digits and letters.
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpic3B4ZXp1YnB6eG11eGNpdXJnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc2ODM0MTMsImV4cCI6MjA3MzI1OTQxM30.ZkM7zlawP8Nke0T3KJrqpOQ4DzqPaXTaJXLC2WU8Y7c',
googleApplicationCredentials: 'googleApplicationCredentials-dev.json',
firebaseConfig: {
@@ -13,10 +13,13 @@ export const DEV_CONFIG: EnvConfig = {
authDomain: "compass-57c3c.firebaseapp.com",
projectId: "compass-57c3c",
storageBucket: "compass-57c3c.firebasestorage.app",
privateBucket: 'compass-private.firebasestorage.app',
privateBucket: 'compass-130ba-private',
messagingSenderId: "297460199314",
appId: "1:297460199314:web:c45678c54285910e255b4b",
measurementId: "G-N6LZ64EMJ2",
region: 'us-west1',
},
adminIds: [
'ULxLz04VW1V4vbnj5XLwvzCSkYd2', // Martin
],
}

View File

@@ -44,7 +44,7 @@ export const PROD_CONFIG: EnvConfig = {
authDomain: "compass-130ba.firebaseapp.com",
projectId: "compass-130ba",
storageBucket: "compass-130ba.firebasestorage.app",
privateBucket: 'compass-private.firebasestorage.app',
privateBucket: 'compass-130ba-private',
messagingSenderId: "253367029065",
appId: "1:253367029065:web:b338785af99d4145095e98",
measurementId: "G-2LSQYJQE6P",

View File

@@ -7,6 +7,7 @@ export type FilterFields = {
geodbCityIds: string[] | null
genders: string[]
name: string | undefined
shortBio: boolean | undefined
} & Pick<
ProfileRow,
| 'wants_kids_strength'
@@ -47,6 +48,7 @@ export const initialFilters: Partial<FilterFields> = {
is_smoker: undefined,
pref_relation_styles: undefined,
pref_gender: undefined,
shortBio: undefined,
orderBy: 'created_time',
}
export type OriginLocation = { id: string; name: string }

View File

@@ -23,10 +23,10 @@ export const geodbFetch = async (endpoint: string) => {
}
const data = await res.json()
console.log('geodbFetch', endpoint, data)
console.debug('geodbFetch', endpoint, data)
return {status: 'success', data}
} catch (error) {
console.log('geodbFetch', endpoint, error)
console.debug('geodbFetch', endpoint, error)
return {status: 'failure', data: error}
}
}

View File

@@ -5,7 +5,7 @@ const isPreferredGender = (
preferredGenders: string[] | undefined,
gender: string | undefined
) => {
// console.log('isPreferredGender', preferredGenders, gender)
// console.debug('isPreferredGender', preferredGenders, gender)
if (preferredGenders === undefined || preferredGenders.length === 0 || gender === undefined) return true
// If simple gender preference, don't include non-binary.
@@ -19,7 +19,7 @@ const isPreferredGender = (
}
export const areGenderCompatible = (profile1: ProfileRow, profile2: ProfileRow) => {
// console.log('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
// console.debug('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
return (
isPreferredGender(profile1.pref_gender, profile2.gender) &&
isPreferredGender(profile2.pref_gender, profile1.gender)

View File

@@ -4,7 +4,7 @@ import { User } from 'common/user'
export type ProfileRow = Row<'profiles'>
export type Profile = ProfileRow & { user: User }
export const getProfileRow = async (userId: string, db: SupabaseClient) => {
console.log('getProfileRow', userId)
console.debug('getProfileRow', userId)
const res = await run(db.from('profiles').select('*').eq('user_id', userId))
return res.data[0]
}

View File

@@ -53,7 +53,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') return
if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy' || typedKey == 'shortBio') return
if (Array.isArray(value) && value.length === 0) return
if (initialFilters[typedKey] === value) return

View File

@@ -32,7 +32,7 @@ type SecretId = (typeof secrets)[number]
export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => {
if (!ids.length && IS_LOCAL) return {}
// console.log('Fetching secrets...')
// console.debug('Fetching secrets...')
let client: SecretManagerServiceClient
if (credentials) {
const projectId = credentials['project_id']
@@ -47,7 +47,7 @@ export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => {
const secretIds = ids.length > 0 ? ids : secrets
console.log('secretIds', secretIds)
console.debug('secretIds', secretIds)
const fullSecretNames = secretIds.map(
(secret: string) =>
@@ -75,7 +75,7 @@ export const loadSecretsToEnv = async (credentials?: any) => {
for (const [key, value] of Object.entries(allSecrets)) {
if (key && value) {
process.env[key] = value
// console.log(key, value)
// console.debug(key, value)
}
}
refreshConfig()

View File

@@ -221,45 +221,6 @@ export type Database = {
}
Relationships: []
}
profile_comments: {
Row: {
content: Json
created_time: string
hidden: boolean
id: number
on_user_id: string
reply_to_comment_id: number | null
user_avatar_url: string
user_id: string
user_name: string
user_username: string
}
Insert: {
content: Json
created_time?: string
hidden?: boolean
id?: never
on_user_id: string
reply_to_comment_id?: number | null
user_avatar_url: string
user_id: string
user_name: string
user_username: string
}
Update: {
content?: Json
created_time?: string
hidden?: boolean
id?: never
on_user_id?: string
reply_to_comment_id?: number | null
user_avatar_url?: string
user_id?: string
user_name?: string
user_username?: string
}
Relationships: []
}
private_user_message_channel_members: {
Row: {
channel_id: number
@@ -401,10 +362,52 @@ export type Database = {
}
Relationships: []
}
profile_comments: {
Row: {
content: Json
created_time: string
hidden: boolean
id: number
on_user_id: string
reply_to_comment_id: number | null
user_avatar_url: string
user_id: string
user_name: string
user_username: string
}
Insert: {
content: Json
created_time?: string
hidden?: boolean
id?: number
on_user_id: string
reply_to_comment_id?: number | null
user_avatar_url: string
user_id: string
user_name: string
user_username: string
}
Update: {
content?: Json
created_time?: string
hidden?: boolean
id?: number
on_user_id?: string
reply_to_comment_id?: number | null
user_avatar_url?: string
user_id?: string
user_name?: string
user_username?: string
}
Relationships: []
}
profiles: {
Row: {
age: number | null
bio: Json | null
bio_length: number | null
bio_text: string | null
bio_tsv: unknown | null
born_in_location: string | null
city: string
city_latitude: number | null
@@ -443,13 +446,16 @@ export type Database = {
twitter: string | null
university: string | null
user_id: string
visibility: Database['public']['Enums']['profile_visibility']
visibility: Database['public']['Enums']['lover_visibility']
wants_kids_strength: number
website: string | null
}
Insert: {
age?: number | null
bio?: Json | null
bio_length?: number | null
bio_text?: string | null
bio_tsv?: unknown | null
born_in_location?: string | null
city: string
city_latitude?: number | null
@@ -465,7 +471,7 @@ export type Database = {
geodb_city_id?: string | null
has_kids?: number | null
height_in_inches?: number | null
id?: never
id?: number
is_smoker?: boolean | null
is_vegetarian_or_vegan?: boolean | null
last_modification_time?: string
@@ -488,13 +494,16 @@ export type Database = {
twitter?: string | null
university?: string | null
user_id: string
visibility?: Database['public']['Enums']['profile_visibility']
visibility?: Database['public']['Enums']['lover_visibility']
wants_kids_strength?: number
website?: string | null
}
Update: {
age?: number | null
bio?: Json | null
bio_length?: number | null
bio_text?: string | null
bio_tsv?: unknown | null
born_in_location?: string | null
city?: string
city_latitude?: number | null
@@ -510,7 +519,7 @@ export type Database = {
geodb_city_id?: string | null
has_kids?: number | null
height_in_inches?: number | null
id?: never
id?: number
is_smoker?: boolean | null
is_vegetarian_or_vegan?: boolean | null
last_modification_time?: string
@@ -533,7 +542,7 @@ export type Database = {
twitter?: string | null
university?: string | null
user_id?: string
visibility?: Database['public']['Enums']['profile_visibility']
visibility?: Database['public']['Enums']['lover_visibility']
wants_kids_strength?: number
website?: string | null
}
@@ -693,6 +702,10 @@ export type Database = {
Args: Record<PropertyKey, never>
Returns: Record<string, unknown>[]
}
get_love_question_answers_and_lovers: {
Args: { p_question_id: number }
Returns: Record<string, unknown>[]
}
get_love_question_answers_and_profiles: {
Args: { p_question_id: number }
Returns: Record<string, unknown>[]
@@ -755,7 +768,7 @@ export type Database = {
}
}
Enums: {
profile_visibility: 'public' | 'member'
lover_visibility: 'public' | 'member'
}
CompositeTypes: {
[_ in never]: never
@@ -883,7 +896,7 @@ export type CompositeTypes<
export const Constants = {
public: {
Enums: {
profile_visibility: ['public', 'member'],
lover_visibility: ['public', 'member'],
},
},
} as const

View File

@@ -30,7 +30,7 @@ export function createClient(
opts?: SupabaseClientOptionsGeneric<'public'>
) {
const url = `https://${instanceId}.supabase.co`
// console.log('createClient', instanceId, key, opts)
// console.debug('createClient', instanceId, key, opts)
return createClientGeneric(
url,
key,

View File

@@ -57,6 +57,7 @@ export const getDefaultNotificationPreferences = (isDev?: boolean) => {
return defaults
}
export const UNSUBSCRIBE_URL = 'https://compassmeet.com/notifications';
export const getNotificationDestinationsForUser = (
privateUser: PrivateUser,
type: notification_preference
@@ -72,7 +73,7 @@ export const getNotificationDestinationsForUser = (
destinations.includes('browser') && !opt_out.includes('browser'),
sendToMobile:
destinations.includes('mobile') && !opt_out.includes('mobile'),
unsubscribeUrl: 'https://compassmeet.com/notifications',
unsubscribeUrl: UNSUBSCRIBE_URL,
urlToManageThisNotification: '/notifications',
}
}

View File

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

View File

@@ -32,7 +32,7 @@ export function factorizeMatrix(
const mFeatures = fillMatrix(m, FEATURES, initCell)
const nFeatures = fillMatrix(n, FEATURES, initCell)
console.log('rows', m, 'columns', n, 'numPoints', points)
console.debug('rows', m, 'columns', n, 'numPoints', points)
const updateFeature = (a: number, b: number, error: number) =>
a + LEARNING_RATE * (2 * error * b - REGULARIZATION_RATE * a)
@@ -75,7 +75,7 @@ export function factorizeMatrix(
}
}
}
console.log(iter, 'error', totalError / points)
console.debug(iter, 'error', totalError / points)
// Complete factorization process if total error falls below a certain threshold
if (totalError / points < THRESHOLD) break

View File

@@ -44,7 +44,7 @@ export function wantsKidsToHasKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
export function wantsKidsDatabaseToWantsKidsFilter(
wantsKidsStrength: wantsKidsDatabase
) {
// console.log(wantsKidsStrength)
// console.debug(wantsKidsStrength)
if (wantsKidsStrength == wantsKidsLabels.no_preference.strength) {
return wantsKidsLabels.no_preference.strength
}

View File

@@ -5,7 +5,7 @@
"rules": "storage.rules"
},
{
"bucket": "compass-130ba-private.firebasestorage.app",
"bucket": "compass-130ba-private",
"rules": "private-storage.rules"
}
]

View File

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

View File

@@ -5,7 +5,7 @@ cd "$(dirname "$0")"/..
source .env
export url=http://localhost:8088/v0
export url=http://localhost:8088
#export url=https://api.compassmeet.com
export endpoint=/internal/send-search-notifications

View File

@@ -9,6 +9,9 @@ if [ ! -f .env ]; then
echo ".env file created from .env.example"
fi
source .env.example
source .env
echo $GOOGLE_CREDENTIALS_ENC_PWD
openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 -in secrets/googleApplicationCredentials-dev.json.enc -out backend/shared/src/googleApplicationCredentials-dev.json -pass pass:$GOOGLE_CREDENTIALS_ENC_PWD

View File

@@ -58,7 +58,7 @@ function AddCompatibilityQuestionModal(props: {
)
const afterAddQuestion = (newQuestion: rowFor<'love_questions'>) => {
setDbQuestion(newQuestion)
console.log('setDbQuestion', newQuestion)
console.debug('setDbQuestion', newQuestion)
}
return (
@@ -137,7 +137,7 @@ function CreateCompatibilityModalContent(props: {
options: generateJson(),
};
const newQuestion = await api('create-compatibility-question', data)
console.log('create-compatibility-question', newQuestion, data)
console.debug('create-compatibility-question', newQuestion, data)
const q = newQuestion?.question
if (q) {
afterAddQuestion(q as rowFor<'love_questions'>)

View File

@@ -157,6 +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">
<span>
Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.
</span>
</Row>
{index !== null &&
index !== undefined &&
total !== null &&
@@ -184,7 +189,7 @@ export function AnswerCompatibilityQuestionContent(props: {
<Col
className={clsx(
SCROLLABLE_MODAL_CLASS,
'h-[30rem] w-full gap-4 sm:h-[30rem]'
'h-[20rem] w-full gap-4 sm:h-[30rem]'
)}
>
<Col className="gap-2">

View File

@@ -1,16 +1,17 @@
import { JSONContent } from '@tiptap/core'
import { MAX_DESCRIPTION_LENGTH } from 'common/envs/constants'
import { Profile } from 'common/love/profile'
import { tryCatch } from 'common/util/try-catch'
import { Button } from 'web/components/buttons/button'
import { Col } from 'web/components/layout/col'
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, {useState} from "react";
import {Editor} from '@tiptap/core'
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
import {Profile} from 'common/love/profile'
import {tryCatch} from 'common/util/try-catch'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
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 ReactMarkdown from "react-markdown";
import Link from "next/link"
import {MIN_BIO_LENGTH} from "common/constants";
const placeHolder = "Tell us about yourself — and what you're looking for!";
@@ -20,13 +21,10 @@ Write a clear and engaging bio to help others understand who you are and the con
- Connection goals (friendship, romantic, collaborative) and availability
- What makes you unique and what you care about
- Expectations, boundaries, and personality traits
- How to contact you or start a conversation (email, social media, etc.)
- Optional: romantic preferences, lifestyle habits, and conversation starters
`
export function CharLimitText() {
return <p>Profiles with fewer than 250 characters will be hidden by default from the profile grid write a meaningful bio so others can find you through keyword search and connect intentionally.</p>
}
export function BioTips() {
const [showMoreInfo, setShowMoreInfo] = useState(false)
@@ -62,18 +60,16 @@ export function EditableBio(props: {
onSave: () => void
onCancel?: () => void
}) {
const { profile, onCancel, onSave } = props
const editor = useTextEditor({
max: MAX_DESCRIPTION_LENGTH,
defaultValue: (profile.bio as JSONContent) ?? '',
placeholder: placeHolder,
})
const {profile, onCancel, onSave} = props
const [editor, setEditor] = useState<any>(null)
const [textLength, setTextLength] = useState(0);
const hideButtons = editor?.getText().length === 0 && !profile.bio
const hideButtons = (textLength === 0) && !profile.bio
const saveBio = async () => {
if (!editor) return
const { error } = await tryCatch(updateProfile({ bio: editor.getJSON() }))
console.log(editor.getText().length)
const {error} = await tryCatch(updateProfile({bio: editor.getJSON(), bio_length: editor.getText().length}))
if (error) {
console.error(error)
@@ -85,10 +81,15 @@ export function EditableBio(props: {
return (
<Col className="relative w-full">
<CharLimitText/>
<BioTips/>
<TextEditor editor={editor} />
<BaseBio
defaultValue={profile.bio}
onEditor={(e) => {
setEditor(e);
e?.on('update', () => {
setTextLength(e.getText().length);
});
}}
/>
{!hideButtons && (
<Row className="absolute bottom-1 right-1 justify-between gap-2">
{onCancel && (
@@ -112,40 +113,53 @@ export function EditableBio(props: {
}
export function SignupBio(props: {
onChange: (e: JSONContent) => void
onChange: (e: Editor) => void
}) {
const { onChange } = props
const editor = useTextEditor({
max: MAX_DESCRIPTION_LENGTH,
defaultValue: '',
placeholder: placeHolder,
})
// const [charLength, setCharLength] = useState(0)
const {onChange} = props
return (
<Col className="relative w-full">
<CharLimitText/>
<BioTips/>
<TextEditor
editor={editor}
onBlur={() => {
// console.log('onchange', editor?.getText())
<BaseBio
onBlur={(editor) => {
if (!editor) return
const e = editor.getJSON()
// console.log(e)
// const text = e.content.map((block: any) => block.content?.map((c: any) => c.text).join('') ?? '').join('');
// setCharLength(text.length)
// console.log(text, text.length)
// if (text.length < 250) {
// return; // do not save
// }
// console.log('bio changed', e, profile.bio);
onChange(e)
onChange(editor)
}}
/>
{/*<p>{charLength} / 250</p>*/}
</Col>
)
}
interface BaseBioProps {
defaultValue?: any
onBlur?: (editor: any) => void
onEditor?: (editor: any) => void
}
export function BaseBio({defaultValue, onBlur, onEditor}: BaseBioProps) {
const editor = useTextEditor({
// extensions: [StarterKit],
max: MAX_DESCRIPTION_LENGTH,
defaultValue: defaultValue,
placeholder: placeHolder,
})
const textLength = editor?.getText().length ?? 0
useEffect(() => {
onEditor?.(editor)
}, [editor, onEditor])
return (
<div>
{textLength < MIN_BIO_LENGTH &&
<p>
Add {MIN_BIO_LENGTH - textLength} more {MIN_BIO_LENGTH - textLength === 1 ? 'character' : 'characters'} so
your profile can appear in search resultsor leave it for now and explore others profiles first.
</p>
}
<BioTips/>
<TextEditor
editor={editor}
onBlur={() => onBlur?.(editor)}
/>
</div>
)
}

View File

@@ -55,7 +55,7 @@
// // })
// setDialogOpen(false)
//
// // console.log('result', result)
// // console.debug('result', result)
//
// // if (result.success) {
// // window.location.reload()

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export const GeneralButton = (props: {
url: string
content: string
}) => {
const {url, content} = props
return <div className="rounded-xl shadow p-6 flex flex-col items-center">
<Link
href={url}
className="px-6 py-2 rounded-full bg-gray-200 text-gray-800 font-semibold text-lg shadow hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 transition"
target={url.startsWith('http') ? '_blank' : undefined}
rel={url.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{content}
</Link>
</div>;
}

View File

@@ -16,6 +16,7 @@ import { SimpleCopyTextButton } from 'web/components/buttons/copy-link-button'
import { api } from 'web/lib/api'
import { buildArray } from 'common/util/array'
import { DeleteYourselfButton } from '../profile/delete-yourself'
import {toast} from "react-hot-toast";
export function MoreOptionsUserButton(props: { user: User }) {
const { user } = props
@@ -55,11 +56,22 @@ export function MoreOptionsUserButton(props: { user: User }) {
<Button
color={'red'}
size="xs"
onClick={() => {
api('ban-user', {
userId,
unban: user.isBannedFromPosting ?? false,
})
onClick={async () => {
await toast.promise(
api('ban-user', {
userId,
unban: user.isBannedFromPosting ?? false,
}),
{
loading: 'Banning...',
success: () => {
return 'User banned!'
},
error: () => {
return 'Error banning user'
},
}
)
}}
>
{user.isBannedFromPosting ? 'Banned' : 'Ban User'}

View File

@@ -59,7 +59,7 @@ export const ChatMessageItem = memo(function ChatMessageItem(props: {
userId={id}
/>
)}
<Col className="@sm:max-w-[calc(100vw-6rem)] @md:max-w-[80%] max-w-[calc(100vw-2rem)]">
<Col className="sm:max-w-[calc(100vw-6rem)] md:max-w-[70%]">
{firstOfUser && !isMe && chat.visibility !== 'system_status' && (
<Row className={'items-center gap-3'}>
<Link

View File

@@ -13,6 +13,7 @@ import {RelationshipFilter, RelationshipFilterText,} from './relationship-filter
import {MyMatchesToggle} from './my-matches-toggle'
import {Profile} from 'common/love/profile'
import {FilterFields} from "common/filters";
import {ShortBioToggle} from "web/components/filters/short-bio-toggle";
export function DesktopFilters(props: {
filters: Partial<FilterFields>
@@ -133,6 +134,12 @@ export function DesktopFilters(props: {
}
popoverClassName="bg-canvas-50"
/>
{/* Short Bios */}
<ShortBioToggle
updateFilter={updateFilter}
filters={filters}
hidden={false}
/>
{/* PREFERRED GENDER */}
{/*<CustomizeableDropdown*/}
{/* buttonContent={(open: boolean) => (*/}

View File

@@ -12,6 +12,7 @@ import {Profile} from 'common/love/profile'
import {Gender} from 'common/gender'
import {RelationshipType} from 'web/lib/util/convert-relationship-type'
import {FilterFields} from "common/filters";
import {ShortBioToggle} from "web/components/filters/short-bio-toggle";
export function MobileFilters(props: {
filters: Partial<FilterFields>
@@ -132,6 +133,15 @@ export function MobileFilters(props: {
>
<GenderFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* Short Bios */}
<Col className="p-4 pb-2">
<ShortBioToggle
updateFilter={updateFilter}
filters={filters}
hidden={false}
/>
</Col>
{/* PREFERRED GENDER */}
{/*<MobileFilterSection*/}
{/* title="Interested in"*/}

View File

@@ -25,7 +25,7 @@ function isOrderBy(input: string): input is FilterFields['orderBy'] {
}
const TYPING_SPEED = 100; // ms per character
const HOLD_TIME = 5000; // ms to hold full word before deleting or switching
const HOLD_TIME = 2000; // ms to hold full word before deleting or switching
export const WORDS: string[] = [
// Values
"Minimalism",

View File

@@ -0,0 +1,33 @@
import {Row} from 'web/components/layout/row'
import clsx from 'clsx'
import {FilterFields} from "common/filters";
export function ShortBioToggle(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
hidden: boolean
}) {
const {filters, updateFilter, hidden} = props
if (hidden) {
return <></>
}
const label = 'Include Short Bios'
const on = filters.shortBio || false
return (
<Row className={clsx('mr-2 items-center', on && 'font-semibold')}>
<input
id={label}
type="checkbox"
className="border-ink-300 bg-canvas-0 dark:border-ink-500 text-primary-600 focus:ring-primary-500 h-4 w-4 rounded"
checked={on}
onChange={(e) => updateFilter({shortBio: e.target.checked ? true : undefined})}
/>
<label htmlFor={label} className={clsx('text-ink-600 ml-2')}>
{label}
</label>
</Row>
)
}

View File

@@ -80,7 +80,7 @@ export const useFilters = (you: Profile | undefined) => {
(you?.wants_kids_strength ?? 2) as wantsKidsDatabase
),
}
console.log(you, yourFilters)
console.debug(you, yourFilters)
const isYourFilters =
!!you &&

View File

@@ -72,9 +72,11 @@ export function LoggedOutHome() {
<AboutBox title="Built for Depth" text="Search and filter by values, interests, goals, and keywords — from “stoicism” to “sustainable living.” Surface the connections that truly matter."/>
<AboutBox title="Community Owned & Open Source" text="Free forever. No ads, no subscriptions. Built by the people who use it, for the benefit of everyone."/>
</div>
<p>
Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to browsing a public digital good designed to serve people, not profit.
</p>
<div className="mt-10 max-w-xl mx-auto">
<p className="text-center">
Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to browsing a public digital good designed to serve people, not profit.
</p>
</div>
</div>
</div>
<div className="block lg:hidden h-12"></div>

View File

@@ -1,6 +1,8 @@
import {HomeIcon, QuestionMarkCircleIcon} from '@heroicons/react/outline'
import {
GlobeAltIcon,
HomeIcon as SolidHomeIcon,
LinkIcon,
QuestionMarkCircleIcon as SolidQuestionIcon,
UserCircleIcon,
} from '@heroicons/react/solid'
@@ -21,6 +23,7 @@ import Sidebar from './nav/love-sidebar'
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";
export function LovePage(props: {
trackPageView: string | false
@@ -43,13 +46,11 @@ export function LovePage(props: {
const profile = useProfile()
const bottomNavOptions = user
? getBottomNavigation(user, profile)
: signedOutNavigation()
: getBottomSignedOutNavigation()
// const [isModalOpen, setIsModalOpen] = useState(false)
const desktopSidebarOptions = getDesktopNav(user)
const desktopSidebarOptions = getDesktopNavigation(user)
const mobileSidebarOptions = user
? getSidebarNavigation(() => setIsAddFundsModalOpen(true))
: []
const mobileSidebarOptions = getMobileSidebar(() => setIsAddFundsModalOpen(true))
// eslint-disable-next-line react-hooks/rules-of-hooks
trackPageView && useTracking(`view love ${trackPageView}`, trackPageProps)
@@ -58,7 +59,7 @@ export function LovePage(props: {
return (
<>
<GoogleOneTapLogin className="fixed bottom-12 right-4 z-[1000]" />
<GoogleOneTapLogin className="fixed bottom-12 right-4 z-[1000]"/>
<Col
className={clsx(
'pb-[58px] lg:pb-0', // bottom bar padding
@@ -69,13 +70,18 @@ export function LovePage(props: {
position={isMobile ? 'bottom-center' : 'top-center'}
containerClassName="!bottom-[70px]"
/>
{/* Maintenance banner */}
{IS_MAINTENANCE &&
<div className="lg:col-span-12 w-full bg-orange-500 text-white text-center text-sm py-2 px-3">
Maintenance in progress: Some features may be broken for the next few hours.
</div>}
{hideSidebar ? (
<div className="lg:col-span-2 lg:flex" />
<div className="lg:col-span-2 lg:flex"/>
) : (
<Sidebar
navigationOptions={desktopSidebarOptions}
className="sticky top-0 hidden self-start px-2 lg:col-span-2 lg:flex sidebar-nav bg-canvas-25"
/>
/>
)}
<main
className={clsx(
@@ -97,14 +103,23 @@ export function LovePage(props: {
)
}
const Profiles = { name: 'Profiles', href: '/', icon: SolidHomeIcon };
const ProfilesHome = { name: 'Profiles', href: '/', icon: HomeIcon };
const faq = { name: 'FAQ', href: '/faq', icon: SolidQuestionIcon };
const About = { name: 'About', href: '/about', icon: QuestionMarkCircleIcon };
const Signin = { name: 'Sign in', href: '/signin', icon: UserCircleIcon };
const Profiles = {name: 'Profiles', href: '/', icon: SolidHomeIcon};
const ProfilesHome = {name: 'Profiles', href: '/', icon: HomeIcon};
const faq = {name: 'FAQ', href: '/faq', icon: SolidQuestionIcon};
const About = {name: 'About', href: '/about', icon: QuestionMarkCircleIcon};
const Signin = {name: 'Sign in', href: '/signin', icon: UserCircleIcon};
const Notifs = {name: 'Notifs', href: `/notifications`, icon: NotificationsIcon};
const NotifsSolid = {name: 'Notifs', href: `/notifications`, icon: SolidNotificationsIcon};
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 base = [
About,
faq,
Social,
Organization,
]
function getBottomNavigation(user: User, profile: Profile | null | undefined) {
return buildArray(
@@ -125,33 +140,28 @@ function getBottomNavigation(user: User, profile: Profile | null | undefined) {
)
}
const signedOutNavigation = () => [
const getBottomSignedOutNavigation = () => [
Profiles,
About,
faq,
Signin,
]
const getDesktopNav = (user: User | null | undefined) => {
const getDesktopNavigation = (user: User | null | undefined) => {
if (user)
return buildArray(
ProfilesHome,
Notifs,
Messages,
About,
faq
...base,
)
return buildArray(
// { name: 'Profiles', href: '/', icon: HomeIcon },
About,
faq
...base
)
}
// No sidebar when signed out
const getSidebarNavigation = (_toggleModal: () => void) => {
const getMobileSidebar = (_toggleModal: () => void) => {
return buildArray(
About,
faq
...base,
)
}

View File

@@ -4,7 +4,7 @@ import { Row } from './layout/row'
import clsx from 'clsx'
export const MultipleOrSingleAvatars = (props: {
avatars: Array<{ avatarUrl: string; id: string }>
avatars: Array<{ avatarUrl: string; id: string; name: string; username: string }>
onClick?: () => void
size: AvatarSizeType
// TODO: standardize these numbers so they are calculated from the size
@@ -17,7 +17,7 @@ export const MultipleOrSingleAvatars = (props: {
if (avatars.length === 0) return null
if (avatars.length === 1) {
return <Avatar size={size} avatarUrl={avatars[0].avatarUrl} />
return <Avatar size={size} avatarUrl={avatars[0].avatarUrl} username={avatars[0].username} />
}
const totalAvatars = avatars.length

View File

@@ -1,18 +1,18 @@
import Link from 'next/link'
import clsx from 'clsx'
import { MenuAlt3Icon } from '@heroicons/react/solid'
import { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react'
import { useRouter } from 'next/router'
import {MenuAlt3Icon} from '@heroicons/react/solid'
import {Dialog, Transition} from '@headlessui/react'
import {Fragment, useState} from 'react'
import {useRouter} from 'next/router'
import Sidebar from './love-sidebar'
import { Item } from './love-sidebar-item'
import { useUser } from 'web/hooks/use-user'
import { Avatar } from 'web/components/widgets/avatar'
import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics'
import { User } from 'common/user'
import { Col } from 'web/components/layout/col'
import { useProfile } from 'web/hooks/use-profile'
import {Item} from './love-sidebar-item'
import {useUser} from 'web/hooks/use-user'
import {Avatar} from 'web/components/widgets/avatar'
import {useIsIframe} from 'web/hooks/use-is-iframe'
import {trackCallback} from 'web/lib/service/analytics'
import {User} from 'common/user'
import {Col} from 'web/components/layout/col'
import {useProfile} from 'web/hooks/use-profile'
const itemClass =
'sm:hover:bg-ink-200 block w-full py-1 px-3 text-center sm:hover:text-primary-700 transition-colors'
@@ -24,7 +24,7 @@ export function BottomNavBar(props: {
navigationOptions: Item[]
sidebarNavigationOptions: Item[]
}) {
const { navigationOptions, sidebarNavigationOptions } = props
const {navigationOptions, sidebarNavigationOptions} = props
const [sidebarOpen, setSidebarOpen] = useState(false)
const router = useRouter()
@@ -38,7 +38,8 @@ export function BottomNavBar(props: {
}
return (
<nav className="border-ink-200 dark:border-ink-300 text-ink-700 bg-canvas-50 fixed inset-x-0 bottom-0 z-50 flex select-none items-center justify-between border-t-2 text-xs lg:hidden sidebar-nav">
<nav
className="border-ink-200 dark:border-ink-300 text-ink-700 bg-canvas-50 fixed inset-x-0 bottom-0 z-50 flex select-none items-center justify-between border-t-2 text-xs lg:hidden sidebar-nav">
{navigationOptions.map((item) => (
<NavBarItem
key={item.name}
@@ -47,26 +48,22 @@ export function BottomNavBar(props: {
user={user}
/>
))}
{!!user && (
<>
<div
className={clsx(
itemClass,
'relative',
sidebarOpen ? selectedItemClass : ''
)}
onClick={() => setSidebarOpen(true)}
>
<MenuAlt3Icon className="mx-auto my-1 h-6 w-6" aria-hidden="true" />
More
</div>
<MobileSidebar
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
sidebarNavigationOptions={sidebarNavigationOptions}
/>
</>
)}
<div
className={clsx(
itemClass,
'relative',
sidebarOpen ? selectedItemClass : ''
)}
onClick={() => setSidebarOpen(true)}
>
<MenuAlt3Icon className="mx-auto my-1 h-6 w-6" aria-hidden="true"/>
More
</div>
<MobileSidebar
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
sidebarNavigationOptions={sidebarNavigationOptions}
/>
</nav>
)
}
@@ -79,7 +76,7 @@ function ProfileItem(props: {
currentPage: string
track: () => void
}) {
const { user, item, touched, setTouched, currentPage, track } = props
const {user, item, touched, setTouched, currentPage, track} = props
const profile = useProfile()
return (
<Link
@@ -114,7 +111,7 @@ function NavBarItem(props: {
user?: User | null
className?: string
}) {
const { item, currentPage, children, user } = props
const {item, currentPage, children, user} = props
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
const [touched, setTouched] = useState(false)
if (item.name === 'Profile' && user) {
@@ -141,7 +138,7 @@ function NavBarItem(props: {
onTouchStart={() => setTouched(true)}
onTouchEnd={() => setTouched(false)}
>
{item.icon && <item.icon className="mx-auto my-1 h-6 w-6" />}
{item.icon && <item.icon className="mx-auto my-1 h-6 w-6"/>}
{children}
{item.name}
</button>
@@ -163,7 +160,7 @@ function NavBarItem(props: {
onTouchStart={() => setTouched(true)}
onTouchEnd={() => setTouched(false)}
>
{item.icon && <item.icon className="mx-auto my-1 h-6 w-6" />}
{item.icon && <item.icon className="mx-auto my-1 h-6 w-6"/>}
{children}
{item.name}
</Link>
@@ -176,7 +173,7 @@ export function MobileSidebar(props: {
setSidebarOpen: (open: boolean) => void
sidebarNavigationOptions: Item[]
}) {
const { sidebarOpen, sidebarNavigationOptions, setSidebarOpen } = props
const {sidebarOpen, sidebarNavigationOptions, setSidebarOpen} = props
return (
<div>
<Transition.Root show={sidebarOpen} as={Fragment}>
@@ -195,7 +192,7 @@ export function MobileSidebar(props: {
leaveTo="opacity-0"
>
{/* background cover */}
<Dialog.Overlay className="bg-canvas-100/75 fixed inset-0" />
<Dialog.Overlay className="bg-canvas-100/75 fixed inset-0"/>
</Transition.Child>
<Transition.Child
as={Fragment}

View File

@@ -65,7 +65,7 @@ export const OptionalLoveUserForm = (props: {
const handleSubmit = async () => {
setIsSubmitting(true)
const {bio: _, ...otherProfileProps} = profile
console.log('otherProfileProps', removeNullOrUndefinedProps(otherProfileProps))
console.debug('otherProfileProps', removeNullOrUndefinedProps(otherProfileProps))
const {error} = await tryCatch(
updateProfile(removeNullOrUndefinedProps(otherProfileProps) as any)
)

View File

@@ -67,7 +67,7 @@ export const ProfileGrid = (props: {
{!isLoadingMore && !isReloading && other_profiles.length === 0 && (
<div className="py-8 text-center">
<p>No profiles found.</p>
<p>Feel free to bookmark your search 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 it!</p>
</div>
)}
</div>

View File

@@ -46,7 +46,7 @@ export default function ProfileHeader(props: {
const isCurrentUser = currentUser?.id === user.id
const [showVisibilityModal, setShowVisibilityModal] = useState(false)
console.log('ProfileProfileHeader', {user, profile, currentUser})
console.debug('ProfileProfileHeader', {user, profile, currentUser})
return (
<Col className="w-full">
@@ -144,13 +144,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

@@ -24,7 +24,7 @@ export function ProfileInfo(props: {
fromSignup?: boolean
}) {
const {profile, user, refreshProfile, fromProfilePage, fromSignup} = props
console.log('Rendering Profile for', user.username, user.name, props)
console.debug('Rendering Profile for', user.username, user.name, props)
const currentUser = useUser()
// const currentProfile = useProfile()
@@ -79,6 +79,7 @@ export function ProfileInfo(props: {
refreshProfile={refreshProfile}
fromProfilePage={fromProfilePage}
fromSignup={fromSignup}
isProfileVisible={isProfileVisible}
// likesGiven={likesGiven ?? []}
// likesReceived={likesReceived ?? []}
// ships={ships ?? []}
@@ -119,7 +120,6 @@ export function ProfileInfo(props: {
{/* />*/}
{/* </Row>*/}
{/* )}*/}
{isProfileVisible && profile.photo_urls && <ProfileCarousel profile={profile}/>}
</>
)
}
@@ -130,6 +130,7 @@ function ProfileContent(props: {
refreshProfile: () => void
fromProfilePage?: Profile
fromSignup?: boolean
isProfileVisible?: true | User
// likesGiven: LikeData[]
// likesReceived: LikeData[]
// ships: ShipData[]
@@ -141,6 +142,7 @@ function ProfileContent(props: {
refreshProfile,
fromProfilePage,
fromSignup,
isProfileVisible
// likesGiven,
// likesReceived,
// ships,
@@ -166,6 +168,7 @@ function ProfileContent(props: {
fromProfilePage={fromProfilePage}
profile={profile}
/>
{isProfileVisible && profile.photo_urls && <ProfileCarousel profile={profile}/>}
<ProfileCommentSection
onUser={user}
profile={profile}

View File

@@ -4,9 +4,7 @@ import {Search} from 'web/components/filters/search'
import {useProfile} from 'web/hooks/use-profile'
import {useCompatibleProfiles} from 'web/hooks/use-profiles'
import {getStars} from 'web/lib/supabase/stars'
import Router from 'next/router'
import {useCallback, useEffect, useRef, useState} from 'react'
import {Button} from 'web/components/buttons/button'
import {ProfileGrid} from 'web/components/profile-grid'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Title} from 'web/components/widgets/title'
@@ -20,8 +18,7 @@ import {useFilters} from "web/components/filters/use-filters";
export function ProfilesHome() {
const user = useUser();
const profile = useProfile();
const you = profile;
const you = useProfile();
const {
filters,
@@ -95,7 +92,7 @@ export function ProfilesHome() {
return (
<>
{!profile && <Button className="mb-4 lg:hidden" onClick={() => Router.push('signup')}>Create a profile</Button>}
{/*{user && !profile && <Button className="mb-4 lg:hidden" onClick={() => Router.push('signup')}>Create a profile</Button>}*/}
<Title className="!mb-2 text-3xl">Profiles</Title>
<Search
youProfile={you}

View File

@@ -141,7 +141,7 @@ const QuestionRow = (props: { row: rowFor<'love_questions'>; user: User }) => {
className={'w-44'}
choicesMap={options}
setChoice={(choice) => {
// console.log(choice)
// console.debug(choice)
const updatedForm = { ...form, multiple_choice: choice }
setForm(updatedForm)
submitAnswer(updatedForm)

View File

@@ -1,4 +1,4 @@
import {useEffect} from 'react'
import {useEffect, useState} from 'react'
import {Title} from 'web/components/widgets/title'
import {Col} from 'web/components/layout/col'
import clsx from 'clsx'
@@ -12,7 +12,7 @@ import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Column} from 'common/supabase/utils'
import {ProfileRow} from 'common/love/profile'
import {SignupBio} from "web/components/bio/editable-bio";
import {JSONContent} from "@tiptap/core";
import {Editor} from "@tiptap/core";
export const initialRequiredState = {
age: undefined,
@@ -49,6 +49,8 @@ export const RequiredLoveUserForm = (props: {
const {user, onSubmit, profileCreatedAlready, setProfile, profile, isSubmitting} = props
const {updateUsername, updateDisplayName, userInfo, updateUserState} = useEditableUserInfo(user)
const [step, setStep] = useState<number>(0)
const {
name,
username,
@@ -83,56 +85,58 @@ export const RequiredLoveUserForm = (props: {
return (
<>
<Title>The Basics</Title>
{!profileCreatedAlready && <div className="text-ink-500 mb-6 text-lg">No endless formswrite your own bio, your own way.</div>}
{step === 1 && !profileCreatedAlready &&
<div className="text-ink-500 mb-6 text-lg">No endless formswrite your own bio, your own way.</div>}
<Col className={'gap-8'}>
<Col>
<label className={clsx(labelClassName)}>Display name</label>
<Row className={'items-center gap-2'}>
<Input
disabled={loadingName}
type="text"
placeholder="Display name"
value={name}
onChange={(e) => {
updateUserState({name: e.target.value || ''})
}}
onBlur={updateDisplayName}
/>
{loadingName && <LoadingIndicator className={'ml-2'}/>}
</Row>
{step === 0 && <Col>
<label className={clsx(labelClassName)}>Display name</label>
<Row className={'items-center gap-2'}>
<Input
disabled={loadingName}
type="text"
placeholder="Display name"
value={name}
onChange={(e) => {
updateUserState({name: e.target.value || ''})
}}
onBlur={updateDisplayName}
/>
{loadingName && <LoadingIndicator className={'ml-2'}/>}
</Row>
{errorName && <span className="text-error text-sm">{errorName}</span>}
</Col>
</Col>}
{!profileCreatedAlready && <>
<Col>
<label className={clsx(labelClassName)}>Username</label>
<Row className={'items-center gap-2'}>
<Input
disabled={loadingUsername}
type="text"
placeholder="Username"
value={username}
onChange={(e) => {
updateUserState({username: e.target.value || ''})
}}
onBlur={updateUsername}
/>
{loadingUsername && <LoadingIndicator className={'ml-2'}/>}
</Row>
{errorUsername && (
<span className="text-error text-sm">{errorUsername}</span>
)}
</Col>
{step === 0 && <Col>
<label className={clsx(labelClassName)}>Username</label>
<Row className={'items-center gap-2'}>
<Input
disabled={loadingUsername}
type="text"
placeholder="Username"
value={username}
onChange={(e) => {
updateUserState({username: e.target.value || ''})
}}
onBlur={updateUsername}
/>
{loadingUsername && <LoadingIndicator className={'ml-2'}/>}
</Row>
{errorUsername && (
<span className="text-error text-sm">{errorUsername}</span>
)}
</Col>}
<Col>
<label className={clsx(labelClassName)}>Bio</label>
<SignupBio
onChange={(e: JSONContent) => {
console.log('bio changed', e, profile.bio)
setProfile('bio', e)
}}
/>
</Col>
{step === 1 && <Col>
<label className={clsx(labelClassName)}>Bio</label>
<SignupBio
onChange={(e: Editor) => {
console.debug('bio changed', e, profile.bio)
setProfile('bio', e.getJSON())
setProfile('bio_length', e.getText().length)
}}
/>
</Col>}
</>
}
@@ -141,7 +145,13 @@ export const RequiredLoveUserForm = (props: {
<Button
disabled={!canContinue || isSubmitting}
loading={isSubmitting}
onClick={onSubmit}
onClick={() => {
if (step === 1) {
onSubmit()
} else {
setStep(1)
}
}}
>
Next
</Button>

View File

@@ -1,10 +1,14 @@
import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import { api } from 'web/lib/api'
import { countryCodeToFlag } from 'web/lib/util/location'
import { ProfileRow } from 'common/love/profile'
import {useEffect, useRef, useState} from 'react'
import {api} from 'web/lib/api'
import {countryCodeToFlag} from 'web/lib/util/location'
import {ProfileRow} from 'common/love/profile'
import {OriginLocation} from "common/filters";
function isDigitString(value: string): boolean {
return /^\d+$/.test(value);
}
export type City = {
geodb_city_id: string
city: string
@@ -44,7 +48,7 @@ export function CityRow(props: {
onSelect: (city: City) => void
className?: string
}) {
const { city, onSelect, className } = props
const {city, onSelect, className} = props
return (
<button
key={city.geodb_city_id}
@@ -56,7 +60,7 @@ export function CityRow(props: {
>
<div className="group-hover:text-ink-950 font-semibold transition-colors">
{city.city}
{city.region_code ? `, ${city.region_code}` : ''}
{city.region_code && !isDigitString(city.region_code) ? `, ${city.region_code}` : ''}
</div>
<div className="text-ink-400 group-hover:text-ink-700 transition-colors">
{countryCodeToFlag(city.country_code) || city.country}
@@ -78,7 +82,7 @@ export const useCitySearch = () => {
setLoading(true)
searchCountRef.current++
const thisSearchCount = searchCountRef.current
const response = await api('search-location', { term: query, limit: 5 })
const response = await api('search-location', {term: query, limit: 8})
if (response.status !== 'success') {
throw new Error(response.data)
}
@@ -106,16 +110,15 @@ export const useCitySearch = () => {
if (query.length < 2) {
setCities([])
return
}
if (query.length >= 2) {
} else {
fetchData()
}
}, 200)
}, 400)
return () => {
clearTimeout(debounce)
}
}, [query])
return { query, setQuery, loading, cities }
return {query, setQuery, loading, cities}
}

View File

@@ -43,14 +43,11 @@ export const Avatar = memo(
Router.push(`/${username}`)
}
}
const fallbackInitial = (username || 'U')[0]; // first character, not encoded string
const url: string = avatarUrl && avatarUrl.length > 0
? avatarUrl
: `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackInitial)}`;
// console.log(url)
// console.log(username, fallbackInitial, url)
// there can be no avatar URL or username in the feed, we show a "submit comment"
// item with a fake grey user circle guy even if you aren't signed in

View File

@@ -1,46 +1,108 @@
import {useEffect, useState} from "react";
import {CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts";
import {getUserCreations} from "web/lib/supabase/users";
import {Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts";
import {getProfilesCreations, getProfilesWithBioCreations} from "web/lib/supabase/users";
export default function ChartComponent() {
const [data, setData] = useState([]);
// Helper to convert rows into date -> count map
function buildCounts(rows: any[]) {
const counts: Record<string, number> = {}
for (const r of rows) {
const date = new Date(r.created_time).toISOString().split('T')[0]
counts[date] = (counts[date] || 0) + 1
}
return counts
}
// Helper to turn count map into cumulative by sorted date array
function cumulativeFromCounts(counts: Record<string, number>, sortedDates: string[]) {
const out: Record<string, number> = {}
let prev = 0
for (const d of sortedDates) {
const v = counts[d] || 0
prev += v
out[d] = prev
}
return out
}
export default function ChartMembers() {
const [data, setData] = useState<any[]>([])
useEffect(() => {
async function loadData() {
// Load some data from the backend API or Supabase
const data = await getUserCreations()
const counts: { [date: string]: number } = {}
data.forEach((d) => {
const date = new Date(d.created_time).toISOString().split('T')[0]
counts[date] = (counts[date] || 0) + 1
})
const json: any = Object.entries(counts).map(([date, value]) => ({date, value}))
let prev = 0
for (const e of json) {
e.value += prev
prev = e.value
}
json.sort((a: any, b: any) => a.date.localeCompare(b.date))
async function load() {
const [allProfiles, bioProfiles] = await Promise.all([
getProfilesCreations(),
getProfilesWithBioCreations(),
])
// Example static data
// const json: any = [
// { date: '2023-01-01', value: 400 },
// { date: '2023-02-01', value: 300 },
// { date: '2023-03-01', value: 500 },
// { date: '2023-04-01', value: 200 },
// { date: '2023-05-01', value: 600 },
// ]
setData(json);
const countsAll = buildCounts(allProfiles)
const countsBio = buildCounts(bioProfiles)
// Build a full daily date range from min to max date for equidistant time axis
const allDates = Object.keys(countsAll)
const bioDates = Object.keys(countsBio)
const minDateStr = [
...allDates,
...bioDates,
].sort((a, b) => a.localeCompare(b))[0]
const maxDateStr = [
...allDates,
...bioDates,
].sort((a, b) => b.localeCompare(a))[0]
function toISODate(d: Date) {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
.toISOString()
.split('T')[0]
}
function addDays(d: Date, days: number) {
const nd = new Date(d)
nd.setUTCDate(nd.getUTCDate() + days)
return nd
}
function buildDailyRange(startStr: string, endStr: string) {
const out: string[] = []
const start = new Date(startStr + 'T00:00:00.000Z')
const end = new Date(endStr + 'T00:00:00.000Z')
for (let d = start; d <= end; d = addDays(d, 1)) {
out.push(toISODate(d))
}
return out
}
const dates = buildDailyRange(minDateStr, maxDateStr)
const cumAll = cumulativeFromCounts(countsAll, dates)
const cumBio = cumulativeFromCounts(countsBio, dates)
const merged = dates.map((date) => ({
date,
dateTs: new Date(date + 'T00:00:00.000Z').getTime(),
profilesCreations: cumAll[date] || 0,
profilesWithBioCreations: cumBio[date] || 0,
}))
setData(merged)
}
loadData();
}, []);
void load()
}, [])
// 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="date" label={{value: "Date", position: "insideBottomRight", offset: -5}}/>
{/*<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={{
@@ -52,14 +114,27 @@ export default function ChartComponent() {
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="value"
stroke="rgb(var(--color-primary-900))"
dataKey="profilesWithBioCreations"
name="With Bio"
stroke="#9ca3af"
strokeDasharray="4 2"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
)
}

View File

@@ -1,35 +1,29 @@
import CharacterCount from '@tiptap/extension-character-count'
import { Link } from '@tiptap/extension-link'
import {Link} from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import type { Content, JSONContent } from '@tiptap/react'
import {
Editor,
EditorContent,
Extensions,
mergeAttributes,
useEditor,
} from '@tiptap/react'
import type {Content, JSONContent} from '@tiptap/react'
import {Editor, EditorContent, Extensions, mergeAttributes, useEditor,} from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import clsx from 'clsx'
import { ReactNode, useCallback, useEffect, useMemo } from 'react'
import { DisplayMention } from '../editor/user-mention/mention-extension'
import { Linkify } from './linkify'
import { linkClass } from './site-link'
import {ReactNode, useCallback, useEffect, useMemo} from 'react'
import {DisplayMention} from '../editor/user-mention/mention-extension'
import {Linkify} from './linkify'
import {linkClass} from './site-link'
import Iframe from 'common/util/tiptap-iframe'
import { debounce, noop } from 'lodash'
import { FloatingFormatMenu } from '../editor/floating-format-menu'
import { StickyFormatMenu } from '../editor/sticky-format-menu'
import { Upload, useUploadMutation } from '../editor/upload-extension'
import { generateReact } from '../editor/utils'
import { EmojiExtension } from '../editor/emoji/emoji-extension'
import { nodeViewMiddleware } from '../editor/nodeview-middleware'
import { BasicImage, DisplayImage, MediumDisplayImage } from '../editor/image'
import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'
import { richTextToString } from 'common/util/parse'
import { safeLocalStorage } from 'web/lib/util/local'
import {debounce, noop} from 'lodash'
import {FloatingFormatMenu} from '../editor/floating-format-menu'
import {StickyFormatMenu} from '../editor/sticky-format-menu'
import {Upload, useUploadMutation} from '../editor/upload-extension'
import {generateReact} from '../editor/utils'
import {EmojiExtension} from '../editor/emoji/emoji-extension'
import {nodeViewMiddleware} from '../editor/nodeview-middleware'
import {BasicImage, DisplayImage, MediumDisplayImage} from '../editor/image'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {richTextToString} from 'common/util/parse'
import {safeLocalStorage} from 'web/lib/util/local'
const DisplayLink = Link.extend({
renderHTML({ HTMLAttributes }) {
renderHTML({HTMLAttributes}) {
HTMLAttributes.target = HTMLAttributes.href.includes('manifold.markets')
? '_self'
: '_blank'
@@ -51,7 +45,7 @@ const DisplayLink = Link.extend({
const editorExtensions = (simple = false): Extensions =>
nodeViewMiddleware([
StarterKit.configure({
heading: simple ? false : { levels: [1, 2, 3] },
heading: simple ? false : {levels: [1, 2, 3]},
horizontalRule: simple ? false : {},
}),
simple ? DisplayImage : BasicImage,
@@ -84,7 +78,7 @@ export function useTextEditor(props: {
extensions?: Extensions
className?: string
}) {
const { placeholder, className, max, defaultValue, size = 'md', key } = props
const {placeholder, className, max, defaultValue, size = 'md', key} = props
const simple = size === 'sm'
const [content, setContent] = usePersistentLocalState<
@@ -122,9 +116,9 @@ export function useTextEditor(props: {
editorProps: getEditorProps(),
onUpdate: !key
? noop
: ({ editor }) => {
save(editor.getJSON())
},
: ({editor}) => {
save(editor.getJSON())
},
extensions: [
...editorExtensions(simple),
Placeholder.configure({
@@ -132,7 +126,7 @@ export function useTextEditor(props: {
emptyEditorClass:
'before:content-[attr(data-placeholder)] before:text-ink-500 before:float-left before:h-0 cursor-text',
}),
CharacterCount.configure({ limit: max }),
CharacterCount.configure({limit: max}),
...(props.extensions ?? []),
],
content: defaultValue ?? (key && content ? content : ''),
@@ -186,7 +180,7 @@ export function TextEditor(props: {
onBlur?: () => void
onChange?: () => void
}) {
const { editor, simple, hideEmbed, children, className, onBlur, onChange } = props
const {editor, simple, hideEmbed, children, className, onBlur, onChange} = props
return (
// matches input styling
@@ -196,9 +190,9 @@ export function TextEditor(props: {
className
)}
>
<FloatingFormatMenu editor={editor} advanced={!simple} />
<FloatingFormatMenu editor={editor} advanced={!simple}/>
<div className={clsx('max-h-[69vh] overflow-auto')}>
<EditorContent editor={editor} onBlur={onBlur} onChange={onChange} />
<EditorContent editor={editor} onBlur={onBlur} onChange={onChange}/>
</div>
<StickyFormatMenu editor={editor} hideEmbed={hideEmbed}>
@@ -213,7 +207,7 @@ function RichContent(props: {
className?: string
size?: 'sm' | 'md' | 'lg'
}) {
const { className, content, size = 'md' } = props
const {className, content, size = 'md'} = props
const jsxContent = useMemo(() => {
try {
@@ -222,8 +216,8 @@ function RichContent(props: {
size === 'sm'
? DisplayImage
: size === 'md'
? MediumDisplayImage
: BasicImage,
? MediumDisplayImage
: BasicImage,
DisplayLink,
DisplayMention,
Iframe,
@@ -257,7 +251,7 @@ export function Content(props: {
size?: 'sm' | 'md' | 'lg'
className?: string
}) {
const { className, size = 'md', content } = props
const {className, size = 'md', content} = props
return typeof content === 'string' ? (
<Linkify
className={clsx('whitespace-pre-line', proseClass(size), className)}

View File

@@ -125,7 +125,7 @@ const UserAvatar = (props: { userId: string; className?: string }) => {
const profile = useProfileByUserId(userId)
const user = useUserById(userId)
// console.log('UserAvatar', user?.username, profile?.pinned_url)
// console.debug('UserAvatar', user?.username, profile?.pinned_url)
if (!profile)
return <EmptyAvatar className={className} size={10} />

View File

@@ -19,12 +19,12 @@ export function useNearbyCities(
cityId: referenceCityId,
radius,
}).then((result) => {
console.log('search-near-city', result)
console.debug('search-near-city', result)
if (thisSearchCount == searchCount.current) {
if (result.status === 'failure') {
setNearbyCities(null)
lastKnownCities.current = null
console.log('ERROR:', result.data)
console.error(result.data)
} else {
const cities = (result.data.data as any[]).map((city) =>
city.id.toString()

View File

@@ -22,7 +22,7 @@ export function usePrivateMessages(
limit: number,
userId: string
) {
console.log('getWebsocketUrl', getWebsocketUrl())
// console.debug('getWebsocketUrl', getWebsocketUrl())
const [messages, setMessages] = usePersistentLocalState<
PrivateChatMessage[] | undefined
>(undefined, `private-messages-${channelId}-${limit}-v1`)

View File

@@ -15,7 +15,7 @@ export const useProfile = () => {
const refreshProfile = () => {
if (user) {
console.log('Refreshing profile in useProfile for', user?.username, profile);
console.debug('Refreshing profile in useProfile for', user?.username, profile);
getProfileRow(user.id, db).then((profile) => {
if (!profile) setProfile(null)
else setProfile(profile)
@@ -38,18 +38,18 @@ export const useProfileByUser = (user: User | undefined) => {
function refreshProfile() {
if (userId) {
console.log('Refreshing profile in useProfileByUser for', user?.username, profile);
console.debug('Refreshing profile in useProfileByUser for', user?.username, profile);
getProfileRow(userId, db)
.then((profile) => {
if (!profile) setProfile(null)
else setProfile({...profile, user})
})
.catch(error => {
console.log('Warning: profile not found', user?.username, error);
console.debug('Warning: profile not found', user?.username, error);
setProfile(null)
return
});
console.log('End Refreshing profile for', user?.username, profile);
console.debug('End Refreshing profile for', user?.username, profile);
}
}
@@ -66,7 +66,7 @@ export const useProfileByUserId = (userId: string | undefined) => {
>(undefined, `profile-${userId}`)
useEffect(() => {
console.log('Refreshing profile in useProfileByUserId for', userId, profile);
console.debug('Refreshing profile in useProfileByUserId for', userId, profile);
if (userId)
getProfileRow(userId, db).then((profile) => {
if (!profile) setProfile(null)

View File

@@ -29,7 +29,7 @@ export const useWebsocketUser = (userId: string | undefined) => {
useApiSubscription({
topics: [`user/${userId ?? '_'}`],
onBroadcast: ({ data }) => {
console.log(data)
console.debug(data)
setUser((user) => {
if (!user || !data.user) {
return user

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