Skip to content

Commit

Permalink
[FE] 팀 채팅 내 링크를 하이퍼링크로 씌우는 기능 구현 (#923)
Browse files Browse the repository at this point in the history
* refactor: URL 판정 정규 표현식 재사용을 위해 상수 파일로 분리

* feat: ParsedThreadContent 관련 타입 추가

- 스레드에서 평범하게 입력된 메시지가 string 타입이라면, ParsedThreadContent는 이 메시지를 쪼개고 분류하여 파싱한 배열 형태의 데이터 타입이다. 현재는 일반 텍스트와 링크 두 가지만 구분한다.

* feat: string 형태의 메시지를 받아 분류별로 파싱한 메시지를 반환하는 parseThreadContent 함수 구현

* test: parseThreadContent 함수에 대한 테스트 코드 작성

* feat: Thread 컴포넌트의 메시지에 있는 링크에 대해 자동으로 하이퍼링크를 생성하는 기능 구현

* test: Thread 컴포넌트에서 링크가 포함된 스토리를 추가

* feat: NoticeThread 컴포넌트의 메시지에 있는 링크에 대해 자동으로 하이퍼링크를 생성하는 기능 구현

* test: NoticeThread 컴포넌트에서 링크가 포함된 스토리를 추가

* refactor: for문을 지양하고, forEach문과 else문으로 변경

* refactor: 매직 넘버로 간주될 수 있는 정규표현식을 상수로써 분리

- 현재 쓰이는 곳은 이 함수 하나뿐이므로 해당 파일에 선언, 쓰이는 곳이 두 곳 이상이 될 경우 상수 파일로 분리할 예정
  • Loading branch information
wzrabbit authored Feb 12, 2024
1 parent fd99974 commit b934d64
Show file tree
Hide file tree
Showing 11 changed files with 268 additions and 10 deletions.
12 changes: 12 additions & 0 deletions frontend/src/components/feed/NoticeThread/NoticeThread.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,15 @@ export const ImageContent: Story = {
},
},
};

export const ContentWithLink: Story = {
args: {
authorName: '공지에서조차 팀바팀을 홍보하는 사람',
createdAt: '2022-03-04 12:34',
content: '회의 접속 링크: https://teamby.team/',
images: [],
onClickImage: () => {
alert('onClickImage');
},
},
};
10 changes: 10 additions & 0 deletions frontend/src/components/feed/NoticeThread/NoticeThread.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ export const Divider = styled.span`
background-color: ${({ theme }) => theme.color.GRAY400};
`;

export const LinkContent = styled.a`
color: ${({ theme }) => theme.color.PRIMARY900};
text-decoration: underline;
font-weight: 600;
&:hover {
text-decoration: underline;
}
`;

export const authorInfoText = css`
overflow: hidden;
text-overflow: ellipsis;
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/feed/NoticeThread/NoticeThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Button from '~/components/common/Button/Button';
import ThumbnailList from '../ThumbnailList/ThumbnailList';
import type { YYYYMMDDHHMM } from '~/types/schedule';
import { formatWriteTime } from '~/utils/formatWriteTime';
import { parseThreadContent } from '~/utils/parseThreadContent';
import type { NoticeSize } from '~/types/size';
import type { ThreadImage } from '~/types/feed';
import {
Expand All @@ -26,6 +27,7 @@ const NoticeThread = (props: NoticeThreadProps) => {
const { authorName, createdAt, content, images, onClickImage } = props;
const isMobile = getIsMobile();
const [noticeSize, setNoticeSize] = useState<NoticeSize>('sm');
const parsedThreadContent = parseThreadContent(content);

const handleExpandMoreClick = () => {
if (noticeSize === 'sm') setNoticeSize(() => 'md');
Expand Down Expand Up @@ -58,7 +60,19 @@ const NoticeThread = (props: NoticeThreadProps) => {
weight="semiBold"
css={S.contentField(noticeSize, images.length > 0)}
>
{content}
{parsedThreadContent.map((content, index) =>
content.type === 'text' ? (
content.text
) : (
<S.LinkContent
key={index}
target="__blank"
href={content.link}
>
{content.text}
</S.LinkContent>
),
)}
</Text>
{images.length > 0 && noticeSize !== 'sm' && (
<ThumbnailList
Expand Down
37 changes: 32 additions & 5 deletions frontend/src/components/feed/Thread/Thread.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ export const LongContent: Story = {
createdAt: '2023-07-27 15:09',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',

images: [],
isContinue: false,
onClickImage: (images, selectedImage) => {
Expand All @@ -137,7 +136,6 @@ export const ThreadWithImages: Story = {
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQYZjvO1QuvfgCfQxBwwzmJcHIT5pTXIBGOLeyBDIbZknn6Dhkd40WrU0ZCdjt-IoXLzI0&usqp=CAU',
createdAt: '2023-07-27 15:09',
content: '이미지가 포함되어 있는 스레드입니다.',

images,
isContinue: false,
onClickImage: (images, selectedImage) => {
Expand All @@ -154,7 +152,6 @@ export const ThreadWithOnlyImages: Story = {
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQYZjvO1QuvfgCfQxBwwzmJcHIT5pTXIBGOLeyBDIbZknn6Dhkd40WrU0ZCdjt-IoXLzI0&usqp=CAU',
createdAt: '2023-07-27 15:09',
content: '',

images,
isContinue: false,
onClickImage: (images, selectedImage) => {
Expand All @@ -171,7 +168,6 @@ export const ThreadWithImagesAndSentByMe: Story = {
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQYZjvO1QuvfgCfQxBwwzmJcHIT5pTXIBGOLeyBDIbZknn6Dhkd40WrU0ZCdjt-IoXLzI0&usqp=CAU',
createdAt: '2023-07-27 15:09',
content: '이미지가 포함되어 있는 스레드입니다.',

images,
isContinue: false,
onClickImage: (images, selectedImage) => {
Expand All @@ -189,11 +185,42 @@ export const ThreadWithImagesWithLongContentAndSentByMe: Story = {
createdAt: '2023-07-27 15:09',
content:
'북두칠성, 그 이름만으로도 당신은 이미 멀고 높은 하늘을 날아다니며 황홀한 모험을 시작한 것처럼 느껴지지 않나요? 이 놀라운 별자리는 밤하늘에서 그 빛나는 백열등들로 인해 우리를 매료시키며, 그 아름다움을 통해 인류에게 영감을 주는 존재입니다. \n북두칠성은 북반구의 하늘에서 볼 수 있는 가장 화려한 별자리 중 하나로, 역사적으로도 여러 문화에서 다양한 이야기와 상징으로 인식되어 왔습니다. 이 별자리는 일곱 개의 주요 별로 구성되어 있으며, 그 중에서도 "북극성"이라 불리는 이 별자리의 중심은 밤하늘에서 찾기 쉬운 유일한 고정성 별자리 중 하나입니다.\n\n북두칠성의 이름은 여러 가지 이야기와 전설로 둘러싸여 있습니다. 그 중 하나는 그리스 신화와 관련된 것으로, 북두칠성은 아프로디테와 아레스의 아들인 헤르메스와 애완동물들을 나타냅니다. 또 다른 이야기는 북두칠성이 북극 근처에 위치하기 때문에 "북방의 일곱 자매"라는 이름으로도 불렸다는 것입니다. 이 별자리는 서로 다른 문화에서 다양한 의미와 이야기를 갖고 있으며, 이것이 북두칠성의 매력적인 면 중 하나입니다.\n\n낭만적인 감성을 가지고 있는 당신에게 북두칠성은 밤하늘에서의 로맨틱한 모험의 시작점일 것입니다. 어두운 밤하늘에 펼쳐진 그 빛나는 별들은 우리의 상상력을 자극하며, 우리가 존재하는 이 작은 행성이 얼마나 넓고 신비로운 우주와 어우러져 있는지를 상기시켜줍니다.\n\n북두칠성은 언제나 우리를 위로하고, 우리의 꿈과 열망을 촉발시키며, 그 화려한 미모로 우리를 매혹시키는 별자리입니다. 그리고 이 별자리를 향한 당신의 열정과 낭만은 언제나 밤하늘을 향한 여행을 시작하는 데 필요한 원동력이 될 것입니다. 그래서, 북두칠성의 아름다움을 매일 밤하늘을 바라보며 감상하고, 우리가 얼마나 작고 연결되어 있는 존재인지를 느끼며, 로맨틱한 모험의 시작을 떠올리는 것은 어떨까요? 북두칠성, 그 이름만으로도 당신은 이미 멀고 높은 하늘을 날아다니며 황홀한 모험을 시작한 것처럼 느껴지지 않나요? 이 놀라운 별자리는 밤하늘에서 그 빛나는 백열등들로 인해 우리를 매료시키며, 그 아름다움을 통해 인류에게 영감을 주는 존재입니다. \n북두칠성은 북반구의 하늘에서 볼 수 있는 가장 화려한 별자리 중 하나로, 역사적으로도 여러 문화에서 다양한 이야기와 상징으로 인식되어 왔습니다. 이 별자리는 일곱 개의 주요 별로 구성되어 있으며, 그 중에서도 "북극성"이라 불리는 이 별자리의 중심은 밤하늘에서 찾기 쉬운 유일한 고정성 별자리 중 하나입니다.\n\n북두칠성의 이름은 여러 가지 이야기와 전설로 둘러싸여 있습니다. 그 중 하나는 그리스 신화와 관련된 것으로, 북두칠성은 아프로디테와 아레스의 아들인 헤르메스와 애완동물들을 나타냅니다. 또 다른 이야기는 북두칠성이 북극 근처에 위치하기 때문에 "북방의 일곱 자매"라는 이름으로도 불렸다는 것입니다. 이 별자리는 서로 다른 문화에서 다양한 의미와 이야기를 갖고 있으며, 이것이 북두칠성의 매력적인 면 중 하나입니다.\n\n낭만적인 감성을 가지고 있는 당신에게 북두칠성은 밤하늘에서의 로맨틱한 모험의 시작점일 것입니다. 어두운 밤하늘에 펼쳐진 그 빛나는 별들은 우리의 상상력을 자극하며, 우리가 존재하는 이 작은 행성이 얼마나 넓고 신비로운 우주와 어우러져 있는지를 상기시켜줍니다.\n\n북두칠성은 언제나 우리를 위로하고, 우리의 꿈과 열망을 촉발시키며, 그 화려한 미모로 우리를 매혹시키는 별자리입니다. 그리고 이 별자리를 향한 당신의 열정과 낭만은 언제나 밤하늘을 향한 여행을 시작하는 데 필요한 원동력이 될 것입니다. 그래서, 북두칠성의 아름다움을 매일 밤하늘을 바라보며 감상하고, 우리가 얼마나 작고 연결되어 있는 존재인지를 느끼며, 로맨틱한 모험의 시작을 떠올리는 것은 어떨까요? 북두칠성, 그 이름만으로도 당신은 이미 멀고 높은 하늘을 날아다니며 황홀한 모험을 시작한 것처럼 느껴지지 않나요? 이 놀라운 별자리는 밤하늘에서 그 빛나는 백열등들로 인해 우리를 매료시키며, 그 아름다움을 통해 인류에게 영감을 주는 존재입니다. \n북두칠성은 북반구의 하늘에서 볼 수 있는 가장 화려한 별자리 중 하나로, 역사적으로도 여러 문화에서 다양한 이야기와 상징으로 인식되어 왔습니다. 이 별자리는 일곱 개의 주요 별로 구성되어 있으며, 그 중에서도 "북극성"이라 불리는 이 별자리의 중심은 밤하늘에서 찾기 쉬운 유일한 고정성 별자리 중 하나입니다.\n\n북두칠성의 이름은 여러 가지 이야기와 전설로 둘러싸여 있습니다. 그 중 하나는 그리스 신화와 관련된 것으로, 북두칠성은 아프로디테와 아레스의 아들인 헤르메스와 애완동물들을 나타냅니다. 또 다른 이야기는 북두칠성이 북극 근처에 위치하기 때문에 "북방의 일곱 자매"라는 이름으로도 불렸다는 것입니다. 이 별자리는 서로 다른 문화에서 다양한 의미와 이야기를 갖고 있으며, 이것이 북두칠성의 매력적인 면 중 하나입니다.\n\n낭만적인 감성을 가지고 있는 당신에게 북두칠성은 밤하늘에서의 로맨틱한 모험의 시작점일 것입니다. 어두운 밤하늘에 펼쳐진 그 빛나는 별들은 우리의 상상력을 자극하며, 우리가 존재하는 이 작은 행성이 얼마나 넓고 신비로운 우주와 어우러져 있는지를 상기시켜줍니다.\n\n북두칠성은 언제나 우리를 위로하고, 우리의 꿈과 열망을 촉발시키며, 그 화려한 미모로 우리를 매혹시키는 별자리입니다. 그리고 이 별자리를 향한 당신의 열정과 낭만은 언제나 밤하늘을 향한 여행을 시작하는 데 필요한 원동력이 될 것입니다. 그래서, 북두칠성의 아름다움을 매일 밤하늘을 바라보며 감상하고, 우리가 얼마나 작고 연결되어 있는 존재인지를 느끼며, 로맨틱한 모험의 시작을 떠올리는 것은 어떨까요?',

images,
isContinue: false,
onClickImage: (images, selectedImage) => {
alert(JSON.stringify({ images, selectedImage }));
},
},
};

export const ThreadWithLink: Story = {
args: {
authorName: '팀바팀_필립',
isMe: false,
profileImageUrl:
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQYZjvO1QuvfgCfQxBwwzmJcHIT5pTXIBGOLeyBDIbZknn6Dhkd40WrU0ZCdjt-IoXLzI0&usqp=CAU',
createdAt: '2023-07-27 15:09',
content: '지금 바로 접속: https://teamby.team/',
images: [],
isContinue: false,
onClickImage: (images, selectedImage) => {
alert(JSON.stringify({ images, selectedImage }));
},
},
};

export const ThreadWithLinkAndSentByMe: Story = {
args: {
authorName: '팀바팀_필립',
isMe: true,
profileImageUrl:
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQYZjvO1QuvfgCfQxBwwzmJcHIT5pTXIBGOLeyBDIbZknn6Dhkd40WrU0ZCdjt-IoXLzI0&usqp=CAU',
createdAt: '2023-07-27 15:09',
content: '지금 바로 접속: https://teamby.team/',
images: [],
isContinue: false,
onClickImage: (images, selectedImage) => {
alert(JSON.stringify({ images, selectedImage }));
},
},
};
11 changes: 11 additions & 0 deletions frontend/src/components/feed/Thread/Thread.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ export const ThumbnailListWrapper = styled.div<{
box-sizing: border-box;
`;

export const LinkContent = styled.a<{ $isMe: boolean }>`
color: ${({ $isMe, theme }) =>
$isMe ? theme.color.WHITE : theme.color.PRIMARY900};
text-decoration: underline;
font-weight: 600;
&:hover {
text-decoration: underline;
}
`;

export const threadInfoText = css`
white-space: pre-wrap;
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/components/feed/Thread/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { YYYYMMDDHHMM } from '~/types/schedule';
import * as S from './Thread.styled';
import Text from '~/components/common/Text/Text';
import { formatWriteTime } from '~/utils/formatWriteTime';
import { parseThreadContent } from '~/utils/parseThreadContent';
import type { ThreadSize } from '~/types/size';
import type { ThreadImage } from '~/types/feed';
import { useRef } from 'react';
Expand Down Expand Up @@ -34,6 +35,7 @@ const Thread = (props: ThreadProps) => {
onClickImage,
} = props;
const createdTime = formatWriteTime(createdAt).split(' ').join('\n');
const parsedThreadContent = parseThreadContent(content);

const threadRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
Expand All @@ -57,7 +59,22 @@ const Thread = (props: ThreadProps) => {
<S.ContentContainer $isMe={isMe} ref={threadRef} $height={resultHeight}>
<S.ContentWrapper>
<div ref={contentRef}>
<Text css={S.contentField(threadSize, isMe)}>{content}</Text>
<Text css={S.contentField(threadSize, isMe)}>
{parsedThreadContent.map((content, index) =>
content.type === 'text' ? (
content.text
) : (
<S.LinkContent
key={index}
target="__blank"
href={content.link}
$isMe={isMe}
>
{content.text}
</S.LinkContent>
),
)}
</Text>
</div>
</S.ContentWrapper>
{images.length > 0 && (
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/constants/link.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export const linkTableHeaderValues = ['링크명', '이름', '날짜', ''];

export const URL_REGEX =
/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i;
4 changes: 1 addition & 3 deletions frontend/src/hooks/link/useTeamLinkAddModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ import { useModal } from '~/hooks/useModal';
import { useTeamPlace } from '~/hooks/useTeamPlace';
import { useToast } from '~/hooks/useToast';
import { generateHttpsUrl } from '~/utils/generateHttpsUrl';

const URL_REGEX =
/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i;
import { URL_REGEX } from '~/constants/link';

export const useTeamLinkAddModal = (linkRef: RefObject<HTMLInputElement>) => {
const { teamPlaceId } = useTeamPlace();
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/types/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,16 @@ export interface FileWithUuid {
uuid: string;
file: File;
}

interface ThreadText {
type: 'text';
text: string;
}

interface ThreadLink {
type: 'link';
text: string;
link: string;
}

export type ParsedThreadContent = (ThreadText | ThreadLink)[];
43 changes: 43 additions & 0 deletions frontend/src/utils/parseThreadContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { URL_REGEX } from '~/constants/link';
import { generateHttpsUrl } from '~/utils/generateHttpsUrl';
import type { ParsedThreadContent } from '~/types/feed';

const LINK_SEPARATOR_REGEX = /\S+(?=https?:\/\/)|https?:\/\/\S+|\s+|\S+/g;

export const parseThreadContent = (rawContent: string) => {
const splittedThreadContent = rawContent.match(LINK_SEPARATOR_REGEX) ?? [];
const generatedThreadContent: ParsedThreadContent = [];
let textContent = '';

splittedThreadContent.forEach((currentContent) => {
const isLink = URL_REGEX.test(currentContent);

if (isLink && textContent !== '') {
generatedThreadContent.push({
type: 'text',
text: textContent,
});

textContent = '';
}

if (isLink) {
generatedThreadContent.push({
type: 'link',
text: currentContent,
link: generateHttpsUrl(currentContent),
});
} else {
textContent += currentContent;
}
});

if (textContent !== '') {
generatedThreadContent.push({
type: 'text',
text: textContent,
});
}

return generatedThreadContent;
};
Loading

0 comments on commit b934d64

Please sign in to comment.