diff --git a/packages/react-chat/src/components/Form/ContactMenu.tsx b/packages/react-chat/src/components/Form/ContactMenu.tsx index 39390c77..97e6ac6b 100644 --- a/packages/react-chat/src/components/Form/ContactMenu.tsx +++ b/packages/react-chat/src/components/Form/ContactMenu.tsx @@ -3,8 +3,8 @@ import React, { useMemo } from "react"; import styled from "styled-components"; import { useIdentity } from "../../contexts/identityProvider"; +import { useMessengerContext } from "../../contexts/messengerProvider"; import { useModal } from "../../contexts/modalProvider"; -import { useManageContact } from "../../hooks/useManageContact"; import { AddContactIcon } from "../Icons/AddContactIcon"; import { BlockSvg } from "../Icons/BlockIcon"; import { ChatSvg } from "../Icons/ChatIcon"; @@ -26,6 +26,8 @@ type ContactMenuProps = { export function ContactMenu({ id, setShowMenu }: ContactMenuProps) { const identity = useIdentity(); + const { contacts, contactsDispatch } = useMessengerContext(); + const contact = useMemo(() => contacts[id], [id, contacts]); const isUser = useMemo(() => { if (identity) { return id === bufToHex(identity.publicKey); @@ -35,7 +37,6 @@ export function ContactMenu({ id, setShowMenu }: ContactMenuProps) { }, [id, identity]); const { setModal } = useModal(ProfileModalName); - const { contact, setBlocked, setIsUntrustworthy } = useManageContact(id); if (!contact) return null; return ( @@ -88,7 +89,11 @@ export function ContactMenu({ id, setShowMenu }: ContactMenuProps) { - setIsUntrustworthy(!contact.isUntrustworthy)}> + + contactsDispatch({ type: "toggleTrustworthy", payload: { id } }) + } + > { - setBlocked(!contact.blocked); + contactsDispatch({ type: "toggleBlocked", payload: { id } }); setShowMenu(false); }} > diff --git a/packages/react-chat/src/components/Modals/ProfileModal.tsx b/packages/react-chat/src/components/Modals/ProfileModal.tsx index 858df849..9204213b 100644 --- a/packages/react-chat/src/components/Modals/ProfileModal.tsx +++ b/packages/react-chat/src/components/Modals/ProfileModal.tsx @@ -4,9 +4,9 @@ import styled from "styled-components"; import { useActivities } from "../../contexts/activityProvider"; import { useIdentity } from "../../contexts/identityProvider"; +import { useMessengerContext } from "../../contexts/messengerProvider"; import { useModal } from "../../contexts/modalProvider"; import { useToasts } from "../../contexts/toastProvider"; -import { useManageContact } from "../../hooks/useManageContact"; import { copy } from "../../utils"; import { buttonStyles } from "../Buttons/buttonStyle"; import { @@ -75,13 +75,8 @@ export const ProfileModal = () => { setRequestCreation(requestState ?? false); }, [requestState]); - const { - contact, - setBlocked, - setCustomName, - setIsUntrustworthy, - setIsUserFriend, - } = useManageContact(id); + const { contacts, contactsDispatch } = useMessengerContext(); + const contact = useMemo(() => contacts[id], [id, contacts]); const [customNameInput, setCustomNameInput] = useState(""); if (!contact) return null; @@ -129,7 +124,10 @@ export const ProfileModal = () => { {customNameInput && ( { - setCustomName(undefined); + contactsDispatch({ + type: "setCustomName", + payload: { id, customName: undefined }, + }); setCustomNameInput(""); }} > @@ -184,7 +182,10 @@ export const ProfileModal = () => { { - setCustomName(customNameInput); + contactsDispatch({ + type: "setCustomName", + payload: { id, customName: customNameInput }, + }); setRenaming(false); }} > @@ -234,7 +235,7 @@ export const ProfileModal = () => { { - setBlocked(!contact.blocked); + contactsDispatch({ type: "toggleBlocked", payload: { id } }); }} > {contact.blocked ? "Unblock" : "Block"} @@ -243,14 +244,21 @@ export const ProfileModal = () => { {contact.isFriend && ( setIsUserFriend(false)} + onClick={() => + contactsDispatch({ + type: "setIsFriend", + payload: { id, isFriend: false }, + }) + } > Remove Contact )} setIsUntrustworthy(!contact.isUntrustworthy)} + onClick={() => + contactsDispatch({ type: "toggleTrustworthy", payload: { id } }) + } > {contact.isUntrustworthy ? "Remove Untrustworthy Mark" diff --git a/packages/react-chat/src/contexts/messengerProvider.tsx b/packages/react-chat/src/contexts/messengerProvider.tsx index b0300824..cd632f27 100644 --- a/packages/react-chat/src/contexts/messengerProvider.tsx +++ b/packages/react-chat/src/contexts/messengerProvider.tsx @@ -17,7 +17,7 @@ const MessengerContext = createContext({ loadingMessenger: true, communityData: undefined, contacts: {}, - setContacts: () => undefined, + contactsDispatch: () => undefined, activeChannel: undefined, channels: {}, channelsDispatch: () => undefined, diff --git a/packages/react-chat/src/hooks/messenger/useChannelsReducer.ts b/packages/react-chat/src/hooks/messenger/useChannelsReducer.ts new file mode 100644 index 00000000..728a8f31 --- /dev/null +++ b/packages/react-chat/src/hooks/messenger/useChannelsReducer.ts @@ -0,0 +1,79 @@ +import { useReducer } from "react"; + +import { ChannelData, ChannelsData } from "../../models/ChannelData"; + +export type ChannelsState = { + channels: ChannelsData; + activeChannel: ChannelData; +}; + +export type ChannelAction = + | { type: "AddChannel"; payload: ChannelData } + | { type: "UpdateActive"; payload: ChannelData } + | { type: "ChangeActive"; payload: string } + | { type: "ToggleMuted"; payload: string } + | { type: "RemoveChannel"; payload: string }; + +function channelReducer( + state: ChannelsState, + action: ChannelAction +): ChannelsState { + switch (action.type) { + case "AddChannel": { + const channels = { + ...state.channels, + [action.payload.id]: action.payload, + }; + return { channels, activeChannel: action.payload }; + } + case "UpdateActive": { + const activeChannel = state.activeChannel; + if (activeChannel) { + return { + channels: { ...state.channels, [activeChannel.id]: action.payload }, + activeChannel: action.payload, + }; + } + return state; + } + case "ChangeActive": { + const newActive = state.channels[action.payload]; + if (newActive) { + return { ...state, activeChannel: newActive }; + } + return state; + } + case "ToggleMuted": { + const channel = state.channels[action.payload]; + if (channel) { + const updatedChannel: ChannelData = { + ...channel, + isMuted: !channel.isMuted, + }; + return { + channels: { ...state.channels, [channel.id]: updatedChannel }, + activeChannel: updatedChannel, + }; + } + return state; + } + case "RemoveChannel": { + const channelsCopy = { ...state.channels }; + delete channelsCopy[action.payload]; + let newActive = { id: "", name: "", type: "channel" } as ChannelData; + if (Object.values(channelsCopy).length > 0) { + newActive = Object.values(channelsCopy)[0]; + } + return { channels: channelsCopy, activeChannel: newActive }; + } + default: + throw new Error(); + } +} + +export function useChannelsReducer() { + return useReducer(channelReducer, { + channels: {}, + activeChannel: { id: "", name: "", type: "channel" }, + } as ChannelsState); +} diff --git a/packages/react-chat/src/hooks/messenger/useContacts.ts b/packages/react-chat/src/hooks/messenger/useContacts.ts index 97229da1..bd9a0abf 100644 --- a/packages/react-chat/src/hooks/messenger/useContacts.ts +++ b/packages/react-chat/src/hooks/messenger/useContacts.ts @@ -4,36 +4,116 @@ import { Messenger, } from "@waku/status-communities/dist/cjs"; import { bufToHex } from "@waku/status-communities/dist/cjs/utils"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useReducer, useState } from "react"; import { Contacts } from "../../models/Contact"; +export type ContactsAction = + | { type: "updateOnline"; payload: { id: string; clock: number } } + | { type: "setTrueName"; payload: { id: string; trueName: string } } + | { + type: "setCustomName"; + payload: { id: string; customName: string | undefined }; + } + | { + type: "setIsUntrustworthy"; + payload: { id: string; isUntrustworthy: boolean }; + } + | { type: "setIsFriend"; payload: { id: string; isFriend: boolean } } + | { type: "setBlocked"; payload: { id: string; blocked: boolean } } + | { type: "toggleBlocked"; payload: { id: string } } + | { type: "toggleTrustworthy"; payload: { id: string } }; + +function contactsReducer(state: Contacts, action: ContactsAction): Contacts { + const id = action.payload.id; + const prev = state[id]; + + switch (action.type) { + case "updateOnline": { + const now = Date.now(); + const clock = action.payload.clock; + if (prev) { + return { ...state, [id]: { ...prev, online: clock > now - 301000 } }; + } + return { ...state, [id]: { id, trueName: id.slice(0, 10) } }; + } + case "setTrueName": { + const trueName = action.payload.trueName; + if (prev) { + return { ...state, [id]: { ...prev, trueName } }; + } + return { ...state, [id]: { id, trueName } }; + } + case "setCustomName": { + const customName = action.payload.customName; + if (prev) { + return { ...state, [id]: { ...prev, customName } }; + } + return state; + } + case "setIsUntrustworthy": { + const isUntrustworthy = action.payload.isUntrustworthy; + if (prev) { + return { ...state, [id]: { ...prev, isUntrustworthy } }; + } + return state; + } + case "setIsFriend": { + const isFriend = action.payload.isFriend; + if (prev) { + return { ...state, [id]: { ...prev, isFriend } }; + } + return state; + } + case "setBlocked": { + const blocked = action.payload.blocked; + if (prev) { + return { ...state, [id]: { ...prev, blocked } }; + } + return state; + } + case "toggleBlocked": { + if (prev) { + return { ...state, [id]: { ...prev, blocked: !prev.blocked } }; + } + return state; + } + case "toggleTrustworthy": { + if (prev) { + return { + ...state, + [id]: { ...prev, isUntrustworthy: !prev.isUntrustworthy }, + }; + } + return state; + } + default: + throw new Error(); + } +} + export function useContacts( messenger: Messenger | undefined, identity: Identity | undefined, newNickname: string | undefined ) { const [nickname, setNickname] = useState(undefined); - const [internalContacts, setInternalContacts] = useState<{ - [id: string]: { clock: number; nickname?: string }; - }>({}); + const [contacts, contactsDispatch] = useReducer(contactsReducer, {}); const contactsClass = useMemo(() => { if (messenger) { const newContacts = new ContactsClass( identity, messenger.waku, - (id, clock) => { - setInternalContacts((prev) => { - return { ...prev, [id]: { ...prev[id], clock } }; - }); - }, + (id, clock) => + contactsDispatch({ type: "updateOnline", payload: { id, clock } }), (id, nickname) => { - setInternalContacts((prev) => { - if (identity?.publicKey && id === bufToHex(identity.publicKey)) { - setNickname(nickname); - } - return { ...prev, [id]: { ...prev[id], nickname } }; + if (identity?.publicKey && id === bufToHex(identity.publicKey)) { + setNickname(nickname); + } + contactsDispatch({ + type: "setTrueName", + payload: { id, trueName: nickname }, }); }, newNickname @@ -42,27 +122,5 @@ export function useContacts( } }, [messenger, identity]); - const [contacts, setContacts] = useState({}); - - useEffect(() => { - const now = Date.now(); - setContacts((prev) => { - const newContacts: Contacts = {}; - Object.entries(internalContacts).forEach(([id, { clock, nickname }]) => { - newContacts[id] = { - id, - online: clock > now - 301000, - trueName: nickname ?? id.slice(0, 10), - isUntrustworthy: false, - blocked: false, - }; - if (prev[id]) { - newContacts[id] = { ...prev[id], ...newContacts[id] }; - } - }); - return newContacts; - }); - }, [internalContacts]); - - return { contacts, setContacts, contactsClass, nickname }; + return { contacts, contactsDispatch, contactsClass, nickname }; } diff --git a/packages/react-chat/src/hooks/messenger/useGroupChats.ts b/packages/react-chat/src/hooks/messenger/useGroupChats.ts index ffdb8729..16c5297c 100644 --- a/packages/react-chat/src/hooks/messenger/useGroupChats.ts +++ b/packages/react-chat/src/hooks/messenger/useGroupChats.ts @@ -12,7 +12,7 @@ import { ChatMessage } from "../../models/ChatMessage"; import { Contact } from "../../models/Contact"; import { uintToImgUrl } from "../../utils"; -import { ChannelAction } from "./useMessenger"; +import { ChannelAction } from "./useChannelsReducer"; const contactFromId = (member: string): Contact => { return { @@ -43,13 +43,14 @@ export function useGroupChats( name: chat.name ?? chat.chatId.slice(0, 10), type: "group", description: `${chat.members.length} members`, - members: members, + members, } : { id: chat.chatId, name: chat.members[0].id, type: "dm", description: `Chatkey: ${chat.members[0].id}`, + members, }; dispatch({ type: "AddChannel", payload: channel }); }; diff --git a/packages/react-chat/src/hooks/messenger/useMessenger.ts b/packages/react-chat/src/hooks/messenger/useMessenger.ts index eef2c9b7..45dba7bc 100644 --- a/packages/react-chat/src/hooks/messenger/useMessenger.ts +++ b/packages/react-chat/src/hooks/messenger/useMessenger.ts @@ -6,7 +6,7 @@ import { Identity, Messenger, } from "@waku/status-communities/dist/cjs"; -import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useConfig } from "../../contexts/configProvider"; import { ChannelData, ChannelsData } from "../../models/ChannelData"; @@ -17,7 +17,8 @@ import { createCommunity } from "../../utils/createCommunity"; import { createMessenger } from "../../utils/createMessenger"; import { uintToImgUrl } from "../../utils/uintToImgUrl"; -import { useContacts } from "./useContacts"; +import { ChannelAction, useChannelsReducer } from "./useChannelsReducer"; +import { ContactsAction, useContacts } from "./useContacts"; import { useGroupChats } from "./useGroupChats"; import { useLoadPrevDay } from "./useLoadPrevDay"; import { useMessages } from "./useMessages"; @@ -39,7 +40,7 @@ export type MessengerType = { loadingMessenger: boolean; communityData: CommunityData | undefined; contacts: Contacts; - setContacts: React.Dispatch>; + contactsDispatch: (action: ContactsAction) => void; channels: ChannelsData; channelsDispatch: (action: ChannelAction) => void; removeChannel: (channelId: string) => void; @@ -78,9 +79,12 @@ function useCreateCommunity( const communityData = useMemo(() => { if (community?.description) { - Object.keys(community.description.proto.members).forEach((contact) => - contactsClass?.addContact(contact) - ); + const membersList = Object.keys(community.description.proto.members); + + if (contactsClass) { + membersList.forEach(contactsClass.addContact, contactsClass); + } + return { id: community.publicKeyStr, name: community.description.identity?.displayName ?? "", @@ -88,8 +92,8 @@ function useCreateCommunity( community.description?.identity?.images?.thumbnail?.payload ?? new Uint8Array() ), - members: 0, - membersList: Object.keys(community.description.proto.members), + members: membersList.length, + membersList, description: community.description.identity?.description ?? "", }; } else { @@ -100,88 +104,14 @@ function useCreateCommunity( return { community, communityData }; } -export type ChannelsState = { - channels: ChannelsData; - activeChannel: ChannelData; -}; - -export type ChannelAction = - | { type: "AddChannel"; payload: ChannelData } - | { type: "UpdateActive"; payload: ChannelData } - | { type: "ChangeActive"; payload: string } - | { type: "ToggleMuted"; payload: string } - | { type: "RemoveChannel"; payload: string }; - -function channelReducer( - state: ChannelsState, - action: ChannelAction -): ChannelsState { - switch (action.type) { - case "AddChannel": { - const channels = { - ...state.channels, - [action.payload.id]: action.payload, - }; - return { channels, activeChannel: action.payload }; - } - case "UpdateActive": { - const activeChannel = state.activeChannel; - if (activeChannel) { - return { - channels: { ...state.channels, [activeChannel.id]: action.payload }, - activeChannel: action.payload, - }; - } - return state; - } - case "ChangeActive": { - const newActive = state.channels[action.payload]; - if (newActive) { - return { ...state, activeChannel: newActive }; - } - return state; - } - case "ToggleMuted": { - const channel = state.channels[action.payload]; - if (channel) { - const updatedChannel: ChannelData = { - ...channel, - isMuted: !channel.isMuted, - }; - return { - channels: { ...state.channels, [channel.id]: updatedChannel }, - activeChannel: updatedChannel, - }; - } - return state; - } - case "RemoveChannel": { - const channelsCopy = { ...state.channels }; - delete channelsCopy[action.payload]; - let newActive = { id: "", name: "", type: "channel" } as ChannelData; - if (Object.values(channelsCopy).length > 0) { - newActive = Object.values(channelsCopy)[0]; - } - return { channels: channelsCopy, activeChannel: newActive }; - } - default: - throw new Error(); - } -} - export function useMessenger( communityKey: string, identity: Identity | undefined, newNickname: string | undefined ) { - const [channelsState, channelsDispatch] = useReducer(channelReducer, { - channels: {}, - activeChannel: { id: "", name: "", type: "channel" }, - } as ChannelsState); - + const [channelsState, channelsDispatch] = useChannelsReducer(); const messenger = useCreateMessenger(identity); - - const { contacts, setContacts, contactsClass, nickname } = useContacts( + const { contacts, contactsDispatch, contactsClass, nickname } = useContacts( messenger, identity, newNickname @@ -224,18 +154,21 @@ export function useMessenger( Object.values(channelsState.channels) .filter((channel) => channel.type === "dm") .forEach((channel) => { - const contact = contacts?.[channel?.members?.[0]?.id ?? ""]; - if (contact && channel.name !== (contact?.customName ?? channel.name)) { + const contact = contacts?.[channel?.members?.[1]?.id ?? ""]; + if ( + contact && + channel.name !== (contact?.customName ?? contact.trueName) + ) { channelsDispatch({ type: "AddChannel", payload: { ...channel, - name: contact?.customName ?? channel.name, + name: contact?.customName ?? contact.trueName, }, }); } }); - }, [contacts]); + }, [contacts, channelsState.channels]); const { groupChat, @@ -276,7 +209,7 @@ export function useMessenger( }; } if (content) { - if (channelsState.activeChannel.type === "group") { + if (channelsState.activeChannel.type !== "channel") { await groupChat?.sendMessage( channelsState.activeChannel.id, content, @@ -318,7 +251,7 @@ export function useMessenger( loadingMessenger, communityData, contacts, - setContacts, + contactsDispatch, channels: channelsState.channels, channelsDispatch, removeChannel, diff --git a/packages/react-chat/src/hooks/useManageContact.ts b/packages/react-chat/src/hooks/useManageContact.ts deleted file mode 100644 index 556a45f3..00000000 --- a/packages/react-chat/src/hooks/useManageContact.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useCallback, useMemo } from "react"; - -import { useMessengerContext } from "../contexts/messengerProvider"; - -export function useManageContact(id: string) { - const { contacts, setContacts } = useMessengerContext(); - const contact = useMemo(() => contacts[id], [id, contacts]); - - const setCustomName = useCallback( - (customName: string | undefined) => { - setContacts((prev) => { - const prevUser = prev[id]; - if (!prevUser) return prev; - return { ...prev, [id]: { ...prevUser, customName } }; - }); - }, - [id] - ); - - const setBlocked = useCallback( - (blocked: boolean) => { - setContacts((prev) => { - const prevUser = prev[id]; - if (!prevUser) return prev; - return { ...prev, [id]: { ...prevUser, blocked } }; - }); - }, - [id] - ); - - const setIsUntrustworthy = useCallback( - (isUntrustworthy: boolean) => { - setContacts((prev) => { - const prevUser = prev[id]; - if (!prevUser) return prev; - return { ...prev, [id]: { ...prevUser, isUntrustworthy } }; - }); - }, - [id] - ); - - const setIsUserFriend = useCallback( - (isFriend: boolean) => { - setContacts((prev) => { - const prevUser = prev[id]; - if (!prevUser) return prev; - return { ...prev, [id]: { ...prevUser, isFriend } }; - }); - }, - [id] - ); - - return { - contact, - setCustomName, - setBlocked, - setIsUntrustworthy, - setIsUserFriend, - }; -} diff --git a/packages/status-communities/src/groupChats.ts b/packages/status-communities/src/groupChats.ts index cd397e5a..fbf79170 100644 --- a/packages/status-communities/src/groupChats.ts +++ b/packages/status-communities/src/groupChats.ts @@ -32,12 +32,34 @@ export type GroupChatsType = { }; /* TODO: add chat messages encryption */ +class GroupChatUsers { + private users: { [id: string]: GroupMember } = {}; + private identity: Identity; + + public constructor(_identity: Identity) { + this.identity = _identity; + } + + public async getUser(id: string): Promise { + if (this.users[id]) { + return this.users[id]; + } + const topic = await getNegotiatedTopic(this.identity, id); + const symKey = await createSymKeyFromPassword(topic); + const partitionedTopic = getPartitionedTopic(id); + const groupUser: GroupMember = { topic, symKey, id, partitionedTopic }; + this.users[id] = groupUser; + return groupUser; + } +} + export class GroupChats { waku: Waku; identity: Identity; private callback: (chats: GroupChat) => void; private removeCallback: (chats: GroupChat) => void; private addMessage: (message: ChatMessage, sender: string) => void; + private groupChatUsers; public chats: GroupChatsType = {}; /** @@ -62,6 +84,7 @@ export class GroupChats { ) { this.waku = waku; this.identity = identity; + this.groupChatUsers = new GroupChatUsers(identity); this.callback = callback; this.removeCallback = removeCallback; this.addMessage = addMessage; @@ -117,10 +140,7 @@ export class GroupChats { const members: GroupMember[] = []; await Promise.all( event.event.members.map(async (member) => { - const topic = await getNegotiatedTopic(this.identity, member); - const symKey = await createSymKeyFromPassword(topic); - const partitionedTopic = getPartitionedTopic(member); - members.push({ topic, symKey, id: member, partitionedTopic }); + members.push(await this.groupChatUsers.getUser(member)); }) ); await this.addChat( @@ -160,10 +180,7 @@ export class GroupChats { const members: GroupMember[] = []; await Promise.all( event.event.members.map(async (member) => { - const topic = await getNegotiatedTopic(this.identity, member); - const symKey = await createSymKeyFromPassword(topic); - const partitionedTopic = getPartitionedTopic(member); - members.push({ topic, symKey, id: member, partitionedTopic }); + members.push(await this.groupChatUsers.getUser(member)); }) ); chat.members.push(...members); @@ -353,10 +370,7 @@ export class GroupChats { !chat.members.map((chatMember) => chatMember.id).includes(member) ) .map(async (member) => { - const topic = await getNegotiatedTopic(this.identity, member); - const symKey = await createSymKeyFromPassword(topic); - const partitionedTopic = getPartitionedTopic(member); - newMembers.push({ topic, symKey, id: member, partitionedTopic }); + newMembers.push(await this.groupChatUsers.getUser(member)); }) ); @@ -383,10 +397,7 @@ export class GroupChats { await Promise.all( members.map(async (member) => { - const topic = await getNegotiatedTopic(this.identity, member); - const symKey = await createSymKeyFromPassword(topic); - const partitionedTopic = getPartitionedTopic(member); - newMembers.push({ topic, symKey, id: member, partitionedTopic }); + newMembers.push(await this.groupChatUsers.getUser(member)); }) );