Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web-foldkit/src/chat/message/parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const headerView = <M>(h: HtmlBuilder<M>, row: MessageRow, statusEmoji: H
const { message, author } = row
const isEdited = message.updatedAtMs !== null && message.updatedAtMs > message.createdAtMs
const time = formatTime(message.createdAtMs)
const isMachine = message.author?.userType === "machine"
const isMachine = author.isBot
return h.div(
[h.Class("flex items-baseline gap-2")],
[
Expand Down
4 changes: 2 additions & 2 deletions apps/web-foldkit/src/chat/message/row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const urlEmbedView = <M>(
const photos =
tweet?.photos?.map((photo) => ({ url: photo.url, alt: tweet.text || "Tweet image" })) ?? []
// Legacy only mounts the viewer for a message with an author.
const canOpen = photos.length > 0 && row.message.author !== null
const canOpen = photos.length > 0 && row.hasAuthor
return tweetView(
h,
unfurl,
Expand Down Expand Up @@ -214,7 +214,7 @@ export const messageRowView = <M>(
h.div(
[h.Class("min-w-0 flex-1")],
[
showAvatar && message.author !== null
showAvatar && row.hasAuthor
? headerView(
h,
row,
Expand Down
1 change: 1 addition & 0 deletions apps/web-foldkit/src/chat/messages.gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const row = (options: {
groupPosition: "standalone",
reactions: [],
author: { displayName: options.name, avatarUrl: null, isBot: options.isBot ?? false },
hasAuthor: true,
status: null,
isDiscordSynced: false,
reply: null,
Expand Down
2 changes: 1 addition & 1 deletion apps/web-foldkit/src/core/db/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
* Rows carry timestamps as ISO text; the models' `JsonDate` would decode that to a `DateTime`, while
* the app reads `Date`s, so ISO timestamp strings are parsed into `Date`s first.
*/
const parseTimestamps = (row: unknown): unknown => {
export const parseTimestamps = (row: unknown): unknown => {
if (typeof row !== "object" || row === null) return row
const parsed: Record<string, unknown> = {}
for (const [key, value] of Object.entries(row)) {
Expand Down
100 changes: 100 additions & 0 deletions apps/web-foldkit/src/page/chat/channel/authors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { describe, expect, test, vi } from "vitest"
import { toDeriveContext } from "../derive"
import { emptyLookups, type UserInfo } from "../lookups"
import { starterNameOf } from "../thread-panel"
import type { MessageRow } from "../rows"
import { ada, grace, loadedMessages, messages, shared } from "./fixtures.test-support"
import { init, Message, type Model, update } from "./page"

/**
* Messages carry no author (their query does not wait on the org's users); each row's author comes
* from the users lookup, which may answer before or after the messages.
*/

// The Files tab's `ui/aria/interaction` and ProseMirror's view read `document` at import; node has none.
vi.hoisted(() => {
if (!("document" in globalThis))
Object.assign(globalThis, {
document: Object.assign(new EventTarget(), { documentElement: { style: {} } }),
})
})

const user = (id: UserInfo["id"], firstName: string, userType = "user"): UserInfo => ({
id,
firstName,
lastName: "Test",
email: `${firstName.toLowerCase()}@hazel.sh`,
avatarUrl: `https://img.test/${firstName}.png`,
userType,
timezone: null,
})

const users = [user(ada, "Ada"), user(grace, "Grace")]

const apply = (model: Model, ...steps: ReadonlyArray<Message>): Model =>
steps.reduce((current, step) => update(current, step, shared).model, model)

const authorsOf = (model: Model) =>
model.rows
.filter((row): row is MessageRow => row._tag === "MessageRow")
.map((row) => [row.message.content, row.author.displayName, row.author.avatarUrl])

const hasAuthor = (model: Model) =>
model.rows.filter((row): row is MessageRow => row._tag === "MessageRow").map((row) => row.hasAuthor)

const start = () => init(messages[0]!.channelId, "hazel", ada)

describe("message authors", () => {
test("messages show as soon as they arrive, before the users lookup", () => {
const model = apply(start(), loadedMessages(messages))
expect(model.hasLoadedMessages).toBe(true)
// No header until the author is known.
expect(hasAuthor(model)).toEqual([false, false])
expect(authorsOf(model)).toEqual([
["Launch window confirmed", "", null],
["Shipped the onboarding copy", "", null],
])
})

test("authors fill in when the users lookup answers after the messages", () => {
const model = apply(start(), loadedMessages(messages), Message.UpdatedUsers({ users }))
expect(authorsOf(model)).toEqual([
["Launch window confirmed", "Grace Test", "https://img.test/Grace.png"],
["Shipped the onboarding copy", "Ada Test", "https://img.test/Ada.png"],
])
expect(hasAuthor(model)).toEqual([true, true])
})

test("authors are there at once when the users lookup answered first", () => {
const model = apply(start(), Message.UpdatedUsers({ users }), loadedMessages(messages))
expect(authorsOf(model).map(([, name]) => name)).toEqual(["Grace Test", "Ada Test"])
})

test("a bot author shows its bot name", () => {
const model = apply(
start(),
Message.UpdatedUsers({ users: [user(ada, "Ada"), user(grace, "Grace", "machine")] }),
Message.UpdatedBots({ bots: [{ userId: grace, name: "Maple" }] }),
loadedMessages(messages),
)
const graceRow = model.rows.find(
(row): row is MessageRow => row._tag === "MessageRow" && row.message.authorId === grace,
)!
expect(graceRow.author).toMatchObject({ displayName: "Maple", isBot: true })
})
})

describe("thread starter", () => {
const original = messages[1]!
const contextWith = (known: ReadonlyArray<UserInfo>) =>
toDeriveContext({ ...emptyLookups, users: known }, ada)

test('no "Started by" until the users lookup has the author', () => {
expect(starterNameOf(contextWith([]), original)).toBeNull()
})

test("the author's name once it has them", () => {
expect(starterNameOf(contextWith(users), original)).toBe("Grace Test")
})
})
23 changes: 23 additions & 0 deletions apps/web-foldkit/src/page/chat/channel/messages.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,29 @@ describe("reading", () => {
)
})

// The messages carry no author: the header comes with the users lookup, in either order.
test("each message's header shows its author once the users lookup has them", () => {
const users = [
{
id: ada,
firstName: "Ada",
lastName: "Lovelace",
email: "ada@hazel.sh",
avatarUrl: null,
userType: "user",
timezone: null,
},
]
const base = loadedModel()
Scene.scene(
channelScene,
Scene.given(derivedModel({ ...base, lookups: { ...base.lookups, users } })),
mountedChannel(),
Scene.expect(Scene.text("Ada Lovelace")).toExist(),
Scene.expect(Scene.text("Shipped the onboarding copy")).toExist(),
)
})

test("before its first snapshot the list says it is loading, and shows no messages", () => {
Scene.scene(
channelScene,
Expand Down
14 changes: 7 additions & 7 deletions apps/web-foldkit/src/page/chat/channel/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,13 @@ const imageViewerOverlay = (h: H, model: Model): Html => {
caption: { title: image.title, description: image.description },
})),
index: viewer.index,
author: row.message.author
? {
name: `${row.message.author.firstName} ${row.message.author.lastName}`,
avatarUrl: row.message.author.avatarUrl,
seed: `${row.message.author.firstName} ${row.message.author.lastName}`,
}
: null,
author: !row.hasAuthor
? null
: {
name: row.author.displayName,
avatarUrl: row.author.avatarUrl,
seed: row.author.displayName,
},
createdAtMs: row.message.createdAtMs,
toClose: () => toOverlay(Overlays.Message.ClosedImageViewer()),
toSelect: (index) => toOverlay(Overlays.Message.SelectedViewerImage({ index })),
Expand Down
176 changes: 176 additions & 0 deletions apps/web-foldkit/src/page/chat/data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Message as MessageModel, PinnedMessage } from "@hazel/domain/models"
import { channelPartition, createEffectCollection, SyncHub, sourceSet } from "@hazel/effect-db-collection"
import { fakeSockets } from "@hazel/effect-db-collection/testing"
import { ChannelId, MessageId, OrganizationId, UserId } from "@hazel/schema"
import { Effect, Option, Schema, Stream } from "effect"
import { afterAll, describe, expect, test, vi } from "vitest"
import { parseTimestamps } from "~/db/collections"
import { type MessageSources, messageQueryStream, threadPanelQueryStream } from "./data"

/**
* The channel's message queries over a real hub and collections on fake sockets, while one of the
* user's orgs has its socket open and never sends its snapshot (a cold or slow `OrgObject`). The
* messages once joined the org's `users`, an eager collection that is ready only when every org
* has answered, so an empty channel stayed on its skeleton until the slowest org did.
*/

const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`
const organizationId = Schema.decodeSync(OrganizationId)(uuid(1))
const channelId = Schema.decodeSync(ChannelId)(uuid(2))
const threadChannelId = Schema.decodeSync(ChannelId)(uuid(3))
const grace = Schema.decodeSync(UserId)(uuid(4))
const emptyChannelId = Schema.decodeSync(ChannelId)(uuid(5))
const emptyThreadChannelId = Schema.decodeSync(ChannelId)(uuid(6))

const sockets = fakeSockets()
const hub = new SyncHub({
baseUrl: "https://api.test",
getToken: () => Promise.resolve("token"),
WebSocket: sockets.WebSocket,
releaseGraceMs: 0,
log: () => {},
})

const sources: MessageSources = {
// Keyed as the app's collections are (`collections.ts` `syncedCollection`).
messages: createEffectCollection<MessageModel.Type, string | number>({
id: "test-messages",
table: "messages",
schema: MessageModel.Schema,
parseRow: parseTimestamps,
getKey: (row) => row.id,
hub,
sources: channelPartition(),
}),
pinned: createEffectCollection<PinnedMessage.Type, string | number>({
id: "test-pinned_messages",
table: "pinned_messages",
schema: PinnedMessage.Schema,
parseRow: parseTimestamps,
getKey: (row) => row.id,
hub,
sources: channelPartition(),
}),
loadsWholeChannel: () => false,
}

// The user's org: its socket opens, and its snapshot never comes.
const orgSources = sourceSet()
orgSources.set([`org:${organizationId}`])
const users = createEffectCollection({
id: "test-users",
table: "users",
schema: Schema.Struct({ id: Schema.String }),
getKey: (row) => row.id,
hub,
sources: orgSources,
})
void users.preload()

afterAll(async () => {
await Promise.all([sources.messages.cleanup(), sources.pinned.cleanup(), users.cleanup()])
hub.close()
})

const messageRow = (id: number, inChannel: ChannelId, minute: number) => ({
id: Schema.decodeSync(MessageId)(uuid(id)),
channelId: inChannel,
conversationId: null,
authorId: grace,
content: `message ${id}`,
embeds: null,
replyToMessageId: null,
threadChannelId: null,
createdAt: new Date(Date.UTC(2026, 10, 5, 8, minute)).toISOString(),
updatedAt: null,
deletedAt: null,
})

const socketFor = (path: string) =>
vi.waitFor(
() => {
const socket = sockets.latest(path)
if (socket === undefined) throw new Error(`no socket for ${path} yet`)
return socket
},
{ timeout: 1_000 },
)

/** The stream's first message, or `None` when it has sent nothing within `ms`. */
const firstWithin = <A>(stream: Stream.Stream<A>, ms: number) =>
stream.pipe(
Stream.take(1),
Stream.runHead,
Effect.timeoutOption(ms),
Effect.map(Option.flatten),
Effect.runPromise,
)

const window = { offset: 0, limit: 50 }
const channelMessages = (id: ChannelId) =>
firstWithin(
messageQueryStream(
sources,
id,
window,
() => window,
Stream.never,
(changes) => changes,
),
2_000,
)
const threadMessages = (id: ChannelId) =>
firstWithin(
threadPanelQueryStream(sources, id, (messages) => messages),
2_000,
)

describe("channel messages while an org is still loading", () => {
test("the org source is open and has not answered", async () => {
await socketFor(`/sync/org/${organizationId}`)
expect(users.isReady()).toBe(false)
})

test("the channel's messages arrive with its own snapshot", async () => {
const first = channelMessages(channelId)
const channel = await socketFor(`/sync/channel/${channelId}`)
channel.snapshot(1, { messages: [messageRow(10, channelId, 1), messageRow(11, channelId, 2)] })

const changes = Option.getOrThrow(await first)
expect(users.isReady()).toBe(false)
// Newest first; authors come from the users lookup, not the query.
expect(changes.order).toEqual([uuid(11), uuid(10)])
expect(changes.upserts.map((message) => message.author)).toEqual([null, null])
})

test("the thread panel's messages arrive with the thread's snapshot", async () => {
const first = threadMessages(threadChannelId)
const thread = await socketFor(`/sync/channel/${threadChannelId}`)
thread.snapshot(1, { messages: [messageRow(20, threadChannelId, 1)] })

const messages = Option.getOrThrow(await first)
expect(users.isReady()).toBe(false)
expect(messages.map((message) => message.id)).toEqual([uuid(20)])
})

// No row changes in an empty channel, so its first result waits for the query to be ready:
// with the org's users joined in, that was once every org had answered.
test("an empty channel shows as empty once its own snapshot is in", async () => {
const first = channelMessages(emptyChannelId)
const channel = await socketFor(`/sync/channel/${emptyChannelId}`)
channel.snapshot(1, { messages: [] })

const changes = Option.getOrThrow(await first)
expect(users.isReady()).toBe(false)
expect(changes.order).toEqual([])
})

test("an empty thread shows as empty once its own snapshot is in", async () => {
const first = threadMessages(emptyThreadChannelId)
const thread = await socketFor(`/sync/channel/${emptyThreadChannelId}`)
thread.snapshot(1, { messages: [] })

expect(Option.getOrThrow(await first)).toEqual([])
expect(users.isReady()).toBe(false)
})
})
Loading
Loading