diff --git a/experimental/chatproto/packages/appview/src/api/lexicons/getMessages.ts b/experimental/chatproto/packages/appview/src/api/lexicons/getMessages.ts new file mode 100644 index 0000000..c9317a3 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/api/lexicons/getMessages.ts @@ -0,0 +1,153 @@ +import { AppContext } from '#/context' +import { Server } from '#/lexicons' +import { statusToStatusView } from '#/lib/hydrate' +import { AtUri, isValidHandle } from '@atproto/syntax'; + +import { getSessionAgent } from '#/session' +import { AppAndrefChatprotoChannel, AppAndrefChatprotoMessage, AppAndrefChatprotoSpace } from '@statusphere/lexicon' +import { isValidDidDoc } from '@atproto/common'; +import { DidResolver, HandleResolver } from '@atproto/identity'; + +export default function (server: Server, ctx: AppContext) { + server.app.andref.chatproto.getMessages({ + handler: async ({ params, req, res }) => { + // TODO: handle pagination + if (params.before !== undefined) { + throw new Error('Pagination is not supported yet'); + } + + // TODO: use cached data stored in DB + if (params.hintChannelOwner === undefined) { + throw new Error('hintChannelOwner is required for now'); + } + + const agent = await getSessionAgent(req, res, ctx); + if (!agent) { + // TODO: is auth required? + throw new Error('Authentication required'); + } + + const channel = await agent.com.atproto.repo.getRecord({ + repo: params.hintChannelOwner, + collection: 'app.andref.chatproto.channel', + rkey: params.channelNsid, + }).catch(() => { + throw new Error('Channel not found'); + }); + if (!channel.success) { + throw new Error(`Getting channel record failed:\nHeaders:\n${channel.headers}\nData:\n${channel.data}`); + } + + // TODO: think about whether it makes sense to require the channel's belonging space to be owned by the same hintChannelOwner + const validatedChannel = AppAndrefChatprotoChannel.validateRecord(channel.data); + if (!validatedChannel.success) { + throw new Error(`Channel record validation failed:\n${validatedChannel.error}`); + } + const parts = params.channelNsid.split('.'); + parts.pop(); + const spaceNsid = parts.join('.'); + const space = await agent.com.atproto.repo.getRecord({ + repo: params.hintChannelOwner, + collection: 'app.andref.chatproto.space', + rkey: spaceNsid, + }).catch(() => { + throw new Error('Space not found'); + }); + if (!space.success) { + throw new Error(`Getting space record failed:\nHeaders:\n${space.headers}\nData:\n${space.data}`); + } + const validatedSpace = AppAndrefChatprotoSpace.validateRecord(space.data); + if (!validatedSpace.success) { + throw new Error(`Space record validation failed:\n${validatedSpace.error}`); + } + const writers = validatedSpace.value.writers ?? []; + const messagesByWriter = await Promise.all(writers.map(async (writer) => { + const messagesFromWriter = await agent.com.atproto.repo.listRecords({ + repo: writer, + collection: 'app.andref.chatproto.message', + limit: 50, + reverse: true, + }); + if (!messagesFromWriter.success) { + throw new Error(`Listing messages from ${writer} failed:\nHeaders:\n${messagesFromWriter.headers}\nData:\n${messagesFromWriter.data}`); + } + const validatedMessageRkeyPairs = messagesFromWriter.data.records.map((entry) => { + const uri = new AtUri(entry.uri); + const rkey = uri.rkey; + const validatedMessage = AppAndrefChatprotoMessage.validateRecord(entry.value); + if (!validatedMessage.success) { + throw new Error(`Message record validation failed:\n${validatedMessage.error}`); + } + return { + message: validatedMessage.value, + rkey, + }; + }); + return { + writer, + messages: validatedMessageRkeyPairs, + }; + })); + const messages = messagesByWriter.flatMap(({ writer, messages }) => { + return messages.map(({ message, rkey }) => ({ + writer, + message, + rkey, + })); + }).sort((a, b) => { + if (a.message.createdAt > b.message.createdAt) return -1; + else if (a.message.createdAt < b.message.createdAt) return 1; + else return 0; + }); + + // Convert writer to did and handle + const messagesWithAuthorViews = await Promise.all(messages.map(async ({ writer, message, rkey }) => { + if (!isValidDidDoc(writer) && !isValidHandle(writer)) { + throw new Error(`Invalid at-identifier: ${writer}`); + } + let did: string; + let handle: string | null; + if (!isValidDidDoc(writer)) { + handle = writer; + // Convert handle to did + const handleResolver = new HandleResolver({}); + const resolved = await handleResolver.resolve(writer); + if (resolved === undefined) { + throw new Error(`Failed to resolve handle: ${writer}`); + } + did = resolved; + } else { + did = writer; + // Convert did to handle (not required to be successful) + const didResolver = new DidResolver({}); + const userData = await didResolver.resolveAtprotoData(did); + handle = userData.handle; + } + return { + did, + handle, + message, + rkey, + }; + })); + + const messagesToReturn = messagesWithAuthorViews.map(({ did, handle, message, rkey }) => ({ + tid: rkey, + plainText: message.plaintext, + createdAt: message.createdAt, + channel: validatedChannel.value.uri, + author: { + did, + handle, + }, + })); + + return { + encoding: 'application/json', + body: { + messages: messagesToReturn.slice(0, 50), // limit to 50 messages + }, + }; + }, + }) +} diff --git a/experimental/chatproto/packages/client/src/pages/Channel.tsx b/experimental/chatproto/packages/client/src/pages/Channel.tsx index 5e6d6bd..7371b1a 100644 --- a/experimental/chatproto/packages/client/src/pages/Channel.tsx +++ b/experimental/chatproto/packages/client/src/pages/Channel.tsx @@ -3,18 +3,46 @@ import StatusForm, { STATUS_OPTIONS } from '#/components/StatusForm' import StatusList from '#/components/StatusList' import { useAuth } from '#/hooks/useAuth' import { useParams, useSearchParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import api from '#/services/api' +import { useEffect } from 'react' const Channel = () => { const { channelNsid } = useParams(); + if (channelNsid === undefined) { + return
+ Channel component requires channelNsid router param. +
++ Query failed. Try refreshing? +
+Author: {messageData.author.handle} ({messageData.author.did}) ({messageData.createdAt})
+{messageData.plaintext ?? ""}
+