diff --git a/src/@type/index.d.ts b/src/@type/index.d.ts index 4c246bb7..95ce45ae 100644 --- a/src/@type/index.d.ts +++ b/src/@type/index.d.ts @@ -200,6 +200,7 @@ declare module ModalType { | "report" | "articleMenu" | "shareWith" + | "alarmUnfollowing" | null; interface ModalPositionProps { @@ -460,3 +461,39 @@ declare module EditType { type modalType = "image" | "gender" | null; } + +declare module AlarmType { + interface AlarmItem { + content: AlarmContent[]; + totalPages: number; + currentPage: number; + } + + type AlarmContent = PostAlarm | FollowAlarm; + + interface CommonAlarm { + id: number; + type: "COMMENT" | "LIKE_POST" | "MENTION_POST"; + message: string; + agent: { + id: number; + username: string; + name: string; + image: CommonType.ImageInfo; + hasStory: false; + }; + createdDate: string; + } + interface PostAlarm extends CommonAlarm { + postId: number; + postImageUrl: string; + content: string; + mentionsOfContent: string[]; + hashtagsOfContent: string[]; + } + + interface FollowAlarm extends CommonAlarm { + type: "FOLLOW"; + following: boolean; + } +} diff --git a/src/app/store/ducks/alarm/alarmSlice.ts b/src/app/store/ducks/alarm/alarmSlice.ts new file mode 100644 index 00000000..28272112 --- /dev/null +++ b/src/app/store/ducks/alarm/alarmSlice.ts @@ -0,0 +1,16 @@ +import { createSlice } from "@reduxjs/toolkit"; + +export interface AlarmStateProps {} + +const initialState: AlarmStateProps = {}; + +const alarmSlice = createSlice({ + name: " alarm", + initialState, + reducers: {}, + extraReducers: (build) => {}, +}); + +export const alarmAction = alarmSlice.actions; + +export const alarmReducer = alarmSlice.reducer; diff --git a/src/app/store/ducks/alarm/alarmThunk.ts b/src/app/store/ducks/alarm/alarmThunk.ts new file mode 100644 index 00000000..16526503 --- /dev/null +++ b/src/app/store/ducks/alarm/alarmThunk.ts @@ -0,0 +1,22 @@ +import { authorizedCustomAxios } from "customAxios"; +import { createAsyncThunk } from "@reduxjs/toolkit"; + +export const loadAlarmList = createAsyncThunk< + AlarmType.AlarmItem, + { page: number } +>("alarm/loadList", async (payload, ThunkOptions) => { + try { + const config = { + params: { + page: payload.page, + size: 10, + }, + }; + const { + data: { data }, + } = await authorizedCustomAxios.get(`/alarms`, config); + return { ...data, currentPage: payload.page }; + } catch (error) { + ThunkOptions.rejectWithValue(error); + } +}); diff --git a/src/app/store/ducks/modal/modalSlice.ts b/src/app/store/ducks/modal/modalSlice.ts index c1c00d34..06915152 100644 --- a/src/app/store/ducks/modal/modalSlice.ts +++ b/src/app/store/ducks/modal/modalSlice.ts @@ -16,6 +16,13 @@ const modalSlice = createSlice({ name: "modal", initialState, reducers: { + setModalUsernameAndImageUrl: ( + state, + action: PayloadAction<{ nickname: string; imageUrl: string }>, + ) => { + state.memberNickname = action.payload.nickname; + state.memberImageUrl = action.payload.imageUrl; + }, startModal: ( state, action: PayloadAction, diff --git a/src/app/store/store.ts b/src/app/store/store.ts index 1362fd4d..64a67297 100644 --- a/src/app/store/store.ts +++ b/src/app/store/store.ts @@ -1,3 +1,4 @@ +import { alarmReducer } from "./ducks/alarm/alarmSlice"; import { configureStore } from "@reduxjs/toolkit"; import { authReducer } from "app/store/ducks/auth/authSlice"; import { homeReducer } from "app/store/ducks/home/homeSlice"; @@ -18,6 +19,7 @@ export const store = configureStore({ profile: profileReducer, edit: editReducer, common: commonReducer, + alarm: alarmReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/src/components/Common/Header/NavItems.tsx b/src/components/Common/Header/NavItems.tsx index 6ae7cb72..3cf36fa6 100644 --- a/src/components/Common/Header/NavItems.tsx +++ b/src/components/Common/Header/NavItems.tsx @@ -8,20 +8,19 @@ import { ReactComponent as DirectActive } from "assets/Svgs/direct-active.svg"; import { ReactComponent as NewArticle } from "assets/Svgs/new-article.svg"; import { ReactComponent as NewArticleActive } from "assets/Svgs/new-article-active.svg"; -import { ReactComponent as Map } from "assets/Svgs/map.svg"; -import { ReactComponent as MapActive } from "assets/Svgs/map-active.svg"; - import { ReactComponent as Heart } from "assets/Svgs/heart.svg"; import { ReactComponent as HeartActive } from "assets/Svgs/heart-active.svg"; -import { NavLink, Link } from "react-router-dom"; +import { NavLink } from "react-router-dom"; import { useAppDispatch, useAppSelector } from "app/store/Hooks"; import { selectView } from "app/store/ducks/direct/DirectSlice"; import { uploadActions } from "app/store/ducks/upload/uploadSlice"; import Upload from "components/Common/Header/Upload"; import SubNav from "./SubNav"; + import { useRef, useState } from "react"; import useOutsideClick from "hooks/useOutsideClick"; +import Alarm from "components/Common/Header/alarm"; const Container = styled.div` flex: 1 0 0%; @@ -71,14 +70,22 @@ const AvatarWrapper = styled(NavItemWrapper)<{ isSubnavModalOn: boolean }>` const NavItems = () => { const [isSubnavModalOn, setIsSubnavMoalOn] = useState(false); + const [isAlarmOn, setIsAlarmOn] = useState(false); + const dispatch = useAppDispatch(); const isUploading = useAppSelector(({ upload }) => upload.isUploading); const userInfo = useAppSelector((state) => state.auth.userInfo); + // setting const navContainerRef = useRef(null); - const subModalControllerRef = useRef(null); + const subModalControllerRef = useRef(null); useOutsideClick(navContainerRef, setIsSubnavMoalOn, subModalControllerRef); + // alarm + const alarmContainerRef = useRef(null); + const alarmModalControllerRef = useRef(null); + useOutsideClick(alarmContainerRef, setIsAlarmOn, alarmModalControllerRef); + const navItems = [ { id: "홈", @@ -127,8 +134,19 @@ const NavItems = () => { { id: "피드 활동", path: "/", - component: , - activeComponent: , + component: ( + + setIsAlarmOn(!isAlarmOn)} /> + + ), + activeComponent: ( + + setIsAlarmOn(!isAlarmOn)} /> + {isAlarmOn && ( + + )} + + ), }, ]; @@ -144,6 +162,12 @@ const NavItems = () => { ? navItem.activeComponent : navItem.component} + ) : navItem.id === "피드 활동" ? ( +
+ {isAlarmOn + ? navItem.activeComponent + : navItem.component} +
) : ( {navItem.component} @@ -154,7 +178,6 @@ const NavItems = () => {
{ setIsSubnavMoalOn(!isSubnavModalOn); }} @@ -166,6 +189,7 @@ const NavItems = () => { data-testid="user-avatar" draggable="false" src={userInfo?.memberImageUrl} + ref={subModalControllerRef} />
{isSubnavModalOn && ( diff --git a/src/components/Common/Header/alarm/AlarmList.tsx b/src/components/Common/Header/alarm/AlarmList.tsx new file mode 100644 index 00000000..36d5ac70 --- /dev/null +++ b/src/components/Common/Header/alarm/AlarmList.tsx @@ -0,0 +1,49 @@ +import AlarmItem from "components/Common/Header/alarm/alarmType/AlarmItem"; +import FollowAlarm from "components/Common/Header/alarm/alarmType/FollowAlarm"; +import useOnView from "hooks/useOnView"; +import { useEffect, useRef } from "react"; +import styled from "styled-components"; + +const Container = styled.ul` + display: flex; + flex-direction: column; + + .alarm-item { + padding: 12px 16px; + } +`; + +export default function AlarmList({ + alarmList, + onLoadExtraAlarm, +}: { + alarmList: AlarmType.AlarmContent[]; + onLoadExtraAlarm: () => void; +}) { + const lastAlarmItemRef = useRef(null); + const isVisible = useOnView(lastAlarmItemRef); + + useEffect(() => { + isVisible && onLoadExtraAlarm(); + }, [isVisible]); + + return ( + + {alarmList.map((alarm, index) => ( +
  • + {alarm.type === "FOLLOW" ? ( + + ) : ( + + )} +
  • + ))} +
    + ); +} diff --git a/src/components/Common/Header/alarm/AlarmProfile.tsx b/src/components/Common/Header/alarm/AlarmProfile.tsx new file mode 100644 index 00000000..8f2be0c8 --- /dev/null +++ b/src/components/Common/Header/alarm/AlarmProfile.tsx @@ -0,0 +1,25 @@ +import { Link } from "react-router-dom"; +import styled from "styled-components"; + +const Container = styled.div` + img { + height: 44px; + width: 44px; + border-radius: 50%; + } +`; + +export default function AlarmProfile({ + agent, +}: Pick) { + return ( + + + {`${agent.username}님의 + + + ); +} diff --git a/src/components/Common/Header/alarm/alarmType/AlarmItem.tsx b/src/components/Common/Header/alarm/alarmType/AlarmItem.tsx new file mode 100644 index 00000000..5deeb6eb --- /dev/null +++ b/src/components/Common/Header/alarm/alarmType/AlarmItem.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import styled from "styled-components"; +import AlarmProfile from "components/Common/Header/alarm/AlarmProfile"; +import { Link } from "react-router-dom"; +import useGapText from "hooks/useGapText"; +import { removeRefer } from "components/Common/Header/alarm/utils"; +import StringFragmentWithMentionOrHashtagLink from "components/Common/StringFragmentWithMentionOrHashtagLink"; + +const Container = styled.div` + display: flex; + flex: 1; + + .alarm { + margin: 0 12px; + flex: 1; + + a { + text-decoration: none; + + .username { + font-weight: 700; + } + } + + .create-date { + color: ${(props) => props.theme.font.gray}; + } + } + + .relative-image { + display: flex; + flex-direction: column; + justify-content: center; + + img { + height: 40px; + width: 40px; + } + } +`; + +export default function AlarmItem({ alarm }: { alarm: AlarmType.PostAlarm }) { + const alarmMessage = removeRefer(alarm.message); + // 무한스크롤 + // 컴포넌트 언마운트 -> alarm창 닫도록 + + return ( + + +
    + + {alarm.agent.username} + + {alarmMessage} + {" "} + + {useGapText(alarm.createdDate)} + +
    +
    + + {"이미지 + +
    +
    + ); +} diff --git a/src/components/Common/Header/alarm/alarmType/FollowAlarm.tsx b/src/components/Common/Header/alarm/alarmType/FollowAlarm.tsx new file mode 100644 index 00000000..4b32fd54 --- /dev/null +++ b/src/components/Common/Header/alarm/alarmType/FollowAlarm.tsx @@ -0,0 +1,94 @@ +import { postFollow } from "app/store/ducks/home/homThunk"; +import { modalActions } from "app/store/ducks/modal/modalSlice"; +import { useAppDispatch } from "app/store/Hooks"; +import AlarmProfile from "components/Common/Header/alarm/AlarmProfile"; +import { removeRefer } from "components/Common/Header/alarm/utils"; +import useGapText from "hooks/useGapText"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import styled, { useTheme } from "styled-components"; +import Button from "styles/UI/Button"; + +const Container = styled.div` + display: flex; + flex: 1; + + .alarm { + margin: 0 12px; + flex: 1; + + a { + text-decoration: none; + + .username { + font-weight: 700; + } + } + + .create-date { + color: ${(props) => props.theme.font.gray}; + } + } +`; + +export default function FollowAlarm({ + alarm, +}: { + alarm: AlarmType.FollowAlarm; +}) { + const alarmMessage = removeRefer(alarm.message); + const dispatch = useAppDispatch(); + const theme = useTheme(); + const [isFollowing, setIsFollowing] = useState(alarm.following); + + const followHandler = () => { + dispatch(postFollow({ username: alarm.agent.username })) // + .then(() => { + setIsFollowing(!isFollowing); + }); + }; + + const showUnfollowingModalHandler = () => { + // 모달에 들어갈 유저 정보 세팅 + dispatch( + modalActions.setModalUsernameAndImageUrl({ + nickname: alarm.agent.username, + imageUrl: alarm.agent.image.imageUrl, + }), + ); + // 모달 켜기 + dispatch(modalActions.changeActivatedModal("alarmUnfollowing")); + }; + + return ( + + +
    + + {alarm.agent.username} + + {alarmMessage}{" "} + + {useGapText(alarm.createdDate)} + +
    + {isFollowing ? ( + + ) : ( + + )} +
    + ); +} diff --git a/src/components/Common/Header/alarm/index.tsx b/src/components/Common/Header/alarm/index.tsx new file mode 100644 index 00000000..fbb244df --- /dev/null +++ b/src/components/Common/Header/alarm/index.tsx @@ -0,0 +1,134 @@ +import { loadAlarmList } from "app/store/ducks/alarm/alarmThunk"; +import AlarmList from "components/Common/Header/alarm/AlarmList"; +import ImageSprite from "components/Common/ImageSprite"; +import Loading from "components/Common/Loading"; +import React, { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; +import sprite from "assets/Images/sprite.png"; +import { useAppDispatch } from "app/store/Hooks"; + +const Container = styled.div` + position: relative; + height: 1px; + + .pointer { + background-color: #fff; + border: 1px solid #fff; + height: 14px; + transform: rotate(45deg); + box-shadow: rgba(0, 0, 0, 0.098) 0px 0px 5px 1px; + width: 14px; + position: relative; + top: 5px; + left: 5px; + } + + .alarm-container { + display: flex; + flex-direction: column; + width: 485px; + z-index: 200; + position: absolute; + right: -26px; + top: 8px; + background-color: #fff; + box-shadow: rgba(0, 0, 0, 0.098) 0px 0px 5px 1px; + border-radius: 6px; + max-height: 440px; + min-height: 200px; + overflow: auto; + + .title { + font-weight: 700; + padding: 4px; + display: flex; + justify-content: flex-start; + margin: 8px 0 0 8px; + } + + .empty-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 50px; + font-size: 15px; + + span { + padding: 10px 0; + } + } + } +`; + +// TODO: 팔로우 모달 닫을 때, 다 닫힘 -> 해당 모달만 닫히게! (알람은 닫히면 안됨) +export default function Alarm({ + alarmContainerRef, +}: { + alarmContainerRef: React.RefObject; +}) { + const dispatch = useAppDispatch(); + const [pageToLoad, setPageToLoad] = useState(1); + const [alarmList, setAlarmList] = useState([]); + const [totalPage, setTotalPage] = useState(1); + + useEffect(() => { + dispatch(loadAlarmList({ page: pageToLoad })) + .unwrap() + .then((res) => { + setAlarmList([...res.content]); + setTotalPage(res.totalPages); + setPageToLoad(pageToLoad + 1); + }); + }, []); + + const loadExtraAlarmList = useCallback(() => { + if (pageToLoad <= totalPage) { + dispatch(loadAlarmList({ page: pageToLoad })) + .unwrap() + .then((res) => { + setAlarmList((currentList) => [ + ...currentList, + ...res.content, + ]); + setPageToLoad(pageToLoad + 1); + }); + } + }, [pageToLoad, totalPage, dispatch]); + + const emptyImage: CommonType.ImageProps = { + width: 96, + height: 96, + position: `0 -303px`, + url: sprite, + }; + + return ( + +
    +
    +
    +
    이전 활동
    +
    +
    + {!alarmList ? ( + + ) : alarmList.length === 0 ? ( +
    + + 게시물 활동 + + 다른 사람이 회원님이 게시물을 좋아하거나 댓글을 + 남기면 여기에 표시됩니다. + +
    + ) : ( + + )} +
    +
    + + ); +} diff --git a/src/components/Common/Header/alarm/utils.ts b/src/components/Common/Header/alarm/utils.ts new file mode 100644 index 00000000..e3a87170 --- /dev/null +++ b/src/components/Common/Header/alarm/utils.ts @@ -0,0 +1,3 @@ +export const removeRefer = (message: string) => { + return message.replace(/\{[a-z.]+\}/g, ""); +}; diff --git a/src/hooks/useOnView.ts b/src/hooks/useOnView.ts index e85aef92..bda5d69e 100644 --- a/src/hooks/useOnView.ts +++ b/src/hooks/useOnView.ts @@ -1,6 +1,6 @@ import { RefObject, useEffect, useMemo, useState } from "react"; -const useOnView = (ref: RefObject | null) => { +const useOnView = (ref: RefObject) => { const [isIntersecting, setIntersecting] = useState(false); const observer = useMemo( @@ -19,7 +19,7 @@ const useOnView = (ref: RefObject | null) => { return () => { observer.disconnect(); }; - }, [observer, ref]); + }, [observer, ref.current]); return isIntersecting; }; diff --git a/src/hooks/useOutsideClick.ts b/src/hooks/useOutsideClick.ts index a93b6298..db42cde2 100644 --- a/src/hooks/useOutsideClick.ts +++ b/src/hooks/useOutsideClick.ts @@ -8,6 +8,7 @@ const useOutsideClick = ( useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (trigger?.current.contains(event.target as Node)) { + // 정리: 클릭버튼을 클릭 시, 여기서 모달이 닫히도록 처리되지만, 컴포넌트에서도 클릭이벤트가 일어나(!false), 모달 안꺼짐 // event 발생시킨 요소가 trigger에 속한다면, 외부에 클릭한 거로 여기지 않음 // 즉, event는 외부클릭 | trigger 클릭으로 나눌 수 있음 // 여기서는 event 외부클릭을 처리하고, diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index be9e3524..0376c000 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -108,6 +108,18 @@ const Home = () => { onModalOff={() => dispatch(modalActions.resetModal())} /> )} + {activatedModal === "alarmUnfollowing" && ( + + dispatch( + modalActions.maintainModalon("alarmUnfollowing"), + ) + } + onModalOff={() => dispatch(modalActions.resetModal())} + /> + )} ); };