# 스킨 UI 확장하기

> 기본 스킨에 메뉴 버튼을 더하고, 대화상자로 입력을 받고, 대화 위에 영역을 붙이고, 해제 함수로 정리하는 흐름을 하나의 예제로 따라 해요.

스킨 UI API(`room.skin`)로 기본 스킨(`basic-v2`) 안에 버튼·대화상자·영역을 더할 수 있어요. 이 문서는 "관리자가 버튼을 눌러 대화 위에 배너를 띄우는" 기능을 만들며 흐름을 설명해요. 함수와 옵션 전체는 [스킨 UI API](/doc/3.4)에 있어요.

## 원칙 {#principles}

- 스킨이 자리·순서·겹침을 정하고, 내 코드는 스킨이 건네 준 빈 영역(`host`) 안만 다뤄요. 스킨 내부 DOM을 찾아 고치지 마세요.
- 등록할 때 주는 `id`는 종류마다 겹치면 안 돼요. 겹치면 오류가 나요.
- 등록 함수는 **해제 함수**를 돌려줘요. 해제 함수는 여러 번 불러도 안전해요.
- 이 API는 기본 스킨에만 있어요. 레거시 스킨(`basic`)은 [레거시 스킨 API](/doc/3.8)를 써요.

## 1. 준비 시점 잡기 {#ready}

스킨 UI API는 채팅방 화면이 준비된 뒤에 쓸 수 있으므로 `after.create` 안에서 등록해요. 미리 등록 코드는 [비동기 로딩과 여러 채팅방](/doc/2.2)을 보세요.

```js
U.chat('my-room').on('after.create', function (room) {
	var ui = room.skin;
	if (!ui.menu) return; // 기본 스킨이 아니면 그만둠
	// 여기서 등록
});
```

## 2. 메뉴 버튼 더하기 {#menu}

`ui.menu.add`는 입력창 도구 모음에 버튼을 더해요. `condition`이 `false`를 돌려주면 버튼이 숨겨져요.

```js
var removeMenu = ui.menu.add({
	id: 'my-banner',
	label: '배너',
	title: '대화 위에 배너 띄우기',
	condition: function (ctx) { return ctx.room.my.auth >= 2; }, // 부관리자 이상만
	onSelect: function (ctx) { askBanner(); }
});
```

닉네임을 눌렀을 때 나오는 메뉴에는 `ui.userMenu.add`로 같은 모양의 항목을 더해요. 이때 `ctx.target`이 누른 사람의 사용자 객체예요. 메뉴를 숨겨도 권한 검사는 서버가 하므로, 숨기기는 화면 정리용으로만 생각하세요.

## 3. 대화상자로 입력 받기 {#dialog}

`ui.dialog.*`는 `open`을 빼고 모두 Promise를 돌려줘요. `prompt`는 확인하면 입력한 문자열, 취소하면 `null`을 줘요.

```js
function askBanner() {
	ui.dialog.prompt('배너', '띄울 문구를 적어 주세요.').then(function (text) {
		if (text) showBanner(text);
	});
}
```

`alert`, `confirm`(취소하면 `false`), `password`, `select`도 같은 방식이에요. 도중에 닫아야 하면 `ui.dialog.open`으로 `close`가 있는 손잡이를 받아요. → [스킨 UI API](/doc/3.4)

## 4. 영역 붙이기 {#mount}

`ui.mount.*`에 `mount(host, ctx)` 함수를 주면 스킨이 그 자리에 빈 영역을 만들고 `host`로 건네 줘요. `mount`가 함수를 돌려주면 영역이 없어질 때 한 번 불려요.

```js
var removeBanner = null;

function showBanner(text) {
	if (removeBanner) removeBanner(); // 이전 배너 정리
	removeBanner = ui.mount.messageList({
		id: 'my-banner-area',
		position: 'before', // 대화 영역 위
		mount: function (host, ctx) {
			var doc = host.ownerDocument;
			var box = doc.createElement('div');
			box.style.padding = '8px 12px';
			box.textContent = text;
			var close = doc.createElement('button');
			close.type = 'button';
			close.textContent = '닫기';
			close.addEventListener('click', function () { ctx.close(); });
			box.appendChild(close);
			host.appendChild(box);
			return function () { host.replaceChildren(); };
		}
	});
}
```

- `host`는 채팅방 iframe 안의 요소예요. 새 요소는 `host.ownerDocument`로 만들어요.
- 글자는 `textContent`로 넣어요. 사용자 입력을 `innerHTML`로 넣지 마세요.
- `ctx.close()`는 이 등록을 해제해요. `ctx.signal`은 정리 직전에 abort되므로 `fetch`나 이벤트 리스너 정리에 쓸 수 있어요.

`messageList` 외에 `header`, `middle`, `userList`, `footer`, `wrap`, `composer`, `headerTitle`, `toolbar` 자리와, 레이아웃을 밀지 않고 겹치는 `overlay`가 있어요. 자리별 `position` 값과 `overlay`의 `layer`·`scope`는 [스킨 UI API](/doc/3.4)를 보세요.

## 5. 해제 함수로 정리 {#cleanup}

등록한 것은 돌려받은 해제 함수로 지워요. 채팅방이 닫히거나 다시 만들어지면 스킨이 등록을 모두 정리하므로, 직접 정리할 일은 기능을 끄거나 바꿀 때예요.

```js
function disableBanner() {
	if (removeBanner) removeBanner();
	removeMenu();
}
```

이미 해제된 것을 다시 해제해도 오류가 나지 않아요. 같은 `id`로 다시 등록하려면 먼저 해제해야 해요.

`room.reload()`나 태그 재삽입으로 채팅방이 다시 만들어지면 채팅방 아이디로 건 이벤트도 함께 지워져 `after.create`가 다시 오지 않아요. 다시 만들어질 때도 등록되게 하려면 `U.chat('*')`로 걸고 `room.id`를 확인하세요. → [비동기 로딩과 여러 채팅방](/doc/2.2)

## 콜백은 동기 함수로 {#sync-callbacks}

메뉴의 `condition`, `describe`, resolver의 `resolve`, `message.extend`의 `match`는 스킨이 화면을 그리는 도중에 불러요. 이 콜백들은 **바로 값을 돌려주는 동기 함수**여야 해요.

- `condition`, `describe`, `resolve`, `match`가 Promise를 돌려주면 오류로 처리되어 그 결과가 무시돼요(메뉴 항목이면 보이지 않아요). `async function`으로 쓰지 마세요.
- 서버에서 값을 받아야 하면 미리 받아 변수에 두고, 값이 바뀌면 `ui.menu.resolver.refresh()`처럼 다시 계산을 요청해요.
- `onSelect`의 반환값은 쓰지 않아요. 버튼을 누른 뒤의 비동기 작업은 `onSelect` 안에서 시작만 하면 돼요(위 `askBanner`처럼).

## 전체 코드 {#full-code}

```html
<script>U=window.U=window.U||{},U.events=U.events||[],U.chat=function(n){return{on:function(e,t){U.events.push([n,e,t])},off:function(e){for(var t=U.events.length;t>0;)U.events[--t][0]==n&&U.events[t][1]==e&&U.events.splice(t,1)}}};</script>
<script async src="https://client.uchat.io/uchat.js"></script>
<u-chat room="my-room" style="display:inline-block; width:100%; max-width:500px; height:500px;"></u-chat>
<script>
U.chat('my-room').on('after.create', function (room) {
	var ui = room.skin;
	if (!ui.menu) return;
	var removeBanner = null;

	function showBanner(text) {
		if (removeBanner) removeBanner();
		removeBanner = ui.mount.messageList({
			id: 'my-banner-area',
			position: 'before',
			mount: function (host, ctx) {
				var doc = host.ownerDocument;
				var box = doc.createElement('div');
				box.style.padding = '8px 12px';
				box.textContent = text;
				var close = doc.createElement('button');
				close.type = 'button';
				close.textContent = '닫기';
				close.addEventListener('click', function () { ctx.close(); });
				box.appendChild(close);
				host.appendChild(box);
				return function () { host.replaceChildren(); };
			}
		});
	}

	ui.menu.add({
		id: 'my-banner',
		label: '배너',
		title: '대화 위에 배너 띄우기',
		condition: function (ctx) { return ctx.room.my.auth >= 2; },
		onSelect: function () {
			ui.dialog.prompt('배너', '띄울 문구를 적어 주세요.').then(function (text) {
				if (text) showBanner(text);
			});
		}
	});
});
</script>
```

배너는 버튼을 누른 사람의 화면에만 보여요. 모두에게 띄우려면 채널로 문구를 보내고 받는 쪽에서 `showBanner`를 부르세요. → [실시간 데이터 주고받기](/doc/2.4)
