birdclaw 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -0
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +7 -6
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +399 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +29 -0
  12. package/src/cli.ts +46 -1
  13. package/src/components/AppNav.tsx +3 -3
  14. package/src/components/BrandMark.tsx +67 -0
  15. package/src/components/ConversationThread.tsx +11 -10
  16. package/src/components/DmWorkspace.tsx +24 -9
  17. package/src/components/FeedState.tsx +147 -0
  18. package/src/components/InboxCard.tsx +14 -2
  19. package/src/components/SavedTimelineView.tsx +64 -56
  20. package/src/components/SyncNowButton.tsx +105 -0
  21. package/src/components/ThemeSlider.tsx +10 -59
  22. package/src/components/TimelineCard.tsx +157 -61
  23. package/src/components/TimelineRouteFrame.tsx +156 -0
  24. package/src/components/TweetMediaGrid.tsx +120 -23
  25. package/src/components/TweetRichText.tsx +13 -2
  26. package/src/components/useTimelineRouteData.ts +137 -0
  27. package/src/lib/api-client.ts +229 -0
  28. package/src/lib/archive-finder.ts +24 -20
  29. package/src/lib/archive-import.ts +18 -3
  30. package/src/lib/conversation-surface.ts +174 -0
  31. package/src/lib/db.ts +2 -0
  32. package/src/lib/queries.ts +93 -28
  33. package/src/lib/ui.ts +11 -3
  34. package/src/lib/web-sync.ts +443 -0
  35. package/src/routeTree.gen.ts +21 -0
  36. package/src/routes/api/sync.tsx +59 -0
  37. package/src/routes/dms.tsx +100 -27
  38. package/src/routes/index.tsx +21 -127
  39. package/src/routes/links.tsx +50 -5
  40. package/src/routes/mentions.tsx +21 -127
  41. package/src/styles.css +74 -11
  42. package/vite.config.ts +8 -0
@@ -0,0 +1,137 @@
1
+ import { useEffect, useState } from "react";
2
+ import type {
3
+ QueryEnvelope,
4
+ ReplyFilter,
5
+ ResourceKind,
6
+ TimelineItem,
7
+ } from "#/lib/types";
8
+ import {
9
+ fetchQueryEnvelope,
10
+ fetchQueryResponse,
11
+ postAction,
12
+ } from "#/lib/api-client";
13
+
14
+ interface UseTimelineRouteDataOptions {
15
+ resource: Exclude<ResourceKind, "dms">;
16
+ search: string;
17
+ errorFallback: string;
18
+ replyFilter?: ReplyFilter;
19
+ likedOnly?: boolean;
20
+ bookmarkedOnly?: boolean;
21
+ }
22
+
23
+ export function useTimelineRouteData({
24
+ resource,
25
+ search,
26
+ errorFallback,
27
+ replyFilter,
28
+ likedOnly = false,
29
+ bookmarkedOnly = false,
30
+ }: UseTimelineRouteDataOptions) {
31
+ const [meta, setMeta] = useState<QueryEnvelope | null>(null);
32
+ const [items, setItems] = useState<TimelineItem[]>([]);
33
+ const [loading, setLoading] = useState(true);
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [refreshTick, setRefreshTick] = useState(0);
36
+
37
+ async function loadStatus() {
38
+ setMeta(await fetchQueryEnvelope());
39
+ }
40
+
41
+ useEffect(() => {
42
+ void loadStatus();
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ const url = new URL("/api/query", window.location.origin);
47
+ url.searchParams.set("resource", resource);
48
+ url.searchParams.set("refresh", String(refreshTick));
49
+ if (replyFilter) {
50
+ url.searchParams.set("replyFilter", replyFilter);
51
+ }
52
+ if (likedOnly) {
53
+ url.searchParams.set("liked", "true");
54
+ }
55
+ if (bookmarkedOnly) {
56
+ url.searchParams.set("bookmarked", "true");
57
+ }
58
+ if (search.trim()) {
59
+ url.searchParams.set("search", search.trim());
60
+ }
61
+
62
+ const controller = new AbortController();
63
+ let active = true;
64
+ setError(null);
65
+ setLoading(true);
66
+ fetchQueryResponse(url, { signal: controller.signal })
67
+ .then((data) => {
68
+ if (active) {
69
+ setItems(data.items as TimelineItem[]);
70
+ }
71
+ })
72
+ .catch((fetchError: unknown) => {
73
+ if (
74
+ fetchError instanceof DOMException &&
75
+ fetchError.name === "AbortError"
76
+ ) {
77
+ return;
78
+ }
79
+ if (!active) return;
80
+ setError(
81
+ fetchError instanceof Error ? fetchError.message : errorFallback,
82
+ );
83
+ setItems([]);
84
+ })
85
+ .finally(() => {
86
+ if (active) {
87
+ setLoading(false);
88
+ }
89
+ });
90
+
91
+ return () => {
92
+ active = false;
93
+ controller.abort();
94
+ };
95
+ }, [
96
+ bookmarkedOnly,
97
+ errorFallback,
98
+ likedOnly,
99
+ refreshTick,
100
+ replyFilter,
101
+ resource,
102
+ search,
103
+ ]);
104
+
105
+ function retry() {
106
+ setRefreshTick((value) => value + 1);
107
+ }
108
+
109
+ function refreshLocalView() {
110
+ setRefreshTick((value) => value + 1);
111
+ void loadStatus();
112
+ }
113
+
114
+ async function replyToTweet(tweetId: string) {
115
+ const text = window.prompt("Reply text");
116
+ if (!text?.trim()) return;
117
+
118
+ await postAction({
119
+ kind: "replyTweet",
120
+ accountId: "acct_primary",
121
+ tweetId,
122
+ text,
123
+ });
124
+
125
+ retry();
126
+ }
127
+
128
+ return {
129
+ meta,
130
+ items,
131
+ loading,
132
+ error,
133
+ retry,
134
+ refreshLocalView,
135
+ replyToTweet,
136
+ };
137
+ }
@@ -0,0 +1,229 @@
1
+ import { z } from "zod";
2
+ import type {
3
+ DmConversationItem,
4
+ DmMessageItem,
5
+ QueryEnvelope,
6
+ QueryResponse,
7
+ TimelineItem,
8
+ } from "./types";
9
+ import type {
10
+ WebSyncJobSnapshot,
11
+ WebSyncKind,
12
+ WebSyncResponse,
13
+ } from "./web-sync";
14
+
15
+ const jsonRecordSchema = z.object({}).passthrough();
16
+ const resourceKindSchema = z.enum(["home", "mentions", "authored", "dms"]);
17
+ const webSyncKindSchema = z.enum([
18
+ "timeline",
19
+ "mentions",
20
+ "likes",
21
+ "bookmarks",
22
+ "dms",
23
+ ]);
24
+
25
+ const queryEnvelopeSchema = z
26
+ .object({
27
+ accounts: z.array(jsonRecordSchema),
28
+ archives: z.array(jsonRecordSchema),
29
+ transport: z
30
+ .object({
31
+ statusText: z.string(),
32
+ })
33
+ .passthrough(),
34
+ stats: z.object({
35
+ home: z.number(),
36
+ mentions: z.number(),
37
+ dms: z.number(),
38
+ needsReply: z.number(),
39
+ inbox: z.number(),
40
+ }),
41
+ })
42
+ .transform((value) => value as unknown as QueryEnvelope);
43
+
44
+ const queryResponseSchema = z
45
+ .object({
46
+ resource: resourceKindSchema,
47
+ items: z.array(jsonRecordSchema),
48
+ selectedConversation: z
49
+ .object({
50
+ conversation: jsonRecordSchema,
51
+ messages: z.array(jsonRecordSchema),
52
+ })
53
+ .nullish(),
54
+ })
55
+ .transform(
56
+ (value) =>
57
+ ({
58
+ ...value,
59
+ items: value.items as unknown as Array<
60
+ TimelineItem | DmConversationItem
61
+ >,
62
+ selectedConversation: value.selectedConversation
63
+ ? {
64
+ conversation: value.selectedConversation
65
+ .conversation as unknown as DmConversationItem,
66
+ messages: value.selectedConversation
67
+ .messages as unknown as DmMessageItem[],
68
+ }
69
+ : value.selectedConversation,
70
+ }) as QueryResponse,
71
+ );
72
+
73
+ const webSyncResponseSchema = z
74
+ .object({
75
+ ok: z.boolean(),
76
+ kind: webSyncKindSchema,
77
+ accountId: z.string().optional(),
78
+ summary: z.string(),
79
+ steps: z.array(jsonRecordSchema),
80
+ startedAt: z.string().optional(),
81
+ finishedAt: z.string().optional(),
82
+ inProgress: z.boolean().optional(),
83
+ backup: z.unknown().optional(),
84
+ error: z.string().optional(),
85
+ })
86
+ .transform((value) => value as unknown as WebSyncResponse);
87
+
88
+ const webSyncJobSchema = z
89
+ .object({
90
+ id: z.string(),
91
+ kind: webSyncKindSchema,
92
+ accountId: z.string().optional(),
93
+ status: z.enum(["running", "succeeded", "failed"]),
94
+ startedAt: z.string(),
95
+ finishedAt: z.string().optional(),
96
+ summary: z.string(),
97
+ inProgress: z.boolean(),
98
+ result: webSyncResponseSchema.optional(),
99
+ error: z.string().optional(),
100
+ })
101
+ .transform((value) => value as unknown as WebSyncJobSnapshot);
102
+
103
+ const actionResponseSchema = jsonRecordSchema;
104
+ const SYNC_POLL_INTERVAL_MS = 500;
105
+
106
+ export class ApiFetchError extends Error {
107
+ constructor(
108
+ message: string,
109
+ readonly status?: number,
110
+ ) {
111
+ super(message);
112
+ this.name = "ApiFetchError";
113
+ }
114
+ }
115
+
116
+ function responseMessage(data: unknown, fallback: string) {
117
+ if (data && typeof data === "object") {
118
+ const record = data as {
119
+ message?: unknown;
120
+ error?: unknown;
121
+ summary?: unknown;
122
+ };
123
+ if (typeof record.message === "string") return record.message;
124
+ if (typeof record.error === "string") return record.error;
125
+ if (typeof record.summary === "string") return record.summary;
126
+ }
127
+ return fallback;
128
+ }
129
+
130
+ async function readJson(response: Response) {
131
+ try {
132
+ return await response.json();
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ export async function fetchJson<T>(
139
+ input: RequestInfo | URL,
140
+ init: RequestInit | undefined,
141
+ schema: z.ZodType<T>,
142
+ fallbackMessage: string,
143
+ ): Promise<T> {
144
+ const response = await fetch(input, init);
145
+ const data = await readJson(response);
146
+ if (!response.ok) {
147
+ throw new ApiFetchError(
148
+ responseMessage(data, fallbackMessage),
149
+ response.status,
150
+ );
151
+ }
152
+
153
+ const parsed = schema.safeParse(data);
154
+ if (!parsed.success) {
155
+ throw new ApiFetchError(fallbackMessage);
156
+ }
157
+ return parsed.data;
158
+ }
159
+
160
+ export function fetchQueryEnvelope(init?: RequestInit) {
161
+ return fetchJson(
162
+ "/api/status",
163
+ init,
164
+ queryEnvelopeSchema,
165
+ "Status unavailable",
166
+ );
167
+ }
168
+
169
+ export function fetchQueryResponse(
170
+ input: RequestInfo | URL,
171
+ init?: RequestInit,
172
+ ) {
173
+ return fetchJson(input, init, queryResponseSchema, "Query unavailable");
174
+ }
175
+
176
+ export function postAction(body: Record<string, unknown>) {
177
+ return fetchJson(
178
+ "/api/action",
179
+ {
180
+ method: "POST",
181
+ headers: { "content-type": "application/json" },
182
+ body: JSON.stringify(body),
183
+ },
184
+ actionResponseSchema,
185
+ "Action failed",
186
+ );
187
+ }
188
+
189
+ export function postSync(kind: WebSyncKind, accountId?: string) {
190
+ return fetchJson(
191
+ "/api/sync",
192
+ {
193
+ method: "POST",
194
+ headers: { "content-type": "application/json" },
195
+ body: JSON.stringify({
196
+ kind,
197
+ ...(accountId ? { accountId } : {}),
198
+ }),
199
+ },
200
+ webSyncJobSchema,
201
+ "Sync failed",
202
+ ).then(waitForWebSyncJob);
203
+ }
204
+
205
+ function fetchSyncJob(id: string) {
206
+ const url = new URL("/api/sync", window.location.origin);
207
+ url.searchParams.set("id", id);
208
+ return fetchJson(url, undefined, webSyncJobSchema, "Sync status unavailable");
209
+ }
210
+
211
+ function delay(ms: number) {
212
+ return new Promise((resolve) => setTimeout(resolve, ms));
213
+ }
214
+
215
+ async function waitForWebSyncJob(job: WebSyncJobSnapshot) {
216
+ let current = job;
217
+ while (current.inProgress) {
218
+ await delay(SYNC_POLL_INTERVAL_MS);
219
+ current = await fetchSyncJob(current.id);
220
+ }
221
+
222
+ if (!current.result) {
223
+ throw new ApiFetchError(current.error ?? current.summary);
224
+ }
225
+ if (!current.result.ok) {
226
+ throw new ApiFetchError(current.result.error ?? current.result.summary);
227
+ }
228
+ return current.result;
229
+ }
@@ -96,27 +96,31 @@ export async function findArchives(): Promise<ArchiveCandidate[]> {
96
96
  'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
97
97
  ];
98
98
 
99
- for (const query of queries) {
100
- try {
101
- const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
102
- timeout: 5000,
103
- });
104
-
105
- const paths = stdout
106
- .split("\n")
107
- .map((item) => item.trim())
108
- .filter((item) => item.length > 0 && item.endsWith(".zip"));
109
-
110
- const candidates = await Promise.all(
111
- paths.map((filePath) => getCandidate(filePath)),
112
- );
113
- for (const candidate of candidates) {
114
- if (candidate) {
115
- found.set(candidate.path, candidate);
116
- }
99
+ const spotlightCandidates = await Promise.all(
100
+ queries.map(async (query) => {
101
+ try {
102
+ const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
103
+ timeout: 5000,
104
+ });
105
+
106
+ const paths = stdout
107
+ .split("\n")
108
+ .map((item) => item.trim())
109
+ .filter((item) => item.length > 0 && item.endsWith(".zip"));
110
+
111
+ return Promise.all(paths.map((filePath) => getCandidate(filePath)));
112
+ } catch {
113
+ // Best-effort only.
114
+ return [];
115
+ }
116
+ }),
117
+ );
118
+
119
+ for (const candidates of spotlightCandidates) {
120
+ for (const candidate of candidates) {
121
+ if (candidate) {
122
+ found.set(candidate.path, candidate);
117
123
  }
118
- } catch {
119
- // Best-effort only.
120
124
  }
121
125
  }
122
126
 
@@ -218,7 +218,12 @@ function toFiniteNumber(value: unknown) {
218
218
 
219
219
  function extractTweetEntities(tweet: Record<string, unknown>) {
220
220
  const entities = asRecord(tweet.entities);
221
- const urls = asArray<Record<string, unknown>>(entities?.urls)
221
+ const urlEntries = [
222
+ ...asArray<Record<string, unknown>>(entities?.urls),
223
+ ...asArray<Record<string, unknown>>(entities?.media),
224
+ ];
225
+ const seenUrls = new Set<string>();
226
+ const urls = urlEntries
222
227
  .map((entry) => ({
223
228
  url: String(entry.url ?? ""),
224
229
  expandedUrl: String(
@@ -243,7 +248,11 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
243
248
  ? entry.imageUrl
244
249
  : typeof entry.thumbnail_url === "string"
245
250
  ? entry.thumbnail_url
246
- : undefined,
251
+ : typeof entry.media_url_https === "string"
252
+ ? entry.media_url_https
253
+ : typeof entry.media_url === "string"
254
+ ? entry.media_url
255
+ : undefined,
247
256
  siteName:
248
257
  typeof entry.site_name === "string"
249
258
  ? entry.site_name
@@ -251,7 +260,13 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
251
260
  ? entry.siteName
252
261
  : undefined,
253
262
  }))
254
- .filter((entry) => entry.url.length > 0 || entry.expandedUrl.length > 0);
263
+ .filter((entry) => entry.url.length > 0 || entry.expandedUrl.length > 0)
264
+ .filter((entry) => {
265
+ const key = `${entry.start}:${entry.end}:${entry.url}:${entry.expandedUrl}`;
266
+ if (seenUrls.has(key)) return false;
267
+ seenUrls.add(key);
268
+ return true;
269
+ });
255
270
  const mentions = asArray<Record<string, unknown>>(entities?.user_mentions)
256
271
  .map((entry) => ({
257
272
  username: String(entry.screen_name ?? ""),
@@ -0,0 +1,174 @@
1
+ import { useCallback, useEffect, useSyncExternalStore } from "react";
2
+ import type { ReactNode } from "react";
3
+ import type { EmbeddedTweet } from "#/lib/types";
4
+
5
+ type ConversationStatus = "idle" | "loading" | "ready" | "error";
6
+
7
+ interface ConversationRecord {
8
+ error: string | null;
9
+ items: EmbeddedTweet[];
10
+ status: ConversationStatus;
11
+ }
12
+
13
+ interface ConversationSurfaceSnapshot {
14
+ expandedTweetId: string | null;
15
+ records: ReadonlyMap<string, ConversationRecord>;
16
+ }
17
+
18
+ type Listener = () => void;
19
+
20
+ const emptyRecord: ConversationRecord = {
21
+ error: null,
22
+ items: [],
23
+ status: "idle",
24
+ };
25
+
26
+ let snapshot: ConversationSurfaceSnapshot = {
27
+ expandedTweetId: null,
28
+ records: new Map(),
29
+ };
30
+ const listeners = new Set<Listener>();
31
+ const inFlight = new Set<string>();
32
+ let activeScopes = 0;
33
+ let generation = 0;
34
+
35
+ function emit() {
36
+ for (const listener of listeners) {
37
+ listener();
38
+ }
39
+ }
40
+
41
+ function setSnapshot(next: ConversationSurfaceSnapshot) {
42
+ snapshot = next;
43
+ emit();
44
+ }
45
+
46
+ function updateRecord(tweetId: string, record: ConversationRecord) {
47
+ const records = new Map(snapshot.records);
48
+ records.set(tweetId, record);
49
+ setSnapshot({ ...snapshot, records });
50
+ }
51
+
52
+ function subscribe(listener: Listener) {
53
+ listeners.add(listener);
54
+ return () => {
55
+ listeners.delete(listener);
56
+ };
57
+ }
58
+
59
+ function getSnapshot() {
60
+ return snapshot;
61
+ }
62
+
63
+ async function loadConversation(tweetId: string) {
64
+ const current = snapshot.records.get(tweetId);
65
+ if (current?.status === "ready" || inFlight.has(tweetId)) {
66
+ return;
67
+ }
68
+
69
+ const loadGeneration = generation;
70
+ inFlight.add(tweetId);
71
+ updateRecord(tweetId, {
72
+ error: null,
73
+ items: current?.items ?? [],
74
+ status: "loading",
75
+ });
76
+
77
+ try {
78
+ const response = await fetch(
79
+ `/api/conversation?tweetId=${encodeURIComponent(tweetId)}`,
80
+ );
81
+ const data = (await response.json()) as {
82
+ error?: string;
83
+ items?: EmbeddedTweet[];
84
+ ok?: boolean;
85
+ };
86
+ if (!response.ok || data.ok === false) {
87
+ throw new Error(data.error ?? "Conversation unavailable");
88
+ }
89
+ if (loadGeneration !== generation) {
90
+ return;
91
+ }
92
+ updateRecord(tweetId, {
93
+ error: null,
94
+ items: (data.items ?? []).filter(Boolean),
95
+ status: "ready",
96
+ });
97
+ } catch (error) {
98
+ if (loadGeneration !== generation) {
99
+ return;
100
+ }
101
+ updateRecord(tweetId, {
102
+ error:
103
+ error instanceof Error ? error.message : "Conversation unavailable",
104
+ items: [],
105
+ status: "error",
106
+ });
107
+ } finally {
108
+ inFlight.delete(tweetId);
109
+ }
110
+ }
111
+
112
+ export function retainConversationSurfaceScope() {
113
+ activeScopes += 1;
114
+ return () => {
115
+ activeScopes = Math.max(0, activeScopes - 1);
116
+ if (activeScopes === 0) {
117
+ resetConversationSurface();
118
+ }
119
+ };
120
+ }
121
+
122
+ export function resetConversationSurface() {
123
+ generation += 1;
124
+ inFlight.clear();
125
+ setSnapshot({
126
+ expandedTweetId: null,
127
+ records: new Map(),
128
+ });
129
+ }
130
+
131
+ export function ConversationSurfaceScope({
132
+ children,
133
+ }: {
134
+ children: ReactNode;
135
+ }) {
136
+ useEffect(() => retainConversationSurfaceScope(), []);
137
+ return children;
138
+ }
139
+
140
+ export function useConversationSurface(tweetId: string) {
141
+ const currentSnapshot = useSyncExternalStore(
142
+ subscribe,
143
+ getSnapshot,
144
+ getSnapshot,
145
+ );
146
+ const record = currentSnapshot.records.get(tweetId) ?? emptyRecord;
147
+ const isOpen = currentSnapshot.expandedTweetId === tweetId;
148
+
149
+ const toggle = useCallback(() => {
150
+ const nextExpanded = snapshot.expandedTweetId === tweetId ? null : tweetId;
151
+ setSnapshot({ ...snapshot, expandedTweetId: nextExpanded });
152
+ if (nextExpanded) {
153
+ void loadConversation(tweetId);
154
+ }
155
+ }, [tweetId]);
156
+
157
+ const prefetch = useCallback(() => {
158
+ const current = snapshot.records.get(tweetId);
159
+ if (current?.status === "ready" || current?.status === "loading") {
160
+ return;
161
+ }
162
+ void loadConversation(tweetId);
163
+ }, [tweetId]);
164
+
165
+ return {
166
+ error: record.error,
167
+ isOpen,
168
+ items: record.items,
169
+ loading: record.status === "loading",
170
+ prefetch,
171
+ status: record.status,
172
+ toggle,
173
+ };
174
+ }
package/src/lib/db.ts CHANGED
@@ -574,11 +574,13 @@ const BASE_SCHEMA_SQL = `
574
574
 
575
575
  const INDEX_SQL = `
576
576
  create index if not exists idx_tweets_kind_created on tweets(kind, created_at desc);
577
+ create index if not exists idx_tweets_created on tweets(created_at desc);
577
578
  create index if not exists idx_tweets_account_created on tweets(account_id, created_at desc);
578
579
  create index if not exists idx_tweets_quoted on tweets(quoted_tweet_id);
579
580
  create index if not exists idx_tweet_collections_kind_account on tweet_collections(kind, account_id, collected_at desc, tweet_id);
580
581
  create index if not exists idx_tweet_collections_tweet on tweet_collections(tweet_id);
581
582
  create index if not exists idx_tweet_account_edges_kind_account on tweet_account_edges(kind, account_id, last_seen_at desc, tweet_id);
583
+ create index if not exists idx_tweet_account_edges_kind_tweet on tweet_account_edges(kind, tweet_id, account_id);
582
584
  create index if not exists idx_tweet_account_edges_tweet on tweet_account_edges(tweet_id);
583
585
  create index if not exists idx_dm_conversations_account on dm_conversations(account_id, last_message_at desc);
584
586
  create index if not exists idx_dm_messages_conversation on dm_messages(conversation_id, created_at asc);