배지가 오르내리는 일은 거의 다 화면 안에서 끝난다. 서버로 올라가는 것은 읽음 커서 하나뿐이고, 그것도 10초에 한 번이다. 이 문서는 그 셋 — 올라가는 것, 내려오는 것, 화면에서 끝나는 것 — 이 각각 언제 발동하는지를 적는다.
PUT /rooms/:id/read+1·−1 이 여기 산다Alice 는 탭 A 로 10번 방을 보고 있고, 폰은 같은 계정이지만 아무 방도 안 보고 있다. Bob 이 말한다.
/rooms/10/messages · /rooms서버가 준 unreadCount 와 커서를 그대로 안다 — 화면이 짐작하지 않는다followBottom — 스크롤 지킴이가 이미 아는 값이다message.created(101) — 탭 A 와 폰 둘 다 받는다소켓은 내가 속한 모든 방을 듣는다/rooms/10/read { 101 }스로틀 창이 비어 있으므로 첫 발은 즉시{ unreadCount: 0 } — 탭 A 가 즉시 맞는다read.changed { roomId: 10, unreadCount: 0 }폰이 배지를 0 으로. GET 을 한 번도 안 한다message.created(102)message.created(103)/rooms/10/read { 103 } — 한 번. 102 는 안 보낸다서버가 GREATEST 로 받으므로 103 하나가 앞의 둘을 포함한다read.changed { roomId: 10, unreadCount: 0 }103 하나가 101·102 를 포함한다 — 그 성질이 없으면 값을 건너뛰는 어떤
방식도 못 쓴다.
같은 방을 보고 있어도 맨 아래에 붙어 있지 않으면 읽는 중이 아니다. 그때는 새 말이 와도 커서가 안 움직이고, 대신 배지가 오른다.
followBottom = falsemessage.created(104)/rooms/10/read { 104 }
「맨 아래에 붙어 있나」는 lib/useScrollKeeper.ts 가 이미 아는 값이다.
새 상태를 만들 자리가 아니다.
pagehide · visibilitychange: hiddenfetch(…, { keepalive: true })응답을 안 기다린다 — 문서가 사라져도 요청은 살아남는다navigator.sendBeacon 은 못 쓴다. 이 앱의 인증은
Authorization: Bearer 인데 sendBeacon 은 커스텀 헤더를 못 붙인다.
조용히 401 이 되고, 하필 떠나는 순간이라 아무도 못 본다.
안 하면 최대 10초어치 읽음이 날아간다 — 다음에 열었을 때 이미 읽은 것이 안 읽음으로 뜬다.
| 트리거 | 커서 | 배지 | 서버로 |
|---|---|---|---|
| 방을 연다 | 서버 값을 받아 안다 | 서버 값 | GET 만 |
message.created — 보는 방 + 맨 아래 | 전진 | 0 유지 | ⏳ 스로틀 |
message.created — 보는 방 + 위에서 읽는 중 | 그대로 | +1 | — |
message.created — 다른 방 | 그대로 | +1 | — |
message.updated — 지움, 커서보다 뒤 | 그대로 | −1 | — |
message.updated — 수정 | 그대로 | 그대로 | — |
| 내가 쓴 것 (어느 경우든) | 전진 | 안 센다 | ⏳ 스로틀 |
| 스로틀 창 만료 | — | — | ↑ PUT 한 번 |
| 맨 아래로 내려온다 | 전진 | 0 으로 | ⏳ 스로틀 |
| 탭 숨김 · 닫힘 | — | — | ↑ flush |
PUT 응답 도착 | 서버 값으로 맞춤 | 서버 값으로 맞춤 | — |
read.changed 도착 (다른 탭·기기) | — | 그 숫자로 | — |
| 재연결 | 서버 값 | 서버 값 | GET |
⏳ 는 즉시 안 보내고 스로틀 창에 맡긴다는 뜻이다. 창이 비어 있으면 첫 발은 즉시 나간다.
배지의 +1·−1 은 왕복을 아끼는 최적화일 뿐이다.
끊겨 있던 동안 놓친 이벤트, 두 탭의 경쟁, 상한(99+) 근처의 셈 — 어긋날 길은 많다.
PUT 응답 · read.changed · 재연결이 오면 그것으로 덮는다