mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-24 09:33:42 -04:00
Fix zod types and unseen icon not showing
This commit is contained in:
@@ -12,10 +12,13 @@ export const getLastSeenChannelTime: APIHandler<'get-channel-seen-time'> = async
|
||||
order by channel_id, created_time desc
|
||||
`,
|
||||
[channelIds, auth.uid],
|
||||
(r) => [r.channel_id as number, r.created_time as string],
|
||||
(r) => [r.channel_id, r.created_time] as [number, Date],
|
||||
)
|
||||
return unseens as [number, string][]
|
||||
// When this hits the network, JSON.stringify() turns the Date into an ISO string.
|
||||
// Then the zod schema in the endpoint definition casts it back to front-end Date
|
||||
return unseens
|
||||
}
|
||||
|
||||
export const setChannelLastSeenTime: APIHandler<'set-channel-seen-time'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {channelId} = props
|
||||
|
||||
@@ -10,10 +10,11 @@ export type DbChatMessage = ChatMessage & {
|
||||
|
||||
export const convertPrivateChatMessage = (row: Row<'private_user_messages'>) => {
|
||||
const message = convertSQLtoTS<'private_user_messages', DbChatMessage>(row, {
|
||||
// TODO: do not convert, stick to Date everywhere (zod converts Date -> String -> Date between front and back end)
|
||||
created_time: tsToMillis as any,
|
||||
})
|
||||
parseMessageObject(message)
|
||||
return message
|
||||
return message as ChatMessage
|
||||
}
|
||||
|
||||
type MessageObject = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
baseProfilesSchema,
|
||||
combinedProfileSchema,
|
||||
contentSchema,
|
||||
dateSchema,
|
||||
zBoolean,
|
||||
} from 'common/api/zod-types'
|
||||
import {ChatMessage} from 'common/chat-message'
|
||||
@@ -20,7 +21,7 @@ import {arrify} from 'common/util/array'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {LikeData, ShipData} from './profile-types'
|
||||
import {FullUser, HiddenProfile} from './user-types'
|
||||
import {FullUser, HiddenProfile} from './user-types' // mqp: very unscientific, just balancing our willingness to accept load
|
||||
|
||||
// mqp: very unscientific, just balancing our willingness to accept load
|
||||
// with user willingness to put up with stale data
|
||||
@@ -32,7 +33,7 @@ export const DEFAULT_CACHE_STRATEGY = 'public, max-age=5, stale-while-revalidate
|
||||
* Defines the structure and behavior of API endpoints including HTTP method,
|
||||
* authentication requirements, request/response schemas, and metadata.
|
||||
*/
|
||||
type APIGenericSchema = {
|
||||
export type APIGenericSchema = {
|
||||
/**
|
||||
* HTTP method for the endpoint
|
||||
* - GET: For data retrieval operations
|
||||
@@ -66,7 +67,7 @@ type APIGenericSchema = {
|
||||
* Response type definition (JSON serializable)
|
||||
* Used for TypeScript typing and API documentation generation
|
||||
*/
|
||||
returns?: Record<string, any>
|
||||
returns?: z.ZodType | Record<string, any>
|
||||
|
||||
/**
|
||||
* Cache-Control header value
|
||||
@@ -880,7 +881,12 @@ export const API = (_apiTypeCheck = {
|
||||
props: z.object({
|
||||
channelIds: z.array(z.coerce.number()).or(z.coerce.number()).transform(arrify),
|
||||
}),
|
||||
returns: [] as [number, string][],
|
||||
returns: z.array(
|
||||
z.tuple([
|
||||
z.number(), // Channel ID
|
||||
dateSchema, // This turns the ISO string into a JS Date object
|
||||
]),
|
||||
),
|
||||
summary: 'Get last seen times for one or more channels',
|
||||
tag: 'Messages',
|
||||
},
|
||||
@@ -1239,13 +1245,26 @@ export type APISchema<N extends APIPath> = (typeof API)[N]
|
||||
export type APIParams<N extends APIPath> = z.input<APISchema<N>['props']>
|
||||
export type ValidatedAPIParams<N extends APIPath> = z.output<APISchema<N>['props']>
|
||||
|
||||
export type APIResponse<N extends APIPath> =
|
||||
APISchema<N> extends {
|
||||
returns: Record<string, any>
|
||||
}
|
||||
? APISchema<N>['returns']
|
||||
: void
|
||||
/**
|
||||
* A helper to extract either the Input (backend) or Output (frontend)
|
||||
* while preserving the fallback logic for non-zod types.
|
||||
*/
|
||||
type ExtractAPIResult<N extends APIPath, TMode extends 'input' | 'output'> =
|
||||
APISchema<N> extends {returns: z.ZodTypeAny}
|
||||
? TMode extends 'input'
|
||||
? z.input<APISchema<N>['returns']>
|
||||
: z.output<APISchema<N>['returns']>
|
||||
: APISchema<N> extends {returns: infer R}
|
||||
? R
|
||||
: void
|
||||
|
||||
// 1. Frontend: The "Output" (Dates)
|
||||
export type APIResponse<N extends APIPath> = ExtractAPIResult<N, 'output'>
|
||||
|
||||
// 2. Backend: The "Input" (Strings/Dates)
|
||||
export type APIBackendReturn<N extends APIPath> = ExtractAPIResult<N, 'input'>
|
||||
|
||||
// 3. Keep your wrapper using the Backend Return type
|
||||
export type APIResponseOptionalContinue<N extends APIPath> =
|
||||
| {continue: () => Promise<void>; result: APIResponse<N>}
|
||||
| APIResponse<N>
|
||||
| {continue: () => Promise<void>; result: APIBackendReturn<N>}
|
||||
| APIBackendReturn<N>
|
||||
|
||||
@@ -28,6 +28,8 @@ export const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
),
|
||||
)
|
||||
|
||||
export const dateSchema = z.union([z.string(), z.date(), z.number()]).pipe(z.coerce.date())
|
||||
|
||||
const genderType = z.string()
|
||||
// z.union([
|
||||
// z.literal('male'),
|
||||
|
||||
@@ -6,8 +6,7 @@ export type ChatMessage = {
|
||||
userId: string
|
||||
channelId: string
|
||||
content: JSONContent
|
||||
createdTime: number
|
||||
createdTimeTs: number
|
||||
createdTime: number // TODO: switch to Date
|
||||
visibility: ChatVisibility
|
||||
isEdited: boolean
|
||||
reactions: any
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {IS_PROD} from 'common/envs/constants'
|
||||
import {IS_LOCAL} from 'common/hosting/constants'
|
||||
|
||||
/**
|
||||
* Log level severity types
|
||||
@@ -151,6 +152,7 @@ export function logPageView(path: string): void {
|
||||
* @returns Current log level threshold
|
||||
*/
|
||||
const currentLevel = (): LogLevel => {
|
||||
if (IS_LOCAL) return 'debug'
|
||||
if (IS_PROD || process.env.NODE_ENV == 'production') return 'info'
|
||||
return 'debug'
|
||||
}
|
||||
|
||||
@@ -92,12 +92,12 @@ export function selectFrom<
|
||||
return db.from(table).select<string, TResult>(query)
|
||||
}
|
||||
|
||||
export function millisToTs(millis: number | undefined) {
|
||||
export function millisToTs(millis: number | undefined): string | undefined {
|
||||
if (!millis) return
|
||||
return new Date(millis).toISOString()
|
||||
}
|
||||
|
||||
export function tsToMillis(ts: string) {
|
||||
export function tsToMillis(ts: string): number {
|
||||
return Date.parse(ts)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import {API, APIParams, APIPath, APIResponse} from 'common/api/schema'
|
||||
import {API, APIGenericSchema, APIParams, APIPath, APIResponse} from 'common/api/schema'
|
||||
import {APIError, getApiUrl} from 'common/api/utils'
|
||||
import {removeUndefinedProps} from 'common/util/object'
|
||||
import {User} from 'firebase/auth'
|
||||
import {forEach} from 'lodash'
|
||||
|
||||
export function unauthedApi<P extends APIPath>(path: P, params: APIParams<P>) {
|
||||
return typedAPICall(path, params, null)
|
||||
}
|
||||
// export function unauthedApi<P extends APIPath>(path: P, params: APIParams<P>) {
|
||||
// return typedAPICall(path, params, null)
|
||||
// }
|
||||
|
||||
export const typedAPICall = <P extends APIPath>(
|
||||
export async function typedAPICall<P extends APIPath>(
|
||||
path: P,
|
||||
params: APIParams<P>,
|
||||
user: User | null,
|
||||
) => {
|
||||
) {
|
||||
const definition = API[path] as APIGenericSchema
|
||||
const method = definition.method
|
||||
|
||||
// parse any params that should part of the path (like market/:id)
|
||||
const newParams: any = {}
|
||||
let url = getApiUrl(path)
|
||||
@@ -24,35 +27,6 @@ export const typedAPICall = <P extends APIPath>(
|
||||
}
|
||||
})
|
||||
|
||||
return baseApiCall({
|
||||
url,
|
||||
method: API[path].method,
|
||||
params: newParams,
|
||||
user,
|
||||
}) as Promise<APIResponse<P>>
|
||||
}
|
||||
|
||||
function appendQuery(url: string, props: Record<string, any>) {
|
||||
const [base, query] = url.split(/\?(.+)/)
|
||||
const params = new URLSearchParams(query)
|
||||
forEach(removeUndefinedProps(props ?? {}), (v, k) => {
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((item) => params.append(k, item))
|
||||
} else {
|
||||
params.set(k, v)
|
||||
}
|
||||
})
|
||||
return `${base}?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function baseApiCall(props: {
|
||||
url: string
|
||||
method: 'POST' | 'PUT' | 'GET'
|
||||
params: any
|
||||
user: User | null
|
||||
}) {
|
||||
const {url, method, params, user} = props
|
||||
|
||||
const actualUrl = method === 'POST' ? url : appendQuery(url, params)
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -68,10 +42,29 @@ export async function baseApiCall(props: {
|
||||
})
|
||||
// console.log('Request', req)
|
||||
return fetch(req).then(async (resp) => {
|
||||
const json = (await resp.json()) as {[k: string]: any}
|
||||
let json = (await resp.json()) as {[k: string]: any}
|
||||
|
||||
// Use Zod to parse the JSON. This triggers the z.coerce.date() transformation.
|
||||
if (definition.returns && typeof definition.returns.parse === 'function') {
|
||||
json = definition.returns.parse(json)
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new APIError(resp.status as any, json?.message, json?.details)
|
||||
}
|
||||
return json
|
||||
})
|
||||
}) as APIResponse<P>
|
||||
}
|
||||
|
||||
function appendQuery(url: string, props: Record<string, any>) {
|
||||
const [base, query] = url.split(/\?(.+)/)
|
||||
const params = new URLSearchParams(query)
|
||||
forEach(removeUndefinedProps(props ?? {}), (v, k) => {
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((item) => params.append(k, item))
|
||||
} else {
|
||||
params.set(k, v)
|
||||
}
|
||||
})
|
||||
return `${base}?${params.toString()}`
|
||||
}
|
||||
|
||||
@@ -481,3 +481,29 @@ for (const id of betIds) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Timestamps
|
||||
|
||||
We use `Date` for timestamps everywhere, not strings or milliseconds. There are still a few non-date left-overs; we need
|
||||
to migrate them to Date.
|
||||
|
||||
**Database:** `TIMESTAMPTZ` (PostgreSQL standard)
|
||||
|
||||
**TypeScript:** `Date`
|
||||
|
||||
Postgres automatically converts the timestamp to Date. Nothing needed in the code.
|
||||
|
||||
```typescript
|
||||
// What we do:
|
||||
type User = {
|
||||
id: string
|
||||
createdTime: Date
|
||||
}
|
||||
```
|
||||
|
||||
The only instance when Date is not good is that it is not serializable. So when moving between front and back end, we
|
||||
use zod in the endpoint definition to switch from Date to string, then request sent, then back to Date. Another issue is
|
||||
when JSON serializing to store in local storage. Storing the date does not need conversion as JSON.stringify() converts
|
||||
Date to string, but loading from local storage requires conversion from string to Date.
|
||||
|
||||
@@ -50,7 +50,7 @@ function InternalUnseenMessagesBubble(props: {
|
||||
}) {
|
||||
const {privateUser, className, bubbleClassName} = props
|
||||
|
||||
const {unseenChannels} = useUnseenPrivateMessageChannels(privateUser.id, false)
|
||||
const {unseenChannels} = useUnseenPrivateMessageChannels(false)
|
||||
const pathName = usePathname()
|
||||
|
||||
const {sendToBrowser} = getNotificationDestinationsForUser(privateUser, 'new_message')
|
||||
|
||||
@@ -5,7 +5,7 @@ import {formatTime} from 'web/lib/util/time'
|
||||
import {Tooltip} from './tooltip'
|
||||
|
||||
export function DateTimeTooltip(props: {
|
||||
time: number
|
||||
time: number | Date | string
|
||||
text?: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
@@ -15,7 +15,7 @@ export function DateTimeTooltip(props: {
|
||||
}) {
|
||||
const {time, text, ...rest} = props
|
||||
|
||||
const formattedTime = formatTime(time)
|
||||
const formattedTime = formatTime(new Date(time))
|
||||
const toolTip = text ? `${text} ${formattedTime}` : formattedTime
|
||||
|
||||
return <Tooltip text={toolTip} {...rest} suppressHydrationWarning />
|
||||
|
||||
@@ -58,11 +58,11 @@ export function usePrivateMessages(channelId: number, limit: number, userId: str
|
||||
return {messages, fetchMessages, setMessages}
|
||||
}
|
||||
|
||||
export const useUnseenPrivateMessageChannels = (userId: string, ignorePageSeenTime: boolean) => {
|
||||
export const useUnseenPrivateMessageChannels = (ignorePageSeenTime: boolean) => {
|
||||
const pathName = usePathname()
|
||||
const lastSeenMessagesPageTime = useLastSeenMessagesPageTime()
|
||||
const lastSeenMessagesPageTime = useLastSeenMessagesPageTime() // ms for now
|
||||
const [lastSeenChatTimeByChannelId, setLastSeenChatTimeByChannelId] = useState<
|
||||
Record<number, string> | undefined
|
||||
Record<number, Date> | undefined
|
||||
>(undefined)
|
||||
|
||||
const {data, refresh} = useAPIGetter(
|
||||
@@ -116,16 +116,20 @@ export const useUnseenPrivateMessageChannels = (userId: string, ignorePageSeenTi
|
||||
}
|
||||
}, [channels?.length])
|
||||
|
||||
console.log({lastSeenChatTimeByChannelId})
|
||||
|
||||
if (!lastSeenChatTimeByChannelId) return {unseenChannels: [], lastSeenChatTimeByChannelId: {}}
|
||||
console.log('Got defined')
|
||||
const unseenChannels = channels.filter((channel) => {
|
||||
const channelId = channel.channel_id
|
||||
const notifyAfterTime =
|
||||
channels?.find((m) => m.channel_id === channelId)?.notify_after_time ?? '0'
|
||||
const notifyAfterTime = new Date(
|
||||
channels?.find((m) => m.channel_id === channelId)?.notify_after_time ?? '0',
|
||||
)
|
||||
|
||||
const lastSeenTime = lastSeenChatTimeByChannelId[channelId] ?? 0
|
||||
const lastSeenChatTime = notifyAfterTime > lastSeenTime ? notifyAfterTime : (lastSeenTime ?? 0)
|
||||
const lastSeenTime = lastSeenChatTimeByChannelId[channelId]
|
||||
const lastSeenChatTime = notifyAfterTime > lastSeenTime ? notifyAfterTime : lastSeenTime
|
||||
return (
|
||||
channel.last_updated_time > lastSeenChatTime &&
|
||||
new Date(channel.last_updated_time) > lastSeenChatTime &&
|
||||
(ignorePageSeenTime || tsToMillis(channel.last_updated_time) > lastSeenMessagesPageTime) &&
|
||||
!pathName?.endsWith(`/messages/${channelId}`)
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function api<P extends APIPath>(path: P, params: APIParams<P> = {})
|
||||
}
|
||||
}
|
||||
|
||||
return typedAPICall(path, params, auth.currentUser)
|
||||
return await typedAPICall(path, params, auth.currentUser)
|
||||
}
|
||||
|
||||
function curriedAPI<P extends APIPath>(path: P) {
|
||||
|
||||
@@ -54,7 +54,7 @@ export function MessagesContent(props: {currentUser: User}) {
|
||||
const {currentUser} = props
|
||||
const t = useT()
|
||||
const {channels, memberIdsByChannelId} = useSortedPrivateMessageMemberships(currentUser.id)
|
||||
const {lastSeenChatTimeByChannelId} = useUnseenPrivateMessageChannels(currentUser.id, true)
|
||||
const {lastSeenChatTimeByChannelId} = useUnseenPrivateMessageChannels(true)
|
||||
const lastMessages = useLastPrivateMessages(currentUser.id)
|
||||
|
||||
return (
|
||||
@@ -91,13 +91,13 @@ export const MessageChannelRow = (props: {
|
||||
otherUserIds: string[]
|
||||
currentUser: User
|
||||
channel: PrivateMessageChannel
|
||||
lastSeenTime: string
|
||||
lastSeenTime: Date | undefined
|
||||
lastMessage?: ChatMessage
|
||||
}) => {
|
||||
const {otherUserIds, lastSeenTime, currentUser, channel, lastMessage} = props
|
||||
const channelId = channel.channel_id
|
||||
const otherUsers = useUsersInStore(otherUserIds, `${channelId}`, 100)
|
||||
const unseen = (lastMessage?.createdTimeTs ?? '0') > lastSeenTime
|
||||
const unseen = lastSeenTime ? new Date(lastMessage?.createdTime ?? 0) > lastSeenTime : false
|
||||
const numOthers = otherUsers?.length ?? 0
|
||||
const t = useT()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user