diff --git a/package.json b/package.json index dcdf88ef81d..fa2dba1d772 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "start:prod": "NODE_ENV=production node build/server.js" }, "dependencies": { + "@ecency/ns-query": "^1.0.0", "@ecency/render-helper": "^2.2.29", "@ecency/render-helper-amp": "^1.1.0", "@emoji-mart/data": "^1.1.2", @@ -24,6 +25,7 @@ "@hiveio/hivescript": "^1.2.7", "@loadable/component": "^5.15.2", "@loadable/server": "^5.15.2", + "@noble/secp256k1": "^1.7.1", "@popperjs/core": "^2.11.8", "@tanstack/react-query": "^4.29.7", "@tanstack/react-query-devtools": "^4.29.7", @@ -35,6 +37,7 @@ "connected-react-router": "^6.8.0", "cookie-parser": "^1.4.5", "currency-symbol-map": "^4.0.4", + "date-fns": "^2.30.0", "debounce": "^1.2.1", "diff-match-patch": "^1.0.5", "emoji-mart": "^5.5.2", @@ -55,6 +58,7 @@ "moment": "^2.29.4", "node-cache": "^5.1.0", "node-html-parser": "^5.3.3", + "nostr-relaypool": "^0.6.28", "numeral": "^2.0.6", "path-to-regexp": "^6.1.0", "qrcode": "^1.5.1", @@ -135,7 +139,7 @@ "@types/webpack-env": "^1.14.0", "@types/webscopeio__react-textarea-autocomplete": "^4.7.2", "autoprefixer": "^10.4.14", - "babel-preset-razzle": "^4.0.5", + "babel-preset-razzle": "^4.2.18", "html-webpack-plugin": "4.5.2", "husky": "^8.0.1", "jest": "^26.0.0", @@ -145,9 +149,9 @@ "postcss": "8.4.31", "postcss-cli": "^10.1.0", "prettier": "^2.7.1", - "razzle": "^4.0.5", - "razzle-dev-utils": "^4.0.5", - "razzle-plugin-scss": "^4.0.5", + "razzle": "^4.2.18", + "razzle-dev-utils": "^4.2.18", + "razzle-plugin-scss": "^4.2.18", "razzle-plugin-typescript": "^3.0.0", "react-test-renderer": "^16.13.1", "tailwindcss": "^3.3.2", diff --git a/src/common/api/queries.ts b/src/common/api/queries.ts index 0e87bd38c74..a3062356030 100644 --- a/src/common/api/queries.ts +++ b/src/common/api/queries.ts @@ -1,10 +1,11 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import { QueryIdentifiers } from "../core"; import { getPoints, getPointTransactions } from "./private-api"; import { useMappedStore } from "../store/use-mapped-store"; import axios from "axios"; import { catchPostImage } from "@ecency/render-helper"; import { Entry } from "../store/entries/types"; +import { getAccountFull } from "./hive"; const DEFAULT = { points: "0.000", @@ -88,3 +89,19 @@ export function useImageDownloader( } ); } + +export function useGetAccountFullQuery(username?: string) { + return useQuery([QueryIdentifiers.GET_ACCOUNT_FULL, username], () => getAccountFull(username!), { + enabled: !!username + }); +} + +export function useGetAccountsFullQuery(usernames: string[]) { + return useQueries({ + queries: usernames.map((username) => ({ + queryKey: [QueryIdentifiers.GET_ACCOUNT_FULL, username], + queryFn: () => getAccountFull(username!), + enabled: !!username + })) + }); +} diff --git a/src/common/app.tsx b/src/common/app.tsx index a753e149200..627e7af6fe5 100644 --- a/src/common/app.tsx +++ b/src/common/app.tsx @@ -26,10 +26,13 @@ import Announcement from "./components/announcement"; import FloatingFAQ from "./components/floating-faq"; import { useMappedStore } from "./store/use-mapped-store"; import { EntriesCacheManager } from "./core"; - import { UserActivityRecorder } from "./components/user-activity-recorder"; import { useGlobalLoader } from "./util/use-global-loader"; import useMount from "react-use/lib/useMount"; +import { ChatPopUp } from "./features/chats/components/chat-popup"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ChatContextProvider } from "@ecency/ns-query"; +import { useGetAccountFullQuery } from "./api/queries"; // Define lazy pages const ProfileContainer = loadable(() => import("./pages/profile-functional")); @@ -50,6 +53,9 @@ const AuthPage = (props: any) => ; const SubmitContainer = loadable(() => import("./pages/submit")); const SubmitPage = (props: any) => ; +const ChatsContainer = loadable(() => import("./features/chats/screens/chats")); +const ChatsPage = (props: any) => ; + const OnboardContainer = loadable(() => import("./pages/onboard")); const OnboardPage = (props: any) => ; @@ -80,9 +86,11 @@ const PurchasePage = (props: any) => ; const DecksPage = loadable(() => import("./pages/decks")); const App = (props: any) => { - const { global } = useMappedStore(); + const { global, activeUser } = useMappedStore(); const { hide } = useGlobalLoader(); + const { data: activeUserAccount } = useGetAccountFullQuery(activeUser?.username); + useMount(() => { // Drop hiding from main queue to give React time to render setTimeout(() => hide(), 1); @@ -100,88 +108,108 @@ const App = (props: any) => { return ( {/*Excluded from production*/} - {/**/} + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
+
{CommunityPostBtn({ ...this.props })}
diff --git a/src/common/components/community-menu/__snapshots__/index.spec.tsx.snap b/src/common/components/community-menu/__snapshots__/index.spec.tsx.snap index c3e9099cd5f..7b6cd9ed7be 100644 --- a/src/common/components/community-menu/__snapshots__/index.spec.tsx.snap +++ b/src/common/components/community-menu/__snapshots__/index.spec.tsx.snap @@ -100,6 +100,7 @@ exports[`(2) Hot filter 1`] = `
-
+
diff --git a/src/common/components/dropdown/_index.scss b/src/common/components/dropdown/_index.scss index 1008d031105..0fd1f2f866c 100644 --- a/src/common/components/dropdown/_index.scss +++ b/src/common/components/dropdown/_index.scss @@ -23,6 +23,7 @@ border-radius: 50%; display: flex; height: 28px; + height: 28px; justify-content: center; width: 28px; user-select: none; diff --git a/src/common/components/dropdown/index.tsx b/src/common/components/dropdown/index.tsx index fd2cdcae1c2..7714cc4fe9e 100644 --- a/src/common/components/dropdown/index.tsx +++ b/src/common/components/dropdown/index.tsx @@ -34,6 +34,10 @@ interface Props { withPadding?: boolean; menuHide?: boolean; noMarginTop?: boolean; + style?: { + width: string; + height: string; + }; } const MyDropDown = (props: Props) => { @@ -112,11 +116,17 @@ const MyDropDown = (props: Props) => { const { label, float, items } = props; + const menuDownStyle = { + ...(props.style && props.style) // Merge the passed style props if available + }; + const child: JSX.Element = typeof label === "string" ? (
{label &&
{label}
} -
{props?.icon || menuDownSvg}
+
+ {props?.icon || menuDownSvg} +
) : ( label @@ -203,7 +213,8 @@ export default (p: Props) => { className: p?.className, withPadding: p?.withPadding, menuHide: p?.menuHide, - noMarginTop: p?.noMarginTop + noMarginTop: p?.noMarginTop, + style: p?.style }; return ; diff --git a/src/common/components/emoji-picker/__snapshots__/index.spec.tsx.snap b/src/common/components/emoji-picker/__snapshots__/index.spec.tsx.snap index adf68b2a960..ae50384afb1 100644 --- a/src/common/components/emoji-picker/__snapshots__/index.spec.tsx.snap +++ b/src/common/components/emoji-picker/__snapshots__/index.spec.tsx.snap @@ -3,6 +3,7 @@ exports[`(1) Default full render 1`] = `
void; + style?: EmojiPickerStyleProps; } interface State { @@ -137,8 +139,12 @@ export default class EmojiPicker extends BaseComponent { const recent: string[] = ls.get("recent-emoji", []); + const emojiPickerStyle = { + ...(this.props.style && this.props.style) + }; + return ( -
+
void; + position?: "top" | "bottom"; + isDisabled?: boolean; } /** @@ -16,15 +19,16 @@ interface Props { * * @param {Props} anchor - The anchor element to position the picker relative to. * @param {function} onSelect - The callback function to be called when an emoji is selected. + * @param position * @return The rendered emoji picker dialog. */ -export function EmojiPicker({ anchor, onSelect }: Props) { +export function EmojiPicker({ anchor, onSelect, position, isDisabled }: Props) { const ref = useRef(null); const { global } = useMappedStore(); const [show, setShow] = useState(false); - const [position, setPosition] = useState({ x: 0, y: 0 }); + // Due to ability to hold multiple dialogs we have to identify them const dialogId = useMemo(() => v4(), []); @@ -37,7 +41,7 @@ export function EmojiPicker({ anchor, onSelect }: Props) { useEffect(() => { if (anchor) { anchor.addEventListener("click", () => { - anchor.style.position = "relative !important"; + (anchor as HTMLElement).style.position = "relative !important"; setShow(true); }); } @@ -48,14 +52,23 @@ export function EmojiPicker({ anchor, onSelect }: Props) { ref={ref} id={dialogId} key={dialogId} - className="emoji-picker-dialog" + className={classNameObject({ + "emoji-picker-dialog": true, + "top-[100%]": (position ?? "bottom") === "bottom", + "bottom-[100%] right-0": position === "top" + })} style={{ display: show ? "block" : "none" }} > onSelect(e.native)} + onEmojiSelect={(e: { native: string }) => { + if (isDisabled) { + return; + } + onSelect(e.native); + }} previewPosition="none" set="apple" theme={global.theme === "day" ? "light" : "dark"} diff --git a/src/common/components/entry-list-item/index.tsx b/src/common/components/entry-list-item/index.tsx index 5907f9300d7..92e5862a331 100644 --- a/src/common/components/entry-list-item/index.tsx +++ b/src/common/components/entry-list-item/index.tsx @@ -280,7 +280,7 @@ export function EntryListItem({
- +
{volumeOffSvg}
{_t("g.muted")}
diff --git a/src/common/components/entry-menu/__snapshots__/index.spec.tsx.snap b/src/common/components/entry-menu/__snapshots__/index.spec.tsx.snap index bf56f3b52ee..27c3ba578a8 100644 --- a/src/common/components/entry-menu/__snapshots__/index.spec.tsx.snap +++ b/src/common/components/entry-menu/__snapshots__/index.spec.tsx.snap @@ -84,6 +84,7 @@ exports[`(2) Separated sharing buttons 1`] = `
{ ) }); + if (where && where === "chat-box" && following) { + return <>{btnUnfollow}; + } + + if (where && where === "chat-box" && !following) { + return <>{btnFollow}; + } + if (fetching) { return ( <> diff --git a/src/common/components/friends/__snapshots__/index.spec.tsx.snap b/src/common/components/friends/__snapshots__/index.spec.tsx.snap index 55081780d66..1a722434e18 100644 --- a/src/common/components/friends/__snapshots__/index.spec.tsx.snap +++ b/src/common/components/friends/__snapshots__/index.spec.tsx.snap @@ -31,6 +31,7 @@ exports[`(1) Render list 1`] = `
void; shGif: boolean; changeState: (gifState?: boolean) => void; + pureStyle?: boolean; + style?: { + width: string; + bottom: string; + left: string | number; + marginLeft: string; + borderTopLeftRadius: string; + borderTopRightRadius: string; + borderBottomLeftRadius: string; + }; + gifImagesStyle?: { + width: string; + }; + rootRef?: MutableRefObject; } interface State { @@ -21,6 +37,10 @@ interface State { total_count: number; } +interface GifImageStyle { + width: string; +} + export default class GifPicker extends BaseComponent { state: State = { data: [], @@ -44,6 +64,7 @@ export default class GifPicker extends BaseComponent { this.delayedSearchOnScroll(filter, limit, offset + 50); } }; + componentDidMount() { const gifWrapper = document.querySelector(".emoji-picker"); gifWrapper?.addEventListener("scroll", this.handleScroll); @@ -95,6 +116,9 @@ export default class GifPicker extends BaseComponent { }; this.stateSet(_data); }; + + delayedSearch = _.debounce(this.getSearchedData, 2000); + getSearchedDataOnScroll = async (_filter: string | null, limit: string, offset: string) => { const { data } = await fetchGif(_filter, limit, offset); if (_filter?.length) { @@ -110,6 +134,8 @@ export default class GifPicker extends BaseComponent { } }; + delayedSearchOnScroll = _.debounce(this.getSearchedDataOnScroll, 2000); + getGifsData = async (_filter: string | null, limit: string, offset: string) => { const { data } = await fetchGif(_filter, limit, offset); let _data: State = { @@ -122,6 +148,7 @@ export default class GifPicker extends BaseComponent { }; this.stateSet(_data); }; + getGifsDataOnScroll = async (_filter: string | null, limit: string, offset: string) => { const { data } = await fetchGif(_filter, limit, offset); let _data: State = { @@ -158,9 +185,6 @@ export default class GifPicker extends BaseComponent { this.props.changeState(!this.props.shGif); }; - delayedSearch = _.debounce(this.getSearchedData, 2000); - delayedSearchOnScroll = _.debounce(this.getSearchedDataOnScroll, 2000); - filterChanged = (e: React.ChangeEvent) => { this.setState({ filter: e.target.value }); if (e.target.value === "") { @@ -171,10 +195,15 @@ export default class GifPicker extends BaseComponent { }; renderEmoji = (gifData: any[] | null) => { + const gifImageStyle: GifImageStyle = { + width: "200px", + ...(this.props.gifImagesStyle && this.props.gifImagesStyle) + }; return gifData?.map((_gif, i) => { return (
can't fetch :( { return null; } + const gifPickerStyle = { + ...(this.props.style && this.props.style) + }; + return ( -
+
{ ); } })()} - {_t("gif-picker.credits")} + {_t("gif-picker.credits")}
); } diff --git a/src/common/components/leaderboard/__snapshots__/index.spec.tsx.snap b/src/common/components/leaderboard/__snapshots__/index.spec.tsx.snap index c154b22f2db..a8be449a4bc 100644 --- a/src/common/components/leaderboard/__snapshots__/index.spec.tsx.snap +++ b/src/common/components/leaderboard/__snapshots__/index.spec.tsx.snap @@ -24,6 +24,7 @@ exports[`(1) Render with data. 1`] = `
{ const menuConfig = { history: props.history, label: "", - icon: KebabMenu, + icon: kebabMenuSvg, items: menuItems }; diff --git a/src/common/components/navbar/index.tsx b/src/common/components/navbar/index.tsx index 76f54b1f4ff..e806342eaf3 100644 --- a/src/common/components/navbar/index.tsx +++ b/src/common/components/navbar/index.tsx @@ -120,7 +120,7 @@ export function Navbar({ match, history, setStepOne, setStepTwo, step }: Props) return (
{ const [, updateState] = useState(); const forceUpdate = useCallback(() => updateState({} as any), []); + const { data: community } = useCommunityCache(props.account?.name); + const { activeUser, account, section, global } = props; useEffect(() => { @@ -258,9 +262,12 @@ export const ProfileCard = (props: Props) => { )}
{isCommunity(account?.name) && ( - - - + <> + + + + {!!community && } + )} {isMyProfile && ( <> diff --git a/src/common/components/profile-menu/__snapshots__/index.spec.tsx.snap b/src/common/components/profile-menu/__snapshots__/index.spec.tsx.snap index 3733838ae1b..577caf5583d 100644 --- a/src/common/components/profile-menu/__snapshots__/index.spec.tsx.snap +++ b/src/common/components/profile-menu/__snapshots__/index.spec.tsx.snap @@ -89,6 +89,7 @@ exports[`(2) With active user 1`] = `
button { - bottom: 1.25rem !important; - right: 4.5rem !important; + & > .help-btn { + right: 5rem; } & > .floating-container { - bottom: 4.4rem; right: 5rem; } } } - + &.small-screen { + bottom: 65px; + } svg { width: 20px; height: 20px; diff --git a/src/common/components/scroll-to-top/index.tsx b/src/common/components/scroll-to-top/index.tsx index 9148f0a73c6..24dad4d6e43 100644 --- a/src/common/components/scroll-to-top/index.tsx +++ b/src/common/components/scroll-to-top/index.tsx @@ -37,6 +37,11 @@ export default class ScrollToTop extends Component { } if (this.shouldShow()) { + if (window.innerWidth <= 666) { + this.button.current.classList.add("small-screen"); + } else { + this.button.current.classList.remove("small-screen"); + } this.button.current.classList.add("visible"); return; } diff --git a/src/common/components/user-nav/_index.scss b/src/common/components/user-nav/_index.scss index 8f0f221e997..7346e943999 100644 --- a/src/common/components/user-nav/_index.scss +++ b/src/common/components/user-nav/_index.scss @@ -36,6 +36,7 @@ .user-wallet, .user-points, .notifications, + .chats, .points { @apply text-white; display: flex; @@ -68,7 +69,8 @@ } } - .notifications { + .notifications, + .chats { cursor: pointer; position: relative; @@ -113,10 +115,6 @@ @apply bg-dark-200; } - .label { - - } - .power { display: flex; @@ -125,10 +123,10 @@ margin-right: 2px; } - .voting, .downVoting { + .voting, + .downVoting { display: flex; align-items: center; - } .voting { @@ -148,7 +146,6 @@ // custom dropdown override .custom-dropdown { - .menu-inner { width: 220px; diff --git a/src/common/components/user-nav/index.tsx b/src/common/components/user-nav/index.tsx index 5d094e1e7a5..20473f3ee83 100644 --- a/src/common/components/user-nav/index.tsx +++ b/src/common/components/user-nav/index.tsx @@ -1,9 +1,10 @@ import React, { useState } from "react"; import { _t } from "../../i18n"; +import { Link } from "react-router-dom"; import { useMappedStore } from "../../store/use-mapped-store"; import { useLocation } from "react-router"; import "./_index.scss"; -import { bellOffSvg, bellSvg, chevronUpSvg, rocketSvg } from "../../img/svg"; +import { bellOffSvg, bellSvg, chevronUpSvg, messangerSvg, rocketSvg } from "../../img/svg"; import { downVotingPower, votingPower } from "../../api/hive"; import { WalletBadge } from "./wallet-badge"; import ToolTip from "../tooltip"; @@ -115,16 +116,23 @@ export const UserNav = ({ history, icon }: Props) => { {global.usePrivate ? ( - - toggleUIProp("notifications")}> - {notifications.unread > 0 && ( - - {notifications.unread.toString().length < 3 ? notifications.unread : "..."} - - )} - {global.notifications ? bellSvg : bellOffSvg} - - + <> + + toggleUIProp("notifications")}> + {notifications.unread > 0 && ( + + {notifications.unread.toString().length < 3 ? notifications.unread : "..."} + + )} + {global.notifications ? bellSvg : bellOffSvg} + + + + + {messangerSvg} + + + ) : ( <> )} diff --git a/src/common/components/video-upload-threespeak/index.tsx b/src/common/components/video-upload-threespeak/index.tsx index 30758c074ae..a273692f906 100644 --- a/src/common/components/video-upload-threespeak/index.tsx +++ b/src/common/components/video-upload-threespeak/index.tsx @@ -186,7 +186,7 @@ export const VideoUpload = (props: Props & React.HTMLAttributes)
-
+
+
+ +
+ ); +} diff --git a/src/common/features/chats/components/chat-message-box.tsx b/src/common/features/chats/components/chat-message-box.tsx new file mode 100644 index 00000000000..e962c25d51e --- /dev/null +++ b/src/common/features/chats/components/chat-message-box.tsx @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import { match } from "react-router"; +import { History } from "history"; +import ChatsMessagesHeader from "./chat-messages-header"; +import ChatsMessagesView from "./chat-messages-view"; +import { Button } from "@ui/button"; +import { useCommunityCache } from "../../../core"; +import ChatsProfileBox from "./chat-profile-box"; +import { _t } from "../../../i18n"; +import { + Channel, + DirectContact, + useAddCommunityChannel, + useAutoScrollInChatBox, + useLeftCommunityChannelsQuery +} from "@ecency/ns-query"; + +interface MatchParams { + filter: string; + name: string; + path: string; + url: string; + username: string; +} + +interface Props { + match: match; + history: History; + channel?: Channel; + currentContact?: DirectContact; +} + +export default function ChatsMessagesBox(props: Props) { + const { data: community } = useCommunityCache(props.match.params.username); + + const { data: leftCommunityChannelsIds } = useLeftCommunityChannelsQuery(); + + const { mutateAsync: addCommunityChannel, isLoading: isAddCommunityChannelLoading } = + useAddCommunityChannel(props.channel); + + const hasLeftCommunity = useMemo( + () => leftCommunityChannelsIds?.includes(props.channel?.id ?? ""), + [props.channel] + ); + + useAutoScrollInChatBox(props.currentContact, props.channel); + + return ( +
+ {(props.channel && !hasLeftCommunity) || props.currentContact ? ( + <> + + + + ) : ( + <> +
+
+ +

{_t("chat.welcome.join-description")}

+ +
+
+ + )} +
+ ); +} diff --git a/src/common/features/chats/components/chat-message-channel-item-extension.tsx b/src/common/features/chats/components/chat-message-channel-item-extension.tsx new file mode 100644 index 00000000000..573589441df --- /dev/null +++ b/src/common/features/chats/components/chat-message-channel-item-extension.tsx @@ -0,0 +1,113 @@ +import { Popover, PopoverContent } from "@ui/popover"; +import React, { PropsWithChildren, RefObject, useMemo, useRef } from "react"; +import FollowControls from "../../../components/follow-controls"; +import { Button } from "@ui/button"; +import { _t } from "../../../i18n"; +import UserAvatar from "../../../components/user-avatar"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import { COMMUNITYADMINROLES } from "./chat-popup/chat-constants"; +import { + Channel, + CommunityModerator, + useNostrGetUserProfileQuery, + useUpdateChannelBlockedUsers +} from "@ecency/ns-query"; + +interface Props { + currentChannel: Channel; + creator: string; +} + +export function ChatMessageChannelItemExtension({ + currentChannel, + children, + creator +}: PropsWithChildren) { + const popoverRef = useRef(null); + + const { activeUser, setActiveUser, updateActiveUser, deleteUser, toggleUIProp, ui, users } = + useMappedStore(); + + const { data: nostrUserProfiles } = useNostrGetUserProfileQuery(creator); + + const profile = useMemo( + () => nostrUserProfiles?.find((p) => p.creator === creator), + [creator, nostrUserProfiles] + ); + const communityAdmins = useMemo( + () => + currentChannel?.communityModerators + ?.filter((user: CommunityModerator) => COMMUNITYADMINROLES.includes(user.role)) + .map((user: CommunityModerator) => user.name) ?? [], + [currentChannel] + ); + + const { mutateAsync: updateBlockedUsers } = useUpdateChannelBlockedUsers(currentChannel); + + const block = (removedUserId: string) => + updateBlockedUsers([...(currentChannel.removedUserIds ?? []), removedUserId]); + + const unBlock = (removedUserId: string) => + updateBlockedUsers( + currentChannel.removedUserIds?.filter((item) => item !== removedUserId) ?? [] + ); + + return ( + <> + + + {children} + +
}> +
+
+ +
+ +

{`@${profile?.name}`}

+
+ + + {communityAdmins.includes(activeUser?.username!) && + profile?.name !== currentChannel.communityName && ( + <> + {currentChannel?.removedUserIds?.includes(profile?.creator ?? "") ? ( + <> + + + ) : ( + <> + + + )} + + )} +
+
+
+
+
+ + ); +} diff --git a/src/common/features/chats/components/chat-message-item.tsx b/src/common/features/chats/components/chat-message-item.tsx new file mode 100644 index 00000000000..40227d948bd --- /dev/null +++ b/src/common/features/chats/components/chat-message-item.tsx @@ -0,0 +1,168 @@ +import { isMessageGif } from "../utils"; +import { Spinner } from "@ui/spinner"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { classNameObject } from "../../../helper/class-name-object"; +import { renderPostBody } from "@ecency/render-helper"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import { _t } from "../../../i18n"; +import { ChatMessageChannelItemExtension } from "./chat-message-channel-item-extension"; +import useMount from "react-use/lib/useMount"; +import { Button } from "@ui/button"; +import { failedMessageSvg } from "../../../img/svg"; +import useDebounce from "react-use/lib/useDebounce"; +import { + Channel, + DirectContact, + isMessageImage, + isSingleEmoji, + Message, + useKeysQuery, + useResendMessage +} from "@ecency/ns-query"; +import { format } from "date-fns"; +import { useInViewport } from "react-in-viewport"; + +interface Props { + type: "sender" | "receiver"; + message: Message; + isSameUser: boolean; + showDate?: boolean; + currentContact?: DirectContact; + currentChannel?: Channel; + onContextMenu?: () => void; + onAppear?: () => void; + onInViewport?: (inViewport: boolean) => void; + className?: string; +} + +export function ChatMessageItem({ + type, + message, + isSameUser, + currentContact, + currentChannel, + onContextMenu, + onInViewport, + onAppear, + showDate = true, + className = "" +}: Props) { + const ref = useRef(null); + const { global } = useMappedStore(); + const { publicKey } = useKeysQuery(); + + const [holdStarted, setHoldStarted] = useState(false); + + const isFailed = useMemo(() => message.sent === 2, [message]); + const isSending = useMemo(() => message.sent === 0, [message]); + const isGif = useMemo(() => isMessageGif(message.content), [message]); + const isImage = useMemo(() => isMessageImage(message.content), [message]); + const isEmoji = useMemo(() => isSingleEmoji(message.content), [message]); + const renderedPreview = useMemo( + () => + renderPostBody(message.content, false, global.canUseWebp) + .replace(/]*>/g, "") + .replace(/<\/p>/g, ""), + [message] + ); + + const { mutateAsync: resendMessage } = useResendMessage(currentChannel, currentContact); + const { inViewport } = useInViewport(ref); + + useMount(() => onAppear?.()); + + useDebounce( + () => { + if (holdStarted) { + setHoldStarted(false); + onContextMenu?.(); + } + }, + 500, + [holdStarted] + ); + + useEffect(() => { + onInViewport?.(inViewport); + }, [inViewport]); + + return ( +
+
setHoldStarted(true)} + onMouseUp={() => setHoldStarted(false)} + onTouchStart={() => setHoldStarted(true)} + onTouchEnd={() => setHoldStarted(false)} + onContextMenu={(e) => { + if (onContextMenu) { + e.stopPropagation(); + e.preventDefault(); + onContextMenu(); + } + }} + > + {currentChannel && message.creator !== publicKey && ( + + )} +
+
+
+
+ {message.sent == 1 && showDate && ( +
+ {format(new Date(message.created * 1000), "HH:mm")} +
+ )} + {message.sent === 0 && } + {message.sent === 2 && ( +
+ {failedMessageSvg} + +
+ )} +
+
+
+ ); +} diff --git a/src/common/features/chats/components/chat-messages-header.tsx b/src/common/features/chats/components/chat-messages-header.tsx new file mode 100644 index 00000000000..256228eb087 --- /dev/null +++ b/src/common/features/chats/components/chat-messages-header.tsx @@ -0,0 +1,64 @@ +import React, { useContext } from "react"; +import { History } from "history"; +import ChatsCommunityDropdownMenu from "./chats-community-actions"; +import UserAvatar from "../../../components/user-avatar"; +import { CHATPAGE } from "./chat-popup/chat-constants"; +import Link from "../../../components/alink"; +import { expandSideBar } from "../../../img/svg"; +import { Button } from "@ui/button"; +import isCommunity from "../../../helper/is-community"; +import { ChatContext, formattedUserName, useChannelsQuery } from "@ecency/ns-query"; + +interface Props { + username: string; + history: History; +} + +export default function ChatsMessagesHeader(props: Props) { + const { username } = props; + const { setReceiverPubKey } = useContext(ChatContext); + const { data: channels } = useChannelsQuery(); + + const formattedName = (username: string) => { + if (username && !username.startsWith("@")) { + const community = channels?.find((channel) => channel.communityName === username); + if (community) { + return community.name; + } + } + return username.replace("@", ""); + }; + + return ( +
+
+
+ + {isCommunity(username) && ( +
+ +
+ )} +
+ ); +} diff --git a/src/common/features/chats/components/chat-messages-view.tsx b/src/common/features/chats/components/chat-messages-view.tsx new file mode 100644 index 00000000000..fe3521b79b1 --- /dev/null +++ b/src/common/features/chats/components/chat-messages-view.tsx @@ -0,0 +1,66 @@ +import React, { useRef } from "react"; +import { Link } from "react-router-dom"; +import ChatsProfileBox from "./chat-profile-box"; +import ChatsDirectMessages from "./chats-direct-messages"; +import ChatInput from "./chat-input"; +import { classNameObject } from "../../../helper/class-name-object"; +import { ChatsChannelMessages } from "./chat-channel-messages"; +import { + Channel, + DirectContact, + DirectMessage, + PublicMessage, + useMessagesQuery +} from "@ecency/ns-query"; + +interface Props { + currentContact: DirectContact; + currentChannel: Channel; +} + +export default function ChatsMessagesView({ currentContact, currentChannel }: Props) { + const { data: messages } = useMessagesQuery(currentContact, currentChannel); + + const messagesBoxRef = useRef(null); + + return ( + <> +
+ + + + {currentChannel ? ( + <> + + + ) : ( + + )} +
+ +
+ +
+ + ); +} diff --git a/src/common/features/chats/components/chat-popup/chat-constants.ts b/src/common/features/chats/components/chat-popup/chat-constants.ts new file mode 100644 index 00000000000..dd215b6fa64 --- /dev/null +++ b/src/common/features/chats/components/chat-popup/chat-constants.ts @@ -0,0 +1,28 @@ +export const DropDownStyle = { + width: "35px", + height: "35px" +}; + +export const GifImagesStyle = { + width: "170px" +}; + +export const CHAT_FILE_CONTENT_TYPES = ["jpg", "jpeg", "gif", "png", "webp"]; +export const PRIVILEGEDROLES = ["owner", "admin", "mod"]; +export const COMMUNITYADMINROLES = ["owner", "admin"]; + +export const NOSTRKEY = "nsKey"; +export const UPLOADING = "Uploading"; +export const GIPHGY = "giphy"; +export const HIDEMESSAGE = "hideMessage"; +export const ADDROLE = "addRole"; +export const BLOCKUSER = "blockUser"; +export const LEAVECOMMUNITY = "leaveCommunity"; +export const UNBLOCKUSER = "unblockUser"; +export const NEWCHATACCOUNT = "newChatAccount"; +export const CHATIMPORT = "chatImport"; +export const RESENDMESSAGE = "resendMessage"; +export const CHAT = "chat"; +export const COMMUNITY = "community"; +export const CHATPAGE = "chatPage"; +export const CHANNEL = "channel"; diff --git a/src/common/features/chats/components/chat-popup/chat-direct-contact-or-channel-item.tsx b/src/common/features/chats/components/chat-popup/chat-direct-contact-or-channel-item.tsx new file mode 100644 index 00000000000..43d0e0f7159 --- /dev/null +++ b/src/common/features/chats/components/chat-popup/chat-direct-contact-or-channel-item.tsx @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; +import React from "react"; +import UserAvatar from "../../../../components/user-avatar"; +import { Channel, DirectContact, isCommunity, useLastMessageQuery } from "@ecency/ns-query"; +import { useCommunityCache } from "../../../../core"; + +interface Props { + username: string; + contact?: DirectContact; + channel?: Channel; + userClicked: (username: string) => void; +} + +export function ChatDirectContactOrChannelItem({ contact, channel, username, userClicked }: Props) { + const lastMessage = useLastMessageQuery(contact, channel); + const { data: community } = useCommunityCache(channel?.communityName); + + return ( +
userClicked(username)} + > + + + + +
+

+ {isCommunity(username) && community ? community.title : username} +

+

{lastMessage?.content}

+
+
+ ); +} diff --git a/src/common/features/chats/components/chat-popup/chat-popup-contacts-and-channels.tsx b/src/common/features/chats/components/chat-popup/chat-popup-contacts-and-channels.tsx new file mode 100644 index 00000000000..cc770d5be36 --- /dev/null +++ b/src/common/features/chats/components/chat-popup/chat-popup-contacts-and-channels.tsx @@ -0,0 +1,77 @@ +import React, { useContext } from "react"; +import { _t } from "../../../../i18n"; +import { ChatsWelcome } from "../chats-welcome"; +import { Button } from "@ui/button"; +import { ChatDirectContactOrChannelItem } from "./chat-direct-contact-or-channel-item"; +import { + ChatContext, + useChannelsQuery, + useDirectContactsQuery, + useKeysQuery +} from "@ecency/ns-query"; + +interface Props { + communityClicked: (v: string) => void; + userClicked: (v: string) => void; + setShowSearchUser: (v: boolean) => void; +} + +export function ChatPopupContactsAndChannels({ + communityClicked, + userClicked, + setShowSearchUser +}: Props) { + const { setReceiverPubKey } = useContext(ChatContext); + + const { privateKey } = useKeysQuery(); + const { data: directContacts } = useDirectContactsQuery(); + const { data: channels } = useChannelsQuery(); + + return ( + <> + {(directContacts?.length !== 0 || (channels?.length !== 0 && channels?.length !== 0)) && + privateKey ? ( + <> + {channels?.length !== 0 && ( + <> +
+ {_t("chat.communities")} +
+ {channels?.map((channel) => ( + communityClicked(channel.communityName!)} + /> + ))} + {directContacts?.length !== 0 && ( +
+ {_t("chat.dms")} +
+ )} + + )} + {directContacts?.map((user) => ( + { + setReceiverPubKey(user.pubkey); + userClicked(v); + }} + key={user.pubkey} + /> + ))} + + ) : !privateKey ? ( + + ) : ( +
+

{_t("chat.no-chat")}

+ +
+ )} + + ); +} diff --git a/src/common/features/chats/components/chat-popup/chat-popup-header.tsx b/src/common/features/chats/components/chat-popup/chat-popup-header.tsx new file mode 100644 index 00000000000..d44d143969d --- /dev/null +++ b/src/common/features/chats/components/chat-popup/chat-popup-header.tsx @@ -0,0 +1,151 @@ +import Tooltip from "../../../../components/tooltip"; +import { _t } from "../../../../i18n"; +import { Button } from "@ui/button"; +import { addMessageSvg, arrowBackSvg, expandArrow } from "../../../../img/svg"; +import ChatsCommunityDropdownMenu from "../chats-community-actions"; +import { history } from "../../../../store"; +import ChatsDropdownMenu from "../chats-dropdown-menu"; +import { classNameObject } from "../../../../helper/class-name-object"; +import React, { useContext, useMemo } from "react"; +import UserAvatar from "../../../../components/user-avatar"; +import { ChatContext, useKeysQuery } from "@ecency/ns-query"; +import { useCommunityCache } from "../../../../core"; + +interface Props { + currentUser: string; + communityName: string; + showSearchUser: boolean; + expanded: boolean; + canSendMessage: boolean; + isCommunity: boolean; + isCurrentUser: boolean; + handleBackArrowSvg: () => void; + handleMessageSvgClick: () => void; + setExpanded: (v: boolean) => void; +} + +export function ChatPopupHeader({ + currentUser, + communityName, + showSearchUser, + expanded, + canSendMessage, + isCommunity, + isCurrentUser, + handleBackArrowSvg, + handleMessageSvgClick, + setExpanded +}: Props) { + const { revealPrivateKey, setRevealPrivateKey } = useContext(ChatContext); + + const { data: community } = useCommunityCache(communityName); + const { privateKey } = useKeysQuery(); + const title = useMemo(() => { + if (revealPrivateKey) { + return _t("chat.manage-chat-key"); + } + + if (currentUser) { + return currentUser; + } + + if (isCommunity && community) { + return community.title; + } + + if (showSearchUser) { + return _t("chat.new-message"); + } + + return _t("chat.page-title"); + }, [currentUser, isCommunity, communityName, showSearchUser, revealPrivateKey]); + const isExpanded = useMemo( + () => (currentUser || communityName || showSearchUser || revealPrivateKey) && expanded, + [currentUser, communityName, showSearchUser, revealPrivateKey, expanded] + ); + + return ( +
setExpanded(!expanded)} + > +
+ {isExpanded && ( + +
+
+
+ {canSendMessage && ( + +
+
+ ); +} diff --git a/src/common/features/chats/components/chat-popup/chat-popup-messages-list.tsx b/src/common/features/chats/components/chat-popup/chat-popup-messages-list.tsx new file mode 100644 index 00000000000..52e5c4eb814 --- /dev/null +++ b/src/common/features/chats/components/chat-popup/chat-popup-messages-list.tsx @@ -0,0 +1,48 @@ +import { Link } from "react-router-dom"; +import ChatsProfileBox from "../chat-profile-box"; +import ChatsDirectMessages from "../chats-direct-messages"; +import React from "react"; +import { useCommunityCache } from "../../../../core"; +import { ChatsChannelMessages } from "../chat-channel-messages"; +import { + Channel, + DirectContact, + DirectMessage, + PublicMessage, + useMessagesQuery +} from "@ecency/ns-query"; + +interface Props { + currentContact?: DirectContact; + currentChannel?: Channel; +} + +export function ChatPopupMessagesList({ currentContact, currentChannel }: Props) { + const { data: currentCommunity } = useCommunityCache(currentChannel?.communityName); + const { data: messages } = useMessagesQuery(currentContact, currentChannel); + + return ( +
+ {" "} + + + + {!!currentChannel ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/common/features/chats/components/chat-popup/chat-popup-search-user.tsx b/src/common/features/chats/components/chat-popup/chat-popup-search-user.tsx new file mode 100644 index 00000000000..afb8a03c953 --- /dev/null +++ b/src/common/features/chats/components/chat-popup/chat-popup-search-user.tsx @@ -0,0 +1,32 @@ +import React, { useState } from "react"; +import { useSearchUsersQuery } from "../../queries"; +import { ChatSidebarSearch } from "../chats-sidebar/chat-sidebar-search"; +import { useSearchCommunitiesQuery } from "../../queries/search-communities-query"; +import { ChatSidebarSearchItem } from "../chats-sidebar/chat-sidebar-search-item"; + +interface Props { + setCurrentUser: (v: string) => void; +} + +export function ChatPopupSearchUser({ setCurrentUser }: Props) { + const [searchQuery, setSearchQuery] = useState(""); + const { data: searchUsers } = useSearchUsersQuery(searchQuery); + const { data: searchCommunities } = useSearchCommunitiesQuery(searchQuery); + + return ( + <> +
+ +
+
+ {[...searchUsers, ...searchCommunities].map((item) => ( + setCurrentUser("account" in item ? item.account : item.name)} + key={"account" in item ? item.account : item.id} + /> + ))} +
+ + ); +} diff --git a/src/common/features/chats/components/chat-popup/index.scss b/src/common/features/chats/components/chat-popup/index.scss new file mode 100644 index 00000000000..3937bf2478d --- /dev/null +++ b/src/common/features/chats/components/chat-popup/index.scss @@ -0,0 +1,200 @@ +@import "src/style/vars_mixins"; + +.chatbox-container { + position: fixed; + z-index: 99; + right: 13rem; + bottom: 0rem; + height: 50px; + width: 410px; + + border-top-right-radius: 16px; + border-top-left-radius: 16px; + + transition: height 0.5s ease; + + @include themify(day) { + @apply bg-white border-[--border-color] border shadow-2xl; + } + + @include themify(night) { + @apply bg-dark-200 border-[--border-color] border shadow-2xl; + } + + &.expanded { + height: 530px; + } + + .chat-body { + position: relative; + height: 470px; + overflow-y: auto; + overflow-x: hidden; + + &.current-user { + padding-bottom: 10px; + @apply border-b; + + @include media-breakpoint-up(md) { + height: 422px; + } + + @include themify(day) { + @apply border-light-300; + } + @include themify(night) { + border-bottom: 1px solid #172b44; + } + } + + &.community { + height: 422px; + @apply border-b; + + @include themify(day) { + @apply border-light-300; + } + @include themify(night) { + border-bottom: 1px solid #172b44; + } + } + + .user-search-suggestion-list { + .search-content { + padding: 0.7rem; + border-radius: 10px; + cursor: pointer; + display: flex; + margin-bottom: 1rem; + + .search-user-img { + justify-content: center; + align-items: center; + display: flex; + margin-left: 10px; + } + + .search-user-title { + display: flex; + margin-left: 1rem; + + .search-username { + margin: 7px 0 0 0; + font-size: 20px; + font-weight: 700; + } + + .search-reputation { + margin: 9px 0 0 6px; + } + } + + &:hover { + @include themify(day) { + @apply bg-light-400; + } + @include themify(night) { + @apply bg-gray-charcoal; + } + } + } + } + + .chat-content:hover { + @include themify(day) { + background: #eeeeee; + } + @include themify(night) { + @apply bg-gunmetal; + } + } + + .chats { + .not-joined { + display: flex; + align-items: center; + justify-content: center; + margin-top: 14%; + } + } + + .import-chats { + margin-top: 20px; + } + + .manage-chat-key { + @apply p-4; + + .private-key { + margin: 0; + } + } + } + + @media screen and (max-width: 768px) { + right: 0; + width: 100vw; + display: grid; + grid-template-rows: min-content 1fr min-content; + grid-template-columns: repeat(1, 1fr); + + .chat-header { + width: 100vw; + grid-row: span 5; + } + + .back-arrow-svg { + padding: 0; + } + + .message-header-content { + max-width: 40vw !important; + } + + .sender-message-content, + .receiver-message-content { + max-width: 75vw !important; + } + + &.expanded { + height: 100vh; + } + + .message-header-title { + width: 100vw; + margin: 0; + margin-right: 0.3rem; + } + + .chat-body { + grid-row: span 95; + height: 100%; + + &.current-user, + .community { + grid-row: span 90; + } + + .chat-content { + .last-message { + max-width: 75vw; + } + } + } + + .chat { + grid-row: span 5; + } + } + + .chat-input { + @apply relative; + + .gif-picker { + bottom: calc(100% - 1rem); + width: calc(100% - -3px); + left: 0.25rem; + @apply absolute border-t border-[--border-color]; + } + } +} diff --git a/src/common/features/chats/components/chat-popup/index.tsx b/src/common/features/chats/components/chat-popup/index.tsx new file mode 100644 index 00000000000..32cbc38dfc8 --- /dev/null +++ b/src/common/features/chats/components/chat-popup/index.tsx @@ -0,0 +1,183 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useLocation } from "react-router"; +import LinearProgress from "../../../../components/linear-progress"; +import ManageChatKey from "../manage-chat-key"; +import ChatInput from "../chat-input"; +import { usePrevious } from "../../../../util/use-previous"; +import "./index.scss"; +import { useMappedStore } from "../../../../store/use-mapped-store"; +import { useMount } from "react-use"; +import { classNameObject } from "../../../../helper/class-name-object"; +import { ChatPopupHeader } from "./chat-popup-header"; +import { ChatPopupMessagesList } from "./chat-popup-messages-list"; +import { ChatPopupSearchUser } from "./chat-popup-search-user"; +import { ChatPopupContactsAndChannels } from "./chat-popup-contacts-and-channels"; +import { ChatsWelcome } from "../chats-welcome"; +import { useGetAccountFullQuery } from "../../../../api/queries"; +import { + ChatContext, + getUserChatPublicKey, + useChannelsQuery, + useDirectContactsQuery, + useJoinChat, + useKeysQuery +} from "@ecency/ns-query"; +import { uploadChatKeys } from "../../utils/upload-chat-keys"; + +export const ChatPopUp = () => { + const { activeUser, global } = useMappedStore(); + + const { receiverPubKey, revealPrivateKey, setRevealPrivateKey, setReceiverPubKey } = + useContext(ChatContext); + const { isLoading: isJoinChatLoading } = useJoinChat(uploadChatKeys); + + const [currentUser, setCurrentUser] = useState(""); + + const { privateKey } = useKeysQuery(); + const { data: directContacts } = useDirectContactsQuery(); + const directContact = useMemo( + () => directContacts?.find((contact) => contact.pubkey === receiverPubKey), + [directContacts, receiverPubKey] + ); + const { data: channels, isLoading: isChannelsLoading } = useChannelsQuery(); + const { data: currentUserAccount } = useGetAccountFullQuery(currentUser); + + const routerLocation = useLocation(); + const prevActiveUser = usePrevious(activeUser); + const chatBodyDivRef = useRef(null); + + const [expanded, setExpanded] = useState(false); + const [showSearchUser, setShowSearchUser] = useState(false); + const [show, setShow] = useState(false); + const [isCommunity, setIsCommunity] = useState(false); + const [communityName, setCommunityName] = useState(""); + const [hasMore, setHasMore] = useState(true); + + const hasUserJoinedChat = useMemo(() => !!privateKey, [privateKey]); + const currentContact = useMemo( + () => directContacts?.find((dc) => dc.pubkey === receiverPubKey), + [receiverPubKey] + ); + const currentChannel = useMemo( + () => channels?.find((channel) => channel.communityName === communityName), + [communityName, channels] + ); + const isCurrentUser = useMemo(() => !!currentUser, [currentUser]); + const canSendMessage = useMemo( + () => !currentUser && hasUserJoinedChat && !!privateKey && !isCommunity && !revealPrivateKey, + [currentUser, hasUserJoinedChat, privateKey, isCommunity, revealPrivateKey] + ); + + useMount(() => { + setShow(!routerLocation.pathname.match("/chats") && !!activeUser); + }); + + useEffect(() => { + if (currentUserAccount) { + setReceiverPubKey(getUserChatPublicKey(currentUserAccount) ?? ""); + } + }, [currentUserAccount]); + + // Show or hide the popup if current pathname was changed or user changed + useEffect(() => { + setShow(!routerLocation.pathname.match("/chats") && !!activeUser); + }, [routerLocation, activeUser]); + + useEffect(() => { + if (prevActiveUser?.username !== activeUser?.username) { + setIsCommunity(false); + setCurrentUser(""); + setCommunityName(""); + } + }, [global.theme, activeUser]); + + const handleMessageSvgClick = () => { + setShowSearchUser(!showSearchUser); + setExpanded(true); + }; + + const handleBackArrowSvg = () => { + setCurrentUser(""); + setCommunityName(""); + setIsCommunity(false); + setShowSearchUser(false); + setHasMore(true); + setRevealPrivateKey(false); + }; + + return ( + <> + {show && ( +
+ + {(isJoinChatLoading || isChannelsLoading) && } +
+ {hasUserJoinedChat && !revealPrivateKey ? ( + <> + {currentUser.length !== 0 || communityName.length !== 0 ? ( + + ) : showSearchUser ? ( + + ) : ( + { + setIsCommunity(true); + setCommunityName(community); + setCurrentUser(""); + setReceiverPubKey(""); + }} + setShowSearchUser={setShowSearchUser} + userClicked={(username) => { + setCurrentUser(username); + setCommunityName(""); + }} + /> + )} + + ) : revealPrivateKey ? ( +
+ +
+ ) : ( + + )} +
+
+ {((isCurrentUser && receiverPubKey) || isCommunity) && ( + + )} +
+
+ )} + + ); +}; diff --git a/src/common/features/chats/components/chat-profile-box.tsx b/src/common/features/chats/components/chat-profile-box.tsx new file mode 100644 index 00000000000..aaac49c6e06 --- /dev/null +++ b/src/common/features/chats/components/chat-profile-box.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from "react"; +import UserAvatar from "../../../components/user-avatar"; +import { dateToFormatted } from "../../../helper/parse-date"; +import { _t } from "../../../i18n"; +import { getAccountFull } from "../../../api/hive"; +import { useCommunityCache } from "../../../core"; + +export interface ProfileData { + joiningData: string; + about: string | undefined; + followers: number | undefined; + name: string; + username: string; +} + +interface Props { + communityName?: string; + currentUser?: string; +} + +export default function ChatsProfileBox({ communityName, currentUser }: Props) { + const [profileData, setProfileData] = useState(); + + const { data: community } = useCommunityCache(communityName!); + + useEffect(() => { + fetchProfileData(); + }, [communityName, currentUser]); + + const formatFollowers = (count: number | undefined) => { + if (count) { + return count >= 1e6 + ? (count / 1e6).toLocaleString() + "M" + : count >= 1e3 + ? (count / 1e3).toLocaleString() + "K" + : count.toLocaleString(); + } + return count; + }; + + const fetchProfileData = async () => { + if (community) { + setProfileData({ + joiningData: community?.created_at!, + about: community?.about, + followers: community?.subscribers, + name: community?.title!, + username: community?.name! + }); + } else if (currentUser) { + const response = await getAccountFull(currentUser.replace("@", "")); + setProfileData({ + joiningData: response.created, + about: response.profile?.about, + followers: response.follow_stats?.follower_count, + name: response.name, + username: response.name + }); + } + }; + + return profileData?.joiningData ? ( +
+
+ +
{profileData.name}
+ {profileData.about?.length !== 0 && ( +
{profileData.about}
+ )} + +
+
+
{_t("chat.joined")}
+
+ {dateToFormatted(profileData!.joiningData, "LL")} +
+
+
+
{_t("chat.subscribers")}
+
{formatFollowers(profileData!.followers)}
+
+
+
+
+ ) : ( + <> + ); +} diff --git a/src/common/features/chats/components/chats-community-actions/blocked-users-modal.tsx b/src/common/features/chats/components/chats-community-actions/blocked-users-modal.tsx new file mode 100644 index 00000000000..ed9ebc371a2 --- /dev/null +++ b/src/common/features/chats/components/chats-community-actions/blocked-users-modal.tsx @@ -0,0 +1,96 @@ +import { _t } from "../../../../i18n"; +import React, { useMemo } from "react"; +import UserAvatar from "../../../../components/user-avatar"; +import { Table, Td, Th, Tr } from "@ui/table"; +import { Modal, ModalBody, ModalHeader } from "@ui/modal"; +import { Button } from "@ui/button"; +import { + useChannelsQuery, + useNostrGetUserProfilesQuery, + useUpdateChannelBlockedUsers +} from "@ecency/ns-query"; + +interface Props { + username: string; + show: boolean; + setShow: (v: boolean) => void; +} + +export function BlockedUsersModal({ username, setShow, show }: Props) { + const { data: channels } = useChannelsQuery(); + + const currentChannel = useMemo( + () => channels?.find((c) => c.communityName === username), + [channels] + ); + + const { data: profiles } = useNostrGetUserProfilesQuery(currentChannel?.removedUserIds ?? []); + + const { mutateAsync: updateBlockedUsers, isLoading: isBlockedUsersLoading } = + useUpdateChannelBlockedUsers(currentChannel!!); + + return ( + setShow(false)}> + {_t("chat.blocked-users-management")} + +
+ {currentChannel?.communityModerators?.length !== 0 ? ( + + + + + + + + + {currentChannel?.removedUserIds?.map((user, i) => { + return ( + + + + + ); + })} + +
{_t("g.username")}{_t("g.status")}
+
+ profile.creator === user)?.name ?? "" + } + size="medium" + />{" "} + + @{profiles?.find((profile) => profile.creator === user)} + +
+
+ +
+ ) : ( +
+

{_t("chat.no-admin")}

+
+ )} +
+
+
+ ); +} diff --git a/src/common/features/chats/components/chats-community-actions/edit-roles-modal.tsx b/src/common/features/chats/components/chats-community-actions/edit-roles-modal.tsx new file mode 100644 index 00000000000..6db2579ce1a --- /dev/null +++ b/src/common/features/chats/components/chats-community-actions/edit-roles-modal.tsx @@ -0,0 +1,212 @@ +import { _t } from "../../../../i18n"; +import LinearProgress from "../../../../components/linear-progress"; +import { FormControl, InputGroup } from "@ui/input"; +import React, { useMemo, useState } from "react"; +import { Button } from "@ui/button"; +import { NOSTRKEY } from "../chat-popup/chat-constants"; +import UserAvatar from "../../../../components/user-avatar"; +import { ROLES } from "../../../../store/communities"; +import { error } from "../../../../components/feedback"; +import useDebounce from "react-use/lib/useDebounce"; +import { getAccountFull } from "../../../../api/hive"; +import { useMappedStore } from "../../../../store/use-mapped-store"; +import { Table, Td, Th, Tr } from "@ui/table"; +import { Spinner } from "@ui/spinner"; +import { Modal, ModalBody, ModalHeader } from "@ui/modal"; +import { CommunityModerator, useChannelsQuery, useUpdateChannelModerator } from "@ecency/ns-query"; + +interface Props { + username: string; + show: boolean; + setShow: (v: boolean) => void; +} + +const roles = [ROLES.ADMIN, ROLES.MOD, ROLES.GUEST]; + +export function EditRolesModal({ username, setShow, show }: Props) { + const { activeUser } = useMappedStore(); + const { data: channels } = useChannelsQuery(); + + const [inProgress, setInProgress] = useState(false); + const [user, setUser] = useState(""); + const [moderator, setModerator] = useState(); + const [role, setRole] = useState("admin"); + const [addRoleError, setAddRoleError] = useState(""); + + const currentChannel = useMemo( + () => channels?.find((c) => c.communityName === username), + [channels] + ); + + const { mutateAsync: updateModerator, isLoading: isUpdateModeratorLoading } = + useUpdateChannelModerator(currentChannel); + + useDebounce( + async () => { + if (user.length === 0) { + setAddRoleError(""); + setInProgress(false); + return; + } + try { + const response = await getAccountFull(user); + if (!response) { + setAddRoleError("Account does not exist"); + return; + } + + if (!response.posting_json_metadata) { + setAddRoleError("This user hasn't joined the chat yet."); + return; + } + + const { posting_json_metadata } = response; + const profile = JSON.parse(posting_json_metadata).profile; + + if (!profile || !profile.hasOwnProperty(NOSTRKEY)) { + setAddRoleError("You cannot set this user because this user hasn't joined the chat yet."); + return; + } + + const alreadyExists = currentChannel?.communityModerators?.some( + (moderator) => moderator.name === response.name + ); + + if (alreadyExists) { + setAddRoleError("You have already assigned some rule to this user."); + setInProgress(false); + } else { + const moderator = { + name: user, + pubkey: profile.nsKey, + role: role + }; + setModerator(moderator); + setAddRoleError(""); + } + } catch (err) { + error(err as string); + } finally { + setInProgress(false); + } + }, + 200, + [user, role] + ); + + return ( + setShow(false)}> + {_t("chat.edit-community-roles")} + {inProgress && } + +
+
+
{_t("community-role-edit.username")}
+
+ + { + setUser(e.target.value); + setInProgress(true); + }} + className={addRoleError ? "is-invalid" : ""} + /> + + {addRoleError &&
{addRoleError}
} +
+
+
+
{_t("community-role-edit.role")}
+
+ ) => setRole(e.target.value)} + > + {roles.map((r, i) => ( + + ))} + +
+
+
+ +
+
+ {currentChannel?.communityModerators?.length !== 0 ? ( + <> + + + + + + + + + {currentChannel?.communityModerators && + currentChannel?.communityModerators!.map((moderator, i) => { + return ( + + + + + ); + })} + +
{_t("community.roles-account")}{_t("community.roles-role")}
+
+ {" "} + @{moderator.name} +
+
+ {moderator.name === activeUser?.username ? ( +
{moderator.role}
+ ) : ( + + } + > + ) => + updateModerator({ ...moderator, role: e.target.value }) + } + disabled={isUpdateModeratorLoading} + > + {roles.map((r, i) => ( + + ))} + + + )} +
+ + ) : ( +
+

{_t("chat.no-admin")}

+
+ )} +
+
+ ); +} diff --git a/src/common/features/chats/components/chats-community-actions/index.tsx b/src/common/features/chats/components/chats-community-actions/index.tsx new file mode 100644 index 00000000000..e70ed0e5cfc --- /dev/null +++ b/src/common/features/chats/components/chats-community-actions/index.tsx @@ -0,0 +1,90 @@ +import React, { useMemo, useState } from "react"; +import { History } from "history"; +import { chatLeaveSvg, editSVG, kebabMenuSvg, linkSvg, removeUserSvg } from "../../../../img/svg"; +import { _t } from "../../../../i18n"; +import { useMappedStore } from "../../../../store/use-mapped-store"; +import { success } from "../../../../components/feedback"; +import { Dropdown, DropdownItemWithIcon, DropdownMenu, DropdownToggle } from "@ui/dropdown"; +import { EditRolesModal } from "./edit-roles-modal"; +import { Button } from "@ui/button"; +import { BlockedUsersModal } from "./blocked-users-modal"; +import { copyToClipboard, useChannelsQuery, useLeaveCommunityChannel } from "@ecency/ns-query"; + +interface Props { + history: History; + from?: string; + username: string; +} + +const ChatsCommunityDropdownMenu = ({ history, username }: Props) => { + const { activeUser } = useMappedStore(); + + const [showEditRolesModal, setShowEditRolesModal] = useState(false); + const [showBlockedUsersModal, setShowBlockedUsersModal] = useState(false); + + const { data: channels } = useChannelsQuery(); + + const currentChannel = useMemo( + () => channels?.find((channel) => channel.communityName === username), + [channels, username] + ); + const communityAdmins = useMemo( + () => (currentChannel?.communityModerators ?? []).map((mod) => mod.name), + [currentChannel] + ); + + const { mutateAsync: leaveChannel } = useLeaveCommunityChannel(() => history?.push("/chats")); + + return ( + <> + + + + + + + ); +}; + +export default ChatsConfirmationModal; diff --git a/src/common/features/chats/components/chats-default-screen.tsx b/src/common/features/chats/components/chats-default-screen.tsx new file mode 100644 index 00000000000..5c0a1c39500 --- /dev/null +++ b/src/common/features/chats/components/chats-default-screen.tsx @@ -0,0 +1,55 @@ +import { _t } from "../../../i18n"; +import { Alert } from "@ui/alert"; +import { Button } from "@ui/button"; +import moment from "moment"; +import React, { HTMLProps, useContext, useMemo } from "react"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import useLocalStorage from "react-use/lib/useLocalStorage"; +import { PREFIX } from "../../../util/local-storage"; +import { classNameObject } from "../../../helper/class-name-object"; +import { ChatContext } from "@ecency/ns-query"; + +export function ChatsDefaultScreen(props: HTMLProps) { + const { activeUser } = useMappedStore(); + const { setRevealPrivateKey } = useContext(ChatContext); + + const [lastKeysSavingTime, setLastKeysSaving] = useLocalStorage(PREFIX + "_chats_lkst"); + + // We offer user to save account credentials each month + const isLastKeysSavingTimeExpired = useMemo( + () => + lastKeysSavingTime + ? moment(new Date(lastKeysSavingTime)).isBefore(moment().subtract(30, "days")) + : true, + [lastKeysSavingTime] + ); + + return ( +
+
+ {_t("chat.welcome.hello")}, @{activeUser?.username} +
+
{_t("chat.welcome.start-description")}
+ {isLastKeysSavingTimeExpired && ( + + {_t("chat.warn-key-saving")} + + + )} +
+ ); +} diff --git a/src/common/features/chats/components/chats-direct-messages/index.scss b/src/common/features/chats/components/chats-direct-messages/index.scss new file mode 100644 index 00000000000..a0c47aaeb67 --- /dev/null +++ b/src/common/features/chats/components/chats-direct-messages/index.scss @@ -0,0 +1,19 @@ +@import "src/style/vars_mixins"; + +.direct-messages { + padding-bottom: 15px; + + .custom-divider { + .custom-divider-text { + margin-bottom: 1rem; + font-size: 14px; + } + } + + .not-joined { + display: flex; + align-items: center; + justify-content: center; + margin-top: 14%; + } +} diff --git a/src/common/features/chats/components/chats-direct-messages/index.tsx b/src/common/features/chats/components/chats-direct-messages/index.tsx new file mode 100644 index 00000000000..60e3b4e9f14 --- /dev/null +++ b/src/common/features/chats/components/chats-direct-messages/index.tsx @@ -0,0 +1,134 @@ +import React, { useContext, useMemo, useState } from "react"; +import { _t } from "../../../../i18n"; +import "./index.scss"; +import { ChatMessageItem } from "../chat-message-item"; +import { Button } from "@ui/button"; +import { useInviteViaPostComment } from "../../mutations"; +import { FormControl } from "@ui/input"; +import { Alert } from "@ui/alert"; +import { + ChatContext, + checkContiguousMessage, + DirectContact, + DirectMessage, + useDirectMessagesQuery, + useKeysQuery +} from "@ecency/ns-query"; +import { ChatFloatingDate } from "../chat-floating-date"; +import { differenceInCalendarDays } from "date-fns"; +import { groupMessages } from "../../utils"; + +interface Props { + directMessages: DirectMessage[]; + currentContact: DirectContact; + isPage?: boolean; +} + +export default function ChatsDirectMessages(props: Props) { + const { directMessages } = props; + + const { receiverPubKey } = useContext(ChatContext); + + const [initiatedInviting, setInitiatedInviting] = useState(false); + const [invitationText, setInvitationText] = useState( + "Hi! Let's start messaging privately. Register an account on [https://ecency.com/chats](https://ecency.com/chats)" + ); + const { publicKey } = useKeysQuery(); + const { fetchNextPage } = useDirectMessagesQuery(props.currentContact); + + const { + mutateAsync: invite, + isLoading: isInviting, + isSuccess: isInvited + } = useInviteViaPostComment(props.currentContact?.name); + + const groupedDirectMessages = useMemo(() => groupMessages(directMessages), [directMessages]); + + return ( + <> +
+ {receiverPubKey ? ( + <> + {groupedDirectMessages?.map(([date, messages], i) => { + const diff = + i > 0 ? differenceInCalendarDays(date, groupedDirectMessages[i - 1][0]) : 1; + return ( + + {diff > 0 && } + {messages.map((message, j) => ( + + setTimeout( + () => + directMessages?.length - 1 === i + ? document + .querySelector(`[data-message-id="${message.id}"]`) + ?.scrollIntoView() + : {}, + 100 + ) + } + onInViewport={() => + i === groupedDirectMessages.length - 1 && + j === messages.length - 1 && + fetchNextPage({ + pageParam: message.created * 1000 + }) + } + /> + ))} + + ); + })} + + ) : ( +
+
{_t("chat.welcome.oops")}
+
+ {_t("chat.welcome.user-not-joined-yet")} +
+ {!isInvited && + (initiatedInviting ? ( +
+ {_t("chat.specify-invitation-message")} + ) => + setInvitationText(e.target.value) + } + /> + +
+ ) : ( + + ))} + {isInvited && ( + + {_t("chat.successfully-invited")} + + )} +
+ )} +
+ + ); +} diff --git a/src/common/features/chats/components/chats-dropdown-menu.tsx b/src/common/features/chats/components/chats-dropdown-menu.tsx new file mode 100644 index 00000000000..335172a480f --- /dev/null +++ b/src/common/features/chats/components/chats-dropdown-menu.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { History } from "history"; +import { chatKeySvg, extendedView, kebabMenuSvg, keySvg } from "../../../img/svg"; +import { _t } from "../../../i18n"; +import { Dropdown, DropdownItemWithIcon, DropdownMenu, DropdownToggle } from "@ui/dropdown"; +import { Button } from "@ui/button"; +import { history } from "../../../store"; +import { useLocation } from "react-router"; +import { useLogoutFromChats } from "@ecency/ns-query"; + +interface Props { + history: History | null; + onManageChatKey?: () => void; + currentUser?: string; + communityName?: string; +} + +const ChatsDropdownMenu = (props: Props) => { + const { mutateAsync: logout } = useLogoutFromChats(); + const location = useLocation(); + + const handleExtendedView = () => { + if (props.currentUser) { + history?.push(`/chats/@${props.currentUser}`); + } else if (props.communityName) { + history?.push(`/chats/${props.communityName}`); + } else { + history?.push("/chats"); + } + }; + + return ( + + + + + {!location.pathname.startsWith("/chats") && ( + void }) => { + e.stopPropagation(); + handleExtendedView(); + }} + /> + )} + props.onManageChatKey?.()} + /> + logout()} /> + + + + ); +}; + +export default ChatsDropdownMenu; diff --git a/src/common/features/chats/components/chats-import.tsx b/src/common/features/chats/components/chats-import.tsx new file mode 100644 index 00000000000..1eb8aae6865 --- /dev/null +++ b/src/common/features/chats/components/chats-import.tsx @@ -0,0 +1,52 @@ +import { Button } from "@ui/button"; +import { _t } from "../../../i18n"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "@ui/modal"; +import { CodeInput, FormControl } from "@ui/input"; +import React, { useState } from "react"; +import { Alert } from "@ui/alert"; +import { useImportChatByKeys } from "@ecency/ns-query"; +import { uploadChatKeys } from "../utils/upload-chat-keys"; + +export function ChatsImport() { + const [step, setStep] = useState(0); + const [ecencyChatKey, setEcencyChatKey] = useState(""); + const [pin, setPin] = useState(""); + + const { mutateAsync: importChatByKey } = useImportChatByKeys(uploadChatKeys); + + return ( + <> + + {step === 1 && ( + setStep(0)}> + {_t("chat.import.title")} + +
{_t("chat.import.description")}
+
{_t("chat.key")}
+ setEcencyChatKey(e.target.value)} + /> + {_t("chat.create-pin-description")} + +
+ + + + +
+ )} + + ); +} diff --git a/src/common/features/chats/components/chats-scroller/index.scss b/src/common/features/chats/components/chats-scroller/index.scss new file mode 100644 index 00000000000..8493b3b827e --- /dev/null +++ b/src/common/features/chats/components/chats-scroller/index.scss @@ -0,0 +1,33 @@ +@import "src/style/vars_mixins"; + +.scroller { + position: sticky; + width: 33px; + height: 33px; + border-radius: 50%; + float: right; + z-index: 9; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + @include themify(day) { + background: #e4e6eb; + @apply border border-light-500; + } + @include themify(night) { + background: #0f223a; + } + + svg { + @include themify(day) { + @apply text-blue-dark-sky; + } + @include themify(night) { + @apply text-white; + } + + width: 20px; + height: 20px; + } +} \ No newline at end of file diff --git a/src/common/features/chats/components/chats-scroller/index.tsx b/src/common/features/chats/components/chats-scroller/index.tsx new file mode 100644 index 00000000000..8cec4b490d5 --- /dev/null +++ b/src/common/features/chats/components/chats-scroller/index.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import Tooltip from "../../../../components/tooltip"; + +import { _t } from "../../../../i18n"; +import { chevronDownSvgForSlider, chevronUpSvg } from "../../../../img/svg"; + +import "./index.scss"; + +interface Props { + bodyRef: React.RefObject; + isScrollToTop: boolean; + isScrollToBottom: boolean; + marginRight: string; +} + +export default function ChatsScroller(props: Props) { + const { bodyRef, isScrollToTop, isScrollToBottom, marginRight } = props; + + const scrollerClicked = () => { + bodyRef?.current?.scroll({ + top: isScrollToBottom ? bodyRef.current?.scrollHeight : 0, + behavior: "auto" + }); + }; + + return ( + +
+ {isScrollToTop ? chevronUpSvg : chevronDownSvgForSlider} +
+
+ ); +} diff --git a/src/common/features/chats/components/chats-sidebar/chat-sidebar-channel.tsx b/src/common/features/chats/components/chats-sidebar/chat-sidebar-channel.tsx new file mode 100644 index 00000000000..439f828e0ac --- /dev/null +++ b/src/common/features/chats/components/chats-sidebar/chat-sidebar-channel.tsx @@ -0,0 +1,45 @@ +import { Link } from "react-router-dom"; +import React, { useContext, useMemo } from "react"; +import UserAvatar from "../../../../components/user-avatar"; +import { classNameObject } from "../../../../helper/class-name-object"; +import { Channel, ChatContext, getRelativeDate, useLastMessageQuery } from "@ecency/ns-query"; + +interface Props { + username: string; + channel: Channel; + isChannel: boolean; +} + +export function ChatSidebarChannel({ channel, username, isChannel }: Props) { + const { revealPrivateKey, setRevealPrivateKey } = useContext(ChatContext); + + const lastMessage = useLastMessageQuery(undefined, channel); + + const rawUsername = useMemo(() => username?.replace("@", "") ?? "", [username]); + const lastMessageDate = useMemo(() => getRelativeDate(lastMessage?.created), [lastMessage]); + + return ( + { + if (revealPrivateKey) { + setRevealPrivateKey(false); + } + }} + > + +
+
+
{channel.name}
+
{lastMessageDate}
+
+
{lastMessage?.content}
+
+ + ); +} diff --git a/src/common/features/chats/components/chats-sidebar/chat-sidebar-direct-contact.tsx b/src/common/features/chats/components/chats-sidebar/chat-sidebar-direct-contact.tsx new file mode 100644 index 00000000000..28869144751 --- /dev/null +++ b/src/common/features/chats/components/chats-sidebar/chat-sidebar-direct-contact.tsx @@ -0,0 +1,46 @@ +import { Link } from "react-router-dom"; +import React, { useContext, useMemo } from "react"; +import UserAvatar from "../../../../components/user-avatar"; +import { classNameObject } from "../../../../helper/class-name-object"; +import { ChatContext, DirectContact, getRelativeDate, useLastMessageQuery } from "@ecency/ns-query"; + +interface Props { + contact: DirectContact; + username: string; +} + +export function ChatSidebarDirectContact({ contact, username }: Props) { + const { receiverPubKey, setReceiverPubKey, revealPrivateKey, setRevealPrivateKey } = + useContext(ChatContext); + + const lastMessage = useLastMessageQuery(contact); + const rawUsername = useMemo(() => username?.replace("@", "") ?? "", [username]); + const lastMessageDate = useMemo(() => getRelativeDate(lastMessage?.created), [lastMessage]); + + return ( + { + setReceiverPubKey(contact.pubkey); + if (revealPrivateKey) { + setRevealPrivateKey(false); + } + }} + > + +
+
+
{contact.name}
+
{lastMessageDate}
+
+
{lastMessage?.content}
+
+ + ); +} diff --git a/src/common/features/chats/components/chats-sidebar/chat-sidebar-header.tsx b/src/common/features/chats/components/chats-sidebar/chat-sidebar-header.tsx new file mode 100644 index 00000000000..2dcfaf38d49 --- /dev/null +++ b/src/common/features/chats/components/chats-sidebar/chat-sidebar-header.tsx @@ -0,0 +1,32 @@ +import { _t } from "../../../../i18n"; +import ChatsDropdownMenu from "../chats-dropdown-menu"; +import React, { useContext } from "react"; +import { History } from "history"; +import { ChatContext, useKeysQuery } from "@ecency/ns-query"; + +interface Props { + history: History; +} + +export function ChatSidebarHeader({ history }: Props) { + const { privateKey } = useKeysQuery(); + const { revealPrivateKey, setRevealPrivateKey } = useContext(ChatContext); + + return ( +
+
+
{_t("chat.title")}
+
+
+ {!!privateKey && ( +
+ setRevealPrivateKey(!revealPrivateKey)} + history={history} + /> +
+ )} +
+
+ ); +} diff --git a/src/common/features/chats/components/chats-sidebar/chat-sidebar-search-item.tsx b/src/common/features/chats/components/chats-sidebar/chat-sidebar-search-item.tsx new file mode 100644 index 00000000000..25eba44e392 --- /dev/null +++ b/src/common/features/chats/components/chats-sidebar/chat-sidebar-search-item.tsx @@ -0,0 +1,47 @@ +import accountReputation from "../../../../helper/account-reputation"; +import { Link } from "react-router-dom"; +import React, { createElement, useMemo } from "react"; +import UserAvatar from "../../../../components/user-avatar"; +import isCommunity from "../../../../helper/is-community"; +import { AccountWithReputation } from "@ecency/ns-query"; +import { Community } from "../../../../store/communities"; + +interface Props { + item: AccountWithReputation | Community; + onClick: () => void; +} + +export function ChatSidebarSearchItem({ item, onClick }: Props) { + const username = useMemo(() => { + if ("account" in item) { + return isCommunity(item.account) ? item.account : "@" + item.account; + } + return item.name; + }, [item]); + + return createElement( + Link, + { + to: `/chats/${username}`, + className: + "flex items-center cursor-pointer justify-between gap-3 p-3 border-b border-[--border-color] last:border-0 hover:bg-gray-100 dark:hover:bg-gray-800", + onClick + }, + <> +
+ +
+
+ {"account" in item ? item.account : item.title} +
+
+ {isCommunity(username) ? "Community" : "User"} +
+
+
+ {"reputation" in item && ( + ({accountReputation(item.reputation)}) + )} + + ); +} diff --git a/src/common/features/chats/components/chats-sidebar/chat-sidebar-search.tsx b/src/common/features/chats/components/chats-sidebar/chat-sidebar-search.tsx new file mode 100644 index 00000000000..89c317067a5 --- /dev/null +++ b/src/common/features/chats/components/chats-sidebar/chat-sidebar-search.tsx @@ -0,0 +1,41 @@ +import { FormControl } from "@ui/input"; +import { _t } from "../../../../i18n"; +import React, { useState } from "react"; +import useDebounce from "react-use/lib/useDebounce"; +import LinearProgress from "../../../../components/linear-progress"; +import { useSearchUsersQuery } from "../../queries"; +import { useSearchCommunitiesQuery } from "../../queries/search-communities-query"; + +interface Props { + setSearch: (v: string) => void; +} + +export function ChatSidebarSearch({ setSearch: emitSearch }: Props) { + const [search, setSearch] = useState(""); + const { isLoading, refetch } = useSearchUsersQuery(search); + const { isLoading: isCommunitiesLoading, refetch: refetchCommunities } = + useSearchCommunitiesQuery(search); + + useDebounce( + async () => { + await Promise.all([refetch(), refetchCommunities()]); + emitSearch(search); + }, + 500, + [search] + ); + + return ( + <> +
+ setSearch(e.target.value)} + /> +
+ {(isLoading || isCommunitiesLoading) && } + + ); +} diff --git a/src/common/features/chats/components/chats-sidebar/index.tsx b/src/common/features/chats/components/chats-sidebar/index.tsx new file mode 100644 index 00000000000..a55680672dc --- /dev/null +++ b/src/common/features/chats/components/chats-sidebar/index.tsx @@ -0,0 +1,87 @@ +import React, { useContext, useState } from "react"; +import { History } from "history"; +import { ChatSidebarHeader } from "./chat-sidebar-header"; +import { ChatSidebarSearch } from "./chat-sidebar-search"; +import { ChatSidebarSearchItem } from "./chat-sidebar-search-item"; +import { ChatSidebarDirectContact } from "./chat-sidebar-direct-contact"; +import { _t } from "../../../../i18n"; +import { ChatSidebarChannel } from "./chat-sidebar-channel"; +import { ChatContext, useChannelsQuery, useDirectContactsQuery } from "@ecency/ns-query"; +import { useSearchUsersQuery } from "../../queries"; +import { useSearchCommunitiesQuery } from "../../queries/search-communities-query"; + +interface Props { + username: string; + history: History; + isChannel: boolean; +} + +export default function ChatsSideBar(props: Props) { + const { username } = props; + const { setRevealPrivateKey } = useContext(ChatContext); + + const { data: directContacts } = useDirectContactsQuery(); + const { data: channels } = useChannelsQuery(); + const chatsSideBarRef = React.createRef(); + + const [searchQuery, setSearchQuery] = useState(""); + const [showDivider, setShowDivider] = useState(false); + + const { data: searchUsers } = useSearchUsersQuery(searchQuery); + const { data: searchCommunities } = useSearchCommunitiesQuery(searchQuery); + + return ( +
+ + + {showDivider &&
} +
+ {searchQuery ? ( + [...searchUsers, ...searchCommunities].map((item) => ( + { + setSearchQuery(""); + setRevealPrivateKey(false); + }} + key={"account" in item ? item.account : item.id} + /> + )) + ) : ( + <> + {channels?.length !== 0 && ( +
+ {_t("chat.communities")} +
+ )} + {channels?.map((channel) => ( + + ))} + {directContacts?.length !== 0 && ( +
+ {_t("chat.direct-messages")} +
+ )} + {directContacts?.map((contact) => ( + + ))} + + )} +
+ {directContacts?.length === 0 && channels?.length === 0 && ( +
+ {_t("chat.no-contacts-or-channels")} +
+ )} +
+ ); +} diff --git a/src/common/features/chats/components/chats-welcome.tsx b/src/common/features/chats/components/chats-welcome.tsx new file mode 100644 index 00000000000..57ab938dbd6 --- /dev/null +++ b/src/common/features/chats/components/chats-welcome.tsx @@ -0,0 +1,88 @@ +import React, { useContext, useEffect, useMemo, useState } from "react"; +import { _t } from "../../../i18n"; +import { CodeInput } from "@ui/input"; +import OrDivider from "../../../components/or-divider"; +import { useGetAccountFullQuery } from "../../../api/queries"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import { CreateAnAccount } from "./create-an-account"; +import { ChatsImport } from "./chats-import"; +import { + ChatContext, + getUserChatPrivateKey, + getUserChatPublicKey, + useRestoreChatByPin +} from "@ecency/ns-query"; + +export function ChatsWelcome() { + const { activeUser } = useMappedStore(); + + const [step, setStep] = useState(0); + const [pin, setPin] = useState(""); + + const { hasUserJoinedChat } = useContext(ChatContext); + const { data: fullAccount } = useGetAccountFullQuery(activeUser?.username); + + const { + mutateAsync: restoreByPin, + isError: isRestoreFailed, + isLoading: isRestoreLoading + } = useRestoreChatByPin(); + + const isAlreadyRegisteredInChats = useMemo(() => { + if (!fullAccount) { + return false; + } + const publicKey = getUserChatPublicKey(fullAccount); + const { key, iv } = getUserChatPrivateKey(fullAccount); + return !!key && !!iv && !!publicKey; + }, [fullAccount]); + + useEffect(() => { + // Handle PIN on account restoring + if (step === 0 && pin.length === 8) { + restoreByPin(pin); + } + }, [pin]); + + useEffect(() => { + if (hasUserJoinedChat) { + setStep(0); + } + }, [hasUserJoinedChat]); + + return ( + <> +
+
+
{_t("chat.welcome.title")}
+
+ + {isAlreadyRegisteredInChats ? ( + <> +
+
{_t("chat.welcome.already-joined-title")}
+
{_t("chat.welcome.already-joined-hint")}
+ + {isRestoreFailed &&
{_t("chat.welcome.pin-failed")}
} +
+ +
+ + +
+ + ) : ( + <> +
+
{_t("chat.welcome.description")}
+
+
+ + +
+ + )} +
+ + ); +} diff --git a/src/common/features/chats/components/create-an-account.tsx b/src/common/features/chats/components/create-an-account.tsx new file mode 100644 index 00000000000..7eaab90948e --- /dev/null +++ b/src/common/features/chats/components/create-an-account.tsx @@ -0,0 +1,39 @@ +import { _t } from "../../../i18n"; +import { Button } from "@ui/button"; +import React, { useState } from "react"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "@ui/modal"; +import { Alert } from "@ui/alert"; +import { CodeInput } from "@ui/input"; +import { useJoinChat } from "@ecency/ns-query"; +import { uploadChatKeys } from "../utils/upload-chat-keys"; + +export function CreateAnAccount() { + const [step, setStep] = useState(0); + const [pin, setPin] = useState(""); + + const { mutateAsync: joinChat } = useJoinChat(uploadChatKeys); + + return ( + <> + + {step === 1 && ( + setStep(0)}> + {_t("chat.create-an-account")} + +
{_t("chat.create-description")}
+ {_t("chat.create-pin-description")} + +
+ + + + +
+ )} + + ); +} diff --git a/src/common/features/chats/components/join-community-chat-btn.tsx b/src/common/features/chats/components/join-community-chat-btn.tsx new file mode 100644 index 00000000000..b35f0b79b70 --- /dev/null +++ b/src/common/features/chats/components/join-community-chat-btn.tsx @@ -0,0 +1,133 @@ +import React, { useContext, useMemo } from "react"; +import { History } from "history"; +import { Community } from "../../../store/communities"; +import { _t } from "../../../i18n"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import { Spinner } from "@ui/spinner"; +import { Button } from "@ui/button"; +import { + ChatContext, + useAddCommunityChannel, + useChannelsQuery, + useCommunityChannelQuery, + useCreateCommunityChat, + useKeysQuery, + useLeaveCommunityChannel, + useLeftCommunityChannelsQuery, + useNostrJoinedCommunityTeamQuery +} from "@ecency/ns-query"; +import { useGetAccountFullQuery, useGetAccountsFullQuery } from "../../../api/queries"; +import { updateProfile } from "../../../api/operations"; + +interface Props { + history: History; + community: Community; +} + +export default function JoinCommunityChatBtn(props: Props) { + const { publicKey } = useKeysQuery(); + const { hasUserJoinedChat } = useContext(ChatContext); + const { activeUser } = useMappedStore(); + + const { data: communityAccount, isLoading: isCommunityAccountLoading } = useGetAccountFullQuery( + props.community.name + ); + const communityTeamQueries = useGetAccountsFullQuery( + props.community.team.map(([name, role]) => name) + ); + const { data: currentChannel, isLoading: isCurrentChannelLoading } = useCommunityChannelQuery( + props.community + ); + const { data: channels, isLoading: isChannelsLoading } = useChannelsQuery(); + const { data: communityTeam, isLoading: isCommunityTeamLoading } = + useNostrJoinedCommunityTeamQuery( + props.community, + communityAccount!!, + communityTeamQueries.map((query) => query.data!!) + ); + const { data: leftChannelsIds, isLoading: isChannelsIdsLoading } = + useLeftCommunityChannelsQuery(); + + const { mutateAsync: addCommunityChannel, isLoading: isAddCommunityChannelLoading } = + useAddCommunityChannel(currentChannel); + const { mutateAsync: createCommunityChat, isLoading: isCreateCommunityChatLoading } = + useCreateCommunityChat(props.community, communityAccount!!, updateProfile); + const { mutateAsync: leaveCommunityChannel, isLoading: isLeavingCommunityChannelLoading } = + useLeaveCommunityChannel(); + + const isGlobalLoading = useMemo( + () => + isChannelsLoading || + isChannelsIdsLoading || + isCommunityAccountLoading || + isCommunityTeamLoading, + [isChannelsLoading, isChannelsIdsLoading, isCommunityAccountLoading, isCommunityTeamLoading] + ); + const isCommunityChannelCreated = useMemo(() => !!currentChannel, [currentChannel]); + const isCommunityChannelJoined = useMemo( + () => + channels?.some( + (item) => + item.communityName === props.community.name && + !leftChannelsIds?.includes(currentChannel?.id!) + ), + [channels, leftChannelsIds] + ); + // Todo: all admin team should be able to create a channel + // Upd: only community owner could create a channel because it requires Nostr private key + const isAbleToCreateChannel = useMemo( + () => props.community.name === activeUser?.username, + [activeUser, props.community] + ); + + const join = async () => { + if (!hasUserJoinedChat) { + return; + } + await addCommunityChannel(); + }; + + return isGlobalLoading ? ( + <> + ) : ( + <> + {isCommunityChannelJoined && ( + + )} + {!isCommunityChannelJoined && ( + + )} + {!isCommunityChannelCreated && isAbleToCreateChannel && ( + + )} + + ); +} diff --git a/src/common/features/chats/components/manage-chat-key.tsx b/src/common/features/chats/components/manage-chat-key.tsx new file mode 100644 index 00000000000..3fe7214754d --- /dev/null +++ b/src/common/features/chats/components/manage-chat-key.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { _t } from "../../../i18n"; +import { CodeInput, InputGroupCopyClipboard } from "@ui/input"; +import qrcode from "qrcode"; +import { classNameObject } from "../../../helper/class-name-object"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import { PREFIX } from "../../../util/local-storage"; +import { Button } from "@ui/button"; +import { useKeysQuery } from "@ecency/ns-query"; + +export default function ManageChatKey() { + const { activeUser } = useMappedStore(); + + const qrImgRef = useRef(null); + + const [isUnlocked, setIsUnlocked] = useState(false); + const [validationPin, setValidationPin] = useState(""); + const [isQrShow, setIsQrShow] = useState(false); + + const { publicKey, privateKey, iv } = useKeysQuery(); + + const pin = useMemo(() => localStorage.getItem(PREFIX + "_nostr_pr_" + activeUser?.username), []); + const ecencyKey = useMemo( + () => + Buffer.from( + JSON.stringify({ + pub: publicKey, + priv: privateKey, + iv + }) + ).toString("base64"), + [publicKey, privateKey] + ); + + useEffect(() => { + if (validationPin === pin) { + setIsUnlocked(true); + } + }, [validationPin, pin]); + + useEffect(() => { + if (ecencyKey) { + compileQR(); + } + }, [ecencyKey]); + + const compileQR = async () => { + if (qrImgRef.current) { + qrImgRef.current.src = await qrcode.toDataURL(ecencyKey!!, { width: 300 }); + setIsQrShow(true); + } + }; + + return ( +
+ {isUnlocked ? ( + <> +
{_t("chat.chat-priv-key")}
+
PIN
+ +
{_t("chat.ecency-key")}
+ + + + + ) : ( + <> +
+ {_t("chat.unlock-the-section")} +
+ + {validationPin !== pin && validationPin.length === pin?.length && ( +
{_t("chat.welcome.pin-failed")}
+ )} + + )} +
+ ); +} diff --git a/src/common/features/chats/mutations/index.ts b/src/common/features/chats/mutations/index.ts new file mode 100644 index 00000000000..ed1ed19caac --- /dev/null +++ b/src/common/features/chats/mutations/index.ts @@ -0,0 +1,2 @@ +export * from "./upload"; +export * from "./invite-via-post-comment"; diff --git a/src/common/features/chats/mutations/invite-via-post-comment.ts b/src/common/features/chats/mutations/invite-via-post-comment.ts new file mode 100644 index 00000000000..974c20f4e10 --- /dev/null +++ b/src/common/features/chats/mutations/invite-via-post-comment.ts @@ -0,0 +1,40 @@ +import { useMutation } from "@tanstack/react-query"; +import { getAccountPosts } from "../../../api/bridge"; +import { comment } from "../../../api/operations"; +import tempEntry from "../../../helper/temp-entry"; +import { FullAccount } from "../../../store/accounts/types"; +import { createReplyPermlink } from "../../../helper/posting"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import { error } from "../../../components/feedback"; +import { _t } from "../../../i18n"; + +export function useInviteViaPostComment(username: string) { + const { activeUser, addReply } = useMappedStore(); + + return useMutation(["chats/invite-via-post-comment"], async (text: string) => { + const response = await getAccountPosts("posts", username); + if (response && response.length > 0) { + const firstPost = response[0]; + + const { author: parentAuthor, permlink: parentPermlink } = firstPost; + const author = activeUser!!.username; + const permlink = createReplyPermlink(author); + await comment(author, parentAuthor, parentPermlink, permlink, "", text, {}, null, true); + addReply( + tempEntry({ + author: activeUser!!.data as FullAccount, + permlink, + parentAuthor, + parentPermlink, + title: "", + body: text, + tags: [], + description: null + }) + ); + } else { + error(_t("chat.no-posts-for-invite")); + throw new Error(_t("chat.no-posts-for-invite")); + } + }); +} diff --git a/src/common/features/chats/mutations/upload.ts b/src/common/features/chats/mutations/upload.ts new file mode 100644 index 00000000000..e1ac46cf6de --- /dev/null +++ b/src/common/features/chats/mutations/upload.ts @@ -0,0 +1,61 @@ +import { useMutation } from "@tanstack/react-query"; +import { getAccessToken } from "../../../helper/user-token"; +import { uploadImage } from "../../../api/misc"; +import { addImage } from "../../../api/private-api"; +import { error } from "../../../components/feedback"; +import { _t } from "../../../i18n"; +import axios from "axios"; +import { useMappedStore } from "../../../store/use-mapped-store"; + +class FileUploadingError { + constructor(public code: number, public message: string) {} +} + +export function useChatFileUpload(setMessage: (v: string) => void) { + const { activeUser, global } = useMappedStore(); + + return useMutation( + ["chats/file-upload"], + async (file: File) => { + const username = activeUser?.username; + + if (!username) { + throw new FileUploadingError(0, "[Chat][File uploading] No user"); + } + + const token = getAccessToken(username); + + if (!token) { + throw new FileUploadingError(1, "[Chat][File uploading] No token"); + } + + const tempImgTag = `![Uploading ${file.name} #${Math.floor(Math.random() * 99)}]()\n\n`; + setMessage(tempImgTag); + + let imageUrl: string; + const resp = await uploadImage(file, token); + imageUrl = resp.url; + + if (global.usePrivate && imageUrl.length > 0) { + await addImage(username, imageUrl); + } + + const imgTag = imageUrl.length > 0 && `![](${imageUrl})\n\n`; + if (imgTag) { + setMessage(imgTag); + } + }, + { + onError: (e) => { + if (axios.isAxiosError(e) && e.response?.status === 413) { + error(_t("editor-toolbar.image-error-size")); + } else if (e instanceof FileUploadingError) { + error(_t("editor-toolbar.image-error-cache")); + throw new Error(e.message); + } else { + error(_t("editor-toolbar.image-error")); + } + } + } + ); +} diff --git a/src/common/features/chats/queries/index.ts b/src/common/features/chats/queries/index.ts new file mode 100644 index 00000000000..a25d33f77de --- /dev/null +++ b/src/common/features/chats/queries/index.ts @@ -0,0 +1 @@ +export * from "./search-users-query"; diff --git a/src/common/features/chats/queries/search-communities-query.ts b/src/common/features/chats/queries/search-communities-query.ts new file mode 100644 index 00000000000..d3d9a5da3ed --- /dev/null +++ b/src/common/features/chats/queries/search-communities-query.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { getCommunities } from "../../../api/bridge"; + +export function useSearchCommunitiesQuery(query: string) { + return useQuery( + ["chats/search-communities", query], + async () => { + const response = await getCommunities("", 10, query); + return response ?? []; + }, + { + initialData: [] + } + ); +} diff --git a/src/common/features/chats/queries/search-users-query.ts b/src/common/features/chats/queries/search-users-query.ts new file mode 100644 index 00000000000..b29d7eee6fa --- /dev/null +++ b/src/common/features/chats/queries/search-users-query.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAccountReputations } from "../../../api/hive"; + +export function useSearchUsersQuery(query: string) { + return useQuery( + ["chats/search-user", query], + async () => { + if (!query) { + return []; + } + const response = await getAccountReputations(query, 10); + response.sort((a, b) => (a.reputation > b.reputation ? -1 : 1)); + return response; + }, + { + initialData: [] + } + ); +} diff --git a/src/common/features/chats/screens/_chats.scss b/src/common/features/chats/screens/_chats.scss new file mode 100644 index 00000000000..14d6760d3a3 --- /dev/null +++ b/src/common/features/chats/screens/_chats.scss @@ -0,0 +1,24 @@ +.chat-input { + .gif-picker { + max-height: 300px; + overflow-y: auto; + @apply bg-white dark:bg-dark-200 border-b border-[--border-color] w-[calc(100%+0.75rem)] flex flex-col gap-3 mb-4 -mx-3; + + &::-webkit-scrollbar { + display: none; + } + + .gif-list { + display: flex; + flex-wrap: wrap; + gap: 1rem; + @apply p-3; + } + + .search-box { + position: sticky; + top: 0; + @apply bg-white dark:bg-dark-200 border-b border-[--border-color] p-3; + } + } +} \ No newline at end of file diff --git a/src/common/features/chats/screens/chats-manage-key-section.tsx b/src/common/features/chats/screens/chats-manage-key-section.tsx new file mode 100644 index 00000000000..1efc200539e --- /dev/null +++ b/src/common/features/chats/screens/chats-manage-key-section.tsx @@ -0,0 +1,28 @@ +import { Button } from "@ui/button"; +import { arrowBackSvg } from "../../../img/svg"; +import { _t } from "../../../i18n"; +import ManageChatKey from "../components/manage-chat-key"; +import React, { useContext } from "react"; +import { ChatContext } from "@ecency/ns-query"; + +export function ChatsManageKeySection() { + const { setRevealPrivateKey } = useContext(ChatContext); + + return ( +
+
+
+
+ +
+
+
+ ); +} diff --git a/src/common/features/chats/screens/chats-user-not-joined-section.tsx b/src/common/features/chats/screens/chats-user-not-joined-section.tsx new file mode 100644 index 00000000000..d380f283c89 --- /dev/null +++ b/src/common/features/chats/screens/chats-user-not-joined-section.tsx @@ -0,0 +1,25 @@ +import { _t } from "../../../i18n"; +import ChatsProfileBox from "../components/chat-profile-box"; +import React, { HTMLProps } from "react"; +import { match } from "react-router"; +import { classNameObject } from "../../../helper/class-name-object"; + +export function ChatsUserNotJoinedSection({ + match, + className +}: HTMLProps & { match: match<{ username: string }> }) { + return ( +
+
{_t("chat.welcome.oops")}
+
+ {_t("chat.welcome.user-not-joined-yet")} +
+ +
+ ); +} diff --git a/src/common/features/chats/screens/chats.tsx b/src/common/features/chats/screens/chats.tsx new file mode 100644 index 00000000000..409507c7399 --- /dev/null +++ b/src/common/features/chats/screens/chats.tsx @@ -0,0 +1,180 @@ +import React, { useContext, useEffect, useMemo } from "react"; +import { connect } from "react-redux"; +import { match } from "react-router"; +import NavBar from "../../../components/navbar"; +import { pageMapDispatchToProps, pageMapStateToProps, PageProps } from "../../../pages/common"; +import ChatsSideBar from "../components/chats-sidebar"; +import Feedback from "../../../components/feedback"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import ChatsMessagesBox from "../components/chat-message-box"; +import { classNameObject } from "../../../helper/class-name-object"; +import "./_chats.scss"; +import { ChatsWelcome } from "../components/chats-welcome"; +import { useCommunityCache } from "../../../core"; +import { useGetAccountFullQuery } from "../../../api/queries"; +import useMountedState from "react-use/lib/useMountedState"; +import { _t } from "../../../i18n"; +import Meta from "../../../components/meta"; +import { ChatsDefaultScreen } from "../components/chats-default-screen"; +import { ChatsManageKeySection } from "./chats-manage-key-section"; +import { ChatsUserNotJoinedSection } from "./chats-user-not-joined-section"; +import { + ChatContext, + getUserChatPublicKey, + useChannelsQuery, + useCommunityChannelQuery, + useDirectContactsQuery, + useKeysQuery +} from "@ecency/ns-query"; + +interface Props extends PageProps { + match: match<{ + filter: string; + name: string; + path: string; + url: string; + username: string; + channel?: string; + }>; +} + +export const Chats = ({ match, history }: Props) => { + const { activeUser } = useMappedStore(); + const { receiverPubKey, revealPrivateKey, setReceiverPubKey, setRevealPrivateKey } = + useContext(ChatContext); + const { data: community } = useCommunityCache(match.params.username); + + const { publicKey, privateKey } = useKeysQuery(); + const { data: userAccount } = useGetAccountFullQuery(match.params.username?.replace("@", "")); + const { data: directContacts } = useDirectContactsQuery(); + const { data: channels } = useChannelsQuery(); + + const isChannel = useMemo(() => match.params.channel === "channel", [match.params]); + // Generate temporary contact from username and its public key + const directContact = useMemo( + () => + match.params.username && !isChannel + ? { + name: match.params.username.replace("@", ""), + pubkey: receiverPubKey + } + : undefined, + [receiverPubKey, match.params] + ); + const { data: communityChannel } = useCommunityChannelQuery( + isChannel && community ? community : undefined, + userAccount + ); + + const isReady = useMemo( + () => !!(activeUser && publicKey && privateKey), + [publicKey, privateKey, activeUser] + ); + const isShowManageKey = useMemo(() => isReady && revealPrivateKey, [isReady, revealPrivateKey]); + const isShowChatRoom = useMemo( + () => isReady && (!!directContact || !!communityChannel) && !revealPrivateKey, + [isReady, receiverPubKey, revealPrivateKey, communityChannel, directContact] + ); + const isShowDefaultScreen = useMemo( + () => + isReady && !directContact && !communityChannel && !revealPrivateKey && !match.params.username, + [isReady, receiverPubKey, directContact, communityChannel, match] + ); + const isShowImportChats = useMemo(() => !isReady, [isReady]); + + const isMounted = useMountedState(); + + const title = useMemo(() => { + let title = _t("chat.page-title"); + + if (community) { + title = `${community.title} | ${title}`; + } else if (userAccount) { + title = `${userAccount.name} | ${title}`; + } + + return title; + }, [community, userAccount]); + + useEffect(() => { + if (userAccount && !isChannel) { + const key = getUserChatPublicKey(userAccount); + setReceiverPubKey(key ?? ""); + } else { + setReceiverPubKey(""); + } + }, [userAccount, isChannel]); + + useEffect(() => { + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = "auto"; + }; + }, []); + + return isMounted() ? ( +
+ + + + +
+
+
+ {isReady ? ( + + ) : ( + <> + )} + {(!directContacts?.length || !channels?.length) && isShowDefaultScreen && ( + + )} + {(!directContacts?.length || !channels?.length) && isShowImportChats && activeUser && ( +
+ +
+ )} + {!isShowChatRoom && isReady && match.params.username && ( + + )} +
+
+ {isShowManageKey && } + {isShowImportChats && activeUser && ( +
+ +
+ )} + {isShowChatRoom && ( + + )} + {!isShowChatRoom && isReady && match.params.username && ( + + )} + {isShowDefaultScreen && } +
+
+
+
+ ) : ( + <> + ); +}; +export default connect(pageMapStateToProps, pageMapDispatchToProps)(Chats); diff --git a/src/common/features/chats/screens/index.ts b/src/common/features/chats/screens/index.ts new file mode 100644 index 00000000000..9a6ff3c6d09 --- /dev/null +++ b/src/common/features/chats/screens/index.ts @@ -0,0 +1 @@ +export * from "./chats"; diff --git a/src/common/features/chats/utils/get-date-fns-locale.ts b/src/common/features/chats/utils/get-date-fns-locale.ts new file mode 100644 index 00000000000..6752403b418 --- /dev/null +++ b/src/common/features/chats/utils/get-date-fns-locale.ts @@ -0,0 +1,32 @@ +import { bg, enUS, es, fi, hi, id, it, pt, ru, sr, uk, uz, zhCN } from "date-fns/locale"; + +export function getDateFnsLocale(lang: string) { + switch (lang) { + case "es": + return es; + case "hi": + return hi; + case "it": + return it; + case "id": + return id; + case "pt": + return pt; + case "sr": + return sr; + case "fi": + return fi; + case "uk": + return uk; + case "bg": + return bg; + case "ru": + return ru; + case "uz": + return uz; + case "zh": + return zhCN; + default: + return enUS; + } +} diff --git a/src/common/features/chats/utils/group-messages.ts b/src/common/features/chats/utils/group-messages.ts new file mode 100644 index 00000000000..132fb1629eb --- /dev/null +++ b/src/common/features/chats/utils/group-messages.ts @@ -0,0 +1,22 @@ +import { Message } from "@ecency/ns-query"; + +export function groupMessages(messages: Message[]) { + return messages.reduce<[Date, Message[]][]>((acc, item, currentIndex) => { + if (currentIndex === 0) { + return [...acc, [new Date(item.created * 1000), [item]]]; + } + + // If difference less than 5 minutes then we group them together + const differenceInDates = item.created - messages[currentIndex - 1].created; + const groupItems = acc[acc.length - 1][1]; + if ( + differenceInDates * 1000 < 120000 && + groupItems[groupItems.length - 1].creator === item.creator + ) { + groupItems.push(item); + return acc; + } + + return [...acc, [new Date(item.created * 1000), [item]]]; + }, []); +} diff --git a/src/common/features/chats/utils/index.ts b/src/common/features/chats/utils/index.ts new file mode 100644 index 00000000000..799ebfe5fdf --- /dev/null +++ b/src/common/features/chats/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./is-message-gif"; +export * from "./get-date-fns-locale"; +export * from "./group-messages"; diff --git a/src/common/features/chats/utils/is-message-gif.ts b/src/common/features/chats/utils/is-message-gif.ts new file mode 100644 index 00000000000..77d8fda9817 --- /dev/null +++ b/src/common/features/chats/utils/is-message-gif.ts @@ -0,0 +1,5 @@ +import { GIPHGY } from "../components/chat-popup/chat-constants"; + +export const isMessageGif = (content: string) => { + return content.includes(GIPHGY); +}; diff --git a/src/common/features/chats/utils/upload-chat-keys.ts b/src/common/features/chats/utils/upload-chat-keys.ts new file mode 100644 index 00000000000..356fcefb528 --- /dev/null +++ b/src/common/features/chats/utils/upload-chat-keys.ts @@ -0,0 +1,19 @@ +import { getAccountFull } from "../../../api/hive"; +import { updateProfile } from "../../../api/operations"; +import { AccountData, createNoStrAccount } from "@ecency/ns-query"; + +export const uploadChatKeys = async ( + activeUser: AccountData, + { pub, priv, iv }: ReturnType & { iv: Buffer } +) => { + const response = await getAccountFull(activeUser?.name!); + + return await updateProfile(response, { + ...JSON.parse(response?.posting_json_metadata ? response.posting_json_metadata : "{}").profile, + echat: { + pubKey: pub, + iv: iv.toString("base64"), + key: priv + } + }); +}; diff --git a/src/common/features/ui/button/index.tsx b/src/common/features/ui/button/index.tsx index 32d00632ded..7f32c0ef23d 100644 --- a/src/common/features/ui/button/index.tsx +++ b/src/common/features/ui/button/index.tsx @@ -3,10 +3,11 @@ import { ButtonProps } from "./props"; import { classNameObject } from "../../../helper/class-name-object"; import { BUTTON_OUTLINE_STYLES, BUTTON_SIZES, BUTTON_STYLES } from "./styles"; import { useFilteredProps } from "../../../util/props-filter"; +import { Link, NavLinkProps } from "react-router-dom"; export * from "./props"; -export const Button = forwardRef( +export const Button = forwardRef( (props, ref) => { const nativeProps = useFilteredProps>(props, [ "appearance", @@ -23,7 +24,8 @@ export const Button = forwardRef + ) : "to" in props ? ( + + {children} + {icon} + ) : (