Feed screen props (mobile)
화면 픽스처(앱 저장소):
src/react/ui/mobile/fixtures/FeedScreen.fixture.ts
<a id="notification-target-integration"></a>
2026-09-08 통합 — 알림에서 포스트 이동
notificationTargetStore는 실제 ledger의 sessionId를 기존 loadSessionDetailData와 listSessionComments(limit=1)로 조회한다. 두 RPC가 서버 권한을 확인하며 상세 ownerId와 반응 응답 owner_user_id가 일치해야 한다. 좋아요/댓글을 쓴 actor를 내 포스트의 소유자로 사용하지 않는다. 친구 포스트만 실제 세션 소유자와 일치하는 알림 actor를 author로 공급한다.
조회한 상세의 실제 종목·복합 세트·자유기록·KPI와 서버 반응 수로 한 장의 포스트를 투영한다. mergeNotificationTargetPost는 이미 로드된 같은 세션은 교체하고 페이지 밖이면 한 장을 삽입한다. focusSessionId는 그 포스트를 스크롤/강조하고 onFocusConsumed가 소비한다. 댓글 알림은 포스트 확보 뒤 해당 세션의 댓글 시트를 연다. 삭제/권한 변경은 오류 상태이며 다른 세션으로 대체하지 않는다. 계정 변경·다른 화면 이탈·더 최근 알림 선택 뒤 늦게 온 응답은 무시한다.
검증: tests/react/notificationIntegration.test.mjs. 그룹 초대 본문은 그룹 탭 이동 계약을 따른다.
UiFeedScreen — 피드 탭. 세션 포스트 타임라인 (데스크톱 홈 피드의 모바일 대응, 코드 독립). 화면 fixture는 위 앱 저장소 경로에 두고 화면 props와 같은 PR에서 갱신한다. 표시 의미의 규범은 이 계약이며, 반입 방식은 전달 계약을 따른다.
Props
| prop | shape | notes |
|---|---|---|
posts | [{ kind:'session', session, title, when, duration?, review?, kpi, mains?, notes? }] | 최신순. kind:'session' 외 항목은 미표시 |
posts[].session | { id, source:"sessions", status, date, title, author? } | lite 객체 — 자세히 클릭 시 그대로 콜백에 전달. source:"sessions"는 컨테이너(openSessionDetail)가 요구한다(없으면 조용히 실패 — BUG-007, 2026-08-23). 친구 카드는 author를 함께 든다 |
posts[].when | "YYYY-MM-DD · 시각 · 소요" | duration 미주입 시 3분절이면 마지막 분절을 소요로 파싱해 첨부 카드 KPI로 이동 |
posts[].review | string? | 세션 리뷰 본문 (없으면 미표시) |
posts[].kpi | { volume, sets } | volume = 든 중량 총합(kg) |
posts[].mains | [{ name, sets:[{ load?, reps }] }]? | 기록 첨부 카드 종목 행. 세트 토큰은 연속 동일 구성(무게·횟수·세트 종류)을 무게 횟수x세트수로 축약 — 1세트도 x1, 그룹 보드 GbSeq와 같은 규칙(오너 지시 2026-08-26). load 없으면 무게 생략(10x3), 무게 × N회 꼴이 아닌 토큰(유산소 in·실패 3/5회)은 축약 없이 원문 유지 |
posts[].notes | [{ id?, title, note }]? | 자유 기록 행 — 제목(없으면 "자유 기록") + 메모 앞 2줄 미리보기. 종목 행 뒤에 레터를 이어 붙이고, mains가 없어도 첨부 카드를 연다(세트 KPI는 mains가 있을 때만) (2026-08-22) |
posts[].author | { id, name, handle?, photo?, short? }? | 없으면 내 포스트(profile 헤더), 있으면 팔로우한 친구 포스트 — 아바타/이름 탭 = 친구 프로필 |
posts[].social | { likeCount, commentCount, likedByMe } | 반응 카운트(2026-08-23). 푸터 왼쪽에 하트(눌렀으면 채움)·말풍선 + 카운트(0이면 숫자 생략). onToggleLike/onOpenComments가 없으면 버튼을 그리지 않는다 |
likePendingIds | string[] | 좋아요 응답 대기 중인 세션 id — 그 포스트의 하트는 disabled |
comments | { sessionId, status:'loading'|'ready'|'error', items:[{ id, body, createdAt, mine, canDelete, author:{ id, name, photo?, short? } }], truncated, error, submitting, deletingId } | null | non-null이면 댓글 시트(목록 오래된 순 + 하단 입력 행). canDelete인 행만 "삭제" 노출 |
profile | { name, photo?, short? } | 내 포스트 헤더 아바타/닉네임 |
following | [{ id, name, handle?, photo?, short? }] | 팔로잉 스트립(+친구 추가 버튼) + 검색 시트 기본 목록. 미공급 시 스트립 미노출 |
friendResults | [followingLite] | 아이디/이름 검색 결과 — 컨테이너 소유(onSearchFriends(q)로 요청받아 주입) |
friendProfile | friendLite + stats?/big4? | null | { id, name, handle?, photo?, short?, goal?, stats?:{monthDays,monthVolume,yearPrCount}, big4?:[{name,pr,unit?}] } — non-null이면 프로필 시트(헤드 + 이번 달 운동/볼륨/올해 PR + 4대 운동 1RM) 표시(onOpenFriend(id) 하이드레이션 패턴) |
hasMore / loading | boolean | 무한 스크롤 — 하단 sentinel 400px 접근 시 onLoadMore() 1회, loading 중엔 재호출 안 함 |
Callbacks
onOpenSession(sessionLite)— 포스트 "자세히 →". 컨테이너가 세션 상세로 라우팅(복귀 라벨 "피드로 돌아가기" 권장).onOpenOwnPostMenu(sessionLite)— 내 포스트(author없음) 헤더의 ⋯(data-lg-action="feed.ownPostMenu"). 화면이 시트(data-lg-hook="feedOwnPostMenu", 행 "수정"feed.editOwnPost)를 열고, "수정"은onOpenSession(sessionLite)과 같은 세션 상세 페이지를 연다 — 수정 화면 = 세션 상세(오너 지시 2026-09-15, 앱 #1634). 친구 포스트의 ⋯(feed.postMenu, 신고·차단)과 동작 이름을 분리한다. 콜백을 주지 않으면 ⋯를 그리지 않는다.onLoadMore()— 다음 페이지 요청.onRefresh() → Promise?— 당겨서 새로고침(2026-08-25). 놓는 즉시 콘텐츠는 원위치 — 발동 시에도 유지 오프셋·busy 표시 없음(오너 피드백 2026-08-25), Promise가 접힐 때까지 재발동만 막는다. 미공급이면 제스처 비활성(인디케이터는 그려지되 발동 없음).onSearchFriends(q)— 아이디/이름 검색 요청(디바운스 컨테이너 소유) →friendResults주입. 시트 닫힐 때""로 호출됨.onOpenFriend(id)/onCloseFriend()— 친구 프로필 시트 열기/닫기(friendProfile하이드레이션). 운영 배선(2026-08-23): 컨테이너는onOpenFriend(id)를 친구 리포트 페이지로 보낸다(아래 "2026-08-23 친구 리포트") — 프로필 시트(friendProfile)는 여전히 미공급.onFollow(id)/onUnfollow(id)— 팔로우 토글(검색 시트 행 · 프로필 시트 버튼). 버튼 상태는following포함 여부로 파생.onOpenFriendPage(id)— 프로필 시트 "프로필 보기 →". 컨테이너가 친구 메인 페이지로 라우팅 —UiHomeScreen을 친구 데이터(profile/profileStats/weekLabel/weekStats/attendance/lifted/feed=친구 포스트)로 렌더 +backLabel/onBack(상단 돌아가기 행). 시작 FAB는 홈 탭 전용이므로 미노출.onToggleLike(sessionId, social)·onOpenComments(sessionId)— 포스트 푸터 하트/말풍선(2026-08-23).onCloseComments()·onSubmitComment(body) → Promise<boolean>(true면 입력을 비움) ·onDeleteComment(commentId)— 댓글 시트.UiHomeScreen도 같은 7 prop을 받는다(홈 피드 = 공용FdPost).
2026-09-14 · 피드 범위 탭(팔로잉 | 추천) + 내 피드 — [전달 대기, 디자인 확정]
- 범위 탭: 내 피드 탭(친구 스코프 아님) 워드마크 행 아래 전폭 2열 밑줄 탭
.fd-scope(role=tablist): 팔로잉(following= 팔로우한 사람 + 내 포스트, 종전 피드) | 추천(forYou= 추천 피드). 새 props(선택):scope?: "following" | "forYou" | "mine"·onChangeScope?(scope).onChangeScope미공급 = 탭 없음(종전 화면). 어느 포스트가 어느 범위인지는 컨테이너가posts로 공급 — 화면은 탭과 콜백만(범위 전환 시 컨테이너가 새 범위의 첫 페이지 적재 ·loading/hasMore같이 교체). 추천 서버 자원·정책은 Codex/서버 후속. - 내 피드(
scope: "mine"+onBack): 드로어 프로필 옆›(NavDrawerProps.onOpenMyFeed) → 현재 탭 스택을 비운 뒤 새 스택 항목 kindmyFeed(등록표 신설 요청) push — 셀UiStackScreen className="fd-mine"(탭바 유지, 공용 pg-overlay 숨), 안에UiFeedScreen scope="mine" posts=내 포스트만 onBack=startBack. 워드마크 행 = 좌측 ‹(sj-back마크업, 미등재 마커 없음) + 중앙 "내 피드"(UiBrandRow ownerLabel) + 우측 워드마크 — 범위 탭·친구 검색/팔로잉 아이콘 없음. 드로어›는onOpenMyFeed미공급 시 종전대로 닫기. - Codex 후속:
types/feedPresentation.ts MobileFeedScreenProps에scope·onChangeScope·types/reactComponents.ts NavDrawerProps에onOpenMyFeed정식 등재 ·controllers/screenKinds.ts에myFeed등록 · 컨테이너 범위별 피드 자원(추천 피드 RPC) · 픽스처FeedScreen.fixture.ts는 선택 prop이라 무변(추천 시나리오 추가는 자원 확정 후).
알림 → 포스트 점프 (2026-09-08)
focusSessionId?: string|null·onFocusConsumed?()— 워드마크 알림(친구 운동 완료kind:"feed"· 내 포스트 좋아요"like"· 내 포스트 댓글"comment", 모두sessionId동반) 탭 시 컨테이너가 피드 탭으로 전환하고 이 prop을 공급한다. 화면은 해당feedSessionPost(option-value = 세션 id)를 스크롤 루트 상단(워드마크 행 아래)으로 맞추고 1.6s accent-wash 하이라이트(.fd-focus) 후onFocusConsumed()로 소비를 알린다. 목록에 아직 없으면 items 갱신마다 재시도(컨테이너가 필요 시 해당 포스트까지 페이지네이션). 알림 문구·생성은 컨테이너 몫 — 완료 "{이름} 님이 {제목} 운동을 완료했어요" · 좋아요 "{이름} 님이 {제목}을 좋아해요"(같은 포스트 여러 명은 "{이름} 님 외 N명이 …"로 접기 권장) · 댓글 "{이름} 님이 {제목}에 댓글을 남겼어요: “{본문 앞부분}”". 댓글 알림은 점프 뒤 댓글 시트(onOpenComments(sessionId))까지 열어도 좋다(컨테이너 판단). 소스 = 서버 session_likes/session_comments(§2026-08-23) — 내 세션 대상 행만.
2026-09-08 · 친구 검색과 팔로잉 목록 분리
- 워드마크 왼쪽의 친구 검색(
feedFriendAdd)과 팔로잉 목록(feedFollowing)은 각각 센터 모달을 연다. 친구 스코프에서는 두 진입 버튼을 숨긴다. - 검색 모달은 빈 검색어에서 목록을 표시하지 않는다. 검색·디바운스·팔로우 처리는 기존
onSearchFriends·friendResults·onFollow·onUnfollow계약을 유지한다. - 팔로잉 모달(
feedFollowingSheet)의 행은 열릴 때following을 복사한 스냅샷이다. 언팔로우해도 열린 모달에는 행을 남겨 다시 팔로우할 수 있고, 버튼 상태는 현재following으로 표시한다. 닫고 다시 열면 최신 목록을 사용한다. 행의 아바타·이름 탭은 모달을 닫고 기존onOpenFriend(id)를 호출한다. - 알림 포스트 점프(
focusSessionId·onFocusConsumed)는 두 모달과 독립적으로 유지한다.
Markers
data-lg-hook="mobileFeedView"data-lg-view="dashboard.feed"(식별용, 레지스트리 등록 완료)- 각 세션 포스트는
data-lg-hook="feedSessionPost"와data-lg-option-value=<session id>를 제공한다. - 소셜(2026-07-22, 식별용 등록 완료):
feedFollowing(스트립) ·feedFriendAdd(친구 추가 버튼) ·feedFriendSearchSheet·feedFriendProfile(시트). - 액션은 기존 등록명만 사용:
session.open(+option-value=세션 id). 팔로우/언팔로우는 콜백 전용 — 계약 테스트용 액션 마커(friend.follow등)가 필요해지면 Codex 등록 후 부착. - 반응(2026-08-23, 등록 완료): 액션
feed.like·feed.comments(+option-value=세션 id), 훅feedCommentsSheet(시트) ·feedComment(댓글 행).
세션 상세 = 화면 위 드로어 (2026-07-30)
- 포스트 "자세히"
onOpenSession(lite)→ 컨테이너가 full 세션을selectedSession으로 공급하면 피드 탭 안에 일지와 동일한 세션 상세 슬라이드 오버레이(이슈 #985, 2026-08-31 — 구 바텀 드로어 대체)가 열림: 우→좌 진입(pg-slidein), 좌→우 스와이프(공용useSlideBack, dx>45·가로 우세)/뒤로 버튼 = 슬라이드 퇴장, 탭바를 덮는 층(.sj-moz46), hooksessionDetailSheet유지. 탭 이동 없음(컨테이너keepMobileOrigin). 세션이 열린 채 재마운트(수정 플로우 복귀)면pg-restore로 진입 애니 억제. - 신규 props(선택):
selectedSession·onCloseSession()·isWorkoutActive·onEditPlan/onStartWorkout/onUpdateSession/onDeleteSession/onDownloadImage. (홈 내 피드 포스트는 2026-08-31 철거 — HomeScreen은 이 쌍을 더 받지 않고, 홈 세션 상세는 하루 드로어 스와프 전용.) 신규 마커 0.
2026-08-12 피드 정책
today?prop 신설("YYYY-MM-DD") — 포스트when의 선두 날짜를 7일 이내 상대 표기로 변환(오늘/어제/2~7일전), 그 밖은 원 표기. 홈 피드(FdPost 공용)도 동일.- 포스트 본문 순서(앱 #1634, 2026-09-15): 헤더 → 후기(
review) → 사진 행(photos, 가로 스냅 + 마지막 슬라이드 상세) → (사진 없을 때) 기록 첨부 → 푸터. 후기가 있으면 사진보다 먼저 읽힌다. - 피드 탭 = 내 포스트 + 팔로잉 포스트.
author가 없으면 현재 사용자 포스트이고,author가 있으면 팔로우한 사용자 포스트다. - 운영 컨테이너는
today와 현재 사용자의get_profile_feed결과를 공급한다. 현재 서버에는 팔로잉 피드 RPC가 없으므로 지금은 내 완료 세션이 표시되며, 소셜 저장소 도입 후 같은posts계약에 author 포스트를 합친다. - 저장 완료 시 컨테이너가 프로필 피드를 강제 갱신하므로, 새 세션은 새로고침 없이 피드에 나타나야 한다. CASE-004가 저장 직후와 재진입 후 이 왕복을 검증한다.
2026-08-22 친구 검색·팔로우 운영 배선 (v1)
- 서버: 마이그레이션
20260821460000—public.user_follows(follower/followee, RPC 전용 쓰기, 본인 follower 행만 읽기) +search_user_profiles_v1·follow_user_v1·unfollow_user_v1·list_following_v1. pgTAPsupabase/tests/database/feed_friend_follow_v1.test.sql. - 컨테이너:
controllers/followingStore.ts(프레임워크 무의존,tests/react/followingStore.test.mjs) +followingController.ts→appController렌더 컨텍스트feedFollowing · feedFriendResults · searchFeedFriends · followFeedFriend · unfollowFeedFriend→mobileApp이UiFeedScreen의following · friendResults · onSearchFriends · onFollow · onUnfollow에 연결. 팔로잉 목록은 피드 탭 진입 시 1회 로드, 오너 전환 시 리셋. - 검색 키 = 아이디(
profiles.handle, 2026-08-2320260821470000) + 닉네임(display_name): 대소문자·선두@를 무시한 부분일치, 정렬은 아이디 정확→아이디 접두→닉네임 정확→닉네임 접두→이름순, 본인·온보딩 미완료 프로필 제외, 빈 검색어 = 결과 없음. 온보딩의@handle은complete_onboarding(payload.handle)로 저장되고 내 정보 탭에서 설정/변경한다(docs/data/onboarding-data-model.md).followingLite.handle=profiles.handle(없으면 빈 문자열 → 화면은@줄을 그리지 않는다 — 2026-08-23 UUID 폴백 제거). - 디바운스 250ms는 스토어 소유(계약대로). 늦게 도착한 이전 검색어 응답·오너 전환 뒤 응답은 버린다. 시트 닫힘의
onSearchFriends("")는 결과를 즉시 비운다. - 팔로우/언팔로우: RPC 성공 시
following을 낙관 반영하고 토스트 후list_following_v1로 재동기화. 버튼 상태는 계약대로 화면이following포함 여부로 파생한다. - 친구 사진(2026-08-23):
following[].photo/friendResults[].photo= 서버 행의avatar_url(소셜 제공 http 사진) 또는avatar_path를profile-images에서 서명한 URL(7일,socialDomain.withFriendPhotos— 읽기 정책profile_image_objects_read_authenticated). 둘 다 없으면 빈 문자열 → 이니셜 폴백. - 팔로잉 포스트(2026-08-23,
20260821480000):get_profile_feed페이지가 본인 + 팔로우한 사용자의 완료 세션을 한 정렬(date·created_at·id desc)로 싣고, 친구 카드는author{id, display_name, handle, avatar_path, avatar_url}를 낸다(본인 카드 null). 컨테이너(statsDomain.loadProfileFeedData)가 author 사진을 서명(photo_url)하고buildProfileFeedItems/buildMobileFeedPosts가posts[].author = { id, name, handle, photo, short }로 투영 →FdPost가 친구 헤더를 그린다(데스크톱DkH3SessionPost도 동일). 팔로우/언팔로우 성공 시followingStore.onFollowingChanged→loadProfileFeed({ force: true })로 피드를 다시 읽는다. 서버 표시 함수는 팔로워에게 팔로우한 사용자의 main 카드를 허용한다(profile_feed_can_view_v1). 제품 결정: 팔로우는 승인 없는 단방향이라, 누군가를 팔로우하면 그 사람의 완료 세션(제목·리뷰·자유 기록·main 카드·KPI)이 내 피드에 보인다. - 미공급(계약만 유지):
friendProfile/onCloseFriend/onOpenFriendPage(친구 프로필 시트·친구 홈 페이지).onOpenFriend는 2026-08-23부터 친구 리포트 페이지로 배선됨(아래).
2026-08-23 친구 리포트 (피드 친구 아이디/아바타 탭)
- 오너 요청: "피드에서 친구 아이디를 누르면 친구의 리포트 페이지를 조회". 친구 포스트 헤더의 아바타/닉네임 탭(
FdPost→onOpenFriend(author.id))이 그 친구의 리포트 페이지를 연다 — 프로필 시트를 거치지 않는다(v1 결정: 시트·친구 홈은 계약만 유지). - 서버: 마이그레이션
20260821520000—get_following_volume_overview_v1(p_user_id, p_as_of). 팔로우 게이트(profile_feed_can_view_v1, 비팔로우 42501) 뒤에서 같은 트랜잭션 로컬로 JWT sub 클레임을 대상으로 바꿔 기존get_volume_overview(p_as_of, 4)체인을 그대로 호출하고 원복 — 응답은 내 리포트와 같은 v4 페이로드. pgTAPsupabase/tests/database/following_volume_overview_v1.test.sql(14). - 컨테이너:
controllers/friendReportStore.ts(프레임워크 무의존: 열기→로드→ready/error, 늦은 응답 폐기, 42501 문구, 닫기/오너 전환 리셋;tests/react/friendReportStore.test.mjs) +friendReportController.ts→appController렌더 컨텍스트friendReport · openFriendReport · closeFriendReport · retryFriendReport(히스토리 레이어friendReport, 스크롤 복원 대상) →mobileApp이onOpenFriend를 피드 카드author(없으면following)로 친구 lite를 만들어openFriendReport에 넘기고, 친구가 열려 있는 동안 피드 탭 자리에UiReportScreen을 친구의 볼륨 개요 투영(buildMobileReportProjection)으로 렌더한다:profile=친구 lite,backLabel="피드로 돌아가기"/onBack, 기간·종목 컨트롤은 친구별 초기화,onOpenExercise/onOpenDay/onOpenWeek미공급(조회 전용 — 종목 점프·날짜/주차 드로어 없음). 뒤로가기(하드웨어/브라우저)·탭 이동도 닫는다. - 본인 카드는
author가 없으므로 탭 대상이 아니다. 데스크톱 홈 피드 카드(DkH3SessionPost)는 작성자 탭 계약이 없어 이번 범위 밖. - 미공급(계약만 유지):
friendProfile/onOpenFriend/onCloseFriend/onOpenFriendPage(친구 프로필 시트·친구 홈 페이지) — 친구 포스트 아바타/이름 탭은 아직 동작하지 않는다.
2026-08-23 좋아요·댓글 + 친구 세션 "자세히" (v1, 20260821530000)
- "자세히" 수리:
get_session_detail이 소유자만 허용해 친구 카드의 "자세히 →"는 P0002 → 토스트로 끝났다. 이제 소유자 또는 완료 세션의 팔로워(profile_feed_can_view_v1)가 읽는다. 컨테이너(sessionDetailStore.openCompletedWorkoutDetail)는 서버가 돌려준session.user_id(ownerId)가 나가 아니면selectedSession.readOnly = true+owner(카드 author)를 붙이고,mobileApp은onEditRecord/onDeleteSession/onUpdateSession/onStartWorkout을 넘기지 않으며selectedSessionProfile(= owner)로 드로어의 리뷰 아바타를 그린다. 화면은 콜백 유무로만 수정·삭제 표면을 닫는다(계약 불변). 쓰기 RPC는 여전히 소유자 고정. - 서버:
session_likes(session_id, user_id)·session_comments(id, session_id, user_id, body 1~500자)— RPC 전용 쓰기, 읽기 정책 본인 행.set_session_like_v1(p_session_id, p_liked)·add_session_comment_v1(p_session_id, p_body)·delete_session_comment_v1(p_comment_id)(작성자 또는 세션 소유자) ·list_session_comments_v1(p_session_id, p_limit ≤200)(오래된 순). 대상 = 본인 또는 팔로우한 사람의 완료 세션.get_profile_feed카드에 추가 전용social{like_count, comment_count, liked_by_me}. pgTAPfeed_reactions_v1.test.sql36. - 컨테이너:
controllers/feedSocialStore.ts(프레임워크 무의존,tests/react/feedSocialStore.test.mjs) +feedSocialController.ts→appController렌더 컨텍스트feedSocialBySession · feedLikePendingIds · feedComments · toggleFeedLike · openFeedComments · closeFeedComments · submitFeedComment · deleteFeedComment→mobileApp이withFeedSocialOverrides로 피드·홈 포스트의social을 스토어 정본(반응한 세션)으로 덮고 7 prop을UiFeedScreen/UiHomeScreen에 연결. 좋아요는 낙관 반영 → 서버 응답으로 확정, 실패 시 되돌림+토스트. 댓글 시트는 열 때 목록을 읽고 카운트를 동기화, 늦은 응답·오너 전환 뒤 응답은 버린다. 댓글 author 사진은socialDomain.withFriendPhotos와 같은 서명 경로. - 데스크톱: 홈 피드
DkH3SessionPost는 카운트·버튼 미배선(계약 밖). 친구 카드 "자세히"는 상세를 읽지만 읽기 전용 표면 닫기는 모바일만 — 데스크톱 수정·삭제는 서버가 거부(42501/P0002 토스트).
2026-08-24 웜업-only 종목 표시 + 피드 카드 표시 상한 해제 (20260821610000)
- 오너 지시: 웜업 세트만 있는 종목이 피드 카드에서 통째로 빠졌다 — "웜업 세트만 있으면 그 웜업 세트라도 표시. 세트 5개·종목 6개 표시 상한도 해제."
- 서버:
get_session_presentation_mains_json(_v2_engine)에 표시 상한 파라미터p_max_mains(기본 6)·p_max_sets(기본 5, 물리 계약 상한 24/32로 클램프) + 웜업 폴백 — 세트가 하나라도 있는 종목은 카드 후보이고, main/top이 있으면 그 세트만(웜업 비표시), main/top이 없으면 웜업 세트를 대신 담는다(type:'warmup'토큰).get_profile_feed(_v2_engine)는 카드 mains를(…, 24, 32)로 요청(피드 표시 상한 해제)하고 페이지 바이트 상한 200,000 → 3,000,000.get_session_search는 재발행 없음 — 기본 인자(6/5) 상한 유지, 웜업 폴백만 공유. pgTAPfeed_warmup_only_cards.test.sql15. - 클라이언트:
profileFeed예산 mains 6→24 · 세트 토큰 5→32 · 응답 3,000,000B. 피드는 truncated 충만 단언을 걸지 않는다(구 서버 6/5 페이지와의 전환 안전 + 물리 상한이라 truncation은 레거시 안전판) — 검색은 계속 단언.posts[].mains[].sets[].type에'warmup'이 올 수 있다. - 표기: 종목 행 세트 수는 메인 기준 그대로, 웜업-only 행만
웜업 N세트(FdAttach·DkH3Mains·DkSrchMains). 첨부 헤더 세트 KPI는kpi.sets > 0일 때만(웜업만 있는 세션에 "0세트"를 달지 않는다 — KPI 규칙 세트수=메인만·볼륨=전세트는 불변). - 배포 순서: 구 번들 어댑터는 피드 mains ≤6·sets ≤5를 단언하므로 마이그레이션은 Vercel 새 번들 확인 뒤 적용.
2026-08-25 신고·차단 (UGC 안전 v1, 20260821740000 — 이슈 #670)
- 오너 배경: App Store Guideline 1.2 — 신고·차단·게시 전 필터링·연락처·무관용 약관 5요건. 결정 D1~D8은 이슈 #670 권장안 채택.
- ⋯ 메뉴: 타인 포스트(
author있음) 헤더 오른쪽 끝에⋯버튼(feed.postMenu) →FdPostMenuSheet(hookfeedPostMenu): 신고하기(feed.report) / 차단하기(feed.block).onOpenPostMenu(target)—target = { kind:'session', targetId, sessionId, authorId, authorName }. 본인 포스트에는 그리지 않는다(스토어도authorId === ownerId를 거른다). - 댓글 신고: 댓글 시트의 타인 댓글(
mine=false) 행에 "신고"(feed.commentReport) —onReportComment(comment)가 메뉴를 거치지 않고 사유 시트로 바로 간다. - 신고 사유 시트
FdReportSheet(hookfeedReportSheet):REPORT_REASONS(spam·harassment·sexual·violence·hate·illegal·other, 라벨은 스토어 정본) 라디오 + 선택 상세(≤500자) + 접수(feed.reportSubmit, 사유 선택 전 disabled). 성공 토스트 "신고가 접수되었어요. 24시간 안에 확인할게요." - 차단 확인
FdBlockConfirm(hookfeedBlockConfirm,feed.blockConfirm): 양방향 팔로우 즉시 해제·해제해도 미복구(D1)를 명시하고 확인을 거친다. - 컨테이너:
controllers/moderationStore.ts(프레임워크 무의존,tests/react/moderationStore.test.mjs13) +moderationController.ts→appController렌더 컨텍스트moderation · openModerationMenu · closeModerationMenu · openModerationReport · closeModerationReport · submitModerationReport · openModerationBlockConfirm · closeModerationBlockConfirm · confirmModerationBlock · openBlockedUsers · closeBlockedUsers · unblockUser→mobileApp이UiFeedScreen에moderationMenu · moderationReport · moderationBlockConfirm · reportReasons+ 콜백 8종을 연결. - 즉시 숨김(D3/D1): 신고·차단 성공 시 스토어가 로컬 숨김(
hiddenSessionIds/hiddenCommentIds/hiddenAuthorIds)을 들고,mobileApp이withModerationHides(피드·홈 포스트)·withCommentHides(댓글 시트)로 병합한다. 서버 재조회 페이지(get_profile_feed등,20260821740000필터)는 이미 걸러져 온다. - 정지 게이트(D5): 부팅 시
get_my_moderation_state_v1(fail-open) → 정지면SuspensionGate(hooksuspensionGate)가 앱 전면을 덮는다(닫기 없음, 문의 주소 병기). - 서버 가시성 계약: 차단 = 양방향 불가시(
profile_feed_can_view_v1게이트 + 우회 5 RPC 재발행) · 신고자 숨김 = 비기각 신고의 대상(D3) · 내려짐(D4) = 소셜 표면 제거(소유자 사적 기록 유지) · 정지(D5) = 타인에게 불가시 + 뮤테이션 42501 · 금칙어(D6) = 댓글·핸들 22023(분리 문자 우회 수집). pgTAPugc_safety_v1.test.sql47.
§친구 스코프 전환 (2026-08-25 디자인 확정 — 컨테이너 구현 규칙)
| 트리거 | 상태 변화 | 복귀 |
|---|---|---|
| 피드 포스트 아바타/이름 탭(onOpenFriend) · 그룹 멤버 탭 | 중간 프로필 시트 없이 친구 페이지(랜딩) 즉시 마운트 — 연 탭(피드·그룹) 화면 스택의 항목(friendScope, 이슈 #1393 Phase 4). 진입 = 공용 슬라이드 셸(.pg-slidein, 오른쪽 → 왼쪽, 규칙 3 — 구 왼쪽 진입 키프레임 폐지). 내 피드는 뒤에 정지 상태로 유지(중간 빈 프레임 금지) | — |
| 페이지 안 ‹ 돌아가기 · 앱 안 스와이프 · iOS 가장자리 · Android 뒤로 | 스택 pop = 슬라이드 아웃 재생 후 항목 제거(내 피드로 복귀). 페이지 위에 하루·월간·세션 상세·PR 전체 보기가 열려 있으면 그것부터 한 겹씩 | 하단 탭바 복원(칩 바 fsb 신고 해제) |
| 페이지 안 4탭(프로필·일지·리포트·피드) 전환 | 교체(스택 항목 아님) — 친구 항목 위에 열린 층을 먼저 걷어낸다. 친구 피드의 세션 상세는 friendSession 슬롯 | — |
| 하단 탭 전환(다른 내 탭으로 이탈) | 스택 초기화 — 애니 없이 즉시 제거 | — |
| prefers-reduced-motion: reduce | 진입·퇴장 애니 생략, 즉시 전환 | — |
- 정지 상태 transform은 반드시 none — 스코프 하단 도크(.fsb)가 .app 기준 absolute라 transform 잔존 시 anchoring이 깨진다.
- 스코프 판 = 4화면(프로필 랜딩·일지·리포트·피드) 공용 래퍼 1장. 판 내부 탭 전환(friendNav.onGo)은 애니 없음. 판정·전환 로직은 컨테이너 소유(screens 금지) — 목업 배선·키프레임은 로컬 하니스(_preview-local.html)에만 있음.
- 컨테이너 상태 정본(2026-08-25, 이슈 #792) =
controllers/friendScopeStore.ts— 친구 정체·활성 탭·데이터 슬라이스 3종(report=볼륨 개요·pr=PR 개요get_following_pr_overview_v1·calendar=달력 한 달)을 한 스토어가 소유(구 friendReportStore 흡수, 히스토리 레이어friendScope). 랜딩 데이터 = 친구 PR 개요를 내 홈과 같은 투영으로 — 보드 행은 즐겨찾기(비공개) 대신 보유 기록 상위 목록.
A11 resource 연결 (2026-09-10)
내 피드와 친구 피드는 동일 resource의 owner/target/cursor 창을 사용한다. refresh와 append가 겹치면 새 첫 페이지의 cursor를 따른다. 확정 댓글·좋아요를 늦은 조회가 되돌리지 못하며 unfollow/block 뒤 해당 친구의 이전 데이터는 표시하지 않는다. 소유·무효화 계약을 따른다.
2026-09-14 · 포스트 사진 카드 (디자인 델타, base main b622cc808b47)
posts[].photos?—ReadonlyArray<SessionPhoto> | null(타입 정본mobile/lib/PhotoCard.tsx:{ id, url, overlay?: { theme: "white" | "black", stats: { volumeKg, sets, duration, exercises[{ name, sets, volumeKg }] } } }). 완료 화면에서 첨부한 세션 사진 — 컨테이너가 세션에 귀속된 사진·오버레이를 그대로 실어 준다(화면 재계산 0). 없거나 비어 있으면 종전 포스트와 동일.- 위치: 포스트 헤더(이름·시각) 바로 아래 히어로, 리뷰 본문 위. 사진이 있으면 가로 스냅 행(
.fd-photos.multi, 다음 장 12% 노출) = 사진 카드들 + 마지막 슬라이드 = 상세 운동(기록 첨부 카드FdAttach) — 사진과 같은 4:5 박스(.fd-slide-attach)에 넣어 스와이프해도 피드 높이가 변하지 않는다. 카드 내용이 박스보다 크면FdFitSlide가 폭 1/k·scale(k)로 축소(시각 효과만, 판정 없음). 사진이 없으면 종전대로 리뷰 아래 전폭 기록 카드(09:40Z 지시). 홈 피드(HomeScreen이 같은FdPost렌더)도 동일. - 카드 = 사진 위 워드마크 + 우측 종목 요약(종목명 kg 우측 붙임 + 채운 막대만, 최대 3 + 그 외, 카드 폭 절반) + 맨 아래 한 줄 좌 총 kg(최대) · 우 n min, 글자색 =
overlay.theme— 규격은 workout-screen-props.md 2026-09-14 절. - 타입:
FdPost는MobileFeedPost & { photos? }로컬 확장으로 읽는다 — Codex 후속MobileFeedPost.photos(types/feedPresentation) ·FeedPostItem.photos(contracts/ui/socialDataProps) 정식화 + 피드 RPC에 세션 사진 동봉. 마커 신규 0.