Skip to content

Commit

Permalink
Add message content validators
Browse files Browse the repository at this point in the history
  • Loading branch information
rygine committed Aug 17, 2023
1 parent cb7d916 commit a9c13b4
Show file tree
Hide file tree
Showing 21 changed files with 486 additions and 60 deletions.
3 changes: 2 additions & 1 deletion packages/react-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.6",
"react": "^18.2.0",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"zod": "^3.22.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.0.0",
Expand Down
32 changes: 26 additions & 6 deletions packages/react-sdk/src/contexts/XMTPContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@ import Dexie from "dexie";
import type {
CacheConfiguration,
CachedMessageProcessors,
CachedMessageValidators,
} from "@/helpers/caching/db";
import { getDbInstance } from "@/helpers/caching/db";
import { combineNamespaces } from "@/helpers/combineNamespaces";
import { combineMessageProcessors } from "@/helpers/combineMessageProcessors";
import { combineCodecs } from "@/helpers/combineCodecs";
import { combineValidators } from "@/helpers/combineValidators";

export type XMTPContextValue = {
/**
* The XMTP client instance
*/
client?: Client;
/**
* Content codecs used by the XMTP client
* Content codecs used by the XMTP client instance
*/
codecs: ContentCodec<any>[];
/**
Expand All @@ -31,12 +33,22 @@ export type XMTPContextValue = {
* Message processors for caching
*/
processors: CachedMessageProcessors;
/**
* Set the XMTP client instance
*/
setClient: React.Dispatch<React.SetStateAction<Client | undefined>>;
/**
* Set the signer (wallet) to associate with the XMTP client instance
*/
setClientSigner: React.Dispatch<React.SetStateAction<Signer | undefined>>;
/**
* The signer (wallet) to associate with the XMTP client
* The signer (wallet) associated with the XMTP client instance
*/
signer?: Signer | null;
/**
* Message content validators for content types
*/
validators: CachedMessageValidators;
};

const initialDb = new Dexie("__XMTP__");
Expand All @@ -48,6 +60,7 @@ export const XMTPContext = createContext<XMTPContextValue>({
processors: {},
setClient: () => {},
setClientSigner: () => {},
validators: {},
});

export type XMTPProviderProps = React.PropsWithChildren & {
Expand Down Expand Up @@ -79,21 +92,27 @@ export const XMTPProvider: React.FC<XMTPProviderProps> = ({
undefined,
);

// combine all processors into a single object
// combine all message processors
const processors = useMemo(
() => combineMessageProcessors(cacheConfig ?? []),
[cacheConfig],
);

// combine all codecs into a single array
// combine all codecs
const codecs = useMemo(() => combineCodecs(cacheConfig ?? []), [cacheConfig]);

// combine all namespaces into a single object
// combine all namespaces
const namespaces = useMemo(
() => combineNamespaces(cacheConfig ?? []),
[cacheConfig],
);

// combine all content validators
const validators = useMemo(
() => combineValidators(cacheConfig ?? []),
[cacheConfig],
);

// DB instance for caching
const db = useMemo(
() =>
Expand All @@ -116,8 +135,9 @@ export const XMTPProvider: React.FC<XMTPProviderProps> = ({
setClient,
setClientSigner,
signer: clientSigner,
validators,
}),
[client, clientSigner, codecs, db, namespaces, processors],
[client, clientSigner, codecs, db, namespaces, processors, validators],
);

return <XMTPContext.Provider value={value}>{children}</XMTPContext.Provider>;
Expand Down
85 changes: 79 additions & 6 deletions packages/react-sdk/src/helpers/caching/contentTypes/attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,41 +9,110 @@ import {
RemoteAttachmentCodec,
} from "@xmtp/content-type-remote-attachment";
import { ContentTypeId } from "@xmtp/xmtp-js";
import { z } from "zod";
import type { CacheConfiguration, CachedMessageProcessor } from "../db";
import { type CachedMessage } from "../messages";

const NAMESPACE = "attachment";

export type CachedAttachmentsMetadata = Attachment | undefined;

/**
* Get the attachment data from a cached message
*
* @param message Cached message
* @returns The attachment data, or `undefined` if the message has no attachment
*/
export const getAttachment = (message: CachedMessage) =>
message?.metadata?.[NAMESPACE] as CachedAttachmentsMetadata;

/**
* Check if a cached message has an attachment
*
* @param message Cached message
* @returns `true` if the message has an attachment, `false` otherwise
*/
export const hasAttachment = (message: CachedMessage) => {
const metadata = message?.metadata?.[NAMESPACE] as CachedAttachmentsMetadata;
return !!metadata;
const attachment = getAttachment(message);
return !!attachment;
};

export const getAttachment = (message: CachedMessage) =>
message?.metadata?.[NAMESPACE] as CachedAttachmentsMetadata;
const AttachmentContentSchema = z.object({
filename: z.string(),
mimeType: z.string(),
data: z.instanceof(Uint8Array),
});

/**
* Validate the content of an attachment message
*
* @param content Message content
* @returns `true` if the content is valid, `false` otherwise
*/
const isValidAttachmentContent = (content: unknown) => {
const { success } = AttachmentContentSchema.safeParse(content);
return success;
};

/**
* Process an attachment message
*
* The message content is also saved to the metadata of the message.
*/
export const processAttachment: CachedMessageProcessor = async ({
message,
persist,
}) => {
const contentType = ContentTypeId.fromString(message.contentType);
if (ContentTypeAttachment.sameAs(contentType)) {
if (
ContentTypeAttachment.sameAs(contentType) &&
isValidAttachmentContent(message.content)
) {
// save message to cache with the attachment metadata
await persist({
metadata: message.content as Attachment,
});
}
};

const RemoveAttachmentContentSchema = z.object({
url: z.string(),
contentDigest: z.string(),
salt: z.instanceof(Uint8Array),
nonce: z.instanceof(Uint8Array),
secret: z.instanceof(Uint8Array),
scheme: z.string(),
contentLength: z.number().gte(0),
filename: z.string(),
});

/**
* Validate the content of a remote attachment message
*
* @param content Message content
* @returns `true` if the content is valid, `false` otherwise
*/
const isValidRemoveAttachmentContent = (content: unknown) => {
const { success } = RemoveAttachmentContentSchema.safeParse(content);
return success;
};

/**
* Process a remote attachment message
*
* Loads the attachment from the remote URL and saves it to the metadata
* of the message.
*/
export const processRemoteAttachment: CachedMessageProcessor = async ({
client,
message,
persist,
}) => {
const contentType = ContentTypeId.fromString(message.contentType);
if (ContentTypeRemoteAttachment.sameAs(contentType)) {
if (
ContentTypeRemoteAttachment.sameAs(contentType) &&
isValidRemoveAttachmentContent(message.content)
) {
const attachment = await RemoteAttachmentCodec.load<Attachment>(
message.content as RemoteAttachment,
client,
Expand All @@ -63,4 +132,8 @@ export const attachmentsCacheConfig: CacheConfiguration = {
[ContentTypeAttachment.toString()]: [processAttachment],
[ContentTypeRemoteAttachment.toString()]: [processRemoteAttachment],
},
validators: {
[ContentTypeAttachment.toString()]: isValidAttachmentContent,
[ContentTypeRemoteAttachment.toString()]: isValidRemoveAttachmentContent,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe("ContentTypeReaction caching", () => {
xmtpID: "testXmtpId1",
} satisfies CachedMessageWithId;

await saveMessage({ db, message: testTextMessage });
await saveMessage(testTextMessage, db);

const testReactionContent = {
content: "test",
Expand Down
71 changes: 69 additions & 2 deletions packages/react-sdk/src/helpers/caching/contentTypes/reaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "@xmtp/content-type-reaction";
import { ContentTypeId } from "@xmtp/xmtp-js";
import type { Dexie, Table } from "dexie";
import { z } from "zod";
import type { CacheConfiguration, CachedMessageProcessor } from "../db";
import type { CachedMessage } from "../messages";
import { getMessageByXmtpID, updateMessageMetadata } from "../messages";
Expand Down Expand Up @@ -35,6 +36,13 @@ export type CachedReactionsMetadata = boolean;

export type CachedReactionsTable = Table<CachedReaction, number>;

/**
* Finds a reaction in the cache
*
* @param reaction Cached reaction properties to look for
* @param db Database instance
* @returns Cached reaction, or `undefined` if not found
*/
export const findReaction = async (reaction: CachedReaction, db: Dexie) => {
const reactionsTable = db.table("reactions") as CachedReactionsTable;

Expand All @@ -50,6 +58,14 @@ export const findReaction = async (reaction: CachedReaction, db: Dexie) => {
return found ? (found as CachedReactionWithId) : undefined;
};

/**
* Save a reaction to the cache
*
* @param reaction Reaction to save
* @param db Database instance
* @returns ID of the saved reaction, or an existing ID if the reaction
* already exists in the cache
*/
export const saveReaction = async (reaction: CachedReaction, db: Dexie) => {
const reactionsTable = db.table("reactions") as CachedReactionsTable;

Expand All @@ -62,6 +78,12 @@ export const saveReaction = async (reaction: CachedReaction, db: Dexie) => {
return reactionsTable.add(reaction);
};

/**
* Delete a reaction from the cache
*
* @param reaction Reaction to delete
* @param db Database instance
*/
export const deleteReaction = async (reaction: CachedReaction, db: Dexie) => {
const reactionsTable = db.table("reactions") as CachedReactionsTable;
// make sure reaction exists
Expand All @@ -71,6 +93,13 @@ export const deleteReaction = async (reaction: CachedReaction, db: Dexie) => {
}
};

/**
* Get all reactions to a cached message by its XMTP ID
*
* @param xmtpID The XMTP ID of the cached message
* @param db Database instance
* @returns An array of reactions to the message
*/
export const getReactionsByXmtpID = async (
xmtpID: Reaction["reference"],
db: Dexie,
Expand All @@ -79,6 +108,14 @@ export const getReactionsByXmtpID = async (
return reactionsTable.where({ referenceXmtpID: xmtpID }).toArray();
};

/**
* Update the reactions metadata of a cached message
*
* The metadata stores the number of reactions to the message only.
*
* @param referenceXmtpID The XMTP ID of the cached message
* @param db Database instance
*/
const updateReactionsMetadata = async (
referenceXmtpID: Reaction["reference"],
db: Dexie,
Expand All @@ -90,21 +127,48 @@ const updateReactionsMetadata = async (
}
};

/**
* Check if a cached message has a reaction
*
* @param message Cached message
* @returns `true` if the message has a reaction, `false` otherwise
*/
export const hasReaction = (message: CachedMessage) =>
!!message?.metadata?.[NAMESPACE];

const ReactionContentSchema = z.object({
reference: z.string(),
action: z.enum(["added", "removed"]),
content: z.string(),
schema: z.enum(["unicode", "shortcode", "custom"]),
});

/**
* Validate the content of a reaction message
*
* @param content Message content
* @returns `true` if the content is valid, `false` otherwise
*/
const isValidReactionContent = (content: unknown) => {
const { success } = ReactionContentSchema.safeParse(content);
return success;
};

/**
* Process a reaction message
*
* This will add or remove the reaction from the cache based on the `action`
* Adds or removes the reaction from the cache based on the `action`
* property. The original message is not saved to the messages cache.
*/
export const processReaction: CachedMessageProcessor = async ({
message,
db,
}) => {
const contentType = ContentTypeId.fromString(message.contentType);
if (ContentTypeReaction.sameAs(contentType)) {
if (
ContentTypeReaction.sameAs(contentType) &&
isValidReactionContent(message.content)
) {
const reaction = message.content as Reaction;
const cachedReaction = {
content: reaction.content,
Expand Down Expand Up @@ -146,4 +210,7 @@ export const reactionsCacheConfig: CacheConfiguration = {
xmtpID
`,
},
validators: {
[ContentTypeReaction.toString()]: isValidReactionContent,
},
};
Loading

0 comments on commit a9c13b4

Please sign in to comment.