diff --git a/app/auth/login.tsx b/app/auth/login.tsx
index 2135071..9c4f987 100644
--- a/app/auth/login.tsx
+++ b/app/auth/login.tsx
@@ -10,6 +10,7 @@ import { PageHeader } from "@/components/PageHeader";
import { Screen } from "@/components/Screen";
import { useAuthStore } from "@/store/authStore";
import { useUserProfileStore } from "@/store/userProfileStore";
+import { SOUNDLOG_TERMS_VERSION } from "@/constants/legal";
type AuthMode = "login" | "register";
@@ -42,6 +43,7 @@ export default function LoginScreen() {
const handleModePress = (nextMode: AuthMode) => {
setMode(nextMode);
+ setHasAcceptedRequiredTerms(false);
clearAuthError();
};
@@ -63,9 +65,11 @@ export default function LoginScreen() {
return;
}
- if (mode === "register" && !hasAcceptedRequiredTerms) {
+ if (!hasAcceptedRequiredTerms) {
setAuthError(
- "계정을 만들려면 이용약관과 개인정보 처리방침에 동의해주세요.",
+ mode === "register"
+ ? "계정을 만들려면 이용약관과 개인정보 처리방침에 동의해주세요."
+ : "로그인하려면 최신 이용약관과 개인정보 처리방침에 동의해주세요.",
);
return;
}
@@ -79,11 +83,15 @@ export default function LoginScreen() {
? await loginMutation.mutateAsync({
email: normalizedEmail,
password,
+ termsAccepted: true,
+ termsVersion: SOUNDLOG_TERMS_VERSION,
})
: await registerMutation.mutateAsync({
displayName: trimmedDisplayName || undefined,
email: normalizedEmail,
password,
+ termsAccepted: true,
+ termsVersion: SOUNDLOG_TERMS_VERSION,
});
const didCompleteOnboarding =
@@ -227,34 +235,32 @@ export default function LoginScreen() {
) : null}
- {mode === "register" ? (
- {
- setHasAcceptedRequiredTerms((accepted) => !accepted);
- clearAuthError();
- }}
+ {
+ setHasAcceptedRequiredTerms((accepted) => !accepted);
+ clearAuthError();
+ }}
+ >
+
-
- {hasAcceptedRequiredTerms ? (
-
- ) : null}
-
-
- 이용약관과 개인정보 처리방침에 동의합니다. (필수)
-
-
- ) : null}
+ {hasAcceptedRequiredTerms ? (
+
+ ) : null}
+
+
+ 최신 이용약관과 개인정보 처리방침에 동의합니다. (필수)
+
+
{errorMessage ? (
diff --git a/app/legal/terms.tsx b/app/legal/terms.tsx
index ed66958..5f6c28b 100644
--- a/app/legal/terms.tsx
+++ b/app/legal/terms.tsx
@@ -20,7 +20,11 @@ const termsSections = [
},
{
title: "제한 사항",
- body: "타인의 권리를 침해하는 콘텐츠, 불법적인 목적의 이용, 서비스 안정성을 해치는 행위는 허용되지 않습니다. 필요한 경우 서비스 이용이 제한될 수 있습니다.",
+ body: "Soundlog는 불쾌하거나 폭력적이거나 혐오적이거나 성적인 콘텐츠를 용납하지 않습니다. 타인을 괴롭히거나 사칭하거나 위협하거나 스팸을 보내는 행위와 불법적인 목적의 이용도 허용하지 않습니다. 위반 콘텐츠는 즉시 숨김 또는 삭제될 수 있으며 위반 사용자는 서비스 이용이 정지될 수 있습니다.",
+ },
+ {
+ title: "신고와 차단",
+ body: `사용자는 앱에서 부적절한 콘텐츠를 신고하고 해당 사용자를 차단할 수 있습니다. 차단한 사용자의 콘텐츠는 즉시 피드와 지도에서 숨겨집니다. 신고는 ${SOUNDLOG_SUPPORT_EMAIL} 으로도 접수할 수 있으며 Soundlog는 신고 접수 후 24시간 안에 검토하고 필요한 콘텐츠 삭제와 사용자 제재를 진행합니다.`,
},
{
title: "문의와 변경",
@@ -34,7 +38,7 @@ export default function TermsScreen() {
sections={termsSections}
subtitle="Soundlog를 사용할 때 적용되는 기본 조건을 정리했습니다."
title="서비스 이용약관"
- updatedAt="시행일 2026.06.24"
+ updatedAt="시행일 2026.08.15"
/>
);
}
diff --git a/artifacts/app-review-remediation-20260815/01-required-terms.png b/artifacts/app-review-remediation-20260815/01-required-terms.png
new file mode 100644
index 0000000..ab3cc8d
Binary files /dev/null and b/artifacts/app-review-remediation-20260815/01-required-terms.png differ
diff --git a/artifacts/app-review-remediation-20260815/02-public-recap-image.png b/artifacts/app-review-remediation-20260815/02-public-recap-image.png
new file mode 100644
index 0000000..d3f643a
Binary files /dev/null and b/artifacts/app-review-remediation-20260815/02-public-recap-image.png differ
diff --git a/artifacts/app-review-remediation-20260815/03-report-with-24h-review.png b/artifacts/app-review-remediation-20260815/03-report-with-24h-review.png
new file mode 100644
index 0000000..33c0a35
Binary files /dev/null and b/artifacts/app-review-remediation-20260815/03-report-with-24h-review.png differ
diff --git a/artifacts/app-review-remediation-20260815/04-shared-room-safety-controls.png b/artifacts/app-review-remediation-20260815/04-shared-room-safety-controls.png
new file mode 100644
index 0000000..82574a5
Binary files /dev/null and b/artifacts/app-review-remediation-20260815/04-shared-room-safety-controls.png differ
diff --git a/assets/icon.png b/assets/icon.png
index 7165a53..de9e7a1 100644
Binary files a/assets/icon.png and b/assets/icon.png differ
diff --git a/docs/implementation/2026-08-15-app-store-review-remediation-plan.md b/docs/implementation/2026-08-15-app-store-review-remediation-plan.md
new file mode 100644
index 0000000..4dbe70f
--- /dev/null
+++ b/docs/implementation/2026-08-15-app-store-review-remediation-plan.md
@@ -0,0 +1,306 @@
+# App Store 심사 거절 대응 계획
+
+## 1. 문서 목적
+
+이 문서는 2026년 8월 14일 App Store 심사에서 지적된 세 가지 문제를 해결하고 새 빌드를 재제출하기 위한 실행 계획이다.
+
+대상 제출은 `1.0.0 (10)`이며 제출 ID는 `334b774a-ec5f-454f-9fc8-a629b8b61c56`이다.
+
+이번 대응의 완료 기준은 코드를 수정하는 데서 끝나지 않는다. 새 빌드에서 기능을 검증하고 App Store Connect의 스크린샷과 심사 메모와 실제 기기 녹화까지 교체한 뒤 심사자가 같은 흐름을 재현할 수 있어야 한다.
+
+## 2. 심사 지적과 현재 상태
+
+| 지침 | Apple 지적 | 현재 코드에서 확인한 상태 | 판단 |
+| --- | --- | --- | --- |
+| 1.2 사용자 생성 콘텐츠 | 약관과 필터와 신고와 차단과 24시간 대응 체계가 필요함 | 회원가입 약관 동의가 있고 음악 매칭 화면에 신고와 차단 API가 있다. 그러나 약관에 불쾌한 콘텐츠와 악용 사용자를 용납하지 않는다는 문구가 명확하지 않다. 게시 전 콘텐츠 필터가 없다. 신고와 차단은 하나의 버튼으로 묶여 있다. 공개 리캡과 댓글에는 신고와 차단 진입점이 없다. 신고 운영 상태와 24시간 처리 수단도 없다. | 새 빌드 필요 |
+| 2.3.3 정확한 메타데이터 | 13인치 iPad 스크린샷이 실제 핵심 기능을 보여주지 않음 | App Store Connect에는 iPhone 이미지 다섯 장과 iPad 온보딩 이미지 한 장이 등록되어 있다. 제출 빌드 `1.0.0 (10)`의 기기 제품군은 iPhone과 iPad다. 현재 소스 설정과 iOS 프로젝트는 이후 iPhone 전용으로 변경되어 있으므로 새 빌드에서 이 설정이 반영되는지 확인해야 한다. | 새 빌드와 App Store Connect 수정 필요 |
+| 2.3.8 정확한 메타데이터 | 앱 아이콘이 임시 아이콘으로 보임 | `assets/icon.png`와 네이티브 AppIcon이 Expo 기본 템플릿 아이콘이다. 크기와 투명도 검사만 통과할 뿐 Soundlog 브랜드 아이콘이 아니다. | 새 아이콘과 새 빌드 필요 |
+
+### 2.1 App Store Connect 실사 결과
+
+로그인된 App Store Connect에서 다음 상태를 직접 확인했다.
+
+- 재제출 버튼은 비활성화되어 있고 앱 버전 수정 링크만 사용할 수 있다.
+- iPhone 6.5 디스플레이에는 `01-onboarding`, `02-music-recommendation`, `03-playlist-detail`, `04-library-playlists`, `05-map` 다섯 장이 등록되어 있다.
+- 13인치 iPad에는 `01-onboarding` 한 장만 등록되어 있다.
+- Apple이 첨부한 iPad 지적 화면도 넓은 빈 배경이 대부분인 온보딩 화면이다.
+- Apple이 첨부한 아이콘 지적 화면은 Expo 기본 템플릿 아이콘이다.
+- Apple 첨부 세 개 중 iPad 온보딩 화면과 Expo 기본 아이콘은 확인했다. `스크린샷-0814-125651.png`는 App Store Connect에서 다운로드를 반복해도 로컬 파일이 생성되지 않아 원본 확인이 남아 있다. 구현 전에 사용자가 직접 내려받아 제공하면 이 계획과 최종 대조한다.
+- 빌드 10의 기기 제품군은 App Store Connect에서 `iPhone, iPad`로 표시된다.
+- 심사용 로그인 계정은 `local-demo@soundlog.test`로 등록되어 있다.
+- 현재 심사 메모는 위치와 카메라와 공개 리캡만 설명한다. 약관과 필터와 신고와 차단의 진입 경로와 24시간 운영 정책과 실제 기기 영상은 없다.
+- 출시 방식은 심사 승인 후 수동 출시로 설정되어 있다.
+
+### 2.2 공개 지원 채널 실사 결과
+
+`api.soundlog.shop`은 현재 `34.64.116.40`으로 해석되며 `/v1/health`, `/legal/terms`, `/support`가 HTTPS 200 응답을 반환한다. 고객지원 페이지에는 `support@soundlog.shop`이 공개되어 있다.
+
+다만 `soundlog.shop` 최상위 도메인에는 현재 A 레코드와 MX 레코드가 없다. 따라서 페이지에 이메일 주소가 보이는 것과 실제로 신고 메일을 수신할 수 있다는 것은 별개다. 재제출 전에 메일 수신용 MX를 설정하고 외부 주소에서 보낸 시험 메일이 운영 받은편지함에 도착하며 답장까지 가능한지 확인해야 한다. 이 검증 전에는 24시간 대응 채널이 준비되었다고 판단하지 않는다.
+
+## 3. 해결 원칙
+
+사용자 생성 콘텐츠는 텍스트만 뜻하지 않는다. 공개되는 닉네임과 댓글과 리캡 제목과 사진과 현재 음악 정보가 모두 심사 대상이 될 수 있다.
+
+신고 버튼만 추가해서는 통과 조건을 충족하지 못한다. 게시 전에 차단할 수 있어야 하고 신고가 서버에 저장되어야 하며 운영자가 24시간 안에 처리할 수 있어야 한다. 사용자를 차단하면 해당 사용자의 콘텐츠가 즉시 현재 화면에서 사라지고 이후 서버 응답에서도 제외되어야 한다.
+
+iPad를 지원하지 않는 현재 제품 방향은 유지한다. 심사에 제출된 빌드 10은 App Store Connect에서 iPhone과 iPad 지원으로 표시되므로 현재 소스의 iPhone 전용 설정을 반영한 새 빌드가 필요하다. 새 빌드가 실제로 iPhone 전용으로 처리되는지 확인한 뒤 잘못 업로드된 iPad 스크린샷을 제거한다. App Store Connect가 새 빌드도 iPad 지원 앱으로 판단한다면 스크린샷만 우회해서 처리하지 않고 빌드 설정부터 바로잡는다.
+
+### 3.1 Apple 요구사항 추적표
+
+| Apple 요구사항 | 구현 범위 | 심사자가 확인할 증거 |
+| --- | --- | --- |
+| 로그인 전 EULA 또는 이용약관 | 로그인 화면에서 약관 열람, 필수 동의, 약관 버전과 동의 시각 서버 저장 | 로그아웃 상태 실제 기기 영상과 사용자 동의 레코드 |
+| 불쾌한 콘텐츠와 악용 사용자 무관용 문구 | 앱 약관과 공개 약관 페이지를 같은 버전으로 수정 | 앱 화면과 `api.soundlog.shop/legal/terms` |
+| 불쾌한 콘텐츠 필터 | 모든 사용자 작성 텍스트의 서버 필터와 공개 사진 검토 상태 | 거절 API 테스트와 실제 기기 게시 거절 화면 |
+| 콘텐츠 신고 | 사용자 생성 콘텐츠별 별도 신고 동작과 사유 입력 | 미리 작성된 공개 콘텐츠의 신고 영상과 신고 레코드 |
+| 사용자 차단 | 사용자별 별도 차단 동작과 모든 조회 경로의 서버 필터 | 차단 직후 콘텐츠가 사라지는 영상과 조회 API 테스트 |
+| 차단 시 개발자 통지 | 차단과 관련 콘텐츠 사본을 운영 신고 큐에 생성하고 알림 발송 | 운영 알림과 관리자 신고 큐 |
+| 24시간 내 삭제와 사용자 제재 | 처리 기한, 콘텐츠 삭제, 사용자 정지, 미처리 경고 | 관리자 처리 기록과 기한 점검 결과 |
+| 미리 작성된 콘텐츠 | 전용 심사 계정에 신고와 차단 가능한 타 사용자 콘텐츠 준비 | TestFlight 심사 계정의 실제 화면 |
+| 실제 기기 녹화 | TestFlight 설치 iPhone에서 약관, 신고, 차단을 연속 촬영 | 심사 메모에 첨부된 원본 영상 |
+| 실제 핵심 기능 스크린샷 | 최종 서버 데이터가 보이는 iPhone 핵심 화면으로 전체 교체 | App Store Connect 미디어 관리자 |
+| 최종 앱 아이콘 | Expo 기본 아이콘을 브랜드 아이콘으로 교체하고 새 빌드 생성 | TestFlight, 홈 화면, 새 빌드 아이콘 |
+| 지원 연락처 | 공개 지원 페이지와 실제 수신 가능한 운영 메일 | HTTPS 200 응답과 외부 메일 송수신 기록 |
+
+## 4. 단계별 실행 계획
+
+### 4.1 App Store Connect 원본 확인
+
+로그인된 App Store Connect에서 다음 항목을 먼저 확인한다.
+
+- Apple이 첨부한 이미지 세 장을 내려받아 각각 어떤 화면과 아이콘을 지적했는지 확인한다.
+- 미디어 관리자에서 한국어 스크린샷의 모든 기기 크기를 연다.
+- 13인치 iPad에 실제로 등록된 이미지 개수와 출처를 확인한다.
+- 빌드 `1.0.0 (10)`의 기기 제품군이 iPhone과 iPad로 확인되었으므로 새 빌드가 iPhone 전용으로 바뀌는지 확인한다.
+- 심사 정보에 등록된 데모 계정과 메모와 첨부 파일을 확인한다.
+- 앱 정보에 표시되는 아이콘과 빌드 아이콘을 각각 확인한다.
+
+이 단계는 화면을 읽는 작업만 수행한다. 삭제와 업로드와 회신과 재제출은 구현과 검증이 끝난 뒤 별도 확인을 받고 진행한다.
+
+### 4.2 이용약관 동의 증거 강화
+
+수정 대상은 프론트엔드 `app/auth/login.tsx`와 `app/legal/terms.tsx`이며 서버의 회원가입 요청과 사용자 약관 동의 저장 구조도 함께 변경한다.
+
+이용약관에는 다음 정책을 명확히 쓴다.
+
+- 불쾌하거나 폭력적이거나 혐오적이거나 성적인 콘텐츠를 허용하지 않는다.
+- 타인을 괴롭히거나 사칭하거나 위협하거나 스팸을 보내는 사용자를 허용하지 않는다.
+- 위반 콘텐츠는 삭제될 수 있고 위반 사용자는 서비스 이용이 제한되거나 차단될 수 있다.
+- 신고는 `support@soundlog.shop`으로도 접수할 수 있으며 24시간 안에 검토하고 필요한 조치를 한다.
+
+`support@soundlog.shop`은 현재 수신 가능성이 증명되지 않았다. 약관에 24시간 대응을 약속하기 전에 MX 설정과 운영 받은편지함 담당자와 휴일을 포함한 확인 주기를 확정한다. 외부 메일 주소에서 신고 시험 메일을 보내고 수신과 답장을 모두 확인한다.
+
+회원가입 전에 약관 본문을 열 수 있어야 한다. 필수 동의 체크를 하지 않으면 회원가입이 불가능해야 한다. 서버에는 동의한 약관 버전과 동의 시각을 저장하여 프론트 화면만 조작해서 우회할 수 없도록 한다.
+
+예상 수정 파일은 다음과 같다.
+
+- `app/auth/login.tsx`
+- `app/legal/terms.tsx`
+- `src/api/authApi.ts`
+- `src/types/auth.ts`
+- `SoundLogServer/src/validators/api.validators.ts`
+- `SoundLogServer/src/services/auth.service.ts`
+- `SoundLogServer/prisma/models/auth.prisma`
+- 새 Prisma 마이그레이션
+
+### 4.3 게시 전 콘텐츠 필터 구현
+
+게시 전 필터는 사용자가 작성한 내용을 저장하기 전에 금지된 내용을 탐지하고 거절하는 기능이다. 프론트엔드의 입력 제한만 믿지 않고 서버에서 최종 판정한다.
+
+1차 적용 대상은 닉네임과 여행방 제목과 참여자 표시 이름과 후보 메모와 댓글과 공동 리캡 제목이다. 이 값들이 저장되는 모든 API가 하나의 필터 서비스를 거치도록 한다.
+
+사진이 공개 리캡으로 노출되는 경로에는 별도의 검토 상태를 둔다. 사진 검토 기능이 준비되기 전까지 공개 전환 요청은 검토 대기 상태로 저장하고 안전 판정이 끝난 콘텐츠만 공개 목록에 포함한다. 비공개 저장은 그대로 허용한다.
+
+예상 서버 변경은 다음과 같다.
+
+- `src/services/content-moderation.service.ts`를 추가한다.
+- 금지어와 반복 스팸과 외부 연락처 노출을 검사하는 공통 규칙을 추가한다.
+- 이미지 검토 결과를 `pending`, `approved`, `rejected` 상태로 저장한다.
+- 필터에 걸린 요청은 사용자가 이해할 수 있는 오류 코드와 문구를 반환한다.
+- 우회 입력과 공백 변형과 대소문자 변형을 포함한 테스트를 추가한다.
+
+프론트엔드는 서버 오류를 일반 네트워크 오류로 숨기지 않고 해당 문구를 수정하도록 안내한다.
+
+### 4.4 신고 기능을 모든 사용자 콘텐츠에 연결
+
+현재 음악 매칭 화면의 `차단/신고` 결합 버튼을 `신고`와 `사용자 차단`으로 분리한다. 신고는 사유 선택과 선택 설명을 받는 바텀 시트를 사용한다.
+
+신고 진입점은 다음 화면에 제공한다.
+
+- 주변 사운드 사용자 카드
+- 공개 리캡 목록과 공개 리캡 상세
+- 공동 여행방의 다른 사용자 댓글과 후보 리캡
+- 받은 동행 요청
+
+신고 대상에는 대상 사용자와 콘텐츠 종류와 콘텐츠 ID와 당시 콘텐츠 사본을 함께 저장한다. 음악 핀 ID만 전달된 경우에도 서버가 소유 사용자를 찾아 `targetUserId`를 반드시 채운다.
+
+예상 프론트엔드 변경은 다음과 같다.
+
+- `src/components/moderation/ReportContentSheet.tsx`를 추가한다.
+- `src/components/travel/live-sound-map/LiveSoundMapSection.tsx`
+- `src/components/travel/recap-map/SelectedRecapPinPanel.tsx`
+- `src/components/travel/CommunityRecapCard.tsx`
+- `src/components/travel/TravelRoomDetailScreen.tsx`
+- `src/api/communityApi.ts`
+- `src/types/domain.ts`
+
+### 4.5 사용자 차단과 즉시 숨김 보장
+
+차단 버튼에는 확인 화면을 제공한다. 사용자가 차단을 확정하면 다음 결과가 즉시 발생해야 한다.
+
+- 현재 화면에서 대상 사용자의 카드와 핀과 댓글과 공개 리캡이 사라진다.
+- 서버의 주변 사운드와 공개 리캡과 여행방 조회에서도 대상 사용자의 콘텐츠가 제외된다.
+- 양방향의 대기 중 동행 요청이 취소된다.
+- 차단 작업과 관련 신고가 운영자 큐에 기록된다.
+
+현재 서버는 주변 음악 핀 일부에서 차단 사용자를 제외하지만 공개 리캡과 여행방 댓글 전체에 같은 규칙이 적용되었다는 증거가 없다. 조회 서비스마다 흩어진 조건을 공통 차단 범위 함수로 묶고 API 테스트로 증명한다.
+
+### 4.6 24시간 운영 처리 체계 구축
+
+신고 레코드에는 처리 상태와 처리 기한과 담당자와 처리 결과를 저장한다. 단순히 신고가 데이터베이스에 쌓이는 상태로는 완료로 보지 않는다.
+
+운영자가 수행할 수 있어야 하는 작업은 다음과 같다.
+
+- 대기 중 신고를 오래된 순서로 조회한다.
+- 신고 당시 콘텐츠를 확인한다.
+- 콘텐츠를 숨기거나 삭제한다.
+- 위반 사용자를 정지하거나 차단한다.
+- 신고를 해결 처리하고 사유를 기록한다.
+
+새 신고가 들어오면 운영 메일로 알림을 보낸다. 접수 후 20시간이 지나도록 해결되지 않은 신고는 다시 알림을 보내고 24시간을 넘긴 신고를 운영 상태 점검에서 실패로 표시한다.
+
+초기 운영 도구는 외부 공개 관리자 페이지가 아니라 인증된 관리자 API와 실행 문서로 구성해도 된다. 다만 운영자가 실제로 매일 확인할 수 있고 삭제와 사용자 정지까지 실행할 수 있어야 한다.
+
+예상 서버 변경은 다음과 같다.
+
+- `CommunityReport`에 상태와 기한과 처리 정보와 콘텐츠 사본을 추가한다.
+- `User`에 서비스 정지 상태와 정지 사유와 시각을 추가한다.
+- 관리자 인증 미들웨어와 신고 목록 및 해결 API를 추가한다.
+- 신고 알림 서비스와 만료 점검 작업을 추가한다.
+- 신고 접수와 차단과 삭제와 사용자 정지에 대한 API 테스트를 추가한다.
+
+### 4.7 심사용 미리 작성된 콘텐츠 준비
+
+Apple은 안전 기능을 확인할 수 있는 미리 작성된 콘텐츠를 요청했다. 심사용 계정에는 다음 상태를 준비한다.
+
+- 다른 데모 사용자가 작성한 공개 사운드 카드 두 개 이상
+- 신고 가능한 공개 리캡 한 개 이상
+- 다른 사용자가 작성한 공동 여행방 댓글 한 개 이상
+- 차단 후 사라지는 대상 사용자 한 명
+
+심사자가 운영 데이터나 실제 사용자에게 영향을 주지 않도록 전용 데모 계정과 전용 데모 콘텐츠를 사용한다. 데모 콘텐츠는 매 배포 후 초기화할 수 있어야 한다.
+
+현재 등록된 `local-demo@soundlog.test` 계정은 개발 도구가 최초 로그인 때 생성하는 계정과 같다. 심사 직전에 계정이 존재한다는 사실만 확인하지 말고 공개 콘텐츠와 신고 대상 사용자가 실제로 보이는지 TestFlight에서 다시 확인한다.
+
+### 4.8 최종 Soundlog 앱 아이콘 교체
+
+현재 아이콘은 Expo 기본 템플릿 아이콘이므로 완전히 교체한다.
+
+최종 아이콘은 Soundlog의 장소와 음악과 기록이라는 핵심 인상을 하나의 단순한 기호로 표현한다. 작은 홈 화면 크기에서도 알아볼 수 있어야 하며 아이콘 안에 작은 글자나 앱 이름을 넣지 않는다.
+
+파일 기준은 1024 곱하기 1024 PNG이며 투명 배경을 사용하지 않는다. 모서리는 이미지에서 직접 둥글게 만들지 않고 시스템 마스크에 맡긴다.
+
+변경 후 다음 작업을 수행한다.
+
+- `assets/icon.png`를 최종 원본으로 교체한다.
+- 스플래시와 Android 아이콘이 같은 브랜드로 보이도록 관련 자산을 정리한다.
+- Expo 네이티브 자산을 다시 생성하고 `ios/Soundlog/Images.xcassets/AppIcon.appiconset`을 확인한다.
+- 기본 Expo 아이콘 해시를 금지 목록에 넣어 `check:store-release`가 다시 통과시키지 못하게 한다.
+- 실제 기기에 설치하여 홈 화면과 설정과 TestFlight에서 같은 아이콘이 보이는지 확인한다.
+
+아이콘 교체는 기존 빌드의 메타데이터만 수정해서 해결할 수 없다. 반드시 새 빌드를 올린다.
+
+### 4.9 App Store 스크린샷 교체
+
+제출 빌드 10은 iPhone과 iPad를 모두 지원하는 바이너리로 처리되어 있다. 현재 소스의 `supportsTablet: false`와 iOS 대상 기기 설정을 반영한 새 빌드를 올린 뒤 App Store Connect의 기기 제품군이 iPhone으로만 표시되는지 먼저 확인한다. 확인 후 미디어 관리자에서 잘못 등록된 13인치 iPad 스크린샷을 제거한다.
+
+iPhone 스크린샷은 실제 iOS 앱 화면으로 다시 촬영한다. 현재 iPhone 이미지 다섯 장 중 첫 이미지도 온보딩 화면이므로 모든 이미지를 새 촬영본으로 교체한다. 온보딩과 로그인 화면은 핵심 스크린샷 묶음에서 제외한다.
+
+권장 순서는 다음과 같다.
+
+1. 현재 장소와 여행 모드가 표시된 지도
+2. 장소와 무드를 반영한 음악 추천
+3. 플레이리스트 상세와 실제 곡 목록
+4. 사진과 음악으로 리캡을 만드는 화면
+5. 저장된 여행 로그와 리캡 상세
+
+스크린샷에는 개발자 도구와 테스트 버튼과 빈 데이터와 반복 폴백 결과가 보이면 안 된다. 서버와 추천과 관광 이미지가 정상 동작하는 최종 배포 환경에서 촬영한다. 원본 앱 화면이 대부분을 차지하게 하고 장식용 목업이 실제 인터페이스를 가리지 않게 한다.
+
+### 4.10 새 빌드 검증
+
+새 빌드는 `1.0.0`의 다음 빌드 번호로 생성한다. 빌드 번호는 App Store Connect의 처리 결과를 확인한 뒤 확정한다.
+
+프론트엔드 검증은 다음을 포함한다.
+
+- `npm run typecheck`
+- `npm test`
+- `npm run doctor`
+- `npm run check:store-release`
+- iOS 시뮬레이터에서 회원가입 약관과 필터와 신고와 차단과 공개 콘텐츠 숨김을 검증한다.
+- 작은 iPhone과 큰 iPhone 화면에서 핵심 화면을 검증한다.
+
+서버 검증은 다음을 포함한다.
+
+- `pnpm typecheck`
+- `pnpm test:api`
+- `pnpm check:openapi-sync`
+- `pnpm build`
+- 운영 서버 배포 후 신고와 차단과 필터와 관리자 처리 API를 전용 데모 데이터로 확인한다.
+
+웹 화면은 Soundlog 제품 검수 증거로 사용하지 않는다.
+
+### 4.11 실제 기기 심사 녹화
+
+Apple이 실제 기기 녹화를 요청했으므로 시뮬레이터 녹화만 첨부해서는 안 된다. TestFlight로 설치한 실제 iPhone에서 하나의 연속된 영상으로 다음 흐름을 보여준다.
+
+1. 로그아웃 상태에서 이용약관을 연다.
+2. 불쾌한 콘텐츠와 악용 사용자를 용납하지 않는다는 문구를 보여준다.
+3. 필수 동의 전에는 회원가입할 수 없음을 보여준다.
+4. 심사용 계정으로 로그인한다.
+5. 금지된 댓글이 게시 전에 거절되는 모습을 보여준다.
+6. 정상 댓글이 게시되는 모습을 보여준다.
+7. 다른 사용자의 공개 콘텐츠를 신고한다.
+8. 같은 사용자를 차단한다.
+9. 차단 즉시 해당 사용자의 콘텐츠가 피드와 지도에서 사라지는 모습을 보여준다.
+
+영상은 App Store Connect의 앱 심사 정보 메모에 첨부하고 심사 메모에는 각 기능의 정확한 진입 경로를 적는다.
+
+### 4.12 재제출 순서
+
+1. 서버의 필터와 신고 운영 기능을 배포한다.
+2. 프론트엔드 안전 기능과 최종 아이콘을 포함한 새 iOS 빌드를 만든다.
+3. TestFlight에서 실제 기기 검증을 완료한다.
+4. 실제 기기 녹화본을 준비한다.
+5. App Store Connect에서 새 빌드를 선택한다.
+6. iPhone 스크린샷을 교체하고 잘못된 iPad 스크린샷을 제거한다.
+7. 심사 메모에 데모 계정과 기능 경로와 운영 정책과 녹화본을 등록한다.
+8. Apple 메시지에 각 지적 사항의 해결 내용을 답변한다.
+9. 마지막으로 앱 심사에 다시 제출한다.
+
+업로드와 메시지 전송과 재제출은 외부 상태를 변경하므로 실행 직전에 사용자 확인을 받는다.
+
+## 5. 완료 판정표
+
+| 확인 항목 | 완료 증거 |
+| --- | --- |
+| 약관 | 로그인 전 약관 화면과 필수 동의 차단 영상과 서버의 약관 버전 저장 값 |
+| 콘텐츠 필터 | 금지 문구가 저장되지 않는 API 테스트와 실제 기기 화면 |
+| 신고 | 콘텐츠별 신고 UI와 서버 신고 레코드와 운영 알림 |
+| 차단 | 차단 직후 UI 삭제와 이후 조회 API 제외 테스트 |
+| 24시간 처리 | 관리자 신고 큐와 콘텐츠 삭제와 사용자 정지 기능과 기한 알림 |
+| 지원 연락처 | 공개 지원 페이지 200 응답과 외부 주소에서 보낸 시험 메일의 수신 및 답장 기록 |
+| 심사용 데이터 | 데모 계정 로그인 후 신고 가능한 콘텐츠가 보이는 실제 기기 영상 |
+| 앱 아이콘 | 최종 1024 PNG와 새 빌드의 홈 화면과 TestFlight 아이콘 |
+| 스크린샷 | 미디어 관리자에 실제 핵심 기능 중심의 iPhone 이미지가 등록된 화면 |
+| iPad 범위 | 새 빌드가 iPhone 전용으로 표시되고 잘못된 iPad 이미지가 제거된 화면 |
+| 재제출 | 새 빌드 번호와 Apple 회신과 제출 상태가 심사 대기로 변경된 화면 |
+
+## 6. 계획 자체 검토 결과
+
+현재 있는 신고와 차단 API를 그대로 두고 UI만 보여주는 계획은 부족하다. 공개 리캡과 댓글까지 범위를 넓히고 서버가 차단 사용자의 콘텐츠를 모든 조회에서 제외하는지 검증해야 한다.
+
+금지어 목록만으로 사진과 우회 문구를 완전히 판정할 수 없으므로 공개 사진에는 검토 상태가 필요하다. 자동 이미지 검토 공급자를 바로 정하지 못하더라도 검토 전 콘텐츠를 공개하지 않는 원칙은 유지한다.
+
+13인치 iPad 스크린샷을 새로 만드는 것은 현재 iPhone 전용 제품 설정과 맞지 않는다. 먼저 새 빌드의 지원 기기 정보를 확인하고 iPad 미디어를 제거하는 방향이 일관된다.
+
+심사 메시지에 답변만 보내거나 기존 빌드를 다시 제출해서는 아이콘과 안전 기능이 바뀌지 않는다. 세 지적 사항을 모두 반영한 새 빌드와 새 메타데이터를 한 번에 제출해야 한다.
diff --git a/scripts/check-store-release.js b/scripts/check-store-release.js
index 4049c03..374e239 100644
--- a/scripts/check-store-release.js
+++ b/scripts/check-store-release.js
@@ -1,11 +1,16 @@
#!/usr/bin/env node
const fs = require('fs');
+const crypto = require('crypto');
const path = require('path');
const projectRoot = path.resolve(__dirname, '..');
const errors = [];
const warnings = [];
+const forbiddenIconHashes = new Set([
+ // Expo SDK starter icon. Shipping this hash caused App Store metadata rejection.
+ '119462bb78eb240a65c869fc067ee599639b3cb5a41953f25c07b17d2a8c7e0f',
+]);
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -79,6 +84,15 @@ function hasPngAlpha(filePath) {
return colorType === 4 || colorType === 6 || hasTransparencyChunk;
}
+function getPngDimensions(filePath) {
+ const buffer = fs.readFileSync(filePath);
+ return { height: buffer.readUInt32BE(20), width: buffer.readUInt32BE(16) };
+}
+
+function sha256(filePath) {
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
+}
+
function assertProductionEnv(productionEnv) {
const apiBaseUrl = productionEnv.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL;
const privacyUrl = productionEnv.EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL;
@@ -144,6 +158,24 @@ function assertAppIcon(config) {
if (hasPngAlpha(iconPath)) {
addError(`App icon must not have alpha transparency: ${config.icon}`);
}
+
+ const iconDimensions = getPngDimensions(iconPath);
+ if (iconDimensions.width !== 1024 || iconDimensions.height !== 1024) {
+ addError(`App icon must be exactly 1024x1024: ${config.icon}`);
+ }
+
+ const iconHash = sha256(iconPath);
+ if (forbiddenIconHashes.has(iconHash)) {
+ addError('App icon is still the Expo starter icon. Replace it with the Soundlog brand icon.');
+ }
+
+ const nativeIconPath = path.join(
+ projectRoot,
+ 'ios/Soundlog/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png',
+ );
+ if (fs.existsSync(nativeIconPath) && sha256(nativeIconPath) !== iconHash) {
+ addError('The generated iOS AppIcon does not match the Expo config icon. Run Expo prebuild.');
+ }
}
function assertAndroidPermissions(config) {
diff --git a/src/api/communityApi.ts b/src/api/communityApi.ts
index e14934a..3e0021d 100644
--- a/src/api/communityApi.ts
+++ b/src/api/communityApi.ts
@@ -7,6 +7,8 @@ import type {
CommunityVisibility,
GeoPoint,
MoodTag,
+ ModerationTarget,
+ ModerationTargetType,
MusicMatch,
RecapItem,
SoundMapPin,
@@ -302,7 +304,7 @@ export const communityApi = {
},
);
},
- blockUser: async (input: { targetPinId?: string; targetUserId?: string }) => {
+ blockUser: async (input: ModerationTarget) => {
if (!shouldAttemptAuthenticatedApi()) {
return { accepted: false };
}
@@ -316,14 +318,16 @@ export const communityApi = {
details?: string;
reason: 'inappropriate' | 'other' | 'safety' | 'spam';
requestId?: string;
+ targetContentId?: string;
targetPinId?: string;
+ targetType: ModerationTargetType;
targetUserId?: string;
}) => {
if (!shouldAttemptAuthenticatedApi()) {
return { accepted: false };
}
- return requestApi<{ accepted: boolean }>('/v1/community/reports', {
+ return requestApi<{ dueAt: string; id: string; notified: boolean }>('/v1/community/reports', {
body: input,
method: 'POST',
});
diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx
index b6060a4..26dbd2c 100644
--- a/src/components/dev/DevTestManager.tsx
+++ b/src/components/dev/DevTestManager.tsx
@@ -13,6 +13,7 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { authApi } from '@/api/authApi';
+import { SOUNDLOG_TERMS_VERSION } from '@/constants/legal';
import { getApiBaseUrl } from '@/api/client';
import { queryClient } from '@/providers/queryClient';
import { AppText } from '@/components/AppText';
@@ -268,9 +269,15 @@ function DevTestManagerContent() {
session = await authApi.register({
...credentials,
displayName: '로컬데모',
+ termsAccepted: true,
+ termsVersion: SOUNDLOG_TERMS_VERSION,
});
} catch {
- session = await authApi.login(credentials);
+ session = await authApi.login({
+ ...credentials,
+ termsAccepted: true,
+ termsVersion: SOUNDLOG_TERMS_VERSION,
+ });
}
finishLogin(session);
diff --git a/src/components/moderation/ReportContentSheet.tsx b/src/components/moderation/ReportContentSheet.tsx
new file mode 100644
index 0000000..504f38f
--- /dev/null
+++ b/src/components/moderation/ReportContentSheet.tsx
@@ -0,0 +1,172 @@
+import { Feather } from '@expo/vector-icons';
+import { useEffect, useState } from 'react';
+import {
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ TextInput,
+ View,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { ApiError } from '@/api/client';
+import { communityApi } from '@/api/communityApi';
+import { AppText } from '@/components/AppText';
+import type { ModerationTarget } from '@/types/domain';
+
+type ReportReason = 'inappropriate' | 'other' | 'safety' | 'spam';
+
+const reasons: Array<{ label: string; value: ReportReason }> = [
+ { label: '부적절한 콘텐츠', value: 'inappropriate' },
+ { label: '괴롭힘 또는 안전 문제', value: 'safety' },
+ { label: '스팸 또는 홍보', value: 'spam' },
+ { label: '기타', value: 'other' },
+];
+
+type ReportContentSheetProps = {
+ onClose: () => void;
+ onReported?: () => void;
+ target?: ModerationTarget;
+ title?: string;
+ visible: boolean;
+};
+
+export function ReportContentSheet({
+ onClose,
+ onReported,
+ target,
+ title = '콘텐츠 신고',
+ visible,
+}: ReportContentSheetProps) {
+ const insets = useSafeAreaInsets();
+ const [details, setDetails] = useState('');
+ const [errorMessage, setErrorMessage] = useState();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [reason, setReason] = useState('inappropriate');
+
+ useEffect(() => {
+ if (visible) {
+ setDetails('');
+ setErrorMessage(undefined);
+ setReason('inappropriate');
+ }
+ }, [visible]);
+
+ const handleSubmit = async () => {
+ if (!target || isSubmitting) return;
+ setIsSubmitting(true);
+ setErrorMessage(undefined);
+
+ try {
+ await communityApi.reportTarget({
+ ...target,
+ details: details.trim() || undefined,
+ reason,
+ });
+ onReported?.();
+ onClose();
+ } catch (error) {
+ setErrorMessage(
+ error instanceof ApiError
+ ? error.message
+ : '신고를 접수하지 못했어요. 잠시 후 다시 시도해주세요.',
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {title}
+
+ 신고 내용은 운영자에게 전달되며 24시간 안에 검토합니다.
+
+
+
+
+
+
+
+
+ {reasons.map((option) => {
+ const selected = option.value === reason;
+ return (
+ setReason(option.value)}
+ >
+
+ {option.label}
+
+
+ );
+ })}
+
+
+
+
+ {errorMessage ? (
+ {errorMessage}
+ ) : null}
+
+ void handleSubmit()}
+ style={{ opacity: isSubmitting || !target ? 0.55 : 1 }}
+ >
+
+ {isSubmitting ? '접수 중' : '신고 접수'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx
index 1c1cbac..2a2fe72 100644
--- a/src/components/moment-capture/MomentCaptureScreen.tsx
+++ b/src/components/moment-capture/MomentCaptureScreen.tsx
@@ -8,6 +8,7 @@ import { syncRecommendationEvent } from "@/api/recommendationEventApi";
import { momentLogApi } from "@/api/momentLogApi";
import { momentLogQueryKeys } from "@/api/momentLogQueries";
import { recapApi } from "@/api/recapApi";
+import { ApiError } from "@/api/client";
import { recapQueryKeys } from "@/api/recapQueries";
import { AppText } from "@/components/AppText";
import { PageHeader } from "@/components/PageHeader";
@@ -334,8 +335,12 @@ export function MomentCaptureScreen() {
} else {
router.replace(resolveReturnPath(returnTo) as never);
}
- } catch {
- setErrorMessage("이 리캡을 저장하지 못했어요. 다시 시도해주세요.");
+ } catch (error) {
+ setErrorMessage(
+ error instanceof ApiError
+ ? error.message
+ : "이 리캡을 저장하지 못했어요. 다시 시도해주세요.",
+ );
} finally {
isSavingRef.current = false;
setIsSaving(false);
diff --git a/src/components/recap-share/RecapShareScreen.tsx b/src/components/recap-share/RecapShareScreen.tsx
index 58289ab..5a862af 100644
--- a/src/components/recap-share/RecapShareScreen.tsx
+++ b/src/components/recap-share/RecapShareScreen.tsx
@@ -1,13 +1,15 @@
import { useEffect, useRef, useState } from "react";
-import { Pressable, ScrollView, View } from "react-native";
+import { Alert, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQueryClient } from "@tanstack/react-query";
import { router } from "expo-router";
import { ApiError } from "@/api/client";
+import { communityApi } from "@/api/communityApi";
import { recapApi } from "@/api/recapApi";
import { recapQueryKeys, useRecapShareQuery } from "@/api/recapQueries";
import { AppText } from "@/components/AppText";
+import { ReportContentSheet } from "@/components/moderation/ReportContentSheet";
import { IconButton } from "@/components/IconButton";
import { PageHeader } from "@/components/PageHeader";
import {
@@ -67,6 +69,7 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) {
const [visibility, setVisibility] = useState("private");
const [thumbnailMessage, setThumbnailMessage] = useState();
const [visibilityMessage, setVisibilityMessage] = useState();
+ const [isReportOpen, setIsReportOpen] = useState(false);
const {
data: recap,
isError,
@@ -83,6 +86,34 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) {
captureFrameRef.current?.capture() ?? Promise.resolve(undefined),
recapId: recap?.id,
});
+ const handleBlockAuthor = () => {
+ if (!recap || recap.isMine) return;
+ Alert.alert(
+ '이 사용자를 차단할까요?',
+ '이 사용자의 공개 리캡과 로그가 피드와 지도에서 즉시 숨겨집니다.',
+ [
+ { style: 'cancel', text: '취소' },
+ {
+ style: 'destructive',
+ text: '차단',
+ onPress: () => {
+ void communityApi
+ .blockUser({ targetContentId: recap.id, targetType: 'recap' })
+ .then(() => {
+ void queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists });
+ Alert.alert('차단 완료', '해당 사용자의 공개 콘텐츠를 숨겼어요.', [
+ { text: '확인', onPress: () => router.back() },
+ ]);
+ })
+ .catch((error) => Alert.alert(
+ '차단 실패',
+ error instanceof ApiError ? error.message : '잠시 후 다시 시도해주세요.',
+ ));
+ },
+ },
+ ],
+ );
+ };
const handleChangeVisibility = async (nextVisibility: RecapVisibility) => {
if (!recap || !canManageVisibility || isUpdatingVisibility) {
return;
@@ -114,7 +145,9 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) {
}
setVisibilityMessage(
nextVisibility === "public"
- ? "전체공개로 바꿨어요. 현재 위치 300m 이내 지도에 리캡 핀이 남아요."
+ ? updatedRecap?.moderationStatus === "pending"
+ ? "공개 검토를 요청했어요. 승인되면 다른 사람의 피드와 지도에 표시돼요."
+ : "전체공개로 바꿨어요. 현재 위치 300m 이내 지도에 리캡 핀이 남아요."
: "나만보기로 바꿨어요. 다른 사람의 지도에는 보이지 않아요.",
);
void refetch();
@@ -257,7 +290,24 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) {
);
})}
- ) : null}
+ ) : (
+
+ setIsReportOpen(true)}
+ >
+ 신고
+
+
+ 사용자 차단
+
+
+ )}
{visibilityMessage ? (
@@ -363,6 +413,13 @@ export function RecapShareScreen({ recapId }: RecapShareScreenProps) {
)}
+ setIsReportOpen(false)}
+ onReported={() => Alert.alert('신고 접수', '운영자가 24시간 안에 확인합니다.')}
+ target={recap && !recap.isMine ? { targetContentId: recap.id, targetType: 'recap' } : undefined}
+ title="공개 리캡 신고"
+ visible={isReportOpen}
+ />
);
}
diff --git a/src/components/recap/RecapListScreen.tsx b/src/components/recap/RecapListScreen.tsx
index 251132a..11a5e8e 100644
--- a/src/components/recap/RecapListScreen.tsx
+++ b/src/components/recap/RecapListScreen.tsx
@@ -6,6 +6,7 @@ import { LinearGradient } from "expo-linear-gradient";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Animated,
+ Alert,
GestureResponderEvent,
NativeScrollEvent,
NativeSyntheticEvent,
@@ -18,9 +19,11 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ApiError } from "@/api/client";
+import { communityApi } from "@/api/communityApi";
import { recapApi } from "@/api/recapApi";
import { recapQueryKeys, useRecapListQuery } from "@/api/recapQueries";
import { AppText } from "@/components/AppText";
+import { ReportContentSheet } from "@/components/moderation/ReportContentSheet";
import { PageHeader } from "@/components/PageHeader";
import { RecapEmptyState } from "@/components/recap/RecapEmptyState";
import { Screen } from "@/components/Screen";
@@ -87,6 +90,8 @@ type LogGridCardProps = {
event: GestureResponderEvent,
) => void;
onPress: () => void;
+ onBlock: (entry: LogGridEntry) => void;
+ onReport: (entry: LogGridEntry) => void;
};
function LogGridCard({
@@ -95,7 +100,9 @@ function LogGridCard({
isMine,
isUpdating,
onChangeVisibility,
+ onBlock,
onPress,
+ onReport,
}: LogGridCardProps) {
const imageUrl = getEntryImageUrl(entry);
const [failedImageUrl, setFailedImageUrl] = useState();
@@ -201,7 +208,26 @@ function LogGridCard({
{isUpdating ? "변경중" : getVisibilityLabel(visibility)}
- ) : null}
+ ) : (
+
+ onReport(entry)}
+ >
+
+
+ onBlock(entry)}
+ >
+
+
+
+ )}
);
}
@@ -222,6 +248,8 @@ type LogFeedPageProps = {
event: GestureResponderEvent,
) => void;
onOpenEntry: (entry: LogGridEntry) => void;
+ onBlockEntry: (entry: LogGridEntry) => void;
+ onReportEntry: (entry: LogGridEntry) => void;
tabId: LogFeedTabId;
updatingRecapId?: string;
width: number;
@@ -238,7 +266,9 @@ function LogFeedPage({
isLoading,
itemSize,
onChangeVisibility,
+ onBlockEntry,
onOpenEntry,
+ onReportEntry,
tabId,
updatingRecapId,
width,
@@ -298,7 +328,9 @@ function LogFeedPage({
isUpdating={updatingRecapId === entry.item.id}
key={`${tabId}-${entry.item.id}`}
onChangeVisibility={onChangeVisibility}
+ onBlock={onBlockEntry}
onPress={() => onOpenEntry(entry)}
+ onReport={onReportEntry}
/>
))}
@@ -327,6 +359,7 @@ export function RecapListScreen() {
);
const [updatingRecapId, setUpdatingRecapId] = useState();
const [actionMessage, setActionMessage] = useState();
+ const [reportEntry, setReportEntry] = useState();
const pagerRef = useRef(null);
const initialTabIndex =
initialView === "mine" || initialView === "all" ? 1 : 0;
@@ -377,6 +410,37 @@ export function RecapListScreen() {
const handleOpenTravel = useCallback(() => {
router.navigate("/" as never);
}, []);
+ const handleReportEntry = useCallback((entry: LogGridEntry) => {
+ setReportEntry(entry);
+ }, []);
+ const handleBlockEntry = useCallback((entry: LogGridEntry) => {
+ Alert.alert(
+ '이 사용자를 차단할까요?',
+ '이 사용자가 공개한 로그와 리캡이 즉시 숨겨지고 운영자에게 전달됩니다.',
+ [
+ { style: 'cancel', text: '취소' },
+ {
+ style: 'destructive',
+ text: '차단',
+ onPress: () => {
+ void communityApi
+ .blockUser({ targetContentId: entry.item.id, targetType: 'recap' })
+ .then(() => {
+ queryClient.setQueryData(
+ recapQueryKeys.list('others'),
+ (previous = []) => previous.filter((item) => item.id !== entry.item.id),
+ );
+ setActionMessage('사용자를 차단했어요. 해당 사용자의 공개 콘텐츠는 더 이상 보이지 않아요.');
+ void queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists });
+ })
+ .catch((error) => {
+ setActionMessage(error instanceof ApiError ? error.message : '사용자를 차단하지 못했어요.');
+ });
+ },
+ },
+ ],
+ );
+ }, [queryClient]);
const handleSelectTab = useCallback(
(tab: LogFeedTabId) => {
@@ -460,7 +524,9 @@ export function RecapListScreen() {
setActionMessage(
nextVisibility === "public"
- ? "전체공개로 바꿨어요. 다른 사람의 공개 피드와 지도에 표시돼요."
+ ? updatedRecap?.moderationStatus === "pending"
+ ? "공개 검토를 요청했어요. 승인되면 다른 사람의 피드와 지도에 표시돼요."
+ : "전체공개로 바꿨어요. 다른 사람의 공개 피드와 지도에 표시돼요."
: "비공개로 바꿨어요. 내 로그에서만 확인할 수 있어요.",
);
void queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists });
@@ -579,7 +645,7 @@ export function RecapListScreen() {
style={styles.pager}
>
+ setReportEntry(undefined)}
+ onReported={() => setActionMessage('신고를 접수했어요. 운영자가 24시간 안에 확인합니다.')}
+ target={reportEntry ? { targetContentId: reportEntry.item.id, targetType: 'recap' } : undefined}
+ title="공개 로그 신고"
+ visible={Boolean(reportEntry)}
+ />
);
}
diff --git a/src/components/travel/CommunityRecapCard.tsx b/src/components/travel/CommunityRecapCard.tsx
index b08d966..5d7ab49 100644
--- a/src/components/travel/CommunityRecapCard.tsx
+++ b/src/components/travel/CommunityRecapCard.tsx
@@ -1,10 +1,12 @@
import { Feather } from "@expo/vector-icons";
import { router } from "expo-router";
import { useEffect, useState } from "react";
-import { Pressable, TextInput, View } from "react-native";
+import { Alert, Pressable, TextInput, View } from "react-native";
import { communityApi } from "@/api/communityApi";
+import { ApiError } from "@/api/client";
import { AppText } from "@/components/AppText";
+import { ReportContentSheet } from "@/components/moderation/ReportContentSheet";
import {
SoundlogButton,
SoundlogMetric,
@@ -14,6 +16,7 @@ import { useAuthStore } from "@/store/authStore";
import { useTravelRoomStore } from "@/store/travelRoomStore";
import type {
PlaceContext,
+ ModerationTarget,
RecapItem,
Track,
TravelRoom,
@@ -120,8 +123,11 @@ function CandidateMomentRow({
commentsExpanded,
index,
moment,
+ currentUserId,
+ onBlock,
onChangeComment,
onSubmitComment,
+ onReport,
onToggleComments,
onToggleStatus,
pendingComment,
@@ -133,8 +139,11 @@ function CandidateMomentRow({
commentsExpanded: boolean;
index: number;
moment: TravelRoomMoment;
+ currentUserId?: string;
+ onBlock: (target: ModerationTarget, userId: string) => void;
onChangeComment: (value: string) => void;
onSubmitComment: () => void;
+ onReport: (target: ModerationTarget) => void;
onToggleComments: () => void;
onToggleStatus: () => void;
pendingComment: boolean;
@@ -205,6 +214,32 @@ function CandidateMomentRow({
+ {moment.userId !== currentUserId && !moment.id.startsWith("preview-") ? (
+
+ onReport({
+ targetContentId: moment.id,
+ targetType: "travel_room_moment",
+ targetUserId: moment.userId,
+ })}
+ >
+ 후보 신고
+
+ onBlock({
+ targetContentId: moment.id,
+ targetType: "travel_room_moment",
+ }, moment.userId)}
+ >
+ 사용자 차단
+
+
+ ) : null}
+
{commentCount > 0 ? (
@@ -214,13 +249,41 @@ function CandidateMomentRow({
{visibleComments.length > 0 ? (
visibleComments.map((comment) => (
-
- {comment.userId} · {comment.body}
-
+
+
+ {comment.displayName ?? comment.userId} · {comment.body}
+
+ {comment.userId !== currentUserId ? (
+
+ onReport({
+ targetContentId: comment.id,
+ targetType: "travel_room_comment",
+ targetUserId: comment.userId,
+ })}
+ >
+
+
+ onBlock({
+ targetContentId: comment.id,
+ targetType: "travel_room_comment",
+ }, comment.userId)}
+ >
+
+
+
+ ) : null}
+
))
) : (
@@ -322,12 +385,47 @@ export function CommunityRecapCard({
const [pendingCommentMomentId, setPendingCommentMomentId] =
useState();
const [pendingStatusMomentId, setPendingStatusMomentId] = useState();
+ const [reportTarget, setReportTarget] = useState();
const [showAllMoments, setShowAllMoments] = useState(false);
const authStatus = useAuthStore((state) => state.status);
const currentUserId = useAuthStore((state) => state.user?.id);
const room = useTravelRoomStore((state) => state.roomsBySessionId[sessionId]);
const setTravelRoom = useTravelRoomStore((state) => state.setRoom);
+ const handleBlockUser = (target: ModerationTarget, targetUserId: string) => {
+ if (!room) return;
+ Alert.alert(
+ "이 사용자를 차단할까요?",
+ "이 사용자가 작성한 공동 리캡 후보와 댓글이 즉시 숨겨집니다.",
+ [
+ { style: "cancel", text: "취소" },
+ {
+ style: "destructive",
+ text: "차단",
+ onPress: () => {
+ void communityApi
+ .blockUser({ ...target, targetUserId })
+ .then(() => {
+ setTravelRoom(sessionId, {
+ ...room,
+ moments: room.moments
+ .filter((moment) => moment.userId !== targetUserId)
+ .map((moment) => ({
+ ...moment,
+ comments: moment.comments?.filter(
+ (comment) => comment.userId !== targetUserId,
+ ),
+ })),
+ });
+ setMessage("사용자를 차단했어요. 해당 사용자의 콘텐츠를 숨겼습니다.");
+ })
+ .catch(() => setMessage("사용자를 차단하지 못했어요. 잠시 후 다시 시도해주세요."));
+ },
+ },
+ ],
+ );
+ };
+
const placeName = currentPlace?.title ?? "이번 여행";
const canUseRoom = sessionStatus !== "idle";
const normalizedInviteCode = joinInviteCode.trim().toUpperCase();
@@ -556,9 +654,11 @@ export function CommunityRecapCard({
setMessage(
"공동 여행방에 참여했어요. 이제 현재 곡을 Recap 후보로 올릴 수 있어요.",
);
- } catch {
+ } catch (error) {
setMessage(
- "초대 코드로 여행방에 참여하지 못했어요. 코드를 다시 확인해주세요.",
+ error instanceof ApiError
+ ? error.message
+ : "초대 코드로 여행방에 참여하지 못했어요. 코드를 다시 확인해주세요.",
);
} finally {
setIsJoiningRoom(false);
@@ -632,9 +732,11 @@ export function CommunityRecapCard({
);
setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: "" }));
setMessage("후보 리캡에 댓글을 남겼어요.");
- } catch {
+ } catch (error) {
setMessage(
- "댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.",
+ error instanceof ApiError
+ ? error.message
+ : "댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.",
);
} finally {
setPendingCommentMomentId(undefined);
@@ -772,9 +874,11 @@ export function CommunityRecapCard({
canComment={canCommentRoom}
canModerate={canModerateRoom}
commentDraft={commentDrafts[moment.id] ?? ""}
+ currentUserId={currentUserId}
index={index}
key={moment.id}
moment={moment}
+ onBlock={handleBlockUser}
onChangeComment={(value) =>
setCommentDrafts((drafts) => ({
...drafts,
@@ -782,6 +886,7 @@ export function CommunityRecapCard({
}))
}
onSubmitComment={() => void handleSubmitMomentComment(moment)}
+ onReport={setReportTarget}
onToggleComments={() =>
setExpandedCommentIds((state) => {
if (state[moment.id]) {
@@ -901,6 +1006,13 @@ export function CommunityRecapCard({
)}
+ setReportTarget(undefined)}
+ onReported={() => setMessage("신고를 접수했어요. 운영자가 24시간 안에 확인합니다.")}
+ target={reportTarget}
+ title="공동 여행 콘텐츠 신고"
+ visible={Boolean(reportTarget)}
+ />
);
}
diff --git a/src/components/travel/TravelRoomDetailScreen.tsx b/src/components/travel/TravelRoomDetailScreen.tsx
index e8383db..57598b8 100644
--- a/src/components/travel/TravelRoomDetailScreen.tsx
+++ b/src/components/travel/TravelRoomDetailScreen.tsx
@@ -1,10 +1,13 @@
import { router } from "expo-router";
+import { Feather } from "@expo/vector-icons";
import { useEffect, useMemo, useState } from "react";
-import { ScrollView, TextInput, View } from "react-native";
+import { Alert, Pressable, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { communityApi } from "@/api/communityApi";
+import { ApiError } from "@/api/client";
import { AppText } from "@/components/AppText";
+import { ReportContentSheet } from "@/components/moderation/ReportContentSheet";
import { IconButton } from "@/components/IconButton";
import { PageHeader } from "@/components/PageHeader";
import { Screen } from "@/components/Screen";
@@ -16,7 +19,7 @@ import { useAuthStore } from "@/store/authStore";
import { usePlayerStore } from "@/store/playerStore";
import { useTravelRoomStore } from "@/store/travelRoomStore";
import { useTravelSessionStore } from "@/store/travelSessionStore";
-import type { TravelRoom, TravelRoomMoment } from "@/types/domain";
+import type { ModerationTarget, TravelRoom, TravelRoomMoment } from "@/types/domain";
import { shareTravelRoomInvite } from "@/utils/travelRoomInvite";
type TravelRoomDetailScreenProps = {
@@ -62,6 +65,18 @@ function appendRoomMomentComment(
};
}
+function hideBlockedUserContent(room: TravelRoom, blockedUserId: string): TravelRoom {
+ return {
+ ...room,
+ moments: room.moments
+ .filter((moment) => moment.userId !== blockedUserId)
+ .map((moment) => ({
+ ...moment,
+ comments: moment.comments?.filter((comment) => comment.userId !== blockedUserId),
+ })),
+ };
+}
+
function createMemberLabel(
member: TravelRoom["members"][number],
currentUserId?: string,
@@ -127,6 +142,7 @@ export function TravelRoomDetailScreen({
const [pendingCommentMomentId, setPendingCommentMomentId] =
useState();
const [pendingStatusMomentId, setPendingStatusMomentId] = useState();
+ const [reportTarget, setReportTarget] = useState();
const room = cachedRoom;
const hasCachedRoom = Boolean(cachedRoom);
const sortedMoments = useMemo(
@@ -142,6 +158,30 @@ export function TravelRoomDetailScreen({
const canModerate = myMemberRole === "owner";
const canUseServerRoom = authStatus === "authenticated";
+ const handleBlockUser = (target: ModerationTarget, targetUserId: string) => {
+ if (!room) return;
+ Alert.alert(
+ "이 사용자를 차단할까요?",
+ "공동 여행방에서 이 사용자가 작성한 후보와 댓글이 즉시 숨겨집니다.",
+ [
+ { style: "cancel", text: "취소" },
+ {
+ style: "destructive",
+ text: "차단",
+ onPress: () => {
+ void communityApi
+ .blockUser({ ...target, targetUserId })
+ .then(() => {
+ setRoomById(hideBlockedUserContent(room, targetUserId));
+ setMessage("사용자를 차단했어요. 해당 사용자의 콘텐츠를 숨겼습니다.");
+ })
+ .catch(() => setMessage("사용자를 차단하지 못했어요. 잠시 후 다시 시도해주세요."));
+ },
+ },
+ ],
+ );
+ };
+
useEffect(() => {
if (!canUseServerRoom || !roomId) {
setIsLoading(false);
@@ -302,9 +342,11 @@ export function TravelRoomDetailScreen({
setRoomById(appendRoomMomentComment(room, moment.id, comment));
setCommentDrafts((drafts) => ({ ...drafts, [moment.id]: "" }));
setMessage("후보 리캡에 댓글을 남겼어요.");
- } catch {
+ } catch (error) {
setMessage(
- "댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.",
+ error instanceof ApiError
+ ? error.message
+ : "댓글을 저장하지 못했어요. 여행방 참여 상태나 네트워크를 확인해주세요.",
);
} finally {
setPendingCommentMomentId(undefined);
@@ -528,15 +570,67 @@ export function TravelRoomDetailScreen({
+ {moment.userId !== currentUserId ? (
+
+ setReportTarget({
+ targetContentId: moment.id,
+ targetType: "travel_room_moment",
+ targetUserId: moment.userId,
+ })}
+ >
+ 후보 신고
+
+ handleBlockUser({
+ targetContentId: moment.id,
+ targetType: "travel_room_moment",
+ }, moment.userId)}
+ >
+ 사용자 차단
+
+
+ ) : null}
+
{moment.comments?.length ? (
{moment.comments.map((comment) => (
-
- {comment.userId} · {comment.body}
-
+
+
+ {comment.displayName ?? comment.userId} · {comment.body}
+
+ {comment.userId !== currentUserId ? (
+
+ setReportTarget({
+ targetContentId: comment.id,
+ targetType: "travel_room_comment",
+ targetUserId: comment.userId,
+ })}
+ >
+
+
+ handleBlockUser({
+ targetContentId: comment.id,
+ targetType: "travel_room_comment",
+ }, comment.userId)}
+ >
+
+
+
+ ) : null}
+
))}
) : null}
@@ -605,6 +699,13 @@ export function TravelRoomDetailScreen({
>
)}
+ setReportTarget(undefined)}
+ onReported={() => setMessage("신고를 접수했어요. 운영자가 24시간 안에 확인합니다.")}
+ target={reportTarget}
+ title="공동 여행 콘텐츠 신고"
+ visible={Boolean(reportTarget)}
+ />
);
}
diff --git a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx
index fc4a152..c248f18 100644
--- a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx
+++ b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx
@@ -1,16 +1,18 @@
import { Feather } from '@expo/vector-icons';
import { useEffect, useMemo, useState } from 'react';
-import { Pressable, View } from 'react-native';
+import { Alert, Pressable, View } from 'react-native';
import { ApiError } from '@/api/client';
import { communityApi } from '@/api/communityApi';
import { syncRecommendationEvent } from '@/api/recommendationEventApi';
import { AppText } from '@/components/AppText';
+import { ReportContentSheet } from '@/components/moderation/ReportContentSheet';
import { useAuthStore } from '@/store/authStore';
import { useRecommendationEventStore } from '@/store/recommendationEventStore';
import type {
GeoPoint,
MusicMatch,
+ ModerationTarget,
PlaceContext,
SoundMapPin as ServerSoundMapPin,
Track,
@@ -103,6 +105,7 @@ export function LiveSoundMapSection({
const [pendingMatchId, setPendingMatchId] = useState();
const [pendingMateRequestActionId, setPendingMateRequestActionId] = useState();
const [publishState, setPublishState] = useState('idle');
+ const [reportTarget, setReportTarget] = useState();
const authStatus = useAuthStore((state) => state.status);
const currentUserId = useAuthStore((state) => state.user?.id);
const addEvent = useRecommendationEventStore((state) => state.addEvent);
@@ -462,20 +465,79 @@ export function LiveSoundMapSection({
setPendingMatchId(undefined);
}
};
- const handleReportAndBlockMatch = async (match: MusicMatch) => {
- try {
- await communityApi.reportTarget({
- reason: 'safety',
- targetPinId: match.targetPinId,
- });
- await communityApi.blockUser({ targetPinId: match.targetPinId });
- setHiddenMatchIds((state) => ({ ...state, [match.id]: true }));
- setMatches((items) => items.filter((item) => item.id !== match.id));
- setServerPins((items) => items.filter((item) => item.id !== match.targetPinId));
- setMapMessage('차단/신고를 접수했어요. 해당 여행자의 공개 음악은 더 이상 추천에 보이지 않아요.');
- } catch {
- setMapMessage('차단/신고를 접수하지 못했어요. 잠시 후 다시 시도해주세요.');
- }
+ const hideMatch = (match: MusicMatch) => {
+ setHiddenMatchIds((state) => ({ ...state, [match.id]: true }));
+ setMatches((items) => items.filter((item) => item.id !== match.id));
+ setServerPins((items) => items.filter((item) => item.id !== match.targetPinId));
+ };
+ const handleBlockMatch = (match: MusicMatch) => {
+ Alert.alert(
+ '이 사용자를 차단할까요?',
+ '차단하면 이 사용자의 공개 음악과 콘텐츠가 즉시 숨겨지고 운영자에게 전달됩니다.',
+ [
+ { style: 'cancel', text: '취소' },
+ {
+ style: 'destructive',
+ text: '차단',
+ onPress: () => {
+ void communityApi
+ .blockUser({ targetPinId: match.targetPinId, targetType: 'sound_pin' })
+ .then(() => {
+ hideMatch(match);
+ setMapMessage('사용자를 차단했어요. 해당 사용자의 콘텐츠는 더 이상 보이지 않아요.');
+ })
+ .catch(() => {
+ setMapMessage('사용자를 차단하지 못했어요. 잠시 후 다시 시도해주세요.');
+ });
+ },
+ },
+ ],
+ );
+ };
+ const handleBlockRequest = (request: TravelMateRequest) => {
+ const targetUserId = request.requesterId === currentUserId
+ ? request.targetUserId
+ : request.requesterId;
+
+ Alert.alert(
+ '요청을 보낸 사용자를 차단할까요?',
+ '요청이 취소되고 이 사용자의 콘텐츠가 즉시 숨겨집니다.',
+ [
+ { style: 'cancel', text: '취소' },
+ {
+ style: 'destructive',
+ text: '차단',
+ onPress: () => {
+ void communityApi
+ .blockUser({
+ requestId: request.id,
+ targetContentId: request.id,
+ targetType: 'mate_request',
+ targetUserId,
+ })
+ .then(() => {
+ setMateRequests((items) => items.filter((item) => item.id !== request.id));
+ setMapMessage('사용자를 차단하고 요청을 숨겼어요.');
+ })
+ .catch(() => setMapMessage('사용자를 차단하지 못했어요. 잠시 후 다시 시도해주세요.'));
+ },
+ },
+ ],
+ );
+ };
+ const handleReportMatch = (match: MusicMatch) => {
+ setReportTarget({ targetPinId: match.targetPinId, targetType: 'sound_pin' });
+ };
+ const handleReportRequest = (request: TravelMateRequest) => {
+ setReportTarget({
+ requestId: request.id,
+ targetContentId: request.id,
+ targetType: 'mate_request',
+ targetUserId: request.requesterId,
+ });
+ };
+ const handleReportSubmitted = () => {
+ setMapMessage('신고를 접수했어요. 운영자가 24시간 안에 확인합니다.');
};
const handleUpdateMateRequest = async (
request: TravelMateRequest,
@@ -707,6 +769,24 @@ export function LiveSoundMapSection({
)}
+ {isIncoming ? (
+
+ handleReportRequest(request)}
+ >
+ 신고
+
+ handleBlockRequest(request)}
+ >
+ 사용자 차단
+
+
+ ) : null}
);
})}
@@ -816,10 +896,10 @@ export function LiveSoundMapSection({
-
+
- void handleReportAndBlockMatch(match)}
- >
- 차단/신고
-
+
+ handleReportMatch(match)}
+ >
+ 신고
+
+ handleBlockMatch(match)}
+ >
+ 사용자 차단
+
+
);
@@ -856,6 +945,12 @@ export function LiveSoundMapSection({
)}
)}
+ setReportTarget(undefined)}
+ onReported={handleReportSubmitted}
+ target={reportTarget}
+ visible={Boolean(reportTarget)}
+ />
);
}
diff --git a/src/components/travel/recap-map/RecapMapSection.tsx b/src/components/travel/recap-map/RecapMapSection.tsx
index b0845b2..e8bf0a8 100644
--- a/src/components/travel/recap-map/RecapMapSection.tsx
+++ b/src/components/travel/recap-map/RecapMapSection.tsx
@@ -105,7 +105,7 @@ function getTourPlaceLabel(currentPlace: PlaceContext | undefined, status: TourP
}
function toMapPin(marker: RecapMapMarker): SoundMapPin {
- const isMine = marker.ownerAlias === '나' || marker.visibility === 'private';
+ const isMine = marker.isMine;
return {
artistName: marker.artistName,
@@ -214,6 +214,7 @@ export function RecapMapSection({
const [isLoadingMarkers, setIsLoadingMarkers] = useState(false);
const [mapRegion, setMapRegion] = useState();
const [mapMessage, setMapMessage] = useState();
+ const [markerRefreshVersion, setMarkerRefreshVersion] = useState(0);
const [mapViewportSize, setMapViewportSize] = useState({
height: 0,
width: 0,
@@ -353,7 +354,7 @@ export function RecapMapSection({
ignore = true;
};
},
- [authStatus, markerQueryLat, markerQueryLng, scope],
+ [authStatus, markerQueryLat, markerQueryLng, markerRefreshVersion, scope],
);
useEffect(
@@ -536,6 +537,7 @@ export function RecapMapSection({
setSelectedPinId(undefined)}
+ onBlocked={() => setMarkerRefreshVersion((version) => version + 1)}
onOpenRecap={onOpenRecap}
pin={selectedPinGroup.pin}
/>
diff --git a/src/components/travel/recap-map/SelectedRecapPinPanel.tsx b/src/components/travel/recap-map/SelectedRecapPinPanel.tsx
index 48a32f0..054745d 100644
--- a/src/components/travel/recap-map/SelectedRecapPinPanel.tsx
+++ b/src/components/travel/recap-map/SelectedRecapPinPanel.tsx
@@ -1,8 +1,11 @@
import { Feather } from '@expo/vector-icons';
import { Image } from 'expo-image';
-import { Pressable, ScrollView, View } from 'react-native';
+import { useState } from 'react';
+import { Alert, Pressable, ScrollView, View } from 'react-native';
+import { communityApi } from '@/api/communityApi';
import { AppText } from '@/components/AppText';
+import { ReportContentSheet } from '@/components/moderation/ReportContentSheet';
import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource';
import type { RecapMapMarker } from '@/types/domain';
import { formatRecapRecordedAt } from '@/utils/dateFormat';
@@ -12,6 +15,7 @@ import type { SoundMapPin } from '../live-sound-map/types';
type SelectedRecapPinPanelProps = {
markers: RecapMapMarker[];
onClose: () => void;
+ onBlocked: () => void;
onOpenRecap: (recapId: string) => void;
pin: SoundMapPin;
};
@@ -43,9 +47,36 @@ function MarkerThumbnail({ imageUrl }: { imageUrl?: string }) {
export function SelectedRecapPinPanel({
markers,
onClose,
+ onBlocked,
onOpenRecap,
pin,
}: SelectedRecapPinPanelProps) {
+ const [reportRecapId, setReportRecapId] = useState();
+
+ const handleBlock = (recapId: string) => {
+ Alert.alert(
+ '이 사용자를 차단할까요?',
+ '이 사용자의 공개 리캡이 지도와 피드에서 즉시 숨겨집니다.',
+ [
+ { style: 'cancel', text: '취소' },
+ {
+ style: 'destructive',
+ text: '차단',
+ onPress: () => {
+ void communityApi
+ .blockUser({ targetContentId: recapId, targetType: 'recap' })
+ .then(() => {
+ onClose();
+ onBlocked();
+ Alert.alert('차단 완료', '해당 사용자의 공개 콘텐츠를 숨겼어요.');
+ })
+ .catch(() => Alert.alert('차단 실패', '잠시 후 다시 시도해주세요.'));
+ },
+ },
+ ],
+ );
+ };
+
return (
@@ -116,6 +147,32 @@ export function SelectedRecapPinPanel({
+ {!marker.isMine ? (
+ <>
+ {
+ event.stopPropagation();
+ setReportRecapId(marker.recapId);
+ }}
+ >
+
+
+ {
+ event.stopPropagation();
+ handleBlock(marker.recapId);
+ }}
+ >
+
+
+ >
+ ) : null}
상세
@@ -124,6 +181,13 @@ export function SelectedRecapPinPanel({
))}
+ setReportRecapId(undefined)}
+ onReported={() => Alert.alert('신고 접수', '운영자가 24시간 안에 확인합니다.')}
+ target={reportRecapId ? { targetContentId: reportRecapId, targetType: 'recap' } : undefined}
+ title="공개 리캡 신고"
+ visible={Boolean(reportRecapId)}
+ />
);
}
diff --git a/src/constants/legal.ts b/src/constants/legal.ts
index 623de9b..0695ea5 100644
--- a/src/constants/legal.ts
+++ b/src/constants/legal.ts
@@ -4,3 +4,5 @@ export const SOUNDLOG_SUPPORT_EMAIL =
export const SOUNDLOG_PRIVACY_URL = process.env.EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL;
export const SOUNDLOG_TERMS_URL = process.env.EXPO_PUBLIC_SOUNDLOG_TERMS_URL;
+
+export const SOUNDLOG_TERMS_VERSION = '2026-08-15';
diff --git a/src/mock-server/authHandlers.ts b/src/mock-server/authHandlers.ts
index f38d5fc..1c1568a 100644
--- a/src/mock-server/authHandlers.ts
+++ b/src/mock-server/authHandlers.ts
@@ -62,6 +62,11 @@ export const authMockHandlers = {
},
async register(request: RegisterRequest) {
+ if (!request.termsAccepted || !request.termsVersion) {
+ return mockServerDelay('auth.register', undefined as never, {
+ shouldFail: true,
+ });
+ }
const email = normalizeEmail(request.email);
if (passwordUsers.has(email)) {
diff --git a/src/types/auth.ts b/src/types/auth.ts
index 422eb48..abc9db1 100644
--- a/src/types/auth.ts
+++ b/src/types/auth.ts
@@ -10,11 +10,15 @@ export type AuthUser = {
email?: string;
profileImageUrl?: string;
provider: AuthProvider;
+ termsAcceptedAt?: string;
+ termsVersion?: string;
};
export type LoginRequest = {
email: string;
password: string;
+ termsAccepted: true;
+ termsVersion: string;
};
export type RegisterRequest = LoginRequest & {
diff --git a/src/types/domain.ts b/src/types/domain.ts
index 8790f2b..9125ec1 100644
--- a/src/types/domain.ts
+++ b/src/types/domain.ts
@@ -135,6 +135,7 @@ export type MomentLog = {
travelMode?: TravelMode;
moodTags: MoodTag[];
source: 'camera';
+ moderationStatus?: 'approved' | 'pending' | 'rejected';
templateId?: RecapTemplateId;
};
@@ -149,6 +150,7 @@ export type RecapItem = {
sessionId?: string;
thumbnailMomentId?: string;
visibility?: RecapVisibility;
+ moderationStatus?: 'approved' | 'pending' | 'rejected';
};
export type RecapTemplateId = 'album' | 'film' | 'lp' | 'map';
@@ -163,6 +165,7 @@ export type RecapMapMarker = {
distanceMeters?: number;
id: string;
imageUrl?: string;
+ isMine: boolean;
location: GeoPoint;
ownerAlias: string;
placeName: string;
@@ -217,10 +220,27 @@ export type RecapShare = {
thumbnailMomentId?: string;
travelSummary?: RecapTravelSummary;
visibility?: RecapVisibility;
+ moderationStatus?: 'approved' | 'pending' | 'rejected';
};
export type CommunityVisibility = 'companions' | 'nearby' | 'private';
+export type ModerationTargetType =
+ | 'user'
+ | 'sound_pin'
+ | 'recap'
+ | 'travel_room_moment'
+ | 'travel_room_comment'
+ | 'mate_request';
+
+export type ModerationTarget = {
+ requestId?: string;
+ targetContentId?: string;
+ targetPinId?: string;
+ targetType: ModerationTargetType;
+ targetUserId?: string;
+};
+
export type TravelRoomMember = {
id: string;
userId: string;
@@ -239,10 +259,11 @@ export type TravelRoomMoment = {
track?: Track;
commentCount?: number;
comments?: Array<{
- id: string;
- userId: string;
body: string;
createdAt: string;
+ displayName?: string;
+ id: string;
+ userId: string;
}>;
createdAt: string;
};