Skip to content

Feed screen props (mobile)

화면 픽스처(앱 저장소): src/react/ui/mobile/fixtures/FeedScreen.fixture.ts

<a id="notification-target-integration"></a>

2026-09-08 통합 — 알림에서 포스트 이동

notificationTargetStore는 실제 ledger의 sessionId를 기존 loadSessionDetailDatalistSessionComments(limit=1)로 조회한다. 두 RPC가 서버 권한을 확인하며 상세 ownerId와 반응 응답 owner_user_id가 일치해야 한다. 좋아요/댓글을 쓴 actor를 내 포스트의 소유자로 사용하지 않는다. 친구 포스트만 실제 세션 소유자와 일치하는 알림 actor를 author로 공급한다.

조회한 상세의 실제 종목·복합 세트·자유기록·KPI와 서버 반응 수로 한 장의 포스트를 투영한다. mergeNotificationTargetPost는 이미 로드된 같은 세션은 교체하고 페이지 밖이면 한 장을 삽입한다. focusSessionId는 그 포스트를 스크롤/강조하고 onFocusConsumed가 소비한다. 댓글 알림은 포스트 확보 뒤 해당 세션의 댓글 시트를 연다. 삭제/권한 변경은 오류 상태이며 다른 세션으로 대체하지 않는다. 계정 변경·다른 화면 이탈·더 최근 알림 선택 뒤 늦게 온 응답은 무시한다.

검증: tests/react/notificationIntegration.test.mjs. 그룹 초대 본문은 그룹 탭 이동 계약을 따른다.

UiFeedScreen — 피드 탭. 세션 포스트 타임라인 (데스크톱 홈 피드의 모바일 대응, 코드 독립). 화면 fixture는 위 앱 저장소 경로에 두고 화면 props와 같은 PR에서 갱신한다. 표시 의미의 규범은 이 계약이며, 반입 방식은 전달 계약을 따른다.

Props

propshapenotes
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[].reviewstring?세션 리뷰 본문 (없으면 미표시)
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가 없으면 버튼을 그리지 않는다
likePendingIdsstring[]좋아요 응답 대기 중인 세션 id — 그 포스트의 하트는 disabled
comments{ sessionId, status:'loading'|'ready'|'error', items:[{ id, body, createdAt, mine, canDelete, author:{ id, name, photo?, short? } }], truncated, error, submitting, deletingId } | nullnon-null이면 댓글 시트(목록 오래된 순 + 하단 입력 행). canDelete인 행만 "삭제" 노출
profile{ name, photo?, short? }내 포스트 헤더 아바타/닉네임
following[{ id, name, handle?, photo?, short? }]팔로잉 스트립(+친구 추가 버튼) + 검색 시트 기본 목록. 미공급 시 스트립 미노출
friendResults[followingLite]아이디/이름 검색 결과 — 컨테이너 소유(onSearchFriends(q)로 요청받아 주입)
friendProfilefriendLite + 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 / loadingboolean무한 스크롤 — 하단 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) → 현재 탭 스택을 비운 뒤 새 스택 항목 kind myFeed(등록표 신설 요청) push — 셀 UiStackScreen className="fd-mine"(탭바 유지, 공용 pg-overlay 숨), 안에 UiFeedScreen scope="mine" posts=내 포스트만 onBack=startBack. 워드마크 행 = 좌측 ‹(sj-back 마크업, 미등재 마커 없음) + 중앙 "내 피드"(UiBrandRow ownerLabel) + 우측 워드마크 — 범위 탭·친구 검색/팔로잉 아이콘 없음. 드로어 onOpenMyFeed 미공급 시 종전대로 닫기.
  • Codex 후속: types/feedPresentation.ts MobileFeedScreenPropsscope·onChangeScope · types/reactComponents.ts NavDrawerPropsonOpenMyFeed 정식 등재 · controllers/screenKinds.tsmyFeed 등록 · 컨테이너 범위별 피드 자원(추천 피드 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-mo z46), hook sessionDetailSheet 유지. 탭 이동 없음(컨테이너 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)

  • 서버: 마이그레이션 20260821460000public.user_follows(follower/followee, RPC 전용 쓰기, 본인 follower 행만 읽기) + search_user_profiles_v1 · follow_user_v1 · unfollow_user_v1 · list_following_v1. pgTAP supabase/tests/database/feed_friend_follow_v1.test.sql.
  • 컨테이너: controllers/followingStore.ts(프레임워크 무의존, tests/react/followingStore.test.mjs) + followingController.tsappController 렌더 컨텍스트 feedFollowing · feedFriendResults · searchFeedFriends · followFeedFriend · unfollowFeedFriendmobileAppUiFeedScreenfollowing · friendResults · onSearchFriends · onFollow · onUnfollow에 연결. 팔로잉 목록은 피드 탭 진입 시 1회 로드, 오너 전환 시 리셋.
  • 검색 키 = 아이디(profiles.handle, 2026-08-23 20260821470000) + 닉네임(display_name): 대소문자·선두 @를 무시한 부분일치, 정렬은 아이디 정확→아이디 접두→닉네임 정확→닉네임 접두→이름순, 본인·온보딩 미완료 프로필 제외, 빈 검색어 = 결과 없음. 온보딩의 @handlecomplete_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_pathprofile-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/buildMobileFeedPostsposts[].author = { id, name, handle, photo, short }로 투영 → FdPost가 친구 헤더를 그린다(데스크톱 DkH3SessionPost도 동일). 팔로우/언팔로우 성공 시 followingStore.onFollowingChangedloadProfileFeed({ force: true })로 피드를 다시 읽는다. 서버 표시 함수는 팔로워에게 팔로우한 사용자의 main 카드를 허용한다(profile_feed_can_view_v1). 제품 결정: 팔로우는 승인 없는 단방향이라, 누군가를 팔로우하면 그 사람의 완료 세션(제목·리뷰·자유 기록·main 카드·KPI)이 내 피드에 보인다.
  • 미공급(계약만 유지): friendProfile/onCloseFriend/onOpenFriendPage(친구 프로필 시트·친구 홈 페이지). onOpenFriend는 2026-08-23부터 친구 리포트 페이지로 배선됨(아래).

2026-08-23 친구 리포트 (피드 친구 아이디/아바타 탭)

  • 오너 요청: "피드에서 친구 아이디를 누르면 친구의 리포트 페이지를 조회". 친구 포스트 헤더의 아바타/닉네임 탭(FdPostonOpenFriend(author.id))이 그 친구의 리포트 페이지를 연다 — 프로필 시트를 거치지 않는다(v1 결정: 시트·친구 홈은 계약만 유지).
  • 서버: 마이그레이션 20260821520000get_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 페이로드. pgTAP supabase/tests/database/following_volume_overview_v1.test.sql(14).
  • 컨테이너: controllers/friendReportStore.ts(프레임워크 무의존: 열기→로드→ready/error, 늦은 응답 폐기, 42501 문구, 닫기/오너 전환 리셋; tests/react/friendReportStore.test.mjs) + friendReportController.tsappController 렌더 컨텍스트 friendReport · openFriendReport · closeFriendReport · retryFriendReport(히스토리 레이어 friendReport, 스크롤 복원 대상) → mobileApponOpenFriend를 피드 카드 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)를 붙이고, mobileApponEditRecord/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}. pgTAP feed_reactions_v1.test.sql 36.
  • 컨테이너: controllers/feedSocialStore.ts(프레임워크 무의존, tests/react/feedSocialStore.test.mjs) + feedSocialController.tsappController 렌더 컨텍스트 feedSocialBySession · feedLikePendingIds · feedComments · toggleFeedLike · openFeedComments · closeFeedComments · submitFeedComment · deleteFeedCommentmobileAppwithFeedSocialOverrides로 피드·홈 포스트의 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) 상한 유지, 웜업 폴백만 공유. pgTAP feed_warmup_only_cards.test.sql 15.
  • 클라이언트: 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(hook feedPostMenu): 신고하기(feed.report) / 차단하기(feed.block). onOpenPostMenu(target)target = { kind:'session', targetId, sessionId, authorId, authorName }. 본인 포스트에는 그리지 않는다(스토어도 authorId === ownerId를 거른다).
  • 댓글 신고: 댓글 시트의 타인 댓글(mine=false) 행에 "신고"(feed.commentReport) — onReportComment(comment)가 메뉴를 거치지 않고 사유 시트로 바로 간다.
  • 신고 사유 시트 FdReportSheet(hook feedReportSheet): REPORT_REASONS(spam·harassment·sexual·violence·hate·illegal·other, 라벨은 스토어 정본) 라디오 + 선택 상세(≤500자) + 접수(feed.reportSubmit, 사유 선택 전 disabled). 성공 토스트 "신고가 접수되었어요. 24시간 안에 확인할게요."
  • 차단 확인 FdBlockConfirm(hook feedBlockConfirm, feed.blockConfirm): 양방향 팔로우 즉시 해제·해제해도 미복구(D1)를 명시하고 확인을 거친다.
  • 컨테이너: controllers/moderationStore.ts(프레임워크 무의존, tests/react/moderationStore.test.mjs 13) + moderationController.tsappController 렌더 컨텍스트 moderation · openModerationMenu · closeModerationMenu · openModerationReport · closeModerationReport · submitModerationReport · openModerationBlockConfirm · closeModerationBlockConfirm · confirmModerationBlock · openBlockedUsers · closeBlockedUsers · unblockUsermobileAppUiFeedScreenmoderationMenu · moderationReport · moderationBlockConfirm · reportReasons + 콜백 8종을 연결.
  • 즉시 숨김(D3/D1): 신고·차단 성공 시 스토어가 로컬 숨김(hiddenSessionIds/hiddenCommentIds/hiddenAuthorIds)을 들고, mobileAppwithModerationHides(피드·홈 포스트)·withCommentHides(댓글 시트)로 병합한다. 서버 재조회 페이지(get_profile_feed 등, 20260821740000 필터)는 이미 걸러져 온다.
  • 정지 게이트(D5): 부팅 시 get_my_moderation_state_v1(fail-open) → 정지면 SuspensionGate(hook suspensionGate)가 앱 전면을 덮는다(닫기 없음, 문의 주소 병기).
  • 서버 가시성 계약: 차단 = 양방향 불가시(profile_feed_can_view_v1 게이트 + 우회 5 RPC 재발행) · 신고자 숨김 = 비기각 신고의 대상(D3) · 내려짐(D4) = 소셜 표면 제거(소유자 사적 기록 유지) · 정지(D5) = 타인에게 불가시 + 뮤테이션 42501 · 금칙어(D6) = 댓글·핸들 22023(분리 문자 우회 수집). pgTAP ugc_safety_v1.test.sql 47.

§친구 스코프 전환 (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 절.
  • 타입: FdPostMobileFeedPost & { photos? } 로컬 확장으로 읽는다 — Codex 후속 MobileFeedPost.photos(types/feedPresentation) · FeedPostItem.photos(contracts/ui/socialDataProps) 정식화 + 피드 RPC에 세션 사진 동봉. 마커 신규 0.