# 앱에 새 소식 보여 주기

팀이 피드백 보드에 변경 로그 항목을 게시하면, 사용자가 이미 머무는 곳인 앱에서 그 소식을 접해야 합니다. "What's new" 배지는 사용자가 아직 보지 않은 항목 수를 세고, 배지를 열면 최신 항목이 나오면서 숫자가 0으로 돌아갑니다.

![읽지 않은 항목 3개를 세는 배지가 달린 "What's new" 버튼](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/33988e9a-1df0-4df7-978c-d9e8d2e1c318/whats-new-badge.png)

## 1. 시작하기 전에

- 변경 로그 항목이 게시된 피드백 보드가 필요합니다([공개 피드백 보드 열기](https://www.mocco.club/docs/ko/articles/zp45zj-public-board) 참고). 보드는 공개여야 하며, 앱은 `ideas` 같은 슬러그로 보드를 읽습니다.
- 프로젝트의 **publishable 키**(**Settings → API keys**)가 필요하고, `feedback:read` 권한이 있어야 합니다. 사용자가 로그인하는 앱이라면 `feedback:write`도 필요합니다. 로그인한 사용자의 항목을 읽음으로 표시하는 것은 쓰기 작업이기 때문입니다. publishable 키는 앱에 넣어 배포해도 안전합니다.
- 웹 앱이라면 그 주소를 프로젝트의 웹 앱 **web origin**(**Settings → Apps**)으로 등록해야 합니다. 예: `https://app.acme.com`. 브라우저는 다른 페이지에 대한 API 응답을 거부합니다.

## 2. 로그인한 사용자와 방문자

배지는 모든 사용자에게 동작합니다. "읽음"의 의미는 앱이 Mocco에 사용자가 누구인지 알려 주는지에 따라 달라집니다.

- **로그인한 사용자.** 서버가 로그인한 사용자를 위해 수명이 짧은 토큰에 서명합니다. [공개 보드](https://www.mocco.club/docs/ko/articles/zp45zj-public-board#3-sign-your-users-in-from-your-app)에서 쓰는 것과 같은 토큰으로, 프로젝트의 identity secret으로 서명한 HS256 JWT입니다. 사용자 ID를 `sub`에 넣고, 만료는 최대 한 시간 뒤로 합니다. 토큰을 가져오는 함수를 SDK에 넘겨 주세요. 그러면 Mocco가 사용자의 "마지막으로 본 시점"을 직접 보관하므로, 휴대폰에서 변경 로그를 열면 웹의 배지도 사라집니다.
- **방문자.** 토큰이 없으면 기기가 자신이 보여 준 가장 최신 항목의 날짜를 보관합니다. 웹에서는 localStorage에, React Native에서는 직접 넘긴 스토리지에 저장합니다. 새 기기를 쓰거나 브라우저 데이터를 지우면 모든 항목이 다시 새 항목으로 계산됩니다.

SDK는 토큰이 처음 필요할 때 함수를 호출하고, Mocco가 만료를 이유로 토큰을 거부할 때만 다시 호출합니다. 아무도 로그인하지 않은 동안에는 `null`을 반환하세요. 사용자가 로그아웃하거나 다른 사용자가 로그인하면 `whatsNew.feedback.resetEndUser()`를 호출합니다.

## 3. 웹에서

브라우저 SDK를 설치합니다.

```bash
npm install @mocco/js
```

페이지의 버튼에 위젯을 마운트합니다. 버튼에는 읽지 않은 수가 배지로 붙고, 버튼을 누르면 작은 팝오버에 최신 항목이 열립니다. 열면 항목이 읽음으로 표시됩니다.

```ts
import { createWhatsNew } from '@mocco/js/whats-new';

const whatsNew = createWhatsNew({
  publishableKey: 'mk_pub_…',
  board: 'ideas',
  // Leave out for visitors only. Return null while nobody is signed in.
  endUserToken: async () => {
    const response = await fetch('/api/mocco-token');
    return response.ok ? ((await response.json()) as { token: string }).token : null;
  },
});

whatsNew.mount('#whats-new', {
  // Optional: a link under the list, such as the board's public changelog.
  moreUrl: 'https://acme.help.mocco.club/feedback/ideas/changelog',
});
```

![최신 항목 3개와 날짜, 태그, 짧은 발췌문이 보이는 팝오버](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/d59d50c2-d07d-4a7c-93db-33a05d76aa42/whats-new-widget.png)

팝오버는 각 항목의 제목, 날짜, 태그, 본문 첫 몇 줄을 일반 텍스트로 보여 줍니다. 서식, 링크, 이미지는 빠지므로 항목 안의 내용이 페이지에서 코드로 실행되는 일은 없습니다. Escape를 누르거나 팝오버 바깥을 클릭하면 닫힙니다.

`mount`의 옵션은 다음과 같습니다.

| 옵션 | 동작 |
|---|---|
| `labels` | 위젯에 쓰는 문구: `title`, `loading`, `empty`, `error`, `more`, `unread`(숫자 뒤에 스크린 리더가 읽음) |
| `moreUrl` | 목록 아래에 표시하는 링크 |
| `markSeenOnOpen` | `false`로 두면 `whatsNew.markSeen()`으로 직접 읽음 처리 |
| `formatDate` | 항목 날짜를 표시하는 방식(기본값은 브라우저 언어이며, 예를 들어 "Oct 17, 2026") |
| `onOpenChange` | 팝오버가 열리거나 닫힐 때 `true` 또는 `false`와 함께 호출됨. 분석 용도 등에 사용 |

`mount`는 배지와 팝오버를 다시 제거하는 함수를 반환합니다. 색을 바꾸려면 페이지에 `--mocco-wn-accent`(배지), `--mocco-wn-bg`, `--mocco-wn-fg`, `--mocco-wn-border`(팝오버)를 설정합니다. 팝오버는 기본적으로 시스템의 다크 모드를 따릅니다.

위젯 없이 직접 그리려면 `await whatsNew.unreadCount()`가 개수를 반환하고, `whatsNew.markSeen()`이 개수를 지우며, `whatsNew.client`에 항목이 있습니다(`loadEntries()`, `getState()`, `subscribe(listener)`).

위젯과 필요한 클라이언트는 번들된 앱에서 gzip 기준 약 5 KB이고, Mocco의 검사는 25 KB 미만을 유지합니다.

## 4. React Native에서

React Native SDK를 설치합니다(Expo Go에서 실행됩니다).

```bash
npx expo install @mocco/react-native @react-native-async-storage/async-storage
```

앱에서 `WhatsNew`를 컴포넌트 바깥에 하나만 만듭니다. AsyncStorage를 함께 쓰면 방문자의 "마지막으로 본 시점"이 앱을 다시 시작해도 유지됩니다.

```ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createWhatsNew } from '@mocco/react-native/whats-new';

export const whatsNew = createWhatsNew({
  publishableKey: 'mk_pub_…',
  board: 'ideas',
  storage: AsyncStorage,
  endUserToken: async () => await fetchMoccoTokenFromYourServer(),
});
```

배지는 메뉴 항목이나 탭 옆에 둡니다. 새 소식이 없으면 아무것도 그리지 않고, 앱이 포그라운드로 돌아오면 새로고침됩니다.

```tsx
import { WhatsNewBadge } from '@mocco/react-native/whats-new';

<WhatsNewBadge whatsNew={whatsNew} style={{ backgroundColor: '#4f46e5' }} />;
```

항목을 나열하는 화면에서는 `useWhatsNew`가 데이터를 주고, 목록은 앱의 디자인과 언어로 직접 그립니다.

```tsx
import { useEffect } from 'react';
import { FlatList, Text } from 'react-native';
import { useWhatsNew } from '@mocco/react-native/whats-new';

export function WhatsNewScreen() {
  const { entries, markSeen } = useWhatsNew(whatsNew, { loadEntries: true });
  useEffect(() => {
    void markSeen().catch(() => undefined);
  }, []);
  return <FlatList data={entries} keyExtractor={entry => entry.id} renderItem={({ item }) => <Text>{item.title}</Text>} />;
}
```

항목의 `body`는 Markdown입니다. HTML을 실행하지 않는 Markdown 컴포넌트로 렌더링하거나, 제목과 날짜만 보여 주세요.

## 5. 게시물 읽기와 투표

같은 키로 보드의 게시물을 읽을 수 있고, 로그인한 사용자는 투표할 수 있습니다. 웹에서는 `@mocco/js/feedback`을, React Native에서는 `@mocco/react-native/whats-new`의 `FeedbackClient`를 사용합니다.

```ts
import { createFeedback } from '@mocco/js/feedback';

const feedback = createFeedback({ publishableKey: 'mk_pub_…', endUserToken: fetchMoccoToken });
const board = feedback.board('ideas');

const { posts } = await board.posts({ sort: 'top', limit: 20 });
await feedback.vote(posts[0].id); // voting again changes nothing; feedback.unvote(id) takes it back
const { viewer } = await feedback.post(posts[0].id); // viewer.vote: 'counted' once they voted
```

로그인한 사용자 없이 투표하면 아무것도 전송되기 전에 `MoccoError`로 거부되며, 이 오류의 `code`는 `missing_end_user_token`입니다. 사용자가 앱에서 아이디어를 올리고, 댓글을 달고, 팔로우하게 하거나 아이디어 시트를 바로 열려면 [앱에서 아이디어 모으기](https://www.mocco.club/docs/ko/articles/5z9xeg-in-app-ideas)를 참고하세요.

## 6. 한도

로그인한 사용자는 한 시간에 120번까지 변경 로그를 읽음으로 표시할 수 있습니다. 자주 여는 위젯도 이 한도에 한참 못 미칩니다. 읽지 않은 수는 100에서 멈춥니다.
